""" Cities game feature (RU). Plays the classic "города" game: each next city starts with the last valid letter. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Optional _EXCLUDED_LAST_LETTERS = {"ь", "ъ", "ы", "й"} _CITY_TOKEN_RE = re.compile(r"[a-zа-яё]+", re.IGNORECASE) _NON_CITY_TOKENS = {"город", "города", "игра", "игру", "сыграем", "сыграть"} # Core city list (RU + a few common international cities in RU spelling). # Keep it curated and deterministic to avoid "rambling" responses. _CITIES = [ "Абакан", "Анапа", "Апатиты", "Архангельск", "Астрахань", "Барнаул", "Белгород", "Бердск", "Бийск", "Благовещенск", "Брянск", "Великий Новгород", "Великий Устюг", "Владивосток", "Владикавказ", "Владимир", "Волгоград", "Волгодонск", "Вологда", "Воронеж", "Выборг", "Гатчина", "Геленджик", "Екатеринбург", "Елабуга", "Ессентуки", "Жуковский", "Зеленоград", "Иваново", "Ижевск", "Иркутск", "Йошкар-Ола", "Казань", "Калининград", "Калуга", "Каменск-Уральский", "Кемерово", "Киров", "Кисловодск", "Клин", "Кострома", "Краснодар", "Красноярск", "Курган", "Курск", "Липецк", "Магадан", "Магнитогорск", "Майкоп", "Махачкала", "Минеральные Воды", "Москва", "Мурманск", "Муром", "Мытищи", "Набережные Челны", "Нальчик", "Находка", "Невинномысск", "Нижний Новгород", "Нижний Тагил", "Новороссийск", "Новосибирск", "Новочеркасск", "Новокузнецк", "Норильск", "Обнинск", "Омск", "Орёл", "Оренбург", "Орск", "Пенза", "Пермь", "Петрозаводск", "Петропавловск-Камчатский", "Псков", "Пятигорск", "Ростов-на-Дону", "Рязань", "Самара", "Санкт-Петербург", "Саратов", "Саров", "Севастополь", "Северодвинск", "Смоленск", "Сочи", "Ставрополь", "Старый Оскол", "Стерлитамак", "Сургут", "Сызрань", "Сыктывкар", "Таганрог", "Тамбов", "Тверь", "Тольятти", "Томск", "Туапсе", "Тула", "Тюмень", "Улан-Удэ", "Ульяновск", "Уфа", "Хабаровск", "Ханты-Мансийск", "Химки", "Чебоксары", "Челябинск", "Череповец", "Чита", "Элиста", "Южно-Сахалинск", "Якутск", "Ялта", "Ярославль", # International cities in Russian spelling (optional but handy). "Амстердам", "Афины", "Баку", "Белград", "Берлин", "Варшава", "Вена", "Вильнюс", "Дели", "Дублин", "Женева", "Иерусалим", "Каир", "Киев", "Лиссабон", "Лондон", "Мадрид", "Минск", "Монако", "Нью-Йорк", "Осака", "Осло", "Париж", "Прага", "Рига", "Рим", "Стамбул", "Таллин", "Тбилиси", "Токио", "Хельсинки", "Цюрих", ] def _normalize(text: str) -> str: text = text.lower().replace("ё", "е") text = re.sub(r"[^a-zа-я\s-]+", " ", text, flags=re.IGNORECASE) text = text.replace("-", " ") text = re.sub(r"\s+", " ", text).strip() return text def _first_letter(text: str) -> Optional[str]: for ch in text: if ch.isalpha(): return ch return None def _last_letter(text: str) -> Optional[str]: for ch in reversed(text): if not ch.isalpha(): continue if ch in _EXCLUDED_LAST_LETTERS: continue return ch return None def _tokenize(text: str) -> list[str]: return [m.group(0).lower().replace("ё", "е") for m in _CITY_TOKEN_RE.finditer(text)] _CITY_MAP = {} for city in _CITIES: norm = _normalize(city) if norm not in _CITY_MAP: _CITY_MAP[norm] = city _CITY_NORMS = sorted(_CITY_MAP.keys(), key=lambda s: (len(s), s)) _CITY_BY_FIRST = {} for norm, display in _CITY_MAP.items(): letter = _first_letter(norm) if not letter: continue _CITY_BY_FIRST.setdefault(letter, []).append((norm, display)) for letter in _CITY_BY_FIRST: _CITY_BY_FIRST[letter].sort(key=lambda pair: pair[0]) _START_PATTERNS = [ r"\b(игра|сыграем|сыграть|давай|хочу|можем)\b.*\bгорода\b", r"\bв\s+города\b", r"^города$", ] _STOP_PATTERNS = [ r"\b(стоп|хватит|выход|конец|закончим|останови)\b.*\b(игру|игра|города)?\b", r"\bсдаюсь\b", ] def _is_start_command(text: str) -> bool: return any(re.search(p, text, flags=re.IGNORECASE) for p in _START_PATTERNS) def _is_stop_command(text: str) -> bool: return any(re.search(p, text, flags=re.IGNORECASE) for p in _STOP_PATTERNS) def _extract_city(text: str) -> Optional[str]: tokens = _tokenize(text) if not tokens: return None # Try to find the longest matching city name among tokens. max_len = min(4, len(tokens)) for span in range(max_len, 0, -1): for i in range(len(tokens) - span + 1): candidate = " ".join(tokens[i : i + span]) if candidate in _CITY_MAP: return candidate # Fallback: treat the last token as a city if it looks plausible. candidate = tokens[-1] if candidate in _NON_CITY_TOKENS: return None if len(candidate) >= 3: return candidate return None @dataclass class CitiesGame: active: bool = False required_letter: Optional[str] = None used: set[str] = field(default_factory=set) def start(self) -> str: self.active = True self.required_letter = None self.used.clear() return "Давай сыграем в города. Назови город." def stop(self) -> str: self.active = False self.required_letter = None self.used.clear() return "Хорошо, заканчиваем игру в города." def handle(self, text: str) -> Optional[str]: normalized = _normalize(text) if not self.active: if _is_start_command(normalized): return self.start() return None if _is_stop_command(normalized): return self.stop() city_norm = _extract_city(normalized) if not city_norm: return "Назови город." if self.required_letter and not city_norm.startswith(self.required_letter): return f"Нужно на букву {self.required_letter.upper()}." if city_norm in self.used: return "Этот город уже был. Назови другой." self.used.add(city_norm) next_letter = _last_letter(city_norm) if not next_letter: return "Не могу определить следующую букву. Назови другой город." candidates = _CITY_BY_FIRST.get(next_letter, []) for candidate_norm, candidate_display in candidates: if candidate_norm not in self.used: self.used.add(candidate_norm) self.required_letter = _last_letter(candidate_norm) return candidate_display self.active = False self.required_letter = None return f"Не знаю город на букву {next_letter.upper()}. Ты победил." _game = None def get_cities_game() -> CitiesGame: global _game if _game is None: _game = CitiesGame() return _game