# Colombino Analytics — ТЗ

<p class="callout info" id="bkmrk-plane">Проект Plane: пока <strong>CREATIVE</strong> (отдельный проект аналитики не заведён). Источник: обсуждение 25.08.2026 (retention, ключевые действия, история плотов, Plan). Документ — контракт v1, не экран в Plan.</p>
<blockquote id="bkmrk-summary">
<p>Плагин-шина ключевых действий игроков. Другие плагины вызывают API, события пишутся в <strong>свою PostgreSQL</strong>. Plan (<a href="https://stats.binomc.net">stats.binomc.net</a>), DataExtension и отчёт воронки в v1 <strong>нет</strong>.</p>
</blockquote>
<hr class="separator" id="bkmrk-sep-0">
<h2 id="bkmrk-0-tech">0) Техническая информация</h2>
<ul id="bkmrk-0-list">
<li>
<p><strong>Название:</strong> <code>ColombinoAnalytics</code></p>
</li>
<li>
<p><strong>Платформа:</strong> Paper (Hub, Creative, ивентовые) + Velocity <strong>Master</strong></p>
</li>
<li>
<p><strong>Сборка:</strong> один репозиторий, три модуля:</p>
<ul>
<li>
<p><code>analytics-api</code> — тонкий jar, <code>compileOnly</code> у других плагинов</p>
</li>
<li>
<p><code>analytics-paper</code></p>
</li>
<li>
<p><code>analytics-velocity</code></p>
</li>
</ul>
</li>
<li>
<p><strong>БД:</strong> отдельная database PostgreSQL (тот же хост, что Plane/Bookstack, <strong>не</strong> их схемы и <strong>не</strong> БД Plan)</p>
</li>
<li>
<p><strong>Soft-depend (Paper):</strong> PlotSquared, BinoRankup, PlotMark — нет плагина, нет слушателя, API живёт</p>
</li>
</ul>
<hr class="separator" id="bkmrk-sep-1">
<h2 id="bkmrk-1-why">1) Зачем</h2>
<p id="bkmrk-1-p">Три цели на одном логе. Creative — UGC: активация не «зашёл», а <strong>взял слот</strong>. Ивентовые режимы (Build Battle и др.) в логе не отдельные продукты, а значение <code>server</code> у захода.</p>
<table id="bkmrk-1-goals">
<thead>
<tr>
<th>Цель</th>
<th>Что даёт лог</th>
<th>Когда на экране</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Шина</strong></td>
<td>API для других плагинов</td>
<td>v1</td>
</tr>
<tr>
<td><strong>Модерация</strong></td>
<td>История владения плотом</td>
<td>данные в v1, UI позже</td>
</tr>
<tr>
<td><strong>Воронка новичка</strong></td>
<td>Где обрыв до активации и ранга</td>
<td>данные в v1, отчёт позже</td>
</tr>
<tr>
<td><strong>Staff</strong></td>
<td>Ревью, staff-rate, марки PlotMark</td>
<td>данные в v1, UI позже</td>
</tr>
</tbody>
</table>
<hr class="separator" id="bkmrk-sep-2">
<h2 id="bkmrk-2-done">2) Готово когда (v1)</h2>
<ol id="bkmrk-2-ol">
<li>
<p>Jar <code>analytics-api</code>: другой плагин вызывает <code>track</code>, строка появляется в Postgres. Если analytics не установлен — вызов no-op, не падает.</p>
</li>
<li>
<p>Velocity <strong>Master</strong> пишет заходы с каноническим именем сервера.</p>
</li>
<li>
<p>Paper <strong>Creative</strong> пишет плоты, заявки ранга, staff-ревью/рейтинг, марки PlotMark.</p>
</li>
<li>
<p>Paper стоит на <strong>Hub</strong> и <strong>ивентовых</strong> бэкендах (API и будущие типы; заходы в v1 пишет прокси).</p>
</li>
</ol>
<p class="callout warning" id="bkmrk-2-out">Не в приёмке v1: DataExtension и страницы Plan, дашборд retention, <code>BlockPlace</code>, плагин на Shadow, TTL/чистка лога, Redis.</p>
<hr class="separator" id="bkmrk-sep-3">
<h2 id="bkmrk-3-terms">3) Термины</h2>
<table id="bkmrk-3-table">
<thead>
<tr>
<th>Термин</th>
<th>Смысл</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Network-сессия</strong></td>
<td>Пока игрок в сети (Velocity), не «пока на этом Paper»</td>
</tr>
<tr>
<td><strong>Канон <code>server</code></strong></td>
<td>Имя сервера как в Velocity: <code>hub</code>, <code>creative</code>, имя ивентового</td>
</tr>
<tr>
<td><strong>Активация</strong></td>
<td><code>plot.claimed</code> — взял слот, не заход на Creative</td>
</tr>
<tr>
<td><strong>DataExtension</strong></td>
<td>API Plan для <em>снимка</em> на сайте. Лог не пишет. В v1 не используем</td>
</tr>
<tr>
<td><strong>Query API Plan</strong></td>
<td>SQL в БД Plan. Типы движка: SQLite или MySQL. Postgres через Plan нет. В v1 не используем</td>
</tr>
</tbody>
</table>
<hr class="separator" id="bkmrk-sep-4">
<h2 id="bkmrk-4-arch">4) Архитектура</h2>
<p id="bkmrk-4-why-not-plan"><strong>Почему не таблица в БД Plan:</strong> DataExtension не пишет события (4 колонки на игрока, 50 символов в ячейке). Query API — SQLite/MySQL. Заходы со всех серверов не кладём в боевую БД Plan: патчи, <code>/plan db clear</code> и <code>move</code> не знают чужие таблицы; воронки будут конкурировать с вебом.</p>
<p id="bkmrk-4-flow">Поток: Velocity Master и Paper пишут в одну PostgreSQL <code>player_events</code>. Прокси шлёт на Paper plugin message канала <code>colombino:analytics</code> (<code>session_id</code> + канон <code>server</code>). Плот, ранг и марки прокси <strong>не</strong> слушает.</p>
<hr class="separator" id="bkmrk-sep-5">
<h2 id="bkmrk-5-funnel">5) Воронка новичка (как смотрит аналитик)</h2>
<ol id="bkmrk-5-ol">
<li>
<p>Зашёл в сеть → заход с <code>server=hub</code></p>
</li>
<li>
<p>Попал на Creative → заход с <code>server=creative</code></p>
</li>
<li>
<p>Взял плот → <code>plot.claimed</code> ← <strong>активация</strong></p>
</li>
<li>
<p>Подал <code>/done</code> → <code>rank.request_submitted</code> (<code>/rankup</code> — только меню, не этот шаг)</p>
</li>
<li>
<p>Заявка закрылась → <code>rank.request_status_changed</code> (<code>APPROVED</code> / <code>REJECTED</code> / <code>WITHDRAWN</code> / <code>DELETED</code>)</p>
</li>
<li>
<p>Получил ранг → <code>rank.granted</code></p>
</li>
</ol>
<p class="callout warning" id="bkmrk-5-hole"><strong>Дыра между 3 и 4:</strong> PlotSquared не видит «начал строить». Без <code>BlockPlace</code> на своём плоте не отличить «взял и бросил» от «строит третий день». В v1 дыру помечаем, не закрываем.</p>
<p id="bkmrk-5-bb">«Поучаствовал в Build Battle» = заход на ивентовый сервер. Отдельного типа <code>build_battle.joined</code> нет.</p>
<hr class="separator" id="bkmrk-sep-6">
<h2 id="bkmrk-6-catalog">6) Каталог событий v1 (закрыт)</h2>
<p id="bkmrk-6-intro">Строка: <code>player_uuid</code>, <code>event_type</code>, <code>occurred_at</code>, <code>server</code>, <code>session_id</code>, <code>extra</code> (JSONB строго по типу). Неизвестный тип API отвергает. Свободных строк типов нет.</p>
<h3 id="bkmrk-6-1-joins">6.1 Заходы (пишет только Velocity)</h3>
<p id="bkmrk-6-1-p">Hub → Creative → Hub → Creative в одном визите: по одному <code>session_joined</code> на <code>hub</code> и на <code>creative</code>. Возврат на Creative в той же сессии не пишем.</p>
<table id="bkmrk-6-1-table">
<thead>
<tr>
<th>Тип</th>
<th>Смысл</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>player.first_joined_server</code></td>
<td>Первый раз в жизни на этом <code>server</code></td>
<td><code>server</code></td>
</tr>
<tr>
<td><code>player.session_joined_server</code></td>
<td>Первый раз на этом <code>server</code> в network-сессии</td>
<td><code>server</code></td>
</tr>
</tbody>
</table>
<h3 id="bkmrk-6-2-plots">6.2 Плоты (PlotSquared, Creative)</h3>
<p id="bkmrk-6-2-p">Активация и история владения. <code>PlayerEnterPlotEvent</code> не пишем. Trusted/members в v1 нет. <code>plot_id</code> — идентификатор из PlotSquared API. <code>width</code> — ширина в блоках.</p>
<table id="bkmrk-6-2-table">
<thead>
<tr>
<th>Тип</th>
<th>Источник</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>plot.claimed</code></td>
<td><code>PlotClaimedNotifyEvent</code> (<code>wasAuto()</code> → <code>method</code>: <code>auto</code> | <code>claim</code>)</td>
<td><code>plot_id</code>, <code>world</code>, <code>width</code>, <code>method</code></td>
</tr>
<tr>
<td><code>plot.unclaimed</code></td>
<td>unclaim</td>
<td><code>plot_id</code>, <code>world</code>, <code>width</code></td>
</tr>
<tr>
<td><code>plot.deleted</code></td>
<td>delete</td>
<td><code>plot_id</code>, <code>world</code>, <code>width</code></td>
</tr>
<tr>
<td><code>plot.cleared</code></td>
<td>clear</td>
<td><code>plot_id</code>, <code>world</code>, <code>width</code></td>
</tr>
<tr>
<td><code>plot.transferred</code></td>
<td><code>PlotChangeOwnerEvent</code></td>
<td>то же + <code>from_uuid</code>, <code>to_uuid</code></td>
</tr>
</tbody>
</table>
<h3 id="bkmrk-6-3-rank-player">6.3 Ранг — новичок (BinoRankup)</h3>
<p id="bkmrk-6-3-p">Слушаем Bukkit API плагина, хуки не выдумываем. <code>categories</code> — как отдаёт Rankup (JSON-массив строк). Имена классов сверить с репозиторием при реализации; смысл событий не менять.</p>
<table id="bkmrk-6-3-table">
<thead>
<tr>
<th>Тип</th>
<th>Слушаем</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>rank.request_submitted</code></td>
<td><code>RequestSubmitEvent</code></td>
<td><code>request_id</code>, <code>from_rank</code>, <code>to_rank</code>, <code>plot_id</code>, <code>world</code>, <code>width</code>, <code>categories</code></td>
</tr>
<tr>
<td><code>rank.request_status_changed</code></td>
<td><code>RequestStatusChangedEvent</code></td>
<td><code>request_id</code>, <code>from_status</code>, <code>to_status</code></td>
</tr>
<tr>
<td><code>rank.granted</code></td>
<td><code>PlayerRankGrantedEvent</code></td>
<td><code>from_rank</code>, <code>to_rank</code>, <code>request_id</code></td>
</tr>
</tbody>
</table>
<h3 id="bkmrk-6-4-rank-staff">6.4 Ранг — staff (основная метрика команды)</h3>
<table id="bkmrk-6-4-table">
<thead>
<tr>
<th>Тип</th>
<th>Слушаем</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>rank.review_submitted</code></td>
<td><code>ReviewSubmitEvent</code></td>
<td><code>request_id</code>, <code>vote</code> (<code>APPROVE</code>/<code>REJECT</code>), <code>voter_role</code> как в конфиге Rankup, <code>plot_id</code>, <code>world</code></td>
</tr>
<tr>
<td><code>rank.rating_submitted</code></td>
<td><code>RatingSubmitEvent</code> только если у <strong>голосующего</strong> роль Helper / Mentor / Senior Mentor</td>
<td><code>request_id</code>, <code>plot_id</code>, <code>world</code></td>
</tr>
</tbody>
</table>
<p id="bkmrk-6-4-note">Review без <code>voter_role</code> не пишем: вес голоса в Rankup от роли. Обычный игроковый <code>/rate</code> в лог не идёт (отсекаем на входе).</p>
<h3 id="bkmrk-6-5-marks">6.5 Марки (PlotMark, Creative)</h3>
<p id="bkmrk-6-5-p">См. <a href="https://wiki.binomc.net/books/other/page/metki-na-plotax-tz-ideia">Метки на плотах — ТЗ</a>. PlotMark вызывает <code>Analytics.track</code> или отдаёт Bukkit-событие с тем же extra.</p>
<table id="bkmrk-6-5-table">
<thead>
<tr>
<th>Тип</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>staff.mark_placed</code></td>
<td><code>mark_type</code>: <code>pointer</code> / <code>edit</code> / <code>rework</code>; <code>plot_id</code>, <code>world</code></td>
</tr>
<tr>
<td><code>staff.mark_resolved</code></td>
<td>то же + <code>resolved_by</code>: <code>player</code> | <code>staff</code></td>
</tr>
</tbody>
</table>
<hr class="separator" id="bkmrk-sep-7">
<h2 id="bkmrk-7-storage">7) Хранение</h2>
<p id="bkmrk-7-p">Таблица <code>player_events</code>, append-only.</p>
<table id="bkmrk-7-cols">
<thead>
<tr>
<th>Колонка</th>
<th>Тип</th>
<th>Назначение</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>id</code></td>
<td><code>bigserial</code></td>
<td>PK</td>
</tr>
<tr>
<td><code>occurred_at</code></td>
<td><code>timestamptz</code></td>
<td>время события, не вставки</td>
</tr>
<tr>
<td><code>received_at</code></td>
<td><code>timestamptz</code></td>
<td>время записи в Postgres</td>
</tr>
<tr>
<td><code>player_uuid</code></td>
<td><code>uuid</code></td>
<td>игрок</td>
</tr>
<tr>
<td><code>event_type</code></td>
<td><code>text</code></td>
<td>только каталог §6</td>
</tr>
<tr>
<td><code>server</code></td>
<td><code>text</code></td>
<td>канон Velocity</td>
</tr>
<tr>
<td><code>session_id</code></td>
<td><code>uuid</code> nullable</td>
<td>network-сессия; null если прокси не доставил</td>
</tr>
<tr>
<td><code>extra</code></td>
<td><code>jsonb</code></td>
<td>контракт типа</td>
</tr>
</tbody>
</table>
<p id="bkmrk-7-idx"><strong>Индексы:</strong> <code>(player_uuid, occurred_at DESC)</code>; <code>(event_type, occurred_at)</code>; <code>(server, event_type, occurred_at)</code>; expression <code>(extra->>'plot_id')</code>, <code>(extra->>'request_id')</code>. GIN по всему extra в v1 нет.</p>
<p id="bkmrk-7-uniq"><strong>Уникальность:</strong> <code>first_joined</code> — <code>(player_uuid, event_type, server)</code>. <code>session_joined</code> — <code>(session_id, event_type, server) WHERE session_id IS NOT NULL</code> (без <code>WHERE</code> в Postgres несколько NULL не дедупятся). Повтор — <code>ON CONFLICT DO NOTHING</code>. Плот/ранг/марки не дедупим.</p>
<p id="bkmrk-7-ttl">TTL сырого лога в v1 нет. Политика хранения — отдельное решение по факту объёма.</p>
<hr class="separator" id="bkmrk-sep-8">
<h2 id="bkmrk-8-roles">8) Кто пишет что</h2>
<h3 id="bkmrk-8-1-vel">8.1 Velocity Master</h3>
<ul id="bkmrk-8-1-ul">
<li>
<p>Выдаёт <code>session_id</code> на время пребывания в сети.</p>
</li>
<li>
<p>Пишет два типа заходов (§6.1). First: кэш + уникальный индекс (гонка → conflict ignore).</p>
</li>
<li>
<p>После коннекта/switch на Paper — plugin message с <code>session_id</code> и каноном <code>server</code>.</p>
</li>
</ul>
<h3 id="bkmrk-8-2-paper">8.2 Paper</h3>
<ul id="bkmrk-8-2-ul">
<li>
<p>Не считает first/session. <code>track()</code> дописывает текущие <code>session_id</code> и <code>server</code>.</p>
</li>
<li>
<p>Нет сообщения с прокси — событие пишем, <code>session_id = null</code>. Факт <code>plot.claimed</code> важнее склейки с сессией.</p>
</li>
<li>
<p>Состояние: карта UUID → сессия до quit. Тот же <code>session_id</code> при каждом switch.</p>
</li>
<li>
<p>Гонка: событие раньше plugin message — допустимо, <code>session_id</code> null.</p>
</li>
</ul>
<p class="callout info" id="bkmrk-8-shadow"><strong>Shadow:</strong> в v1 не ставим. Иначе два источника сессий. Игрок через Shadow = как без прокси.</p>
<p id="bkmrk-8-no">Не в v1: Redis, слушатели плотов на прокси.</p>
<hr class="separator" id="bkmrk-sep-9">
<h2 id="bkmrk-9-api">9) API</h2>
<pre id="bkmrk-9-sig"><code>boolean track(UUID player, String eventType, Map&lt;String, Object&gt; extra)</code></pre>
<ul id="bkmrk-9-ul">
<li>
<p>Нет paper/velocity плагина на сервере → no-op, <code>false</code>.</p>
</li>
<li>
<p>Тип не из каталога или нет обязательных ключей extra → <code>false</code>, warn в лог, в очередь не кладём.</p>
</li>
<li>
<p>Успешный enqueue → <code>true</code> (ещё не значит «уже в Postgres»).</p>
</li>
</ul>
<p id="bkmrk-9-thread">Игровой поток только enqueue. Фоновый writer — batch insert. Главный поток JDBC не ждёт.</p>
<hr class="separator" id="bkmrk-sep-10">
<h2 id="bkmrk-10-errors">10) Ошибки</h2>
<table id="bkmrk-10-table">
<thead>
<tr>
<th>Ситуация</th>
<th>Поведение</th>
</tr>
</thead>
<tbody>
<tr>
<td>Postgres недоступен при старте</td>
<td>Плагин включается; <code>track</code> enqueue; периодический warn</td>
</tr>
<tr>
<td>Postgres лёг в рантайме</td>
<td>Очередь в памяти, потолок <strong>10 000</strong>. Overflow: drop + счётчик. Игрок не лагает</td>
</tr>
<tr>
<td>После подъёма БД</td>
<td>Flush очереди</td>
</tr>
<tr>
<td>Failover на диск/SQLite</td>
<td>Нет в v1 (два источника правды)</td>
</tr>
<tr>
<td>Битый plugin message</td>
<td>Игнор</td>
</tr>
<tr>
<td>Неверный extra / тип</td>
<td>Отказ до очереди</td>
</tr>
</tbody>
</table>
<hr class="separator" id="bkmrk-sep-11">
<h2 id="bkmrk-11-tests">11) Тесты (CI без живого MC)</h2>
<ul id="bkmrk-11-ul">
<li>
<p>Контракт extra: полный набор — ok; дырявый — reject.</p>
</li>
<li>
<p>Дедуп <code>first_joined</code> и <code>session_joined</code>.</p>
</li>
<li>
<p><code>rank.rating_submitted</code>: Helper/Mentor/Senior Mentor — пишем; игрок без роли — нет.</p>
</li>
<li>
<p><code>rank.review_submitted</code> без <code>voter_role</code> — нет.</p>
</li>
<li>
<p>Запрос-заготовка воронки: цепочка типов по uuid (не UI).</p>
</li>
</ul>
<hr class="separator" id="bkmrk-sep-12">
<h2 id="bkmrk-12-next">12) Следующие этапы (не v1)</h2>
<ol id="bkmrk-12-ol">
<li>
<p>DataExtension Plan: счётчики, first/last, урезанная история (лимиты Plan: 4 колонки, 50 символов).</p>
</li>
<li>
<p>Отчёт воронки / retention по когортам (SQL или отдельный сервис).</p>
</li>
<li>
<p>Закрытие дыры «взял плот, не строит».</p>
</li>
<li>
<p>Velocity Shadow, если станет основным входом.</p>
</li>
<li>
<p>TTL/партиции, когда будет ясен объём.</p>
</li>
</ol>
<hr class="separator" id="bkmrk-sep-13">
<h2 id="bkmrk-13-rejected">13) Сознательно отвергнутое</h2>
<ul id="bkmrk-13-ul">
<li>
<p>Хранение в БД Plan через Query API.</p>
</li>
<li>
<p>DataExtension как «запись в БД» — он не пишет лог.</p>
</li>
<li>
<p><code>build_battle.joined</code> — дубль захода на ивентовый <code>server</code>.</p>
</li>
<li>
<p><code>PlayerEnterPlotEvent</code> — ходьба, не ключ.</p>
</li>
<li>
<p>Каждый Paper join без дедупа по network-сессии.</p>
</li>
<li>
<p>PostgreSQL через Plan API — не поддерживается (<code>SQLITE</code> / <code>MYSQL</code> only).</p>
</li>
</ul>
```