Add Wordle, welcome system, integrations, and update roadmap
- Add Wordle game engine with daily puzzles, hard mode, stats, and share - Add welcome module (react-to-join onboarding, Space join DMs) - Add Ollama LLM integration (!ask), Minecraft RCON whitelist (!minecraft) - Add !trivia, !champion, !agent, !health commands - Add DM routing for Wordle (games in DMs, share to public room) - Update README: reflect Phase 4 completion, hookshot webhook setup, infrastructure migration (LXC 151/109 to large1), Spam and Stuff room, all 12 webhook connections with UUIDs and transform notes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
786
wordle.py
Normal file
786
wordle.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""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'<font data-mx-bg-color="{bg}" data-mx-color="#ffffff">'
|
||||
f"<b>\u00a0{letter}\u00a0</b></font>"
|
||||
)
|
||||
|
||||
|
||||
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 "<br>".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'<font data-mx-bg-color="{bg}" data-mx-color="{color}">'
|
||||
f"{letter}</font>"
|
||||
)
|
||||
kb_rows.append(" ".join(keys))
|
||||
|
||||
return "<br>" + "<br>".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 = "<strong>Wordle Statistics</strong><br><br>"
|
||||
html += (
|
||||
f"<b>{played}</b> Played | "
|
||||
f"<b>{win_pct:.0f}%</b> Win | "
|
||||
f"<b>{streak}</b> Streak | "
|
||||
f"<b>{max_streak}</b> Best<br><br>"
|
||||
)
|
||||
|
||||
html += "<strong>Guess Distribution</strong><br>"
|
||||
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} <font data-mx-bg-color="#538d4e" data-mx-color="#ffffff"> {bar} {count} </font><br>'
|
||||
else:
|
||||
html += f'{i} <font data-mx-bg-color="#3a3a3c" data-mx-color="#ffffff"> {bar} {count} </font><br>'
|
||||
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 <word> 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 = (
|
||||
"<h4>Wordle</h4>"
|
||||
"<p>Guess the 5-letter word in 6 tries!</p>"
|
||||
"<strong>Commands:</strong>"
|
||||
"<ul>"
|
||||
f"<li><code>{p}wordle</code> — Start today's daily puzzle</li>"
|
||||
f"<li><code>{p}wordle <word></code> — Submit a guess</li>"
|
||||
f"<li><code>{p}wordle stats</code> — View your statistics</li>"
|
||||
f"<li><code>{p}wordle hard</code> — Toggle hard mode</li>"
|
||||
f"<li><code>{p}wordle share</code> — Share your last result</li>"
|
||||
f"<li><code>{p}wordle give up</code> — Forfeit current game</li>"
|
||||
"</ul>"
|
||||
"<strong>How to play:</strong>"
|
||||
"<ul>"
|
||||
'<li><font data-mx-bg-color="#538d4e" data-mx-color="#ffffff"><b> G </b></font> '
|
||||
"Green = correct letter, correct position</li>"
|
||||
'<li><font data-mx-bg-color="#b59f3b" data-mx-color="#ffffff"><b> Y </b></font> '
|
||||
"Yellow = correct letter, wrong position</li>"
|
||||
'<li><font data-mx-bg-color="#3a3a3c" data-mx-color="#ffffff"><b> X </b></font> '
|
||||
"Gray = letter not in the word</li>"
|
||||
"</ul>"
|
||||
"<p><em>Hard mode:</em> You must use all revealed hints in subsequent guesses.</p>"
|
||||
"<p>Everyone gets the same daily word!</p>"
|
||||
)
|
||||
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"<strong>Wordle {game.daily_number}{mode}</strong> — "
|
||||
f"Guess {len(game.guesses) + 1}/6<br><br>"
|
||||
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 <word> to guess."
|
||||
)
|
||||
html = (
|
||||
f''
|
||||
f"<strong>Wordle #{puzzle_number}{mode_str}</strong><br>"
|
||||
f"Guess a 5-letter word! You have 6 attempts.<br>"
|
||||
f"Type <code>{BOT_PREFIX}wordle <word></code> to guess."
|
||||
f"<br><br>{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"<strong>{congrats}</strong> "
|
||||
f"Wordle {game.daily_number} {num}/6{mode}<br><br>"
|
||||
f"{grid_html}<br>"
|
||||
f"<em>Use <code>{BOT_PREFIX}wordle stats</code> for statistics "
|
||||
f"or <code>{BOT_PREFIX}wordle share</code> to share!</em>"
|
||||
)
|
||||
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"<strong>Wordle {game.daily_number} X/6{mode}</strong><br><br>"
|
||||
f"{grid_html}<br>"
|
||||
f'The word was: <font data-mx-color="#538d4e"><strong>'
|
||||
f"{game.target}</strong></font><br>"
|
||||
f"<em>Better luck tomorrow!</em>"
|
||||
)
|
||||
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"<strong>Wordle {game.daily_number}{mode}</strong> — "
|
||||
f"Guess {len(game.guesses) + 1}/6<br><br>"
|
||||
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"<strong>Game Over</strong><br><br>"
|
||||
f"{grid_html}<br>"
|
||||
f'The word was: <font data-mx-color="#538d4e"><strong>'
|
||||
f"{game.target}</strong></font><br>"
|
||||
f"<em>Better luck tomorrow!</em>"
|
||||
)
|
||||
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()
|
||||
Reference in New Issue
Block a user