Создатель GitHub-репозитория превратил его в полноценную игру

Прокомментировать Просмотры: 3

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

upd: определенная польза все же присутствовала: покупатели получали бэклинк с топовой на тот момент страницы, что неплохо бустило их сайты в поисковой выдаче Google. Тем не менее, многие раскошеливались просто ради фана и участия в глобальном флешмобе.

Мне захотелось воплотить нечто подобное для разработчиков, заменив привычные деньги на валюту, которую мы и так неустанно коллекционируем — звезды на 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, до 1000 и т.д.). Но и здесь кроется тупик. Если градации лиг завязаны на звезды, и побеждает внутри них тот, у кого их больше, то вершину каждой лиги неизбежно оккупирует просто обладатель максимального значения для данного порога. Никакой интриги.

Возраст профиля выступает идеальным критерием для категоризации:

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

  • Абсолютная честность. Искусственно накрутить возраст аккаунта невозможно.

  • Естественная динамика игры. Спустя год любой триумфатор категории Hatchlings неизбежно «взрослеет» и освобождает место. Младшие дивизионы находятся в постоянном движении даже в отсутствие новых участников.

Единственным исключением остается лига Ancients, которая теоретически может «законсервироваться» при появлении признанных легенд индустрии. Но в этом есть своя эстетика: у древних богов свои законы.

Архитектура: полная бессерверность

Технически весь проект держится на одном workflow-файле и компактном Node.js-скрипте, написанном на чистых зависимостях.

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

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

В экосистеме GitHub Actions за звезды отвечает исторически унаследованный хук с неочевидным синтаксисом watch:

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);

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

// репозитории отсортированы по звёздам — как только дошли до нуля, дальше смысла нет
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»), игнорируя часы и минуты, благодаря чему 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 через обычный тег , в контексте которого внешние ресурсы строго заблокированы. Прямая ссылка на аватарку пользователя внутри графики просто не отобразится.

Проблема решается предварительной загрузкой аватарок монархов и их последующей инлайн-конвертацией в base64 непосредственно внутри SVG:

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 Actions может задерживать выполнение задач на десятки минут, особенно на свежих репозиториях, поэтому выход игрока из битвы фиксируется не мгновенно, а в течение часа.

Проблема № 3: упрямое кеширование картинок

После первой коронации текстовая таблица в README обновилась корректно, однако на самом баннере троны упорно отображались пустыми. Инспекция SVG показала, что файл в репозитории сгенерирован верно и содержит актуальные никнеймы с аватарами.

Виной всему оказался браузерный кеш. Баннер извлекается через raw.githubusercontent.com с заголовком ответа Cache-Control: max-age=300, а запрашиваемый URL неизменен — assets/throne.svg.

Проблема решается классическим приемом cache busting: к ссылке динамически дописывается хеш актуального содержимого файла.

return createHash('sha1').update(svg).digest('hex').slice(0, 10);
// → 

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

Проблема № 4: дублирующиеся уведомления

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

О честности состязания

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

При этом привлечь медийного друга ради свержения действующего монарха — это не читерство, а квинтэссенция игровой механики.

Итоги

  • Минималистичный стек: один воркфлоу и скрипт на Node.js объемом около 450 строк кода без сторонних зависимостей.

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

  • Автономная генерация всей сопутствующей истории: летописи, Зала славы и династических хроник.

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

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

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

 

Источник

Поделиться:

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов