"""Deterministic Connections archive matching and private post-game review. No model interprets a player's thoughts. Share colours identify group colours, not words, and exact guesses are analysed only when the player supplies them. """ from __future__ import annotations from collections import Counter from dataclasses import asdict import re import unicodedata from typing import Any, Iterable, Protocol from app.services.connections_data import ConnectionsPuzzle from app.services.puzzle_archive import content_hash VERSION = "connections-workbench-v1" COLOURS = {"🟨": "yellow", "🟩": "green", "🟦": "blue", "🟪": "purple"} class NumberedPuzzle(Protocol): @property def number(self) -> int: ... def normalise(text: str) -> str: value = unicodedata.normalize("NFKD", text).casefold() return " ".join( "".join(c if c.isalnum() else " " for c in value if not unicodedata.combining(c)).split() ) def revision(record: ConnectionsPuzzle) -> str: return content_hash(asdict(record)) def board_words(value: Any, *, count: int = 16) -> list[str]: if isinstance(value, str): value = re.split(r"[\n,;]+", value) if ( not isinstance(value, list) or len(value) != count or any(not isinstance(word, str) or not word.strip() or len(word) > 160 for word in value) ): raise ValueError( f"Enter exactly {count} entries, separated by lines, commas or semicolons." ) result = [word.strip() for word in value] if ( any(not normalise(word) for word in result) or len(set(normalise(word) for word in result)) != count ): raise ValueError("Entries must be distinct; check duplicated or empty words.") return result def recognise_board( words: list[str], records: Iterable[ConnectionsPuzzle] ) -> list[ConnectionsPuzzle]: signature = Counter(normalise(word) for word in board_words(words)) return [ record for record in records if Counter(normalise(word) for word in record.board) == signature ] def archive_matches(record: ConnectionsPuzzle, query: str, *, kind: str = "all") -> str | None: if not query.strip() or len(query) > 200: return None terms = [term.strip() for term in query.split(";") if term.strip()] if any(not normalise(term) for term in terms): return None values: list[str] = [] if kind in {"all", "word"}: values.extend(record.board) if kind in {"all", "category"}: values.extend(group.title for group in record.groups) if not values or not terms: return None if all(any(term.casefold() == value.casefold() for value in values) for term in terms): return "Exact match" if all(any(normalise(term) == normalise(value) for value in values) for term in terms): return "Normalised spelling or punctuation match" if kind != "word" and all( any(normalise(term) in normalise(value) for value in values) for term in terms ): return "Phrase match" return None def check_guess( record: ConnectionsPuzzle, guess: Any, *, solved: Any = (), reveal_missing: bool = False, allow_one_away: bool = True, ) -> dict[str, Any]: words = board_words(guess, count=4) if not isinstance(solved, (list, tuple)) or any( colour not in COLOURS.values() for colour in solved ): raise ValueError("Solved groups must use the four group colour names.") picked = {normalise(word) for word in words} board = {normalise(word) for word in record.board} if not picked <= board: raise ValueError("A selected entry is not on this puzzle. Check the date and spelling.") groups = [group for group in record.groups if group.color not in solved] if any( picked & {normalise(word) for word in group.words} for group in record.groups if group.color in solved ): raise ValueError("Your selection includes an entry from a group marked solved.") exact = next( (group for group in groups if picked == {normalise(word) for word in group.words}), None ) if exact: return { "status": "exact", "colour": exact.color, "message": "These four entries form one recorded group.", } near = [ group for group in groups if len(picked & {normalise(word) for word in group.words}) == 3 ] result: dict[str, Any] = { "status": "one_away" if near and allow_one_away else "no_match", "message": "Three entries share one recorded group." if near and allow_one_away else "These four entries do not form a recorded group.", } if reveal_missing and near: result["missing"] = [word for word in near[0].words if normalise(word) not in picked] return result def review_share(text: str, record: NumberedPuzzle) -> dict[str, Any]: if not isinstance(text, str) or len(text) > 5000: raise ValueError("Paste a Connections share result shorter than 5,000 characters.") if re.search(r"sports|soccer|football", text, re.I): raise ValueError("This reviewer supports the main Connections edition only.") number = re.search(r"(?:Puzzle\s*)?#\s*(\d+)", text, re.I) if not number or int(number.group(1)) != record.number: raise ValueError("The share must include the matching puzzle number; confirm the date.") rows = [] for line in text.splitlines(): if any(char in line for char in COLOURS): compact = line.replace("\ufe0f", "").replace(" ", "").strip() if len(compact) != 4 or any(char not in COLOURS for char in compact): raise ValueError("Each supported share row contains exactly four coloured squares.") rows.append([COLOURS[char] for char in compact]) elif line.strip() and not re.fullmatch( r"(?:Connections|Puzzle\s*#\s*\d+|#\s*\d+)", line.strip(), re.I ): raise ValueError("Unsupported share format. Paste the original main-edition result.") if not 1 <= len(rows) <= 7: raise ValueError("A Connections share contains one to seven guess rows.") solved: list[str] = [] mistakes = 0 for row in rows: if mistakes >= 4 or len(solved) == 4: raise ValueError("The share has rows after the game would have ended.") if any(colour in solved for colour in row): raise ValueError("The share reuses a group that was already solved.") if len(set(row)) == 1: solved.append(row[0]) else: mistakes += 1 return { "mode": "share_only", "rows": rows, "solved_colours": solved, "mistakes": mistakes, "outcome": "solved" if len(solved) == 4 else "failed" if mistakes == 4 else "incomplete", "limitation": "Colours do not identify the words you guessed, your reasoning, hint use or time. No word-level diagnosis can be recovered from this share.", "method": VERSION, } def review_guesses(record: ConnectionsPuzzle, guesses: Any) -> dict[str, Any]: if not isinstance(guesses, list) or not 1 <= len(guesses) <= 7: raise ValueError("Enter one to seven guesses, each containing four entries.") rows = [] solved: list[str] = [] mistakes = 0 for guess in guesses: if mistakes >= 4 or len(solved) == 4: raise ValueError("There are guesses after the game would have ended.") result = check_guess(record, guess, solved=solved) if result["status"] == "exact": solved.append(result["colour"]) else: mistakes += 1 rows.append({"entries": board_words(guess, count=4), **result}) return { "mode": "exact_guesses", "rows": rows, "solved_colours": solved, "mistakes": mistakes, "outcome": "solved" if len(solved) == 4 else "failed" if mistakes == 4 else "incomplete", "limitation": "This checks your supplied guesses against the recorded partition. It does not infer why you chose a word or measure unaided skill.", "method": VERSION, } def source_packet(record: ConnectionsPuzzle) -> dict[str, Any]: """Draft preparation only: source accessibility is not a reuse licence.""" return { "key": f"connections:classic:main:en:{record.date}", "revision": revision(record), "answer_status": "verified", "official_solution": [word for group in record.groups for word in group.words], "groups": [asdict(group) for group in record.groups], "board": list(record.board), "game": "connections", "mode": "classic", "edition": "main", "language": "en", "date": record.date, "release_at": record.date + "T00:00:00+14:00", "evidence": [], "computations": [], "source_urls": [record.source_url], "source_note": "Attach rights-checked evidence and retrieval timestamps for editorial facts before drafting. Official puzzle facts are retained separately.", "generator_version": VERSION, }