All checks were successful
Сегодня доведены до играбельного состояния:
- UI модалка импорта подключена в KubikonStudio (кнопка для МИНа в навигации)
- Converter: SCALE 0.35 (карта пропорциональна R15-персонажу),
playerModelType='skin_bacon-hair', Lua упакован в поле code с маркером
// @roblox-lua (storys API сохраняет только {id,code,target,name})
- vite.config: api+статика через rublox.pro/minecraftia-school.ru
- GameRuntime: распознаёт маркер, запускает через RobloxLuaSandbox
+ wasmoon Worker. Фильтрация: target!=null + lua<2500б +
лимит 50 sandbox'ов (WASM OOM при >50 VM)
- roblox-shim: nullStub (Proxy с no-op методами) вместо null
для FindFirstChild/WaitForChild — цепочки не падают
- require() заменён на nullStub
- RobloxLuaSandbox: совместимость с интерфейсом ScriptSandbox
(sendGlobalEvent/SceneSnapshot/etc — no-op заглушки)
- RobloxLuaWorker: pcall обёртка над user-кодом
- remoteDevlog.js + /devlog endpoint: автосбор browser-логов
- PlayerController._loadSkinManifest: dev-fallback на studio.rublox.pro
Тест на Easy Obby:
- 8205 instances → 2245 primitives + 742 Lua-scripts
- 50/742 Lua-VM запущены (KillBrick handlers и т.п.),
151 отфильтровано как admin/chat services, 541 пропущено по памяти
- Скин bacon-hair виден, FPS 20-25
- Сцена играется, можно ходить, прыгать
TODO (следующая итерация):
- Single-VM mode для wasmoon (один Lua-state на 742 скрипта,
убрать WASM OOM)
- Реализовать select/focus в иерархии для импортированных карт
- Touched events от Babylon impostor → Lua-shim сигналы
- Поддержка GUI (ScreenGui/Frame/TextLabel) в конвертере
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
169 lines
5.8 KiB
JavaScript
169 lines
5.8 KiB
JavaScript
/**
|
||
* remoteDevlog.js — клиент удалённого dev-логгера.
|
||
*
|
||
* Перехватывает: console.error/warn, window.onerror, unhandledrejection,
|
||
* все fetch/XHR ошибки и не-2xx ответы; батчит и шлёт на бэкенд.
|
||
*
|
||
* Запускается только в localhost (dev), на проде no-op.
|
||
*/
|
||
|
||
const IS_DEV = typeof window !== 'undefined'
|
||
&& (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
||
|
||
const ENDPOINT = '/api-rbxl/devlog';
|
||
const FLUSH_INTERVAL_MS = 1500;
|
||
const MAX_BATCH = 50;
|
||
|
||
const queue = [];
|
||
let flushTimer = null;
|
||
|
||
function push(ev) {
|
||
if (!IS_DEV) return;
|
||
ev.ts = Date.now();
|
||
ev.page = location.pathname + location.search;
|
||
queue.push(ev);
|
||
if (queue.length >= MAX_BATCH) flush();
|
||
else scheduleFlush();
|
||
}
|
||
|
||
function scheduleFlush() {
|
||
if (flushTimer) return;
|
||
flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
|
||
}
|
||
|
||
function flush() {
|
||
flushTimer = null;
|
||
if (queue.length === 0) return;
|
||
const events = queue.splice(0, MAX_BATCH);
|
||
try {
|
||
const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });
|
||
// sendBeacon — не блокирует, переживёт unload
|
||
if (navigator.sendBeacon) {
|
||
navigator.sendBeacon(ENDPOINT, blob);
|
||
} else {
|
||
fetch(ENDPOINT, { method: 'POST', body: blob, headers: { 'Content-Type': 'application/json' }, keepalive: true })
|
||
.catch(() => {});
|
||
}
|
||
} catch (e) { /* swallow */ }
|
||
}
|
||
|
||
function truncate(s, max = 4000) {
|
||
if (typeof s !== 'string') {
|
||
try { s = JSON.stringify(s); } catch { s = String(s); }
|
||
}
|
||
return s.length > max ? s.slice(0, max) + '...[truncated]' : s;
|
||
}
|
||
|
||
export function installRemoteDevlog() {
|
||
if (!IS_DEV) return;
|
||
|
||
// 1. console.error / console.warn (но НЕ console.log — слишком шумно)
|
||
const origError = console.error.bind(console);
|
||
const origWarn = console.warn.bind(console);
|
||
console.error = (...args) => {
|
||
try { push({ kind: 'console.error', message: truncate(args.map(formatArg).join(' ')) }); } catch {}
|
||
origError(...args);
|
||
};
|
||
console.warn = (...args) => {
|
||
try { push({ kind: 'console.warn', message: truncate(args.map(formatArg).join(' ')) }); } catch {}
|
||
origWarn(...args);
|
||
};
|
||
|
||
// 2. window.onerror
|
||
window.addEventListener('error', (ev) => {
|
||
push({
|
||
kind: 'window.error',
|
||
message: ev.message,
|
||
filename: ev.filename,
|
||
lineno: ev.lineno,
|
||
colno: ev.colno,
|
||
stack: ev.error?.stack ? truncate(ev.error.stack) : null,
|
||
});
|
||
});
|
||
|
||
// 3. Unhandled promise rejection
|
||
window.addEventListener('unhandledrejection', (ev) => {
|
||
const reason = ev.reason;
|
||
push({
|
||
kind: 'unhandledrejection',
|
||
message: truncate(reason?.message || String(reason)),
|
||
stack: reason?.stack ? truncate(reason.stack) : null,
|
||
});
|
||
});
|
||
|
||
// 4. fetch wrapper — логируем все не-2xx и failed
|
||
const origFetch = window.fetch.bind(window);
|
||
window.fetch = async (input, init) => {
|
||
const url = typeof input === 'string' ? input : input?.url || '';
|
||
const method = (init?.method || 'GET').toUpperCase();
|
||
const t0 = performance.now();
|
||
try {
|
||
const resp = await origFetch(input, init);
|
||
if (!resp.ok) {
|
||
let body = '';
|
||
try {
|
||
const cloned = resp.clone();
|
||
body = truncate(await cloned.text(), 2000);
|
||
} catch {}
|
||
push({
|
||
kind: 'fetch.bad',
|
||
url,
|
||
method,
|
||
status: resp.status,
|
||
duration_ms: Math.round(performance.now() - t0),
|
||
body,
|
||
});
|
||
}
|
||
return resp;
|
||
} catch (e) {
|
||
push({
|
||
kind: 'fetch.fail',
|
||
url,
|
||
method,
|
||
duration_ms: Math.round(performance.now() - t0),
|
||
message: e?.message || String(e),
|
||
});
|
||
throw e;
|
||
}
|
||
};
|
||
|
||
// 5. XHR wrapper — для axios и т.п.
|
||
const XhrOpen = XMLHttpRequest.prototype.open;
|
||
const XhrSend = XMLHttpRequest.prototype.send;
|
||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||
this.__rdl = { method, url, t0: performance.now() };
|
||
return XhrOpen.call(this, method, url, ...rest);
|
||
};
|
||
XMLHttpRequest.prototype.send = function (body) {
|
||
this.addEventListener('loadend', () => {
|
||
const meta = this.__rdl || {};
|
||
const status = this.status;
|
||
if (status === 0 || status >= 400) {
|
||
push({
|
||
kind: 'xhr.bad',
|
||
url: meta.url,
|
||
method: meta.method,
|
||
status,
|
||
duration_ms: Math.round(performance.now() - (meta.t0 || performance.now())),
|
||
body: truncate(this.responseText || '', 2000),
|
||
});
|
||
}
|
||
});
|
||
return XhrSend.call(this, body);
|
||
};
|
||
|
||
// Периодически флушим (для долгоживущих логов)
|
||
setInterval(() => { if (queue.length) flush(); }, 5000);
|
||
window.addEventListener('beforeunload', flush);
|
||
|
||
// Стартовая отметка чтобы в логе было видно начало сессии
|
||
push({ kind: 'session.start', ua: navigator.userAgent });
|
||
}
|
||
|
||
function formatArg(a) {
|
||
if (a == null) return String(a);
|
||
if (typeof a === 'string') return a;
|
||
if (a instanceof Error) return a.message + (a.stack ? '\n' + a.stack : '');
|
||
try { return JSON.stringify(a); } catch { return String(a); }
|
||
}
|