"""Wordle game for Matrix bot. Full implementation with daily puzzles, statistics tracking, hard mode, shareable results, and rich HTML rendering. """ import json import logging import time from dataclasses import dataclass, field from datetime import date from pathlib import Path from nio import AsyncClient from utils import send_text, send_html, get_or_create_dm from config import BOT_PREFIX from wordlist_answers import ANSWERS from wordlist_valid import VALID_GUESSES logger = logging.getLogger("matrixbot") # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- _WORDLE_EPOCH = date(2021, 6, 19) # Build lookup sets at import time _ANSWER_LIST = [w.upper() for w in ANSWERS] _VALID_SET = frozenset(w.upper() for w in VALID_GUESSES) | frozenset(_ANSWER_LIST) # Tile colors (Wordle official palette) _TILE = { 2: {"bg": "#538d4e", "label": "correct"}, # Green 1: {"bg": "#b59f3b", "label": "present"}, # Yellow 0: {"bg": "#3a3a3c", "label": "absent"}, # Gray } _EMPTY_BG = "#121213" _EMPTY_BORDER = "#3a3a3c" # Emoji squares for plain-text fallback & share _EMOJI = {2: "\U0001f7e9", 1: "\U0001f7e8", 0: "\u2b1b"} # Keyboard layout _KB_ROWS = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"] # Stats file STATS_FILE = Path("wordle_stats.json") # Congratulations messages by guess number _CONGRATS = { 1: "Genius!", 2: "Magnificent!", 3: "Impressive!", 4: "Splendid!", 5: "Great!", 6: "Phew!", } # --------------------------------------------------------------------------- # Data structures # --------------------------------------------------------------------------- @dataclass class WordleGame: player_id: str room_id: str target: str guesses: list = field(default_factory=list) results: list = field(default_factory=list) hard_mode: bool = False daily_number: int = 0 started_at: float = field(default_factory=time.time) finished: bool = False won: bool = False origin_room_id: str = "" # Public room where game was started (for share) # Module-level state _active_games: dict[str, WordleGame] = {} _all_stats: dict[str, dict] = {} # --------------------------------------------------------------------------- # Stats persistence # --------------------------------------------------------------------------- def _load_stats(): global _all_stats if STATS_FILE.exists(): try: _all_stats = json.loads(STATS_FILE.read_text()) except (json.JSONDecodeError, OSError) as e: logger.error("Failed to load wordle stats: %s", e) _all_stats = {} else: _all_stats = {} def _save_stats(): try: tmp = STATS_FILE.with_suffix(".tmp") tmp.write_text(json.dumps(_all_stats, indent=2)) tmp.rename(STATS_FILE) except OSError as e: logger.error("Failed to save wordle stats: %s", e) def _get_player_stats(player_id: str) -> dict: if player_id not in _all_stats: _all_stats[player_id] = { "games_played": 0, "games_won": 0, "current_streak": 0, "max_streak": 0, "guess_distribution": {str(i): 0 for i in range(1, 7)}, "last_daily": -1, "hard_mode": False, "last_daily_result": None, "last_daily_guesses": None, } return _all_stats[player_id] def _record_game_result(player_id: str, game: WordleGame): stats = _get_player_stats(player_id) stats["games_played"] += 1 if game.won: stats["games_won"] += 1 stats["current_streak"] += 1 stats["max_streak"] = max(stats["max_streak"], stats["current_streak"]) num_guesses = str(len(game.guesses)) stats["guess_distribution"][num_guesses] = ( stats["guess_distribution"].get(num_guesses, 0) + 1 ) else: stats["current_streak"] = 0 stats["last_daily"] = game.daily_number stats["last_daily_result"] = game.results stats["last_daily_guesses"] = game.guesses stats["last_daily_won"] = game.won stats["last_daily_hard"] = game.hard_mode stats["last_origin_room"] = game.origin_room_id _save_stats() # --------------------------------------------------------------------------- # Core algorithms # --------------------------------------------------------------------------- def get_daily_word() -> tuple[str, int]: """Return (word, puzzle_number) for today's daily puzzle.""" today = date.today() puzzle_number = (today - _WORDLE_EPOCH).days word = _ANSWER_LIST[puzzle_number % len(_ANSWER_LIST)] return word, puzzle_number def evaluate_guess(guess: str, target: str) -> list[int]: """Evaluate a guess against the target. Returns list of 5 scores: 2 = correct position (green), 1 = wrong position (yellow), 0 = absent (gray). Handles duplicate letters correctly with a two-pass approach. """ result = [0] * 5 target_remaining = list(target) # Pass 1: mark exact matches (green) for i in range(5): if guess[i] == target[i]: result[i] = 2 target_remaining[i] = None # Pass 2: mark present-but-wrong-position (yellow) for i in range(5): if result[i] == 2: continue if guess[i] in target_remaining: result[i] = 1 target_remaining[target_remaining.index(guess[i])] = None return result def validate_hard_mode( guess: str, previous_guesses: list[str], previous_results: list[list[int]], ) -> str | None: """Return None if valid, or an error message if hard mode violated.""" for prev_guess, prev_result in zip(previous_guesses, previous_results): for i, (letter, score) in enumerate(zip(prev_guess, prev_result)): if score == 2 and guess[i] != letter: return ( f"Hard mode: position {i + 1} must be " f"'{letter}' (green from previous guess)" ) if score == 1 and letter not in guess: return ( f"Hard mode: guess must contain " f"'{letter}' (yellow from previous guess)" ) return None # --------------------------------------------------------------------------- # HTML rendering # --------------------------------------------------------------------------- def _tile_span(letter: str, bg: str) -> str: """Render a single letter tile using Matrix-compatible attributes.""" return ( f'' f"\u00a0{letter}\u00a0" ) def render_grid_html(game: WordleGame) -> str: """Render the Wordle grid as inline spans (compatible with Cinny).""" rows = [] for row_idx in range(6): tiles = [] if row_idx < len(game.guesses): guess = game.guesses[row_idx] result = game.results[row_idx] for letter, score in zip(guess, result): bg = _TILE[score]["bg"] tiles.append(_tile_span(letter, bg)) else: for _ in range(5): tiles.append(_tile_span("\u00a0", _EMPTY_BG)) rows.append("".join(tiles)) return "
".join(rows) def render_keyboard_html(game: WordleGame) -> str: """Render a virtual keyboard showing letter states.""" letter_states: dict[str, int] = {} for guess, result in zip(game.guesses, game.results): for letter, score in zip(guess, result): letter_states[letter] = max(letter_states.get(letter, -1), score) kb_rows = [] for row in _KB_ROWS: keys = [] for letter in row: state = letter_states.get(letter, -1) if state == -1: bg, color = "#818384", "#ffffff" elif state == 0: bg, color = "#3a3a3c", "#555555" elif state == 1: bg, color = "#b59f3b", "#ffffff" else: bg, color = "#538d4e", "#ffffff" keys.append( f'' f"{letter}" ) kb_rows.append(" ".join(keys)) return "
" + "
".join(kb_rows) def render_grid_plain(game: WordleGame) -> str: """Plain text grid with emoji squares and letter markers.""" _marker = {2: "!", 1: "?", 0: "."} # ! = correct, ? = wrong spot, . = absent lines = [] for guess, result in zip(game.guesses, game.results): emoji_row = "".join(_EMOJI[s] for s in result) # Show each letter with a marker: [C!] = correct, [R?] = wrong spot, [A.] = absent marked = " ".join(f"{letter}{_marker[score]}" for letter, score in zip(guess, result)) lines.append(f"{emoji_row} {marked}") return "\n".join(lines) def render_keyboard_plain(game: WordleGame) -> str: """Plain text keyboard status.""" letter_states: dict[str, int] = {} for guess, result in zip(game.guesses, game.results): for letter, score in zip(guess, result): letter_states[letter] = max(letter_states.get(letter, -1), score) lines = [] symbols = {-1: " ", 0: "\u2717", 1: "?", 2: "\u2713"} for row in _KB_ROWS: chars = [] for letter in row: state = letter_states.get(letter, -1) if state == 0: chars.append("\u00b7") # dimmed elif state >= 1: chars.append(letter) else: chars.append(letter.lower()) lines.append(" ".join(chars)) return "\n".join(lines) def render_stats_html(stats: dict) -> str: """Render player statistics as HTML (Matrix-compatible).""" played = stats["games_played"] won = stats["games_won"] win_pct = (won / max(played, 1)) * 100 streak = stats["current_streak"] max_streak = stats["max_streak"] dist = stats["guess_distribution"] max_count = max((int(v) for v in dist.values()), default=1) or 1 html = "Wordle Statistics

" html += ( f"{played} Played | " f"{win_pct:.0f}% Win | " f"{streak} Streak | " f"{max_streak} Best

" ) html += "Guess Distribution
" for i in range(1, 7): count = int(dist.get(str(i), 0)) is_max = count == max_count and count > 0 bar_len = max(int((count / max_count) * 10), 1) if max_count > 0 else 1 bar = "\u2588" * bar_len # Block character for bar if is_max and count > 0: html += f'{i} {bar} {count}
' else: html += f'{i} {bar} {count}
' return html def render_stats_plain(stats: dict) -> str: """Plain text stats.""" played = stats["games_played"] won = stats["games_won"] win_pct = (won / max(played, 1)) * 100 streak = stats["current_streak"] max_streak = stats["max_streak"] dist = stats["guess_distribution"] max_count = max((int(v) for v in dist.values()), default=1) or 1 lines = [ "Wordle Statistics", f"Played: {played} | Win: {win_pct:.0f}% | Streak: {streak} | Max: {max_streak}", "", "Guess Distribution:", ] for i in range(1, 7): count = int(dist.get(str(i), 0)) bar_len = max(round((count / max_count) * 16), 1) if count > 0 else 0 bar = "\u2588" * bar_len lines.append(f" {i}: {bar} {count}") return "\n".join(lines) def generate_share(stats: dict) -> str: """Generate the shareable emoji grid from last completed daily.""" results = stats.get("last_daily_result") if not results: return "" won = stats.get("last_daily_won", False) hard = stats.get("last_daily_hard", False) daily_num = stats.get("last_daily", 0) score = str(len(results)) if won else "X" mode = "*" if hard else "" header = f"Wordle {daily_num} {score}/6{mode}\n\n" rows = [] for result in results: rows.append("".join(_EMOJI[s] for s in result)) return header + "\n".join(rows) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- async def wordle_help(client: AsyncClient, room_id: str): """Show help text with rules and commands.""" p = BOT_PREFIX plain = ( f"Wordle - Guess the 5-letter word in 6 tries!\n\n" f"Commands:\n" f" {p}wordle Start today's daily puzzle (or show current game)\n" f" {p}wordle Submit a 5-letter guess\n" f" {p}wordle stats View your statistics\n" f" {p}wordle hard Toggle hard mode\n" f" {p}wordle share Share your last daily result\n" f" {p}wordle give up Forfeit current game\n" f" {p}wordle help Show this help\n\n" f"Rules:\n" f" - Each guess must be a valid 5-letter English word\n" f" - Green = correct letter, correct position\n" f" - Yellow = correct letter, wrong position\n" f" - Gray = letter not in the word\n" f" - Hard mode: must use all revealed hints in subsequent guesses\n" f" - Everyone gets the same daily word!" ) html = ( "

Wordle

" "

Guess the 5-letter word in 6 tries!

" "Commands:" "" "How to play:" "" "

Hard mode: You must use all revealed hints in subsequent guesses.

" "

Everyone gets the same daily word!

" ) await send_html(client, room_id, plain, html) async def wordle_start_or_status(client: AsyncClient, room_id: str, sender: str, origin_room_id: str = ""): """Start a new daily game or show current game status.""" # Check for active game if sender in _active_games: game = _active_games[sender] if not game.finished: guesses_left = 6 - len(game.guesses) grid_plain = render_grid_plain(game) kb_plain = render_keyboard_plain(game) plain = ( f"Wordle {game.daily_number} — " f"Guess {len(game.guesses) + 1}/6\n\n" f"{grid_plain}\n\n{kb_plain}" ) grid_html = render_grid_html(game) kb_html = render_keyboard_html(game) mode = " (Hard Mode)" if game.hard_mode else "" html = ( f'' f"Wordle {game.daily_number}{mode} — " f"Guess {len(game.guesses) + 1}/6

" f"{grid_html}{kb_html}" ) await send_html(client, room_id, plain, html) return # Check if already completed today's puzzle word, puzzle_number = get_daily_word() stats = _get_player_stats(sender) if stats["last_daily"] == puzzle_number: await send_text( client, room_id, f"You already completed today's Wordle (#{puzzle_number})! " f"Use {BOT_PREFIX}wordle stats to see your results " f"or {BOT_PREFIX}wordle share to share them." ) return # Start new game hard_mode = stats.get("hard_mode", False) game = WordleGame( player_id=sender, room_id=room_id, target=word, hard_mode=hard_mode, daily_number=puzzle_number, origin_room_id=origin_room_id or room_id, ) _active_games[sender] = game mode_str = " (Hard Mode)" if hard_mode else "" grid_html = render_grid_html(game) kb_html = render_keyboard_html(game) plain = ( f"Wordle #{puzzle_number}{mode_str}\n" f"Guess a 5-letter word! You have 6 attempts.\n" f"Type {BOT_PREFIX}wordle to guess." ) html = ( f'' f"Wordle #{puzzle_number}{mode_str}
" f"Guess a 5-letter word! You have 6 attempts.
" f"Type {BOT_PREFIX}wordle <word> to guess." f"

{grid_html}{kb_html}" ) await send_html(client, room_id, plain, html) async def wordle_guess( client: AsyncClient, room_id: str, sender: str, guess: str ): """Process a guess.""" if sender not in _active_games: await send_text( client, room_id, f"No active game. Start one with {BOT_PREFIX}wordle" ) return game = _active_games[sender] if game.finished: await send_text( client, room_id, f"Your game is already finished! " f"Use {BOT_PREFIX}wordle to start a new daily puzzle." ) return # Validate word if guess not in _VALID_SET: await send_text(client, room_id, f"'{guess.lower()}' is not in the word list. Try again.") return # Hard mode validation if game.hard_mode and game.guesses: violation = validate_hard_mode(guess, game.guesses, game.results) if violation: await send_text(client, room_id, violation) return # Evaluate result = evaluate_guess(guess, game.target) game.guesses.append(guess) game.results.append(result) # Check win if all(s == 2 for s in result): game.finished = True game.won = True _record_game_result(sender, game) del _active_games[sender] num = len(game.guesses) congrats = _CONGRATS.get(num, "Nice!") grid_plain = render_grid_plain(game) plain = ( f"{congrats} Wordle {game.daily_number} {num}/6" f"{'*' if game.hard_mode else ''}\n\n" f"{grid_plain}\n\n" f"Use {BOT_PREFIX}wordle stats for your statistics " f"or {BOT_PREFIX}wordle share to share!" ) grid_html = render_grid_html(game) mode = "*" if game.hard_mode else "" html = ( f'' f"{congrats} " f"Wordle {game.daily_number} {num}/6{mode}

" f"{grid_html}
" f"Use {BOT_PREFIX}wordle stats for statistics " f"or {BOT_PREFIX}wordle share to share!" ) await send_html(client, room_id, plain, html) return # Check loss (6 guesses used) if len(game.guesses) >= 6: game.finished = True game.won = False _record_game_result(sender, game) del _active_games[sender] grid_plain = render_grid_plain(game) plain = ( f"Wordle {game.daily_number} X/6" f"{'*' if game.hard_mode else ''}\n\n" f"{grid_plain}\n\n" f"The word was: {game.target}\n" f"Better luck tomorrow!" ) grid_html = render_grid_html(game) mode = "*" if game.hard_mode else "" html = ( f'' f"Wordle {game.daily_number} X/6{mode}

" f"{grid_html}
" f'The word was: ' f"{game.target}
" f"Better luck tomorrow!" ) await send_html(client, room_id, plain, html) return # Still playing — show grid + keyboard guesses_left = 6 - len(game.guesses) grid_plain = render_grid_plain(game) kb_plain = render_keyboard_plain(game) plain = ( f"Wordle {game.daily_number} — " f"Guess {len(game.guesses) + 1}/6\n\n" f"{grid_plain}\n\n{kb_plain}" ) grid_html = render_grid_html(game) kb_html = render_keyboard_html(game) mode = " (Hard Mode)" if game.hard_mode else "" html = ( f'' f"Wordle {game.daily_number}{mode} — " f"Guess {len(game.guesses) + 1}/6

" f"{grid_html}{kb_html}" ) await send_html(client, room_id, plain, html) async def wordle_stats(client: AsyncClient, room_id: str, sender: str): """Show player statistics.""" stats = _get_player_stats(sender) if stats["games_played"] == 0: await send_text( client, room_id, f"No Wordle stats yet! Start a game with {BOT_PREFIX}wordle" ) return plain = render_stats_plain(stats) html = render_stats_html(stats) await send_html(client, room_id, plain, html) async def wordle_toggle_hard(client: AsyncClient, room_id: str, sender: str): """Toggle hard mode for the player.""" stats = _get_player_stats(sender) new_mode = not stats.get("hard_mode", False) stats["hard_mode"] = new_mode _save_stats() # Also update active game if one exists if sender in _active_games: game = _active_games[sender] if not game.guesses: # Only allow toggling before first guess game.hard_mode = new_mode elif new_mode: await send_text( client, room_id, "Hard mode enabled for future games. " "Cannot enable mid-game after guessing." ) return else: game.hard_mode = False status = "enabled" if new_mode else "disabled" plain = ( f"Hard mode {status}. " + ("You must use all revealed hints in subsequent guesses." if new_mode else "Standard rules apply.") ) await send_text(client, room_id, plain) async def wordle_share(client: AsyncClient, room_id: str, sender: str): """Share the last completed daily result.""" stats = _get_player_stats(sender) share_text = generate_share(stats) if not share_text: await send_text( client, room_id, f"No completed daily puzzle to share. Play one with {BOT_PREFIX}wordle" ) return await send_text(client, room_id, share_text) async def wordle_give_up(client: AsyncClient, room_id: str, sender: str): """Forfeit the current game.""" if sender not in _active_games: await send_text( client, room_id, f"No active game to give up. Start one with {BOT_PREFIX}wordle" ) return game = _active_games[sender] if game.finished: del _active_games[sender] return game.finished = True game.won = False _record_game_result(sender, game) del _active_games[sender] grid_plain = render_grid_plain(game) plain = ( f"Game over! The word was: {game.target}\n\n" f"{grid_plain}\n\n" f"Better luck tomorrow!" ) grid_html = render_grid_html(game) html = ( f'' f"Game Over

" f"{grid_html}
" f'The word was: ' f"{game.target}
" f"Better luck tomorrow!" ) await send_html(client, room_id, plain, html) # --------------------------------------------------------------------------- # Main router # --------------------------------------------------------------------------- async def _get_dm_room(client: AsyncClient, room_id: str, sender: str) -> tuple[str, str]: """Get or create DM room for the sender. Returns (dm_room_id, origin_room_id). If already in a DM, returns (room_id, stored_origin or room_id). If in a public room, creates/finds DM and returns (dm_room_id, room_id). """ # Check if this is already a DM (2 members) room = client.rooms.get(room_id) if room and room.member_count == 2: # Already in DM — use origin from active game if available game = _active_games.get(sender) origin = game.origin_room_id if game and game.origin_room_id else room_id return room_id, origin # Public room — find/create DM dm_room = await get_or_create_dm(client, sender) if dm_room: return dm_room, room_id # Fallback to public room if DM creation fails logger.warning("Could not create DM with %s, falling back to public room", sender) return room_id, room_id async def handle_wordle( client: AsyncClient, room_id: str, sender: str, args: str ): """Main entry point — dispatches to subcommands.""" parts = args.strip().split(None, 1) subcmd = parts[0].lower() if parts else "" sub_args = parts[1] if len(parts) > 1 else "" if subcmd == "help": # Help can go to the same room dm_room, origin = await _get_dm_room(client, room_id, sender) await wordle_help(client, dm_room) elif subcmd == "stats": dm_room, origin = await _get_dm_room(client, room_id, sender) await wordle_stats(client, dm_room, sender) elif subcmd == "hard": dm_room, origin = await _get_dm_room(client, room_id, sender) await wordle_toggle_hard(client, dm_room, sender) elif subcmd == "share": # Share goes to the PUBLIC room (origin), not DM await wordle_share(client, room_id, sender) elif subcmd == "give" and sub_args.lower().startswith("up"): dm_room, origin = await _get_dm_room(client, room_id, sender) await wordle_give_up(client, dm_room, sender) elif subcmd == "": dm_room, origin = await _get_dm_room(client, room_id, sender) await wordle_start_or_status(client, dm_room, sender, origin) elif len(subcmd) == 5 and subcmd.isalpha(): dm_room, origin = await _get_dm_room(client, room_id, sender) await wordle_guess(client, dm_room, sender, subcmd.upper()) else: await send_text( client, room_id, f"Invalid wordle command or guess. " f"Guesses must be exactly 5 letters. " f"Try {BOT_PREFIX}wordle help" ) # --------------------------------------------------------------------------- # Load stats on module import # --------------------------------------------------------------------------- _load_stats()