Files
pump-game-ops/docs/DECISIONS.md
T

402 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DECISIONS.md — журнал решений (ADR)
Каждое решение — короткая запись, чтобы потом не гадать, почему всё устроено так.
Формат: дата, статус (принято/рассмотрение/заменено), суть, аргументы за/против, результат.
---
## ADR-001 — Используем свой токен на pump.fun (мемкойн Binary Rocket)
- **Дата:** 2026-09-09
- **Статус:** принято
- **Суть:** выпускаем СВОЙ токен на pump.fun, а не пользуемся чужим.
- **За:** бренд/маркетинг, контроль экономики, стимул игрокам, единая валюта ставок.
- **Против:** надо эмитировать и следить за курсом; чужой токен дал бы меньше работы.
- **Решение:** свой токен. decimals = 6, Token-2022, создание через `create_v2` (по доке pump.fun).
## ADR-002 — Курс живой рыночный (Способ 1)
- **Дата:** 2026-09-09
- **Статус:** принято
- **Суть:** вне игры баланс — в фантиках, курс берётся с рынка (bonding curve/AMM pump.fun).
Ввод = swap SOL→фантики, вывод = swap фантики→SOL. Никакой фикс-привязки/стейблока.
- **За:** нативно pump.fun, привычно игроку-трейдеру, технически проще, маркетинг (рост/падение токена).
- **Против:** баланс «гуляет» с рынком; определённым аудиториям неудобно.
- **Решение:** Способ 1. (Это и диктует, что ставки идут фантиками через контракт.)
## ADR-003 — Ставки идут фантиками через контракт
- **Дата:** 2026-09-09
- **Статус:** принято (вытекает из ADR-002)
- **Суть:** игрок ставит токен, контракт держит фантики в своём ATA-ваулте и платит фантиками.
- **Следствие:** контракт переводится с нативного SOL (lamports) на SPL-токен — это ядро Фазы 1.
- **Против:** трогаем хорошо работающий контракт; но без этого сценарий ввода-игры-вывода не работает.
## ADR-004 — Ввод/вывод через pump.fun swap (`POST /agents/swap`)
- **Дата:** 2026-09-09
- **Статус:** принято
- **Суть:** пополнение = `inputMint: NATIVE_MINT(SOL), outputMint: наш_mint`; вывод = наоборот.
Клиент на бэкенде (Ktor `PumpApiClient`) уже готов в `smart-updown-server`.
- **За:** возвращает готовую подписанную swap-транзакцию, бэкенд не держит ликвидность.
- **Против:** весь ввод/вывод завязан на доступность pump.fun API.
## ADR-05 (идея) — мета-репозиторий для документации и трекинга
- **Дата:** 2026-09-09
- **Статус:** принято
- **Суть:** создать `pump-game-ops`, где живут карта репозиториев, решения, экономика и список задач.
- **За:** единая точка оркестрации, не плаваем по чату. **Против:** риск превратиться во второй трекер.
---
## ADR-005 — Флоу входа/вывода подтверждён (как описал юзер)
- **Дата:** 2026-09-09
- **Статус:** принято
- **Суть:** человек заходит в игру → видит баланс 0 → кнопка «Пополнить» (одна, без выбора) →
swap SOL→фантики → баланс появляется → ставит фантики через контракт (честное списание/начисление
по исходу) → при выходе нажимает «Вывести» → swap фантики→SOL, баланс уменьшается.
- **За:** точный пользовательский сценарий, подтверждённый владельцем; простая UX-модель.
- **Решение:** именно так и работаем — это источник требований для Фазы 1 (контракт на токене) и Фазы 5 (UI).
---
## ADR-006 — Предмет ставки: цена SOL (валюта — фантики)
- **Дата:** 2026-09-09
- **Статус:** принято
- **Суть:** валютой ставки являются наши фантики (токен Binary Rocket), но ставка делается на
**направление цены SOL** (вверх/вниз) — как и в текущей версии игры. Контракт по-прежнему
сравнивает entry/exit_price цены SOL.
- **Цена SOL сейчас** — случайная заглушка (`RandomPriceSource`). Позже заменяется на реальную
(провайдер/оракул). Это отдельная задача, не блокирует Фазу 1 (контракт от способа получения цены не зависит).
**→ Обновление 2026-09-11:** реальный источник выбран — см. ADR-007.
- **Следствие:** фантики = касса/валюта ввода-вывода; контракт оперирует токеном, но исход считает
по цене SOL. Экономика токена (ADR-002 живой курс) не влияет на логику «вверх/вниз».
---
## ADR-007 — Источник цены SOL = upstream-сервис `binance-market`
- **Дата:** 2026-09-11
- **Статус:** принято
- **Суть:** цена SOL для `PriceSource` берётся из уже работающего в инвест-кластере сервиса
`binance-market` (репо `subochev/binance-market`), а не из своей оракуль-логики и не из прямого
опроса Binance. Контракт между игрой и сервисом — публичный:
- **NATS-подписка** на топик `market.price.solusdt` (предпочтительно, в кластере); либо
- **WebSocket** `ws://<binance-market>:8080/ws/price?symbol=solusdt` (fallback / внешние сети).
Источник цены внутри `binance-market` — last-trade (`aggTrade.p` с биржи Binance), тики идут
на каждой сделке → фактически real-time.
- **За:**
- Уже работает и развёрнут в кластере `kube` (ns `invest`), chart `binom/binance-market`,
последний релиз v10. Не надо поднимать ещё один процесс ради цены.
- Есть и NATS, и WS-контракт — обе формы потребления готовы.
- Покрытие `solusdt` уже в списке символов; дополнительных подписок не требуется.
- Цена приходит в тиках сделок (не сэмплинг раз в N секунд) — лучшее разрешение для
сравнения entry/exit в round-based игре.
- Общий сервис инвест-кластера, используется не только этой игрой; его судьба — не наша забота.
- **Против:**
- Один источник (Binance). Если Binance недоступен — игра без цены. Заглушка-fallback
на `RandomPriceSource` остаётся как dev-only.
- Зависимость по латентности от стороннего сервиса. В кластере это несколько мс,
приемлемо.
- **Результат:** задачи «TODO Цена SOL: реальный источник» (Фаза 0) и «PriceSource: реальная цена
(вместо RandomPriceSource)» (Фаза 2) переводятся в `IN_PROGRESS` со ссылкой на это решение.
Никаких новых подписок/оракулов не делаем — только интеграция в релейере (`smart-updown-token`/
`relayer` или `smart-updown-server`). Внешний статус сервиса см. README → «Внешние зависимости».
**→ Обновление 2026-09-13: реализовано, см. ADR-008** (NATS-консьюмер в шлюзе, цена ×1e8,
раздача фронту по WS).
---
## ADR-008 — Реализация: цена SOL из NATS в шлюзе + раздача фронту по WS
- **Дата:** 2026-09-13
- **Статус:** принято, реализовано
- **Контекст:** ADR-006 зафиксировал предмет ставки (цена SOL), ADR-007 выбрал источник
(`binance-market` через NATS). Оставалось реализовать: сама цена была случайной
заглушкой `RandomPriceSource` / `simulated = 100_000_000 + Math.random()`. Исход раунда был
случаен, а не рыночен. При этом в контуре уже работает `binance-market` — сборщик сделок с
Binance, который публикует каждую цену в NATS (`market.price.<symbol>`).
- **Решение:** игровой шлюз (`smart-updown-token/relayer`) подписывается на NATS
(`market.price.solusdt`) и берёт цену оттуда. Заглушка удалена.
- **Транспорт:** core NATS `nats://192.168.88.93:4222`, subject `market.price.solusdt`.
Режим — **только чтение**; `binance-market` не трогаем.
- **Шкала:** строка из NATS переводится в целое ×**1e8** строковым парсером (без float):
`"101.89000000"` → `10189000000`. То же юнит-пространство, что у оракульных цен и у
прежней заглушки (`100_000_000`), поэтому контракт сравнивает цены корректно.
- **Раздача фронту:** шлюз транслирует каждый тик по WebSocket (`/ws`, снаружи
`wss://testfront.binom.pw/api/ws`), сообщение — только цена:
`{"type":"price","symbol":"solusdt","price":"102.30...","ts":...}`.
- **Отсутствие цены:** считается аварийным состоянием и обрабатывается мягко — шлюз
стартует без NATS, запрос цены ждёт таймаут, фронт показывает последнее значение и
переподключается. Отдельный сценарий деградации — тема для будущего решения.
- **За:** исход раунда становится рыночным (entry/exit — реальные цены Binance);
переиспользуется уже работающий сборщик, торговый контур не дублируется; шина отделяет
источник цены от её потребителей (игра, аналитика, будущие сервисы).
- **Против:** появляется зависимость от NATS и от `binance-market`; core NATS не хранит
историю (нет JetStream) — при разрыве подписки цена не «догоняется», нужен живой поток.
- **Альтернативы:** (а) прямая подписка шлюза на Binance WS — дублирование сборщика, лишние
внешние коннекты; (б) оракул on-chain (Pyth/Switchboard) — правильнее для боя, но
дороже и требует фидов; (в) заглушка — отклонено как единственный вариант, оставлена
возможность (`setPriceOverride`) для тестов.
- **Детали и диаграммы:** `docs/PRICE-NATS.md`, `docs/diagrams/price-flow.puml`.
## ADR-009 — Один бэк на оба домена: прод-домен переведён на TS-шлюз (2026-09-15)
- **Дата:** 2026-09-15
- **Статус:** принято, реализовано
- **Контекст:** на `smart-updown.binom.pw` (Unity-игра) стоял **канонический Kotlin-бэк**
`smart-updown-server` с programId `3NWEK…`, а на `testfront.binom.pw` — **TS-шлюз**
`updown-relayer` (:8895), кастодиальный, на контракте `9ALs…`. Два бэка = два контракта =
разные цепочки состояния: у Kotlin-бэка вообще НЕТ WebSocket, а его программа в цепи
отсутствовала (`getAccountInfo` → `null`), поэтому `/api/state` отдавал
`global state is not initialized`. Игра при этом читала баланс напрямую из цепи через свой
nginx-прокси `/solana-rpc/getBalance` — то есть жила в третьей реальности.
- **Решение:** **не деплоить `3NWEK` под Kotlin**, а повесить игровой домен на **тот же** TS-шлюз,
что и тестовый: `/api` и `/ws` → `testfront-gateway-smart-updown-relayer:8895`. Формулировка
владельца: «тестовый фронт мы для этого и делали»; игру переделают под API шлюза — расхождение
контрактов API не блокер.
- Kotlin-бэк выключен **обратимо**: `replicas=0` + его ingress удалён (иначе два ingress'а на
один хост+path = недетерминированный роутинг Traefik).
- Бэкап состояния до правки — `/root/bak-smart-updown-15.09/` на 192.168.76.120.
- **За:** один бэк = одно состояние на оба стенда (счётчик 55=55, касса 29320); у игры
появляется живой WS; тестовый фронт больше не «отдельный мир»; откат — две команды.
- **Против:** API шлюза и ожидания игры расходятся (`minAmount`/`maxAmount` строкой против
`minAmountLamports`/`entryPrice`; тело ставки `{address, side, amountUnits}` против
`{side, amount, entryPrice, betId, signedTx}`; `GET /api/bet/{id}` у шлюза нет) — игру надо
править. Принято осознанно.
- **Что осталось не сделано:** программа `3NWEK` в цепь так и не задеплоена (ключ
`/root/game_program_kp.json`, `.so` 217496 б лежат в LXC 151; на адрес аирдропнуто 5 SOL).
Это задел на будущее, не потеря.
## ADR-010 — `/api`,`/ws` вынесены в ingress вне helm; образ фронта поднимается явным `--set image.tag` (2026-09-15)
- **Дата:** 2026-09-15
- **Статус:** принято, реализовано
- **Контекст — две независимые ловушки, обе пойманы живьём:**
1. Чарт `fun-game-front2` (`helm/templates/ingress.yaml`) рендерит **ровно один** path `/`
(жёстко, host из `values.yaml`) — значений для `/api`,`/ws` в чарте НЕТ. Значит **любой**
`helm upgrade` игры перерисовывает ingress и **сносит** `/api`,`/ws`: домен начинает отдавать
HTML игры на `/api/*` (nginx 405 на `POST /wallet`, `/api/state` = HTML Unity вместо JSON).
2. Шаблон деплоя объявляет образ как
`image: "{{ Values.image.name }}:{{ default Chart.AppVersion Values.image.tag }}"`, а в
`helm/values.yaml` **захардкожен `image.tag`** (был `26`). Непустой `Values.image.tag`
побеждает `appVersion`, поэтому `helm upgrade` на чарт `28` отрендерил под со **старым**
тегом `26`: спека не изменилась, новый ReplicaSet **не создался**, старый под (от 8 сентября)
продолжал жить. Внешне — «на домене открывается старый билд» при зелёном CI и
`Upgrade complete` в истории helm.
- **Решение:**
1. `/api` и `/ws` живут в **отдельном ingress `smart-updown-api`** (манифест
`k8s/smart-updown-api-ingress.yaml`), front2-ingress остаётся `paths: [/]`. Релиз игры
больше не может их снести.
2. Фронт игры поднимать **всегда** явным тегом и с сохранением values:
`helm upgrade fun-game-front2 binom/fun-game-front2 -n game --reuse-values --set image.tag=<N> --wait`.
Без `--reuse-values` затираются `imagePullSecrets: regcred` и `ingress.host`.
- **Проверка результата — по КУБУ и домену, а не по helm** (helm отрапортует «успех» и в случае 2):
```bash
kubectl get pod -n game -l app.kubernetes.io/name=fun-game-front2 \
-o custom-columns='NAME:.metadata.name,IMAGE:.spec.containers[0].image,CREATED:.metadata.creationTimestamp'
kubectl exec -n game <pod> -- ls -la /usr/share/nginx/html/Build/ # дата файлов = дата билда
curl -sI https://smart-updown.binom.pw/Build/WebGL.data | grep -i last-modified
kubectl -n game get ingress \
-o custom-columns='NAME:.metadata.name,HOST:.spec.rules[0].host,PATHS:.spec.rules[0].http.paths[*].path'
```
- **Против / остаточный риск (не забыть):** в `nginx.conf` все Unity-ассеты объявлены
`Cache-Control: public, max-age=604800, immutable`, а имена файлов (`WebGL.data`, `WebGL.wasm`,
`WebGL.framework.js`) между сборками **НЕ меняются**. Браузер, уже игравший, будет до 7 суток
отдавать старые ассеты из кэша, игнорируя новый билд (`index.html` спасает `no-store`, ассеты — нет).
Лечится сбросом кэша/инкогнито у игрока. **Правильно на будущее** — хэш содержимого в имени
файла на шаге сборки (CI), тогда `immutable` становится корректным.
- **Альтернативы:** (а) патчить чарт фронта (добавить в него `/api`,`/ws`) — чужой репозиторий и
чужой релизный цикл; (б) держать образ на `latest` — нет воспроизводимости; (в) убрать `image.tag`
из `values.yaml` и опираться только на `Chart.AppVersion` — правильнее, но это правка чужого репо
(отдать фронтендеру, см. `TASKS.md`).
---
## ADR-011 — Статика игры вынесена из куба на nginx (LXC 152), версии — каталогами (2026-09-15)
**Контекст.** До этого и корень, и `/api`,`/ws` жили в кубе: внешний Traefik (VDS) вёл весь домен
`smart-updown.binom.pw` на k3s `192.168.76.120`, статику отдавал под `fun-game-front2`
(nginx внутри образа), API — отдельный ingress `smart-updown-api` → TS-шлюз `:8895`.
Владелец решил **разделить плоскости**: бэк честно деплоится в куб, а сама игра (статика)
раздаётся с nginx, развёрнутого на Proxmox — со **версионированием по каталогам** (`/<номер>/`).
**Решение:**
1. **nginx на Proxmox** — новый LXC **152** (`game-web`, 192.168.76.152, Ubuntu 24.04, 2 ядра,
1 ГБ RAM, диск 8 GB на `local-lvm`, `onboot=1`, unprivileged). Отдельный LXC потому, что
статика игры — «10 строк конфига и каталог», держать её в кубе рядом с бэком смысла нет.
2. **Версионирование каталогами:**
- `/opt/game/releases/<N>/` — файлы сборки N (`Build/`, `TemplateData/`, `index.html`);
- `/opt/game/current` — симлинк на активную сборку;
- `https://smart-updown.binom.pw/` → активная сборка, `/28/…` → конкретная сборка.
3. **Маршрутизация на внешнем Traefik (VDS)** — два router'а на один host, вместо одного:
- `smart-updown-api-router`: `Host(...) && (PathPrefix('/api') || PathPrefix('/ws'))` → `192.168.76.120` (куб);
- `smart-updown-router`: `Host(...)` → `192.168.76.152` (nginx на проксмоксе).
PathPrefix-роутер специфичнее Host-роутера, поэтому `/api`,`/ws` **не перехватываются статикой**.
IP LXC 152 достижим с VDS через wg-туннель (192.168.76.0/22 dev wg0).
4. **В кубе ничего не менялось**: `ingress smart-updown-api` (`/api`,`/ws` → шлюз) как был, вне helm.
Под `fun-game-front2` остался жив, но домен он больше не обслуживает (статика ушла на nginx) —
в куб его релизить больше не обязательно; релизы — только доставка статики на nginx.
5. **Инструменты** (`tools/proxmox-nginx/`): `unpack-release.sh` (распаковка dir-дампа образа в
`releases/<N>`), `publish-release.sh` (тянет образ из реестра, раскладывает, по `--activate`
переключает `current`), `game.nginx.conf`, `smart-updown.traefik.yaml`.
**Питфоллы, пойманные живьём:**
- `skopeo copy docker://… dir:` кладёт слои по **sha256-digest**, а `manifest.json` — это **OCI-манифест**
(словарь `layers[].digest`, не список строк как в docker-archiv). Парсить `json['layers'][i]['digest']`,
а не печатать элемент целиком (иначе `tar: Cannot connect to {'mediaType': ...}`).
- Голый шаблон `/28` (без слэша) отдаёт **301** — это `try_files ... /$build$subpath/`, т.е. редирект
на `/28/`. Для относительных путей Unity (`src="Build/WebGL.loader.js"`) важно, чтобы **браузерный
URL заканчивался слэшем**; иначе `Build/...` уедет в `/Build/...` и вернётся 404. Ссылаться на
версии только со слэшем (`/28/`), либо ставить редирект в nginx (`return 301 /$build/`).
- `/solana-rpc/` **не** перехватывается PathPrefix `/api` — это отдельный path, и на VDS он уходит
в корневой router (nginx). Поэтому страховочный `location /solana-rpc/` в nginx нужен: старые сборки
игры стучатся именно туда (проверено: `getHealth` → `ok` через домен).
- Имена ассетов Unity внутри сборки не меняются (`WebGL.data`/`.wasm`/`.framework.js`), но теперь
URL содержит номер сборки → `immutable`-кэш больше не «прилипает» между релизами (закрыт остаточный
риск из ADR-010). Внутри одной версии ассеты неизменяемы, т.к. каталог версии не перезаписывается.
**Приёмка (живой домен, 2026-09-15):** корень `/` → 200 от `nginx/1.24.0 (Ubuntu)` (LXC 152);
`/27/`,`/28/` → 200, ассеты версий различаются (md5 `WebGL.data`: `76b6f3…` vs `47453d…`);
`/api/state` → 200 JSON (куб), `/api/ws` → `[open]` + `{"type":"price"}`; сквозной
`tools/autoclose-check.cjs` на `BASE=https://smart-updown.binom.pw/api` — **9/9**; `testfront` не задет.
Откат: вернуть `/opt/traefik/static/smart-updown.yaml` из бэкапа VDS
(`/root/bak-smart-updown-nginx-2026-09-15/`) — домен снова целиком уйдёт в куб.
**Альтернативы:** (а) оставить статику в кубе и версионировать каталогами внутри образа — тянет
пересборку образа на каждый билд игры и не решает вопрос кэша; (б) поднять в LXC не nginx, а
podman-контейнер с nginx — лишний слой на 10 строках конфига; (в) отдавать статику прямо с k3s-Traefik
по PVC — не то, о чём просил владелец (он хотел nginx на проксмоксе).
## ADR-011-доп — корень редиректит на активную сборку (закрыта кэш-мина ADR-010) (2026-09-15)
**Что нашли на сборке 29 (проверка живьём, не по документам).** `index.html` Unity ссылается на
ассеты **относительным** путём (`var buildUrl = "Build"`). Пока корень отдавал файлы напрямую из
`/opt/game/current`, игрок получал ассеты по URL **без номера сборки** — `/Build/WebGL.wasm`,
`/Build/WebGL.data` и т.д. Эти URL между релизами **не меняются**, а в конфиге они попадали под
маску `immutable` (7 суток). Итог: у игравшего игрока браузер неделю держал бы ассеты старой сборки
и молча игнорировал новый билд. То есть моя собственная маска кэша воспроизводила ровно ту мину,
которую ADR-011 объявлял закрытой. Версионирование каталогами **само по себе** её не лечило —
лечило только то, чтобы URL ассетов реально содержали номер сборки.
**Решение.** Корень больше не раздаёт файлы — он **302-редиректит на активную сборку**:
- `publish-release.sh --activate` пишет `/etc/nginx/game-active.inc`:
`location = / { return 302 /<N>/; }` + то же для `/index.html`;
- `include /etc/nginx/game-active.inc;` подключён в server-блок;
- после редиректа `location.pathname` = `/29/`, относительный `buildUrl = "Build"` резолвится
в `/29/Build/…` — версионированный URL, старый URL перестаёт использоваться вовсе;
- маска `immutable` сужена до путей **с номером сборки** (`~^/[0-9]+/…`); всё остальное,
включая неверсионированные ассеты, теперь `no-store` (страховка от отравления кэша).
**Проверено живьём (сборка 29):** `/` → **302 → `/29/`**; браузер приземляется на `/29/` и тянет
`/29/Build/WebGL.wasm`, `/29/Build/WebGL.data`, `/29/Build/WebGL.framework.js` — все 200, ни одной
ошибки в консоли; `/28/`,`/27/` живы; неверсионированный `/Build/WebGL.wasm` → 200 с `cache-control:
no-store`; `/api/state`,`/api/price`,`/solana-rpc/` → 200; сквозная ставка `autoclose-check` — **9/9**;
`testfront` не задет (200).
**Почему не «отдавать корень без редиректа».** Это работало бы (неверсионированные ассеты получили бы
`no-store`), но теряется весь смысл `immutable`-кэширования: ассеты в 52 МБ + 18 МБ перекачивались бы
при каждом заходе. Редирект даёт и корректность, и кэш.
**Остаточный риск.** Игрок, у которого в кэше уже лежат ассеты по **старому** неверсионированному URL
(`/Build/WebGL.wasm`, полученные до этого фикса), один раз может получить старый билд — URL теперь
`no-store`, так что после первой перезагрузки браузер сходит на сервер и получит свежее. Новые
версии этой проблемы не имеют вовсе.
## ADR-012 — Именованные сборки (zip от фронтендера) рядом с номерными: `/sol/` (2026-09-15)
**Контекст.** Фронтендер прислал готовый Unity-WebGL-билд **zip-архивом** — вне релизного цикла Gitea
(не через CI, без образа в реестре, без номера сборки). Владелец: «создай там сам папку и выложи».
**Решение.** Каталоги на nginx теперь двух видов, живут рядом:
- `/opt/game/releases/<N>/` — номерные релизы из CI/реестра (`1`, `2`, … `29`);
- `/opt/game/releases/<имя>/` — именованные сборки из zip (`sol`).
**Именованные сборки АКТИВНУЮ не трогают**: `current` остаётся на номере, корень по-прежнему
редиректит на `/29/`. Именованная доступна только явной ссылкой `/<имя>/`.
**Изменения в nginx** (`game.nginx.conf`):
- regex-локейшн сборок расширен с чисел до имён: `^/(?<build>[a-zA-Z0-9][a-zA-Z0-9_-]*)(?<subpath>/.*)?$`;
- ⚠️ служебные пути получили префикс **`^~`** (`location ^~ /api`, `^~ /ws`, `^~ /solana-rpc/`).
Без этого расширенный regex перехватывал бы `/api/...` как «сборку с именем api» — regex-локейшн
в nginx приоритетнее обычного префиксного. `^~` подавляет проверку regex для этого префикса.
**Это была реальная регрессия на первом применении: после расширения шаблона `/api/state` начал
уходить в файловый резолвер.**
- маска `immutable` осталась **только для номерных** каталогов (`~^/[0-9]+/…`): именованную папку
могут перезалить тем же именем, поэтому кэшировать её ассеты на 7 суток нельзя. Добавлено
расширение `unityweb` (сборки фронтендера используют `.unityweb`-ассеты).
- новый скрипт `tools/proxmox-nginx/publish-zip.sh <имя> <путь.zip>` — распаковка, подъём
единственной вложенной папки, раскладка, reload, проверка.
**Приёмка (живой домен):** `/sol/` и все его ассеты (`Build.loader.js`, `Build.wasm.unityweb`,
`Build.data.unityweb`, `Build.framework.js.unityweb`, `TemplateData/*`) → **200**; `/` → 302 на `/29/`;
`/27/`,`/28/`,`/29/` живы; `/api/state`,`/api/price`,`/solana-rpc/` → 200; `testfront` цел.
**⚠️ Что нашли в присланной сборке (отдать фронтендеру, не наш блокер):**
1. **Двойной префикс API.** Игра зовёт `GET https://smart-updown.binom.pw/api/api/state` → **404**.
Причина в исходниках: `SmartUpdownConfig.ApiBase()` для WebGL возвращает `Origin() + "/api"`,
а `BackendClient.HttpGetAsync("/api/state")` добавляет путь целиком → `…/api` + `/api/state`.
В сборке 29 тот же код, но она до этого места не доходила (в headless падала раньше).
2. **`ReferenceError: getProvider is not defined`** — падение на `_SolanaBridgeConnect` при подключении
кошелька. В собранном `framework.js` есть **вызовы** `getProvider()`, `errMsg()`, `loadWeb3()`,
но **нет их определений** (в `SolanaBridge.jslib` они объявлены как функции внутри IIFE, а в
библиотеку через `mergeInto(LibraryManager.library, {...})` экспортированы только
`SolanaBridge*`-методы). Ошибка воспроизводится и в сборке 29 — то есть это **не регресс от
переезда на nginx**, а дефект jslib-обвязки, который просто не был виден раньше.
Файл — `Assets/Plugins/WebGL/SolanaBridge.jslib` (коммит `090ebf1`).
⚠️ Что **подтверждено** этим билдом: игра в браузере **стартует и доходит до игрового цикла**
(`[BinaryRocket] Phase -> Idle` → `Phase -> Approach`), чего в headless-окружении не делала ни одна
предыдущая сборка. То есть статика с nginx отдаётся корректно.
## ADR-013 — Разбор: редакторная сборка фронта vs наша Docker/CI (только исследование, 2026-09-15)
**Вопрос владельца:** почему сборка фронтендера (редактор Unity на Mac через TeamCity) «нормальная»,
а наша (Docker/CI) «кривая косое». Задача была — исследовать, ничего не переделывать.
**Вердикт: Docker ни при чём.** Расхождение объясняется одной строкой в НАШЕМ скрипте сборки,
которая насильно выключает сжатие.
| | наша CI (сборка 29) | эталон фронта (`/sol/`) |
|---|---|---|
| файлы | `Build/WebGL.{loader.js,wasm,data,framework.js}` | `Build/Build.*.unityweb` |
| сжатие | **Disabled** (переопределено в `BuildScript.cs`) | **Brotli + Decompression Fallback** |
| wasm | 52.2 МБ raw | 8.59 МБ raw (≈49.1 МБ распакованных) |
| data | 18.3 МБ raw | 8.12 МБ raw (≈17.7 МБ распакованных) |
| едет в браузер | **26 МБ** (nginx жмёт gzip на лету) | **16.4 МБ** |
| версия Unity | 6000.3.16f1 | 6000.3.16f1 (та же!) |
**Причина.** `Assets/Editor/BuildScript.cs` (коммит `4468014`):
`PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Disabled;` — комментарий «Без предсжатия:
nginx отдаёт gzip по Accept-Encoding, мобильные прокси не ломают». Скрипт **затирает**
`ProjectSettings.asset` (`webGLCompressionFormat: 0`). gzip жмёт 52-МБ wasm всего в ~3.3×; brotli тот же
wasm жмёт в ~6×. Отсюда «у него нормально, у нас криво» — это транспорт, не сборка.
**Доки Unity:** *«If you enable Decompression Fallback, Unity appends the extension `.unityweb` to the
build file names. Otherwise, Unity appends `.gz` / `.br`.»* То есть `.unityweb` — следствие
Brotli+fallback, а не другой сборочный конвейер.
**Код в обеих сборках эквивалентен.** В распакованных `wasm` маркеры совпадают 1:1
(`SolanaBridge`=5, `PriceFeed`=2, `UnityWebRequest`=44, `Tonemapping`=4, `PostProcess`=1). Разница
размеров — сжатие/отладочные символы, не исходники.
**Наш конвейер — стандартная практика.** Образ `unityci/editor:ubuntu-6000.3.16f1-webgl-3.2.2` (GameCI),
запуск `unity-editor -batchmode -nographics -quit -executeMethod BuildScript.BuildWebGL` + лицензия `.ulf`
из env — ровно то, что описано в доках game.ci. «Своих кривых практик» здесь нет.
**⚠️ Поправка к прошлому отчёту (ADR-012):** оба дефекта, которые я назвал «дефектами сборки
фронтендера», присутствуют и в НАШЕЙ сборке 29 — проверено побайтово:
1. двойной префикс `/api/api/state` → 404: `ApiBase()` = `Origin()+"/api"`, а `BackendClient` зовёт
`HttpGetAsync("/api/state")`. Общий дефект исходников.
2. `ReferenceError: getProvider is not defined`: в `framework.js` есть вызовы `getProvider()`/`errMsg()`/
`loadWeb3()`, но нет определений — одинаковая картина в обеих сборках.
Это делает п.1–2 задачей для кодовой правки, а не «пересборки».
**Гипотеза (НЕ факт, требует проверки на железе):** в headless наша 29-я падала на шейдерах, а его
сборка доходила до игрового цикла (`Phase → Idle → Approach`). Возможная причина — `-nographics`
влияет на shader stripping / URP-варианты. Проверка: собрать без `-nographics`, сравнить поведение и
размеры. На живом железе разница может не проявиться вовсе.
**Наши побочные находки:** nginx сейчас жмёт `.unityweb` gzip'ом на лету — 0 выгоды (файл уже сжат
brotli), только CPU. Для `.unityweb` gzip надо выключать.
**Возможные шаги (НЕ выполнены — ждём решения):** (1) убрать переопределение `compressionFormat` /
поставить `Brotli` + fallback → `.unityweb` как у фронта, −40% трафика; (2) либо `gzip_static`,
а не сжатие на лету; (3) `gzip off` для `.unityweb`.
**Методика сравнения сборок** (переиспользовать при следующем разборе): распаковать brotli
(заголовок `UnityWeb Compressed Content`, смещение 0) → размеры raw/распакованных → `index.html`
(`buildUrl`, `dataUrl`,`codeUrl`,`frameworkUrl`) → число `function`-объявлений и наличие тел хелперов →
версия Unity в `wasm` → заголовки (`Content-Encoding`,`cache-control`) → поведение в браузере.