mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-02 08:11:32 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
469 lines
15 KiB
Python
469 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
|
||
|
||
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."""
|
||
|
||
@abstractmethod
|
||
def render(
|
||
self,
|
||
round_id: int,
|
||
payload: dict[str, Any],
|
||
language: str,
|
||
back_callback: str = 'contests_menu',
|
||
) -> GameRenderResult:
|
||
"""Render game UI for user."""
|
||
|
||
@abstractmethod
|
||
def check_answer(
|
||
self,
|
||
user_answer: str,
|
||
payload: dict[str, Any],
|
||
language: str,
|
||
) -> AnswerCheckResult:
|
||
"""Check if user's answer is correct."""
|
||
|
||
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) -> BaseGameStrategy | None:
|
||
"""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())
|