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.
This commit is contained in:
Mike Mueller 2012-03-26 16:33:59 -07:00
parent 4bc4c0d858
commit 353b5109dc
4 changed files with 444 additions and 12 deletions

35
README
View File

@ -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:

View File

@ -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:

345
plugin.py
View File

@ -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))

67
trie.py Normal file
View File

@ -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