Files
2025-05-23 22:01:00 +08:00

308 lines
12 KiB
Python

"""
Wordscapes game module for the reasonreason framework.
"""
import random
import numpy as np
import json
import re
import argparse
from pathlib import Path
import uuid
from english_words import get_english_words_set
from games.base.game import Game
from base.data import Data
from base.verifier import Verifier, THOUGHT_DELIMITER_START, THOUGHT_DELIMITER_END
from games.tasks.wordscapes.scripts.wordscapes_verifier import WordscapesVerifier
from games.tasks.wordscapes.scripts.wordscapes_data import WordscapesData
class WordscapesGame(Game):
"""
Wordscapes game implementation
"""
def __init__(self):
super().__init__("Wordscapes", WordscapesVerifier)
def generate(self, num_of_questions: int = 100, max_attempts: int = 100):
"""
Generate Wordscapes puzzles
Args:
num_of_questions: Number of puzzles to generate
max_attempts: Maximum attempts to generate a valid puzzle
Returns:
list: List of WordscapesData objects
"""
game_data = []
for i in range(num_of_questions):
# Randomize the puzzle parameters
size = random.randint(4, 6) # Grid size between 4x4 and 6x6
num_words = random.randint(4, 8) # 4-8 words per puzzle
max_intersections = random.randint(5, 10) # 5-10 intersections
# Try to generate a valid puzzle
for _ in range(max_attempts):
try:
grid, across_words, down_words, solution = self._generate_puzzle(
size, num_words, max_intersections
)
# Create data object
data = WordscapesData(
str(i + 1),
grid,
across_words,
down_words,
solution
)
game_data.append(data)
break
except Exception as e:
print(f"Error generating puzzle {i+1}: {e}")
continue
return game_data
def _generate_puzzle(self, size, num_words, max_intersections):
"""
Generate a single Wordscapes puzzle
Args:
size: Size of the square matrix
num_words: Number of words to include
max_intersections: Maximum number of letter intersections
Returns:
grid, across_words, down_words, solution_grid
"""
# Get words from the library
english_words_set = get_english_words_set(['web2'], lower=True)
# Initialize grid with all '0's
grid = np.full((size, size), '0', dtype=str)
solution_grid = np.full((size, size), ' ', dtype=str)
# Create a pattern of potential word positions
# First create horizontal word slots
h_slots = []
for r in range(size):
# Decide if this row will have a word
if random.random() < 0.7: # 70% chance
length = random.randint(3, size)
if length > 2:
start_col = random.randint(0, size - length)
h_slots.append((r, start_col, length))
# Then create vertical word slots
v_slots = []
for c in range(size):
# Decide if this column will have a word
if random.random() < 0.7: # 70% chance
length = random.randint(3, size)
if length > 2:
start_row = random.randint(0, size - length)
v_slots.append((start_row, c, length))
# Limit to desired number of words
random.shuffle(h_slots)
random.shuffle(v_slots)
h_slots = h_slots[:num_words//2 + 1]
v_slots = v_slots[:num_words - len(h_slots)]
# Mark grid with X for slots
intersection_points = set()
for r, c, length in h_slots:
for i in range(length):
grid[r][c + i] = 'X'
for r, c, length in v_slots:
for i in range(length):
if grid[r + i][c] == 'X':
intersection_points.add((r + i, c))
grid[r + i][c] = 'X'
# Check if we have acceptable intersections
if len(intersection_points) > max_intersections:
# Try again if too many intersections
return self._generate_puzzle(size, num_words, max_intersections)
# Now find words that fit these slots
across_words = []
down_words = []
# Get words from dictionary
valid_words = {}
for length in range(2, size + 1):
valid_words[length] = [word for word in english_words_set if len(word) == length]
# Find words for horizontal slots
for r, c, length in h_slots:
if not valid_words.get(length, []):
# No words of this length, mark as invalid
for i in range(length):
grid[r][c + i] = '0'
continue
# Create constraints based on intersections
constraints = {}
for i in range(length):
pos = (r, c + i)
if pos in intersection_points:
# This position must match vertical word
for row in range(size):
if row != r and grid[row][c + i] == 'X':
constraints[i] = None # Will be set when vertical words are placed
# For now, just select a random word (constraints will be handled later)
word = random.choice(valid_words[length])
across_words.append(word)
# Add word to solution grid
for i, letter in enumerate(word):
solution_grid[r][c + i] = letter
# Find words for vertical slots
for r, c, length in v_slots:
if not valid_words.get(length, []):
# No words of this length, mark as invalid
for i in range(length):
grid[r + i][c] = '0'
continue
# Create constraints based on intersections
constraints = {}
for i in range(length):
pos = (r + i, c)
if pos in intersection_points:
# Find the horizontal word that intersects here
h_index = None
for idx, (hr, hc, hl) in enumerate(h_slots):
if hr == r + i and hc <= c < hc + hl:
h_index = idx
h_pos = c - hc
break
if h_index is not None and h_index < len(across_words):
# This position must match the letter in the horizontal word
constraints[i] = across_words[h_index][h_pos]
# Find a word that matches constraints
candidates = []
for word in valid_words[length]:
matches = True
for pos, letter in constraints.items():
if letter and word[pos] != letter:
matches = False
break
if matches:
candidates.append(word)
if candidates:
word = random.choice(candidates)
down_words.append(word)
# Add word to solution grid
for i, letter in enumerate(word):
solution_grid[r + i][c] = letter
else:
# No matching word found, try a different grid
return self._generate_puzzle(size, num_words, max_intersections)
# Convert numpy arrays to lists for JSON serialization
grid_list = [list(row) for row in grid]
solution_list = [list(row) for row in solution_grid]
return grid_list, across_words, down_words, solution_list
def extract_answer(self, test_solution: str):
"""Extract answer from test solution"""
return self.verifier.extract_answer(test_solution)
def main():
"""
Main function for direct execution of the Wordscapes game generator
"""
parser = argparse.ArgumentParser(description='Generate Wordscapes puzzles')
parser.add_argument('--num-questions', type=int, default=100,
help='Number of puzzles to generate (default: 100)')
parser.add_argument('--max-attempts', type=int, default=100,
help='Maximum attempts to generate a valid puzzle (default: 100)')
parser.add_argument('--size', type=int, default=None,
help='Grid size (default: random between 4-6)')
parser.add_argument('--num-words', type=int, default=None,
help='Number of words per puzzle (default: random between 4-8)')
parser.add_argument('--max-intersections', type=int, default=None,
help='Maximum letter intersections (default: random between 5-10)')
parser.add_argument('--output', type=str, default=None,
help='Custom output file path (overrides default structured path)')
args = parser.parse_args()
# Create game instance
game = WordscapesGame()
# Modify the generate method to support fixed parameters if provided
original_generate_puzzle = game._generate_puzzle
if args.size is not None or args.num_words is not None or args.max_intersections is not None:
def custom_generate_puzzle(size, num_words, max_intersections):
# Use fixed values if provided, otherwise use the passed values
custom_size = args.size if args.size is not None else size
custom_num_words = args.num_words if args.num_words is not None else num_words
custom_max_intersections = args.max_intersections if args.max_intersections is not None else max_intersections
return original_generate_puzzle(custom_size, custom_num_words, custom_max_intersections)
# Replace the method with our custom version
game._generate_puzzle = custom_generate_puzzle
# Generate puzzles
print(f"Generating {args.num_questions} Wordscapes puzzles...")
game_data = game.generate(args.num_questions, args.max_attempts)
# Build structured output path similar to Game of 24
if args.output is None:
data_dir = Path(__file__).parent.parent / "data"
if not data_dir.exists():
data_dir.mkdir(parents=True, exist_ok=True)
size_str = f"size_{args.size}" if args.size is not None else "size_random"
words_str = f"words_{args.num_words}" if args.num_words is not None else "words_random"
intersect_str = f"intersections_{args.max_intersections}" if args.max_intersections is not None else "intersections_random"
output_file = data_dir / "hyperparameter_search" / size_str / words_str / intersect_str / f"wordscapes_{args.num_questions}.jsonl"
else:
output_file = Path(args.output)
# Ensure output directory exists
output_file.parent.mkdir(parents=True, exist_ok=True)
# Convert to JSON serializable format and write to file
with open(output_file, "w") as f:
for data in game_data:
# Add UUID for tracing like Game of 24
json_data = {
"id": data.id,
"grid": data.grid,
"across_words": data.across_words,
"down_words": data.down_words,
"solution": data.solution,
"metadata": {
"trace_id": str(uuid.uuid4()),
"size": args.size,
"num_words": args.num_words,
"max_intersections": args.max_intersections
}
}
f.write(json.dumps(json_data, ensure_ascii=False) + "\n")
print(f"Generated {len(game_data)} puzzles and saved to {output_file}")
if __name__ == "__main__":
main()