diff --git a/.eslintrc.json b/.eslintrc.json index c510105..571899b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -33,7 +33,12 @@ "react-hooks/exhaustive-deps": "warn", "no-eval": "error", "no-new-func": "error", - "no-implied-eval": "error" + "no-implied-eval": "error", + "no-empty": "off", + "react/no-unescaped-entities": "off", + "no-useless-catch": "warn", + "no-constant-condition": ["warn", { "checkLoops": false }], + "no-fallthrough": "warn" }, "ignorePatterns": [ "build/", diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index adb64c6..917275e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -132,23 +132,27 @@ jobs: chmod 600 ~/.ssh/known_hosts - name: Install rsync run: apt-get update -qq && apt-get install -y rsync openssh-client + # S1 — НЕ блокирующий: при недоступности S1 (downtime) деплой не должен + # валиться, главное доставить на S2. ConnectTimeout 20с чтобы не висеть. - name: Deploy to S1 (85.175.7.40:1998) + continue-on-error: true run: | rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \ - -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 1998" \ + -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998" \ build/ min@85.175.7.40:/var/www/rublox-player/build/ - name: Deploy to S2 (192.168.0.124:22, runner в той же сети) run: | rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \ -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \ build/ min@192.168.0.124:/var/www/rublox-player/build/ - - name: Verify deploy + - name: Verify S1 (не блокирующий) + continue-on-error: true run: | - echo "=== S1 ===" - ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 1998 \ + ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \ min@85.175.7.40 \ "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/" - echo "=== S2 ===" + - name: Verify S2 (обязательный) + run: | ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \ min@192.168.0.124 \ "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/" diff --git a/.gitignore b/.gitignore index 5073e93..9429fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ public/kubikon-assets/ # OS Thumbs.db +.env.production diff --git a/API_USAGE.md b/API_USAGE.md new file mode 100644 index 0000000..53a7f6a --- /dev/null +++ b/API_USAGE.md @@ -0,0 +1,104 @@ +# API, которые использует плеер + +Плеер — это **клиент**. Все данные он берёт с серверных API. Этот документ — полный список эндпоинтов которые он дёргает, зачем и от чего ломается если API изменится. + +## Базовый URL + +В prod: `https://api.rublox.pro` (alias на `minecraftia-school.ru/api-storys`) +В dev: `https://dev-api.rublox.pro` (staging, см. [reference-staging-env]) +Локально: `http://localhost:8674` (storys-микросервис) + +## Эндпоинты — список + +### Игры и лента + +| Method | Path | Когда зовётся | Сломается если... | +|---|---|---|---| +| GET | `/kubikon3d/feed?tab=hot\|new\|popular\|topweek` | при заходе на главную | поле `games[]` отсутствует | +| GET | `/kubikon3d/trending` | главная, секция трендов | возвращает не-массив | +| GET | `/kubikon3d/search?q=` | поиск игр | поле `results[]` отсутствует | +| GET | `/kubikon3d/collections` | главная, секции «Хиты», «Новинки» | возвращает не-массив | +| GET | `/kubikon3d/projects/` | при открытии конкретной игры | поле `project_data` отсутствует или поломан JSON-формат | +| GET | `/kubikon3d/my-projects` | страница «Мои игры» | в ответе нет `projects[]` | +| GET | `/kubikon3d/top-authors` | страница «Топ авторов» | возвращает не-массив | +| GET | `/kubikon3d/events` | главная, баннер мероприятий | возвращает не-массив | + +### Игровой процесс (telemetry) + +| Method | Path | Когда | Что шлёт | +|---|---|---|---| +| POST | `/kubikon3d/play/heartbeat` | каждые 30 сек пока игрок в игре | `{ game_id, play_time_ms }` | +| POST | `/kubikon3d/activity` | при входе/выходе из игры | `{ game_id, action: 'enter'\|'leave' }` | +| POST | `/kubikon3d/perf-log` | при детектировании просадки FPS <20 на 5+ сек | `{ game_id, fps, draw_calls, mem }` | +| POST | `/kubikon3d/bug-reports` | юзер жмёт «Сообщить о баге» | `{ game_id, description, screenshot_b64 }` | +| POST | `/kubikon3d/reports` | юзер репортит игру | `{ game_id, reason, comment }` | + +### Скины и аватары (Рублокс-персонажи) + +| Method | Path | Когда | +|---|---|---| +| GET | `/kubikon3d/rublox/equipped-skin/` | при спавне аватара любого игрока | +| GET | `/kubikon3d/rublox/owned-skins` | страница «Мои скины» | +| POST | `/kubikon3d/rublox/equipped-skin` | юзер сменил скин | +| GET | `/api-storys/rublox/avatars/` | список аватаров юзера | +| GET | `/api-storys/rublox/outfit/` | детали одежды | +| GET | `/kubikon-assets/characters/skins_manifest.json` | при загрузке плеера, список всех доступных скинов | + +### Эмоушены (R15 анимации) + +| Method | Path | Когда | +|---|---|---| +| GET | `/api-storys/rublox/emotes/list` | при заходе в игру (для меню эмоушенов) | +| POST | `/api-storys/rublox/emotes/play/` | юзер выбрал эмоушен | + +### Модели и ассеты + +| Method | Path | Когда | +|---|---|---| +| GET | `/kubikon3d/models/public` | при загрузке игры — список public GLB-моделей | +| GET | `/kubikon3d/models/mine` | в редакторе (плеер запускает превью своих моделей) | +| GET | `/kubikon3d/models/` | при первом упоминании модели в проекте | + +### Админка (только видна юзерам с ролью admin) + +Все `/kubikon3d/admin/*` доступны только если в JWT юзер имеет роль `admin`. Иначе бэк возвращает 403. + +| Method | Path | Что показывает | +|---|---|---| +| GET | `/kubikon3d/admin/dashboard` | сводка: онлайн, активные игры | +| GET | `/kubikon3d/admin/online` | список онлайн-юзеров | +| GET | `/kubikon3d/admin/all-games` | все игры с фильтрами | +| GET | `/kubikon3d/admin/moderation-queue` | очередь премодерации (для review-игр) | +| GET | `/kubikon3d/admin/reports` | репорты на игры | +| GET | `/kubikon3d/admin/bug-reports` | баг-репорты | +| GET | `/kubikon3d/admin/comments` | модерация комментариев | +| GET | `/kubikon3d/admin/chat/messages` | чат-сообщения | +| GET | `/kubikon3d/admin/chat/bans` | список банов в чате | +| GET | `/kubikon3d/admin/authors` | топ авторов с детальной статистикой | + +### Multiplayer (Colyseus) + +WebSocket-соединение, не HTTP. Адрес: `wss://multiplayer.rublox.pro/`. Сейчас работает через `kubikon-realtime` микросервис (VM 110, port 8685, Node.js+Colyseus+Redis). + +## Аутентификация + +Все приватные эндпоинты ожидают **JWT в заголовке** `Authorization: Bearer `. JWT выдаёт `/api-user/auth/login`. Срок жизни access-токена — 1 час, refresh-токена — 30 дней. + +В плеере токен хранится в `localStorage.jwt`, рефрешится автоматически при 401 через `localStorage.refresh_token` → `/api-user/auth/refresh`. + +## Изменения API + +**До publish:** если меняешь сигнатуру эндпоинта (убираешь поле, переименовываешь, меняешь тип) — это **breaking change**. Объявление за 48 часов в канале `#разработка` на https://team.rublox.pro. + +Записывай в `API_CHANGELOG.md` админ-репо (приватный, `minecraftia-school.ru/...`) — это позволяет отследить какие изменения когда были. + +## Что делать если эндпоинт пропал + +1. Открой issue в репо плеера: «API endpoint /xxx not found». +2. Прикрепи console-лог с ошибкой. +3. Кто-то из core-команды посмотрит в `API_CHANGELOG.md` админ-репо и ответит — это было умышленное изменение или баг. + +## Контакты + +- Issue tracker: https://git.rublox.pro/rublox/player/issues +- Чат: `#разработка` на https://team.rublox.pro diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 5394fc9..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,31 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{js,jsx}'], - extends: [ - js.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - globals: globals.browser, - parserOptions: { ecmaFeatures: { jsx: true } }, - }, - rules: { - // Стилевые правила — не валим CI на осознанном код-стиле движка - // (пустые catch для тихого проглатывания ошибок, нестрогие var'ы). - 'no-empty': 'off', - 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], - 'no-constant-condition': ['warn', { checkLoops: false }], - 'no-fallthrough': 'warn', - 'no-useless-catch': 'warn', - 'react-refresh/only-export-components': 'off', - }, - }, -]) diff --git a/package.json b/package.json index 5f0e4c8..7b16067 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint . --max-warnings 200", + "lint": "eslint . --ext .js,.jsx --max-warnings 200", "format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"", "fetch-assets": "node scripts/fetch-assets.js", diff --git a/src/App.jsx b/src/App.jsx index 3c292fa..28433b3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -38,7 +38,8 @@ function PlayerRoute() { return ( ); } diff --git a/src/KubikonPlayer/GameMenu.jsx b/src/KubikonPlayer/GameMenu.jsx index c6cfe92..b6e1119 100644 --- a/src/KubikonPlayer/GameMenu.jsx +++ b/src/KubikonPlayer/GameMenu.jsx @@ -1217,6 +1217,17 @@ function TabReport({ gameId, gameTitle }) { setStatus({ text: 'Отправка...', error: false }); try { const token = getToken(); + // Бэкенд /kubikon3d/reports требует reporter_user_id и target_id и + // принимает поле text. Раньше TabReport слал {title, message, + // game_id, game_title} БЕЗ reporter_user_id → бэк отвечал + // 400 'reporter_user_id required' → жалоба падала. Приводим к + // формату бэкенда (как нижняя кнопка «Жалоба»). + const me = getMyProfile(); + if (!me || !me.id) { + setStatus({ text: 'Войдите, чтобы отправить жалобу.', error: true }); + setSending(false); + return; + } const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, { method: 'POST', headers: { @@ -1224,11 +1235,12 @@ function TabReport({ gameId, gameTitle }) { ...(token ? { Authorization: token } : {}), }, body: JSON.stringify({ + reporter_user_id: me.id, + target_type: 'project', + target_id: gameId || null, category, - title: title.trim(), - message: message.trim(), - game_id: gameId || null, - game_title: gameTitle || null, + text: title.trim() + '\n\n' + message.trim() + + (gameTitle ? `\n\n(игра: ${gameTitle})` : ''), }), }); if (res.ok) { @@ -1236,7 +1248,9 @@ function TabReport({ gameId, gameTitle }) { setTitle(''); setMessage(''); } else { - setStatus({ text: 'Не удалось отправить. Попробуйте позже.', error: true }); + let detail = ''; + try { const j = await res.json(); if (j && j.error) detail = ': ' + j.error; } catch (e) {} + setStatus({ text: 'Не удалось отправить' + detail + '.', error: true }); } } catch (e) { setStatus({ text: 'Сеть недоступна.', error: true }); diff --git a/src/KubikonPlayer/KubikonChatPanel.jsx b/src/KubikonPlayer/KubikonChatPanel.jsx index e35310b..96c2695 100644 --- a/src/KubikonPlayer/KubikonChatPanel.jsx +++ b/src/KubikonPlayer/KubikonChatPanel.jsx @@ -363,6 +363,10 @@ const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false, is_manual: data.is_manual, }); setError(data.message || formatMuteMessage(data)); + } else if (code === 'email_not_confirmed') { + // То же поведение что и в WS-пути: русская модалка «подтвердите + // email», а не сырой английский код ошибки. + setEmailNotice(true); } else if (code === 'too_frequent') { setError(data.message || 'Слишком быстро.'); } else if (code === 'login_required') { diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index c23dcf3..7ac0043 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -505,22 +505,26 @@ const KubikonPlayer = () => { }); scene.setOnPlayChange?.((playing) => { setIsPlaying(playing); - // ESC обрабатывается через pointerlockchange-перехват в плеере - // (см. отдельный useEffect ниже). Сюда мы попадаем только если - // exitPlayMode вызвался по другой причине — тогда просто открываем - // меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play - // в UI-cursor режиме. + // ВНИМАНИЕ: при обычном ESC сюда мы больше НЕ попадаем — ESC теперь + // открывает меню через setOnEscMenu (ниже), не выходя из Play. + // exitPlayMode(false) случается только по-настоящему (напр. движок + // сам остановил Play). В этом случае просто открываем меню, чтобы + // юзер мог выйти/перезапустить. НЕ пересоздаём Play автоматически — + // повторный enterPlayMode респавнил игрока и перезапускал скрипты + // («перезапуск плейса» при ESC). Перезапуск делается явной кнопкой. if (!playing) { - setTimeout(() => { - const s = sceneRef.current; - if (!s) return; - s.enterPlayMode?.(); - s.player?.setUiCursorMode?.(true); - }, 30); + const s = sceneRef.current; + s?.player?.setUiCursorMode?.(true); setChatOpen(false); setTopMenuOpen(true); } }); + // ESC в Play → меню-оверлей поверх ЖИВОЙ игры (Roblox-style). Play не + // прерывается, скрипты продолжают идти, игрок не респавнится. + scene.setOnEscMenu?.(() => { + setChatOpen(false); + setTopMenuOpen(true); + }); // Загружаем проект. // STANDALONE-режим (VITE_STANDALONE=true) подсовывает встроенный @@ -711,15 +715,25 @@ const KubikonPlayer = () => { const s = sceneRef.current; if (!s || !s._isPlaying) return; const locked = !!document.pointerLockElement; - // Lock потерян, мы НЕ в UI-cursor mode → пользователь нажал ESC - if (!locked && s.player && !s.player._uiCursorMode) { - // Синхронно ставим флаг — listener PlayerController сработает - // следующим и увидит true, не вызовет _onExitRequest. - s.player._uiCursorMode = true; - // Открываем меню в следующий тик (state-update React) - setChatOpen(false); - setTopMenuOpen(true); - } + if (locked || !s.player || s.player._uiCursorMode) return; + // Lock потерян. НЕ всякая потеря = ESC! В third-person отпускание + // ПКМ (orbit-камера) тоже снимает lock — это НЕ выход в меню. + // Меню открываем ТОЛЬКО если lock был «постоянным» (perma-режим: + // first/lockfirst/sideview/shiftLock) — там потеря lock = реальный ESC. + const p = s.player; + const permaLock = ( + p._cameraMode === 'first' || + p._cameraMode === 'lockfirst' || + p._cameraMode === 'sideview' || + p._shiftLock + ); + // _rmbHeld был выставлен при входе в lock; если ПКМ отпущена в third — + // это orbit-завершение, не меню. + if (!permaLock) return; + // Реальный ESC в perma-режиме → открываем меню. + p._uiCursorMode = true; + setChatOpen(false); + setTopMenuOpen(true); }; // capture-фаза, чтобы успеть раньше PlayerController document.addEventListener('pointerlockchange', onLockChange, true); diff --git a/src/LoadingScreen.jsx b/src/LoadingScreen.jsx index 0cde05b..73ce609 100644 --- a/src/LoadingScreen.jsx +++ b/src/LoadingScreen.jsx @@ -9,11 +9,62 @@ // // CSS-анимации, без JS-фрейма каждый кадр. -import React from 'react'; +import React, { useState } from 'react'; const TITLE = 'Рублокс'; -export default function LoadingScreen({ text = 'Подключение', subText = null }) { +/** + * Dev-only панель вставки JWT. Показывается на экране «Нужен JWT» (только + * localhost). Кнопка → инпут → сохраняет в localStorage['player_jwt'] и + * перезагружает страницу. На проде этот экран не наступает (там redirect). + */ +function DevJwtPanel() { + const [open, setOpen] = useState(false); + const [val, setVal] = useState(''); + + const apply = () => { + const t = (val || '').trim(); + if (!t) return; + try { + localStorage.setItem('player_jwt', t); + // совместимость с другими местами чтения токена + localStorage.setItem('Authorization', t); + } catch (e) { /* ignore */ } + window.location.reload(); + }; + + if (!open) { + return ( + + ); + } + return ( +
+