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

40 KiB
Raw Blame History

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):
    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) → поведение в браузере.