86dd18fbe7
Рефакторинг архитектуры ежедневных конкурсов: - Создан модуль app/services/contests/ с новой архитектурой: - enums.py: GameType, RoundStatus, PrizeType enum классы - games.py: паттерн Стратегия для 7 типов игр - attempt_service.py: ContestAttemptService для атомарных операций - Упрощён handlers/contests.py: - Удалены отдельные _render_* функции (заменены на стратегии) - Логика обработки попыток вынесена в ContestAttemptService - Уменьшено с 523 до 342 строк (-35%) - Обновлён contest_rotation_service.py: - Заменена if-elif цепочка на get_game_strategy().build_payload() - Используются enum классы вместо магических строк - Исправлен handlers/admin/daily_contests.py: - prize_days → prize_type/prize_value (соответствие модели БД) - Обновлены EDITABLE_FIELDS и отображение приза 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
474 lines
15 KiB
Python
474 lines
15 KiB
Python
"""Game strategies for different contest types."""
|
|
|
|
import random
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from aiogram import types
|
|
|
|
from app.services.contests.enums import GameType
|
|
|
|
|
|
@dataclass
|
|
class GameRenderResult:
|
|
"""Result of rendering a game."""
|
|
|
|
text: str
|
|
keyboard: types.InlineKeyboardMarkup
|
|
requires_text_input: bool = False
|
|
|
|
|
|
@dataclass
|
|
class AnswerCheckResult:
|
|
"""Result of checking user's answer."""
|
|
|
|
is_correct: bool
|
|
response_text: str
|
|
|
|
|
|
class BaseGameStrategy(ABC):
|
|
"""Base class for game strategies."""
|
|
|
|
game_type: GameType
|
|
|
|
@abstractmethod
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Build round-specific payload from template config."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
"""Render game UI for user."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
"""Check if user's answer is correct."""
|
|
pass
|
|
|
|
def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton:
|
|
from app.localization.texts import get_texts
|
|
texts = get_texts(language)
|
|
return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback)
|
|
|
|
def _get_texts(self, language: str):
|
|
from app.localization.texts import get_texts
|
|
return get_texts(language)
|
|
|
|
|
|
class QuestButtonsStrategy(BaseGameStrategy):
|
|
"""3x3 grid game - find the secret button."""
|
|
|
|
game_type = GameType.QUEST_BUTTONS
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
rows = template_payload.get("rows", 3)
|
|
cols = template_payload.get("cols", 3)
|
|
total = rows * cols
|
|
secret_idx = random.randint(0, total - 1)
|
|
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
rows = payload.get("rows", 3)
|
|
cols = payload.get("cols", 3)
|
|
|
|
keyboard_rows = []
|
|
for r in range(rows):
|
|
row_buttons = []
|
|
for c in range(cols):
|
|
idx = r * cols + c
|
|
row_buttons.append(
|
|
types.InlineKeyboardButton(
|
|
text="🎛",
|
|
callback_data=f"contest_pick_{round_id}_quest_{idx}",
|
|
)
|
|
)
|
|
keyboard_rows.append(row_buttons)
|
|
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
|
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
secret_idx = payload.get("secret_idx")
|
|
try:
|
|
if user_answer.startswith("quest_"):
|
|
idx = int(user_answer.split("_")[1])
|
|
is_correct = secret_idx is not None and idx == secret_idx
|
|
else:
|
|
is_correct = False
|
|
except (ValueError, IndexError):
|
|
is_correct = False
|
|
|
|
responses = ["Пусто", "Ложный сервер", "Найди другой узел"]
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else random.choice(responses),
|
|
)
|
|
|
|
|
|
class LockHackStrategy(BaseGameStrategy):
|
|
"""20 locks game - find the hacked one."""
|
|
|
|
game_type = GameType.LOCK_HACK
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
total = template_payload.get("buttons", 20)
|
|
secret_idx = random.randint(0, max(0, total - 1))
|
|
return {"total": total, "secret_idx": secret_idx}
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
total = payload.get("total", 20)
|
|
|
|
keyboard_rows = []
|
|
row = []
|
|
for i in range(total):
|
|
row.append(
|
|
types.InlineKeyboardButton(
|
|
text="🔒",
|
|
callback_data=f"contest_pick_{round_id}_locks_{i}",
|
|
)
|
|
)
|
|
if len(row) == 5:
|
|
keyboard_rows.append(row)
|
|
row = []
|
|
if row:
|
|
keyboard_rows.append(row)
|
|
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
|
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
secret_idx = payload.get("secret_idx")
|
|
try:
|
|
if user_answer.startswith("locks_"):
|
|
idx = int(user_answer.split("_")[1])
|
|
is_correct = secret_idx is not None and idx == secret_idx
|
|
else:
|
|
is_correct = False
|
|
except (ValueError, IndexError):
|
|
is_correct = False
|
|
|
|
responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"]
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else random.choice(responses),
|
|
)
|
|
|
|
|
|
class ServerLotteryStrategy(BaseGameStrategy):
|
|
"""Flag lottery game - pick the correct server flag."""
|
|
|
|
game_type = GameType.SERVER_LOTTERY
|
|
|
|
DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"]
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
flags = template_payload.get("flags") or self.DEFAULT_FLAGS
|
|
secret_idx = random.randint(0, len(flags) - 1)
|
|
return {"flags": flags, "secret_idx": secret_idx}
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
flags = payload.get("flags") or []
|
|
shuffled_flags = flags.copy()
|
|
random.shuffle(shuffled_flags)
|
|
|
|
keyboard_rows = []
|
|
row = []
|
|
for flag in shuffled_flags:
|
|
row.append(
|
|
types.InlineKeyboardButton(
|
|
text=flag,
|
|
callback_data=f"contest_pick_{round_id}_{flag}",
|
|
)
|
|
)
|
|
if len(row) == 5:
|
|
keyboard_rows.append(row)
|
|
row = []
|
|
if row:
|
|
keyboard_rows.append(row)
|
|
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
|
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
secret_idx = payload.get("secret_idx")
|
|
flags = payload.get("flags") or []
|
|
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
|
is_correct = user_answer == correct_flag
|
|
|
|
responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"]
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else random.choice(responses),
|
|
)
|
|
|
|
|
|
class BlitzReactionStrategy(BaseGameStrategy):
|
|
"""Blitz reaction game - press button quickly."""
|
|
|
|
game_type = GameType.BLITZ_REACTION
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {"timeout_seconds": template_payload.get("timeout_seconds", 10)}
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
|
|
keyboard = types.InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[
|
|
types.InlineKeyboardButton(
|
|
text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"),
|
|
callback_data=f"contest_pick_{round_id}_blitz",
|
|
)
|
|
],
|
|
[self._get_back_button(language, back_callback)],
|
|
]
|
|
)
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
|
keyboard=keyboard,
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
is_correct = user_answer == "blitz"
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else "Время вышло",
|
|
)
|
|
|
|
|
|
class LetterCipherStrategy(BaseGameStrategy):
|
|
"""Letter cipher game - decode word from letter codes."""
|
|
|
|
game_type = GameType.LETTER_CIPHER
|
|
|
|
DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"]
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
words = template_payload.get("words") or self.DEFAULT_WORDS
|
|
word = random.choice(words)
|
|
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
|
return {"question": "-".join(codes), "answer": word.upper()}
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
question = payload.get("question", "")
|
|
from app.keyboards.inline import get_back_keyboard
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
|
keyboard=get_back_keyboard(language),
|
|
requires_text_input=True,
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
correct = (payload.get("answer") or "").upper()
|
|
is_correct = correct and user_answer.strip().upper() == correct
|
|
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
|
)
|
|
|
|
|
|
class EmojiGuessStrategy(BaseGameStrategy):
|
|
"""Emoji guess game - guess service by emoji."""
|
|
|
|
game_type = GameType.EMOJI_GUESS
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
|
pair = random.choice(pairs)
|
|
return pair
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
question = payload.get("question", "🤔")
|
|
emoji_list = question.split()
|
|
random.shuffle(emoji_list)
|
|
shuffled_question = " ".join(emoji_list)
|
|
from app.keyboards.inline import get_back_keyboard
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(
|
|
q=shuffled_question
|
|
),
|
|
keyboard=get_back_keyboard(language),
|
|
requires_text_input=True,
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
correct = (payload.get("answer") or "").upper()
|
|
is_correct = correct and user_answer.strip().upper() == correct
|
|
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
|
)
|
|
|
|
|
|
class AnagramStrategy(BaseGameStrategy):
|
|
"""Anagram game - unscramble letters to form a word."""
|
|
|
|
game_type = GameType.ANAGRAM
|
|
|
|
DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"]
|
|
|
|
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
words = template_payload.get("words") or self.DEFAULT_WORDS
|
|
word = random.choice(words).upper()
|
|
shuffled = "".join(random.sample(word, len(word)))
|
|
return {"letters": shuffled, "answer": word}
|
|
|
|
def render(
|
|
self,
|
|
round_id: int,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
back_callback: str = "contests_menu",
|
|
) -> GameRenderResult:
|
|
texts = self._get_texts(language)
|
|
letters = payload.get("letters", "")
|
|
from app.keyboards.inline import get_back_keyboard
|
|
|
|
return GameRenderResult(
|
|
text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(
|
|
letters=letters
|
|
),
|
|
keyboard=get_back_keyboard(language),
|
|
requires_text_input=True,
|
|
)
|
|
|
|
def check_answer(
|
|
self,
|
|
user_answer: str,
|
|
payload: Dict[str, Any],
|
|
language: str,
|
|
) -> AnswerCheckResult:
|
|
correct = (payload.get("answer") or "").upper()
|
|
is_correct = correct and user_answer.strip().upper() == correct
|
|
|
|
return AnswerCheckResult(
|
|
is_correct=is_correct,
|
|
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
|
)
|
|
|
|
|
|
# Registry of game strategies
|
|
_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = {
|
|
GameType.QUEST_BUTTONS: QuestButtonsStrategy(),
|
|
GameType.LOCK_HACK: LockHackStrategy(),
|
|
GameType.SERVER_LOTTERY: ServerLotteryStrategy(),
|
|
GameType.BLITZ_REACTION: BlitzReactionStrategy(),
|
|
GameType.LETTER_CIPHER: LetterCipherStrategy(),
|
|
GameType.EMOJI_GUESS: EmojiGuessStrategy(),
|
|
GameType.ANAGRAM: AnagramStrategy(),
|
|
}
|
|
|
|
|
|
def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]:
|
|
"""Get game strategy by type."""
|
|
if isinstance(game_type, str):
|
|
try:
|
|
game_type = GameType(game_type)
|
|
except ValueError:
|
|
return None
|
|
return _GAME_STRATEGIES.get(game_type)
|
|
|
|
|
|
def get_all_game_types() -> List[GameType]:
|
|
"""Get list of all supported game types."""
|
|
return list(_GAME_STRATEGIES.keys())
|