/** * API-сервис для Рублокс 3D (отдельный от 2D-Кубоши). * Бэкенд: storys-микросервис, префикс /kubikon3d/... */ import axios from 'axios'; import { STORYS_addres } from './API'; const api = axios.create({ baseURL: STORYS_addres, timeout: 30000, // Поднимаем лимит размера body — без этого axios отказывается отправлять // payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ // для 250м карты, но запас не помешает. maxContentLength: 100 * 1024 * 1024, // 100 МБ maxBodyLength: 100 * 1024 * 1024, }); // Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть // user-микросервис и узнать имя пользователя (resolve_my_username). // Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя. // // В плеере JWT хранится в localStorage['player_jwt'] (а не 'Authorization' // как в Майнкрафтии), потому что плеер живёт на отдельном поддомене // player.rublox.pro и его localStorage изолирован. Ключ перенумерован // в Этапе 2 портирования плеера. api.interceptors.request.use((config) => { try { const token = localStorage.getItem('player_jwt'); if (token) { config.headers = config.headers || {}; config.headers.Authorization = token; } } catch (e) { /* ignore */ } return config; }); // ============ ПРОЕКТЫ ============ // Save-операции с увеличенным таймаутом (120с) — для больших карт. // Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м // карте, потому что upload 5+МБ JSON по медленной сети занимает больше. const SAVE_TIMEOUT = 120000; export const createProject = (userId, data) => api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT }); /** * Загрузить проект по id. * * Бэкенд проверяет права доступа по правилам: * - published — открыто всем (можно вызвать без userId) * - draft / review / blocked — только автору и админу * * Поэтому если открываем чужой/свой черновик в редакторе — обязательно * передаём userId, иначе бэк отдаст 403. */ export const getProject = (id, userId = null) => { const params = {}; if (userId != null) params.user_id = userId; return api.get(`/kubikon3d/projects/${id}`, { params }); }; /** * Загрузить проект с retry — на случай зависшего/медленного запроса. * * ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный * пул соединений, сетевой лаг) — и страница "Загрузка проекта… 0%" * замирает на 30с (таймаут axios) или до 60с (safety-timer редактора). * Приходилось перезагружать вручную по 5 раз. * * РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая * попытка отменяется и повторяется сама, без ручной перезагрузки. * Сетевые/таймаут-ошибки → retry; 4xx (403/404) → сразу пробрасываем * (повтор не поможет). * * @param {number} id — id проекта * @param {number|null} userId * @param {number} attempts — сколько попыток (по умолчанию 3) * @param {number} perTryTimeout — таймаут одной попытки в мс (по умолчанию 12000) */ export const getProjectWithRetry = async ( id, userId = null, attempts = 3, perTryTimeout = 12000, ) => { const params = {}; if (userId != null) params.user_id = userId; let lastErr = null; for (let i = 0; i < attempts; i++) { try { return await api.get(`/kubikon3d/projects/${id}`, { params, timeout: perTryTimeout, }); } catch (err) { lastErr = err; const status = err.response?.status; // 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу. if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) { throw err; } // Сеть/таймаут/5xx — пробуем ещё раз. console.warn( `[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed` + ` (${err.code || status || 'network'}), retrying...`, ); } } throw lastErr; }; export const updateProject = (id, data) => api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT }); export const deleteProject = (id, userId) => api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } }); export const getMyProjects = (userId) => api.get('/kubikon3d/my-projects', { params: { user_id: userId } }); /** * Лента игр Рублокса (умная лента — RUBLOX_SMART_FEED_PLAN.md). * * Второй аргумент — вкладка ленты: * recommended — ранжирование по hot_score (умная лента); * new — самые свежие; * popular — по числу запусков; * top_week — топ за неделю. * Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя) — * шлём под обоими именами, чтобы не зависеть от версии бэкенда. */ export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) => api.get('/kubikon3d/feed', { params: { page, tab, sort: tab, ...(maxAge != null ? { max_age: maxAge } : {}), ...(minRating != null ? { min_rating: minRating } : {}), ...(opts.rank ? { rank: opts.rank } : {}), ...(opts.multiplayer != null ? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}), ...(opts.genre ? { genre: opts.genre } : {}), ...(opts.per_page ? { per_page: opts.per_page } : {}), }, }); export const searchProjects = (q, maxAge = null) => api.get('/kubikon3d/search', { params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) }, }); // ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============ /** * Опубликовать проект (умная лента — RUBLOX_SMART_FEED_PLAN.md). * Премодерации нет: чистая игра сразу в ленте, подозрительная → review. * payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? } * Ответ: { project, review: bool, too_empty: bool } */ export const publishProject = (id, payload) => api.post(`/kubikon3d/projects/${id}/publish`, payload); export const unpublishProject = (id, userId) => api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId }); /** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */ export const getModerationQueue = () => api.get('/kubikon3d/admin/moderation-queue'); /** * Решение админа по игре из очереди проверки. * payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? } */ export const moderateProject = (id, payload) => api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload); /** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */ export const blockProject = (id, payload) => api.post(`/kubikon3d/admin/projects/${id}/block`, payload); /** Разблокировать игру → published. payload: { admin_user_id } */ export const unblockProject = (id, payload) => api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload); /** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */ export const restoreFeed = (id, payload) => api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload); export const getModerationHistory = (id) => api.get(`/kubikon3d/projects/${id}/moderation-history`); // ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============ /** Загрузить проект для плеера. Передаём user_id для проверки доступа. */ export const getProjectForPlay = (id, userId = null, isAdmin = false) => api.get(`/kubikon3d/projects/${id}`, { params: { ...(userId ? { user_id: userId } : {}), ...(isAdmin ? { is_admin: 'true' } : {}), }, }); export const incrementPlay = (id) => api.post(`/kubikon3d/projects/${id}/play`); /** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает, * голос другого типа — переключает. */ export const toggleLike = (id, userId, kind = 'like') => api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind }); export const getLikeStatus = (id, userId) => api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } }); /** payload: { reporter_user_id, target_type, target_id, category, text } */ export const createReport = (payload) => api.post('/kubikon3d/reports', payload); /** Публичные игры автора. */ export const getUserGames = (userId, maxAge = null) => api.get(`/kubikon3d/users/${userId}/games`, { params: maxAge != null ? { max_age: maxAge } : {}, }); // ============ ЛИДЕРБОРД (рекорды прохождения игр) ============ /** Топ-N рекордов прохождения проекта. По умолчанию 5. */ export const getLeaderboard = (projectId, limit = 5) => api.get(`/kubikon3d/projects/${projectId}/leaderboard`, { params: { limit }, }); /** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */ export const submitLeaderboard = (projectId, userId, timeMs) => api.post(`/kubikon3d/projects/${projectId}/leaderboard`, { user_id: userId, time_ms: timeMs, }); // ============ ИЗБРАННОЕ ============ export const toggleFavorite = (projectId, userId) => api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId }); export const getFavoriteStatus = (projectId, userId) => api.get(`/kubikon3d/projects/${projectId}/favorite-status`, { params: { user_id: userId } }); export const getMyFavorites = (userId) => api.get(`/kubikon3d/users/${userId}/favorites`); // ============ ИСТОРИЯ ============ export const getPlayHistory = (userId, limit = 8) => api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } }); // ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============ export const getTrending = (limit = 8) => api.get('/kubikon3d/trending', { params: { limit } }); export const getTopAuthors = (limit = 10) => api.get('/kubikon3d/top-authors', { params: { limit } }); export const getActivity = (limit = 10) => api.get('/kubikon3d/activity', { params: { limit } }); export const getCollections = () => api.get('/kubikon3d/collections'); export const getEvents = () => api.get('/kubikon3d/events'); // ============ PERF LOGS ============ export const submitPerfLog = (sample) => api.post('/kubikon3d/perf-log', sample); // ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============ /** * Создать баг-репорт. Использует multipart/form-data, потому что может нести файл. * fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File } */ export const createBugReport = (fields) => { const fd = new FormData(); Object.entries(fields).forEach(([k, v]) => { if (v == null || v === '') return; fd.append(k, v); }); return api.post('/kubikon3d/bug-reports', fd, { headers: { 'Content-Type': 'multipart/form-data' }, }); }; export const getAdminBugReports = (status = 'open', limit = 100) => api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } }); export const updateAdminBugReport = (id, payload) => api.post(`/kubikon3d/admin/bug-reports/${id}`, payload); // ============ HEARTBEAT / ОНЛАЙН ============ export const playHeartbeat = (sessionId, projectId, userId = null) => api.post('/kubikon3d/play/heartbeat', { session_id: sessionId, project_id: projectId, user_id: userId, }); export const getOnline = () => api.get('/kubikon3d/admin/online'); // ============ DASHBOARD / СТАТИСТИКА ============ export const getDashboard = () => api.get('/kubikon3d/admin/dashboard'); export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) => api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } }); export const getAdminAuthors = (limit = 100) => api.get('/kubikon3d/admin/authors', { params: { limit } }); // ============ ЖАЛОБЫ (АДМИНКА) ============ export const getAdminReports = (status = 'open', limit = 200) => api.get('/kubikon3d/admin/reports', { params: { status, limit } }); export const resolveAdminReport = (id, status, adminUserId) => api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId }); // ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============ /** Публичный — узнать активный бан публикаций пользователя. */ export const getPublishBanStatus = (userId) => api.get(`/kubikon3d/users/${userId}/publish-ban-status`); export const getPublishBanHistory = (userId) => api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`); // ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============ export const getProjectComments = (projectId) => api.get(`/kubikon3d/projects/${projectId}/comments`); /** payload: { user_id, username, text } */ export const createProjectComment = (projectId, payload) => api.post(`/kubikon3d/projects/${projectId}/comments`, payload); export const deleteProjectComment = (commentId, userId) => api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } }); export const editProjectComment = (commentId, userId, text) => api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text }); // Админ export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) => api.get('/kubikon3d/admin/comments', { params: { filter, ...(projectId ? { project: projectId } : {}), limit }, }); // ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============ /** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */ export const getChat = (projectId, since = null, limit = 50) => api.get(`/kubikon3d/projects/${projectId}/chat`, { params: { ...(since ? { since } : {}), limit }, }); /** payload: { user_id, username, text } */ export const postChatMessage = (projectId, payload) => api.post(`/kubikon3d/projects/${projectId}/chat`, payload); /** Узнать активный мьют чата для пользователя. */ export const getChatMuteStatus = (userId) => api.get(`/kubikon3d/users/${userId}/chat-mute-status`); // Админ export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) => api.get('/kubikon3d/admin/chat/messages', { params: { filter, ...(projectId ? { project: projectId } : {}), limit }, }); export const getAdminChatBans = (filter = 'active', limit = 200) => api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } }); // ============================================================================ // Пользовательские модели (Этап 1+ редактора моделей) // ============================================================================ // Эндпоинты для воксельных и гладких моделей, созданных пользователями. // Отображаются в Toolbox в разделах "Мои модели" и "Сообщество". // См. KUBIKON_MODEL_EDITOR_PLAN.md. /** Создать модель. * payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON, * description?, thumbnail_b64? } * Возвращает serialize_full (с model_data). */ export const createUserModel = (userId, payload) => api.post('/kubikon3d/models', { user_id: userId, ...payload }, { timeout: SAVE_TIMEOUT }); /** Загрузить модель по id. userId — для проверки доступа к приватной. */ export const getUserModel = (id, userId = null) => { const params = {}; if (userId != null) params.user_id = userId; return api.get(`/kubikon3d/models/${id}`, { params }); }; /** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */ export const updateUserModel = (id, userId, payload) => api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload }, { timeout: SAVE_TIMEOUT }); export const deleteUserModel = (id, userId) => api.delete(`/kubikon3d/models/${id}`, { data: { user_id: userId }, params: { user_id: userId }, }); /** Мои модели (для раздела "Мои" в Toolbox). */ export const getMyUserModels = (userId, opts = {}) => api.get('/kubikon3d/models/mine', { params: { user_id: userId, ...(opts.kind ? { kind: opts.kind } : {}), ...(opts.limit ? { limit: opts.limit } : {}), ...(opts.offset ? { offset: opts.offset } : {}), }, }); /** Публичные модели (для раздела "Сообщество" в Toolbox). * opts: { q, kind, limit, offset, userId } * userId — чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */ export const getPublicUserModels = (opts = {}) => api.get('/kubikon3d/models/public', { params: { ...(opts.q ? { q: opts.q } : {}), ...(opts.kind ? { kind: opts.kind } : {}), ...(opts.limit ? { limit: opts.limit } : {}), ...(opts.offset ? { offset: opts.offset } : {}), ...(opts.userId != null ? { user_id: opts.userId } : {}), }, }); export const publishUserModel = (id, userId) => api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId }); export const unpublishUserModel = (id, userId) => api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId }); /** Инкремент uses_count — вызывать когда модель ставят в проект. */ export const incrementModelUses = (id) => api.post(`/kubikon3d/models/${id}/use`); /** Поставить/снять лайк пользовательской модели (toggle). * Возвращает { liked, likes_count }. */ export const likeUserModel = (id, userId) => api.post(`/kubikon3d/models/${id}/like`, { user_id: userId }); // ============ СКИНЫ ИГРОКА (R15) ============ /** Список купленных скинов игрока. Возвращает { skins: [...], count }. */ export const getOwnedSkins = (userId) => api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } }); /** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }. * Если записи нет — бэк отдаёт дефолт skin_bacon-hair. */ export const getEquippedSkin = (userId) => api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } }); /** Установить выбранный скин игрока. Бэк проверяет владение скином. * Возвращает { ok, skin_folder } или ошибку. */ export const setEquippedSkin = (userId, skinFolder) => api.post('/kubikon3d/rublox/equipped-skin', { user_id: userId, skin_folder: skinFolder, }); /** Дизайнерский эндпоинт — получить один скин по id (видит и draft/testing). * Используется в preview-режиме `/_preview-skin/:itemId`. * Требует JWT с ролью designer или owner. Возвращает item.serialize. */ export const getDesignerSkin = (itemId) => api.get(`/designer/skins/${itemId}`); /** Текущий outfit игрока (Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md). * Возвращает { slots: {hat_id, tool_id, ...}, items: {: serialize, ...} }. * В items уже есть attachment-поля — плеер сразу делает equipAccessory. */ export const getRubloxOutfit = (userId) => api.get('/rublox/outfit', { params: { user_id: userId } }); /** Дизайнерская модель (Фаза 5 RUBLOX_DESIGNER_PLAN.md). * Видит draft/testing — требует JWT с ролью designer/owner. * Используется в preview-режиме /_preview-model/:id. */ export const getDesignerModel = (modelId) => api.get(`/designer/models/${modelId}`); /** Дизайнерский аватар (2026-05-27). Видит draft/testing. * Используется в preview-режиме /_preview-avatar/:id. */ export const getDesignerAvatar = (avatarId) => api.get(`/designer/avatars/${avatarId}`);