В прошлых статьях я рассказывал, как устроен мой тренажёр инцидентов, и разбирал кейс с 502 Bad Gateway. За это время я релизнул два новых режима: «дежурство недели» и публичный профиль игрока. Покажу, как они устроены: там нет ни крона, ни новых таблиц, и всё равно есть пара мест, где легко ошибиться.

Один кейс на неделю

Правило простое: каждую неделю один кейс и отдельный рейтинг по нему. Неделя начинается в понедельник в 00:00 по Москве.

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

def case_for_week(start):
    key = f"weekly:{start:%G-W%V}"
    stored = database.get_setting(key)
    if stored in SCENARIOS:
        return stored
    recent = {database.get_setting(week_key(start - timedelta(weeks=n))) for n in range(1, 5)}
    fresh = [sid for sid in eligible_cases() if sid not in recent] or eligible_cases()
    case_id = random.Random(key).choice(fresh)
    database.set_setting(key, case_id)
    return case_id

Здесь три детали:

  • Генератор случайных чисел инициализируется ключом недели. Если два запроса придут одновременно, оба выберут один и тот же кейс, и блокировки не нужны.

  • Выбор сохраняется. Если посреди недели я добавлю новый кейс, список кандидатов изменится, но кейс недели останется прежним.

  • Последние четыре недели исключаются, чтобы кейс не выпадал два раза подряд. Обучающий кейс и кейсы, которые открываются только после прохождения других, в розыгрыш не попадают.

Рейтинг — просто запрос

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

SELECT runs.owner, runs.score,
       runs.solved_at - runs.started_at AS seconds,
       CASE WHEN users.leaderboard_opt_in = 1 AND users.is_admin = 0
            THEN users.username END AS username
FROM runs LEFT JOIN users ON runs.owner = 'u' || users.id
WHERE runs.scenario_id = ? AND runs.started_at >= ? AND runs.solved_at < ?
  AND runs.staff = 0

Лучшая попытка игрока выбирается уже в Python: сначала по очкам, потом по времени. Очки идут первыми: подсказки их снижают, и тот, кто разобрался сам, должен стоять выше того, кто открыл все три подсказки и успел на минуту раньше.

Имя в ответе есть только у тех, кто сам согласился участвовать в таблице лидеров. Остальные попадают в рейтинг недели одной цифрой «закрыли N человек». Мои собственные проверки (staff = 0) не попадают никуда, иначе я бы каждую неделю занимал первое место, было бы не честно:)

Профиль: 404 вместо «доступ запрещён»

Профиль /u/<имя> показывает решённые кейсы, достижения и лучшие времена. Это публикация персональных данных, поэтому он открывается по тому же согласию, что и таблица лидеров:

user = database.get_user_by_username(username)
if user is None or not user["leaderboard_opt_in"] or user["is_admin"]:
    abort(404)

Скрытый профиль отдаёт тот же 404, что и несуществующий. С ответом 403 по разнице кодов можно было бы перебором проверить, какие имена зарегистрированы. Страница профиля помечена noindex: делиться ссылкой можно, но в поисковой выдаче имени игрока не будет.

Что в итоге

Без крона, новых таблиц и фоновых задач вышло около 300 строк вместе с шаблонами и 130 строк тестов. Кейс этой недели — тот самый 502 из прошлой статьи.

Как вы считаете, стоящая тема?

Если есть предложения или пожелания чтобы вы хотели видеть — пишите в комментариях. С удовольствием прочитаю, а лучшие добавлю в кейсы.

Комментарии (0)