Files
smart-speaker/app/features/cities_game.py

326 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"\\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