Skip to content

Repository files navigation

Location King

Геогессер по спутниковым снимкам. Игроку показывают участок снимка — без подписей и без координат — и он ищет это место на карте мира. Чем ближе поставленная точка к центру участка, тем больше очков.

Сервер авторитетен: до принятой догадки клиент не получает координаты цели ни в одном ответе API. Снимок приходит через прокси по локальным координатам тайлов, так что подсмотреть ответ в DevTools нельзя.

Как это устроено

Область раунда — это один тайл Web Mercator и его потомки на четыре уровня вглубь. Цель раунда — центр этого тайла.

Места берутся из каталога backend/scripts/seed.py — почти три сотни областей в сотне стран. Уровень партии выбирает, что из него достанется:

Уровень Что показывают Мест
Легко узнают по силуэту: Париж, Венеция, Манхэттен 45
Средне крупные города знакомых стран: Гамбург, Казань, Сиэтл 107
Сложно города, о которых знают мало, и обжитая местность 82
Хардкор дикая природа: горы, пустыни, тайга, лёд 40

Точка раунда берётся внутри зоны случайно, но не любая: в кадре обязана быть суша. Приморская зона наполовину состоит из моря — Александрия это десять километров города и столько же Средиземного, — и без проверки каждый шестой раунд по ней в узком кадре показывал ровную синеву, по которой не угадать ничего. Сушей считаются границы стран, те же, по которым работает режим стран: море внутрь границ не входит, поэтому отдельной карты суши не нужно.

Проверяется именно кадр, а не сама точка: чем шире вид, тем дальше может стоять берег, оставаясь видимым. Иначе из игры выпала бы Венеция, которая целиком стоит в лагуне, — а в её кадре берег виден со всех сторон. Насколько каталог согласен с этим правилом, показывает scripts/check_zones.py.

Уровень записан у самой зоны — полем tier в каталоге и в базе. Из категории он не выводится: Гамбург и Сурабая оба city, но первый называют с ходу, а второй не назовёт почти никто. Пока уровень выводился из категории, «средне» было четырьмя пятыми каталога и означало «всё, кроме дикой природы».

Уровни не вложены друг в друга: выбрав «средне», Манхэттен не получишь — он на «легко». Каждое место размечено ровно одним уровнем, и это проверяется тестом, как и то, что ни один уровень не съедает половину каталога.

Радиус городской зоны ограничен пятью километрами. Точка раунда берётся из квадрата вокруг центра и в углу отходит от него на семь километров — при виде в пятнадцать километров центр места остаётся в кадре. При десяти километрах радиуса он уходил за край, и «Париж» показывал безымянный пригород. У диких зон разброс больше — это часть замысла. Оба правила проверяются тестами.

Клиент запрашивает тайлы по локальной сетке: GET /api/rounds/{id}/tiles/{z}/{x}/{y}.jpg, где z — уровень от 0 до максимального, а x и y — номера внутри уровня. Сервер переводит их в координаты тайлового сервера, забирает снимок и кладёт в Redis. Запрос за пределы области возвращает 404, чужой раунд — 403.

Расстояние и очки считает только сервер, единственной формулой из app/services/scoring.py.

В любой момент раунда можно взять подсказку. Она раскрывает самое широкое из того, чего игрок ещё не знает из условий партии: по всему миру — часть света, внутри выбранной части света — страну, внутри одной страны — регион. Подсказка, повторяющая условия партии, ничего не добавляет, и такую сервер не выдаёт вовсе. Стоит она трети от максимума раунда, и цену называет сервер: клиент показывает то число, которое пришло в ответе, а не считает его сам. Координат в подсказке нет — правило «до конца раунда клиент не может вычислить цель» она не нарушает.

Частота запросов ограничена: вход и регистрация — по адресу клиента, старт партии и тайлы — по игроку. Незавершённая партия у игрока может быть только одна: начиная новую, он бросает предыдущую. Партии, брошенные посреди игры, закрывает scripts/cleanup.py — его стоит поставить в расписание.

Таблица лидеров

Зачёт делится по условиям игры: отдельно хардкор, отдельно лёгкий уровень, отдельно Россия и США. Общая таблица складывала бы несравнимое — партия в тайге и партия по Парижу стоят разного труда, а очки у них одни и те же.

Считается по завершённым партиям, а не по итогам в профиле: профиль знает сумму за всё время, и разделить её обратно по условиям нельзя. Поэтому условия набора запоминаются вместе с серией раундов.

«Лучшая партия» меряется очками за раунд, а не суммой: иначе партия из десяти раундов всегда обходила бы партию из трёх, и зачёт превращался бы в соревнование по длине.

Челлендж дня и серии

Раз в сутки всем игрокам достаётся один и тот же набор раундов. Партия засчитывается, только если доиграна до конца, — за брошенные серия не растёт.

Серия считается по дням, в которые челлендж доигран, и остаётся живой, пока её можно продолжить: сыграл вчера и ещё не садился сегодня — впереди целый день. Пропуск обрывает счёт. Хранится это нигде — считается запросом по датам партий, чтобы счётчик не разошёлся с правдой.

Режимы на время

Партию можно ограничить по времени: 30 секунд, минута или две на раунд. Срок назначает сервер и он же проверяет опоздание — часы игрока к делу не относятся. Ответ после срока закрывает раунд нулём, но партия продолжается. Скорость не добавляет очков сверх максимума, а медлительность их отнимает: на последней секунде остаётся 80% от заработанного, иначе результаты партий с таймером и без были бы несравнимы.

Челлендж дня

Каждые сутки собирается одна серия из пяти раундов, одинаковая для всех игроков. Серия создаётся при первом обращении за день и дальше не меняется: иначе двое, начавшие игру в разное время, играли бы в разное. Границы суток считаются в UTC, так что день сменяется у всех одновременно. Сыграть челлендж можно один раз — это гарантирует уникальный индекс, а не только проверка в коде.

Комнаты на двоих и больше

Комната — это та же серия раундов, но собранная не на сутки, а по просьбе игрока. Хост выбирает условия, получает шестизначный код и раздаёт ссылку; все, кто вошёл, играют одни и те же раунды каждый в своём темпе и сравнивают результаты в общей таблице.

Живого обмена ходами нет намеренно: он потребовал бы соединения, которое рвётся вместе с вкладкой, а сравнивать результаты можно и после. Войти в комнату дважды нельзя — иначе серию можно было бы переигрывать, пока не выпадет удачный счёт; это гарантирует частичный уникальный индекс. Хост может закрыть набор, чтобы опоздавшие не портили таблицу.

Друзья

Игроки добавляют друг друга по короткому коду, а не по имени: имена не уникальны, и поиск по чужому имени — способ найти не того человека, а заодно собрать чужие имена. Свой код игрок показывает сам, кому захочет; алфавит у него тот же, что у кода комнаты, — без символов, которые путаются на слух.

Дружба хранится одной строкой на пару, а не двумя встречными: две пришлось бы держать согласованными, а «дружба есть у одного и нет у другого» — состояние, которого быть не должно. Встречная заявка не заводит вторую связь, а подтверждает первую: двое, позвавшие друг друга, уже договорились.

Чужого идентификатора в списке друзей нет — только идентификатор самой связи, по которому её принимают и убирают.

В таблице лидеров у друзей свой зачёт: тот же расчёт, но по короткому списку игроков. Сама таблица про дружбу ничего не знает — список ей приносят готовым.

Аватарка

По умолчанию аватарка — не файл, а два числа: форма узора и цвет. Узор по ним рисует клиент, поэтому такая аватарка есть у каждого с первой минуты, её не нужно выбирать и хранить для неё нечего. Числа приезжают в каждом ответе, где виден игрок, а не выводятся клиентом из идентификатора: в таблице комнаты чужих идентификаторов нет и не будет. Новому игроку аватарка достаётся по его идентификатору — у зарегистрированных подряд она разная.

Кто хочет своё лицо, загружает картинку. Файл не сохраняется как пришёл: он обрезается по центру в квадрат, приводится к 256 пикселям и перекодируется в WebP. Перекодирование здесь не про вес — оно оставляет от файла одни пиксели. Ни EXIF с координатами съёмки, ни постороннего содержимого, спрятанного за картинкой, в базу не попадает.

Картинка лежит в отдельной таблице, а не колонкой в users: профиль читается на каждый запрос и в каждой строке таблицы лидеров, а тащить за ним килобайты незачем. В базе, а не файлом на диске, — тогда её забирает тот же pg_dump, что и всё остальное, а удаление учётной записи уносит её каскадом, ничего не оставляя на диске.

Адрес картинки несёт версию и меняется при каждой замене: иначе браузер показывал бы прежнюю неделю. Забирает её клиент авторизованным запросом, как и тайлы снимка, — тег img не умеет отправлять токен, а открывать аватарки без авторизации значило бы отдать их наружу перебором идентификаторов. Сторонних доменов при этом по-прежнему нет: картинка приходит со своего, как того и требует строгая CSP.

Загруженную аватарку видно другим игрокам, то есть это публикация. Что за неё отвечает загрузивший и что именно загружать нельзя, написано в условиях использования; кнопка «Убрать» возвращает узор.

Режим «угадай страну»

Тот же снимок и та же карта, но игрок выбирает не точку, а страну: на карте догадки лежат контуры, страна под курсором подсвечивается, нажатие её выбирает. Серверу уходит код страны, он же сверяет его с правильным — по геометрии ничего не пересчитывается, поэтому разойтись клиенту и серверу не в чем.

Контуры для карты отдаются отдельным упрощённым набором: полмегабайта вместо двух, кэш на сутки и ETag. Ответа они не выдают — на карте лежат границы всех стран сразу. Полные границы остаются в базе, и по ним PostGIS считает, в какой стране находится место со снимка.

Очки другие, потому что вопрос другой. Угадал страну — весь максимум, как бы далеко от её центра ни находилось место. Не угадал — не больше половины, и тем меньше, чем дальше названная страна от места на снимке: назвать соседнюю страну и назвать другое полушарие — разные ошибки.

Место в условиях партии в этом режиме не выбирается: «Россия» рядом с «ответ страной» — это готовый ответ на все раунды. Интерфейс такой выбор не показывает, а сервер отклоняет его и в запросе, собранном руками.

Страна цели считается один раз, при сборке серии: у всех, кто играет одну серию, правильный ответ обязан быть один и тот же.

Выбирается режим там же, где остальные условия партии, — «Чем отвечать» в настройках одиночной; комната собирается по ним же. В результате раунда вместо промаха и точности стоят две страны: правильная и та, в которую попал игрок. Отвечать промахом в километрах на вопрос про страну значило бы объяснять очки не тем, за что их дали.

Границы из OpenStreetMap (ODbL) через simonepri/geo-maps, версия закреплена и загружается scripts/load_countries.py. Разрешение — пять километров: 236 стран и два мегабайта.

На этом наборе 271 зона каталога из 274 получает страну, совпадающую с записанной руками. Остальные три в режим стран не попадают: Монако мельче разрешения — страны такого размера в источнике нет вовсе, — а у Игуасу и Иерусалима центр зоны ложится по другую сторону границы, чем в каталоге. Расхождения отсеиваются при сборке серии, до игрока они не доходят.

Километровый набор — 248 стран и двадцать два мегабайта — при замере на каталоге из 277 зон добавлял ровно одну годную. Одна зона не стоит одиннадцатикратного веса, который качается на каждом развёртывании.

Первый экран

За текстом лежит координатная сетка, а по ней ходит прицел и называет широту и долготу точки, на которую наведён. Числа настоящие: меридианы и параллели стоят там, где им положено в проекции Меркатора — той самой, в которой в игре показана карта мира, — и подпись под перекрестием с ними сходится. Отсюда и неравные промежутки между параллелями: к полюсу они расходятся. Сетка, у которой линии ничего не значат, была бы просто решёткой на фоне.

Мышью прицел ведут курсором, пальцем — касанием. Положение пишется прямо в стиль узла из одного кадра requestAnimationFrame: перерисовывать React на каждое движение мыши — это десятки кадров в секунду впустую.

Арифметика проекции лежит в frontend/src/domain/graticule.ts и проверяется тестами: такое сверяют вычислением, а не скриншотом.

Оформление

Тем две — тёмная и светлая, плюс «как в системе». Выбор хранится у игрока в базе, а не в браузере: он должен пережить и очистку хранилища, и переход на другое устройство. Копия лежит в localStorage и нужна ровно затем, чтобы знать тему до ответа сервера.

Ставит её public/theme.js — отдельный файл, а не строка в index.html: строгая CSP запрещает встроенные скрипты, а тема должна встать до первой отрисовки, иначе светлая начинается с тёмной вспышки. В разметку попадает уже разрешённое значение, dark или light, поэтому светлая палитра описана в tokens.css один раз.

Светлая — тот же прибор при дневном свете: то же зелёно-серое семейство нейтральных и тот же янтарь, взятый глубже. Ярким янтарём на белом нельзя писать текст, а очки, рейтинг и код комнаты им написаны.

Дуэли и рейтинг

Дуэль — это комната на двоих, которую собрал сервер. Игрок встаёт в очередь, и когда рядом находится соперник с близким рейтингом, обоим приходит код той же самой комнаты: серия раундов общая, партии считаются как обычно, таблица результатов та же.

Формат дуэли фиксирован — пять раундов, средний уровень, весь мир, минута на раунд. Это не упрощение, а условие: рейтинг сравнивает игроков между собой и работает, только пока все дуэли устроены одинаково.

Рейтинг — Эло, старт 1000. Считается только по дуэлям, потому что там оба играют одно и то же и условия партии сокращаются. По обычным партиям его вывести нельзя: средний промах на лёгком уровне по Европе в десятки раз меньше, чем на хардкоре по всему миру, и рейтинг из него поднимал бы наверх того, кто выбрал условия помягче. Первые десять дуэлей двигают рейтинг вдвое сильнее, чтобы новичок быстро попал на своё место. Считается исход, а не разрыв в очках: один удачный раунд качает разрыв сильно, а победу — нет.

Очередь живёт в Redis, потому что воркеров четыре и очередь в памяти процесса была бы у каждого своя. Живого соединения нет: клиент опрашивает сервер раз в несколько секунд, и тот же запрос продлевает запись в очереди — закрыл вкладку и выпал, а счётчик ищущих остался честным. Полоса поиска расширяется с ожиданием: сначала ±50 очков рейтинга, дальше шире, через две минуты — с кем угодно. Пара подбирается под коротким замком в Redis, иначе два воркера свели бы одного игрока дважды.

Недоигранная дуэль через десять минут отдаётся тому, кто дошёл до конца: иначе рейтинг чинился бы закрытием вкладки. Обычно рейтинг начисляет тот, кто закончил последним; если не вернулся никто, дуэль добирает scripts/cleanup.py. Отметка rated_at и блокировка строки не дают начислить рейтинг дважды.

Обратная связь

Игрок пишет из раздела «Профиль»: выбирает, впечатление это или проблема, и оставляет текст. Два вида, а не свободная тема, — впечатление читают, когда есть время, а проблему когда её чинят, и разбирать одно от другого глазами по тексту значит однажды пропустить вторую.

Отзывы лежат в базе и читаются командой:

docker compose exec backend python scripts/feedback.py
docker compose exec backend python scripts/feedback.py --kind problem --limit 50

Экрана администратора нет намеренно: пока отзыв — это строка с текстом и именем, экран ради неё стоил бы дороже, чем даёт. Отзыв уходит вместе с учётной записью: политика обещает, что данные можно стереть, и он — такие же данные игрока, как партии.

Стек

  • Backend: Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic
  • База: PostgreSQL 16 + PostGIS — зоны хранятся полигонами, точка раунда выбирается через ST_GeneratePoints
  • Кэш: Redis 7 — спутниковые тайлы
  • Аутентификация: свой JWT, пароли argon2id
  • Frontend: React 18 + TypeScript + Vite, карты на OpenLayers, шрифты свои — ни одного запроса на сторонние домены
  • Инфраструктура: Docker Compose, Nginx

Запуск

git clone https://github.com/vomas7/location_king
cd location_king

make dev        # postgres, redis, backend и dev-сервер фронтенда
make migrate    # накатит миграции
make seed       # загрузит игровые зоны

Игра: http://localhost:5173. Документация API: http://localhost:8000/api/docs.

Остановить — make down, вместе с данными — make down-v.

Без Docker

cd backend
python3 -m venv venv && source venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env      # заполните JWT_SECRET
alembic upgrade head
python scripts/seed.py
uvicorn app.main:app --reload

Фронтенд отдельно:

cd frontend
npm install
npm run dev     # http://localhost:5173, /api проксируется на бэкенд

Разработка

Перед коммитом:

cd backend && ruff check . && ruff format --check . && pytest
cd frontend && npm run lint && npm run build

CI выполняет то же самое и падает, если проверки не проходят или покрытие бэкенда опускается ниже 85%.

Структура бэкенда:

app/
  routers/    HTTP-слой: разбор запроса, вызов сервиса, ответ
  services/   бизнес-логика и правила игры
  models/     модели SQLAlchemy
  schemas/    схемы Pydantic
  utils/      чистые функции без зависимостей от БД

Структура фронтенда:

src/
  api/        транспорт: fetch, токены, типы ответов
  domain/     чистые функции форматирования и оценки
  map/        карты OpenLayers
  state/      контекст авторизации и состояние партии
  components/ разметка и стили в CSS-модулях

API

Метод Путь Что делает
POST /api/auth/register регистрация по email и паролю
POST /api/auth/login вход
POST /api/auth/refresh обновление пары токенов
GET /api/auth/me профиль и статистика
PATCH /api/auth/me сменить имя и узор аватарки
PUT /api/auth/me/avatar загрузить свою картинку
DELETE /api/auth/me/avatar убрать картинку и вернуть узор
GET /api/avatars/{id} аватарка игрока картинкой
POST /api/auth/me/delete удалить учётную запись и все данные
POST /api/sessions начать партию, получить первый раунд
GET /api/sessions/{id} состояние партии и история раундов
POST /api/sessions/{id}/finish завершить партию досрочно
GET /api/rounds/{id} активный раунд, без координат
POST /api/rounds/{id}/guess догадка, в ответе появляется цель
POST /api/rounds/{id}/hint подсказка ценой части очков раунда
POST /api/rounds/{id}/timeout закрыть раунд, на который не успели
GET /api/rounds/{id}/tiles/{z}/{x}/{y}.jpg тайл снимка по локальным координатам
GET /api/sessions история партий игрока
GET /api/sessions/current незаконченная партия или null
GET /api/zones зоны с фильтрами по месту и уровню
GET /api/leaderboard таблица лидеров с зачётом по условиям
GET /api/challenge/today челлендж дня и таблица дня
POST /api/challenge/today/start начать челлендж дня
POST /api/duels/queue встать в очередь на соперника
POST /api/duels/queue/poll продлить поиск и узнать, нашлась ли пара
DELETE /api/duels/queue прекратить поиск
GET /api/duels/searching сколько человек ищет соперника
GET /api/duels/format условия дуэли: одни для всех
GET /api/friends друзья, заявки и свой код игрока
POST /api/friends позвать в друзья по коду
POST /api/friends/{id}/accept принять заявку
DELETE /api/friends/{id} отклонить, отозвать или расстаться
POST /api/matches создать комнату, получить её код
GET /api/matches/mine комнаты, созданные игроком
GET /api/matches/{code} состояние комнаты и таблица результатов
POST /api/matches/{code}/join войти в комнату и начать её серию
POST /api/matches/{code}/close закрыть набор, только хост

Полное описание — в /api/docs.

Посадочная страница и поиск

До входа открывается лендинг: что за игра, как проходит раунд, какие есть режимы, почему ответ нельзя подсмотреть, и частые вопросы. Форма входа стоит там же, на первом экране.

Те же вопросы уходят в разметку JSON-LD, чтобы поисковик показывал их в выдаче, — собирает её плагин в vite.config.ts из того же файла с текстами, что и страница. Картинка для соцсетей рисуется браузером из вёрстки: node scripts/make-og-image.mjs после правок оформления.

Чужое внимание

Открытый в интернет сервер получает поток запросов, которых никто не звал: перебор wp-admin и .env, попытки войти по чужим паролям, долбёжка по несуществующим адресам. Защита от этого стоит слоями, и каждый слой дешевле следующего.

Первым отвечает nginx. Пути, которых в игре нет вовсе — панели чужих CMS, забытые дампы, .php, — закрывают соединение без ответа (444): сканер не получает даже кода, по которому мог бы отличить «нет такого» от «есть, но закрыто». Дальше — ограничение частоты по адресу: шестьдесят запросов в секунду на игровые пути и один в секунду на вход с регистрацией. Перебор пароля упирается в это после десятой попытки, не дойдя до argon2 — а именно argon2 стоит дорого.

Вторым считает приложение: у каждого действия, которое пишет в базу, свой лимит в одной таблице RULES (app/services/rate_limit.py). Эти счётчики про игру — сколько партий начато, сколько заявок в друзья отправлено, — а не про сетевой шум, и до них шум уже не доходит.

Адрес, по которому всё это считается, приходит от nginx одним проверенным значением: в контуре Cloudflare это CF-Connecting-IP от самого Cloudflare, в остальных — $remote_addr после real_ip. Клиентскую цепочку X-Forwarded-For не пересылает ни один контур: её первый элемент пишет сам клиент, и пока она доходила до приложения, лимиты по адресу обходились сменой одного заголовка.

За Cloudflare origin принимает соединения только из его сетей — список собирается при каждом развёртывании. Иначе сканер, нашедший адрес сервера перебором сетей хостинга, обходил бы и защиту Cloudflare, и подсчёт настоящего адреса игрока.

Что настроить на самой машине — файрвол, вход по ключу, автообновления — в docs/deployment.md.

Данные игрока

Мы храним почту, пароль в виде хеша argon2id, имя для таблицы лидеров и ход партий. Никакой аналитики, счётчиков и сторонних скриптов на странице нет, и ни одного файла cookie игра не ставит: в браузере остаётся только токен входа.

Учётная запись удаляется вместе со всеми данными кнопкой в разделе «Профиль» — это обычный эндпоинт POST /api/auth/me/delete, а не письмо в поддержку.

Условия использования, политика конфиденциальности и страница про хранилище открываются из подвала посадочной страницы, а в меню игры — из раздела «Профиль». Тексты лежат в frontend/src/legal/documents.ts, что перед публикацией нужно заполнить — в docs/legal.md.

Деплой

На сервере с Docker — одна команда:

./deploy.sh

Она создаёт .env со сгенерированными паролями, собирает образы, поднимает контур, накатывает миграции, загружает зоны и проверяет, что игра отвечает. Обновление — git pull && ./deploy.sh.

По умолчанию сертификат выпускает Let's Encrypt: игроки приходят прямо на сервер, продление берёт на себя контейнер certbot, и от человека нужны только записи в DNS. Контур за Cloudflare тоже поддерживается — он прячет адрес сервера, но привязывает доступность к доступности самого Cloudflare.

Подробности, свои сертификаты и провайдеры снимков — в docs/deployment.md.

Непрерывная поставка

Push в main → проверки в CI → ветка deploy → сервер забирает её сам. Ветку двигает только CI и только после зелёных проверок; сервер раз в две минуты смотрит, не появилось ли нового.

Ключей от сервера нигде, кроме самого сервера, при этом нет: он ходит наружу сам, а к нему никто не ходит. Не поднялась новая версия — сервер возвращается на предыдущую и остаётся работающим.

Подробности и ручное управление — в docs/deployment.md.

Резервные копии

Копия базы снимается каждую ночь и тут же проверяется разворачиванием в отдельную базу — копия, которую ни разу не восстанавливали, это не копия. Хранятся неделю, лежат рядом с игрой. Как восстановиться — в docs/deployment.md.

Мониторинг

Вместе с игрой поднимается Grafana с Prometheus и Loki — на своём поддомене, grafana.<домен>. Три готовых дашборда: игра, сервер и логи. Логи всех контейнеров собираются автоматически, идентификатор запроса сквозной, так что жалоба игрока находится в них по одному значению.

Не нужно — MONITORING=false в .env. Подробности — в docs/monitoring.md.

Лицензия

MIT — см. LICENSE.

About

Location King is a geography-based guessing game where players identify real-world locations using satellite imagery.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages