Какой стек выбран, почему именно он, и по каким измеримым триггерам его стоит пересматривать. Спутник
architecture.md(§3) иtech-research.md(анализ опций). Цель — чтобы выбор не пере-обсуждался: здесь зафиксированы причины и условия смены.
Одна и та же детерминированная симуляция обязана крутиться и на сервере (авторитет), и
в браузере (превью «если атакую — что будет», сверяемое hashState). Это
«same-code-both-sides». Он один отсекает почти всё: язык ядра должен исполняться в браузере
без границы перевода — значит JS/TS или что-то→WASM. Всё остальное в стеке — следствие
этого плюс жанра (real-time асинхронное MMO, 24/7, server-authority, детерминизм).
| Слой | Выбор | Почему | Триггер пересмотра |
|---|---|---|---|
| Sim-ядро | TypeScript (shared-core) |
Нулевая поверхность перевода сервер↔браузер; data-driven контент на JSON+zod — родная эргономика; нагрузка логическая, не вычислительная (мир идёт по формуле, не по кадрам). Детерминизм держится в бит-точном подмножестве IEEE-754 (Math.sqrt бит-точен по спеке, трансцендентные забанены ESLint-ом) |
Rust→WASM только при И заморозке схемы И измеренном сбое (воспроизведённый desync на подмножестве — крайне маловероятно — или упёртый профилем CPU редьюсера) |
| Слой действий | TS (action-layer, вне ядра) |
Валидация/авторизация/идемпотентность в одном узле; ядро остаётся чистым | — |
| Серверный рантайм | Node 20+ / TS | Тот же язык и тот же shared-core, что у клиента; нагрузка I/O-bound и событийная (advanceTo спит между событиями); скорость команды |
Go/Elixir-шлюз только когда кластер Node — измеренное узкое место по числу stateful-соединений |
| Транспорт | сырой ws (+ тонкий Fastify для HTTP рядом) |
Минимум, ты владеешь протоколом (packages/protocol), noServer-режим даёт контроль над upgrade/auth. socket.io/Fastify-ws — лишний вес и магия |
/health//ready//metrics и create/join API, WS остался на сыром ws |
| Персистентность | Postgres + JSONB | GameState уже JSON → в JSONB без маршалинга; ACID + транзакции + оптимистичные блокировки (anti-double-spend); один датастор. Mongo не даёт выигрыша над JSONB и теряет транзакции |
— |
| Планировщик событий | v1: встроенная будилка (MatchRoom.msUntilNextEvent → advanceTo) → v2: pg-boss на Postgres |
Истина расписания и так в состоянии (scheduled[]). v1 — драйвер в процессе хоста будит комнату к ближайшему событию (одного процесса хватает). pg-boss = та же «разбудить к событию», но durable и в той же БД/транзакции, что и запись состояния — нужен на мульти-процессе. Никакого второго инфра-зверя |
Redis+BullMQ только если очередь станет горячее, чем тянет Postgres (десятки тысяч джоб/с) |
| Кэш / pub-sub | пока ничего → Postgres LISTEN/NOTIFY |
До нескольких процессов кэш не нужен (состояние горячее в памяти комнаты) | Redis — при >1 серверного процесса (presence/locks/cross-node fan-out) |
| Клиент | PWA-first (TS + сборщик), карта Canvas2D→WebGL | Одна TS-кодовая база, переиспользует shared-core для мгновенного превью; асинхронному жанру не нужна нативная input-латентность. RN/Flutter = второй рантайм и потеря общего ядра |
WebGL/PixiJS — когда на экране сотни движущихся сущностей (сейчас десятки блипов) |
| Мобильная обёртка | Capacitor | Тонкий нативный шелл вокруг PWA → push + офлайн + APK, без переписывания | Нативный Kotlin/Swift — практически никогда, кроме тонкого шелла при жёсткой блокировке push/фон |
| Аналитика / античит-ML | Python (офлайн) | Воронки, когорты, anomaly-detection по отклонённым действиям — вне горячего пути, ядра не касается. Первый слой античита — replay на TS-ядре (Python не нужен) | Подключать пост-лонч, когда накопятся матч-данные |
| Инфра / хостинг | Docker Compose на VPS (сервер + Postgres + Caddy) + Cloudflare для сайта клиента | Один хост на старте: подписанный образ, Postgres только на loopback, TLS через Caddy (deploy/); Cloudflare раздаёт статику клиента, сервер партий за ним не стоит |
Managed Postgres и бэкапы вне машины (SE-3.5); своя оркестрация/k8s — только на мульти-региональном масштабе |
| Auth | свой логин+пароль (scrypt + session/join-JWT, SE-1.x); внешний OIDC — цель прода | Своя крипта паролей уже есть (password.ts scrypt); JWT в ws-handshake (auth.ts); OIDC — соц-вход позже (accounts-roadmap) |
— |
| CI | GitHub Actions (.github/workflows/) |
Полный DevSecOps на GitHub: security.yml (Semgrep/CodeQL/Trivy/OSV/Gitleaks/TruffleHog/zizmor + гейт), ci.yml (гейт + OSV-Scanner против сервис-Postgres), image.yml (подписанный образ), android.yml и rustore*.yml (APK), pages.yml. Миграция с GitLab зафиксирована в docs/security/audit-2026-06-27.md |
— |
Стек сошёлся на лёгком варианте, и это правильно:
- Ядро:
@void/shared-core(TS), детерминированное, ESLint-гард детерминизма. - Action-layer:
@void/action-layer(TS). - Сервер: Node + TS, сырой
ws(upgrade черезnoServer),pg(сторpackages/server/src/store/postgres/), тонкий Fastify для HTTP-поверхности рядом (/health//ready//metrics, create/join API — триггер «REST-API рядом» сработал, SV-0.1). Нет Redis и BullMQ — от них отказались осознанно. - Клиент: PWA-first (TS), прототип на Canvas2D, Capacitor-обёртка (APK).
- Хостинг: Docker-стек на VPS владельца (
deploy/); сайт клиента — воркер Cloudflare (wrangler.jsonc), сервер партий — на VPS, без Cloudflare перед ним. - CI: GitHub Actions (см. таблицу выше).
- Redis-шов уже заложен: эфемерные данные матча (пинги) живут за интерфейсом
EphemeralStore(packages/server/src/ephemeral.ts) с in-memory-имплом. Это и есть «заложить Redis заранее» без преждевременного зверя: на мульти-процессе Redis-импл вставляется одной заменой файла, логика комнаты не меняется (триггер №5 ниже).
Историческая сноска:
architecture.md §3и старыйroadmap.mdупоминают Fastify + Redis + BullMQ. Это исходный набросок; персистентность пере-зафиксирована на pg-boss/Postgres (persistence-roadmap.md), а транспорт — на сыройws. Этот документ отражает актуальное решение.
- Redis/BullMQ сейчас — для одного процесса дублируют Postgres + in-memory комнату; меньше зверей = меньше точек отказа.
- Переписывать ядро на Rust/Go/C# — сменишь удержанный, измеримый, чинимый-в-TS риск на неудержанный на golden-границе нового языка и потеряешь общий код с клиентом. Rust→WASM — только «план Б» под триггер.
- ORM поверх JSONB — воюет с JSONB, тащит неявную не-детерминированность; хватит тонкого слоя на
pg. - GraphQL / микросервисы / k8s — решения для проблем масштаба, которых ещё нет.
- React Native / Flutter — второй рантайм и потеря переиспользования
shared-coreна клиенте.
Полиглот по краям — только по измеримому условию, не «на всякий случай»:
- Rust→WASM-ядро — заморожена схема И воспроизведён desync на бит-точном подмножестве, либо профиль упёрся в CPU редьюсера.
- Elixir/Go-шлюз — измеренный потолок stateful-соединений у кластера Node (только край транспорта, не sim).
- WebGL-рендер — сотни движущихся сущностей в кадре.
- Python-аналитика/ML — накоплены матч-данные пост-лонч.
- Redis — появился второй серверный процесс (cross-node pub-sub / presence / locks).
TypeScript-моностек на общем shared-core, тонкий ws-сервер на Node, Postgres+JSONB как
единственный датастор (планировщик — v1 встроенная будилка, durable-эволюция через pg-boss в той же БД), PWA-клиент с Capacitor-обёрткой,
Python только для офлайн-аналитики. Минимум движущихся частей, один язык от карты до
авторитета, одна БД. Языки по краям добавляются только по названным триггерам — до тех пор
новый язык = налог без выгоды.