Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
488 lines
22 KiB
JavaScript
488 lines
22 KiB
JavaScript
/**
|
||
* 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: {<id>: 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}`);
|