mirror of
https://github.com/oddluck/limnoria-plugins.git
synced 2025-04-25 12:31:07 -05:00
1166 lines
41 KiB
Python
1166 lines
41 KiB
Python
###
|
|
# Copyright (c) 2012, Mike Mueller
|
|
# Copyright (c) 2020, oddluck <oddluck@riseup.net>
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# * Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions, and the following disclaimer.
|
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions, and the following disclaimer in the
|
|
# documentation and/or other materials provided with the distribution.
|
|
# * Neither the name of the author of this software nor the name of
|
|
# contributors to this software may be used to endorse or promote products
|
|
# derived from this software without specific prior written consent.
|
|
#
|
|
# 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 operator import add, mul
|
|
import random
|
|
import re
|
|
import time
|
|
|
|
import supybot.conf as conf
|
|
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
|
|
|
|
from .trie import Trie
|
|
from functools import reduce
|
|
|
|
DEBUG = False
|
|
|
|
WHITE = "\x0300"
|
|
GREEN = "\x0303"
|
|
LRED = "\x0304"
|
|
RED = "\x0305"
|
|
YELLOW = "\x0307"
|
|
LYELLOW = "\x0308"
|
|
LGREEN = "\x0309"
|
|
LCYAN = "\x0311"
|
|
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)
|
|
|
|
|
|
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):
|
|
# 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"])
|
|
# 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))
|
|
else:
|
|
debug("Unable to find max targets, using default (1).")
|
|
except Exception as e:
|
|
error("Detecting max targets: %s. Using default (1)." % str(e))
|
|
return result
|
|
|
|
|
|
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."
|
|
|
|
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.
|
|
try:
|
|
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
|
|
except:
|
|
pass
|
|
# In all other cases, default to normal message handling
|
|
return msg
|
|
|
|
def __init__(self, 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):
|
|
for channel, game in self.games.items():
|
|
if game.is_running():
|
|
game.stop(now=True)
|
|
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 boggle(self, irc, msgs, args, channel, command):
|
|
"""[command]
|
|
|
|
Play a game of Boggle. Commands: [easy|medium|hard|evil] [stop|stats]
|
|
(default: start with configured difficulty).
|
|
"""
|
|
try:
|
|
# Allow deprecated 'join' command:
|
|
if not command or command == "join" or command in Difficulty.NAMES:
|
|
difficulty = Difficulty.value(self.registryValue("boggleDifficulty"))
|
|
if command in Difficulty.NAMES:
|
|
difficulty = Difficulty.value(command)
|
|
game = self.games.get(channel)
|
|
if game and game.is_running():
|
|
if game.__class__ == Boggle:
|
|
if command:
|
|
irc.reply('Joining the game. (Ignored "%s".)' % command)
|
|
game.join(msgs.nick)
|
|
else:
|
|
irc.reply("Current word game is not Boggle!")
|
|
else:
|
|
delay = self.registryValue("boggleDelay")
|
|
duration = self.registryValue("boggleDuration")
|
|
self._start_game(
|
|
Boggle, 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__ != Boggle:
|
|
irc.reply("No Boggle 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 Boggle.")
|
|
except WordGamesError as e:
|
|
irc.reply("WordGames error: %s" % str(e))
|
|
irc.reply(
|
|
"Please check the configuration and try again. "
|
|
+ "See README for help."
|
|
)
|
|
|
|
boggle = wrap(
|
|
boggle,
|
|
[
|
|
"channel",
|
|
optional(("literal", Difficulty.NAMES + ["join", "stop", "stats"])),
|
|
],
|
|
)
|
|
|
|
def wordshrink(self, irc, msgs, args, channel, difficulty):
|
|
"""[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.
|
|
"""
|
|
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, difficulty)
|
|
|
|
wordshrink = wrap(
|
|
wordshrink, ["channel", optional("somethingWithoutSpaces", "medium")]
|
|
)
|
|
|
|
def wordtwist(self, irc, msgs, args, channel, difficulty):
|
|
"""[easy|medium|hard|evil] (default: medium)
|
|
|
|
Start a word-twist game. Make new words by changing one letter in
|
|
the previous word.
|
|
"""
|
|
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, difficulty)
|
|
|
|
wordtwist = wrap(
|
|
wordtwist, ["channel", optional("somethingWithoutSpaces", "medium")]
|
|
)
|
|
|
|
def wordquit(self, irc, msgs, args, channel):
|
|
"""(takes no arguments)
|
|
|
|
Stop any currently running word game.
|
|
"""
|
|
self._stop_game(irc, channel)
|
|
|
|
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 list(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"))
|
|
except Exception as e:
|
|
raise WordGamesError("Bad value for wordRegexp: %s" % str(e))
|
|
path = self.registryValue("wordFile")
|
|
try:
|
|
wordFile = open(path)
|
|
except Exception as e:
|
|
raise WordGamesError("Unable to open word file: %s" % path)
|
|
return list(filter(regexp.match, list(map(str.strip, wordFile.readlines()))))
|
|
|
|
def _start_game(self, Game, irc, channel, *args, **kwargs):
|
|
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, *args, **kwargs)
|
|
self.games[channel].start()
|
|
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))
|
|
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."
|
|
|
|
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 solve(self):
|
|
"Show solution(s) for current game."
|
|
pass
|
|
|
|
def start(self):
|
|
"Start the current game."
|
|
self.running = True
|
|
|
|
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):
|
|
"Show the current state of the game."
|
|
pass
|
|
|
|
def is_running(self):
|
|
return self.running
|
|
|
|
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 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(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."
|
|
pass
|
|
|
|
|
|
class Boggle(BaseGame):
|
|
"The Boggle game implementation."
|
|
|
|
BOARD_SIZE = 4
|
|
FREQUENCY_TABLE = {
|
|
19: "E",
|
|
13: "T",
|
|
12: "AR",
|
|
11: "INO",
|
|
9: "S",
|
|
6: "D",
|
|
5: "CHL",
|
|
4: "FMPU",
|
|
3: "GY",
|
|
2: "W",
|
|
1: "BJKQVXZ",
|
|
}
|
|
POINT_VALUES = {
|
|
3: 1,
|
|
4: 1,
|
|
5: 2,
|
|
6: 3,
|
|
7: 5,
|
|
}
|
|
MAX_POINTS = 11 # 8 letters or longer
|
|
MESSAGES = {
|
|
"chat": "%s%%(nick)s%s says: %%(text)s" % (WHITE, LGRAY),
|
|
"joined": "%s%%(nick)s%s joined the game." % (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 " + "point%%(plural)s (%%(words)s)"
|
|
)
|
|
% (WHITE, LGRAY, LGREEN, LGRAY),
|
|
"startup": (
|
|
"Starting in %%(seconds)d seconds, "
|
|
+ 'use "%s%%(commandChar)sboggle%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)"
|
|
)
|
|
% (LGREEN, LGRAY, WHITE, LGRAY, WHITE, LGRAY),
|
|
"welcome2": ("%s%%(nick)s%s, write your answers here, e.g.: " + "cat dog ...")
|
|
% (WHITE, LGRAY),
|
|
}
|
|
|
|
class State:
|
|
PREGAME = 0
|
|
READY = 1
|
|
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 __eq__(self, other):
|
|
return (self.get_score()) == (other.get_score())
|
|
|
|
def __ne__(self, other):
|
|
return (self.get_score()) != (other.get_score())
|
|
|
|
def __lt__(self, other):
|
|
return (self.get_score()) < (other.get_score())
|
|
|
|
def __le__(self, other):
|
|
return (self.get_score()) <= (other.get_score())
|
|
|
|
def __gt__(self, other):
|
|
return (self.get_score()) > (other.get_score())
|
|
|
|
def __ge__(self, other):
|
|
return (self.get_score()) >= (other.get_score())
|
|
|
|
def __repr__(self):
|
|
return "%s %s" % (self.get_score(), other.get_score())
|
|
|
|
def get_score(self):
|
|
score = 0
|
|
for word in self.unique:
|
|
score += Boggle.POINT_VALUES.get(len(word), Boggle.MAX_POINTS)
|
|
return score
|
|
|
|
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 = ""
|
|
last_color = LGRAY
|
|
for word in words:
|
|
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 + "*"
|
|
last_color = LYELLOW
|
|
words_text += "%s " % word
|
|
if not words_text:
|
|
words_text = "%s-none-" % (GRAY)
|
|
words_text = words_text.strip() + LGRAY
|
|
return words_text
|
|
|
|
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 list(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] = Boggle.PlayerResult(player, unique, dup)
|
|
|
|
def sorted_results(self):
|
|
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.
|
|
self.parent = super(Boggle, self)
|
|
self.parent.__init__(words, irc, channel)
|
|
self.delay = delay
|
|
self.duration = duration
|
|
self.difficulty = difficulty
|
|
self.max_targets = get_max_targets(irc)
|
|
self._handle_difficulty()
|
|
self.board = self._generate_board()
|
|
self.event_name = "Boggle.%d" % id(self)
|
|
self.init_time = time.time()
|
|
self.longest_len = len(max(self.board.solutions, key=len))
|
|
self.starter = nick
|
|
self.state = Boggle.State.PREGAME
|
|
self.players = []
|
|
self.player_answers = {}
|
|
self.warnings = [30, 10, 5]
|
|
while self.warnings[0] >= duration:
|
|
self.warnings = self.warnings[1:]
|
|
|
|
def guess(self, nick, text):
|
|
# 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 < Boggle.State.ACTIVE:
|
|
self._broadcast("chat", self.players, nick=nick, text=text)
|
|
return
|
|
guesses = set(map(str.lower, text.split()))
|
|
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:
|
|
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_to(nick, message)
|
|
|
|
def join(self, nick):
|
|
assert self.is_running()
|
|
assert self.state != Boggle.State.DONE
|
|
if nick not in self.players:
|
|
self._broadcast(
|
|
"welcome1",
|
|
[nick],
|
|
now=True,
|
|
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)
|
|
self.player_answers[nick] = set()
|
|
if self.state == Boggle.State.ACTIVE:
|
|
self._display_board(nick)
|
|
else:
|
|
self._broadcast("players", [nick])
|
|
# Keep at least 5 seconds on the pre-game clock if someone joins
|
|
if self.state == Boggle.State.PREGAME:
|
|
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)
|
|
|
|
def show(self):
|
|
# Not sure if this is really useful.
|
|
# if self.state == Boggle.State.ACTIVE:
|
|
# self._display_board(self.channel)
|
|
pass
|
|
|
|
def solve(self):
|
|
self.announce("Solutions: " + " ".join(sorted(self.board.solutions)))
|
|
|
|
def start(self):
|
|
self.parent.start()
|
|
self._broadcast("startup", [self.channel], True, seconds=self.delay)
|
|
self.join(self.starter)
|
|
self._schedule_next_event()
|
|
|
|
def stop(self, now=False):
|
|
self.parent.stop()
|
|
self.state = Boggle.State.DONE
|
|
try:
|
|
schedule.removeEvent(self.event_name)
|
|
except KeyError:
|
|
pass
|
|
if not now:
|
|
self._broadcast("stopped", [self.channel])
|
|
self._broadcast("stopped2", self.players)
|
|
|
|
def stats(self):
|
|
assert self.state == Boggle.State.DONE
|
|
points = 0
|
|
for word in self.board.solutions:
|
|
points += Boggle.POINT_VALUES.get(len(word), Boggle.MAX_POINTS)
|
|
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,
|
|
" 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
|
|
all players, not the game channel). Set now to bypass Supybot's queue
|
|
and send the message immediately.
|
|
"""
|
|
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 = Boggle.MESSAGES[name] % kwargs
|
|
self._broadcast_text(formatted, recipients, now)
|
|
|
|
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 = Boggle.State.READY
|
|
self._broadcast("ready", now=True)
|
|
self._schedule_next_event()
|
|
|
|
def _begin_game(self):
|
|
self.state = Boggle.State.ACTIVE
|
|
self.start_time = time.time()
|
|
self.end_time = self.start_time + self.duration
|
|
self._display_board()
|
|
self._schedule_next_event()
|
|
|
|
def _schedule_next_event(self):
|
|
"""
|
|
(Re)schedules the next game event (start, time left warning, end)
|
|
as appropriate.
|
|
"""
|
|
# Unschedule any previous event
|
|
try:
|
|
schedule.removeEvent(self.event_name)
|
|
except KeyError:
|
|
pass
|
|
if self.state == Boggle.State.PREGAME:
|
|
# Schedule "get ready" message
|
|
schedule.addEvent(
|
|
self._get_ready, self.init_time + self.delay, self.event_name
|
|
)
|
|
elif self.state == Boggle.State.READY:
|
|
# Schedule game start
|
|
schedule.addEvent(
|
|
self._begin_game, self.init_time + self.delay + 3, self.event_name
|
|
)
|
|
elif self.state == Boggle.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, self.event_name)
|
|
self.warnings = self.warnings[1:]
|
|
else:
|
|
# Schedule game end
|
|
schedule.addEvent(self._end_game, self.end_time, self.event_name)
|
|
|
|
def _time_warning(self):
|
|
seconds = round(self.start_time + self.duration - time.time())
|
|
self._broadcast("warning", now=True, seconds=seconds)
|
|
self._schedule_next_event()
|
|
|
|
def _end_game(self):
|
|
self.gameover()
|
|
self.state = Boggle.State.DONE
|
|
|
|
# Compute results
|
|
results = Boggle.Results()
|
|
for player, answers in self.player_answers.items():
|
|
results.add_player_words(player, answers)
|
|
|
|
# Notify players
|
|
for result in list(results.player_results.values()):
|
|
self._broadcast("gameover", [result.player], now=True)
|
|
|
|
# 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()
|
|
verb = "got"
|
|
if score == high_score:
|
|
if tie:
|
|
verb = "%stied%s with" % (LYELLOW, LGRAY)
|
|
elif high_score > 0:
|
|
verb = "%swins%s with" % (LGREEN, LGRAY)
|
|
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,
|
|
)
|
|
|
|
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 = [""] * Boggle.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 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()
|
|
list(map(wordtrie.add, self.words))
|
|
boards = [
|
|
BoggleBoard(wordtrie, Boggle.BOARD_SIZE, self.min_length)
|
|
for i in range(0, attempts)
|
|
]
|
|
board_quality = lambda b: len(b.solutions)
|
|
return max(boards, key=board_quality)
|
|
|
|
|
|
class BoggleBoard(object):
|
|
"Represents the board in a Boggle game."
|
|
|
|
def __init__(self, wordtrie, n, min_length):
|
|
"Generate a new n x n Boggle board."
|
|
self.size = n
|
|
self.min_length = min_length
|
|
self.rows = self._generate_rows()
|
|
self.solutions = self._find_solutions(wordtrie)
|
|
|
|
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, 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(wordtrie, [], row, col, ""))
|
|
else:
|
|
visited = visited + [(row, col)]
|
|
current = prefix + self.rows[row][col].lower()
|
|
if current[-1] == "q":
|
|
current += "u"
|
|
node = wordtrie.find_prefix(current)
|
|
if node:
|
|
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),
|
|
(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] >= self.size:
|
|
continue
|
|
if point[1] < 0 or point[1] >= self.size:
|
|
continue
|
|
result.update(
|
|
self._find_solutions(
|
|
wordtrie, visited, point[0], point[1], current
|
|
)
|
|
)
|
|
return result
|
|
|
|
def _generate_rows(self):
|
|
"Randomly generate a Boggle board (a list of lists)."
|
|
letters = reduce(
|
|
add,
|
|
(
|
|
list(
|
|
map(
|
|
mul,
|
|
list(Boggle.FREQUENCY_TABLE.keys()),
|
|
list(Boggle.FREQUENCY_TABLE.values()),
|
|
)
|
|
)
|
|
),
|
|
)
|
|
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."
|
|
|
|
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):
|
|
# 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 = []
|
|
self.solutions = []
|
|
self.word_map = {}
|
|
if settings.word_lengths:
|
|
self.words = [w for w in self.words if len(w) in settings.word_lengths]
|
|
else:
|
|
self.words = [w for w in self.words if len(w) >= 3]
|
|
self.build_word_map()
|
|
|
|
def start(self):
|
|
# Build a puzzle
|
|
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]]
|
|
values = [w for w in values if w not in self.solution]
|
|
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):
|
|
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()
|
|
self.parent.start()
|
|
|
|
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 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, now=False):
|
|
self.parent.stop()
|
|
if not now:
|
|
self.announce(self._join_words(self.solution))
|
|
|
|
def handle_message(self, msg):
|
|
words = list(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)
|
|
|
|
# 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.solution[-1] in self._get_successors(seed[-1]):
|
|
self.solutions.append(seed + [self.solution[-1]])
|
|
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:
|
|
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):
|
|
return False
|
|
# Check for incorrect start/end words
|
|
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." % (nick, words[i + 1], words[i])
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
class WordShrink(WordChain):
|
|
def __init__(self, words, irc, channel, difficulty):
|
|
assert difficulty in ["easy", "medium", "hard", "evil"], "Bad mojo."
|
|
settings = {
|
|
"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])
|
|
|
|
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] = []
|
|
keys = set()
|
|
for i in range(0, len(s)):
|
|
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):
|
|
"Consider pure substring solutions trivial."
|
|
for i in range(0, len(solution) - 1):
|
|
for j in range(i + 1, len(solution)):
|
|
if solution[i].find(solution[j]) >= 0:
|
|
return True
|
|
return False
|
|
|
|
|
|
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], 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])
|
|
|
|
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] += [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."
|
|
return len(solution) < self.solution_length
|
|
|
|
|
|
Class = WordGames
|
|
|
|
# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:
|