From 4afb3cbe3ea8ed393564af5c0c2055710e5f9392 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 24 Feb 2012 00:10:33 -0800 Subject: [PATCH 01/62] Initial commit! --- .gitignore | 2 + README | 29 ++++ __init__.py | 58 ++++++++ config.py | 39 ++++++ plugin.py | 371 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test.py | 27 ++++ 6 files changed, 526 insertions(+) create mode 100644 .gitignore create mode 100644 README create mode 100644 __init__.py create mode 100644 config.py create mode 100644 plugin.py create mode 100644 test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9b568f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +*.swp diff --git a/README b/README new file mode 100644 index 0000000..fb6b8e7 --- /dev/null +++ b/README @@ -0,0 +1,29 @@ +Supybot Word Games Plugin +========================= + +A few word games to play in IRC with Supybot! + +These games rely on a dictionary file, default 'words.txt' in the directory +where supybot runs. It should consist of one word per line. The location of +this file can be set with config plugins.Wordgames.wordFile. + +Commands: + + wordshrink [length] + Start a new WordShrink game. + + wordtwist [length] + Start a new WordTwist game. + + wordquit + Give up on any currently running game. + +A puzzle will be presented in the form a > --- > --- > d, and your job is to +come up with a response of the form b > c. (You can optionally include the +start and end words in your response, as long as each word is separated by a +greater-than sign.) + +The goal of both games is to change the word by one letter until you have +turned the starting word into the ending word. In WordShrink, you must remove +one letter and rearrange the letters to form a new word. In WordTwist, you +must change exactly one letter (no re-arranging) to form a new word. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..69aefc7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,58 @@ +### +# Copyright (c) 2012, Mike Mueller +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Do whatever you want. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +""" +Implements some word games. +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "" + +# Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.Author('Mike Mueller', 'mmueller', 'mike@subfocal.net') + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = 'http://github.com/mmueller/supybot-wordgames' + +import config +import plugin +reload(plugin) # In case we're being reloaded. +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/config.py b/config.py new file mode 100644 index 0000000..02daefa --- /dev/null +++ b/config.py @@ -0,0 +1,39 @@ +### +# Copyright (c) 2012, Mike Mueller +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Do whatever you want +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.conf as conf +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Wordgames', True) + +Wordgames = conf.registerPlugin('Wordgames') + +conf.registerGlobalValue(Wordgames, 'wordFile', + registry.String('words.txt', "Path to the dictionary file.")) + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..99ba4ee --- /dev/null +++ b/plugin.py @@ -0,0 +1,371 @@ +### +# Copyright (c) 2012, Mike Mueller +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Do whatever you want +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks +import supybot.schedule as schedule +import supybot.log as log +import supybot.world as world + +import random +import re + +WHITE = '\x0300' +GREEN = '\x0303' +RED = '\x0305' +YELLOW = '\x0307' +LYELLOW = '\x0308' +LGREEN = '\x0309' +LCYAN = '\x0311' +LBLUE = '\x0312' +LGRAY = '\x0315' + +def error(message): + log.error('Wordgames: ' + message) + +class Wordgames(callbacks.Plugin): + "Please see the README file to configure and use this plugin." + + def __init__(self, irc): + self.__parent = super(Wordgames, self) + self.__parent.__init__(irc) + self.games = {} + self.words = map(str.strip, + file(self.registryValue('wordFile')).readlines()) + + def die(self): + self.__parent.die() + + def doPrivmsg(self, irc, msg): + channel = msg.args[0] + if channel in self.games: + self.games[channel].handle_message(msg) + + def wordshrink(self, irc, msgs, args, channel, length): + """[length] (default: 4) + + Start a word-shrink game. Make new words by dropping one letter from + the previous word. + """ + if channel in self.games and self.games[channel].is_running(): + irc.reply('A word game is already running here.') + self.games[channel].show() + elif length < 4 or length > 7: + irc.reply('Please use a length between 4 and 7.') + else: + self.games[channel] = WordShrink(self.words, irc, channel, length) + self.games[channel].start() + wordshrink = wrap(wordshrink, ['channel', optional('int', 4)]) + + def wordtwist(self, irc, msgs, args, channel, length): + """[length] (default: 4) + + Start a word-twist game. Make new words by changing one letter in + the previous word. + """ + if channel in self.games and self.games[channel].is_running(): + irc.reply('A word game is already running here.') + self.games[channel].show() + elif length < 4 or length > 7: + irc.reply('Please use a length between 4 and 7.') + else: + self.games[channel] = WordTwist(self.words, irc, channel, length) + self.games[channel].start() + wordtwist = wrap(wordtwist, ['channel', optional('int', 4)]) + + def wordquit(self, irc, msgs, args, channel): + """(takes no arguments) + + Stop any currently running word game. + """ + if channel in self.games and self.games[channel].is_running(): + self.games[channel].stop() + del self.games[channel] + else: + irc.reply('No word game currently running.') + wordquit = wrap(wordquit, ['channel']) + +class BaseGame(object): + "Base class for the games in this plugin." + + def __init__(self, words, irc, channel): + self.words = words + self.irc = irc + self.channel = channel + self.running = False + + def gameover(self): + "The game is finished." + self.running = False + + def start(self): + "Start the current game." + self.running = True + + def stop(self): + "Shut down the current game." + self.running = False + + def show(self): + "Show the current state of the game." + pass + + def is_running(self): + return self.running + + def announce(self, msg): + "Announce a message with the game title prefix." + text = '%s%s%s:%s %s' % ( + LBLUE, self.__class__.__name__, WHITE, LGRAY, msg) + self.send(text) + + def send(self, msg): + "Relay a message to the channel." + self.irc.queueMsg(ircmsgs.privmsg(self.channel, msg)) + + def handle_message(self, msg): + "Handle incoming messages on the channel." + pass + + def _join_words(self, words): + sep = "%s > %s" % (LGREEN, YELLOW) + text = words[0] + sep + text += sep.join(words[1:-1]) + text += sep + LGRAY + words[-1] + return text + +class WordShrink(BaseGame): + def __init__(self, words, irc, channel, length): + super(WordShrink, self).__init__(words, irc, channel) + self.solution_length = length + self.solution = [] + self.solutions = [] + + def start(self): + super(WordShrink, self).start() + singular_words = filter(lambda s: s[-1] != 's', self.words) + while len(self.solution) < self.solution_length: + self.solution = [] + word = '' + for i in range(0, self.solution_length): + words = singular_words + if self.solution: + words = filter( + lambda s: self._is_subset(s, self.solution[-1]), words) + else: + words = filter( + lambda s: len(s) >= 2+self.solution_length, words) + if not words: break + self.solution.append(random.choice(words)) + self._find_solutions() + self.show() + + def show(self): + words = [self.solution[0]] + for word in self.solution[1:-1]: + words.append("-" * len(word)) + words.append(self.solution[-1]) + self.announce(self._join_words(words)) + num = len(self.solutions) + self.send("(%s%d%s possible solution%s)" % + (WHITE, num, LGRAY, '' if num == 1 else 's')) + + def stop(self): + super(WordShrink, self).stop() + self.announce(self._join_words(self.solution)) + + def handle_message(self, msg): + words = map(str.strip, msg.args[1].split('>')) + if len(words) == len(self.solution) - 2: + words = [self.solution[0]] + words + [self.solution[-1]] + if self._valid_solution(msg.nick, words): + if self.running: + self.announce("%s%s%s got it!" % (WHITE, msg.nick, LGRAY)) + self.announce(self._join_words(words)) + self.gameover() + else: + self.send("%s: Your solution is also valid." % msg.nick) + + def _is_subset(self, word1, word2): + "Determine if word1 is word2 minus one letter." + if len(word1) != len(word2) - 1: + return False + for c in "abcdefghijklmnopqrstuvwxyz": + if word1.count(c) > word2.count(c): + return False + return True + + def _find_solutions(self, seed=None): + "Recursively find and save all solutions for the puzzle." + if seed is None: + seed = [self.solution[0]] + self._find_solutions(seed) + elif len(seed) == len(self.solution) - 1: + if self._is_subset(self.solution[-1], seed[-1]): + self.solutions.append(seed + [self.solution[-1]]) + else: + length = len(seed[-1]) - 1 + words = filter(lambda s: len(s) == length, self.words) + words = filter(lambda s: self._is_subset(s, seed[-1]), words) + for word in words: + self._find_solutions(seed + [word]) + + def _valid_solution(self, nick, words): + # Ignore things that don't look like attempts to answer + if len(words) != len(self.solution): + return False + # Check for incorrect start/end words + if len(words) == len(self.solution): + if words[0] != self.solution[0]: + self.send('%s: %s is not the starting word.' % (nick, words[0])) + return False + if words[-1] != self.solution[-1]: + self.send('%s: %s is not the final word.' % (nick, words[-1])) + return False + # Add the start/end words (if not present) to simplify the test logic + if len(words) == len(self.solution) - 2: + words = [self.solution[0]] + words + [self.solution[-1]] + for word in words: + if word not in self.words: + self.send("%s: %s is not a word I know." % (nick, word)) + return False + for i in range(0, len(words)-1): + if not self._is_subset(words[i+1], words[i]): + self.send("%s: %s is not a subset of %s." % + (nick, words[i+1], words[i])) + return False + return True + +class WordTwist(BaseGame): + def __init__(self, words, irc, channel, length): + super(WordTwist, self).__init__(words, irc, channel) + self.solution_length = length + self.solution = [] + self.solutions = [] + + def start(self): + super(WordTwist, self).start() + while True: + while len(self.solution) < self.solution_length: + self.solution = [] + word = '' + words = filter(lambda s: len(s) >= 4, self.words) + for i in range(0, self.solution_length): + if self.solution: + words = filter( + lambda s: self._valid_pair(s, self.solution[-1]), + self.words) + if not words: break + self.solution.append(random.choice(words)) + self.solutions = [] + self._find_solutions() + if min(map(len, self.solutions)) == self.solution_length: + break + else: + self.solution = [] + self.show() + + def show(self): + words = [self.solution[0]] + for word in self.solution[1:-1]: + words.append("-" * len(word)) + words.append(self.solution[-1]) + self.announce(self._join_words(words)) + num = len(self.solutions) + self.send("(%s%d%s possible solution%s)" % + (WHITE, num, LGRAY, '' if num == 1 else 's')) + + def stop(self): + super(WordTwist, self).stop() + self.announce(self._join_words(self.solution)) + + def handle_message(self, msg): + words = map(str.strip, msg.args[1].split('>')) + if len(words) == len(self.solution) - 2: + words = [self.solution[0]] + words + [self.solution[-1]] + if self._valid_solution(msg.nick, words): + if self.running: + self.announce("%s%s%s got it!" % (WHITE, msg.nick, LGRAY)) + self.announce(self._join_words(words)) + self.gameover() + else: + self.send("%s: Your solution is also valid." % msg.nick) + + def _valid_pair(self, word1, word2): + "Determine if word2 is a one-letter twist of word1." + if len(word1) != len(word2): + return False + differences = 0 + for c1, c2 in zip(word1, word2): + if c1 != c2: + differences += 1 + return differences == 1 + + def _find_solutions(self, seed=None): + "Recursively find and save all solutions for the puzzle." + if seed is None: + seed = [self.solution[0]] + self._find_solutions(seed) + elif len(seed) == len(self.solution) - 1: + if self._valid_pair(self.solution[-1], seed[-1]): + self.solutions.append(seed + [self.solution[-1]]) + else: + words = filter(lambda s: self._valid_pair(s, seed[-1]), self.words) + for word in words: + if word == self.solution[-1]: + self.solutions.append(seed + [word]) + else: + self._find_solutions(seed + [word]) + + def _valid_solution(self, nick, words): + # Ignore things that don't look like attempts to answer + if len(words) != len(self.solution): + return False + # Check for incorrect start/end words + if len(words) == len(self.solution): + if words[0] != self.solution[0]: + self.send('%s: %s is not the starting word.' % (nick, words[0])) + return False + if words[-1] != self.solution[-1]: + self.send('%s: %s is not the final word.' % (nick, words[-1])) + return False + # Add the start/end words (if not present) to simplify the test logic + if len(words) == len(self.solution) - 2: + words = [self.solution[0]] + words + [self.solution[-1]] + for word in words: + if word not in self.words: + self.send("%s: %s is not a word I know." % (nick, word)) + return False + for i in range(0, len(words)-1): + if not self._valid_pair(words[i+1], words[i]): + self.send("%s: %s is not a twist of %s." % + (nick, words[1], words[0])) + return False + return True + +Class = Wordgames + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/test.py b/test.py new file mode 100644 index 0000000..5510c2d --- /dev/null +++ b/test.py @@ -0,0 +1,27 @@ +# Copyright (c) 2012, Mike Mueller +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Do whatever you want. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from supybot.test import * + +class WordgamesTestCase(PluginTestCase): + plugins = ('Wordgames',) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From 180252e82626cd36b2d97d948d37aa3906b02677 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 24 Feb 2012 03:21:45 -0500 Subject: [PATCH 02/62] Handle missing wordfile gracefully. --- plugin.py | 61 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/plugin.py b/plugin.py index 99ba4ee..a549973 100644 --- a/plugin.py +++ b/plugin.py @@ -53,8 +53,6 @@ class Wordgames(callbacks.Plugin): self.__parent = super(Wordgames, self) self.__parent.__init__(irc) self.games = {} - self.words = map(str.strip, - file(self.registryValue('wordFile')).readlines()) def die(self): self.__parent.die() @@ -65,35 +63,43 @@ class Wordgames(callbacks.Plugin): self.games[channel].handle_message(msg) def wordshrink(self, irc, msgs, args, channel, length): - """[length] (default: 4) + """[length] (default: 4) - Start a word-shrink game. Make new words by dropping one letter from - the previous word. - """ - if channel in self.games and self.games[channel].is_running(): - irc.reply('A word game is already running here.') - self.games[channel].show() - elif length < 4 or length > 7: - irc.reply('Please use a length between 4 and 7.') - else: - self.games[channel] = WordShrink(self.words, irc, channel, length) - self.games[channel].start() + Start a word-shrink game. Make new words by dropping one letter from + the previous word. + """ + try: + if channel in self.games and self.games[channel].is_running(): + irc.reply('A word game is already running here.') + self.games[channel].show() + elif length < 4 or length > 7: + irc.reply('Please use a length between 4 and 7.') + else: + self.games[channel] = WordShrink( + self._get_words(), irc, channel, length) + self.games[channel].start() + except Exception, e: + irc.reply(str(e)) wordshrink = wrap(wordshrink, ['channel', optional('int', 4)]) def wordtwist(self, irc, msgs, args, channel, length): - """[length] (default: 4) + """[length] (default: 4) - Start a word-twist game. Make new words by changing one letter in - the previous word. - """ - if channel in self.games and self.games[channel].is_running(): - irc.reply('A word game is already running here.') - self.games[channel].show() - elif length < 4 or length > 7: - irc.reply('Please use a length between 4 and 7.') - else: - self.games[channel] = WordTwist(self.words, irc, channel, length) - self.games[channel].start() + Start a word-twist game. Make new words by changing one letter in + the previous word. + """ + try: + if channel in self.games and self.games[channel].is_running(): + irc.reply('A word game is already running here.') + self.games[channel].show() + elif length < 4 or length > 7: + irc.reply('Please use a length between 4 and 7.') + else: + self.games[channel] = WordTwist( + self._get_words(), irc, channel, length) + self.games[channel].start() + except Exception, e: + irc.reply(str(e)) wordtwist = wrap(wordtwist, ['channel', optional('int', 4)]) def wordquit(self, irc, msgs, args, channel): @@ -108,6 +114,9 @@ class Wordgames(callbacks.Plugin): irc.reply('No word game currently running.') wordquit = wrap(wordquit, ['channel']) + def _get_words(self): + return map(str.strip, file(self.registryValue('wordFile')).readlines()) + class BaseGame(object): "Base class for the games in this plugin." From e74ca803fe6dd7e4b0ba89b2553fa8c3e4fb7956 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 24 Feb 2012 00:33:14 -0800 Subject: [PATCH 03/62] Restrict input snarfing a bit. Only attempt to validate responses that look like words. --- plugin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugin.py b/plugin.py index a549973..1c14f42 100644 --- a/plugin.py +++ b/plugin.py @@ -208,6 +208,9 @@ class WordShrink(BaseGame): def handle_message(self, msg): words = map(str.strip, msg.args[1].split('>')) + for word in words: + if not re.match(r"^[a-z]+$", word): + return if len(words) == len(self.solution) - 2: words = [self.solution[0]] + words + [self.solution[-1]] if self._valid_solution(msg.nick, words): @@ -313,6 +316,9 @@ class WordTwist(BaseGame): def handle_message(self, msg): words = map(str.strip, msg.args[1].split('>')) + for word in words: + if not re.match(r"^[a-z]+$", word): + return if len(words) == len(self.solution) - 2: words = [self.solution[0]] + words + [self.solution[-1]] if self._valid_solution(msg.nick, words): From 25136ae926ff9f29d594fed63358dfee941ea1c6 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 24 Feb 2012 01:04:10 -0800 Subject: [PATCH 04/62] Fix a bug in error reporting. --- plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 1c14f42..32a4dcc 100644 --- a/plugin.py +++ b/plugin.py @@ -377,7 +377,7 @@ class WordTwist(BaseGame): for i in range(0, len(words)-1): if not self._valid_pair(words[i+1], words[i]): self.send("%s: %s is not a twist of %s." % - (nick, words[1], words[0])) + (nick, words[i+1], words[i])) return False return True From 5e570f61c17e73b0ad5cf65b92102ce7ab86176d Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 24 Feb 2012 19:17:05 -0800 Subject: [PATCH 05/62] Refactor, optimize, clean-up. The two games have a lot in common, so factored out a base class called WordChain that implements most of the logic. The game-specific behaviors are implemented in the (now smaller) WordTwist and WordShrink classes. Optimized to build a map of word relationships first and then derive all game behavior from that map. This could probably be improved even more, but for the moment it is working nicely. --- plugin.py | 287 +++++++++++++++++++++++------------------------------- 1 file changed, 121 insertions(+), 166 deletions(-) diff --git a/plugin.py b/plugin.py index 32a4dcc..ed54bc5 100644 --- a/plugin.py +++ b/plugin.py @@ -43,6 +43,9 @@ LCYAN = '\x0311' LBLUE = '\x0312' LGRAY = '\x0315' +def info(message): + log.info('Wordgames: ' + message) + def error(message): log.error('Wordgames: ' + message) @@ -59,8 +62,9 @@ class Wordgames(callbacks.Plugin): def doPrivmsg(self, irc, msg): channel = msg.args[0] - if channel in self.games: - self.games[channel].handle_message(msg) + game = self.games.get(channel) + if game: + game.handle_message(msg) def wordshrink(self, irc, msgs, args, channel, length): """[length] (default: 4) @@ -68,18 +72,10 @@ class Wordgames(callbacks.Plugin): Start a word-shrink game. Make new words by dropping one letter from the previous word. """ - try: - if channel in self.games and self.games[channel].is_running(): - irc.reply('A word game is already running here.') - self.games[channel].show() - elif length < 4 or length > 7: - irc.reply('Please use a length between 4 and 7.') - else: - self.games[channel] = WordShrink( - self._get_words(), irc, channel, length) - self.games[channel].start() - except Exception, e: - irc.reply(str(e)) + if length < 4 or length > 7: + irc.reply('Please use a length between 4 and 7.') + else: + self._start_game(WordShrink, irc, channel, length) wordshrink = wrap(wordshrink, ['channel', optional('int', 4)]) def wordtwist(self, irc, msgs, args, channel, length): @@ -88,18 +84,10 @@ class Wordgames(callbacks.Plugin): Start a word-twist game. Make new words by changing one letter in the previous word. """ - try: - if channel in self.games and self.games[channel].is_running(): - irc.reply('A word game is already running here.') - self.games[channel].show() - elif length < 4 or length > 7: - irc.reply('Please use a length between 4 and 7.') - else: - self.games[channel] = WordTwist( - self._get_words(), irc, channel, length) - self.games[channel].start() - except Exception, e: - irc.reply(str(e)) + if length < 4 or length > 7: + irc.reply('Please use a length between 4 and 7.') + else: + self._start_game(WordTwist, irc, channel, length) wordtwist = wrap(wordtwist, ['channel', optional('int', 4)]) def wordquit(self, irc, msgs, args, channel): @@ -107,9 +95,9 @@ class Wordgames(callbacks.Plugin): Stop any currently running word game. """ - if channel in self.games and self.games[channel].is_running(): - self.games[channel].stop() - del self.games[channel] + game = self.games.get(channel) + if game and game.is_running(): + game.stop() else: irc.reply('No word game currently running.') wordquit = wrap(wordquit, ['channel']) @@ -117,6 +105,15 @@ class Wordgames(callbacks.Plugin): def _get_words(self): return map(str.strip, file(self.registryValue('wordFile')).readlines()) + def _start_game(self, Game, irc, channel, length): + game = self.games.get(channel) + if game and game.is_running(): + irc.reply('A word game is already running here.') + game.show() + else: + self.games[channel] = Game(self._get_words(), irc, channel, length) + self.games[channel].start() + class BaseGame(object): "Base class for the games in this plugin." @@ -166,138 +163,33 @@ class BaseGame(object): text += sep + LGRAY + words[-1] return text -class WordShrink(BaseGame): +class WordChain(BaseGame): + "Base class for word-chain games like WordShrink and WordTwist." def __init__(self, words, irc, channel, length): - super(WordShrink, self).__init__(words, irc, channel) + super(WordChain, self).__init__(words, irc, channel) self.solution_length = length self.solution = [] self.solutions = [] + self.word_map = {} def start(self): - super(WordShrink, self).start() - singular_words = filter(lambda s: s[-1] != 's', self.words) - while len(self.solution) < self.solution_length: - self.solution = [] - word = '' - for i in range(0, self.solution_length): - words = singular_words - if self.solution: - words = filter( - lambda s: self._is_subset(s, self.solution[-1]), words) - else: - words = filter( - lambda s: len(s) >= 2+self.solution_length, words) - if not words: break - self.solution.append(random.choice(words)) - self._find_solutions() - self.show() - - def show(self): - words = [self.solution[0]] - for word in self.solution[1:-1]: - words.append("-" * len(word)) - words.append(self.solution[-1]) - self.announce(self._join_words(words)) - num = len(self.solutions) - self.send("(%s%d%s possible solution%s)" % - (WHITE, num, LGRAY, '' if num == 1 else 's')) - - def stop(self): - super(WordShrink, self).stop() - self.announce(self._join_words(self.solution)) - - def handle_message(self, msg): - words = map(str.strip, msg.args[1].split('>')) - for word in words: - if not re.match(r"^[a-z]+$", word): - return - if len(words) == len(self.solution) - 2: - words = [self.solution[0]] + words + [self.solution[-1]] - if self._valid_solution(msg.nick, words): - if self.running: - self.announce("%s%s%s got it!" % (WHITE, msg.nick, LGRAY)) - self.announce(self._join_words(words)) - self.gameover() - else: - self.send("%s: Your solution is also valid." % msg.nick) - - def _is_subset(self, word1, word2): - "Determine if word1 is word2 minus one letter." - if len(word1) != len(word2) - 1: - return False - for c in "abcdefghijklmnopqrstuvwxyz": - if word1.count(c) > word2.count(c): - return False - return True - - def _find_solutions(self, seed=None): - "Recursively find and save all solutions for the puzzle." - if seed is None: - seed = [self.solution[0]] - self._find_solutions(seed) - elif len(seed) == len(self.solution) - 1: - if self._is_subset(self.solution[-1], seed[-1]): - self.solutions.append(seed + [self.solution[-1]]) - else: - length = len(seed[-1]) - 1 - words = filter(lambda s: len(s) == length, self.words) - words = filter(lambda s: self._is_subset(s, seed[-1]), words) - for word in words: - self._find_solutions(seed + [word]) - - def _valid_solution(self, nick, words): - # Ignore things that don't look like attempts to answer - if len(words) != len(self.solution): - return False - # Check for incorrect start/end words - if len(words) == len(self.solution): - if words[0] != self.solution[0]: - self.send('%s: %s is not the starting word.' % (nick, words[0])) - return False - if words[-1] != self.solution[-1]: - self.send('%s: %s is not the final word.' % (nick, words[-1])) - return False - # Add the start/end words (if not present) to simplify the test logic - if len(words) == len(self.solution) - 2: - words = [self.solution[0]] + words + [self.solution[-1]] - for word in words: - if word not in self.words: - self.send("%s: %s is not a word I know." % (nick, word)) - return False - for i in range(0, len(words)-1): - if not self._is_subset(words[i+1], words[i]): - self.send("%s: %s is not a subset of %s." % - (nick, words[i+1], words[i])) - return False - return True - -class WordTwist(BaseGame): - def __init__(self, words, irc, channel, length): - super(WordTwist, self).__init__(words, irc, channel) - self.solution_length = length - self.solution = [] - self.solutions = [] - - def start(self): - super(WordTwist, self).start() - while True: + super(WordChain, self).start() + self.build_word_map() + words = filter(lambda s: len(s) >= 2+self.solution_length, self.words) + while not self.solution: while len(self.solution) < self.solution_length: - self.solution = [] - word = '' - words = filter(lambda s: len(s) >= 4, self.words) - for i in range(0, self.solution_length): - if self.solution: - words = filter( - lambda s: self._valid_pair(s, self.solution[-1]), - self.words) - if not words: break - self.solution.append(random.choice(words)) + self.solution = [random.choice(words)] + for i in range(1, self.solution_length): + values = self.word_map[self.solution[-1]] + if not values: break + self.solution.append(random.choice(values)) self.solutions = [] self._find_solutions() - if min(map(len, self.solutions)) == self.solution_length: - break - else: - self.solution = [] + # Ensure no solution is trivial + for solution in self.solutions: + if self.is_trivial_solution(solution): + self.solution = [] + break self.show() def show(self): @@ -311,7 +203,7 @@ class WordTwist(BaseGame): (WHITE, num, LGRAY, '' if num == 1 else 's')) def stop(self): - super(WordTwist, self).stop() + super(WordChain, self).stop() self.announce(self._join_words(self.solution)) def handle_message(self, msg): @@ -329,26 +221,30 @@ class WordTwist(BaseGame): else: self.send("%s: Your solution is also valid." % msg.nick) - def _valid_pair(self, word1, word2): - "Determine if word2 is a one-letter twist of word1." - if len(word1) != len(word2): - return False - differences = 0 - for c1, c2 in zip(word1, word2): - if c1 != c2: - differences += 1 - return differences == 1 + # Override in game class + def build_word_map(self): + "Build a map of word -> [word1, word2] for all valid transitions." + pass + + # Override in game class + def is_trivial_solution(self, solution): + return False + + def get_successors(self, word): + "Lookup a word in the map and return list of possible successor words." + return self.word_map.get(word, []) def _find_solutions(self, seed=None): "Recursively find and save all solutions for the puzzle." if seed is None: seed = [self.solution[0]] + self.solutions = [] self._find_solutions(seed) elif len(seed) == len(self.solution) - 1: - if self._valid_pair(self.solution[-1], seed[-1]): + if self.solution[-1] in self.get_successors(seed[-1]): self.solutions.append(seed + [self.solution[-1]]) else: - words = filter(lambda s: self._valid_pair(s, seed[-1]), self.words) + words = self.get_successors(seed[-1]) for word in words: if word == self.solution[-1]: self.solutions.append(seed + [word]) @@ -375,12 +271,71 @@ class WordTwist(BaseGame): self.send("%s: %s is not a word I know." % (nick, word)) return False for i in range(0, len(words)-1): - if not self._valid_pair(words[i+1], words[i]): - self.send("%s: %s is not a twist of %s." % + if words[i+1] not in self.get_successors(words[i]): + self.send("%s: %s does not follow from %s." % (nick, words[i+1], words[i])) return False return True +class WordShrink(WordChain): + def __init__(self, words, irc, channel, length): + super(WordShrink, self).__init__(words, irc, channel, length) + + def build_word_map(self): + "Build a map of word -> [word1, word2] for all valid transitions." + keymap = {} + for word in self.words: + s = "".join(sorted(word)) + if s in keymap: + keymap[s].append(word) + else: + keymap[s] = [word] + self.word_map = {} + for word1 in self.words: + s = "".join(sorted(word1)) + if s in self.word_map: + self.word_map[word1] = self.word_map[s] + else: + self.word_map[s] = self.word_map[word1] = [] + for i in range(0, len(s)): + t = s[0:i] + s[i+1:] + for word2 in keymap.get(t, []): + self.word_map[s].append(word2) + + def is_trivial_solution(self, solution): + "Consider pure substring solutions trivial." + for i in range(0, len(solution)-1): + if solution[i].find(solution[i+1]) >= 0: + return True + return False + +class WordTwist(WordChain): + def __init__(self, words, irc, channel, length): + super(WordTwist, self).__init__(words, irc, channel, length) + + def build_word_map(self): + "Build the map of word -> [word1, word2, ...] for all valid pairs." + keymap = {} + wildcard = '*' + for word in self.words: + for pos in range(0, len(word)): + key = word[0:pos] + wildcard + word[pos+1:] + if key not in keymap: + keymap[key] = [word] + else: + keymap[key].append(word) + self.word_map = {} + for word in self.words: + self.word_map[word] = [] + for pos in range(0, len(word)): + key = word[0:pos] + wildcard + word[pos+1:] + self.word_map[word] += filter( + lambda w: w != word, keymap.get(key, [])) + + def is_trivial_solution(self, solution): + "If it's possible to get there in fewer hops, this is trivial." + return len(solution) < self.solution_length + Class = Wordgames # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: From 51ca3bedfdccfd28068875e74a7a28164e523964 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 24 Feb 2012 20:18:36 -0800 Subject: [PATCH 06/62] Improve WordShrink's is_trivial definition. No substrings should occur in any word of any solution (not just two successive words). This is really going to narrow down the set of puzzles, hopefully it doesn't churn or get repetitive. --- plugin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin.py b/plugin.py index ed54bc5..ee2484b 100644 --- a/plugin.py +++ b/plugin.py @@ -305,8 +305,9 @@ class WordShrink(WordChain): def is_trivial_solution(self, solution): "Consider pure substring solutions trivial." for i in range(0, len(solution)-1): - if solution[i].find(solution[i+1]) >= 0: - return True + for j in range(i+1, len(solution)): + if solution[i].find(solution[j]) >= 0: + return True return False class WordTwist(WordChain): From b519259f54e6e996f6ac505cfa8b36170fe4819b Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 2 Mar 2012 15:25:44 -0800 Subject: [PATCH 07/62] Update copyright information. --- README | 7 +++++++ plugin.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README b/README index fb6b8e7..9b5a4ab 100644 --- a/README +++ b/README @@ -27,3 +27,10 @@ The goal of both games is to change the word by one letter until you have turned the starting word into the ending word. In WordShrink, you must remove one letter and rearrange the letters to form a new word. In WordTwist, you must change exactly one letter (no re-arranging) to form a new word. + +Credit: + +Copyright 2012 Mike Mueller +Released under the WTF public license: http://sam.zoy.org/wtfpl/ + +Thanks to Ben Schomp for the inspiration. diff --git a/plugin.py b/plugin.py index ee2484b..fd47417 100644 --- a/plugin.py +++ b/plugin.py @@ -1,5 +1,5 @@ ### -# Copyright (c) 2012, Mike Mueller +# Copyright (c) 2012, Mike Mueller # All rights reserved. # # Redistribution and use in source and binary forms, with or without From 17d1f91f8c6df692e072b8fdf9a0c571cdefadef Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 2 Mar 2012 16:46:54 -0800 Subject: [PATCH 08/62] Revise game difficulty settings. Instead of taking lengths, the games now take easy|medium|hard|evil. These values correspond to a range of puzzle lengths, word lengths, and number of possible solutions. I attempted to tune them to reasonable values, but I could see them changing. Also did a little more code clean-up. --- plugin.py | 130 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/plugin.py b/plugin.py index fd47417..d74e8c3 100644 --- a/plugin.py +++ b/plugin.py @@ -66,29 +66,31 @@ class Wordgames(callbacks.Plugin): if game: game.handle_message(msg) - def wordshrink(self, irc, msgs, args, channel, length): - """[length] (default: 4) + def wordshrink(self, irc, msgs, args, channel, difficulty): + """[easy|medium|hard|evil] (default: easy) Start a word-shrink game. Make new words by dropping one letter from - the previous word. + the previous word and rearranging the remaining letters. """ - if length < 4 or length > 7: - irc.reply('Please use a length between 4 and 7.') + if difficulty not in ['easy', 'medium', 'hard', 'evil']: + irc.reply('Difficulty must be easy, medium, hard, or evil.') else: - self._start_game(WordShrink, irc, channel, length) - wordshrink = wrap(wordshrink, ['channel', optional('int', 4)]) + self._start_game(WordShrink, irc, channel, difficulty) + wordshrink = wrap(wordshrink, + ['channel', optional('somethingWithoutSpaces', 'easy')]) - def wordtwist(self, irc, msgs, args, channel, length): - """[length] (default: 4) + def wordtwist(self, irc, msgs, args, channel, difficulty): + """[easy|medium|hard|evil] (default: easy) Start a word-twist game. Make new words by changing one letter in the previous word. """ - if length < 4 or length > 7: - irc.reply('Please use a length between 4 and 7.') + if difficulty not in ['easy', 'medium', 'hard', 'evil']: + irc.reply('Difficulty must be easy, medium, hard, or evil.') else: - self._start_game(WordTwist, irc, channel, length) - wordtwist = wrap(wordtwist, ['channel', optional('int', 4)]) + self._start_game(WordTwist, irc, channel, difficulty) + wordtwist = wrap(wordtwist, + ['channel', optional('somethingWithoutSpaces', 'easy')]) def wordquit(self, irc, msgs, args, channel): """(takes no arguments) @@ -156,41 +158,70 @@ class BaseGame(object): "Handle incoming messages on the channel." pass - def _join_words(self, words): - sep = "%s > %s" % (LGREEN, YELLOW) - text = words[0] + sep - text += sep.join(words[1:-1]) - text += sep + LGRAY + words[-1] - return text - class WordChain(BaseGame): "Base class for word-chain games like WordShrink and WordTwist." - def __init__(self, words, irc, channel, length): + + class Settings: + """ + Parameters affecting the behavior of this class: + + puzzle_lengths: Number of words allowed in the puzzle, including + start and end word. List of integers. + word_lengths: Word lengths allowed in the puzzle. List of integers + or None for the default (3 letters or more). + num_solutions: A limit to the number of possible solutions, or + None for unlimited. + """ + def __init__(self, puzzle_lengths, word_lengths=None, + num_solutions=None): + self.puzzle_lengths = puzzle_lengths + self.word_lengths = word_lengths + self.num_solutions = num_solutions + + def __init__(self, words, irc, channel, settings): super(WordChain, self).__init__(words, irc, channel) - self.solution_length = length + self.settings = settings + self.solution_length = random.choice(settings.puzzle_lengths) self.solution = [] self.solutions = [] self.word_map = {} + if settings.word_lengths: + self.words = filter(lambda w: len(w) in settings.word_lengths, + self.words) + else: + self.words = filter(lambda w: len(w) >= 3, self.words) + self.build_word_map() def start(self): super(WordChain, self).start() - self.build_word_map() - words = filter(lambda s: len(s) >= 2+self.solution_length, self.words) - while not self.solution: + happy = False + # Build a puzzle + while not happy: + self.solution = [] while len(self.solution) < self.solution_length: - self.solution = [random.choice(words)] + self.solution = [random.choice(self.words)] for i in range(1, self.solution_length): values = self.word_map[self.solution[-1]] if not values: break self.solution.append(random.choice(values)) self.solutions = [] self._find_solutions() + # Enforce maximum solutions limit (difficulty parameter) + happy = True + if self.settings.num_solutions and \ + len(self.solutions) not in self.settings.num_solutions: + happy = False # Ensure no solution is trivial for solution in self.solutions: if self.is_trivial_solution(solution): - self.solution = [] + happy = False break self.show() + # For debugging purposes + solution_set = set(map(lambda s: self._join_words(s), self.solutions)) + if len(solution_set) != len(self.solutions): + info('Oops, only %d of %d solutions are unique.' % + (len(solution_set), len(self.solutions))) def show(self): words = [self.solution[0]] @@ -230,7 +261,7 @@ class WordChain(BaseGame): def is_trivial_solution(self, solution): return False - def get_successors(self, word): + def _get_successors(self, word): "Lookup a word in the map and return list of possible successor words." return self.word_map.get(word, []) @@ -241,16 +272,23 @@ class WordChain(BaseGame): self.solutions = [] self._find_solutions(seed) elif len(seed) == len(self.solution) - 1: - if self.solution[-1] in self.get_successors(seed[-1]): + if self.solution[-1] in self._get_successors(seed[-1]): self.solutions.append(seed + [self.solution[-1]]) else: - words = self.get_successors(seed[-1]) + words = self._get_successors(seed[-1]) for word in words: if word == self.solution[-1]: self.solutions.append(seed + [word]) else: self._find_solutions(seed + [word]) + def _join_words(self, words): + sep = "%s > %s" % (LGREEN, YELLOW) + text = words[0] + sep + text += sep.join(words[1:-1]) + text += sep + LGRAY + words[-1] + return text + def _valid_solution(self, nick, words): # Ignore things that don't look like attempts to answer if len(words) != len(self.solution): @@ -271,15 +309,23 @@ class WordChain(BaseGame): self.send("%s: %s is not a word I know." % (nick, word)) return False for i in range(0, len(words)-1): - if words[i+1] not in self.get_successors(words[i]): + if words[i+1] not in self._get_successors(words[i]): self.send("%s: %s does not follow from %s." % (nick, words[i+1], words[i])) return False return True class WordShrink(WordChain): - def __init__(self, words, irc, channel, length): - super(WordShrink, self).__init__(words, irc, channel, length) + def __init__(self, words, irc, channel, difficulty): + assert difficulty in ['easy', 'medium', 'hard', 'evil'], "Bad mojo." + settings = { + 'easy': WordChain.Settings([4], range(3, 10), range(10, 100)), + 'medium': WordChain.Settings([5], range(4, 12), range(5, 12)), + 'hard': WordChain.Settings([6], range(5, 14), range(2, 5)), + 'evil': WordChain.Settings([7], range(6, 16), range(1, 3)), + } + super(WordShrink, self).__init__( + words, irc, channel, settings[difficulty]) def build_word_map(self): "Build a map of word -> [word1, word2] for all valid transitions." @@ -297,9 +343,11 @@ class WordShrink(WordChain): self.word_map[word1] = self.word_map[s] else: self.word_map[s] = self.word_map[word1] = [] + keys = set() for i in range(0, len(s)): - t = s[0:i] + s[i+1:] - for word2 in keymap.get(t, []): + keys.add(s[0:i] + s[i+1:]) + for key in keys: + for word2 in keymap.get(key, []): self.word_map[s].append(word2) def is_trivial_solution(self, solution): @@ -311,8 +359,16 @@ class WordShrink(WordChain): return False class WordTwist(WordChain): - def __init__(self, words, irc, channel, length): - super(WordTwist, self).__init__(words, irc, channel, length) + def __init__(self, words, irc, channel, difficulty): + assert difficulty in ['easy', 'medium', 'hard', 'evil'], "Bad mojo." + settings = { + 'easy': WordChain.Settings([4], [3, 4], range(10, 100)), + 'medium': WordChain.Settings([5], [4, 5], range(5, 12)), + 'hard': WordChain.Settings([6], [4, 5, 6], range(2, 5)), + 'evil': WordChain.Settings([7], [4, 5, 6], range(1, 3)), + } + super(WordTwist, self).__init__( + words, irc, channel, settings[difficulty]) def build_word_map(self): "Build the map of word -> [word1, word2, ...] for all valid pairs." From a443d66473c4af970786fa5f21ca485e8d12be09 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 2 Mar 2012 17:04:21 -0800 Subject: [PATCH 09/62] Fix a performance issue in _find_solutions. WordTwist successors can take you in circles (scare > stare > scare), so check for this condition to avoid generating overly many potential solutions. --- plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin.py b/plugin.py index d74e8c3..a23a9cf 100644 --- a/plugin.py +++ b/plugin.py @@ -277,6 +277,8 @@ class WordChain(BaseGame): else: words = self._get_successors(seed[-1]) for word in words: + if word in seed: + continue if word == self.solution[-1]: self.solutions.append(seed + [word]) else: From 844723d28aa6e88f09b66460ab7ab75f30746971 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 2 Mar 2012 17:23:31 -0800 Subject: [PATCH 10/62] Filter duplicate words in puzzles. Was occasionally generating a puzzle foo > --- > --- > foo. Oops. --- plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin.py b/plugin.py index a23a9cf..08597c1 100644 --- a/plugin.py +++ b/plugin.py @@ -202,6 +202,7 @@ class WordChain(BaseGame): self.solution = [random.choice(self.words)] for i in range(1, self.solution_length): values = self.word_map[self.solution[-1]] + values = filter(lambda w: w not in self.solution, values) if not values: break self.solution.append(random.choice(values)) self.solutions = [] From 029ba1090604b5a68cef65487a8252da2fb509f1 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sat, 3 Mar 2012 14:41:00 -0800 Subject: [PATCH 11/62] Oops, handle missing word file gracefully. Apparently I backed out the previous change that handled this. --- plugin.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/plugin.py b/plugin.py index 08597c1..f79fe3f 100644 --- a/plugin.py +++ b/plugin.py @@ -108,13 +108,20 @@ class Wordgames(callbacks.Plugin): return map(str.strip, file(self.registryValue('wordFile')).readlines()) def _start_game(self, Game, irc, channel, length): - game = self.games.get(channel) - if game and game.is_running(): - irc.reply('A word game is already running here.') - game.show() - else: - self.games[channel] = Game(self._get_words(), irc, channel, length) - self.games[channel].start() + try: + game = self.games.get(channel) + if game and game.is_running(): + irc.reply('A word game is already running here.') + game.show() + else: + words = self._get_words() + self.games[channel] = Game(words, irc, channel, length) + self.games[channel].start() + except IOError, e: + wordfile = self.registryValue('wordFile') + irc.reply('Cannot open word file: %s' % wordfile) + irc.reply('Please create this file or set config plugins.' + + 'Wordgames.wordFile at an existing word file.') class BaseGame(object): "Base class for the games in this plugin." From 82e4665a825e9a902aa204239cf68da20c92b295 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sat, 3 Mar 2012 14:42:58 -0800 Subject: [PATCH 12/62] Minor wording fix. --- plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index f79fe3f..e581c72 100644 --- a/plugin.py +++ b/plugin.py @@ -121,7 +121,7 @@ class Wordgames(callbacks.Plugin): wordfile = self.registryValue('wordFile') irc.reply('Cannot open word file: %s' % wordfile) irc.reply('Please create this file or set config plugins.' + - 'Wordgames.wordFile at an existing word file.') + 'Wordgames.wordFile to point to an existing file.') class BaseGame(object): "Base class for the games in this plugin." From b69967628abd2aa9278f7fa97d23e771ec5c7704 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 4 Mar 2012 13:46:46 -0800 Subject: [PATCH 13/62] Change default dictionary and improve configurability. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now defaults to the /usr/share/dict/american-english dictionary which is probably found on many Linux systems today (avoiding the need to dig up a word file on the interwebs). On Debian/Ubuntu, you can 'apt-get install wamerican'. Added a configurable regexp to filter the word list down to reasonable words. Defaults to allow lowercase a-z only, therefore filtering out proper names, hyphenations, contractions, and words with accented characters like "adiós". (But hopefully still supporting non-English users by allowing this to be changed.) --- README | 24 +++++++++++++++++++++--- config.py | 11 ++++++++++- plugin.py | 22 ++++++++++++++++------ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/README b/README index 9b5a4ab..abcc9c4 100644 --- a/README +++ b/README @@ -3,9 +3,27 @@ Supybot Word Games Plugin A few word games to play in IRC with Supybot! -These games rely on a dictionary file, default 'words.txt' in the directory -where supybot runs. It should consist of one word per line. The location of -this file can be set with config plugins.Wordgames.wordFile. +These games rely on a dictionary file (not included). On Ubuntu, you can +normally just install the 'wamerican' package. See the configurable variables +to customize. + +Configuration: + + plugins.Wordgames.wordFile: + Path to the dictionary file. + + Default: /usr/share/dict/american-english + + plugins.Wordgames.wordRegexp: + A regular expression defining what a valid word looks like. This will + be used to filter words from the dictionary file that contain undesirable + characters (proper names, hyphens, accents, etc.). You will probably have + to quote the string when setting, e.g.: + + @config plugins.Wordgames.wordRegexp "^[a-x]+$" + (No words containing 'y' or 'z' would be allowed by this.) + + Default: ^[a-z]+$ Commands: diff --git a/config.py b/config.py index 02daefa..2d3e892 100644 --- a/config.py +++ b/config.py @@ -23,6 +23,8 @@ import supybot.conf as conf import supybot.registry as registry +import re + def configure(advanced): # This will be called by supybot to configure this module. advanced is # a bool that specifies whether the user identified himself as an advanced @@ -34,6 +36,13 @@ def configure(advanced): Wordgames = conf.registerPlugin('Wordgames') conf.registerGlobalValue(Wordgames, 'wordFile', - registry.String('words.txt', "Path to the dictionary file.")) + registry.String('/usr/share/dict/american-english', + 'Path to the dictionary file.')) + +conf.registerGlobalValue(Wordgames, 'wordRegexp', + registry.String('^[a-z]+$', + 'Regular expression defining what a valid word looks ' + + 'like (i.e. ignore proper names, contractions, etc. ' + + 'Modify this if you need to allow non-English chars.')) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugin.py b/plugin.py index e581c72..3ba3c72 100644 --- a/plugin.py +++ b/plugin.py @@ -49,6 +49,8 @@ def info(message): def error(message): log.error('Wordgames: ' + message) +class WordgamesError(Exception): pass + class Wordgames(callbacks.Plugin): "Please see the README file to configure and use this plugin." @@ -105,7 +107,16 @@ class Wordgames(callbacks.Plugin): wordquit = wrap(wordquit, ['channel']) def _get_words(self): - return map(str.strip, file(self.registryValue('wordFile')).readlines()) + try: + regexp = re.compile(self.registryValue('wordRegexp')) + except Exception, e: + raise WordgamesError("Bad value for wordRegexp: %s" % str(e)) + path = self.registryValue('wordFile') + try: + wordFile = file(path) + except Exception, e: + raise WordgamesError("Unable to open word file: %s" % path) + return filter(regexp.match, map(str.strip, wordFile.readlines())) def _start_game(self, Game, irc, channel, length): try: @@ -117,11 +128,10 @@ class Wordgames(callbacks.Plugin): words = self._get_words() self.games[channel] = Game(words, irc, channel, length) self.games[channel].start() - except IOError, e: - wordfile = self.registryValue('wordFile') - irc.reply('Cannot open word file: %s' % wordfile) - irc.reply('Please create this file or set config plugins.' + - 'Wordgames.wordFile to point to an existing file.') + except WordgamesError, e: + irc.reply('Wordgames error: %s' % str(e)) + irc.reply('Please check the configuration and try again. ' + + 'See README for help.') class BaseGame(object): "Base class for the games in this plugin." From 60c7fd09e7a4d232ef24ab701a11851163892cf5 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 4 Mar 2012 14:05:17 -0800 Subject: [PATCH 14/62] Update the command help in README. --- README | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README b/README index abcc9c4..1b60893 100644 --- a/README +++ b/README @@ -27,13 +27,15 @@ Configuration: Commands: - wordshrink [length] - Start a new WordShrink game. + wordshrink [difficulty] + Start a new WordShrink game. Difficulty values: easy medium hard evil + Default difficulty is easy. - wordtwist [length] - Start a new WordTwist game. + wordtwist [length] + Start a new WordTwist game. Difficulty values: easy medium hard evil + Default difficulty is easy. - wordquit + wordquit Give up on any currently running game. A puzzle will be presented in the form a > --- > --- > d, and your job is to From 943b0c2adf9ae99ee9fb4d6904527820b16ec641 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 4 Mar 2012 15:26:32 -0800 Subject: [PATCH 15/62] More README updates. --- README | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/README b/README index 1b60893..1d8e3de 100644 --- a/README +++ b/README @@ -28,25 +28,42 @@ Configuration: Commands: wordshrink [difficulty] - Start a new WordShrink game. Difficulty values: easy medium hard evil - Default difficulty is easy. + Start a new WordShrink game. Difficulty values: [easy] medium hard evil - wordtwist [length] - Start a new WordTwist game. Difficulty values: easy medium hard evil - Default difficulty is easy. + wordtwist [difficulty] + Start a new WordTwist game. Difficulty values: [easy] medium hard evil wordquit Give up on any currently running game. +Game Rules: + A puzzle will be presented in the form a > --- > --- > d, and your job is to come up with a response of the form b > c. (You can optionally include the start and end words in your response, as long as each word is separated by a greater-than sign.) -The goal of both games is to change the word by one letter until you have -turned the starting word into the ending word. In WordShrink, you must remove -one letter and rearrange the letters to form a new word. In WordTwist, you -must change exactly one letter (no re-arranging) to form a new word. +In WordShrink, you remove one letter from each successive word and rearrange +the letters to form a new word. Example session: + + @wordshrink + WordShrink: lights > ----- > ---- > sit + (12 possible solutions) + sight > this + WordShrink: mike got it! + WordShrink: lights > sight > this > sit + lights > hilts > hits > sit + ben: Your solution is also valid. + +In WordTwist, you change exactly one letter in each successive word to form a +new word (no rearranging). Example session: + + @wordtwist medium + WordTwist: mass > ---- > ---- > ---- > jade + (5 possible solutions) + mars > mare > made + WordTwist: mike got it! + WordTwist: mass > mars > mare > made > jade Credit: From d820ab1f1ce2d1dfc3ba8cc8fa42a85da70a2794 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 4 Mar 2012 22:55:52 -0800 Subject: [PATCH 16/62] Remove some useless checks. Some solution length logic was previously moved to handle_message. Some of the code in _valid_solution became nonsensical (not broken, just weird). Cleaned up. --- plugin.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/plugin.py b/plugin.py index 3ba3c72..8fff3c9 100644 --- a/plugin.py +++ b/plugin.py @@ -314,20 +314,18 @@ class WordChain(BaseGame): if len(words) != len(self.solution): return False # Check for incorrect start/end words - if len(words) == len(self.solution): - if words[0] != self.solution[0]: - self.send('%s: %s is not the starting word.' % (nick, words[0])) - return False - if words[-1] != self.solution[-1]: - self.send('%s: %s is not the final word.' % (nick, words[-1])) - return False - # Add the start/end words (if not present) to simplify the test logic - if len(words) == len(self.solution) - 2: - words = [self.solution[0]] + words + [self.solution[-1]] + if words[0] != self.solution[0]: + self.send('%s: %s is not the starting word.' % (nick, words[0])) + return False + if words[-1] != self.solution[-1]: + self.send('%s: %s is not the final word.' % (nick, words[-1])) + return False + # Check dictionary for word in words: if word not in self.words: self.send("%s: %s is not a word I know." % (nick, word)) return False + # Enforce pairwise relationships for i in range(0, len(words)-1): if words[i+1] not in self._get_successors(words[i]): self.send("%s: %s does not follow from %s." % From 4bc4c0d8584e4fb36dbc27d380d3f2e78ffc2a08 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sat, 24 Mar 2012 15:20:03 -0700 Subject: [PATCH 17/62] Make _start_game arguments generic. It shouldn't need to know what specific parameters the game will take. (And its "length" parameter was out of date since the games currently use "difficulty".) --- plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin.py b/plugin.py index 8fff3c9..e94cacd 100644 --- a/plugin.py +++ b/plugin.py @@ -118,7 +118,7 @@ class Wordgames(callbacks.Plugin): raise WordgamesError("Unable to open word file: %s" % path) return filter(regexp.match, map(str.strip, wordFile.readlines())) - def _start_game(self, Game, irc, channel, length): + def _start_game(self, Game, irc, channel, *args, **kwargs): try: game = self.games.get(channel) if game and game.is_running(): @@ -126,7 +126,7 @@ class Wordgames(callbacks.Plugin): game.show() else: words = self._get_words() - self.games[channel] = Game(words, irc, channel, length) + self.games[channel] = Game(words, irc, channel, *args, **kwargs) self.games[channel].start() except WordgamesError, e: irc.reply('Wordgames error: %s' % str(e)) From 353b5109dc35649dcfa2f5ae839e5bb77ebaa945 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 26 Mar 2012 16:33:59 -0700 Subject: [PATCH 18/62] Add a new game: Worddle This is a clone of a famous game involving a 4x4 grid of random letters. It uses Plugin's inFilter to filter out private messages to the bot during the game, so that they are not treated as commands. You can still send commands to the bot using the command character during this period. When the game ends, the filter puts things back to normal. Also implemented a new command when DEBUG is True, wordsolve, which shows the solution to the current wordgame. --- README | 35 +++++- config.py | 9 ++ plugin.py | 345 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- trie.py | 67 +++++++++++ 4 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 trie.py diff --git a/README b/README index 1d8e3de..cd2ea9f 100644 --- a/README +++ b/README @@ -25,8 +25,24 @@ Configuration: Default: ^[a-z]+$ + plugins.Wordgames.worddleDelay + The length (in seconds) of the pre-game period where players can join a + new Worddle game. + + Default: 15 + + plugins.Wordgames.worddleDuration + The length (in seconds) of the active period of a Worddle game, when + players can submit guesses. + + Default: 90 + Commands: + worddle + Start a new Worddle game. Use "worddle join" to join a game that someone + else has started. + wordshrink [difficulty] Start a new WordShrink game. Difficulty values: [easy] medium hard evil @@ -38,10 +54,21 @@ Commands: Game Rules: -A puzzle will be presented in the form a > --- > --- > d, and your job is to -come up with a response of the form b > c. (You can optionally include the -start and end words in your response, as long as each word is separated by a -greater-than sign.) +Worddle is a clone of a well-known puzzle game involving a 4x4 grid of +randomly-placed letters. Find words on the board by starting at a particular +letter and moving to adjacent letters (in all 8 directions, diagonals ok). +Words must be 3 letters or longer to be considered. At the end of the game, +if a word was found by multiple players, it is not counted. The remaining +words contribute to your score (1 point per letter). + +WordShrink and WordTwist are word chain (or word ladder) style games. +A puzzle will be presented in the form: + + a > --- > --- > d + +... and your job is to come up with a response of the form b > c. (You can +optionally include the start and end words in your response, as long as each +word is separated by a greater-than sign.) In WordShrink, you remove one letter from each successive word and rearrange the letters to form a new word. Example session: diff --git a/config.py b/config.py index 2d3e892..55655a0 100644 --- a/config.py +++ b/config.py @@ -45,4 +45,13 @@ conf.registerGlobalValue(Wordgames, 'wordRegexp', 'like (i.e. ignore proper names, contractions, etc. ' + 'Modify this if you need to allow non-English chars.')) +conf.registerGlobalValue(Wordgames, 'worddleDelay', + registry.NonNegativeInteger(15, + 'Delay (in seconds) before a Worddle game ' + + 'begins.')) + +conf.registerGlobalValue(Wordgames, 'worddleDuration', + registry.NonNegativeInteger(90, + 'Duration (in seconds) of a Worddle game ' + + '(not including the initial delay).')) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugin.py b/plugin.py index e94cacd..6584a78 100644 --- a/plugin.py +++ b/plugin.py @@ -20,6 +20,13 @@ # POSSIBILITY OF SUCH DAMAGE. ### +from operator import add, mul +import random +import re +import time +import traceback + +import supybot.conf as conf import supybot.utils as utils from supybot.commands import * import supybot.plugins as plugins @@ -30,8 +37,9 @@ import supybot.schedule as schedule import supybot.log as log import supybot.world as world -import random -import re +from trie import Trie + +DEBUG = False WHITE = '\x0300' GREEN = '\x0303' @@ -41,6 +49,7 @@ LYELLOW = '\x0308' LGREEN = '\x0309' LCYAN = '\x0311' LBLUE = '\x0312' +GRAY = '\x0314' LGRAY = '\x0315' def info(message): @@ -54,19 +63,67 @@ class WordgamesError(Exception): pass class Wordgames(callbacks.Plugin): "Please see the README file to configure and use this plugin." + def inFilter(self, irc, msg): + # Filter out private messages to the bot when they don't use the + # command prefix and the nick is currently playing a guessing game. + channel = msg.args[0] + commandChars = conf.supybot.reply.whenAddressedBy.chars + if msg.command == 'PRIVMSG' and msg.args[1][0] not in str(commandChars): + if not irc.isChannel(channel) and msg.nick: + game = self._find_player_game(msg.nick) + if game and 'guess' in dir(game): + game.guess(msg.nick, msg.args[1]) + return None + # In all other cases, default to normal message handling + return self.__parent.inFilter(irc, msg) + def __init__(self, irc): self.__parent = super(Wordgames, self) self.__parent.__init__(irc) self.games = {} def die(self): + # Ugly, but we need to ensure that the game actually stops + try: + schedule.removeEvent(Worddle.NAME) + except KeyError: pass + except Exception, e: + error("In die(): " + str(e)) self.__parent.die() - def doPrivmsg(self, irc, msg): - channel = msg.args[0] - game = self.games.get(channel) - if game: - game.handle_message(msg) + if DEBUG: + def wordsolve(self, irc, msgs, args, channel): + "Show solution(s) for the currently running game." + game = self.games.get(channel) + if game and game.is_running(): + game.solve() + else: + irc.reply('No game is currently running.') + wordsolve = wrap(wordsolve, ['channel']) + + def worddle(self, irc, msgs, args, channel, join): + """[join] + + Start a Worddle game or join a running game.""" + delay = self.registryValue('worddleDelay') + duration = self.registryValue('worddleDuration') + if join: + if join == 'join': + game = self.games.get(channel) + if game: + if game.__class__ == Worddle: + game.join(msgs.nick) + else: + irc.reply('Current word game is not Worddle!') + else: + irc.reply('No game is currently running.') + else: + irc.reply('Unrecognized option to worddle.') + else: + self._start_game(Worddle, irc, channel, msgs.nick, delay, duration) + worddle = wrap(worddle, ['channel', optional('somethingWithoutSpaces', '')]) + # Alias for misspelling of the game name + wordle = worddle def wordshrink(self, irc, msgs, args, channel, difficulty): """[easy|medium|hard|evil] (default: easy) @@ -106,6 +163,16 @@ class Wordgames(callbacks.Plugin): irc.reply('No word game currently running.') wordquit = wrap(wordquit, ['channel']) + def _find_player_game(self, player): + "Find a game (in any channel) that lists player as an active player." + my_game = None + for game in self.games.values(): + if game.is_running() and 'players' in dir(game): + if player in game.players: + my_game = game + break + return my_game + def _get_words(self): try: regexp = re.compile(self.registryValue('wordRegexp')) @@ -146,6 +213,10 @@ class BaseGame(object): "The game is finished." self.running = False + def solve(self): + "Show solution(s) for current game." + pass + def start(self): "Start the current game." self.running = True @@ -169,12 +240,261 @@ class BaseGame(object): def send(self, msg): "Relay a message to the channel." - self.irc.queueMsg(ircmsgs.privmsg(self.channel, msg)) + self.irc.sendMsg(ircmsgs.privmsg(self.channel, msg)) + + def send_private(self, nick, msg): + "Send a private message to a person." + self.irc.sendMsg(ircmsgs.privmsg(nick, msg)) def handle_message(self, msg): "Handle incoming messages on the channel." pass +class Worddle(BaseGame): + "The Worddle game implementation." + + BOARD_SIZE = 4 + NAME = 'worddle' # Unique identifier for supybot events + FREQUENCY_TABLE = { + 19: 'E', + 13: 'T', + 12: 'AR', + 11: 'INO', + 9: 'S', + 6: 'D', + 5: 'CHL', + 4: 'FMPU', + 3: 'GY', + 2: 'W', + 1: 'BJKQVXZ', + } + + def __init__(self, words, irc, channel, nick, delay, duration): + super(Worddle, self).__init__(words, irc, channel) + self.letters = reduce(add, (map(mul, + Worddle.FREQUENCY_TABLE.keys(), + Worddle.FREQUENCY_TABLE.values()))) + self._generate_board() + self._generate_wordtrie() + self.active = False + self.delay = delay + self.duration = duration + self.solutions = filter(lambda s: len(s) > 2, self._find_words()) + self.players = [] + self.player_answers = {} + self.warnings = [30, 10, 3] + while self.warnings[0] >= duration: + self.warnings = self.warnings[1:] + self.announce('The game will start in %s%d%s seconds...' % + (WHITE, self.delay, LGRAY)) + self.join(nick) + + def guess(self, nick, text): + if not self.active: + self.send_private(nick, "Relax! The game hasn't started yet!") + return + if nick not in self.players: + self.join(nick, True) + guesses = set(map(str.lower, text.split())) + accepted = filter(lambda s: s in self.solutions, guesses) + rejected = filter(lambda s: s not in self.solutions, guesses) + if len(accepted) > 3: + message = '%sGreat!%s' % (LGREEN, WHITE) + elif len(accepted) > 0: + message = '%sOk!' % WHITE + else: + message = '%sOops!%s' % (RED, LGRAY) + if accepted: + message += ' You got: %s%s' % (' '.join(sorted(accepted)), LGRAY) + self.player_answers[nick].update(accepted) + if rejected: + message += ' (not accepted: %s)' % ' '.join(sorted(rejected)) + self.send_private(nick, message) + + def join(self, nick, quiet=False): + if nick not in self.players: + self.players.append(nick) + self.player_answers[nick] = set() + self.announce('%s%s%s joined the game.' % (WHITE, nick, LGRAY)) + self.send_private(nick, + '-- %sPrivate Worddle session%s --' % (WHITE, LGRAY)) + self.send_private(nick, + 'Write your guesses here (separate words by spaces).') + if self.active and not quiet: + self._display_board(nick) + else: + self.send('%s: You have already joined the game.' % nick) + + def show(self): + if self.active: + self._display_board() + + def solve(self): + self.announce('Solutions: ' + ' '.join(sorted(self.solutions))) + + def start(self): + super(Worddle, self).start() + commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] + self.announce('Use "%s%sworddle join%s" to join the game.' + % (WHITE, commandChar, LGRAY)) + schedule.addEvent(self._begin_game, + time.time() + self.delay, Worddle.NAME) + + def stop(self): + super(Worddle, self).stop() + try: + schedule.removeEvent(Worddle.NAME) + except KeyError: + pass + self.announce('Stopped game.') + + def _begin_game(self): + self.active = True + self.start_time = time.time() + self.end_time = self.start_time + self.duration + message = "%sLet's GO!%s You have %s%d%s seconds!" % \ + (WHITE, LGRAY, WHITE, self.duration, LGRAY) + self.announce(message) + self._display_board() + for player in self.players: + self.send_private(player, message) + self._display_board(player) + self._schedule_next_event() + + def _schedule_next_event(self): + "Schedules the next warning or the end game event as appropriate." + if self.warnings: + # Warn almost half a second early, in case there is a little + # latency before the event is triggered. (Otherwise a 30 second + # warning sometimes shows up as 29 seconds remaining.) + warn_time = self.end_time - self.warnings[0] - 0.499 + schedule.addEvent(self._time_warning, warn_time, Worddle.NAME) + self.warnings = self.warnings[1:] + else: + schedule.addEvent(self._end_game, self.end_time, Worddle.NAME) + + def _time_warning(self): + seconds = round(self.start_time + self.duration - time.time()) + message = '%s%d%s seconds remaining...' % (WHITE, seconds, LGRAY) + self.announce(message) + for player in self.players: + self.send_private(player, message) + self._schedule_next_event() + + def _end_game(self): + self.active = False + self.running = False + results = self._compute_results() + max_score = -1 + for player in self.players: + self.send_private(player, "-- %sTime's up!%s --" % (WHITE, LGRAY)) + + # Announce player results + for player, result in results.iteritems(): + score, unique, dup = result + if score > max_score: + max_score = score + words = sorted(unique + dup) + words_text = '' + for word in words: + if word in unique: + color = LCYAN + else: + color = GRAY + words_text += '%s%s%s ' % (color, word, LGRAY) + if not words_text: + words_text = '%s-none-%s' % (GRAY, LGRAY) + self.announce('%s%s%s gets %s%d%s points (%s)' % + (WHITE, player, LGRAY, LGREEN, score, LGRAY, + words_text.strip())) + + # Announce winner(s) + winners = [("%s%s%s" % (WHITE, p, LGRAY)) + for p in results.keys() if results[p][0] == max_score] + message = ', '.join(winners[:-1]) + if len(winners) > 1: + message += ' and ' + message += winners[-1] + if len(winners) > 1: + message += ' tied ' + else: + message += ' wins ' + message += 'with %s%d%s points!' % (WHITE, max_score, LGRAY) + self.announce(message) + + def _compute_results(self): + "Return a dict of player: (score, unique words, dup words)" + answer_counts = {} + for answers in self.player_answers.values(): + for answer in answers: + if answer in answer_counts: + answer_counts[answer] += 1 + else: + answer_counts[answer] = 1 + results = {} + for player, answers in self.player_answers.iteritems(): + score = 0 + unique = [] + dup = [] + for answer in answers: + if answer_counts[answer] == 1: + score += len(answer) + unique.append(answer) + else: + dup.append(answer) + results[player] = (score, unique, dup) + return results + + def _display_board(self, nick=None): + for row in self.board: + text = LGREEN + ' ' + ' '.join(row) + text = text.replace('Q ', 'Qu') + if nick: + self.send_private(nick, text) + else: + self.announce(text) + + def _find_words(self, visited=None, row=0, col=0, prefix=''): + "Discover and return the set of all solutions for the current board." + result = set() + if visited == None: + for row in range(0, Worddle.BOARD_SIZE): + for col in range(0, Worddle.BOARD_SIZE): + result = result.union(self._find_words([], row, col, '')) + else: + visited = visited + [(row, col)] + current = prefix + self.board[row][col].lower() + if current[-1] == 'q': current += 'u' + node = self.wordtrie.find(current) + if node: + if node.complete: + result.add(current) + # Explore all 8 directions out from here + offsets = [(-1, -1), (-1, 0), (-1, 1), + ( 0, -1), ( 0, 1), + ( 1, -1), ( 1, 0), ( 1, 1)] + for offset in offsets: + point = (row + offset[0], col + offset[1]) + if point in visited: continue + if point[0] < 0 or point[0] >= Worddle.BOARD_SIZE: continue + if point[1] < 0 or point[1] >= Worddle.BOARD_SIZE: continue + result = result.union( + self._find_words(visited, point[0], point[1], current)) + return result + + def _generate_board(self): + self.board = [] + values = random.sample(self.letters, Worddle.BOARD_SIZE**2) + for i in range(0, Worddle.BOARD_SIZE): + start = Worddle.BOARD_SIZE * i + end = start + Worddle.BOARD_SIZE + self.board.append(values[start:end]) + + def _generate_wordtrie(self): + self.wordtrie = Trie() + for word in self.words: + self.wordtrie.add(word) + class WordChain(BaseGame): "Base class for word-chain games like WordShrink and WordTwist." @@ -251,6 +571,15 @@ class WordChain(BaseGame): self.send("(%s%d%s possible solution%s)" % (WHITE, num, LGRAY, '' if num == 1 else 's')) + def solve(self): + show = 3 + for solution in self.solutions[:show]: + self.announce(self._join_words(solution)) + not_shown = len(self.solutions) - show + if not_shown > 0: + self.announce('(%d more solution%s not shown.)' % + (not_shown, 's' if not_shown > 1 else '')) + def stop(self): super(WordChain, self).stop() self.announce(self._join_words(self.solution)) diff --git a/trie.py b/trie.py new file mode 100644 index 0000000..8b786ec --- /dev/null +++ b/trie.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python + +""" +Quick & dirty prefix tree (aka trie). +""" + +class Trie(object): + def __init__(self): + self.complete = False # Does this node complete a valid word? + self.children = {} + + def add(self, value): + if not value: + self.complete = True + return + prefix = value[0] + remainder = value[1:] + node = self.children.get(prefix, None) + if not node: + node = Trie() + self.children[prefix] = node + node.add(remainder) + + def find(self, value): + "Return the node associated with a value (None if not found)." + if not value: + return self + node = self.children.get(value[0], None) + if not node: + return None + return node.find(value[1:]) + + def dump(self, indent=0): + for key in sorted(self.children.keys()): + text = indent * ' ' + text += key + node = self.children[key] + if node.complete: + text += '*' + print(text) + node.dump(indent+2) + +if __name__ == '__main__': + t = Trie() + t.add('hell') + t.add('hello') + t.add('he') + t.add('world') + t.add('alphabet') + t.add('foo') + t.add('food') + t.add('foodie') + t.add('bar') + t.add('alphanumeric') + t.dump() + + assert t.find('r') is None + assert t.find('bars') is None + assert not t.find('hel').complete + assert t.find('hell').complete + assert t.find('hello').complete + assert not t.find('f').complete + assert not t.find('fo').complete + assert t.find('foo').complete + assert t.find('food').complete + assert not t.find('foodi').complete + assert t.find('foodie').complete From 94ec34d5a6d120a54e00f5e74fbcbd9ad1ce7aba Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 27 Mar 2012 01:16:34 -0700 Subject: [PATCH 19/62] General improvements to Worddle gameplay. Added support for broadcasting messages in one shot if the IRC server supports multiple targets. (Inspircd only right now, need to add support for more servers.) This will hopefully reduce flood issues and latency. Cleaned up messages, improved use of color, added join notifications, added a 'get ready' state 5 seconds before game starts. Some code cleanup and refactoring. --- plugin.py | 230 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 161 insertions(+), 69 deletions(-) diff --git a/plugin.py b/plugin.py index 6584a78..18d171d 100644 --- a/plugin.py +++ b/plugin.py @@ -24,7 +24,6 @@ from operator import add, mul import random import re import time -import traceback import supybot.conf as conf import supybot.utils as utils @@ -39,7 +38,7 @@ import supybot.world as world from trie import Trie -DEBUG = False +DEBUG = True WHITE = '\x0300' GREEN = '\x0303' @@ -52,12 +51,32 @@ LBLUE = '\x0312' GRAY = '\x0314' LGRAY = '\x0315' +def debug(message): + log.debug('Wordgames: ' + message) + def info(message): log.info('Wordgames: ' + message) def error(message): log.error('Wordgames: ' + message) +# Ideally Supybot would do this for me. It seems that all IRC servers have +# their own way of reporting this information... +def get_max_targets(irc): + # Default: Play it safe + result = 1 + # Look for known maxtarget strings + try: + # Inspircd + if 'MAXTARGETS' in irc.state.supported: + result = int(irc.state.supported['MAXTARGETS']) + # TODO: Whatever Freenode reports + else: + debug('Unable to find max targets, using default (1).') + except Exception, e: + error('Detecting max targets: %s. Using default (1).' % str(e)) + return result + class WordgamesError(Exception): pass class Wordgames(callbacks.Plugin): @@ -232,19 +251,30 @@ class BaseGame(object): def is_running(self): return self.running - def announce(self, msg): - "Announce a message with the game title prefix." - text = '%s%s%s:%s %s' % ( - LBLUE, self.__class__.__name__, WHITE, LGRAY, msg) - self.send(text) + def announce(self, text, now=False): + """ + Announce a message with the game title prefix. Set now to bypass + Supybot's queue, sending the message immediately. + """ + self.announce_to(self.channel, text, now) - def send(self, msg): - "Relay a message to the channel." - self.irc.sendMsg(ircmsgs.privmsg(self.channel, msg)) + def announce_to(self, dest, text, now=False): + "Announce to a specific destination (nick or channel)." + new_text = '%s%s%s:%s %s' % ( + LBLUE, self.__class__.__name__, WHITE, LGRAY, text) + self.send_to(dest, new_text, now) - def send_private(self, nick, msg): - "Send a private message to a person." - self.irc.sendMsg(ircmsgs.privmsg(nick, msg)) + def send(self, text, now=False): + """ + Send a message to the game's channel. Set now to bypass supybot's + queue, sending the message immediately. + """ + self.send_to(self.channel, text, now) + + def send_to(self, dest, text, now=False): + "Send to a specific destination (nick or channel)." + method = self.irc.sendMsg if now else self.irc.queueMsg + method(ircmsgs.privmsg(dest, text)) def handle_message(self, msg): "Handle incoming messages on the channel." @@ -269,6 +299,12 @@ class Worddle(BaseGame): 1: 'BJKQVXZ', } + class State: + PREGAME = 0 + READY = 1 + ACTIVE = 2 + DONE = 3 + def __init__(self, words, irc, channel, nick, delay, duration): super(Worddle, self).__init__(words, irc, channel) self.letters = reduce(add, (map(mul, @@ -276,25 +312,25 @@ class Worddle(BaseGame): Worddle.FREQUENCY_TABLE.values()))) self._generate_board() self._generate_wordtrie() - self.active = False self.delay = delay self.duration = duration - self.solutions = filter(lambda s: len(s) > 2, self._find_words()) + self.init_time = time.time() + self.max_targets = get_max_targets(irc) + self.solutions = self._find_solutions() + self.starter = nick + self.state = Worddle.State.PREGAME self.players = [] self.player_answers = {} - self.warnings = [30, 10, 3] + self.warnings = [30, 10, 5] while self.warnings[0] >= duration: self.warnings = self.warnings[1:] - self.announce('The game will start in %s%d%s seconds...' % - (WHITE, self.delay, LGRAY)) - self.join(nick) def guess(self, nick, text): - if not self.active: - self.send_private(nick, "Relax! The game hasn't started yet!") + if self.state < Worddle.State.ACTIVE: + self.send_to(nick, "Relax! The game hasn't started yet!") return if nick not in self.players: - self.join(nick, True) + self.join(nick) guesses = set(map(str.lower, text.split())) accepted = filter(lambda s: s in self.solutions, guesses) rejected = filter(lambda s: s not in self.solutions, guesses) @@ -309,25 +345,37 @@ class Worddle(BaseGame): self.player_answers[nick].update(accepted) if rejected: message += ' (not accepted: %s)' % ' '.join(sorted(rejected)) - self.send_private(nick, message) + self.send_to(nick, message) - def join(self, nick, quiet=False): + def join(self, nick): + assert self.state != Worddle.State.DONE if nick not in self.players: self.players.append(nick) self.player_answers[nick] = set() - self.announce('%s%s%s joined the game.' % (WHITE, nick, LGRAY)) - self.send_private(nick, - '-- %sPrivate Worddle session%s --' % (WHITE, LGRAY)) - self.send_private(nick, - 'Write your guesses here (separate words by spaces).') - if self.active and not quiet: + self.announce_to(nick, '-- %sNew Game%s --' % + (WHITE, LGRAY), now=True) + self.announce_to(nick, + "%s%s%s, here's your workspace. Just say: word1 word2 ..." % + (WHITE, nick, LGRAY), now=True) + self._broadcast('%s%s%s joined the game.' % (WHITE, nick, LGRAY), + ignore=[nick]) + if self.state == Worddle.State.ACTIVE: self._display_board(nick) + else: + self.announce_to(nick, 'Current Players: %s%s' % + (WHITE, (LGRAY + ', ' + WHITE).join(self.players))) + # Delay by 5 seconds each time someone joins pre-game + if self.state == Worddle.State.PREGAME: + self.delay += 5 + self._schedule_next_event() else: self.send('%s: You have already joined the game.' % nick) def show(self): - if self.active: - self._display_board() + # Not sure if this is really useful. + #if self.state == Worddle.State.ACTIVE: + # self._display_board(self.channel) + pass def solve(self): self.announce('Solutions: ' + ' '.join(sorted(self.solutions))) @@ -335,10 +383,12 @@ class Worddle(BaseGame): def start(self): super(Worddle, self).start() commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] + self.announce('The game will start in %s%d%s seconds...' % + (LYELLOW, self.delay, LGRAY), now=True) self.announce('Use "%s%sworddle join%s" to join the game.' - % (WHITE, commandChar, LGRAY)) - schedule.addEvent(self._begin_game, - time.time() + self.delay, Worddle.NAME) + % (WHITE, commandChar, LGRAY), now=True) + self.join(self.starter) + self._schedule_next_event() def stop(self): super(Worddle, self).stop() @@ -346,48 +396,88 @@ class Worddle(BaseGame): schedule.removeEvent(Worddle.NAME) except KeyError: pass - self.announce('Stopped game.') + self._broadcast('Game stopped.') + + def _broadcast(self, text, now=False, ignore=None): + """ + Broadcast a message to channel and all players. Set now to bypass + Supybot's queue and send the message immediately. ignore is a list + of names who should NOT receive the message. + """ + recipients = [self.channel] + self.players + if ignore: + recipients = filter(lambda r: r not in ignore, recipients) + for i in range(0, len(recipients), self.max_targets): + targets = ','.join(recipients[i:i+self.max_targets]) + self.announce_to(targets, text, now) + + def _get_ready(self): + self.state = Worddle.State.READY + self._broadcast('%sGet Ready!' % WHITE, now=True, ignore=[self.channel]) + self._schedule_next_event() def _begin_game(self): - self.active = True + self.state = Worddle.State.ACTIVE self.start_time = time.time() self.end_time = self.start_time + self.duration - message = "%sLet's GO!%s You have %s%d%s seconds!" % \ - (WHITE, LGRAY, WHITE, self.duration, LGRAY) - self.announce(message) + commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] self._display_board() - for player in self.players: - self.send_private(player, message) - self._display_board(player) + self._broadcast("%sLet's GO!%s You have %s%d%s seconds!" % + (WHITE, LGRAY, LYELLOW, self.duration, LGRAY), + now=True, ignore=[self.channel]) + self.announce('%sGame Started!%s Use "%s%sworddle join%s" to play!' % + (WHITE, LGRAY, WHITE, commandChar, LGRAY)) self._schedule_next_event() def _schedule_next_event(self): - "Schedules the next warning or the end game event as appropriate." - if self.warnings: - # Warn almost half a second early, in case there is a little - # latency before the event is triggered. (Otherwise a 30 second - # warning sometimes shows up as 29 seconds remaining.) - warn_time = self.end_time - self.warnings[0] - 0.499 - schedule.addEvent(self._time_warning, warn_time, Worddle.NAME) - self.warnings = self.warnings[1:] - else: - schedule.addEvent(self._end_game, self.end_time, Worddle.NAME) + """ + (Re)schedules the next game event (start, time left warning, end) + as appropriate. + """ + # Unschedule any previous event + try: + schedule.removeEvent(Worddle.NAME) + except KeyError: + pass + if self.state == Worddle.State.PREGAME: + # Schedule "get ready" message + schedule.addEvent(self._get_ready, + self.init_time + self.delay - 5, Worddle.NAME) + elif self.state == Worddle.State.READY: + # Schedule game start + schedule.addEvent(self._begin_game, + self.init_time + self.delay, Worddle.NAME) + elif self.state == Worddle.State.ACTIVE: + if self.warnings: + # Warn almost half a second early, in case there is a little + # latency before the event is triggered. (Otherwise a 30 second + # warning sometimes shows up as 29 seconds remaining.) + warn_time = self.end_time - self.warnings[0] - 0.499 + schedule.addEvent(self._time_warning, warn_time, Worddle.NAME) + self.warnings = self.warnings[1:] + else: + # Schedule game end + schedule.addEvent(self._end_game, self.end_time, Worddle.NAME) def _time_warning(self): seconds = round(self.start_time + self.duration - time.time()) - message = '%s%d%s seconds remaining...' % (WHITE, seconds, LGRAY) - self.announce(message) - for player in self.players: - self.send_private(player, message) + message = '%s%d%s seconds remaining...' % (LYELLOW, seconds, LGRAY) + self._broadcast(message, now=True) self._schedule_next_event() def _end_game(self): - self.active = False - self.running = False + self.gameover() + self.state = Worddle.State.DONE results = self._compute_results() max_score = -1 + self.announce("%sTime's up!" % WHITE, now=True) for player in self.players: - self.send_private(player, "-- %sTime's up!%s --" % (WHITE, LGRAY)) + score, unique, dup = results[player] + self.announce_to(player, + ("%sTime's up!%s You scored %s%d%s points! Check " + "%s%s%s for complete results.") % + (WHITE, LGRAY, LGREEN, score, LGRAY, WHITE, + self.channel, LGRAY), now=True) # Announce player results for player, result in results.iteritems(): @@ -446,28 +536,29 @@ class Worddle(BaseGame): return results def _display_board(self, nick=None): + "Display the board to everyone or just one nick if specified." for row in self.board: text = LGREEN + ' ' + ' '.join(row) text = text.replace('Q ', 'Qu') if nick: - self.send_private(nick, text) + self.announce_to(nick, text, now=True) else: - self.announce(text) + self._broadcast(text, now=True) - def _find_words(self, visited=None, row=0, col=0, prefix=''): + def _find_solutions(self, visited=None, row=0, col=0, prefix=''): "Discover and return the set of all solutions for the current board." result = set() if visited == None: for row in range(0, Worddle.BOARD_SIZE): for col in range(0, Worddle.BOARD_SIZE): - result = result.union(self._find_words([], row, col, '')) + result.update(self._find_solutions([], row, col, '')) else: visited = visited + [(row, col)] current = prefix + self.board[row][col].lower() if current[-1] == 'q': current += 'u' node = self.wordtrie.find(current) if node: - if node.complete: + if node.complete and len(current) > 2: result.add(current) # Explore all 8 directions out from here offsets = [(-1, -1), (-1, 0), (-1, 1), @@ -478,11 +569,12 @@ class Worddle(BaseGame): if point in visited: continue if point[0] < 0 or point[0] >= Worddle.BOARD_SIZE: continue if point[1] < 0 or point[1] >= Worddle.BOARD_SIZE: continue - result = result.union( - self._find_words(visited, point[0], point[1], current)) + result.update(self._find_solutions( + visited, point[0], point[1], current)) return result def _generate_board(self): + "Randomly generate a Worddle board (a list of lists)." self.board = [] values = random.sample(self.letters, Worddle.BOARD_SIZE**2) for i in range(0, Worddle.BOARD_SIZE): @@ -491,9 +583,9 @@ class Worddle(BaseGame): self.board.append(values[start:end]) def _generate_wordtrie(self): + "Populate self.wordtrie with the dictionary words." self.wordtrie = Trie() - for word in self.words: - self.wordtrie.add(word) + map(self.wordtrie.add, self.words) class WordChain(BaseGame): "Base class for word-chain games like WordShrink and WordTwist." From 8eed3d5b52e3f159e6e4d2f2284677f532461db4 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 27 Mar 2012 01:26:53 -0700 Subject: [PATCH 20/62] Add technical setup note to README. --- README | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README b/README index cd2ea9f..541769d 100644 --- a/README +++ b/README @@ -92,9 +92,27 @@ new word (no rearranging). Example session: WordTwist: mike got it! WordTwist: mass > mars > mare > made > jade +A Technical Note About Worddle: + +This game sends a lot of PRIVMSGs (between the channel and all the players, +the messages add up). It attempts to send them in a single PRIVMSG if +possible to combine targets. + +Supybot: I run Supybot with the latest git (there are show-stopper bugs in +0.83.4.1) and the Twisted driver (but Socket should work as well). + +IRC Server: I had to tune my IRC server to handle this game, due to the large +amount of messages it sends. You may find that it has problems on your server +due to flood controls (bot may be either fake lagged or kicked from the +server). If the game seems extremely slow, it is either an old Supybot or the +server is throttling you. + +I would like to add an option to tune the verbosity of the game to mitigate +this for restrictive servers. + Credit: Copyright 2012 Mike Mueller Released under the WTF public license: http://sam.zoy.org/wtfpl/ -Thanks to Ben Schomp for the inspiration. +Thanks to Ben Schomp for the inspiration and QA testing. From e63a36b2c1cb576c83479966b0bdbce957931e8f Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 27 Mar 2012 01:39:55 -0700 Subject: [PATCH 21/62] Turn debug mode off. Did't mean to leave this on. --- plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 18d171d..f00ae22 100644 --- a/plugin.py +++ b/plugin.py @@ -38,7 +38,7 @@ import supybot.world as world from trie import Trie -DEBUG = True +DEBUG = False WHITE = '\x0300' GREEN = '\x0303' From 2fb7a3954b5151eceb0157c5a8793c99b3f7088f Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 27 Mar 2012 01:46:44 -0700 Subject: [PATCH 22/62] Fix regression in WordChain games. Somehow I took out the code that handles public messages during a game. --- plugin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugin.py b/plugin.py index f00ae22..1bd0c96 100644 --- a/plugin.py +++ b/plugin.py @@ -110,6 +110,12 @@ class Wordgames(callbacks.Plugin): error("In die(): " + str(e)) self.__parent.die() + def doPrivmsg(self, irc, msg): + channel = msg.args[0] + game = self.games.get(channel) + if game: + game.handle_message(msg) + if DEBUG: def wordsolve(self, irc, msgs, args, channel): "Show solution(s) for the currently running game." From b489a4752be871d06591e27830cc94ac588c3673 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 27 Mar 2012 22:57:54 -0700 Subject: [PATCH 23/62] Only allow join if a game is running. --- plugin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plugin.py b/plugin.py index 1bd0c96..44c12a1 100644 --- a/plugin.py +++ b/plugin.py @@ -135,7 +135,7 @@ class Wordgames(callbacks.Plugin): if join: if join == 'join': game = self.games.get(channel) - if game: + if game and game.is_running(): if game.__class__ == Worddle: game.join(msgs.nick) else: @@ -313,9 +313,6 @@ class Worddle(BaseGame): def __init__(self, words, irc, channel, nick, delay, duration): super(Worddle, self).__init__(words, irc, channel) - self.letters = reduce(add, (map(mul, - Worddle.FREQUENCY_TABLE.keys(), - Worddle.FREQUENCY_TABLE.values()))) self._generate_board() self._generate_wordtrie() self.delay = delay @@ -354,6 +351,7 @@ class Worddle(BaseGame): self.send_to(nick, message) def join(self, nick): + assert self.is_running() assert self.state != Worddle.State.DONE if nick not in self.players: self.players.append(nick) @@ -581,8 +579,11 @@ class Worddle(BaseGame): def _generate_board(self): "Randomly generate a Worddle board (a list of lists)." + letters = reduce(add, (map(mul, + Worddle.FREQUENCY_TABLE.keys(), + Worddle.FREQUENCY_TABLE.values()))) self.board = [] - values = random.sample(self.letters, Worddle.BOARD_SIZE**2) + values = random.sample(letters, Worddle.BOARD_SIZE**2) for i in range(0, Worddle.BOARD_SIZE): start = Worddle.BOARD_SIZE * i end = start + Worddle.BOARD_SIZE From 8f7016f28ea4d0ad153b7bd290b71d0cc7e1a49a Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 28 Mar 2012 00:30:44 -0700 Subject: [PATCH 24/62] Factor out Worddle results into class. Also, results are now presented in descending order by score. --- plugin.py | 136 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 82 insertions(+), 54 deletions(-) diff --git a/plugin.py b/plugin.py index 44c12a1..14e7968 100644 --- a/plugin.py +++ b/plugin.py @@ -311,6 +311,70 @@ class Worddle(BaseGame): ACTIVE = 2 DONE = 3 + class PlayerResult: + "Represents result for a single player." + + def __init__(self, player, unique=None, dup=None): + self.player = player + self.unique = unique if unique else set() + self.dup = dup if dup else set() + + def __cmp__(self, other): + return cmp(self.get_score(), other.get_score()) + + def get_score(self): + return sum(map(len, self.unique)) + + def render(self): + words = sorted(list(self.unique) + list(self.dup)) + words_text = '' + for word in words: + if word in self.unique: + color = LCYAN + else: + color = GRAY + words_text += '%s%s%s ' % (color, word, LGRAY) + if not words_text: + words_text = '%s-none-%s' % (GRAY, LGRAY) + return '%s%s%s gets %s%d%s points (%s)' % \ + (WHITE, self.player, LGRAY, LGREEN, self.get_score(), + LGRAY, words_text.strip()) + + class Results: + "Represents results for all players." + + def __init__(self): + self.player_results = {} + + def add_player_words(self, player, words): + unique = set() + dup = set() + for word in words: + bad = False + for result in self.player_results.values(): + if word in result.unique: + result.unique.remove(word) + result.dup.add(word) + bad = True + elif word in result.dup: + bad = True + if bad: + dup.add(word) + else: + unique.add(word) + self.player_results[player] = \ + Worddle.PlayerResult(player, unique, dup) + + def render(self): + "Return a list of messages to send to IRC." + return [r.render() + for r in sorted(self.player_results.values(), reverse=True)] + + def winners(self): + result_list = sorted(self.player_results.values()) + high_score = result_list[-1].get_score() + return filter(lambda r: r.get_score() == high_score, result_list) + def __init__(self, words, irc, channel, nick, delay, duration): super(Worddle, self).__init__(words, irc, channel) self._generate_board() @@ -472,73 +536,37 @@ class Worddle(BaseGame): def _end_game(self): self.gameover() self.state = Worddle.State.DONE - results = self._compute_results() - max_score = -1 self.announce("%sTime's up!" % WHITE, now=True) - for player in self.players: - score, unique, dup = results[player] - self.announce_to(player, + + # Compute results + results = Worddle.Results() + for player, answers in self.player_answers.iteritems(): + results.add_player_words(player, answers) + + # Notify players + for result in results.player_results.values(): + self.announce_to(result.player, ("%sTime's up!%s You scored %s%d%s points! Check " "%s%s%s for complete results.") % - (WHITE, LGRAY, LGREEN, score, LGRAY, WHITE, + (WHITE, LGRAY, LGREEN, result.get_score(), LGRAY, WHITE, self.channel, LGRAY), now=True) - # Announce player results - for player, result in results.iteritems(): - score, unique, dup = result - if score > max_score: - max_score = score - words = sorted(unique + dup) - words_text = '' - for word in words: - if word in unique: - color = LCYAN - else: - color = GRAY - words_text += '%s%s%s ' % (color, word, LGRAY) - if not words_text: - words_text = '%s-none-%s' % (GRAY, LGRAY) - self.announce('%s%s%s gets %s%d%s points (%s)' % - (WHITE, player, LGRAY, LGREEN, score, LGRAY, - words_text.strip())) - - # Announce winner(s) - winners = [("%s%s%s" % (WHITE, p, LGRAY)) - for p in results.keys() if results[p][0] == max_score] - message = ', '.join(winners[:-1]) + # Announce game results in channel + for message in results.render(): + self.announce(message) + winners = results.winners() + winner_names = [("%s%s%s" % (WHITE, r.player, LGRAY)) for r in winners] + message = ', '.join(winner_names[:-1]) if len(winners) > 1: message += ' and ' - message += winners[-1] + message += winner_names[-1] if len(winners) > 1: message += ' tied ' else: message += ' wins ' - message += 'with %s%d%s points!' % (WHITE, max_score, LGRAY) + message += 'with %s%d%s points!' %(WHITE, winners[0].get_score(), LGRAY) self.announce(message) - def _compute_results(self): - "Return a dict of player: (score, unique words, dup words)" - answer_counts = {} - for answers in self.player_answers.values(): - for answer in answers: - if answer in answer_counts: - answer_counts[answer] += 1 - else: - answer_counts[answer] = 1 - results = {} - for player, answers in self.player_answers.iteritems(): - score = 0 - unique = [] - dup = [] - for answer in answers: - if answer_counts[answer] == 1: - score += len(answer) - unique.append(answer) - else: - dup.append(answer) - results[player] = (score, unique, dup) - return results - def _display_board(self, nick=None): "Display the board to everyone or just one nick if specified." for row in self.board: From 7f721e7fb984aec076e025cf0e8372a49659a1b9 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 28 Mar 2012 00:42:28 -0700 Subject: [PATCH 25/62] Tune pre-game timing. * Whenever someone joins, put at least 5 seconds on the pre-game clock. (If more than 5 seconds are already remaining, do nothing.) * Use the entire delay period before showing "Get ready!". * Delay 3 more seconds before jumping into the game. --- plugin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugin.py b/plugin.py index 14e7968..89bee90 100644 --- a/plugin.py +++ b/plugin.py @@ -432,9 +432,11 @@ class Worddle(BaseGame): else: self.announce_to(nick, 'Current Players: %s%s' % (WHITE, (LGRAY + ', ' + WHITE).join(self.players))) - # Delay by 5 seconds each time someone joins pre-game + # Keep at least 5 seconds on the pre-game clock if someone joins if self.state == Worddle.State.PREGAME: - self.delay += 5 + time_left = self.init_time + self.delay - time.time() + if time_left < 5: + self.delay += (5 - time_left) self._schedule_next_event() else: self.send('%s: You have already joined the game.' % nick) @@ -510,11 +512,11 @@ class Worddle(BaseGame): if self.state == Worddle.State.PREGAME: # Schedule "get ready" message schedule.addEvent(self._get_ready, - self.init_time + self.delay - 5, Worddle.NAME) + self.init_time + self.delay, Worddle.NAME) elif self.state == Worddle.State.READY: # Schedule game start schedule.addEvent(self._begin_game, - self.init_time + self.delay, Worddle.NAME) + self.init_time + self.delay + 3, Worddle.NAME) elif self.state == Worddle.State.ACTIVE: if self.warnings: # Warn almost half a second early, in case there is a little From 1462efce5f20023ab3cfc4202b3043b81d5621bc Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 28 Mar 2012 22:45:15 -0700 Subject: [PATCH 26/62] Fix bug showing Qu in 4th column. If Q was the last letter in a row, it Was showing just 'Q' since the 'Q ' substring wasn't found. --- plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin.py b/plugin.py index 89bee90..756a830 100644 --- a/plugin.py +++ b/plugin.py @@ -572,8 +572,8 @@ class Worddle(BaseGame): def _display_board(self, nick=None): "Display the board to everyone or just one nick if specified." for row in self.board: - text = LGREEN + ' ' + ' '.join(row) - text = text.replace('Q ', 'Qu') + text = LGREEN + ' ' + ' '.join(row) + ' ' + text = text.replace('Q ', 'Qu').rstrip() if nick: self.announce_to(nick, text, now=True) else: From 25d750135c29c90a53ecb0acb81a57bf64c410ad Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 28 Mar 2012 23:16:17 -0700 Subject: [PATCH 27/62] Support Freenode's TARGMAX value. --- plugin.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 756a830..1fb1d89 100644 --- a/plugin.py +++ b/plugin.py @@ -70,7 +70,14 @@ def get_max_targets(irc): # Inspircd if 'MAXTARGETS' in irc.state.supported: result = int(irc.state.supported['MAXTARGETS']) - # TODO: Whatever Freenode reports + # FreenodeA (ircd-seven) + elif 'TARGMAX' in irc.state.supported: + # TARGMAX looks like "...,WHOIS:1,PRIVMSG:4,NOTICE:4,..." + regexp = r'.*PRIVMSG:(\d+).*' + match = re.match(regexp, irc.state.supported['TARGMAX']) + if match: + result = int(match.group(1)) + print 'Determined max targets:', result else: debug('Unable to find max targets, using default (1).') except Exception, e: From 21699c34c35cc577588b75a4dd497874f0961a88 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 28 Mar 2012 23:31:59 -0700 Subject: [PATCH 28/62] Relay pre-game taunts between players. Anything typed before "Get Ready!" will be sent to other players. --- plugin.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/plugin.py b/plugin.py index 1fb1d89..b077e13 100644 --- a/plugin.py +++ b/plugin.py @@ -400,11 +400,22 @@ class Worddle(BaseGame): self.warnings = self.warnings[1:] def guess(self, nick, text): - if self.state < Worddle.State.ACTIVE: - self.send_to(nick, "Relax! The game hasn't started yet!") - return + # This can't happen right now, but it might be useful some day if nick not in self.players: self.join(nick) + # Pre-game messages are relayed as chatter (not treated as guesses) + if self.state < Worddle.State.ACTIVE: + if self.state == Worddle.State.PREGAME: + if len(self.players) > 1: + self._broadcast("%s%s%s says: %s" % + (WHITE, nick, LGRAY, text), ignore=[self.channel, nick]) + self.send_to(nick, "Message sent to other players.") + else: + self.send_to(nick, + "Message not sent (no one else is playing yet).") + else: + self.send_to(nick, "Relax! The game hasn't started yet!") + return guesses = set(map(str.lower, text.split())) accepted = filter(lambda s: s in self.solutions, guesses) rejected = filter(lambda s: s not in self.solutions, guesses) From 78cef6b7a68fc1796ebf2edd32d52bc67105d92e Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 2 Apr 2012 01:05:06 -0700 Subject: [PATCH 29/62] Clean up plugin shutdown logic. Worked around a supybot bug and abstracted shutdown to calling game.stop(now=True). This way the Wordgames class can be ignorant of the details of stopping a game. --- plugin.py | 54 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/plugin.py b/plugin.py index b077e13..f7b8a98 100644 --- a/plugin.py +++ b/plugin.py @@ -101,21 +101,22 @@ class Wordgames(callbacks.Plugin): game.guess(msg.nick, msg.args[1]) return None # In all other cases, default to normal message handling - return self.__parent.inFilter(irc, msg) + return self.parent.inFilter(irc, msg) def __init__(self, irc): - self.__parent = super(Wordgames, self) - self.__parent.__init__(irc) + # Tech note: Save a reference to my parent class because Supybot's + # Owner plugin will reload this module BEFORE calling die(), which + # means super() calls will fail with a TypeError. I consider this a + # bug in Supybot. + self.parent = super(Wordgames, self) + self.parent.__init__(irc) self.games = {} def die(self): - # Ugly, but we need to ensure that the game actually stops - try: - schedule.removeEvent(Worddle.NAME) - except KeyError: pass - except Exception, e: - error("In die(): " + str(e)) - self.__parent.die() + for channel, game in self.games.iteritems(): + if game.is_running(): + game.stop(now=True) + self.parent.die() def doPrivmsg(self, irc, msg): channel = msg.args[0] @@ -253,8 +254,11 @@ class BaseGame(object): "Start the current game." self.running = True - def stop(self): - "Shut down the current game." + def stop(self, now=False): + """ + Shut down the current game. If now is True, do not pass go, do not + announce anything, just stop anything that needs stopping. + """ self.running = False def show(self): @@ -383,7 +387,9 @@ class Worddle(BaseGame): return filter(lambda r: r.get_score() == high_score, result_list) def __init__(self, words, irc, channel, nick, delay, duration): - super(Worddle, self).__init__(words, irc, channel) + # See tech note in the Wordgames class. + self.parent = super(Worddle, self) + self.parent.__init__(words, irc, channel) self._generate_board() self._generate_wordtrie() self.delay = delay @@ -469,7 +475,7 @@ class Worddle(BaseGame): self.announce('Solutions: ' + ' '.join(sorted(self.solutions))) def start(self): - super(Worddle, self).start() + self.parent.start() commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] self.announce('The game will start in %s%d%s seconds...' % (LYELLOW, self.delay, LGRAY), now=True) @@ -478,13 +484,14 @@ class Worddle(BaseGame): self.join(self.starter) self._schedule_next_event() - def stop(self): - super(Worddle, self).stop() + def stop(self, now=False): + self.parent.stop() try: schedule.removeEvent(Worddle.NAME) except KeyError: pass - self._broadcast('Game stopped.') + if not now: + self._broadcast('Game stopped.') def _broadcast(self, text, now=False, ignore=None): """ @@ -663,7 +670,9 @@ class WordChain(BaseGame): self.num_solutions = num_solutions def __init__(self, words, irc, channel, settings): - super(WordChain, self).__init__(words, irc, channel) + # See tech note in the Wordgames class. + self.parent = super(WordChain, self) + self.parent.__init__(words, irc, channel) self.settings = settings self.solution_length = random.choice(settings.puzzle_lengths) self.solution = [] @@ -677,7 +686,7 @@ class WordChain(BaseGame): self.build_word_map() def start(self): - super(WordChain, self).start() + self.parent.start() happy = False # Build a puzzle while not happy: @@ -727,9 +736,10 @@ class WordChain(BaseGame): self.announce('(%d more solution%s not shown.)' % (not_shown, 's' if not_shown > 1 else '')) - def stop(self): - super(WordChain, self).stop() - self.announce(self._join_words(self.solution)) + def stop(self, now=False): + self.parent.stop() + if not now: + self.announce(self._join_words(self.solution)) def handle_message(self, msg): words = map(str.strip, msg.args[1].split('>')) From bffa11361a1644c82d00676c339361ab4ca99e0e Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 2 Apr 2012 01:11:14 -0700 Subject: [PATCH 30/62] Fix Worddle to handle simultaneous games. It was using the string 'worddle' as the unique identifier in all scheduled events, which meant that a second game (in another channel, at the same time) will erase the first game's scheduled events. Now use the Worddle object's unique identifier when scheduling events. --- plugin.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/plugin.py b/plugin.py index f7b8a98..43bd1a2 100644 --- a/plugin.py +++ b/plugin.py @@ -301,7 +301,6 @@ class Worddle(BaseGame): "The Worddle game implementation." BOARD_SIZE = 4 - NAME = 'worddle' # Unique identifier for supybot events FREQUENCY_TABLE = { 19: 'E', 13: 'T', @@ -394,6 +393,7 @@ class Worddle(BaseGame): self._generate_wordtrie() self.delay = delay self.duration = duration + self.event_name = 'Worddle.%d' % id(self) self.init_time = time.time() self.max_targets = get_max_targets(irc) self.solutions = self._find_solutions() @@ -487,7 +487,7 @@ class Worddle(BaseGame): def stop(self, now=False): self.parent.stop() try: - schedule.removeEvent(Worddle.NAME) + schedule.removeEvent(self.event_name) except KeyError: pass if not now: @@ -531,28 +531,30 @@ class Worddle(BaseGame): """ # Unschedule any previous event try: - schedule.removeEvent(Worddle.NAME) + schedule.removeEvent(self.event_name) except KeyError: pass if self.state == Worddle.State.PREGAME: # Schedule "get ready" message schedule.addEvent(self._get_ready, - self.init_time + self.delay, Worddle.NAME) + self.init_time + self.delay, self.event_name) elif self.state == Worddle.State.READY: # Schedule game start schedule.addEvent(self._begin_game, - self.init_time + self.delay + 3, Worddle.NAME) + self.init_time + self.delay + 3, self.event_name) elif self.state == Worddle.State.ACTIVE: if self.warnings: # Warn almost half a second early, in case there is a little # latency before the event is triggered. (Otherwise a 30 second # warning sometimes shows up as 29 seconds remaining.) warn_time = self.end_time - self.warnings[0] - 0.499 - schedule.addEvent(self._time_warning, warn_time, Worddle.NAME) + schedule.addEvent( + self._time_warning, warn_time, self.event_name) self.warnings = self.warnings[1:] else: # Schedule game end - schedule.addEvent(self._end_game, self.end_time, Worddle.NAME) + schedule.addEvent( + self._end_game, self.end_time, self.event_name) def _time_warning(self): seconds = round(self.start_time + self.duration - time.time()) From 5708e06b5ebf78b2c9cbc8748f2bbfadad30ebc6 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 2 Apr 2012 02:11:42 -0700 Subject: [PATCH 31/62] Update README and convert to Markdown. --- README | 119 +---------------------------------------- README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 118 deletions(-) mode change 100644 => 120000 README create mode 100644 README.md diff --git a/README b/README deleted file mode 100644 index 541769d..0000000 --- a/README +++ /dev/null @@ -1,118 +0,0 @@ -Supybot Word Games Plugin -========================= - -A few word games to play in IRC with Supybot! - -These games rely on a dictionary file (not included). On Ubuntu, you can -normally just install the 'wamerican' package. See the configurable variables -to customize. - -Configuration: - - plugins.Wordgames.wordFile: - Path to the dictionary file. - - Default: /usr/share/dict/american-english - - plugins.Wordgames.wordRegexp: - A regular expression defining what a valid word looks like. This will - be used to filter words from the dictionary file that contain undesirable - characters (proper names, hyphens, accents, etc.). You will probably have - to quote the string when setting, e.g.: - - @config plugins.Wordgames.wordRegexp "^[a-x]+$" - (No words containing 'y' or 'z' would be allowed by this.) - - Default: ^[a-z]+$ - - plugins.Wordgames.worddleDelay - The length (in seconds) of the pre-game period where players can join a - new Worddle game. - - Default: 15 - - plugins.Wordgames.worddleDuration - The length (in seconds) of the active period of a Worddle game, when - players can submit guesses. - - Default: 90 - -Commands: - - worddle - Start a new Worddle game. Use "worddle join" to join a game that someone - else has started. - - wordshrink [difficulty] - Start a new WordShrink game. Difficulty values: [easy] medium hard evil - - wordtwist [difficulty] - Start a new WordTwist game. Difficulty values: [easy] medium hard evil - - wordquit - Give up on any currently running game. - -Game Rules: - -Worddle is a clone of a well-known puzzle game involving a 4x4 grid of -randomly-placed letters. Find words on the board by starting at a particular -letter and moving to adjacent letters (in all 8 directions, diagonals ok). -Words must be 3 letters or longer to be considered. At the end of the game, -if a word was found by multiple players, it is not counted. The remaining -words contribute to your score (1 point per letter). - -WordShrink and WordTwist are word chain (or word ladder) style games. -A puzzle will be presented in the form: - - a > --- > --- > d - -... and your job is to come up with a response of the form b > c. (You can -optionally include the start and end words in your response, as long as each -word is separated by a greater-than sign.) - -In WordShrink, you remove one letter from each successive word and rearrange -the letters to form a new word. Example session: - - @wordshrink - WordShrink: lights > ----- > ---- > sit - (12 possible solutions) - sight > this - WordShrink: mike got it! - WordShrink: lights > sight > this > sit - lights > hilts > hits > sit - ben: Your solution is also valid. - -In WordTwist, you change exactly one letter in each successive word to form a -new word (no rearranging). Example session: - - @wordtwist medium - WordTwist: mass > ---- > ---- > ---- > jade - (5 possible solutions) - mars > mare > made - WordTwist: mike got it! - WordTwist: mass > mars > mare > made > jade - -A Technical Note About Worddle: - -This game sends a lot of PRIVMSGs (between the channel and all the players, -the messages add up). It attempts to send them in a single PRIVMSG if -possible to combine targets. - -Supybot: I run Supybot with the latest git (there are show-stopper bugs in -0.83.4.1) and the Twisted driver (but Socket should work as well). - -IRC Server: I had to tune my IRC server to handle this game, due to the large -amount of messages it sends. You may find that it has problems on your server -due to flood controls (bot may be either fake lagged or kicked from the -server). If the game seems extremely slow, it is either an old Supybot or the -server is throttling you. - -I would like to add an option to tune the verbosity of the game to mitigate -this for restrictive servers. - -Credit: - -Copyright 2012 Mike Mueller -Released under the WTF public license: http://sam.zoy.org/wtfpl/ - -Thanks to Ben Schomp for the inspiration and QA testing. diff --git a/README b/README new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/README @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8efa074 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +Supybot Word Games Plugin +========================= + +This is a plugin for the popular IRC bot Supybot that implements a few +word games to play in your IRC channel. + +Typical installation process: + +1. Clone this git repository in your downloads directory. +2. Create a symlink called `Wordgames` in supybot/plugins pointing to it. +3. In IRC, load Wordgames. + +**Note:** These games rely on a dictionary file (not included). On +Ubuntu, you can normally just install the `wamerican` package. See +the Configurable Variables section to customize. + +Commands +-------- + +The following commands are exposed by this plugin: + +>`worddle [join]` +>> Start a new Worddle game (with no arguments). Use "worddle join" +>> to join a game that someone else has started. + +>`wordshrink [difficulty]` +>> Start a new WordShrink game. Difficulty values: [easy] medium hard evil + +>`wordtwist [difficulty]` +>> Start a new WordTwist game. Difficulty values: [easy] medium hard evil + +>`wordquit` +>> Shut down any currently running game. One solution will be displayed for +>> the word chain games, to satisfy your curiosity. + +Game Rules +---------- + +### Worddle + +Worddle is a clone of a well-known puzzle game involving a 4x4 grid of +randomly-placed letters. It's a timed, multiplayer game where you compete +to find words that your opponents didn't find. + +When you start a new game, or join an existing game, the bot will send you a +private message indicating that you are playing. All your guesses must be +sent to the bot in this private conversation, so your opponents can't see your +guesses. At the end of the game, the results will be presented in the channel +where the game was started. + +To be a valid guess, words must: + +* be made of adjacent letters on the board (in all 8 directions, diagonals ok) +* be at least 3 letters in length +* appear in the dictionary file. + +At the end of the game, if a word was found by multiple players, it is not +counted. The remaining words contribute to your score, at 1 point per letter. + +### WordShrink + +WordShrink and WordTwist are word chain (or word ladder) style games. +A puzzle will be presented in the form: + + word1 > --- > --- > word4 + +... and your job is to come up with a response of the form `word2 > word3` +that completes the puzzle. (You can optionally include the start and end +words in your response, as long as each word is separated by a greater-than +sign.) + +In WordShrink, each word must be made by removing one letter from the +preceding word and rearranging the letters to form a new word. Example +session: + + @wordshrink + WordShrink: lights > ----- > ---- > sit + (12 possible solutions) + sight > this + WordShrink: mike got it! + WordShrink: lights > sight > this > sit + lights > hilts > hits > sit + ben: Your solution is also valid. + +### WordTwist + +WordTwist is very similar to WordShrink, except that the way you manipulate +the words to solve the puzzle is changed. In WordTwist, you change exactly +one letter in each successive word to form a new word (no rearranging is +allowed). Example session: + + @wordtwist medium + WordTwist: mass > ---- > ---- > ---- > jade + (5 possible solutions) + mars > mare > made + WordTwist: mike got it! + WordTwist: mass > mars > mare > made > jade + +Configuration Variables +----------------------- + +> `plugins.Wordgames.wordFile` +>> Path to the dictionary file. +>> +>> Default: `/usr/share/dict/american-english` + +> `plugins.Wordgames.wordRegexp` +>> A regular expression defining what a valid word looks like. This will +>> be used to filter words from the dictionary file that contain undesirable +>> characters (proper names, hyphens, accents, etc.). You will probably have +>> to quote the string when setting, e.g.: +>> +>> @config plugins.Wordgames.wordRegexp "^[a-x]+$" +>> +>> (No words containing 'y' or 'z' would be allowed by this.) +>> +>> Default: `^[a-z]+$` + +> `plugins.Wordgames.worddleDelay` +>> The length (in seconds) of the pre-game period where players can join a +>> new Worddle game. +>> +>> Default: `15` + +> `plugins.Wordgames.worddleDuration` +>> The length (in seconds) of the active period of a Worddle game, when +>> players can submit guesses. +>> +>> Default: `90` + +A Technical Note About Worddle +------------------------------ + +This game sends a lot of PRIVMSGs (between the channel and all the players, +the messages add up). It attempts to send them in a single PRIVMSG if +possible to combine targets. + +Supybot: I run Supybot with the latest git (there are show-stopper bugs in +0.83.4.1) and the Twisted driver (but Socket should work as well). + +IRC Server: I had to tune my IRC server to handle this game, due to the large +amount of messages it sends. You may find that it has problems on your server +due to flood controls (bot may be either fake lagged or kicked from the +server). If the game seems extremely slow, it is either an old Supybot or the +server is throttling you. + +I would like to add an option to tune the verbosity of the game to mitigate +this for restrictive servers. + +Credit +------ + +Copyright 2012 Mike Mueller +Released under the WTF public license: http://sam.zoy.org/wtfpl/ + +Thanks to Ben Schomp for the inspiration and QA testing. From 1a74f375076f21f24f6bd72f4d733fb58caa4da2 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 2 Apr 2012 02:15:25 -0700 Subject: [PATCH 32/62] Attempt to make nested blockquotes work. I tested this README with sundown, Github's Markdown implementation, and yet it still screwed me when I pushed to Github. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 8efa074..12efc7d 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,20 @@ Commands The following commands are exposed by this plugin: >`worddle [join]` +> >> Start a new Worddle game (with no arguments). Use "worddle join" >> to join a game that someone else has started. >`wordshrink [difficulty]` +> >> Start a new WordShrink game. Difficulty values: [easy] medium hard evil >`wordtwist [difficulty]` +> >> Start a new WordTwist game. Difficulty values: [easy] medium hard evil >`wordquit` +> >> Shut down any currently running game. One solution will be displayed for >> the word chain games, to satisfy your curiosity. @@ -100,11 +104,13 @@ Configuration Variables ----------------------- > `plugins.Wordgames.wordFile` +> >> Path to the dictionary file. >> >> Default: `/usr/share/dict/american-english` > `plugins.Wordgames.wordRegexp` +> >> A regular expression defining what a valid word looks like. This will >> be used to filter words from the dictionary file that contain undesirable >> characters (proper names, hyphens, accents, etc.). You will probably have @@ -117,12 +123,14 @@ Configuration Variables >> Default: `^[a-z]+$` > `plugins.Wordgames.worddleDelay` +> >> The length (in seconds) of the pre-game period where players can join a >> new Worddle game. >> >> Default: `15` > `plugins.Wordgames.worddleDuration` +> >> The length (in seconds) of the active period of a Worddle game, when >> players can submit guesses. >> From 121aa4c22427989a33737a9e508b39ff36784b2b Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 2 Apr 2012 02:17:35 -0700 Subject: [PATCH 33/62] Remove nested blockquotes in README. After seeing how it looks with Github's CSS, I think this works better. --- README.md | 84 +++++++++++++++++++++++++++---------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 12efc7d..2339bd3 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,23 @@ Commands The following commands are exposed by this plugin: ->`worddle [join]` -> ->> Start a new Worddle game (with no arguments). Use "worddle join" ->> to join a game that someone else has started. +`worddle [join]` ->`wordshrink [difficulty]` -> ->> Start a new WordShrink game. Difficulty values: [easy] medium hard evil +> Start a new Worddle game (with no arguments). Use "worddle join" +> to join a game that someone else has started. ->`wordtwist [difficulty]` -> ->> Start a new WordTwist game. Difficulty values: [easy] medium hard evil +`wordshrink [difficulty]` ->`wordquit` -> ->> Shut down any currently running game. One solution will be displayed for ->> the word chain games, to satisfy your curiosity. +> Start a new WordShrink game. Difficulty values: [easy] medium hard evil + +`wordtwist [difficulty]` + +> Start a new WordTwist game. Difficulty values: [easy] medium hard evil + +`wordquit` + +> Shut down any currently running game. One solution will be displayed for +> the word chain games, to satisfy your curiosity. Game Rules ---------- @@ -103,38 +103,38 @@ allowed). Example session: Configuration Variables ----------------------- -> `plugins.Wordgames.wordFile` -> ->> Path to the dictionary file. ->> ->> Default: `/usr/share/dict/american-english` +`plugins.Wordgames.wordFile` -> `plugins.Wordgames.wordRegexp` -> ->> A regular expression defining what a valid word looks like. This will ->> be used to filter words from the dictionary file that contain undesirable ->> characters (proper names, hyphens, accents, etc.). You will probably have ->> to quote the string when setting, e.g.: ->> ->> @config plugins.Wordgames.wordRegexp "^[a-x]+$" ->> ->> (No words containing 'y' or 'z' would be allowed by this.) ->> ->> Default: `^[a-z]+$` +> Path to the dictionary file. +> +> Default: `/usr/share/dict/american-english` -> `plugins.Wordgames.worddleDelay` -> ->> The length (in seconds) of the pre-game period where players can join a ->> new Worddle game. ->> ->> Default: `15` +`plugins.Wordgames.wordRegexp` -> `plugins.Wordgames.worddleDuration` +> A regular expression defining what a valid word looks like. This will +> be used to filter words from the dictionary file that contain undesirable +> characters (proper names, hyphens, accents, etc.). You will probably have +> to quote the string when setting, e.g.: > ->> The length (in seconds) of the active period of a Worddle game, when ->> players can submit guesses. ->> ->> Default: `90` +> @config plugins.Wordgames.wordRegexp "^[a-x]+$" +> +> (No words containing 'y' or 'z' would be allowed by this.) +> +> Default: `^[a-z]+$` + +`plugins.Wordgames.worddleDelay` + +> The length (in seconds) of the pre-game period where players can join a +> new Worddle game. +> +> Default: `15` + +`plugins.Wordgames.worddleDuration` + +> The length (in seconds) of the active period of a Worddle game, when +> players can submit guesses. +> +> Default: `90` A Technical Note About Worddle ------------------------------ From 4d5c9e4d2cbeb3d66582ea0a455d59e666061fd9 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 3 Apr 2012 16:33:45 -0700 Subject: [PATCH 34/62] Add 'start' and 'stop' Worddle commands. Default is "start" so you can still just say "@worddle" to start a game. "worddle stop" is the same as saying "@wordquit". --- README.md | 7 ++++--- plugin.py | 47 +++++++++++++++++++++++++++-------------------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2339bd3..7b296bc 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ Commands The following commands are exposed by this plugin: -`worddle [join]` +`worddle [start|join|stop]` -> Start a new Worddle game (with no arguments). Use "worddle join" -> to join a game that someone else has started. +> Start a new Worddle game, join an existing game, or stop the current game. +> `start` is the default if nothing is specified. `stop` is an alias for +> @wordquit, added for ease of use. `wordshrink [difficulty]` diff --git a/plugin.py b/plugin.py index 43bd1a2..1d431b6 100644 --- a/plugin.py +++ b/plugin.py @@ -134,27 +134,31 @@ class Wordgames(callbacks.Plugin): irc.reply('No game is currently running.') wordsolve = wrap(wordsolve, ['channel']) - def worddle(self, irc, msgs, args, channel, join): - """[join] + def worddle(self, irc, msgs, args, channel, command): + """[command] - Start a Worddle game or join a running game.""" + Play a Worddle game. Commands: [start|join|stop] (default: start). + """ delay = self.registryValue('worddleDelay') duration = self.registryValue('worddleDuration') - if join: - if join == 'join': - game = self.games.get(channel) - if game and game.is_running(): - if game.__class__ == Worddle: - game.join(msgs.nick) - else: - irc.reply('Current word game is not Worddle!') + if command == 'join': + game = self.games.get(channel) + if game and game.is_running(): + if game.__class__ == Worddle: + game.join(msgs.nick) else: - irc.reply('No game is currently running.') + irc.reply('Current word game is not Worddle!') else: - irc.reply('Unrecognized option to worddle.') - else: + irc.reply('No game is currently running.') + elif command == 'start': self._start_game(Worddle, irc, channel, msgs.nick, delay, duration) - worddle = wrap(worddle, ['channel', optional('somethingWithoutSpaces', '')]) + elif command == 'stop': + # Alias for @wordquit + self._stop_game(irc, channel) + else: + irc.reply('Unrecognized command to worddle.') + worddle = wrap(worddle, + ['channel', optional('somethingWithoutSpaces', 'start')]) # Alias for misspelling of the game name wordle = worddle @@ -189,11 +193,7 @@ class Wordgames(callbacks.Plugin): Stop any currently running word game. """ - game = self.games.get(channel) - if game and game.is_running(): - game.stop() - else: - irc.reply('No word game currently running.') + self._stop_game(irc, channel) wordquit = wrap(wordquit, ['channel']) def _find_player_game(self, player): @@ -233,6 +233,13 @@ class Wordgames(callbacks.Plugin): irc.reply('Please check the configuration and try again. ' + 'See README for help.') + def _stop_game(self, irc, channel): + game = self.games.get(channel) + if game and game.is_running(): + game.stop() + else: + irc.reply('No word game currently running.') + class BaseGame(object): "Base class for the games in this plugin." From 79082016effa768dcd5c4a9eac3d8a9543558b67 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 3 Apr 2012 16:59:54 -0700 Subject: [PATCH 35/62] Implement "official" scoring mechanism. Word values are now: Length | Points -------+-------- 3, 4 | 1 5 | 2 6 | 3 7 | 5 8+ | 11 --- README.md | 10 +++++++++- plugin.py | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7b296bc..c3005ed 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,15 @@ To be a valid guess, words must: * appear in the dictionary file. At the end of the game, if a word was found by multiple players, it is not -counted. The remaining words contribute to your score, at 1 point per letter. +counted. The remaining words contribute to your score using these values: + + Length | Value + --------+------- + 3, 4 | 1 point + 5 | 2 points + 6 | 3 points + 7 | 5 points + 8+ | 11 points ### WordShrink diff --git a/plugin.py b/plugin.py index 1d431b6..41c0f6a 100644 --- a/plugin.py +++ b/plugin.py @@ -38,7 +38,7 @@ import supybot.world as world from trie import Trie -DEBUG = False +DEBUG = True WHITE = '\x0300' GREEN = '\x0303' @@ -60,6 +60,10 @@ def info(message): def error(message): log.error('Wordgames: ' + message) +def point_str(value): + "Return 'point' or 'points' depending on value." + return 'point' if value == 1 else 'points' + # Ideally Supybot would do this for me. It seems that all IRC servers have # their own way of reporting this information... def get_max_targets(irc): @@ -322,6 +326,16 @@ class Worddle(BaseGame): 1: 'BJKQVXZ', } + POINT_VALUES = { + 3: 1, + 4: 1, + 5: 2, + 6: 3, + 7: 5, + } + + MAX_POINTS = 11 # 8 letters or longer + class State: PREGAME = 0 READY = 1 @@ -340,7 +354,10 @@ class Worddle(BaseGame): return cmp(self.get_score(), other.get_score()) def get_score(self): - return sum(map(len, self.unique)) + score = 0 + for word in self.unique: + score += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) + return score def render(self): words = sorted(list(self.unique) + list(self.dup)) @@ -353,9 +370,10 @@ class Worddle(BaseGame): words_text += '%s%s%s ' % (color, word, LGRAY) if not words_text: words_text = '%s-none-%s' % (GRAY, LGRAY) - return '%s%s%s gets %s%d%s points (%s)' % \ - (WHITE, self.player, LGRAY, LGREEN, self.get_score(), - LGRAY, words_text.strip()) + score = self.get_score() + return '%s%s%s gets %s%d%s %s (%s)' % \ + (WHITE, self.player, LGRAY, LGREEN, score, + LGRAY, point_str(score), words_text.strip()) class Results: "Represents results for all players." @@ -581,10 +599,11 @@ class Worddle(BaseGame): # Notify players for result in results.player_results.values(): + score = result.get_score() self.announce_to(result.player, - ("%sTime's up!%s You scored %s%d%s points! Check " + ("%sTime's up!%s You scored %s%d%s %s! Check " "%s%s%s for complete results.") % - (WHITE, LGRAY, LGREEN, result.get_score(), LGRAY, WHITE, + (WHITE, LGRAY, LGREEN, score, LGRAY, point_str(score), WHITE, self.channel, LGRAY), now=True) # Announce game results in channel @@ -600,7 +619,8 @@ class Worddle(BaseGame): message += ' tied ' else: message += ' wins ' - message += 'with %s%d%s points!' %(WHITE, winners[0].get_score(), LGRAY) + message += 'with %s%d%s %s!' % (WHITE, winners[0].get_score(), LGRAY, + point_str(winners[0].get_score())) self.announce(message) def _display_board(self, nick=None): From 1b1b5331ea662af0510fb062ff6a3f104d0fd881 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 4 Apr 2012 00:21:03 -0700 Subject: [PATCH 36/62] Inform late joiners of time left. When you join late, you find out exactly how many seconds you have left in the game. --- plugin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin.py b/plugin.py index 41c0f6a..5dcd515 100644 --- a/plugin.py +++ b/plugin.py @@ -478,6 +478,10 @@ class Worddle(BaseGame): ignore=[nick]) if self.state == Worddle.State.ACTIVE: self._display_board(nick) + time_left = int(round(self.end_time - time.time())) + self.announce_to(nick, + "%sLet's GO!%s You have %s%d%s seconds!" % + (WHITE, LGRAY, LYELLOW, time_left, LGRAY), now=True) else: self.announce_to(nick, 'Current Players: %s%s' % (WHITE, (LGRAY + ', ' + WHITE).join(self.players))) From d3945ea76305fb8abeb292395c075df4829f86db Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 4 Apr 2012 01:46:54 -0700 Subject: [PATCH 37/62] Attempt to refactor text messages. Worddle has a lot of logic dedicated to formatting text, and it makes the code a little unreadable. I tried to move most of the format strings out as constants (and color them once instead of every time). The resulting code is a little cleaner looking, but I'm not sure I love it. --- plugin.py | 115 +++++++++++++++++++++++++++++------------------------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/plugin.py b/plugin.py index 5dcd515..80f87f0 100644 --- a/plugin.py +++ b/plugin.py @@ -325,7 +325,6 @@ class Worddle(BaseGame): 2: 'W', 1: 'BJKQVXZ', } - POINT_VALUES = { 3: 1, 4: 1, @@ -333,8 +332,29 @@ class Worddle(BaseGame): 6: 3, 7: 5, } - MAX_POINTS = 11 # 8 letters or longer + MESSAGES = { + 'chat': '%s%%(nick)s%s says: %%(text)s' % (WHITE, LGRAY), + 'go': '%sLet\'s GO!%s You have %s%%(seconds)d%s seconds!' % + (WHITE, LGRAY, LYELLOW, LGRAY), + 'joined': '%s%%(nick)s%s joined the game.' % (WHITE, LGRAY), + 'gameover': ("%sTime's up!%s You got %s%%(points)d%s point%%(plural)s! " + + "Check %s%%(channel)s%s for complete results.") % + (WHITE, LGRAY, LGREEN, LGRAY, WHITE, LGRAY), + 'players': 'Current Players: %(players)s', + 'ready': '%sGet Ready!' % WHITE, + 'startup1': 'The game will start in %s%%(seconds)d%s seconds...' % + (LYELLOW, LGRAY), + 'startup2': 'Use "%s%%(commandChar)sworddle join%s" to join the game.' % + (WHITE, LGRAY), + 'startup3': ('%sGame Started!%s Use "%s%%(commandChar)sworddle join"' + + '%s to play!') % (WHITE, LGRAY, WHITE, LGRAY), + 'stopped': 'Game stopped.', + 'warning': '%s%%(seconds)d%s seconds remaining...' % (LYELLOW, LGRAY), + 'welcome1': '-- %sNew Game%s --' % (WHITE, LGRAY), + 'welcome2': ('%s%%(nick)s%s, this is your workspace. Just say: ' + + 'word1 word2 ...') % (WHITE, LGRAY), + } class State: PREGAME = 0 @@ -436,16 +456,7 @@ class Worddle(BaseGame): self.join(nick) # Pre-game messages are relayed as chatter (not treated as guesses) if self.state < Worddle.State.ACTIVE: - if self.state == Worddle.State.PREGAME: - if len(self.players) > 1: - self._broadcast("%s%s%s says: %s" % - (WHITE, nick, LGRAY, text), ignore=[self.channel, nick]) - self.send_to(nick, "Message sent to other players.") - else: - self.send_to(nick, - "Message not sent (no one else is playing yet).") - else: - self.send_to(nick, "Relax! The game hasn't started yet!") + self._broadcast('chat', self.players, nick=nick, text=text) return guesses = set(map(str.lower, text.split())) accepted = filter(lambda s: s in self.solutions, guesses) @@ -469,22 +480,15 @@ class Worddle(BaseGame): if nick not in self.players: self.players.append(nick) self.player_answers[nick] = set() - self.announce_to(nick, '-- %sNew Game%s --' % - (WHITE, LGRAY), now=True) - self.announce_to(nick, - "%s%s%s, here's your workspace. Just say: word1 word2 ..." % - (WHITE, nick, LGRAY), now=True) - self._broadcast('%s%s%s joined the game.' % (WHITE, nick, LGRAY), - ignore=[nick]) + self._broadcast('welcome1', now=True, nick=nick) + self._broadcast('welcome2', [nick], now=True, nick=nick) + self._broadcast('joined', nick=nick) if self.state == Worddle.State.ACTIVE: self._display_board(nick) time_left = int(round(self.end_time - time.time())) - self.announce_to(nick, - "%sLet's GO!%s You have %s%d%s seconds!" % - (WHITE, LGRAY, LYELLOW, time_left, LGRAY), now=True) + self._broadcast('go', [nick], now=True, seconds=time_left) else: - self.announce_to(nick, 'Current Players: %s%s' % - (WHITE, (LGRAY + ', ' + WHITE).join(self.players))) + self._broadcast('players', [nick]) # Keep at least 5 seconds on the pre-game clock if someone joins if self.state == Worddle.State.PREGAME: time_left = self.init_time + self.delay - time.time() @@ -505,11 +509,8 @@ class Worddle(BaseGame): def start(self): self.parent.start() - commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] - self.announce('The game will start in %s%d%s seconds...' % - (LYELLOW, self.delay, LGRAY), now=True) - self.announce('Use "%s%sworddle join%s" to join the game.' - % (WHITE, commandChar, LGRAY), now=True) + self._broadcast('startup1', [self.channel], True, seconds=self.delay) + self._broadcast('startup2', [self.channel], True) self.join(self.starter) self._schedule_next_event() @@ -520,37 +521,50 @@ class Worddle(BaseGame): except KeyError: pass if not now: - self._broadcast('Game stopped.') + self._broadcast('stopped') - def _broadcast(self, text, now=False, ignore=None): + def _broadcast_text(self, text, recipients=None, now=False): """ - Broadcast a message to channel and all players. Set now to bypass - Supybot's queue and send the message immediately. ignore is a list - of names who should NOT receive the message. + Broadcast the given string message to the recipient list (default is + all players, not the game channel). Set now to bypass Supybot's queue + and send the message immediately. """ - recipients = [self.channel] + self.players - if ignore: - recipients = filter(lambda r: r not in ignore, recipients) + if recipients is None: + recipients = self.players for i in range(0, len(recipients), self.max_targets): targets = ','.join(recipients[i:i+self.max_targets]) self.announce_to(targets, text, now) + def _broadcast(self, name, recipients=None, now=False, **kwargs): + """ + Broadcast the message named by 'name' using the constants defined + in MESSAGES to the specified recipient list. If recipients is + unspecified, default is all players (game channel not included). + Keyword args should be provided for any format substitution in this + particular message. + """ + # Automatically provide some dictionary values + kwargs['channel'] = self.channel + kwargs['commandChar'] = str(conf.supybot.reply.whenAddressedBy.chars)[0] + kwargs['players'] = "%s%s%s" % \ + (WHITE, (LGRAY + ', ' + WHITE).join(self.players), LGRAY) + if 'points' in kwargs: + kwargs['plural'] = '' if kwargs['points'] == 1 else 's' + formatted = Worddle.MESSAGES[name] % kwargs + self._broadcast_text(formatted, recipients, now) + def _get_ready(self): self.state = Worddle.State.READY - self._broadcast('%sGet Ready!' % WHITE, now=True, ignore=[self.channel]) + self._broadcast('ready', now=True) self._schedule_next_event() def _begin_game(self): self.state = Worddle.State.ACTIVE self.start_time = time.time() self.end_time = self.start_time + self.duration - commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] self._display_board() - self._broadcast("%sLet's GO!%s You have %s%d%s seconds!" % - (WHITE, LGRAY, LYELLOW, self.duration, LGRAY), - now=True, ignore=[self.channel]) - self.announce('%sGame Started!%s Use "%s%sworddle join%s" to play!' % - (WHITE, LGRAY, WHITE, commandChar, LGRAY)) + self._broadcast('go', now=True, seconds=self.duration) + self._broadcast('startup3', [self.channel]) self._schedule_next_event() def _schedule_next_event(self): @@ -587,8 +601,7 @@ class Worddle(BaseGame): def _time_warning(self): seconds = round(self.start_time + self.duration - time.time()) - message = '%s%d%s seconds remaining...' % (LYELLOW, seconds, LGRAY) - self._broadcast(message, now=True) + self._broadcast('warning', now=True, seconds=seconds) self._schedule_next_event() def _end_game(self): @@ -603,12 +616,8 @@ class Worddle(BaseGame): # Notify players for result in results.player_results.values(): - score = result.get_score() - self.announce_to(result.player, - ("%sTime's up!%s You scored %s%d%s %s! Check " - "%s%s%s for complete results.") % - (WHITE, LGRAY, LGREEN, score, LGRAY, point_str(score), WHITE, - self.channel, LGRAY), now=True) + self._broadcast('gameover', [result.player], now=True, + points=result.get_score()) # Announce game results in channel for message in results.render(): @@ -635,7 +644,7 @@ class Worddle(BaseGame): if nick: self.announce_to(nick, text, now=True) else: - self._broadcast(text, now=True) + self._broadcast_text(text, self.players + [self.channel], True) def _find_solutions(self, visited=None, row=0, col=0, prefix=''): "Discover and return the set of all solutions for the current board." From 77b94e68b2c0f3540680732b1ed69b336ce87fdb Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 4 Apr 2012 15:26:54 -0700 Subject: [PATCH 38/62] Clean up and dial down messages. Trying to make the game a little quieter in the main channel, and in general cut down on flooding. Still no verbosity tuning, but this should be a more reasonable default level. --- plugin.py | 108 +++++++++++++++++++++++++----------------------------- 1 file changed, 50 insertions(+), 58 deletions(-) diff --git a/plugin.py b/plugin.py index 80f87f0..ae37cb4 100644 --- a/plugin.py +++ b/plugin.py @@ -38,7 +38,7 @@ import supybot.world as world from trie import Trie -DEBUG = True +DEBUG = False WHITE = '\x0300' GREEN = '\x0303' @@ -335,25 +335,23 @@ class Worddle(BaseGame): MAX_POINTS = 11 # 8 letters or longer MESSAGES = { 'chat': '%s%%(nick)s%s says: %%(text)s' % (WHITE, LGRAY), - 'go': '%sLet\'s GO!%s You have %s%%(seconds)d%s seconds!' % - (WHITE, LGRAY, LYELLOW, LGRAY), 'joined': '%s%%(nick)s%s joined the game.' % (WHITE, LGRAY), 'gameover': ("%sTime's up!%s You got %s%%(points)d%s point%%(plural)s! " + "Check %s%%(channel)s%s for complete results.") % (WHITE, LGRAY, LGREEN, LGRAY, WHITE, LGRAY), 'players': 'Current Players: %(players)s', 'ready': '%sGet Ready!' % WHITE, - 'startup1': 'The game will start in %s%%(seconds)d%s seconds...' % - (LYELLOW, LGRAY), - 'startup2': 'Use "%s%%(commandChar)sworddle join%s" to join the game.' % - (WHITE, LGRAY), - 'startup3': ('%sGame Started!%s Use "%s%%(commandChar)sworddle join"' - + '%s to play!') % (WHITE, LGRAY, WHITE, LGRAY), + 'result': ('%s%%(nick)s%s %%(verb)s %s%%(points)d%s ' + + 'point%%(plural)s (%%(words)s)') % + (WHITE, LGRAY, LGREEN, LGRAY), + 'startup': ('Starting in %s%%(seconds)d%s seconds, ' + + 'use "%s%%(commandChar)sworddle join%s" to play!') % + (LYELLOW, LGRAY, WHITE, LGRAY), 'stopped': 'Game stopped.', 'warning': '%s%%(seconds)d%s seconds remaining...' % (LYELLOW, LGRAY), - 'welcome1': '-- %sNew Game%s --' % (WHITE, LGRAY), - 'welcome2': ('%s%%(nick)s%s, this is your workspace. Just say: ' + - 'word1 word2 ...') % (WHITE, LGRAY), + 'welcome1': '--- %sNew Game%s ---' % (WHITE, LGRAY), + 'welcome2': ('%s%%(nick)s%s, write your answers here, e.g.: ' + + 'cat dog ...') % (WHITE, LGRAY), } class State: @@ -379,7 +377,8 @@ class Worddle(BaseGame): score += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) return score - def render(self): + def render_words(self): + "Return the words in this result, colorized appropriately." words = sorted(list(self.unique) + list(self.dup)) words_text = '' for word in words: @@ -390,10 +389,7 @@ class Worddle(BaseGame): words_text += '%s%s%s ' % (color, word, LGRAY) if not words_text: words_text = '%s-none-%s' % (GRAY, LGRAY) - score = self.get_score() - return '%s%s%s gets %s%d%s %s (%s)' % \ - (WHITE, self.player, LGRAY, LGREEN, score, - LGRAY, point_str(score), words_text.strip()) + return words_text.strip() class Results: "Represents results for all players." @@ -420,15 +416,8 @@ class Worddle(BaseGame): self.player_results[player] = \ Worddle.PlayerResult(player, unique, dup) - def render(self): - "Return a list of messages to send to IRC." - return [r.render() - for r in sorted(self.player_results.values(), reverse=True)] - - def winners(self): - result_list = sorted(self.player_results.values()) - high_score = result_list[-1].get_score() - return filter(lambda r: r.get_score() == high_score, result_list) + def sorted_results(self): + return sorted(self.player_results.values(), reverse=True) def __init__(self, words, irc, channel, nick, delay, duration): # See tech note in the Wordgames class. @@ -478,15 +467,13 @@ class Worddle(BaseGame): assert self.is_running() assert self.state != Worddle.State.DONE if nick not in self.players: + self._broadcast('welcome1', [nick], now=True, nick=nick) + self._broadcast('welcome2', [nick], now=True, nick=nick) + self._broadcast('joined', self.players, nick=nick) self.players.append(nick) self.player_answers[nick] = set() - self._broadcast('welcome1', now=True, nick=nick) - self._broadcast('welcome2', [nick], now=True, nick=nick) - self._broadcast('joined', nick=nick) if self.state == Worddle.State.ACTIVE: - self._display_board(nick) - time_left = int(round(self.end_time - time.time())) - self._broadcast('go', [nick], now=True, seconds=time_left) + self._display_board(nick, show_help=True) else: self._broadcast('players', [nick]) # Keep at least 5 seconds on the pre-game clock if someone joins @@ -509,8 +496,7 @@ class Worddle(BaseGame): def start(self): self.parent.start() - self._broadcast('startup1', [self.channel], True, seconds=self.delay) - self._broadcast('startup2', [self.channel], True) + self._broadcast('startup', [self.channel], True, seconds=self.delay) self.join(self.starter) self._schedule_next_event() @@ -521,7 +507,7 @@ class Worddle(BaseGame): except KeyError: pass if not now: - self._broadcast('stopped') + self._broadcast('stopped', self.players + [self.channel]) def _broadcast_text(self, text, recipients=None, now=False): """ @@ -562,9 +548,7 @@ class Worddle(BaseGame): self.state = Worddle.State.ACTIVE self.start_time = time.time() self.end_time = self.start_time + self.duration - self._display_board() - self._broadcast('go', now=True, seconds=self.duration) - self._broadcast('startup3', [self.channel]) + self._display_board(show_help=True) self._schedule_next_event() def _schedule_next_event(self): @@ -607,7 +591,6 @@ class Worddle(BaseGame): def _end_game(self): self.gameover() self.state = Worddle.State.DONE - self.announce("%sTime's up!" % WHITE, now=True) # Compute results results = Worddle.Results() @@ -619,28 +602,37 @@ class Worddle(BaseGame): self._broadcast('gameover', [result.player], now=True, points=result.get_score()) - # Announce game results in channel - for message in results.render(): - self.announce(message) - winners = results.winners() - winner_names = [("%s%s%s" % (WHITE, r.player, LGRAY)) for r in winners] - message = ', '.join(winner_names[:-1]) - if len(winners) > 1: - message += ' and ' - message += winner_names[-1] - if len(winners) > 1: - message += ' tied ' - else: - message += ' wins ' - message += 'with %s%d%s %s!' % (WHITE, winners[0].get_score(), LGRAY, - point_str(winners[0].get_score())) - self.announce(message) + # Announce results + player_results = results.sorted_results() + high_score = player_results[0].get_score() + tie = len(player_results) > 1 and \ + player_results[1].get_score() == high_score + for result in player_results: + score = result.get_score() + if score == high_score: + if tie: + verb = "%stied%s with" % (LYELLOW, LGRAY) + else: + verb = "%swins%s with" % (LGREEN, LGRAY) + else: + verb = "got" + words_text = result.render_words() + self._broadcast('result', [self.channel], nick=result.player, + verb=verb, points=score, words=words_text) - def _display_board(self, nick=None): + def _display_board(self, nick=None, show_help=False): "Display the board to everyone or just one nick if specified." - for row in self.board: + commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] + help_msgs = [''] * Worddle.BOARD_SIZE + help_msgs[1] = '%sLet\'s GO!' % (WHITE) + help_msgs[2] = '%s%s%s seconds left!' % \ + (LYELLOW, int(round(self.end_time - time.time())), LGRAY) + for i, row in enumerate(self.board): text = LGREEN + ' ' + ' '.join(row) + ' ' - text = text.replace('Q ', 'Qu').rstrip() + text = text.replace('Q ', 'Qu') + if show_help: + text += ' ' + help_msgs[i] + text = text.rstrip() if nick: self.announce_to(nick, text, now=True) else: From c51e21b003a5f2a8333cb7e7aa600e0705b45ddd Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 4 Apr 2012 17:56:37 -0700 Subject: [PATCH 39/62] Add a "worddle stats" command. --- README.md | 5 +++-- plugin.py | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c3005ed..1df32c7 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ Commands The following commands are exposed by this plugin: -`worddle [start|join|stop]` +`worddle [start|join|stop|stats]` > Start a new Worddle game, join an existing game, or stop the current game. > `start` is the default if nothing is specified. `stop` is an alias for -> @wordquit, added for ease of use. +> @wordquit, added for ease of use. Use 'stats' to see a few bits of +> information about the board after the game. `wordshrink [difficulty]` diff --git a/plugin.py b/plugin.py index ae37cb4..b5ede29 100644 --- a/plugin.py +++ b/plugin.py @@ -141,7 +141,7 @@ class Wordgames(callbacks.Plugin): def worddle(self, irc, msgs, args, channel, command): """[command] - Play a Worddle game. Commands: [start|join|stop] (default: start). + Play a Worddle game. Commands: [start|join|stop|stats] (default: start). """ delay = self.registryValue('worddleDelay') duration = self.registryValue('worddleDuration') @@ -159,6 +159,14 @@ class Wordgames(callbacks.Plugin): elif command == 'stop': # Alias for @wordquit self._stop_game(irc, channel) + elif command == 'stats': + game = self.games.get(channel) + if not game or game.__class__ != Worddle: + irc.reply('No Worddle game available for stats.') + elif game.is_running(): + irc.reply('Please wait until the game finishes.') + else: + game.stats() else: irc.reply('Unrecognized command to worddle.') worddle = wrap(worddle, @@ -509,6 +517,19 @@ class Worddle(BaseGame): if not now: self._broadcast('stopped', self.players + [self.channel]) + def stats(self): + assert self.state == Worddle.State.DONE + points = 0 + for word in self.solutions: + points += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) + longest_len = len(max(self.solutions, key=len)) + longest_words = filter(lambda w: len(w) == longest_len, self.solutions) + self.announce(('There were %s%d%s possible words, with total point' + ' value %s%d%s. The longest word%s: %s%s%s.') % + (WHITE, len(self.solutions), LGRAY, LGREEN, points, LGRAY, + ' was' if len(longest_words) == 1 else 's were', + LCYAN, (LGRAY + ', ' + LCYAN).join(longest_words), LGRAY)) + def _broadcast_text(self, text, recipients=None, now=False): """ Broadcast the given string message to the recipient list (default is From e706b6816bf7d9ebd57e1a6f948ffdf45542c12c Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 4 Apr 2012 17:59:25 -0700 Subject: [PATCH 40/62] Clean up board display. Removed color from "starting in n seconds" message to reduce distraction. Moved board one column right to give it more breathing room. --- plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin.py b/plugin.py index b5ede29..b23c900 100644 --- a/plugin.py +++ b/plugin.py @@ -352,9 +352,9 @@ class Worddle(BaseGame): 'result': ('%s%%(nick)s%s %%(verb)s %s%%(points)d%s ' + 'point%%(plural)s (%%(words)s)') % (WHITE, LGRAY, LGREEN, LGRAY), - 'startup': ('Starting in %s%%(seconds)d%s seconds, ' + + 'startup': ('Starting in %%(seconds)d seconds, ' + 'use "%s%%(commandChar)sworddle join%s" to play!') % - (LYELLOW, LGRAY, WHITE, LGRAY), + (WHITE, LGRAY), 'stopped': 'Game stopped.', 'warning': '%s%%(seconds)d%s seconds remaining...' % (LYELLOW, LGRAY), 'welcome1': '--- %sNew Game%s ---' % (WHITE, LGRAY), @@ -649,10 +649,10 @@ class Worddle(BaseGame): help_msgs[2] = '%s%s%s seconds left!' % \ (LYELLOW, int(round(self.end_time - time.time())), LGRAY) for i, row in enumerate(self.board): - text = LGREEN + ' ' + ' '.join(row) + ' ' + text = LGREEN + ' ' + ' '.join(row) + ' ' text = text.replace('Q ', 'Qu') if show_help: - text += ' ' + help_msgs[i] + text += ' ' + help_msgs[i] text = text.rstrip() if nick: self.announce_to(nick, text, now=True) From 38fc1e5ad66900108c61de32bd398ac45b04e3c8 Mon Sep 17 00:00:00 2001 From: Ben Schomp Date: Mon, 9 Apr 2012 13:31:07 -0400 Subject: [PATCH 41/62] Worddle: Award a golden ticket (yellow '*') in the post game word list for getting a longest word. --- plugin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugin.py b/plugin.py index b23c900..2380c34 100644 --- a/plugin.py +++ b/plugin.py @@ -385,7 +385,7 @@ class Worddle(BaseGame): score += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) return score - def render_words(self): + def render_words(self, longest_len=0): "Return the words in this result, colorized appropriately." words = sorted(list(self.unique) + list(self.dup)) words_text = '' @@ -394,6 +394,8 @@ class Worddle(BaseGame): color = LCYAN else: color = GRAY + if len(word) == longest_len: + word += YELLOW + '*' words_text += '%s%s%s ' % (color, word, LGRAY) if not words_text: words_text = '%s-none-%s' % (GRAY, LGRAY) @@ -439,6 +441,7 @@ class Worddle(BaseGame): self.init_time = time.time() self.max_targets = get_max_targets(irc) self.solutions = self._find_solutions() + self.longest_len = len(max(self.solutions, key=len)) self.starter = nick self.state = Worddle.State.PREGAME self.players = [] @@ -522,8 +525,7 @@ class Worddle(BaseGame): points = 0 for word in self.solutions: points += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) - longest_len = len(max(self.solutions, key=len)) - longest_words = filter(lambda w: len(w) == longest_len, self.solutions) + longest_words = filter(lambda w: len(w) == self.longest_len, self.solutions) self.announce(('There were %s%d%s possible words, with total point' ' value %s%d%s. The longest word%s: %s%s%s.') % (WHITE, len(self.solutions), LGRAY, LGREEN, points, LGRAY, @@ -637,7 +639,7 @@ class Worddle(BaseGame): verb = "%swins%s with" % (LGREEN, LGRAY) else: verb = "got" - words_text = result.render_words() + words_text = result.render_words(longest_len=self.longest_len) self._broadcast('result', [self.channel], nick=result.player, verb=verb, points=score, words=words_text) From efa6c89e401c30e1745cffa304b3648b090065e9 Mon Sep 17 00:00:00 2001 From: Ben Schomp Date: Thu, 12 Apr 2012 17:30:18 -0400 Subject: [PATCH 42/62] Keep the color scheme. --- plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 2380c34..bb9bb80 100644 --- a/plugin.py +++ b/plugin.py @@ -395,7 +395,7 @@ class Worddle(BaseGame): else: color = GRAY if len(word) == longest_len: - word += YELLOW + '*' + word += LYELLOW + '*' words_text += '%s%s%s ' % (color, word, LGRAY) if not words_text: words_text = '%s-none-%s' % (GRAY, LGRAY) From 43f07fd142529cb80b3171abe7e29524acac924d Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 22 Apr 2012 20:07:22 -0700 Subject: [PATCH 43/62] Attempt to make bountiful Worddle boards. Generate 5 boards and pick the one with the most solutions. It's more fun when there are more solutions to be found. Refactored a little - the board is now a class WorddleBoard. Moved the generation and solution discovery to WorddleBoard. This simplifies Worddle a little bit, but it probably could be simplified further. --- plugin.py | 92 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/plugin.py b/plugin.py index bb9bb80..fc5ff66 100644 --- a/plugin.py +++ b/plugin.py @@ -433,15 +433,13 @@ class Worddle(BaseGame): # See tech note in the Wordgames class. self.parent = super(Worddle, self) self.parent.__init__(words, irc, channel) - self._generate_board() - self._generate_wordtrie() + self.board = self._generate_board() self.delay = delay self.duration = duration self.event_name = 'Worddle.%d' % id(self) self.init_time = time.time() self.max_targets = get_max_targets(irc) - self.solutions = self._find_solutions() - self.longest_len = len(max(self.solutions, key=len)) + self.longest_len = len(max(self.board.solutions, key=len)) self.starter = nick self.state = Worddle.State.PREGAME self.players = [] @@ -459,8 +457,8 @@ class Worddle(BaseGame): self._broadcast('chat', self.players, nick=nick, text=text) return guesses = set(map(str.lower, text.split())) - accepted = filter(lambda s: s in self.solutions, guesses) - rejected = filter(lambda s: s not in self.solutions, guesses) + accepted = filter(lambda s: s in self.board.solutions, guesses) + rejected = filter(lambda s: s not in self.board.solutions, guesses) if len(accepted) > 3: message = '%sGreat!%s' % (LGREEN, WHITE) elif len(accepted) > 0: @@ -484,7 +482,7 @@ class Worddle(BaseGame): self.players.append(nick) self.player_answers[nick] = set() if self.state == Worddle.State.ACTIVE: - self._display_board(nick, show_help=True) + self._display_board(nick) else: self._broadcast('players', [nick]) # Keep at least 5 seconds on the pre-game clock if someone joins @@ -503,7 +501,7 @@ class Worddle(BaseGame): pass def solve(self): - self.announce('Solutions: ' + ' '.join(sorted(self.solutions))) + self.announce('Solutions: ' + ' '.join(sorted(self.board.solutions))) def start(self): self.parent.start() @@ -523,12 +521,13 @@ class Worddle(BaseGame): def stats(self): assert self.state == Worddle.State.DONE points = 0 - for word in self.solutions: + for word in self.board.solutions: points += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) - longest_words = filter(lambda w: len(w) == self.longest_len, self.solutions) + longest_words = filter(lambda w: len(w) == self.longest_len, + self.board.solutions) self.announce(('There were %s%d%s possible words, with total point' ' value %s%d%s. The longest word%s: %s%s%s.') % - (WHITE, len(self.solutions), LGRAY, LGREEN, points, LGRAY, + (WHITE, len(self.board.solutions), LGRAY, LGREEN, points, LGRAY, ' was' if len(longest_words) == 1 else 's were', LCYAN, (LGRAY + ', ' + LCYAN).join(longest_words), LGRAY)) @@ -571,7 +570,7 @@ class Worddle(BaseGame): self.state = Worddle.State.ACTIVE self.start_time = time.time() self.end_time = self.start_time + self.duration - self._display_board(show_help=True) + self._display_board() self._schedule_next_event() def _schedule_next_event(self): @@ -643,34 +642,59 @@ class Worddle(BaseGame): self._broadcast('result', [self.channel], nick=result.player, verb=verb, points=score, words=words_text) - def _display_board(self, nick=None, show_help=False): + def _display_board(self, nick=None): "Display the board to everyone or just one nick if specified." commandChar = str(conf.supybot.reply.whenAddressedBy.chars)[0] help_msgs = [''] * Worddle.BOARD_SIZE help_msgs[1] = '%sLet\'s GO!' % (WHITE) help_msgs[2] = '%s%s%s seconds left!' % \ (LYELLOW, int(round(self.end_time - time.time())), LGRAY) - for i, row in enumerate(self.board): - text = LGREEN + ' ' + ' '.join(row) + ' ' - text = text.replace('Q ', 'Qu') - if show_help: - text += ' ' + help_msgs[i] - text = text.rstrip() + for row, help_msg in zip(self.board.render(), help_msgs): + text = ' %s %s' % (row, help_msg) if nick: self.announce_to(nick, text, now=True) else: self._broadcast_text(text, self.players + [self.channel], True) + def _generate_board(self): + "Generate several boards and return the most bountiful board." + attempts = 5 + wordtrie = Trie() + map(wordtrie.add, self.words) + boards = [WorddleBoard(wordtrie, Worddle.BOARD_SIZE) + for i in range(0, attempts)] + board_quality = lambda b: len(b.solutions) + return max(boards, key=board_quality) + +class WorddleBoard(object): + "Represents the board in a Worddle game." + + def __init__(self, wordtrie, n): + "Generate a new n x n Worddle board." + self.size = n + self.wordtrie = wordtrie + self.rows = self._generate_rows() + self.solutions = self._find_solutions() + + def render(self): + "Render the board for display in IRC as a list of strings." + result = [] + for row in self.rows: + text = LGREEN + ' '.join(row) + ' ' # Last space pad in case of Qu + text = text.replace('Q ', 'Qu') + result.append(text) + return result + def _find_solutions(self, visited=None, row=0, col=0, prefix=''): "Discover and return the set of all solutions for the current board." result = set() if visited == None: - for row in range(0, Worddle.BOARD_SIZE): - for col in range(0, Worddle.BOARD_SIZE): + for row in range(0, self.size): + for col in range(0, self.size): result.update(self._find_solutions([], row, col, '')) else: visited = visited + [(row, col)] - current = prefix + self.board[row][col].lower() + current = prefix + self.rows[row][col].lower() if current[-1] == 'q': current += 'u' node = self.wordtrie.find(current) if node: @@ -683,28 +707,24 @@ class Worddle(BaseGame): for offset in offsets: point = (row + offset[0], col + offset[1]) if point in visited: continue - if point[0] < 0 or point[0] >= Worddle.BOARD_SIZE: continue - if point[1] < 0 or point[1] >= Worddle.BOARD_SIZE: continue + if point[0] < 0 or point[0] >= self.size: continue + if point[1] < 0 or point[1] >= self.size: continue result.update(self._find_solutions( visited, point[0], point[1], current)) return result - def _generate_board(self): + def _generate_rows(self): "Randomly generate a Worddle board (a list of lists)." letters = reduce(add, (map(mul, Worddle.FREQUENCY_TABLE.keys(), Worddle.FREQUENCY_TABLE.values()))) - self.board = [] - values = random.sample(letters, Worddle.BOARD_SIZE**2) - for i in range(0, Worddle.BOARD_SIZE): - start = Worddle.BOARD_SIZE * i - end = start + Worddle.BOARD_SIZE - self.board.append(values[start:end]) - - def _generate_wordtrie(self): - "Populate self.wordtrie with the dictionary words." - self.wordtrie = Trie() - map(self.wordtrie.add, self.words) + rows = [] + values = random.sample(letters, self.size**2) + for i in range(0, self.size): + start = self.size * i + end = start + self.size + rows.append(values[start:end]) + return rows class WordChain(BaseGame): "Base class for word-chain games like WordShrink and WordTwist." From 142d61cbac1a7745b54faf1b1f95bf2b8f4f5bc2 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 23 Apr 2012 19:32:59 -0700 Subject: [PATCH 44/62] Handle WordChain puzzle build failures. Given certain dictionaries (e.g. scrabble), it can be hard or impossible to create a puzzle meeting the criteria of 'hard' or 'evil' WordShrink. --- plugin.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/plugin.py b/plugin.py index fc5ff66..c2c2bb7 100644 --- a/plugin.py +++ b/plugin.py @@ -763,12 +763,16 @@ class WordChain(BaseGame): self.build_word_map() def start(self): - self.parent.start() - happy = False # Build a puzzle - while not happy: + attempts = 100000 # Prevent infinite loops + while attempts: self.solution = [] while len(self.solution) < self.solution_length: + attempts -= 1 + if attempts == 0: + raise WordgamesError(('Unable to generate %s puzzle. This' + + ' is either a bug, or the word file is too small.') % + self.__class__.__name__) self.solution = [random.choice(self.words)] for i in range(1, self.solution_length): values = self.word_map[self.solution[-1]] @@ -787,12 +791,16 @@ class WordChain(BaseGame): if self.is_trivial_solution(solution): happy = False break + if happy: + break + if not happy: + raise WordgamesError(('Unable to generate %s puzzle meeting the ' + + 'game parameters. This is probably a bug.') % + self.__class__.__name__) + + # Start the game self.show() - # For debugging purposes - solution_set = set(map(lambda s: self._join_words(s), self.solutions)) - if len(solution_set) != len(self.solutions): - info('Oops, only %d of %d solutions are unique.' % - (len(solution_set), len(self.solutions))) + self.parent.start() def show(self): words = [self.solution[0]] From 6aacd441d0f21c55f3a52fadf4887920a7c7a955 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 23 Apr 2012 19:42:47 -0700 Subject: [PATCH 45/62] Make Wordshrink a little easier. The previous difficulty settings were borderline insane. --- plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin.py b/plugin.py index c2c2bb7..a4e9692 100644 --- a/plugin.py +++ b/plugin.py @@ -908,10 +908,10 @@ class WordShrink(WordChain): def __init__(self, words, irc, channel, difficulty): assert difficulty in ['easy', 'medium', 'hard', 'evil'], "Bad mojo." settings = { - 'easy': WordChain.Settings([4], range(3, 10), range(10, 100)), - 'medium': WordChain.Settings([5], range(4, 12), range(5, 12)), - 'hard': WordChain.Settings([6], range(5, 14), range(2, 5)), - 'evil': WordChain.Settings([7], range(6, 16), range(1, 3)), + 'easy': WordChain.Settings([4], range(3, 9), range(15, 100)), + 'medium': WordChain.Settings([5], range(4, 10), range(8, 25)), + 'hard': WordChain.Settings([6], range(4, 12), range(4, 12)), + 'evil': WordChain.Settings([7], range(4, 15), range(1, 10)), } super(WordShrink, self).__init__( words, irc, channel, settings[difficulty]) From 79093bffbcf5d585147a7a69811df0022a51aeab Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 23 Apr 2012 19:44:03 -0700 Subject: [PATCH 46/62] Use medium difficulty by default. --- README.md | 4 ++-- plugin.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1df32c7..8509b82 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ The following commands are exposed by this plugin: `wordshrink [difficulty]` -> Start a new WordShrink game. Difficulty values: [easy] medium hard evil +> Start a new WordShrink game. Difficulty values: easy [medium] hard evil `wordtwist [difficulty]` -> Start a new WordTwist game. Difficulty values: [easy] medium hard evil +> Start a new WordTwist game. Difficulty values: easy [medium] hard evil `wordquit` diff --git a/plugin.py b/plugin.py index a4e9692..c5d13ae 100644 --- a/plugin.py +++ b/plugin.py @@ -175,7 +175,7 @@ class Wordgames(callbacks.Plugin): wordle = worddle def wordshrink(self, irc, msgs, args, channel, difficulty): - """[easy|medium|hard|evil] (default: easy) + """[easy|medium|hard|evil] (default: medium) Start a word-shrink game. Make new words by dropping one letter from the previous word and rearranging the remaining letters. @@ -185,10 +185,10 @@ class Wordgames(callbacks.Plugin): else: self._start_game(WordShrink, irc, channel, difficulty) wordshrink = wrap(wordshrink, - ['channel', optional('somethingWithoutSpaces', 'easy')]) + ['channel', optional('somethingWithoutSpaces', 'medium')]) def wordtwist(self, irc, msgs, args, channel, difficulty): - """[easy|medium|hard|evil] (default: easy) + """[easy|medium|hard|evil] (default: medium) Start a word-twist game. Make new words by changing one letter in the previous word. @@ -198,7 +198,7 @@ class Wordgames(callbacks.Plugin): else: self._start_game(WordTwist, irc, channel, difficulty) wordtwist = wrap(wordtwist, - ['channel', optional('somethingWithoutSpaces', 'easy')]) + ['channel', optional('somethingWithoutSpaces', 'medium')]) def wordquit(self, irc, msgs, args, channel): """(takes no arguments) From 0dc36d83b5a6d2587d2b5a8f03de4989475a9c36 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 23 Apr 2012 23:46:47 -0700 Subject: [PATCH 47/62] Delete game if starting fails. Can't be sure what kind of state the game is in, so it shouldn't be left around (receiving handle_message calls, for example). --- plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin.py b/plugin.py index c5d13ae..69a4e8a 100644 --- a/plugin.py +++ b/plugin.py @@ -241,6 +241,8 @@ class Wordgames(callbacks.Plugin): self.games[channel] = Game(words, irc, channel, *args, **kwargs) self.games[channel].start() except WordgamesError, e: + # Get rid of the game in case it's in an indeterminate state + del self.games[channel] irc.reply('Wordgames error: %s' % str(e)) irc.reply('Please check the configuration and try again. ' + 'See README for help.') From 357d31490d13ee7842fe497980c39dc579cc7246 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Mon, 23 Apr 2012 23:48:19 -0700 Subject: [PATCH 48/62] Update game state on @worddle stop. Otherwise subsequent calls to @worddle stats would throw an exception. --- plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin.py b/plugin.py index 69a4e8a..532c4d3 100644 --- a/plugin.py +++ b/plugin.py @@ -513,6 +513,7 @@ class Worddle(BaseGame): def stop(self, now=False): self.parent.stop() + self.state = Worddle.State.DONE try: schedule.removeEvent(self.event_name) except KeyError: From 68dbff765c25cab8acdd3a40015cd2690b71e6a0 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 24 Apr 2012 17:07:13 -0700 Subject: [PATCH 49/62] Compact color codes in Worddle results. Previously every word would be LCYAN+word+LGRAY (or GRAY+word+LGRAY) and all these extra color codes would cause the message to be truncated when a lot of words are found. Compacted by only sending a color code when a color change is needed. --- plugin.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugin.py b/plugin.py index 532c4d3..ba3cedb 100644 --- a/plugin.py +++ b/plugin.py @@ -391,17 +391,20 @@ class Worddle(BaseGame): "Return the words in this result, colorized appropriately." words = sorted(list(self.unique) + list(self.dup)) words_text = '' + last_color = LGRAY for word in words: - if word in self.unique: - color = LCYAN - else: - color = GRAY + color = LCYAN if word in self.unique else GRAY + if color != last_color: + words_text += color + last_color = color if len(word) == longest_len: word += LYELLOW + '*' - words_text += '%s%s%s ' % (color, word, LGRAY) + last_color = LYELLOW + words_text += '%s ' % word if not words_text: - words_text = '%s-none-%s' % (GRAY, LGRAY) - return words_text.strip() + words_text = '%s-none-' % (GRAY) + words_text = words_text.strip() + LGRAY + return words_text class Results: "Represents results for all players." From 94632a6bcf4b71a272fe01263625868320c4bb5e Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 27 Apr 2012 18:22:26 -0700 Subject: [PATCH 50/62] Optimize prefix trie used in Worddle. This makes the code a little uglier, but cuts time and memory in half when building a new Worddle board. Also updated the boards to not save a reference to the wordtrie, so after generating the board, it's free for garbage collection. --- plugin.py | 14 +++--- trie.py | 129 +++++++++++++++++++++++++++++++++++------------------- 2 files changed, 92 insertions(+), 51 deletions(-) diff --git a/plugin.py b/plugin.py index ba3cedb..d04c551 100644 --- a/plugin.py +++ b/plugin.py @@ -678,9 +678,8 @@ class WorddleBoard(object): def __init__(self, wordtrie, n): "Generate a new n x n Worddle board." self.size = n - self.wordtrie = wordtrie self.rows = self._generate_rows() - self.solutions = self._find_solutions() + self.solutions = self._find_solutions(wordtrie) def render(self): "Render the board for display in IRC as a list of strings." @@ -691,20 +690,21 @@ class WorddleBoard(object): result.append(text) return result - def _find_solutions(self, visited=None, row=0, col=0, prefix=''): + def _find_solutions(self, wordtrie, visited=None, row=0, col=0, prefix=''): "Discover and return the set of all solutions for the current board." result = set() if visited == None: for row in range(0, self.size): for col in range(0, self.size): - result.update(self._find_solutions([], row, col, '')) + result.update( + self._find_solutions(wordtrie, [], row, col, '')) else: visited = visited + [(row, col)] current = prefix + self.rows[row][col].lower() if current[-1] == 'q': current += 'u' - node = self.wordtrie.find(current) + node = wordtrie.find_prefix(current) if node: - if node.complete and len(current) > 2: + if node['*'] and len(current) > 2: result.add(current) # Explore all 8 directions out from here offsets = [(-1, -1), (-1, 0), (-1, 1), @@ -716,7 +716,7 @@ class WorddleBoard(object): if point[0] < 0 or point[0] >= self.size: continue if point[1] < 0 or point[1] >= self.size: continue result.update(self._find_solutions( - visited, point[0], point[1], current)) + wordtrie, visited, point[0], point[1], current)) return result def _generate_rows(self): diff --git a/trie.py b/trie.py index 8b786ec..5ee51ec 100644 --- a/trie.py +++ b/trie.py @@ -4,64 +4,105 @@ Quick & dirty prefix tree (aka trie). """ +# This got a little uglier because using a nice object-oriented approach took +# too much time and memory on big trees. Now every node is simply a dict, +# with the special '*' field meaning that the word is complete at that node. + class Trie(object): def __init__(self): - self.complete = False # Does this node complete a valid word? - self.children = {} + self.contents = {'*': False} - def add(self, value): + def add(self, value, contents=None): + if contents is None: + contents = self.contents if not value: - self.complete = True + contents['*'] = True return prefix = value[0] remainder = value[1:] - node = self.children.get(prefix, None) - if not node: - node = Trie() - self.children[prefix] = node - node.add(remainder) + child_contents = contents.get(prefix, None) + if not child_contents: + child_contents = {'*': False} + contents[prefix] = child_contents + self.add(remainder, child_contents) def find(self, value): - "Return the node associated with a value (None if not found)." - if not value: - return self - node = self.children.get(value[0], None) - if not node: - return None - return node.find(value[1:]) + "Return true if the value appears, false otherwise." + x = self.find_prefix(value) + return x and x['*'] == True - def dump(self, indent=0): - for key in sorted(self.children.keys()): + def find_prefix(self, value, contents=None): + "Return true if the given prefix appears in the tree." + if contents is None: + contents = self.contents + if not value: + return contents + child_contents = contents.get(value[0], None) + if not child_contents: + return None + return self.find_prefix(value[1:], child_contents) + + def dump(self, indent=0, contents=None): + "Dump the trie to stdout." + if contents is None: + contents = self.contents + for key in sorted(contents.keys()): + if key == '*': + continue text = indent * ' ' text += key - node = self.children[key] - if node.complete: + child_contents = contents[key] + if child_contents['*']: text += '*' print(text) - node.dump(indent+2) + self.dump(indent+2, child_contents) if __name__ == '__main__': - t = Trie() - t.add('hell') - t.add('hello') - t.add('he') - t.add('world') - t.add('alphabet') - t.add('foo') - t.add('food') - t.add('foodie') - t.add('bar') - t.add('alphanumeric') - t.dump() + import resource + import sys + import time - assert t.find('r') is None - assert t.find('bars') is None - assert not t.find('hel').complete - assert t.find('hell').complete - assert t.find('hello').complete - assert not t.find('f').complete - assert not t.find('fo').complete - assert t.find('foo').complete - assert t.find('food').complete - assert not t.find('foodi').complete - assert t.find('foodie').complete + if '--perf' in sys.argv: + # Performance test, last arg should be input file + start = time.time() + t = Trie() + f = open(sys.argv[-1], 'r') + for line in f: + t.add(line.strip()) + mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + elapsed = time.time() - start + print('Trie created in %g seconds.' % elapsed) + print('Used %dMB RAM.' % (mem/1024)) + else: + # Regular sanity test + t = Trie() + t.add('hell') + t.add('hello') + t.add('he') + t.add('world') + t.add('alphabet') + t.add('foo') + t.add('food') + t.add('foodie') + t.add('bar') + t.add('alphanumeric') + t.dump() + + assert not t.find('h') + assert t.find('he') + assert not t.find('hel') + assert t.find('hell') + assert t.find('hello') + assert not t.find('r') + assert t.find('world') + assert not t.find('ba') + assert t.find('bar') + assert t.find('alphabet') + assert t.find('alphanumeric') + assert not t.find('alpha') + assert not t.find('f') + assert not t.find('fo') + assert t.find('foo') + assert t.find('food') + assert not t.find('foodi') + assert t.find('foodie') From 6648d546e4c4c89d52a2d4a074186d2eb0dee76a Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 2 May 2012 00:51:00 -0700 Subject: [PATCH 51/62] Remove debugging code. --- plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugin.py b/plugin.py index d04c551..c20be12 100644 --- a/plugin.py +++ b/plugin.py @@ -74,14 +74,13 @@ def get_max_targets(irc): # Inspircd if 'MAXTARGETS' in irc.state.supported: result = int(irc.state.supported['MAXTARGETS']) - # FreenodeA (ircd-seven) + # Freenode (ircd-seven) elif 'TARGMAX' in irc.state.supported: # TARGMAX looks like "...,WHOIS:1,PRIVMSG:4,NOTICE:4,..." regexp = r'.*PRIVMSG:(\d+).*' match = re.match(regexp, irc.state.supported['TARGMAX']) if match: result = int(match.group(1)) - print 'Determined max targets:', result else: debug('Unable to find max targets, using default (1).') except Exception, e: From eb8ca3fb60031a87160f95267c73e34b38fa597b Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 2 May 2012 01:27:15 -0700 Subject: [PATCH 52/62] Customize Worddle min word length. Instead of a hard-coded 3, you can set a configuration value or pass a --min=N parameter to the start command. --- README.md | 17 +++++++++++++---- config.py | 5 +++++ plugin.py | 37 ++++++++++++++++++++++++++----------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8509b82..6a0e6ba 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,13 @@ The following commands are exposed by this plugin: `worddle [start|join|stop|stats]` -> Start a new Worddle game, join an existing game, or stop the current game. -> `start` is the default if nothing is specified. `stop` is an alias for -> @wordquit, added for ease of use. Use 'stats' to see a few bits of -> information about the board after the game. +> Play a Worddle game. Use the following subcommands: +> +> start (Default) Start a new Worddle game, optional arguments: +> --min=N minimum acceptable word length (overrides config) +> join Join a running game +> stop Stop a currently running game (alias for @wordquit) +> stats Display some post-game statistics about the board `wordshrink [difficulty]` @@ -146,6 +149,12 @@ Configuration Variables > > Default: `90` +`plugins.Wordgames.worddleMinLength` + +> The minimum length of a word that will be accepted in Worddle. +> +> Default: `3` + A Technical Note About Worddle ------------------------------ diff --git a/config.py b/config.py index 55655a0..e025a32 100644 --- a/config.py +++ b/config.py @@ -54,4 +54,9 @@ conf.registerGlobalValue(Wordgames, 'worddleDuration', registry.NonNegativeInteger(90, 'Duration (in seconds) of a Worddle game ' + '(not including the initial delay).')) + +conf.registerGlobalValue(Wordgames, 'worddleMinLength', + registry.PositiveInteger(3, 'Minimum length of an acceptable word in a ' + + 'Worddle game.')) + # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugin.py b/plugin.py index c20be12..0048747 100644 --- a/plugin.py +++ b/plugin.py @@ -137,13 +137,12 @@ class Wordgames(callbacks.Plugin): irc.reply('No game is currently running.') wordsolve = wrap(wordsolve, ['channel']) - def worddle(self, irc, msgs, args, channel, command): + def worddle(self, irc, msgs, args, channel, command, extra_args): """[command] Play a Worddle game. Commands: [start|join|stop|stats] (default: start). """ - delay = self.registryValue('worddleDelay') - duration = self.registryValue('worddleDuration') + extra_args = extra_args.split() if command == 'join': game = self.games.get(channel) if game and game.is_running(): @@ -154,7 +153,11 @@ class Wordgames(callbacks.Plugin): else: irc.reply('No game is currently running.') elif command == 'start': - self._start_game(Worddle, irc, channel, msgs.nick, delay, duration) + delay = self.registryValue('worddleDelay') + duration = self.registryValue('worddleDuration') + min_length = self.registryValue('worddleMinLength') + self._start_game(Worddle, irc, channel, msgs.nick, + delay, duration, min_length, extra_args) elif command == 'stop': # Alias for @wordquit self._stop_game(irc, channel) @@ -169,7 +172,8 @@ class Wordgames(callbacks.Plugin): else: irc.reply('Unrecognized command to worddle.') worddle = wrap(worddle, - ['channel', optional('somethingWithoutSpaces', 'start')]) + ['channel', optional('somethingWithoutSpaces', 'start'), + optional('text', '')]) # Alias for misspelling of the game name wordle = worddle @@ -433,16 +437,19 @@ class Worddle(BaseGame): def sorted_results(self): return sorted(self.player_results.values(), reverse=True) - def __init__(self, words, irc, channel, nick, delay, duration): + def __init__(self, words, irc, channel, nick, delay, duration, min_length, + extra_args): # See tech note in the Wordgames class. self.parent = super(Worddle, self) self.parent.__init__(words, irc, channel) - self.board = self._generate_board() self.delay = delay self.duration = duration + self.min_length = min_length + self.max_targets = get_max_targets(irc) + self._handle_args(extra_args) + self.board = self._generate_board() self.event_name = 'Worddle.%d' % id(self) self.init_time = time.time() - self.max_targets = get_max_targets(irc) self.longest_len = len(max(self.board.solutions, key=len)) self.starter = nick self.state = Worddle.State.PREGAME @@ -566,6 +573,13 @@ class Worddle(BaseGame): formatted = Worddle.MESSAGES[name] % kwargs self._broadcast_text(formatted, recipients, now) + def _handle_args(self, extra_args): + for arg in extra_args: + if arg.startswith('--min='): + self.min_length = int(arg[6:]) + else: + raise WordgamesError('Unrecognized argument: %s' % arg) + def _get_ready(self): self.state = Worddle.State.READY self._broadcast('ready', now=True) @@ -666,7 +680,7 @@ class Worddle(BaseGame): attempts = 5 wordtrie = Trie() map(wordtrie.add, self.words) - boards = [WorddleBoard(wordtrie, Worddle.BOARD_SIZE) + boards = [WorddleBoard(wordtrie, Worddle.BOARD_SIZE, self.min_length) for i in range(0, attempts)] board_quality = lambda b: len(b.solutions) return max(boards, key=board_quality) @@ -674,9 +688,10 @@ class Worddle(BaseGame): class WorddleBoard(object): "Represents the board in a Worddle game." - def __init__(self, wordtrie, n): + def __init__(self, wordtrie, n, min_length): "Generate a new n x n Worddle board." self.size = n + self.min_length = min_length self.rows = self._generate_rows() self.solutions = self._find_solutions(wordtrie) @@ -703,7 +718,7 @@ class WorddleBoard(object): if current[-1] == 'q': current += 'u' node = wordtrie.find_prefix(current) if node: - if node['*'] and len(current) > 2: + if node['*'] and len(current) >= self.min_length: result.add(current) # Explore all 8 directions out from here offsets = [(-1, -1), (-1, 0), (-1, 1), From c8af98c40802a104fb81f760e8d8aba31b702add Mon Sep 17 00:00:00 2001 From: Ben Schomp Date: Mon, 14 May 2012 22:11:22 -0400 Subject: [PATCH 53/62] Simplified Worddle invocation, indicate level/min length in private window if not default. --- plugin.py | 70 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/plugin.py b/plugin.py index 0048747..d1f7d24 100644 --- a/plugin.py +++ b/plugin.py @@ -137,27 +137,30 @@ class Wordgames(callbacks.Plugin): irc.reply('No game is currently running.') wordsolve = wrap(wordsolve, ['channel']) - def worddle(self, irc, msgs, args, channel, command, extra_args): + def worddle(self, irc, msgs, args, channel, command): """[command] - Play a Worddle game. Commands: [start|join|stop|stats] (default: start). + Play a Worddle game. Commands: [easy|medium|hard|evil | stop|stats] + (default: easy). """ - extra_args = extra_args.split() - if command == 'join': + level = None + if command in ['easy', 'medium', 'hard', 'evil']: + level = command + command = None + + if not command: game = self.games.get(channel) if game and game.is_running(): if game.__class__ == Worddle: - game.join(msgs.nick) + game.join(msgs.nick, level) else: irc.reply('Current word game is not Worddle!') else: - irc.reply('No game is currently running.') - elif command == 'start': - delay = self.registryValue('worddleDelay') - duration = self.registryValue('worddleDuration') - min_length = self.registryValue('worddleMinLength') - self._start_game(Worddle, irc, channel, msgs.nick, - delay, duration, min_length, extra_args) + delay = self.registryValue('worddleDelay') + duration = self.registryValue('worddleDuration') + min_length = self.registryValue('worddleMinLength') + self._start_game(Worddle, irc, channel, msgs.nick, + delay, duration, min_length, level) elif command == 'stop': # Alias for @wordquit self._stop_game(irc, channel) @@ -172,8 +175,8 @@ class Wordgames(callbacks.Plugin): else: irc.reply('Unrecognized command to worddle.') worddle = wrap(worddle, - ['channel', optional('somethingWithoutSpaces', 'start'), - optional('text', '')]) + ['channel', optional(('literal', + ['easy', 'medium', 'hard', 'evil', 'stop', 'stats']))]) # Alias for misspelling of the game name wordle = worddle @@ -358,13 +361,15 @@ class Worddle(BaseGame): 'point%%(plural)s (%%(words)s)') % (WHITE, LGRAY, LGREEN, LGRAY), 'startup': ('Starting in %%(seconds)d seconds, ' + - 'use "%s%%(commandChar)sworddle join%s" to play!') % + 'use "%s%%(commandChar)sworddle%s" to play!') % (WHITE, LGRAY), 'stopped': 'Game stopped.', 'warning': '%s%%(seconds)d%s seconds remaining...' % (LYELLOW, LGRAY), - 'welcome1': '--- %sNew Game%s ---' % (WHITE, LGRAY), + 'welcome1': '--- %sNew Game %%(min_msg)s%s---' % (WHITE, LGRAY), 'welcome2': ('%s%%(nick)s%s, write your answers here, e.g.: ' + 'cat dog ...') % (WHITE, LGRAY), + 'ignorelevel': '%s(Joined existing game, ignored \'%s%%(level)s%s\')' + % (GRAY, LGRAY, GRAY), } class State: @@ -438,15 +443,16 @@ class Worddle(BaseGame): return sorted(self.player_results.values(), reverse=True) def __init__(self, words, irc, channel, nick, delay, duration, min_length, - extra_args): + level): # See tech note in the Wordgames class. self.parent = super(Worddle, self) self.parent.__init__(words, irc, channel) self.delay = delay self.duration = duration self.min_length = min_length + self.min_msg = '' self.max_targets = get_max_targets(irc) - self._handle_args(extra_args) + self._handle_level(level) self.board = self._generate_board() self.event_name = 'Worddle.%d' % id(self) self.init_time = time.time() @@ -483,11 +489,15 @@ class Worddle(BaseGame): message += ' (not accepted: %s)' % ' '.join(sorted(rejected)) self.send_to(nick, message) - def join(self, nick): + def join(self, nick, level=None): assert self.is_running() assert self.state != Worddle.State.DONE if nick not in self.players: - self._broadcast('welcome1', [nick], now=True, nick=nick) + self._broadcast('welcome1', [nick], now=True, + min_msg=self.min_msg) + if level: + self._broadcast('ignorelevel', [nick], now=True, nick=nick, + level=level) self._broadcast('welcome2', [nick], now=True, nick=nick) self._broadcast('joined', self.players, nick=nick) self.players.append(nick) @@ -573,12 +583,20 @@ class Worddle(BaseGame): formatted = Worddle.MESSAGES[name] % kwargs self._broadcast_text(formatted, recipients, now) - def _handle_args(self, extra_args): - for arg in extra_args: - if arg.startswith('--min='): - self.min_length = int(arg[6:]) - else: - raise WordgamesError('Unrecognized argument: %s' % arg) + def _handle_level(self, level): + if level == None: + return + elif level == 'easy': + self.min_length = 3 + elif level == 'medium': + self.min_length = 4 + elif level == 'hard': + self.min_length = 5 + elif level == 'evil': + self.min_length = 6 + else: + raise WordgamesError('Unrecognized level: %s' % level) + self.min_msg = '(min: %s) ' % self.min_length def _get_ready(self): self.state = Worddle.State.READY From 9cd87ee22e03d06db64e3fa939128454d2e3d7a7 Mon Sep 17 00:00:00 2001 From: Ben Schomp Date: Tue, 15 May 2012 22:12:48 -0400 Subject: [PATCH 54/62] update readme --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6a0e6ba..6248342 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,19 @@ Commands The following commands are exposed by this plugin: -`worddle [start|join|stop|stats]` +`worddle [easy|medium|hard|evil | stop|stats]` -> Play a Worddle game. Use the following subcommands: +> Play a Worddle game. With no specified command, a default game +> will start (default is set in the config, usually 'easy' or a +> min word length of 3). If a game is already started, you will join +> the game in progress. > -> start (Default) Start a new Worddle game, optional arguments: -> --min=N minimum acceptable word length (overrides config) -> join Join a running game +> In addition, you may Use the following subcommands: +> +> easy minimum acceptable word length: 3 (overrides config) +> medium minimum acceptable word length: 4 " " +> hard minimum acceptable word length: 5 " " +> evil minimum acceptable word length: 6 " " > stop Stop a currently running game (alias for @wordquit) > stats Display some post-game statistics about the board @@ -60,7 +66,7 @@ where the game was started. To be a valid guess, words must: * be made of adjacent letters on the board (in all 8 directions, diagonals ok) -* be at least 3 letters in length +* be at least 3 letters in length (or 4, or 5, etc. depending on the level) * appear in the dictionary file. At the end of the game, if a word was found by multiple players, it is not From 444ea4d932c26b5357bea12e27337f834b4b9b27 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 16 May 2012 17:30:54 -0700 Subject: [PATCH 55/62] SImplify Worddle difficulty behavior. * Created a Difficulty class to abstract easy/medium/hard/evil (DRY). * Changed configuration setting from min length to difficulty. * Tolerate the deprecated 'join' subcommand to worddle. * Improve game messaging to player. * Updated documentation. --- README.md | 15 +++--- config.py | 5 +- plugin.py | 144 ++++++++++++++++++++++++++++++------------------------ 3 files changed, 89 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 6248342..85d792e 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,11 @@ The following commands are exposed by this plugin: `worddle [easy|medium|hard|evil | stop|stats]` -> Play a Worddle game. With no specified command, a default game -> will start (default is set in the config, usually 'easy' or a -> min word length of 3). If a game is already started, you will join -> the game in progress. +> Play a Worddle game. With no argument, a new game will start with the +> default difficulty (see worddleDifficulty in the Configuration section). +> If a game is already started, you will join the game in progress. > -> In addition, you may Use the following subcommands: +> In addition, you may use the following subcommands: > > easy minimum acceptable word length: 3 (overrides config) > medium minimum acceptable word length: 4 " " @@ -155,11 +154,11 @@ Configuration Variables > > Default: `90` -`plugins.Wordgames.worddleMinLength` +`plugins.Wordgames.worddleDifficulty` -> The minimum length of a word that will be accepted in Worddle. +> The default difficulty for a Worddle game. > -> Default: `3` +> Default: `easy` (words must be 3 letters or longer) A Technical Note About Worddle ------------------------------ diff --git a/config.py b/config.py index e025a32..ba9b9f2 100644 --- a/config.py +++ b/config.py @@ -55,8 +55,7 @@ conf.registerGlobalValue(Wordgames, 'worddleDuration', 'Duration (in seconds) of a Worddle game ' + '(not including the initial delay).')) -conf.registerGlobalValue(Wordgames, 'worddleMinLength', - registry.PositiveInteger(3, 'Minimum length of an acceptable word in a ' + - 'Worddle game.')) +conf.registerGlobalValue(Wordgames, 'worddleDifficulty', + registry.String('easy', 'Default difficulty for Worddle games.')) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugin.py b/plugin.py index d1f7d24..6d1a606 100644 --- a/plugin.py +++ b/plugin.py @@ -42,6 +42,7 @@ DEBUG = False WHITE = '\x0300' GREEN = '\x0303' +LRED = '\x0304' RED = '\x0305' YELLOW = '\x0307' LYELLOW = '\x0308' @@ -89,6 +90,26 @@ def get_max_targets(irc): class WordgamesError(Exception): pass +class Difficulty: + EASY = 0 + MEDIUM = 1 + HARD = 2 + EVIL = 3 + + VALUES = [EASY, MEDIUM, HARD, EVIL] + NAMES = ['easy', 'medium', 'hard', 'evil'] + + @staticmethod + def name(value): + return Difficulty.NAMES[value] + + @staticmethod + def value(name): + try: + return Difficulty.VALUES[Difficulty.NAMES.index(name)] + except ValueError: + raise WordgamesError('Unrecognized difficulty value: %s' % name) + class Wordgames(callbacks.Plugin): "Please see the README file to configure and use this plugin." @@ -141,42 +162,49 @@ class Wordgames(callbacks.Plugin): """[command] Play a Worddle game. Commands: [easy|medium|hard|evil | stop|stats] - (default: easy). + (default: start with configured difficulty). """ - level = None - if command in ['easy', 'medium', 'hard', 'evil']: - level = command - command = None - - if not command: - game = self.games.get(channel) - if game and game.is_running(): - if game.__class__ == Worddle: - game.join(msgs.nick, level) + try: + # Allow deprecated 'join' command: + if not command or command == 'join' or command in Difficulty.NAMES: + difficulty = Difficulty.value( + self.registryValue('worddleDifficulty')) + if command in Difficulty.NAMES: + difficulty = Difficulty.value(command) + game = self.games.get(channel) + if game and game.is_running(): + if game.__class__ == Worddle: + if command: + irc.reply('Joining the game. (Ignored "%s".)' % + command) + game.join(msgs.nick) + else: + irc.reply('Current word game is not Worddle!') else: - irc.reply('Current word game is not Worddle!') + delay = self.registryValue('worddleDelay') + duration = self.registryValue('worddleDuration') + self._start_game(Worddle, irc, channel, msgs.nick, + delay, duration, difficulty) + elif command == 'stop': + # Alias for @wordquit + self._stop_game(irc, channel) + elif command == 'stats': + game = self.games.get(channel) + if not game or game.__class__ != Worddle: + irc.reply('No Worddle game available for stats.') + elif game.is_running(): + irc.reply('Please wait until the game finishes.') + else: + game.stats() else: - delay = self.registryValue('worddleDelay') - duration = self.registryValue('worddleDuration') - min_length = self.registryValue('worddleMinLength') - self._start_game(Worddle, irc, channel, msgs.nick, - delay, duration, min_length, level) - elif command == 'stop': - # Alias for @wordquit - self._stop_game(irc, channel) - elif command == 'stats': - game = self.games.get(channel) - if not game or game.__class__ != Worddle: - irc.reply('No Worddle game available for stats.') - elif game.is_running(): - irc.reply('Please wait until the game finishes.') - else: - game.stats() - else: - irc.reply('Unrecognized command to worddle.') + irc.reply('Unrecognized command to worddle.') + except WordgamesError, e: + irc.reply('Wordgames error: %s' % str(e)) + irc.reply('Please check the configuration and try again. ' + + 'See README for help.') worddle = wrap(worddle, ['channel', optional(('literal', - ['easy', 'medium', 'hard', 'evil', 'stop', 'stats']))]) + Difficulty.NAMES + ['join', 'stop', 'stats']))]) # Alias for misspelling of the game name wordle = worddle @@ -248,7 +276,7 @@ class Wordgames(callbacks.Plugin): self.games[channel].start() except WordgamesError, e: # Get rid of the game in case it's in an indeterminate state - del self.games[channel] + if channel in self.games: del self.games[channel] irc.reply('Wordgames error: %s' % str(e)) irc.reply('Please check the configuration and try again. ' + 'See README for help.') @@ -352,9 +380,9 @@ class Worddle(BaseGame): MESSAGES = { 'chat': '%s%%(nick)s%s says: %%(text)s' % (WHITE, LGRAY), 'joined': '%s%%(nick)s%s joined the game.' % (WHITE, LGRAY), - 'gameover': ("%sTime's up!%s You got %s%%(points)d%s point%%(plural)s! " - + "Check %s%%(channel)s%s for complete results.") % - (WHITE, LGRAY, LGREEN, LGRAY, WHITE, LGRAY), + 'gameover': ("%s::: Time's Up :::%s Check %s%%(channel)s%s " + + "for results.") % + (LRED, LGRAY, WHITE, LGRAY), 'players': 'Current Players: %(players)s', 'ready': '%sGet Ready!' % WHITE, 'result': ('%s%%(nick)s%s %%(verb)s %s%%(points)d%s ' + @@ -365,11 +393,11 @@ class Worddle(BaseGame): (WHITE, LGRAY), 'stopped': 'Game stopped.', 'warning': '%s%%(seconds)d%s seconds remaining...' % (LYELLOW, LGRAY), - 'welcome1': '--- %sNew Game %%(min_msg)s%s---' % (WHITE, LGRAY), + 'welcome1': ('%s::: New Game :::%s (%s%%(difficulty)s%s: ' + + '%s%%(min_length)d%s letters or longer)') % + (LGREEN, LGRAY, WHITE, LGRAY, WHITE, LGRAY), 'welcome2': ('%s%%(nick)s%s, write your answers here, e.g.: ' + 'cat dog ...') % (WHITE, LGRAY), - 'ignorelevel': '%s(Joined existing game, ignored \'%s%%(level)s%s\')' - % (GRAY, LGRAY, GRAY), } class State: @@ -442,17 +470,15 @@ class Worddle(BaseGame): def sorted_results(self): return sorted(self.player_results.values(), reverse=True) - def __init__(self, words, irc, channel, nick, delay, duration, min_length, - level): + def __init__(self, words, irc, channel, nick, delay, duration, difficulty): # See tech note in the Wordgames class. self.parent = super(Worddle, self) self.parent.__init__(words, irc, channel) self.delay = delay self.duration = duration - self.min_length = min_length - self.min_msg = '' + self.difficulty = difficulty self.max_targets = get_max_targets(irc) - self._handle_level(level) + self._handle_difficulty() self.board = self._generate_board() self.event_name = 'Worddle.%d' % id(self) self.init_time = time.time() @@ -489,15 +515,13 @@ class Worddle(BaseGame): message += ' (not accepted: %s)' % ' '.join(sorted(rejected)) self.send_to(nick, message) - def join(self, nick, level=None): + def join(self, nick): assert self.is_running() assert self.state != Worddle.State.DONE if nick not in self.players: self._broadcast('welcome1', [nick], now=True, - min_msg=self.min_msg) - if level: - self._broadcast('ignorelevel', [nick], now=True, nick=nick, - level=level) + difficulty=Difficulty.name(self.difficulty), + min_length=self.min_length) self._broadcast('welcome2', [nick], now=True, nick=nick) self._broadcast('joined', self.players, nick=nick) self.players.append(nick) @@ -583,20 +607,13 @@ class Worddle(BaseGame): formatted = Worddle.MESSAGES[name] % kwargs self._broadcast_text(formatted, recipients, now) - def _handle_level(self, level): - if level == None: - return - elif level == 'easy': - self.min_length = 3 - elif level == 'medium': - self.min_length = 4 - elif level == 'hard': - self.min_length = 5 - elif level == 'evil': - self.min_length = 6 - else: - raise WordgamesError('Unrecognized level: %s' % level) - self.min_msg = '(min: %s) ' % self.min_length + def _handle_difficulty(self): + self.min_length = { + Difficulty.EASY: 3, + Difficulty.MEDIUM: 4, + Difficulty.HARD: 5, + Difficulty.EVIL: 6, + }[self.difficulty] def _get_ready(self): self.state = Worddle.State.READY @@ -658,8 +675,7 @@ class Worddle(BaseGame): # Notify players for result in results.player_results.values(): - self._broadcast('gameover', [result.player], now=True, - points=result.get_score()) + self._broadcast('gameover', [result.player], now=True) # Announce results player_results = results.sorted_results() From 5ab9ee8163863e124551e087c5ca814e7f7351c7 Mon Sep 17 00:00:00 2001 From: Ben Schomp Date: Fri, 18 May 2012 12:43:04 -0400 Subject: [PATCH 56/62] Stick with msg themes, coloring 'Game Stopped' msg red in private channels. --- plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 6d1a606..9a6d5c0 100644 --- a/plugin.py +++ b/plugin.py @@ -392,6 +392,7 @@ class Worddle(BaseGame): 'use "%s%%(commandChar)sworddle%s" to play!') % (WHITE, LGRAY), 'stopped': 'Game stopped.', + 'stopped2': ('%s::: Game Stopped :::%s') % (LRED, LGRAY), 'warning': '%s%%(seconds)d%s seconds remaining...' % (LYELLOW, LGRAY), 'welcome1': ('%s::: New Game :::%s (%s%%(difficulty)s%s: ' + '%s%%(min_length)d%s letters or longer)') % @@ -562,7 +563,8 @@ class Worddle(BaseGame): except KeyError: pass if not now: - self._broadcast('stopped', self.players + [self.channel]) + self._broadcast('stopped', [self.channel]) + self._broadcast('stopped2', self.players) def stats(self): assert self.state == Worddle.State.DONE From 158debccb77adbd2aa7522a50492142f81e3e48d Mon Sep 17 00:00:00 2001 From: Ben Schomp Date: Fri, 18 May 2012 13:37:51 -0400 Subject: [PATCH 57/62] stop giving a single player with 0 points credit for a 'win' --- plugin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugin.py b/plugin.py index 9a6d5c0..9a896fd 100644 --- a/plugin.py +++ b/plugin.py @@ -686,13 +686,12 @@ class Worddle(BaseGame): player_results[1].get_score() == high_score for result in player_results: score = result.get_score() + verb = "got" if score == high_score: if tie: verb = "%stied%s with" % (LYELLOW, LGRAY) - else: + elif high_score > 0: verb = "%swins%s with" % (LGREEN, LGRAY) - else: - verb = "got" words_text = result.render_words(longest_len=self.longest_len) self._broadcast('result', [self.channel], nick=result.player, verb=verb, points=score, words=words_text) From cb83dda812c7b4d8e1f581fb1c4ecb7c0781cddd Mon Sep 17 00:00:00 2001 From: Gordon Shumway <39967334+oddluck@users.noreply.github.com> Date: Sun, 24 Feb 2019 15:17:41 -0500 Subject: [PATCH 58/62] python3 compatible --- plugin.py | 72 +++++++++++++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/plugin.py b/plugin.py index 9a896fd..faab345 100644 --- a/plugin.py +++ b/plugin.py @@ -36,7 +36,8 @@ import supybot.schedule as schedule import supybot.log as log import supybot.world as world -from trie import Trie +from .trie import Trie +from functools import reduce DEBUG = False @@ -84,7 +85,7 @@ def get_max_targets(irc): result = int(match.group(1)) else: debug('Unable to find max targets, using default (1).') - except Exception, e: + except Exception as e: error('Detecting max targets: %s. Using default (1).' % str(e)) return result @@ -137,7 +138,7 @@ class Wordgames(callbacks.Plugin): self.games = {} def die(self): - for channel, game in self.games.iteritems(): + for channel, game in self.games.items(): if game.is_running(): game.stop(now=True) self.parent.die() @@ -198,7 +199,7 @@ class Wordgames(callbacks.Plugin): game.stats() else: irc.reply('Unrecognized command to worddle.') - except WordgamesError, e: + except WordgamesError as e: irc.reply('Wordgames error: %s' % str(e)) irc.reply('Please check the configuration and try again. ' + 'See README for help.') @@ -245,7 +246,7 @@ class Wordgames(callbacks.Plugin): def _find_player_game(self, player): "Find a game (in any channel) that lists player as an active player." my_game = None - for game in self.games.values(): + for game in list(self.games.values()): if game.is_running() and 'players' in dir(game): if player in game.players: my_game = game @@ -255,14 +256,14 @@ class Wordgames(callbacks.Plugin): def _get_words(self): try: regexp = re.compile(self.registryValue('wordRegexp')) - except Exception, e: + except Exception as e: raise WordgamesError("Bad value for wordRegexp: %s" % str(e)) path = self.registryValue('wordFile') try: - wordFile = file(path) - except Exception, e: + wordFile = open(path) + except Exception as e: raise WordgamesError("Unable to open word file: %s" % path) - return filter(regexp.match, map(str.strip, wordFile.readlines())) + return list(filter(regexp.match, list(map(str.strip, wordFile.readlines())))) def _start_game(self, Game, irc, channel, *args, **kwargs): try: @@ -274,7 +275,7 @@ class Wordgames(callbacks.Plugin): words = self._get_words() self.games[channel] = Game(words, irc, channel, *args, **kwargs) self.games[channel].start() - except WordgamesError, e: + except WordgamesError as e: # Get rid of the game in case it's in an indeterminate state if channel in self.games: del self.games[channel] irc.reply('Wordgames error: %s' % str(e)) @@ -454,7 +455,7 @@ class Worddle(BaseGame): dup = set() for word in words: bad = False - for result in self.player_results.values(): + for result in list(self.player_results.values()): if word in result.unique: result.unique.remove(word) result.dup.add(word) @@ -469,7 +470,7 @@ class Worddle(BaseGame): Worddle.PlayerResult(player, unique, dup) def sorted_results(self): - return sorted(self.player_results.values(), reverse=True) + return sorted(list(self.player_results.values()), reverse=True) def __init__(self, words, irc, channel, nick, delay, duration, difficulty): # See tech note in the Wordgames class. @@ -501,8 +502,8 @@ class Worddle(BaseGame): self._broadcast('chat', self.players, nick=nick, text=text) return guesses = set(map(str.lower, text.split())) - accepted = filter(lambda s: s in self.board.solutions, guesses) - rejected = filter(lambda s: s not in self.board.solutions, guesses) + accepted = [s for s in guesses if s in self.board.solutions] + rejected = [s for s in guesses if s not in self.board.solutions] if len(accepted) > 3: message = '%sGreat!%s' % (LGREEN, WHITE) elif len(accepted) > 0: @@ -571,8 +572,7 @@ class Worddle(BaseGame): points = 0 for word in self.board.solutions: points += Worddle.POINT_VALUES.get(len(word), Worddle.MAX_POINTS) - longest_words = filter(lambda w: len(w) == self.longest_len, - self.board.solutions) + longest_words = [w for w in self.board.solutions if len(w) == self.longest_len] self.announce(('There were %s%d%s possible words, with total point' ' value %s%d%s. The longest word%s: %s%s%s.') % (WHITE, len(self.board.solutions), LGRAY, LGREEN, points, LGRAY, @@ -672,11 +672,11 @@ class Worddle(BaseGame): # Compute results results = Worddle.Results() - for player, answers in self.player_answers.iteritems(): + for player, answers in self.player_answers.items(): results.add_player_words(player, answers) # Notify players - for result in results.player_results.values(): + for result in list(results.player_results.values()): self._broadcast('gameover', [result.player], now=True) # Announce results @@ -714,7 +714,7 @@ class Worddle(BaseGame): "Generate several boards and return the most bountiful board." attempts = 5 wordtrie = Trie() - map(wordtrie.add, self.words) + list(map(wordtrie.add, self.words)) boards = [WorddleBoard(wordtrie, Worddle.BOARD_SIZE, self.min_length) for i in range(0, attempts)] board_quality = lambda b: len(b.solutions) @@ -770,9 +770,9 @@ class WorddleBoard(object): def _generate_rows(self): "Randomly generate a Worddle board (a list of lists)." - letters = reduce(add, (map(mul, - Worddle.FREQUENCY_TABLE.keys(), - Worddle.FREQUENCY_TABLE.values()))) + letters = reduce(add, (list(map(mul, + list(Worddle.FREQUENCY_TABLE.keys()), + list(Worddle.FREQUENCY_TABLE.values()))))) rows = [] values = random.sample(letters, self.size**2) for i in range(0, self.size): @@ -811,10 +811,9 @@ class WordChain(BaseGame): self.solutions = [] self.word_map = {} if settings.word_lengths: - self.words = filter(lambda w: len(w) in settings.word_lengths, - self.words) + self.words = [w for w in self.words if len(w) in settings.word_lengths] else: - self.words = filter(lambda w: len(w) >= 3, self.words) + self.words = [w for w in self.words if len(w) >= 3] self.build_word_map() def start(self): @@ -831,7 +830,7 @@ class WordChain(BaseGame): self.solution = [random.choice(self.words)] for i in range(1, self.solution_length): values = self.word_map[self.solution[-1]] - values = filter(lambda w: w not in self.solution, values) + values = [w for w in values if w not in self.solution] if not values: break self.solution.append(random.choice(values)) self.solutions = [] @@ -882,7 +881,7 @@ class WordChain(BaseGame): self.announce(self._join_words(self.solution)) def handle_message(self, msg): - words = map(str.strip, msg.args[1].split('>')) + words = list(map(str.strip, msg.args[1].split('>'))) for word in words: if not re.match(r"^[a-z]+$", word): return @@ -963,10 +962,10 @@ class WordShrink(WordChain): def __init__(self, words, irc, channel, difficulty): assert difficulty in ['easy', 'medium', 'hard', 'evil'], "Bad mojo." settings = { - 'easy': WordChain.Settings([4], range(3, 9), range(15, 100)), - 'medium': WordChain.Settings([5], range(4, 10), range(8, 25)), - 'hard': WordChain.Settings([6], range(4, 12), range(4, 12)), - 'evil': WordChain.Settings([7], range(4, 15), range(1, 10)), + 'easy': WordChain.Settings([4], list(range(3, 9)), list(range(15, 100))), + 'medium': WordChain.Settings([5], list(range(4, 10)), list(range(8, 25))), + 'hard': WordChain.Settings([6], list(range(4, 12)), list(range(4, 12))), + 'evil': WordChain.Settings([7], list(range(4, 15)), list(range(1, 10))), } super(WordShrink, self).__init__( words, irc, channel, settings[difficulty]) @@ -1006,10 +1005,10 @@ class WordTwist(WordChain): def __init__(self, words, irc, channel, difficulty): assert difficulty in ['easy', 'medium', 'hard', 'evil'], "Bad mojo." settings = { - 'easy': WordChain.Settings([4], [3, 4], range(10, 100)), - 'medium': WordChain.Settings([5], [4, 5], range(5, 12)), - 'hard': WordChain.Settings([6], [4, 5, 6], range(2, 5)), - 'evil': WordChain.Settings([7], [4, 5, 6], range(1, 3)), + 'easy': WordChain.Settings([4], [3, 4], list(range(10, 100))), + 'medium': WordChain.Settings([5], [4, 5], list(range(5, 12))), + 'hard': WordChain.Settings([6], [4, 5, 6], list(range(2, 5))), + 'evil': WordChain.Settings([7], [4, 5, 6], list(range(1, 3))), } super(WordTwist, self).__init__( words, irc, channel, settings[difficulty]) @@ -1030,8 +1029,7 @@ class WordTwist(WordChain): self.word_map[word] = [] for pos in range(0, len(word)): key = word[0:pos] + wildcard + word[pos+1:] - self.word_map[word] += filter( - lambda w: w != word, keymap.get(key, [])) + self.word_map[word] += [w for w in keymap.get(key, []) if w != word] def is_trivial_solution(self, solution): "If it's possible to get there in fewer hops, this is trivial." From 2e69c1e2daa8f235f5b0a065819f9724c21b4dbf Mon Sep 17 00:00:00 2001 From: Gordon Shumway <39967334+oddluck@users.noreply.github.com> Date: Sun, 24 Feb 2019 15:18:32 -0500 Subject: [PATCH 59/62] python3 compatible --- __init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 69aefc7..4a8a4bc 100644 --- a/__init__.py +++ b/__init__.py @@ -27,6 +27,7 @@ Implements some word games. import supybot import supybot.world as world +import importlib # Use this for the version of this plugin. You may wish to put a CVS keyword # in here if you're keeping the plugin in CVS or some similar system. @@ -42,14 +43,14 @@ __contributors__ = {} # This is a url where the most recent plugin package can be downloaded. __url__ = 'http://github.com/mmueller/supybot-wordgames' -import config -import plugin -reload(plugin) # In case we're being reloaded. +from . import config +from . import plugin +importlib.reload(plugin) # In case we're being reloaded. # Add more reloads here if you add third-party modules and want them to be # reloaded when this plugin is reloaded. Don't forget to import them as well! if world.testing: - import test + from . import test Class = plugin.Class configure = config.configure From f735b07cf0cde839da8ee51efbeb2b00e9b5bf29 Mon Sep 17 00:00:00 2001 From: Gordon Shumway <39967334+oddluck@users.noreply.github.com> Date: Sun, 24 Feb 2019 15:19:49 -0500 Subject: [PATCH 60/62] python3 compatible --- trie.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trie.py b/trie.py index 5ee51ec..770a3a5 100644 --- a/trie.py +++ b/trie.py @@ -71,8 +71,8 @@ if __name__ == '__main__': t.add(line.strip()) mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss elapsed = time.time() - start - print('Trie created in %g seconds.' % elapsed) - print('Used %dMB RAM.' % (mem/1024)) + print(('Trie created in %g seconds.' % elapsed)) + print(('Used %dMB RAM.' % (mem/1024))) else: # Regular sanity test t = Trie() From 9b3685dd2e29521a044b6f5d88206a0c6e18372b Mon Sep 17 00:00:00 2001 From: Gordon Shumway <39967334+oddluck@users.noreply.github.com> Date: Sun, 24 Feb 2019 15:20:16 -0500 Subject: [PATCH 61/62] Delete .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c9b568f..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.pyc -*.swp From 854e77a6d4d576aad9da8aa9852a1939e205fc4e Mon Sep 17 00:00:00 2001 From: Gordon Shumway <39967334+oddluck@users.noreply.github.com> Date: Sun, 24 Feb 2019 15:20:27 -0500 Subject: [PATCH 62/62] Delete README --- README | 1 - 1 file changed, 1 deletion(-) delete mode 120000 README diff --git a/README b/README deleted file mode 120000 index 42061c0..0000000 --- a/README +++ /dev/null @@ -1 +0,0 @@ -README.md \ No newline at end of file