javascript

Трон для самого звёздного: я сделал игру, которая живёт внутри GitHub‑репозитория

  • пятница, 2 октября 2026 г. в 00:00:12
https://habr.com/ru/articles/1088866/

Помните Million Dollar Homepage? В 2005 году студент продал миллион пикселей по доллару за штуку и заработал миллион. Никакой пользы в этих пикселях не было, но люди платили за участие в мемчике и за место, которое видят другие.

Мне захотелось сделать что‑то похожее для разработчиков, только вместо денег — валюта, которую у нас и так все копят: звёзды на GitHub.

Так появился Star Throne — репозиторий‑игра. Ставишь звезду на репо, и через минуту‑две GitHub Actions считает, сколько звёзд у тебя самого. Если ты сильнейший в своей лиге, твоя аватарка появляется на троне прямо в README. Сервера нет, базы данных нет, хостинга нет. Всё работает на GitHub.

Ниже расскажу, как это устроено, почему лиги пришлось делить по возрасту аккаунта, а не по звёздам, и на какие грабли я наступил в первую же ночь.

Правила

  1. Поставил звезду — ты в игре.

  2. Сила — сумма звёзд на твоих собственных публичных репозиториях. Форки и организации не считаются.

  3. Лига определяется возрастом аккаунта:

Лига

Возраст аккаунта

Hatchlings

меньше года

Squires

1–3 года

Knights

3–7 лет

Ancients

больше 7 лет

  1. Сильнейший в лиге получает её трон. Сильнейший среди всех — титул Императора.

  2. При равенстве звёзд трон остаётся у нынешнего короля. Чтобы свергнуть, нужно строго больше.

  3. Снял звезду — отрёкся. Корона уходит следующему.

  4. Аккаунт повзрослел и вышел за границу лиги — король оставляет трон и уходит в следующую лигу, где придется сражаться за трон уже с другими.

Каждая смена власти записывается в летопись, а самые долгие правления — в Зал славы.

Почему лиги по возрасту, а не по звёздам

Первая версия идеи была простой: один трон, на нём человек с максимумом звёзд. Проблема видна сразу: как только звезду поставит кто‑нибудь с 200 тысячами звёзд, трон замрёт навсегда. Игра закончится в тот же день.

Очевидное решение — весовые категории по звёздам: до 100, до 1 000 и так далее. Но это не работает. Если лига определяется звёздами, а внутри лиги побеждает тот, у кого звёзд больше, то король каждой лиги — это просто человек у верхней границы. Рейтинг по тому же параметру, по которому делят на лиги, ничего не решает.

Возраст аккаунта — хорошая ось для лиг:

  • Он не связан с силой напрямую. Среди аккаунтов младше года может оказаться человек с тысячей звёзд, а может — с тремя.

  • Он честный. Возраст аккаунта не накрутишь.

  • Он сам двигает игру. Через год любой король Hatchlings неизбежно «повзрослеет» и освободит трон. В младших лигах власть меняется сама собой, даже если никто новый не пришёл.

Только лига Ancients рискует застыть, если туда придёт кто‑то из легенд. Но это даже красиво: у древних свои боги.

Архитектура: игра без сервера

Вся игра — это один workflow и один скрипт на Node.js без зависимостей.

звезда → событие watch → GitHub Action → GraphQL API → решение, кто правит       → README + SVG-баннер → коммит от github-actions[bot]

Триггер: событие watch

В GitHub Actions есть событие с неочевидным названием watch. Исторически «watch» на GitHub означало звезду, и название так и осталось:

on:
  watch:
    types: [started]      # кто-то поставил звезду
  schedule:
    - cron: '17 * * * *'  # ежечасный пересчёт
  workflow_dispatch:      # ручной запуск

permissions:
  contents: write

concurrency:
  group: star-throne
  cancel-in-progress: false

concurrency нужен, чтобы два запуска одновременно не пытались закоммитить разные версии README, если звёзды ставят пачкой.

Считаем силу: GraphQL и алиасы

Сначала скрипт забирает всех старгейзеров репозитория, по 100 штук за запрос:

repository(owner: $o, name: $n) {
  stargazers(first: 100, after: $a, orderBy: {field: STARRED_AT, direction: ASC}) {
    pageInfo { hasNextPage endCursor }
    edges { starredAt node { login } }
  }
}

Потом нужно посчитать звёзды каждого участника. Делать по запросу на человека расточительно, поэтому я склеиваю 25 пользователей в один запрос через алиасы GraphQL:

const defs = chunk.map((_, j) => `$l${j}:String!`).join(',');
const body = chunk.map((_, j) =>
  `u${j}: user(login: $l${j}) { login createdAt avatarUrl(size: 128) ${REPOS} }`
).join('\n');
const { data } = await gql(`query(${defs}) { ${body} }`, vars);

Ещё одна мелочь: репозитории пользователя я запрашиваю отсортированными по звёздам по убыванию. Поэтому листать страницы можно, только пока звёзды не кончились. У человека с 300 репозиториями, из которых звёзды есть у пяти, хватит одного запроса:

// репозитории отсортированы по звёздам — как только дошли до нуля, дальше смысла нет
if (!page.pageInfo.hasNextPage || !last || last.stargazerCount === 0) break;

Если пользователь удалил аккаунт, GraphQL возвращает для этого алиаса null и ошибку в массиве errors, но остальные данные приходят нормально. Поэтому частичные ошибки скрипт не считает падением.

Где хранить кеш, чтобы репозиторий не распух

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

Первая мысль — положить кеш в JSON в репозитории. Но он меняется при каждом запуске, а значит, каждый час будет коммит, и за год git‑история распухнет на сотни мегабайт.

Поэтому кеш лежит в кеше GitHub Actions, а не в git:

- uses: actions/cache@v4
  with:
    path: .cache
    key: throne-users-${{ github.run_id }}
    restore-keys: throne-users-

Уникальный ключ на каждый запуск плюс restore-keys по префиксу дают «всегда восстанавливать последний». Если кеш вдруг пропадёт, ничего страшного: скрипт просто пересчитает всех заново.

В git хранится только data/throne.json: кто правит, с какого момента, история правлений и летопись. Он меняется, только когда меняется власть.

Детерминизм: коммит только когда есть новость

Хотелось, чтобы коммиты появлялись только при реальных событиях, а не каждый час. Для этого всё, что попадает в README и в баннер, должно быть стабильным:

  • Время правления показывается в днях («12 days»), а не «12d 4h 17m». README меняется максимум раз в сутки.

  • Звёзды на баннере округляются: ★ 212k не меняется от каждой новой звезды у Императора.

  • Звёздное небо на фоне баннера рисуется генератором случайных чисел с фиксированным зерном (mulberry32), так что картинка одинаковая при каждом запуске.

Коммит делается, только если git diff не пустой. Заголовок коммита — сама новость:

⚔️ @challenger (★540) overthrew @old-king (★312) and seized the 🛡️ Knights throne after a reign of 3 days.

История коммитов репозитория превращается в хронику королевства.

Баннер: SVG с аватарками внутри

Главная картинка README — это SVG, который скрипт генерирует заново. Тут есть тонкость: GitHub показывает SVG через тег <img>, а SVG в режиме картинки не может загружать внешние ресурсы. Ссылка на аватарку внутри SVG просто не загрузится.

Поэтому скрипт скачивает аватарки правителей и встраивает их прямо в SVG в base64:

const res = await fetch(user.avatarUrl);
const type = res.headers.get('content-type') || 'image/png';
return `data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`;

Аватарки обрезаются в круг через clipPath, сверху рисуется корона из одного polygon. Пять аватарок по 128 пикселей — это около 16 КБ, вполне терпимо.

Проблемы

Проблема № 1: < 1 year сломал всю картинку

Первая отрисовка баннера показала лишь половину картинки и ошибку StartTag: invalid element name. Причина: подпись лиги Hatchlings — account < 1 year. В SVG, который является XML, символ < открывает тег. Одна неэкранированная подпись — и браузер отказывается рисовать всё, что ниже.

Мораль: в генерируемом XML экранировать нужно всё, а не только пользовательский ввод.

Проблема № 2: король, который правил 14 минут

Первым участником стал человек с нулём звёзд (ник я поменял). Раз других претендентов не было, он получил сразу два трона: Squires и Императорский.

👑 @first-king claimed both the empty 🗡️ Squires throne and the 👑 Imperial crown with ★0.

Через несколько минут он снял звезду. А трон остался за ним.

Оказалось, что событие watch бывает только started. Снятие звезды GitHub вообще не присылает. Отследить отречение можно только одним способом: периодически сверять список старгейзеров с тем, кто сидит на троне. Для этого и нужен ежечасный cron. Когда следующий запуск наконец отработал, летопись пополнилась:

🏳️ @first-king abdicated both the 🗡️ Squires throne and the 👑 Imperial crown after 14 min by unstarring the realm. The throne stands empty.

Первое правление в истории королевства длилось 14 минут. Считаю это отличным началом лора.

И ещё один нюанс: запуски по расписанию GitHub может задерживать на десятки минут, особенно в новых репозиториях. Выход из игры фиксируется не мгновенно, а в течение часа.

Проблема № 3: картинка не обновлялась

После первой коронации таблица в README обновилась, а на баннере троны оставались пустыми. Я полез проверять SVG — в репозитории он был правильный, с ником и аватаркой.

Дело было в кеше. Картинка из README отдаётся через raw.githubusercontent.com с заголовком Cache-Control: max-age=300, а адрес у неё всегда один и тот же: assets/throne.svg. Браузер честно показывал старую версию.

Решение — классический cache busting: к адресу добавляется хеш содержимого.

return createHash('sha1').update(svg).digest('hex').slice(0, 10);
// → <img src="assets/throne.svg?v=e9c6a25460">

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

Проблема № 4: двойные новости

У самого сильного игрока лиги Ancients обычно есть и корона Императора. При каждой смене власти летопись писала две почти одинаковые строки: «X сверг Y на троне Ancients» и «X сверг Y на Императорском троне». Теперь такие события склеиваются в одно: «X сверг Y и захватил и трон Ancients, и Императорскую корону».

Честная игра

Первый вопрос, который мне задали: «А если накрутить звёзды?» Скрипт никого не проверяет, но GitHub проверяет: за купленные звёзды и ботов аккаунты помечают и скрывают. Так что трон, завоёванный накруткой, долго не простоит.

А вот позвать знаменитого друга, чтобы он свергнул нынешнего короля, — это не читерство, а весь смысл игры.

Что в итоге

  • Один workflow‑файл и один скрипт на Node.js, около 450 строк, без зависимостей.

  • Ноль серверов и ноль рублей в месяц: Actions для публичных репозиториев бесплатны.

  • Летопись, Зал славы и императорская династия пишутся сами.

Код открыт под MIT. Если хотите своё королевство — для команды, компании или сообщества, — сделайте форк. Лиги, названия и цвета меняются в throne.config.json.

Сейчас в королевстве правит один человек: он занял трон Knights и заодно стал Императором, имея ровно одну звезду. Остальные троны пустуют. Посмотреть, кто правит сейчас, и почитать летопись можно в репозитории: github.com/Lzeray/star‑throne.

Буду рад вопросам и критике в комментариях: и по архитектуре, и по правилам игры. Особенно интересно, какие ещё механики можно построить на одних GitHub Actions без сервера.