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:
parent
4bc4c0d858
commit
353b5109dc
35
README
35
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:
|
||||
|
|
|
|||
|
|
@ -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
345
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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue