Created
May 28, 2015 06:39
-
-
Save Dru89/cb67902ba7dfd3c53104 to your computer and use it in GitHub Desktop.
A simple(ish) script written in python to solve puzzles for the Word Bubbles game. I hacked on this for about a day, so I'm not sure how well it works.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import argparse | |
import os | |
import re | |
import sys | |
from collections import defaultdict, Counter | |
from copy import deepcopy | |
EXAMPLE_PUZZLE = [ | |
['n', 'e', 'e', 't'], | |
['s', 's', ' ', 'e'], | |
['t', 'a', 'n', 'r'], | |
['i', 'o', 's', 't']] | |
EXAMPLE_LENGTHS = [9, 6] | |
class BubbleSolver(object): | |
def __init__(self, files): | |
self.words = set() | |
for file_ in files: | |
with open(file_) as f: | |
for line in f: | |
line = line.strip().lower() | |
if re.match('[a-z]+', line): | |
self.words.add(line) | |
self.words_by_length = defaultdict(list) | |
for word in sorted(list(self.words)): | |
self.words_by_length[len(word)].append(word) | |
def _compare_counters(self, word, possible_subword): | |
"""Returns true if possible_subword is an actual subword | |
of word. | |
""" | |
for k, v in possible_subword.iteritems(): | |
if word[k] < v: | |
return False | |
return True | |
def __find_possible_solutions_by_dictionary(self, puzzle_counter, lengths): | |
"""Does a naive search at finding possible words in the puzzle. | |
This implementation does not care about the location of the words. | |
puzzle_counter: | |
This must be a collections.Counter object, as it will recurse | |
and I don't want to rebuild it each time. | |
ex. Counter(list('neetssetanriost')) | |
lengths: | |
This is an array of lengths to find for. | |
ex. [4, 3, 2] or [4, 7] | |
returns: | |
An iterator of possible word solutions, based solely on | |
character count and not character position. | |
""" | |
length = lengths.pop() | |
for word in self.words_by_length[length]: | |
counter = Counter(word) | |
if self._compare_counters(puzzle_counter, counter): | |
if len(lengths) == 0: | |
yield [word] | |
else: | |
new_counter = puzzle_counter - counter | |
solutions = self.__find_possible_solutions_by_dictionary(new_counter, lengths[:]) | |
for solution in solutions: | |
yield [word] + solution | |
def _find_possible_solutions_by_dictionary(self, puzzle_word, lengths): | |
puzzle_counter = Counter(puzzle_word.lower()) | |
solutions = self.__find_possible_solutions_by_dictionary(puzzle_counter, lengths[:]) | |
for solution in solutions: | |
yield solution | |
def _traverse(self, puzzle_map, word, path, exclude=[]): | |
letter = word[0] | |
x, y = path[-1][0], path[-1][1] | |
if puzzle_map[y][x] != letter: | |
raise ValueError("Expectation for letter to match position was incorrect.") | |
if len(word) == 0: | |
yield [] | |
places = [] | |
xg = x > 0 | |
yg = y > 0 | |
xl = x < len(puzzle_map[0]) - 1 | |
yl = y < len(puzzle_map) - 1 | |
if xg: places.append((x-1, y)) | |
if yg: places.append((x, y-1)) | |
if xl: places.append((x+1, y)) | |
if yl: places.append((x, y+1)) | |
if xg and yg: places.append((x-1, y-1)) | |
if xg and yl: places.append((x-1, y+1)) | |
if xl and yg: places.append((x+1, y-1)) | |
if xl and yl: places.append((x+1, y+1)) | |
next_letter = word[1] | |
for place in places: | |
x2, y2 = place | |
if puzzle_map[y2][x2] == next_letter and place not in exclude and place not in path: | |
if len(word) is 2: | |
yield [path[-1], place] | |
else: | |
for result in self._traverse(puzzle_map, word[1:], path + [place], exclude): | |
yield [path[-1]] + result | |
def _traverse_each_word(self, puzzle_map, solution_words, exclude=[]): | |
if len(solution_words) == 0: | |
raise StopIteration | |
word = solution_words[0] | |
first_letter = word[0] | |
positions = [] | |
for y, row in enumerate(puzzle_map): | |
for x, letter in enumerate(row): | |
if letter == first_letter: | |
positions.append((x, y)) | |
for pos in positions: | |
for path in self._traverse(puzzle_map, word, [pos], exclude): | |
if len(solution_words) == 1: | |
yield [(word, path)] | |
else: | |
for word_path in self._traverse_each_word(puzzle_map, solution_words[1:], exclude + path): | |
yield [(word, path)] + word_path | |
def _find_solutions_by_traversal(self, puzzle, solution_words): | |
puzzle_map = [map(lambda e: e.lower(), row) for row in puzzle] | |
for solution in self._traverse_each_word(puzzle_map, solution_words): | |
yield solution | |
def solve(self, puzzle, lengths): | |
"""Solves a two-dimensional word-bubble puzzle. | |
puzzle: | |
Should be a two-dimensional array representing the board. | |
Use spaces to represent blanks in the board. | |
ex. NEET | |
SS E | |
TANR | |
IOST | |
would be represented as | |
[['N', 'E', 'E', 'T'] | |
['S', 'S', ' ', 'E'] | |
['T', 'A', 'N', 'R'] | |
['I', 'O', 'S', 'T']] | |
lengths: | |
A list of word lengths to solve for. | |
ex. [9, 6] | |
returns: | |
An iterator of possible solutions. | |
ex. [["sensation", "street"]] | |
""" | |
puzzle_word = ''.join([''.join(p) for p in puzzle]).replace(' ', '') | |
used_words = set() | |
for solution in self._find_possible_solutions_by_dictionary(puzzle_word, lengths): | |
for paths in self._find_solutions_by_traversal(puzzle, solution): | |
words = tuple(map(lambda p: p[0], paths)) | |
if words not in used_words: | |
used_words.add(words) | |
yield paths | |
def print_solution(self, puzzle_, solution): | |
for word, path in solution: | |
puzzle = deepcopy(puzzle_) | |
print word.upper() | |
for i, pos in enumerate(path): | |
x, y = pos | |
puzzle[y][x] = str(i+1 % 10) | |
for y, row in enumerate(puzzle): | |
for x, letter in enumerate(row): | |
if not re.match('[0-9]', letter): | |
puzzle[y][x] = '.' | |
print '\n'.join([''.join(p) for p in puzzle]).upper() | |
print '' | |
def print_board(self, puzzle): | |
print '\n'.join([''.join(p) for p in puzzle]).upper() | |
def create_boards(file_): | |
board = [] | |
lengths = [] | |
for line in file_: | |
line = line.strip('\n\r') | |
if not line.strip(): | |
continue | |
elif re.search('[0-9]', line): | |
lengths = map(int, line.strip().split()) | |
elif re.match('-+', line.strip()): | |
if board and lengths: | |
yield (board, lengths) | |
lengths = [] | |
board = [] | |
else: | |
board.append(list(line)) | |
if len(line) != len(board[0]): | |
raise ValueError("The lines on your board are not correctly aligned. " | |
"Please adjust each line to be the same length. " | |
"(This includes adding any necessary trailing whitespace.)") | |
if board and lengths: | |
yield (board, lengths) | |
if __name__ == "__main__": | |
epilog = ''' | |
DICTIONARIES: | |
When specifying dictionary files, you can provide either a file, | |
folder, or glob. Each file should be in the format of one word | |
per line. By default, we look at every file in /usr/share/dict. | |
INPUT FILE: | |
The input file should be specified as a line of numbers (with a | |
space between each number) followed by lines for the game board. | |
Each number represents the length of a word to find. The lines | |
for the game board should be written without any spaces between | |
each character. Blank spaces in the board should be represented | |
by the space key. Completely blank lines (or lines that are all | |
whitespace), will be ignored. You can add multiple boards to | |
the same file by separating them with a line of hyphens. | |
Example input file: | |
9 6 | |
NEET | |
SS E | |
TANR | |
IOST | |
--- | |
9 6 | |
ROA | |
BUCD | |
RACE | |
ETSS | |
''' | |
parser = argparse.ArgumentParser(description = "Word Bubbles solver", epilog=epilog) | |
parser.add_argument('--dictionary', '-d', action='append', default=[], metavar='FILE', required=False, | |
dest='word_files', help='A file, folder, or glob pointing to a list of dictionaries.') | |
parser.add_argument('infile', type=argparse.FileType('r'), default=sys.stdin, nargs='?', | |
help='The input file to read from.') | |
args = parser.parse_args() | |
if not args.word_files: | |
args.word_files.append('/usr/share/dict') | |
entries = [] | |
for entry in args.word_files: | |
if os.path.isdir(entry): | |
paths = map(lambda e: os.path.join(entry, e), os.listdir(entry)) | |
else: | |
paths = glob.glob(entry) | |
entries += paths | |
if not entries: | |
raise ValueError("No dictionaries were found to read from.") | |
solver = BubbleSolver(entries) | |
for puzzle, lengths in create_boards(args.infile): | |
print '====' | |
print 'Lengths:', ' '.join(map(str, lengths)) | |
print 'Board:' | |
solver.print_board(puzzle) | |
print '\nSolutions:' | |
for solution in solver.solve(puzzle, lengths): | |
solver.print_solution(puzzle, solution) | |
print '\n---' | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment