95 lines
3.4 KiB
Python
95 lines
3.4 KiB
Python
"""funnies theme derivation and find-a-word grid construction.
|
|
|
|
Per functional design: BR1.1 (derive theme words + context from content),
|
|
BR1.3 (find-a-word places every theme word). Deterministic build; the cloud
|
|
assistance is only for crossword clues / cartoon context (not find-a-word).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
from .model import FindAWord
|
|
|
|
|
|
def derive_theme_words(content: str, max_words: int = 12) -> list[str]:
|
|
"""Extract theme words from the content (BR1.1).
|
|
|
|
Simple heuristic: split on non-word chars, drop stopwords/short words and
|
|
duplicates, keep uppercase-ish + longer words weighted toward themes. Kept
|
|
dependency-free; a more sophisticated extractor could sit behind this seam.
|
|
"""
|
|
stopwords = {
|
|
"the", "and", "for", "with", "that", "this", "from", "they", "their",
|
|
"will", "have", "been", "was", "were", "are", "our", "your", "we", "us",
|
|
"you", "a", "an", "to", "of", "on", "in", "at", "by", "is", "it", "or",
|
|
"but", "not", "so", "if", "as", "into", "about", "after", "before",
|
|
}
|
|
words = [
|
|
w.lower().strip(".,!?\"'()[]{}")
|
|
for w in content.split()
|
|
]
|
|
seen: dict[str, int] = {}
|
|
for w in words:
|
|
if not w or w in stopwords or len(w) < 3:
|
|
continue
|
|
score = 2 if w[0].isupper() else 1 # proper nouns / names weigh more
|
|
seen[w] = seen.get(w, 0) + score
|
|
ranked = sorted(seen.items(), key=lambda kv: kv[1], reverse=True)
|
|
return [w for w, _ in ranked[:max_words]]
|
|
|
|
|
|
def build_find_a_word(
|
|
words: list[str],
|
|
grid_size: int = 12,
|
|
rng: random.Random | None = None,
|
|
) -> FindAWord:
|
|
"""Place every theme word into a grid (BR1.3). Returns a printable grid.
|
|
|
|
Deterministic: longest words first, each placed via a systematic row-major
|
|
scan of every (row, col, direction) until it fits. Guarantees placement for
|
|
any word the grid is large enough to hold.
|
|
"""
|
|
rng = rng or random.Random(20260913)
|
|
grid = [["." for _ in range(grid_size)] for _ in range(grid_size)]
|
|
directions = [(0, 1), (1, 0), (1, 1), (-1, 1)] # right, down, diag down-right, diag up-right
|
|
|
|
for word in sorted(words, key=len, reverse=True):
|
|
placed = _try_place_systematic(word, grid, grid_size, directions)
|
|
if not placed:
|
|
# Could not fit on this grid; skip rather than corrupt the build.
|
|
continue
|
|
|
|
return FindAWord(find_id="faw-1", words=words, grid=grid)
|
|
|
|
|
|
def _try_place_systematic(
|
|
word: str, grid: list[list[str]], size: int, directions
|
|
) -> bool:
|
|
for row in range(size):
|
|
for col in range(size):
|
|
for dr, dc in directions:
|
|
if _fits(word, row, col, dr, dc, grid):
|
|
_place(word, row, col, dr, dc, grid)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _fits(word: str, row: int, col: int, dr: int, dc: int, grid: list[list[str]]) -> bool:
|
|
size = len(grid)
|
|
end_row = row + dr * (len(word) - 1)
|
|
end_col = col + dc * (len(word) - 1)
|
|
if not (0 <= end_row < size and 0 <= end_col < size):
|
|
return False
|
|
for i, ch in enumerate(word):
|
|
r, c = row + dr * i, col + dc * i
|
|
cell = grid[r][c]
|
|
if cell != "." and cell != ch:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _place(word: str, row: int, col: int, dr: int, dc: int, grid: list[list[str]]) -> None:
|
|
for i, ch in enumerate(word):
|
|
grid[row + dr * i][col + dc * i] = ch.upper()
|