326 lines
8.6 KiB
Python
326 lines
8.6 KiB
Python
"""
|
||
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
|