/**
 * GameRuntime — управляет всеми пользовательскими скриптами в режиме Play.
 *
 * Жизненный цикл:
 *   const rt = new GameRuntime(scene3d);
 *   rt.setOnLog(({level,text}) => console.log(text));
 *   rt.start(scripts);   // scripts — массив { id, code }
 *   ... каждый кадр rt.tick(dt) ...
 *   rt.stop();           // выгрузить всех Worker'ов
 *
 * Каждый скрипт = отдельный Worker. Команды от Worker'ов обрабатываются здесь
 * и применяются к BabylonScene (через player.teleport и т.п.).
 *
 * Этап 2.1: минимальный API — player.teleport, onTick, log.
 */

import { Color3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../api/API';
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
import { LabelManager } from './LabelManager';   // задача: scene.setLabel (require крашит в браузере)

export class GameRuntime {
    constructor(scene3d) {
        this.scene3d = scene3d;
        /** @type {ScriptSandbox[]} */
        this.sandboxes = [];
        this._onLog = null;
        this._isRunning = false;
        // Активные твины (game.tween). Крутятся в tick(dt).
        // Каждый: { tweenId, scriptId, ref, props, from, duration, easing,
        //           delay, repeat, yoyo, elapsed, delayLeft, dir, loopsLeft }
        this._tweens = [];
        // Атрибуты объектов (game.scene.setData/getData). { ref: { key: value } }.
        // Общие для всех скриптов, рассылаются воркерам через dataSnapshot.
        this._objectData = {};
        // Интерактивные объекты (game.self.onInteract / ProximityPrompt).
        // Каждый: { target, text, distance, key }. Заполняется при
        // self.registerInteract, проверяется по дистанции в tick.
        this._interactables = [];
        // ref ближайшего интерактивного объекта в зоне (для подсветки [E]).
        this._activeInteractRef = null;
        // Общее состояние комнаты для game.room.set/get (Фаза 4.3).
        // В редакторе (single-player) — локальное хранилище. С Colyseus-
        // комнатой будет синхронизироваться (требует серверной схемы).
        this._roomState = {};
        // Сессии игроков, которых видели в прошлом tick — для детекта
        // join/leave (game.onPlayerJoin / onPlayerLeave).
        this._seenSessions = null;
        // Команды (Фаза 4.4): name → { name, color }.
        this._teams = new Map();
        // Команда локального игрока (имя) или null.
        this._localPlayerTeam = null;
    }

    setOnLog(cb) { this._onLog = cb; }

    /** Колбэк HUD-команд от скриптов: { cmd, payload }. */
    setOnHud(cb) { this._onHud = cb; }

    /** Колбэк смены прицела через скрипт: (type) — UI обновляет overlay. */
    setOnCrosshairChange(cb) { this._onCrosshair = cb; }

    /**
     * Запустить все скрипты.
     * @param {Array<{id:any, code:string}>} scripts
     */
    start(scripts) {
        this.stop();
        this._isRunning = true;
        // eslint-disable-next-line no-console
        console.log('[GameRuntime] start called with scripts:', scripts);
        // Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы.
        try {
            const ls = this.scene3d?.leaderstats;
            if (ls && !ls._bridgeBound) {
                ls._bridgeBound = true;
                const meId = ls._resolveMe?.();
                ls.onChange((pid, name, nv, ov) => {
                    for (const sb of this.sandboxes) sb.sendGlobalEvent({
                        type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
                        isMe: String(pid) === String(meId),
                    });
                });
            }
        } catch (e) { /* ignore */ }
        if (!Array.isArray(scripts) || scripts.length === 0) {
            // eslint-disable-next-line no-console
            console.warn('[GameRuntime] start: no scripts to run');
            return;
        }
        // Карта модулей для game.require — { имя_скрипта: код }.
        // Любой скрипт проекта можно подключить как модуль по его имени.
        const modules = {};
        for (const s of scripts) {
            if (s && typeof s.name === 'string' && s.name && typeof s.code === 'string') {
                modules[s.name] = s.code;
            }
        }
        // Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и
        // передаём прямо в init. Иначе findOne() в синхронном теле скрипта
        // (на старте) возвращает null → подписки obj.onTouch/find не работают.
        let initialScene = null;
        try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
        // Фаза 2 синхронизации со студией: и user-Lua (language='lua'), и
        // импортированные .rbxl-скрипты (с маркером // @roblox-lua) теперь
        // идут через ОДИН LuaSharedSandbox в main thread (wasmoon один раз).
        // Снимает WASM OOM лимит и устраняет race с worker'ом.
        const luaUserBatch = [];
        const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true);
        let rbxlSkipped = 0;
        for (const s of scripts) {
            // Roblox-Lua скрипты импортированные через rbxl-importer.
            if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
                if (!runImportedRbxl) { rbxlSkipped++; continue; }
                const meta = parseRobloxLuaMeta(s.code);
                if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
                const sname = String(s.name || '').toLowerCase();
                if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
                    rbxlSkipped++; continue;
                }
                const luaSource = unpackRobloxLuaCode(s.code);
                if (luaSource && (
                    /while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
                    /ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
                    /:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
                )) {
                    rbxlSkipped++;
                    // eslint-disable-next-line no-console
                    console.warn(`[GameRuntime] skipped ${s.name}: tight-loop (WaitForChild/ChildAdded:wait)`);
                    continue;
                }
                if (luaSource && luaSource.trim()) {
                    let toolName = null;
                    if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) {
                        toolName = 'Tool';
                    }
                    luaUserBatch.push({
                        id: s.id,
                        name: s.name,
                        target: s.target,
                        toolName,
                        language: 'lua',
                        code: luaSource,
                        _rbxlImported: true,
                    });
                }
                continue;
            }
            if (s && s.language === 'lua') {
                if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
                continue;
            }
            if (!s || typeof s.code !== 'string' || !s.code.trim()) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] skipping invalid script entry', s);
                continue;
            }
            const sb = new ScriptSandbox(s.code, s.target || null);
            sb.scriptId = s.id;
            sb.setModules(modules);
            if (initialScene) sb.setInitialScene(initialScene);
            // Если target есть — передаём начальную позицию self до старта
            if (s.target) {
                const pos = this._collectSelfPosition(s.target);
                if (pos) sb.setInitialSelfPosition(pos);
            }
            sb.setOnCommand((cmd, payload) => {
                // PERF-METRICS: замер скриптов (postMessage→handle)
                const _t0 = performance.now();
                this._handleCommand(s.id, cmd, payload);
                const m = this.scene3d?._perfMetrics;
                if (m) {
                    m.script_ms_sum += performance.now() - _t0;
                    m.script_count++;
                }
            });
            sb.start();
            this.sandboxes.push(sb);
            // eslint-disable-next-line no-console
            console.log('[GameRuntime] sandbox started for script id=', s.id);
        }
        // === Фаза 2: единый LuaSharedSandbox для user-Lua + импортированных .rbxl ===
        let luaUserCount = 0;
        if (luaUserBatch.length > 0) {
            try {
                const sb = new LuaSharedSandbox();
                sb.setOnCommand(({ cmd, payload }) => {
                    if (cmd === 'partSet' || cmd === 'partVel' ||
                        cmd === 'sceneCreate' || cmd === 'sceneDelete') {
                        try {
                            handleLuaCommand(null, cmd, payload, this);
                        } catch (e) {
                            // eslint-disable-next-line no-console
                            console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e);
                        }
                    } else if (cmd === 'toolRegistered') {
                        try { this._registerRbxlTool?.(payload); } catch (e) {
                            // eslint-disable-next-line no-console
                            console.warn('[GameRuntime] toolRegistered failed', e);
                        }
                    } else if (cmd === 'lightingTimeUpdate') {
                        try {
                            const baseHour = Number(payload?.hour);
                            if (baseHour >= 0 && baseHour < 24) {
                                if (this._lightBaseHour == null) {
                                    this._lightBaseHour = baseHour;
                                    this._lightStartReal = performance.now();
                                }
                                const dGame = baseHour - this._lightBaseHour;
                                const accel = 8;
                                const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
                                this.scene3d?.setTimeOfDay?.(hour);
                                let targetPreset;
                                if (hour >= 6 && hour < 8) targetPreset = 'sunset';
                                else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
                                else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
                                else targetPreset = 'starry-night';
                                if (this._lightPreset !== targetPreset) {
                                    this._lightPreset = targetPreset;
                                    try {
                                        const sky = this.scene3d?.skybox;
                                        if (sky?.fadeTo) sky.fadeTo({ preset: targetPreset }, 2);
                                        else this.scene3d?.setSkybox?.({ preset: targetPreset });
                                    } catch (_) {}
                                }
                            }
                        } catch (_) {}
                    } else if (cmd === 'particleCreated') {
                        this._rbxlPendingParticles = this._rbxlPendingParticles || [];
                        this._rbxlPendingParticles.push(payload);
                    } else if (cmd === 'mouseIconChanged') {
                        try {
                            const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
                            if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
                        } catch (_) {}
                    } else if (cmd === 'hudMessage') {
                        try {
                            this._ensureRbxlHud();
                            if (payload.visible && payload.text) {
                                this._rbxlHud.showMessage(payload.text);
                            } else {
                                this._rbxlHud.hideMessage();
                            }
                        } catch (_) {}
                    } else if (cmd === 'killFeed') {
                        try {
                            this._ensureRbxlHud();
                            this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
                        } catch (_) {}
                    } else if (cmd === 'winShow') {
                        try {
                            this._ensureRbxlHud();
                            this._rbxlHud.showWin(payload.text || 'WIN!');
                        } catch (_) {}
                    } else if (cmd === 'ui.showText') {
                        try {
                            this._ensureRbxlHud();
                            this._rbxlHud.showMessage(payload.text || '');
                            const dur = Number(payload.duration) || 2;
                            const t = payload.text || '';
                            setTimeout(() => {
                                try {
                                    if (this._rbxlHud._lastMessage === t) {
                                        this._rbxlHud.hideMessage();
                                    }
                                } catch (_) {}
                            }, dur * 1000);
                            try { this._rbxlHud._lastMessage = t; } catch (_) {}
                        } catch (_) {}
                    } else if (cmd === 'leaderstatSet') {
                        try {
                            const lm = this.scene3d?.leaderstats;
                            if (lm) {
                                const statName = String(payload.statName || 'Stat');
                                if (!lm._defs.some(d => d.name === statName)) {
                                    lm.define(statName, { initial: 0 });
                                }
                                lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
                            }
                        } catch (_) {}
                    } else {
                        this._handleCommand(null, cmd, payload);
                    }
                });
                try {
                    const snap = this._buildSceneSnapshot();
                    sb.sendSceneSnapshot(snap);
                } catch (_) {}
                for (const s of luaUserBatch) {
                    sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName });
                }
                sb.start();
                this.sandboxes.push(sb);
                this._luaUserSandbox = sb;
                luaUserCount = luaUserBatch.length;
            } catch (e) {
                // eslint-disable-next-line no-console
                console.error('[GameRuntime] Lua user runtime failed to init', e);
                this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
            }
        }
        if (rbxlSkipped > 0) {
            this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}`);
        }
        if (luaUserCount > 0) {
            this._log('info', `Запущено Lua-скриптов (включая .rbxl): ${luaUserCount}`);
        }
        this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
        // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
        // во все sandbox'ы. Не перезаписываем существующий обработчик —
        // оборачиваем его (старый колбэк UI должен продолжать работать).
        try {
            const player = this.scene3d?.player;
            if (player && !player._gameRuntimeHpHook) {
                const prevCb = player._onHpChange;
                this._lastSeenHp = player.hp ?? 100;
                player._onHpChange = (ev) => {
                    if (typeof prevCb === 'function') {
                        try { prevCb(ev); } catch (e) {}
                    }
                    const delta = (ev?.hp ?? 0) - (this._lastSeenHp ?? 0);
                    this._lastSeenHp = ev?.hp ?? 0;
                    this.routeGlobalEvent('hpChange', {
                        hp: ev?.hp,
                        maxHp: ev?.maxHp,
                        source: ev?.source || null,
                        damaged: !!ev?.damaged,
                        delta,
                    });
                };
                player._gameRuntimeHpHook = true;
            }
            // Хуки прыжка/приземления для game.onPlayerJump / game.onPlayerLand
            if (player && !player._gameRuntimeMoveHook) {
                player._onJump = () => this.routeGlobalEvent('playerJump', {});
                player._onLand = () => this.routeGlobalEvent('playerLand', {});
                player._gameRuntimeMoveHook = true;
            }
            // Флаг для детекта смерти (game.onPlayerDied) — проверяется в tick
            this._playerWasAlive = (this.scene3d?.player?.hp ?? 100) > 0;
            // Хук смерти NPC (game.scene.onNpcDeath / npc.onDeath) — событие
            // npcDeath с id и позицией погибшего NPC.
            const nm = this.scene3d?.npcManager;
            if (nm && typeof nm.setOnDeath === 'function') {
                nm.setOnDeath((npcId, position) => {
                    this.routeGlobalEvent('npcDeath', { npcId, position });
                });
            }
        } catch (e) { /* ignore */ }
        // Первичный snapshot — нужен чтобы game.scene.find/all и game.gui.find работали с самого начала.
        const sendInitial = () => {
            this._broadcastSceneSnapshot();
            this._broadcastGuiSnapshot();
            this._broadcastTerrainHeightmap();
            this._broadcastSkinsSnapshot();   // задача 07
            // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'.
            this._startGuiAnimationPresets();
        };
        if (typeof requestAnimationFrame !== 'undefined') {
            requestAnimationFrame(sendInitial);
        } else {
            setTimeout(sendInitial, 16);
        }
    }

    /**
     * Разослать карту высот гладкого ландшафта всем sandbox'ам.
     * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по
     * реальному мешу один раз — террейн в Play не меняется.
     */
    _broadcastTerrainHeightmap() {
        const s = this.scene3d;
        if (!s || typeof s.exportRobloxHeightmap !== 'function') return;
        // Шаг 3м — компромисс: меньше точек (~14K при 360м) чем у зомби
        // (там шаг 2), для плавности движения животных достаточно.
        let hm;
        try {
            hm = s.exportRobloxHeightmap(3);
        } catch (e) {
            return;
        }
        if (!hm || !hm.heights) return;
        const payload = {
            origin: hm.origin, step: hm.step,
            cols: hm.cols, rows: hm.rows, heights: hm.heights,
        };
        for (const sb of this.sandboxes) {
            sb.sendTerrainHeightmap(payload);
        }
    }

    /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */
    _startGuiAnimationPresets() {
        const gm = this.scene3d?.guiManager;
        if (!gm) return;
        if (!this._guiTweens) this._guiTweens = [];
        for (const el of (gm.elements || [])) {
            const preset = el.animationPreset;
            if (!preset || preset === 'none') continue;
            const id = el.id;
            // Каждый пресет = одна tween-запись с reverses+repeat=-1
            switch (preset) {
                case 'pulse':
                    this._guiTweens.push(this._mkGuiPreset(id, el,
                        { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1));
                    break;
                case 'rotate':
                    this._guiTweens.push(this._mkGuiPreset(id, el,
                        { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1));
                    break;
                case 'sway':
                    this._guiTweens.push(this._mkGuiPreset(id, el,
                        { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1));
                    this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8;
                    break;
                case 'glow':
                    this._guiTweens.push(this._mkGuiPreset(id, el,
                        { bgOpacity: 0.6 }, 0.8, 'ease', true, -1));
                    break;
                case 'bounce':
                    this._guiTweens.push(this._mkGuiPreset(id, el,
                        { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1));
                    break;
            }
        }
    }
    _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) {
        const start = {};
        for (const k of Object.keys(targetProps)) {
            if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1;
            else if (k === 'rotation') start[k] = el.rotation || 0;
            else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity;
            else start[k] = el[k] || 0;
        }
        return {
            tweenId: ++this._tweenSeq || (this._tweenSeq = 1),
            scriptId: '__preset__',
            realId: id,
            start, target: targetProps,
            elapsed: 0, delay: 0,
            duration, easing,
            repeat, reverses, iter: 0, dir: 1,
        };
    }

    /**
     * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы
     * game.player.getAvailableSkins/getAllSkins работали синхронно.
     * Манифест грузится через fetch (кешируется браузером), затем
     * объединяется с разблокированными скинами из scene.skins.
     */
    async _broadcastSkinsSnapshot() {
        try {
            this._ensureSkinState();
            let manifest = this._skinManifestCache;
            if (!manifest) {
                const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
                const json = await resp.json();
                manifest = (json.skins || []).map(s => ({
                    slug: s.slug || (s.id || '').replace(/^skin_/, ''),
                    name: s.name || s.slug,
                    kind: s.kind || 'r15',
                    category: s.category || 'human',
                    price: Number.isFinite(s.price) ? s.price : 0,
                }));
                // Встроенные «человеки» character-a..g тоже добавим как базовый выбор.
                this._skinManifestCache = manifest;
            }
            const payload = {
                all: manifest,
                unlocked: Array.from(this._skinState.unlocked),
                current: this._skinState.current,
                coins: this._skinState.coins,
            };
            for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload);
            // Также отдать снапшот в scene для React-магазина.
            try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {}
        } catch (e) {
            // манифест недоступен — не критично, скрипт получит пустой список
        }
    }

    /**
     * Задача 07: гарантированно инициализировать состояние скинов при первом
     * обращении. Держит множество разблокированных скинов и текущий.
     */
    _ensureSkinState() {
        if (this._skinState) return this._skinState;
        const sk = this.scene3d?._skinsConfig || {};
        const def = sk.default || this.scene3d?._playerModelType || 'character-a';
        const defSlug = this._slugFromTypeId(def);
        const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []);
        unlocked.add(defSlug);
        this._skinState = {
            unlocked,
            current: defSlug,
            shopVisible: sk.shopVisible !== false,
            coins: Number.isFinite(sk.coins) ? sk.coins : 0,
        };
        return this._skinState;
    }

    /** Ленивая инициализация PlacementManager (задача 11). */
    _ensurePlacementManager() {
        if (this.scene3d?.placementManager) return this.scene3d.placementManager;
        if (!this.scene3d || !this.scene3d.scene) return null;
        try {
            if (this.scene3d._PlacementManagerClass) {
                this.scene3d.placementManager = new this.scene3d._PlacementManagerClass(this.scene3d);
            }
        } catch (e) { this._log('error', 'placementManager init: ' + (e?.message || e)); }
        return this.scene3d.placementManager || null;
    }

    /** Ленивая инициализация виджета слот-инвентаря магазина (задача 11). */
    _ensureShopInventory() {
        if (this.scene3d?.shopInventoryUi) return this.scene3d.shopInventoryUi;
        if (!this.scene3d) return null;
        try {
            if (this.scene3d._ShopInventoryUiClass) {
                this.scene3d.shopInventoryUi = new this.scene3d._ShopInventoryUiClass(this.scene3d);
            }
        } catch (e) { this._log('error', 'shopInventoryUi init: ' + (e?.message || e)); }
        return this.scene3d.shopInventoryUi || null;
    }

    /** Ленивая инициализация экрана загрузки (задача 12). */
    _ensureLoadingScreen() {
        if (this.scene3d?.loadingScreen) return this.scene3d.loadingScreen;
        if (!this.scene3d) return null;
        try {
            if (this.scene3d._LoadingScreenOverlayClass) {
                const ls = new this.scene3d._LoadingScreenOverlayClass(this.scene3d);
                ls.setBridge(
                    (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); },
                    (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); },
                    // Задача 05: onHide.
                    () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); },
                );
                this.scene3d.loadingScreen = ls;
            }
        } catch (e) { this._log('error', 'loadingScreen init: ' + (e?.message || e)); }
        return this.scene3d.loadingScreen || null;
    }

    /** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
    _resolveSkinTypeId(slug) {
        if (!slug) return 'character-a';
        if (slug.startsWith('character-')) return slug;
        if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug;
        return 'skin_' + slug;
    }

    /** _modelTypeId → slug (обратно). */
    _slugFromTypeId(typeId) {
        if (!typeId) return 'character-a';
        if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length);
        return typeId;
    }

    /** Задача 03: обновить GUI-твины (gui.tween + animationPresets). */
    _updateGuiTweens(dt) {
        const gm = this.scene3d?.guiManager;
        if (!gm) return;
        for (let i = this._guiTweens.length - 1; i >= 0; i--) {
            const tw = this._guiTweens[i];
            if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; }
            tw.elapsed += dt;
            let t = tw.elapsed / tw.duration;
            let done = false;
            if (t >= 1) { t = 1; done = true; }
            const raw = tw.dir === -1 ? 1 - t : t;
            const k = GameRuntime._ease(tw.easing, raw);
            // Применяем
            const el = gm.elements.find(e => e.id === tw.realId);
            if (!el) { this._guiTweens.splice(i, 1); continue; }
            const patch = {};
            for (const key of Object.keys(tw.target)) {
                const from = tw.start[key];
                const to = tw.target[key];
                if (typeof from === 'number' && typeof to === 'number') {
                    patch[key] = from + (to - from) * k;
                } else if (typeof from === 'string' && typeof to === 'string'
                           && from.startsWith('#') && to.startsWith('#')) {
                    patch[key] = GameRuntime._lerpColor(from, to, k);
                } else {
                    // Прочее — на конце ставим целевое
                    if (done) patch[key] = to;
                }
            }
            // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS).
            tw._lastApply = tw._lastApply || 0;
            tw._lastApply += dt;
            if (tw._lastApply >= 0.032 || done) {
                tw._lastApply = 0;
                try { gm.update(tw.realId, patch); } catch (e) {}
            }

            if (done) {
                if (tw.reverses && tw.dir === 1) {
                    tw.dir = -1;
                    tw.elapsed = 0;
                    continue;
                }
                tw.iter++;
                if (tw.repeat === -1 || tw.iter < tw.repeat) {
                    // повтор
                    tw.elapsed = 0;
                    tw.dir = 1;
                    continue;
                }
                // готово
                this._guiTweens.splice(i, 1);
                // onDone callback в worker
                const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId);
                if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId });
            }
        }
    }

    /** Слить отложенные команды для конкретного только что зарезолвленного ref. */
    _drainPendingResolveQueue(resolvedLocalRef) {
        if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return;
        const stay = [];
        for (const item of this._pendingResolveQueue) {
            if (item.payload?.ref === resolvedLocalRef) {
                this._handleCommand(item.scriptId, item.cmd, item.payload);
            } else {
                stay.push(item);
            }
        }
        this._pendingResolveQueue = stay;
    }

    /**
     * Получить позицию объекта по его target (для зеркалирования в worker).
     */
    _collectSelfPosition(target) {
        if (!target || !this.scene3d) return null;
        try {
            if (target.kind === 'block') {
                const r = target.ref || target;
                return { x: r.x, y: r.y + 0.5, z: r.z };
            }
            if (target.kind === 'model') {
                const data = this.scene3d.modelManager?.instances?.get(target.id ?? target.ref);
                if (data) return { x: data.x, y: data.y, z: data.z };
            }
            if (target.kind === 'primitive') {
                const data = this.scene3d.primitiveManager?.instances?.get(target.id ?? target.ref);
                if (data) return { x: data.x, y: data.y, z: data.z };
            }
            if (target.kind === 'userModel') {
                const data = this.scene3d.userModelManager?.instances?.get(target.id ?? target.ref);
                if (data) return { x: data.x, y: data.y, z: data.z };
            }
        } catch (e) { /* ignore */ }
        return null;
    }

    /** DOM-overlay для импортированных Roblox-карт (KillFeed/Message/WinGui). */
    _ensureRbxlHud() {
        if (this._rbxlHud) return;
        const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
        const parent = canvas?.parentElement || document.body;
        this._rbxlHud = new RbxlHudOverlay(parent);
    }

    stop() {
        if (this.sandboxes.length > 0) {
            this._log('info', 'Остановка скриптов');
            // eslint-disable-next-line no-console
            console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
            for (const sb of this.sandboxes) sb.stop();
        }
        // Очищаем Roblox HUD overlay (KillFeed/Message/WinGui) — Фаза 2.
        try { this._rbxlHud?.dispose?.(); } catch (_) {}
        this._rbxlHud = null;
        this._rbxlPendingParticles = null;
        this._luaUserSandbox = null;
        // Удаляем все объекты, которые скрипты наспавнили через
        // game.scene.spawn/clone — иначе после Stop они остаются на сцене
        // и накапливаются при повторных запусках.
        this._cleanupSpawnedObjects();
        // Удаляем GUI-элементы, созданные скриптом через game.gui.create —
        // иначе после Stop они остаются в интерфейсе сцены.
        this._cleanupSpawnedGui();
        // Убираем billboard-метки над объектами (game.scene.setLabel).
        try {
            if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll();
        } catch (e) { /* ignore */ }
        this.sandboxes = [];
        this._isRunning = false;
        this._soloScriptId = null;
        this._tweens = [];
        this._objectData = {};
        this._interactables = [];
        this._activeInteractRef = null;
        // Задача 14: убрать машины и HUD водителя, чтобы при повторном start
        // не плодились дубликаты (в плеере start может вызываться повторно).
        try { this.scene3d?.vehicleManager?.dispose?.(); } catch (e) {}
        try { this.scene3d?.vehicleHud?.remove?.(); } catch (e) {}
        this._vehHudShown = false;
        try { if (this.scene3d?.player) this.scene3d.player._inVehicle = null; } catch (e) {}
        this._watchedTouchRefs = null;
        this._watchedClickRefs = null;
        this._roomState = {};
        this._seenSessions = null;
        this._teams = new Map();
        this._localPlayerTeam = null;
        this._constraintLocalToReal = new Map();
        this._fxLocalToReal = new Map();
        this._soundLocalToReal = new Map();
        this._guiLocalToReal = new Map();
        this._guiRealToLocal = new Map();
    }

    /**
     * Удалить GUI-элементы, созданные скриптом через game.gui.create.
     * Вызывается в stop() — иначе скриптовый интерфейс остаётся в сцене
     * после остановки игры и копится при повторных запусках.
     */
    _cleanupSpawnedGui() {
        if (!this._guiLocalToReal || this._guiLocalToReal.size === 0) return;
        const s = this.scene3d;
        if (!s || typeof s.removeGuiElement !== 'function') return;
        for (const realId of this._guiLocalToReal.values()) {
            try {
                // removeGuiElement каскадно удаляет детей — повторный вызов
                // для уже удалённого элемента безопасен (no-op).
                s.removeGuiElement(realId);
            } catch (e) { /* ignore */ }
        }
        // removeGuiElement дёргает _notify GuiManager → KubikonEditor
        // синхронит guiList. Снапшот воркерам не нужен (они остановлены).
    }

    /** Удалить со сцены все объекты, созданные скриптами в Play-режиме. */
    _cleanupSpawnedObjects() {
        if (!this._localToReal || this._localToReal.size === 0) return;
        const s = this.scene3d;
        for (const realRef of this._localToReal.values()) {
            try {
                if (typeof realRef !== 'string') continue;
                const colon = realRef.indexOf(':');
                if (colon < 0) continue;
                const kind = realRef.slice(0, colon);
                const rest = realRef.slice(colon + 1);
                if (kind === 'block') {
                    const [xs, ys, zs] = rest.split(',');
                    s?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs));
                } else if (kind === 'model') {
                    s?.modelManager?.removeInstance(Number(rest));
                } else if (kind === 'primitive') {
                    s?.primitiveManager?.removeInstance(Number(rest));
                } else if (kind === 'usermodel') {
                    // Воксельные модели, наспавненные скриптом (placement) —
                    // удаляем при Stop, иначе placed-объекты остаются. См. studio.
                    s?.userModelManager?.removeInstance(Number(rest));
                }
            } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
        }
        this._localToReal = new Map();
    }

    /**
     * Запустить ОДИН скрипт без перезагрузки сцены — режим отладки.
     * Останавливает другие скрипты, оставляет только заданный.
     * Это альтернатива Play-режиму: без полноценного игрока, без физики, но
     * скрипты получают зеркало state и могут вызывать game.log/teleport.
     *
     * Используется из ScriptEditor → кнопка «Запустить только этот».
     */
    startSolo(script) {
        this.stop();
        this._isRunning = true;
        this._soloScriptId = script?.id || null;
        if (!script || typeof script.code !== 'string' || !script.code.trim()) {
            this._log('warn', 'Solo-запуск: пустой код');
            return;
        }
        const sb = new ScriptSandbox(script.code, script.target || null);
        sb.scriptId = script.id;
        if (script.target) {
            const pos = this._collectSelfPosition(script.target);
            if (pos) sb.setInitialSelfPosition(pos);
        }
        sb.setOnCommand((cmd, payload) => {
            const _t0 = performance.now();
            this._handleCommand(script.id, cmd, payload);
            const m = this.scene3d?._perfMetrics;
            if (m) {
                m.script_ms_sum += performance.now() - _t0;
                m.script_count++;
            }
        });
        sb.start();
        this.sandboxes.push(sb);
        this._log('info', `Отладочный запуск: ${script.id}`);
    }

    /** True если runtime работает в solo-режиме (один скрипт). */
    isSolo() { return !!this._soloScriptId; }
    getSoloScriptId() { return this._soloScriptId; }

    /**
     * Вызывать каждый кадр в Play-режиме.
     * dt в секундах.
     */
    tick(dt) {
        if (!this._isRunning || this.sandboxes.length === 0) return;
        const state = this._collectState();
        // Реальная позиция игрока для Lua __rbxl_player_pos()
        const playerObj = this.scene3d?.player;
        let realPos = null;
        if (playerObj?._pos) {
            const halfH = playerObj.HALF_H ?? 0.9;
            realPos = { x: playerObj._pos.x, y: playerObj._pos.y - halfH, z: playerObj._pos.z };
        } else if (state?.player) {
            realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
        }
        // Позиции спавненных динамических примитивов (id >= 800000)
        let spawnedPositions = null;
        try {
            const pm = this.scene3d?.primitiveManager;
            if (pm && pm.instances) {
                for (const [id, data] of pm.instances.entries()) {
                    if (id < 800000 || data.anchored !== false) continue;
                    if (!spawnedPositions) spawnedPositions = [];
                    spawnedPositions.push([id, data.x, data.y, data.z]);
                }
            }
        } catch (_) {}
        // Позиции NPC для Lua-shim
        const npcPositions = [];
        try {
            const nm = this.scene3d?.npcManager;
            if (nm && nm.npcs && this._localToReal) {
                for (const [localRef, realRef] of this._localToReal.entries()) {
                    if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
                    const npcId = Number(realRef.slice(4));
                    const npc = nm.npcs.get(npcId);
                    if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
                }
            }
        } catch (_) {}
        for (const sb of this.sandboxes) {
            // Синк Lua-shim позиций (LuaSharedSandbox имеет sb.api.update*)
            if (realPos && sb.api?.updatePlayerPos) {
                try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
            }
            if (spawnedPositions && sb.api?.updateSpawnedPos) {
                for (const [id, x, y, z] of spawnedPositions) {
                    try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
                }
            }
            if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
                for (const [ref, x, y, z] of npcPositions) {
                    try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
                }
            }
            // Для скриптов с target — добавляем актуальную позицию self
            const stateForSb = sb.target
                ? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
                : state;
            sb.tick(dt, stateForSb);
        }
        // Анимации game.tween
        if (this._tweens.length > 0) this._updateTweens(dt);
        // Задача 03: GUI tweens
        if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt);

        // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
        if (this._interactables.length > 0) this._updateInteractables();

        // Задача 14: HUD водителя.
        this._updateVehicleHud();

        // Детект смерти игрока — событие game.onPlayerDied (один раз на смерть)
        const hp = this.scene3d?.player?.hp ?? 100;
        const aliveNow = hp > 0;
        if (this._playerWasAlive && !aliveNow) {
            this.routeGlobalEvent('playerDied', {});
        }
        this._playerWasAlive = aliveNow;

        // Детект join/leave игроков комнаты (Фаза 4.3).
        this._detectPlayerJoinLeave(state.players);
    }

    /**
     * Сравнить текущий список игроков с прошлым tick — событие
     * playerJoin для новых, playerLeave для исчезнувших.
     * Локального игрока не учитываем (он не «присоединяется»).
     */
    _detectPlayerJoinLeave(players) {
        if (!players || !players.list) return;
        const now = new Map();
        for (const p of players.list) {
            if (!p.isLocal) now.set(p.sessionId, p);
        }
        if (this._seenSessions == null) {
            // Первый tick — фиксируем без событий (это «уже были»).
            this._seenSessions = now;
            return;
        }
        for (const [sid, p] of now) {
            if (!this._seenSessions.has(sid)) {
                this.routeGlobalEvent('playerJoin', {
                    sessionId: sid, name: p.name,
                });
            }
        }
        for (const [sid, p] of this._seenSessions) {
            if (!now.has(sid)) {
                this.routeGlobalEvent('playerLeave', {
                    sessionId: sid, name: p.name,
                });
            }
        }
        this._seenSessions = now;
    }

    /**
     * Запустить твин: зарезолвить ref, снять стартовые значения, добавить в _tweens.
     * payload: { tweenId, ref, props, duration, easing, delay, repeat, yoyo }
     */
    _startTween(scriptId, payload) {
        try {
            const { tweenId, ref, props } = payload || {};
            if (tweenId == null || typeof ref !== 'string' || !props) return;
            const from = {};
            let guiId = null;

            // --- цель: GUI или 3D-объект ---
            // GUI-id: либо локальный ref (gui.create), либо реальный id
            let resolvedGuiId = ref;
            if (this._guiLocalToReal?.has(ref)) resolvedGuiId = this._guiLocalToReal.get(ref);
            const guiList = this.scene3d?.getGuiElements?.() || [];
            const guiEl = guiList.find(g => g.id === resolvedGuiId);

            if (guiEl) {
                guiId = resolvedGuiId;
                // числовые свойства GUI
                for (const key of ['x', 'y', 'w', 'h', 'bgOpacity', 'textSize']) {
                    if (props[key] != null && guiEl[key] != null) from[key] = Number(guiEl[key]);
                }
                // цвет
                if (props.color != null && guiEl.bgColor) {
                    from._color = Color3.FromHexString(guiEl.bgColor);
                    from._colorTo = Color3.FromHexString(String(props.color));
                }
                if (props.textColor != null && guiEl.textColor) {
                    from._color = Color3.FromHexString(guiEl.textColor);
                    from._colorTo = Color3.FromHexString(String(props.textColor));
                }
            } else {
                // 3D-объект
                const tgt = this._resolveTweenTarget(ref);
                if (!tgt) {
                    this._log('error', 'tween: объект не найден — ' + ref);
                    return;
                }
                const d = tgt.data;
                from.x = d.x || 0; from.y = d.y || 0; from.z = d.z || 0;
                from.rotationX = d.rotationX || 0;
                from.rotationY = d.rotationY || 0;
                from.rotationZ = d.rotationZ || 0;
                from.sx = d.sx != null ? d.sx : 1;
                from.sy = d.sy != null ? d.sy : 1;
                from.sz = d.sz != null ? d.sz : 1;
                from.opacity = d.opacity != null ? d.opacity
                    : (d.mesh?.material?.alpha != null ? d.mesh.material.alpha : 1);
                if (props.color != null) {
                    const cur = d.color || '#ffffff';
                    from._color = Color3.FromHexString(cur);
                    from._colorTo = Color3.FromHexString(String(props.color));
                }
            }

            this._tweens.push({
                tweenId, scriptId, ref, guiId,
                props, from,
                duration: Math.max(0, Number(payload.duration) || 0),
                easing: payload.easing || 'ease',
                delayLeft: Math.max(0, Number(payload.delay) || 0),
                loopsLeft: Number(payload.repeat) || 0,   // 0=без повтора, -1=бесконечно
                yoyo: !!payload.yoyo,
                elapsed: 0,
                dir: 1,
            });
        } catch (e) {
            this._log('error', 'tween.start failed: ' + (e?.message || e));
        }
    }

    /**
     * ProximityPrompt: каждый кадр ищем ближайший интерактивный объект
     * в радиусе и показываем подсказку «[E] ...» над ним (HUD-метка).
     */
    _updateInteractables() {
        const player = this.scene3d?.player;
        const pp = player?._pos;
        if (!pp) return;
        const halfH = player?.HALF_H ?? 0.9;
        const px = pp.x, py = pp.y - halfH, pz = pp.z;

        let nearest = null;
        let nearestD2 = Infinity;
        for (const it of this._interactables) {
            const objPos = this._resolveInteractPos(it);
            if (!objPos) continue;
            const dx = objPos.x - px, dy = objPos.y - py, dz = objPos.z - pz;
            const d2 = dx*dx + dy*dy + dz*dz;
            const r = it.distance;
            if (d2 <= r*r && d2 < nearestD2) {
                nearestD2 = d2;
                nearest = it;
            }
        }

        const nearestRef = nearest ? nearest.ref : null;
        if (nearestRef !== this._activeInteractRef) {
            this._activeInteractRef = nearestRef;
            if (nearest) {
                // показываем подсказку через HUD (как game.ui.set)
                if (this._onHud) {
                    try {
                        this._onHud({ cmd: 'ui.set', payload: {
                            id: '__interact',
                            text: '[' + nearest.key.toUpperCase() + '] ' + nearest.text,
                            opts: { x: 50, y: 75, color: '#ffe44a', size: 20 },
                        } });
                    } catch (e) { /* ignore */ }
                }
            } else {
                // вышли из зоны — убираем подсказку
                if (this._onHud) {
                    try {
                        this._onHud({ cmd: 'ui.set', payload: { id: '__interact', text: null } });
                    } catch (e) { /* ignore */ }
                }
            }
        }
    }

    /** Резолв позиции интерактивного объекта (по ref). */
    _updateVehicleHud() {
        const player = this.scene3d?.player;
        const veh = player?._inVehicle;
        if (veh) {
            const hud = this._ensureVehicleHud();
            if (hud) {
                if (!this._vehHudShown) { try { hud.show(veh.params?.maxSpeed); } catch (e) {} this._vehHudShown = true; }
                try { hud.update(veh.speed); } catch (e) {}
            }
        } else if (this._vehHudShown) {
            this._vehHudShown = false;
            try { this.scene3d?.vehicleHud?.remove(); } catch (e) {}
        }
    }

    _ensureVehicleHud() {
        if (this.scene3d?.vehicleHud) return this.scene3d.vehicleHud;
        if (!this.scene3d || !this.scene3d._VehicleHudClass) return null;
        try { this.scene3d.vehicleHud = new this.scene3d._VehicleHudClass(this.scene3d); }
        catch (e) { this._log('error', 'vehicleHud init: ' + (e?.message || e)); }
        return this.scene3d.vehicleHud || null;
    }

    _resolveInteractPos(it) {
        if (it.ref && it.ref.indexOf('vehicle:') === 0) {
            const veh = this.scene3d?.vehicleManager?.getById?.(Number(it.ref.slice(8)));
            return veh ? { x: veh.pos.x, y: veh.pos.y, z: veh.pos.z } : null;
        }
        const tgt = this._resolveTweenTarget(it.ref);
        if (tgt) {
            const d = tgt.data;
            return { x: d.x || 0, y: d.y || 0, z: d.z || 0 };
        }
        return null;
    }

    /**
     * Нажата клавиша взаимодействия (E) — отправить событие 'interact'
     * скрипту ближайшего интерактивного объекта. Вызывается из routeGlobalEvent
     * при keydown.
     */
    _tryInteract(key) {
        if (!this._activeInteractRef) return;
        const it = this._interactables.find(x => x.ref === this._activeInteractRef);
        if (!it || it.key !== String(key).toLowerCase()) return;
        this._fireInteract(it);
    }

    _fireInteract(it) {
        if (!it) return;
        if (it.isInst) {
            for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref });
        } else if (it.target) {
            this.routeEvent(it.target, 'interact', {});
        }
        if (it.ref && it.ref.indexOf('vehicle:') === 0) {
            const vid = Number(it.ref.slice(8));
            const veh = this.scene3d?.vehicleManager?.getById?.(vid);
            const player = this.scene3d?.player;
            if (veh && player && !player._inVehicle) {
                player.enterVehicle(veh);
                player._onVehicleExit = (v) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleExit', vehicleId: v?.id }); };
                for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleEnter', vehicleId: vid });
            }
        }
    }

    /** Прокрутка всех активных твинов на dt секунд. */
    _updateTweens(dt) {
        for (let i = this._tweens.length - 1; i >= 0; i--) {
            const tw = this._tweens[i];
            // задержка перед стартом
            if (tw.delayLeft > 0) {
                tw.delayLeft -= dt;
                if (tw.delayLeft > 0) continue;
                dt = -tw.delayLeft;  // остаток времени уходит в анимацию
            }
            tw.elapsed += dt;
            let t = tw.duration > 0 ? tw.elapsed / tw.duration : 1;
            let done = false;
            if (t >= 1) {
                t = 1;
                done = true;
            }
            // прогресс с учётом направления (yoyo) + easing
            const raw = tw.dir === -1 ? 1 - t : t;
            const k = GameRuntime._ease(tw.easing, raw);
            this._applyTweenFrame(tw, k);

            if (done) {
                if (tw.yoyo && tw.dir === 1) {
                    // первый проход «туда» завершён — разворачиваем «обратно»
                    tw.dir = -1;
                    tw.elapsed = 0;
                    continue;
                }
                // цикл завершён полностью (или прямой, или yoyo туда-обратно)
                if (tw.loopsLeft !== 0) {
                    if (tw.loopsLeft > 0) tw.loopsLeft--;
                    tw.dir = 1;
                    tw.elapsed = 0;
                    continue;
                }
                // твин закончен — снять и уведомить скрипт
                this._tweens.splice(i, 1);
                this._notifyTweenDone(tw.scriptId, tw.tweenId);
            }
        }
    }

    /** Easing-функции. Принимают t∈[0,1], возвращают сглаженное значение. */
    static _ease(name, t) {
        switch (name) {
            case 'linear':
                return t;
            case 'bounce': {
                const n1 = 7.5625, d1 = 2.75;
                if (t < 1 / d1) return n1 * t * t;
                if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
                if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
                t -= 2.625 / d1; return n1 * t * t + 0.984375;
            }
            case 'elastic': {
                if (t === 0 || t === 1) return t;
                const c4 = (2 * Math.PI) / 3;
                return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
            }
            case 'back': {
                const c1 = 1.70158, c3 = c1 + 1;
                return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
            }
            case 'ease':
            default:
                // ease-in-out (плавный старт и финиш)
                return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
        }
    }

    /** Уведомить воркер скрипта что твин доиграл (resolve onDone). */
    _notifyTweenDone(scriptId, tweenId) {
        const sb = this.sandboxes.find(s => s.scriptId === scriptId);
        if (sb && sb.worker) {
            try { sb.worker.postMessage({ cmd: 'tweenDone', payload: { tweenId } }); } catch (e) {}
        }
    }

    /**
     * Сообщить ВСЕМ sandbox'ам маппинг локальный ref → реальный после
     * scene.spawn. Нужно чтобы синхронные read-методы воркера
     * (getPosition и т.п.) резолвили локальный ref в реальный — иначе
     * заспавненный объект не находится в _sceneIndex (там реальные ref).
     */
    _notifySpawnResolved(localRef, realRef) {
        if (!localRef || !realRef) return;
        // Объект мог быть удалён скриптом ДО того как зарезолвился
        // (асинхронный спавн GLB-модели). Если он в очереди отложенных
        // удалений — удаляем сейчас, когда реальный id известен.
        if (this._pendingDeletes && this._pendingDeletes.has(localRef)) {
            this._pendingDeletes.delete(localRef);
            try {
                this._applySceneDelete({ ref: realRef });
            } catch (e) { /* ignore */ }
            return;
        }
        for (const sb of this.sandboxes) {
            if (sb && sb.worker) {
                try {
                    sb.worker.postMessage({
                        cmd: 'spawnResolved',
                        payload: { localRef, realRef },
                    });
                } catch (e) { /* ignore */ }
            }
        }
    }

    /**
     * Резолв ref в инстанс-данные объекта сцены.
     * Возвращает { kind, data } или null. kind: 'primitive'|'model'|'userModel'.
     * data — объект из *Manager.instances (имеет mesh/rootMesh/rootNode + x/y/z).
     */
    /**
     * Резолв id примитива из любого вида ссылки в реальный id для
     * primitiveManager.instances. Принимает:
     *   - реальный числовой id (или строку-число)
     *   - локальный ref от spawn/clone ('primitive:_local_N')
     *   - ref 'primitive:realId'
     * Возвращает id (число) или null.
     */
    _resolvePrimitiveId(idOrRef) {
        if (idOrRef == null) return null;
        const pm = this.scene3d?.primitiveManager;
        if (!pm) return null;
        let v = idOrRef;
        if (typeof v === 'string') {
            // полный ref 'primitive:_local_N' / 'primitive:123' → резолвим через карту
            if (this._localToReal?.has(v)) v = this._localToReal.get(v);
            const colon = v.indexOf(':');
            if (colon >= 0) v = v.slice(colon + 1);
            // голый '_local_N' (воркер мог отрезать 'primitive:') — ищем по карте:
            // ключ 'primitive:_local_N' → значение 'primitive:realId'.
            if (typeof v === 'string' && v.indexOf('_local_') === 0 && this._localToReal) {
                const full = 'primitive:' + v;
                if (this._localToReal.has(full)) {
                    const real = this._localToReal.get(full);
                    const c2 = real.indexOf(':');
                    v = c2 >= 0 ? real.slice(c2 + 1) : real;
                }
            }
        }
        // прямой id
        if (pm.instances.has(v)) return v;
        const n = Number(v);
        if (Number.isFinite(n) && pm.instances.has(n)) return n;
        return null;
    }

    /**
     * ref NPC ('npc:_local_N' от воркера или 'npc:<id>') → числовой npcId.
     * Возвращает number или null.
     */
    _resolveNpcId(ref) {
        if (typeof ref !== 'string') return null;
        let v = ref;
        // Локальный ref воркера → реальный 'npc:<id>'.
        if (this._localToReal?.has(v)) v = this._localToReal.get(v);
        const colon = v.indexOf(':');
        if (colon < 0) return null;
        const id = Number(v.slice(colon + 1));
        return Number.isFinite(id) ? id : null;
    }

    /**
     * Выполнить NPC-команду. Если NPC ещё не создан (spawnNpc async, а
     * скрипт сразу зовёт follow/moveTo/say) — откладываем команду в
     * очередь по локальному ref и проигрываем после npcSpawned-резолва.
     * Без этого команды сразу после spawnNpc молча терялись.
     */
    _npcCmd(ref, fn) {
        const nid = this._resolveNpcId(ref);
        if (nid != null) { fn(nid); return; }
        // ещё не резолвится — откладываем (только для локальных ref NPC)
        if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) {
            if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map();
            if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
            this._pendingNpcCmds.get(ref).push(fn);
        }
    }

    /** Проиграть отложенные команды для NPC после его резолва. */
    _flushPendingNpcCmds(localRef, npcId) {
        if (!this._pendingNpcCmds) return;
        const queue = this._pendingNpcCmds.get(localRef);
        if (!queue) return;
        this._pendingNpcCmds.delete(localRef);
        for (const fn of queue) {
            try { fn(npcId); } catch (e) { /* ignore */ }
        }
    }

    /** Локальный ref связи ('constraint:_local_N') → числовой id или null. */
    _resolveConstraintId(ref) {
        if (typeof ref !== 'string') return null;
        if (this._constraintLocalToReal?.has(ref)) {
            return this._constraintLocalToReal.get(ref);
        }
        // Запасной путь: прямой числовой id в строке.
        const colon = ref.indexOf(':');
        const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref);
        return Number.isFinite(id) ? id : null;
    }

    /** Локальный ref луча/следа ('fx:_local_N') → числовой id или null. */
    _resolveFxId(ref) {
        if (typeof ref !== 'string') return null;
        if (this._fxLocalToReal?.has(ref)) {
            return this._fxLocalToReal.get(ref);
        }
        const colon = ref.indexOf(':');
        const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref);
        return Number.isFinite(id) ? id : null;
    }

    _resolveTweenTarget(ref) {
        if (typeof ref !== 'string') return null;
        // Локальный ref из scene.spawn ('primitive:_local_N') → реальный id
        if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
        const colon = ref.indexOf(':');
        const kind = colon >= 0 ? ref.slice(0, colon) : null;
        const rawId = colon >= 0 ? ref.slice(colon + 1) : ref;
        const tryGet = (mgr) => {
            if (!mgr || !mgr.instances) return null;
            let d = mgr.instances.get(rawId);
            if (!d) {
                const n = Number(rawId);
                if (Number.isFinite(n)) d = mgr.instances.get(n);
            }
            return d || null;
        };
        if (kind === 'primitive' || kind == null) {
            const d = tryGet(this.scene3d?.primitiveManager);
            if (d) return { kind: 'primitive', data: d };
        }
        if (kind === 'model' || kind == null) {
            const d = tryGet(this.scene3d?.modelManager);
            if (d) return { kind: 'model', data: d };
        }
        const um = tryGet(this.scene3d?.userModelManager);
        if (um) return { kind: 'userModel', data: um };
        return null;
    }

    /**
     * Применить промежуточное состояние твина к объекту.
     * k — сглаженный прогресс [0,1]. Интерполяция from→props по каждому ключу.
     */
    _applyTweenFrame(tw, k) {
        const lerp = (a, b) => a + (b - a) * k;
        // --- GUI-элемент ---
        if (tw.guiId != null) {
            const patch = {};
            for (const key of Object.keys(tw.props)) {
                if (key === 'color' || key === 'textColor') continue;
                if (tw.from[key] == null) continue;
                patch[key] = lerp(tw.from[key], Number(tw.props[key]));
            }
            if (tw.props.color != null || tw.props.textColor != null) {
                const ck = tw.props.color != null ? 'color' : 'textColor';
                patch[ck] = GameRuntime._lerpColor(tw.from._color, tw.from._colorTo, k);
            }
            // обновляем напрямую — без scheduleGuiSnapshot (дорого каждый кадр)
            try { this.scene3d?.updateGuiElement?.(tw.guiId, patch); } catch (e) {}
            return;
        }
        // --- 3D-объект ---
        const tgt = this._resolveTweenTarget(tw.ref);
        if (!tgt) return;
        const d = tgt.data;
        const p = tw.props, f = tw.from;
        // позиция
        let posChanged = false;
        if (p.x != null) { d.x = lerp(f.x, Number(p.x)); posChanged = true; }
        if (p.y != null) { d.y = lerp(f.y, Number(p.y)); posChanged = true; }
        if (p.z != null) { d.z = lerp(f.z, Number(p.z)); posChanged = true; }
        // поворот
        let rotChanged = false;
        if (p.rotationX != null) { d.rotationX = lerp(f.rotationX || 0, Number(p.rotationX)); rotChanged = true; }
        if (p.rotationY != null) { d.rotationY = lerp(f.rotationY || 0, Number(p.rotationY)); rotChanged = true; }
        if (p.rotationZ != null) { d.rotationZ = lerp(f.rotationZ || 0, Number(p.rotationZ)); rotChanged = true; }
        // масштаб
        let scaleChanged = false;
        if (p.sx != null) { d.sx = lerp(f.sx || 1, Number(p.sx)); scaleChanged = true; }
        if (p.sy != null) { d.sy = lerp(f.sy || 1, Number(p.sy)); scaleChanged = true; }
        if (p.sz != null) { d.sz = lerp(f.sz || 1, Number(p.sz)); scaleChanged = true; }
        // меш (primitive → .mesh, model/userModel → .rootMesh/.rootNode)
        const mesh = d.mesh || d.rootMesh || d.rootNode;
        if (mesh) {
            if (posChanged && mesh.position) mesh.position.set(d.x, d.y, d.z);
            if (rotChanged && mesh.rotation) {
                mesh.rotation.x = d.rotationX || 0;
                mesh.rotation.y = d.rotationY || 0;
                mesh.rotation.z = d.rotationZ || 0;
            }
            if (scaleChanged && mesh.scaling) {
                mesh.scaling.set(d.sx || 1, d.sy || 1, d.sz || 1);
            }
            // размороз world-matrix если был заморожен
            if ((posChanged || rotChanged || scaleChanged) && d._worldMatrixFrozen) {
                try { mesh.unfreezeWorldMatrix?.(); } catch (e) {}
                d._worldMatrixFrozen = false;
            }
        }
        // цвет
        if (p.color != null && f._color != null && mesh?.material) {
            const c = GameRuntime._lerpColor3(f._color, f._colorTo, k);
            mesh.material.diffuseColor = c;
            if (d.material === 'neon') mesh.material.emissiveColor = c;
            d.color = '#' + c.toHexString().slice(1);
        }
        // прозрачность
        if (p.opacity != null && mesh?.material) {
            const op = lerp(f.opacity != null ? f.opacity : 1, Number(p.opacity));
            mesh.material.alpha = op;
            d.opacity = op;
        }
    }

    /** Интерполяция цвета (Babylon Color3) между двумя hex. */
    static _lerpColor3(from, to, k) {
        return new Color3(
            from.r + (to.r - from.r) * k,
            from.g + (to.g - from.g) * k,
            from.b + (to.b - from.b) * k,
        );
    }

    /** Интерполяция цвета → hex-строка (для GUI). */
    static _lerpColor(from, to, k) {
        return '#' + GameRuntime._lerpColor3(from, to, k).toHexString().slice(1);
    }

    /**
     * Маршрутизация событий объектов к скриптам с соответствующим target.
     * Вызывается из BabylonScene при клике/touch.
     *
     * @param {object} target — {kind, ref|x|y|z|id}
     * @param {string} eventType — 'click' | 'touch'
     * @param {object} extra — дополнительные данные события
     */
    routeEvent(target, eventType, extra = {}) {
        if (!target || !eventType) return;
        for (const sb of this.sandboxes) {
            if (!sb.target) continue;
            if (!this._targetMatches(sb.target, target)) continue;
            sb.sendEvent({ type: eventType, ...extra });
        }
    }

    /**
     * Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target).
     * Используется для onKey, onClick (глобальный), onPlayerTouch.
     */
    routeGlobalEvent(eventType, extra = {}) {
        if (!eventType) return;
        // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя
        // способами:
        //   1) по локальному ref, который вернул gui.create() — '_gui_local_N'
        //   2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }),
        //      или по name элемента.
        // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2),
        // потому что worker искал handler по localRef, а юзер подписался по
        // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref),
        // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker).
        if ((eventType === 'guiClick' || eventType === 'guiSubmit'
             || eventType === 'guiTextChange')
            && extra && extra.id != null && this._guiRealToLocal) {
            const local = this._guiRealToLocal.get(extra.id);
            if (local && local !== extra.id) extra = { ...extra, localId: local };
        }
        // ProximityPrompt: keydown клавиши взаимодействия → событие interact
        if (eventType === 'keydown' && extra && extra.key
            && this._interactables.length > 0) {
            this._tryInteract(extra.key);
        }
        for (const sb of this.sandboxes) {
            sb.sendGlobalEvent({ type: eventType, ...extra });
        }
    }

    /**
     * Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref.
     * Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref;
     * worker матчит по ref на findOne(x).onTouch/onUntouch/onClick.
     */
    routeInstEvent(ref, type, extra = {}) {
        if (!ref || !type) return;
        this.routeGlobalEvent(type, { ref, ...extra });
    }

    /**
     * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'.
     * Скрипт может подписаться через `game.onMobKilled(fn)`.
     * payload: { type: 'zombie' | ..., x, y, z }
     */
    notifyMobKilled(mobType, position) {
        this.routeGlobalEvent('mobKilled', { mobType, position });
    }

    /** Совпадает ли target скрипта с обращённым target события. */
    _targetMatches(a, b) {
        if (!a || !b) return false;
        if (a.kind !== b.kind) return false;
        if (a.kind === 'block') {
            const ar = a.ref || a;
            const br = b.ref || b;
            return ar.x === br.x && ar.y === br.y && ar.z === br.z;
        }
        const aId = a.id ?? a.ref;
        const bId = b.id ?? b.ref;
        return aId === bId;
    }

    /** Собрать снимок state для отправки в Worker'ы. */
    _collectState() {
        const player = this.scene3d?.player;
        // PlayerController хранит позицию в this._pos (Vector3).
        // Внутри _pos.y — это центр капсулы (учтена HALF_H ~= 0.9), для авторов
        // удобнее давать «низ ног» = _pos.y - HALF_H.
        const p = player?._pos;
        const halfH = player?.HALF_H ?? 0.9;
        const position = p
            ? { x: p.x, y: p.y - halfH, z: p.z }
            : { x: 0, y: 0, z: 0 };
        // Yaw/pitch (для player.forward)
        const yaw = player?._yaw || 0;
        const pitch = player?._pitch || 0;
        // Forward-вектор. PlayerController использует:
        //   fx = sin(yaw)*cos(pitch), fy = -sin(pitch), fz = cos(yaw)*cos(pitch)
        const cosP = Math.cos(pitch);
        const forward = {
            x: Math.sin(yaw) * cosP,
            y: -Math.sin(pitch),
            z: Math.cos(yaw) * cosP,
        };
        const crosshair = this.scene3d?.getCrosshair ? this.scene3d.getCrosshair() : 'none';
        const hp = player?.hp ?? 100;
        const maxHp = player?.maxHp ?? 100;
        // Снимок мобов (зомби) — для game.scene.mobs() из скриптов
        let mobs = [];
        try {
            const zm = this.scene3d?.zombieManager;
            if (zm && typeof zm.getMobsSnapshot === 'function') {
                mobs = zm.getMobsSnapshot();
            }
        } catch (e) {}
        // Снимок NPC — для game.scene.npcs() и npc.position из скриптов.
        let npcs = [];
        try {
            const nm = this.scene3d?.npcManager;
            if (nm && typeof nm.getSnapshot === 'function') {
                npcs = nm.getSnapshot();
            }
        } catch (e) {}
        // Снимок инвентаря — для game.inventory.has/list/active.
        let inventory = null;
        try {
            const inv = this.scene3d?.inventory;
            if (inv) {
                inventory = {
                    slots: inv.slots.map(s => s ? {
                        kind: s.kind, modelTypeId: s.modelTypeId, name: s.name,
                    } : null),
                    activeIndex: inv.activeIndex,
                };
            }
        } catch (e) {}
        // Снимок игроков комнаты — для game.players.* (Фаза 4.3).
        // В редакторе (single-player) — только локальный игрок.
        // С мультиплеером — локальный + все remote из _mpSync.
        const players = this._collectPlayers(position, hp, maxHp);
        // Кубикон Dash: текущее направление гравитации (+1 / -1).
        // Нужно скрипту для рендера куба в правильной ориентации.
        const gravityDir = player?._gravityDir ?? 1;
        // Состояние игрока ('ground'|'air'|'water') для game.player.state.
        const state = player?._playerState || 'ground';
        // Зажатые клавиши — для game.player.isKeyDown(key).
        // _codes хранит коды ('KeyW','Space','ArrowUp'), нормализуем в имена скрипта.
        const keys = {};
        if (player?._codes) {
            for (const code of player._codes) {
                const k = GameRuntime._normalizeKeyCode(code);
                if (k) keys[k] = true;
            }
        }
        return {
            player: { position, yaw, pitch, forward, crosshair, hp, maxHp, gravityDir, state, keys },
            mobs,
            npcs,
            inventory,
            players,
            roomState: this._roomState || {},
            teams: this._teams ? Array.from(this._teams.values()) : [],
        };
    }

    /**
     * Снимок всех игроков комнаты для game.players.* (Фаза 4.3).
     * Локальный игрок всегда первый, sessionId='local' в одиночной игре
     * или реальный sessionId если есть Colyseus-комната.
     * Возвращает { me, list } — list включает me.
     */
    _collectPlayers(myPos, myHp, myMaxHp) {
        const mp = this.scene3d?._mpSync;
        const mySessionId = mp?.room?.sessionId || 'local';
        const myName = mp?.room?.state?.players?.get?.(mySessionId)?.username
            || this._localPlayerName || 'Игрок';
        const me = {
            sessionId: mySessionId,
            name: myName,
            isLocal: true,
            position: myPos,
            hp: myHp, maxHp: myMaxHp,
            team: this._localPlayerTeam || null,
        };
        const list = [me];
        // Remote-игроки из MultiplayerSync (если есть комната).
        if (mp && mp.remotePlayers) {
            const roomPlayers = mp.room?.state?.players;
            for (const rp of mp.remotePlayers.values()) {
                // team берётся из Colyseus-state (его синхронизирует сервер).
                const colyP = roomPlayers?.get?.(rp.sessionId);
                list.push({
                    sessionId: rp.sessionId,
                    name: rp.username || rp.sessionId,
                    isLocal: false,
                    position: rp.current
                        ? { x: rp.current.x, y: rp.current.y, z: rp.current.z }
                        : { x: 0, y: 0, z: 0 },
                    hp: rp.hp ?? 100, maxHp: rp.maxHp ?? 100,
                    team: (colyP && colyP.team) || null,
                });
            }
        }
        return { me, list };
    }

    /** Код клавиши Babylon ('KeyW','Space','ArrowUp') → имя для скрипта ('w','space','arrowup'). */
    static _normalizeKeyCode(code) {
        if (!code) return null;
        if (code.startsWith('Key')) return code.slice(3).toLowerCase();   // KeyW → w
        if (code.startsWith('Digit')) return code.slice(5);               // Digit1 → 1
        if (code.startsWith('Arrow')) return code.toLowerCase();          // ArrowUp → arrowup
        const map = {
            Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift',
            Enter: 'enter', Escape: 'escape',
            ControlLeft: 'ctrl', ControlRight: 'ctrl',
        };
        return map[code] || code.toLowerCase();
    }

    /** Команда от Worker'а пришла — применяем на сцене. */
    _handleCommand(scriptId, cmd, payload) {
        if (cmd === 'log') {
            this._log(payload?.level || 'info', payload?.text || '');
            return;
        }
        // inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик
        // ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает
        // следить за AABB этого объекта в _detectTouchEvents и слать обратно
        // instTouch/instUntouch (через routeInstEvent).
        if (cmd === 'inst.watchTouch') {
            const ref = payload && payload.ref;
            if (typeof ref === 'string') {
                if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set();
                this._watchedTouchRefs.add(ref);
            }
            return;
        }
        if (cmd === 'inst.watchClick') {
            const ref = payload && payload.ref;
            if (typeof ref === 'string') {
                if (!this._watchedClickRefs) this._watchedClickRefs = new Set();
                this._watchedClickRefs.add(ref);
            }
            return;
        }
        if (cmd === 'player.teleport') {
            const player = this.scene3d?.player;
            if (player && player._pos && payload) {
                const { x, y, z } = payload;
                if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
                    try {
                        const halfH = player.HALF_H ?? 0.9;
                        // Конвертируем «низ ног» обратно в центр капсулы
                        player._pos.set(x, y + halfH, z);
                    } catch (e) {
                        // eslint-disable-next-line no-console
                        console.warn('[GameRuntime] teleport failed', e);
                    }
                }
            } else {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] teleport ignored — no player or _pos', { hasPlayer: !!player, hasPos: !!(player && player._pos) });
            }
            return;
        }
        if (cmd === 'player.setLaneX') {
            // Сдвиг игрока ТОЛЬКО по X — не трогает Z и Y. Нужно для
            // раннеров (смена полосы): teleport(x,y,z) затирал бы Z,
            // отменяя продвижение autorun каждый кадр.
            const player = this.scene3d?.player;
            if (player && player._pos && payload) {
                const x = Number(payload.x);
                if (Number.isFinite(x)) {
                    try { player._pos.x = x; } catch (e) { /* ignore */ }
                }
            }
            return;
        }
        if (cmd === 'player.damage') {
            const player = this.scene3d?.player;
            if (player && typeof player.takeDamage === 'function') {
                const amt = Math.max(0, Number(payload?.amount) || 0);
                if (amt > 0) {
                    // Если урон больше maxHp — обходим i-frames для kill().
                    if (amt >= (player.maxHp ?? 100)) {
                        player._lastDamageTime = 0;  // сбрасываем cooldown
                    }
                    try { player.takeDamage(amt, 'script'); } catch (e) {}
                }
            }
            return;
        }
        if (cmd === 'player.heal') {
            const player = this.scene3d?.player;
            if (player && typeof player.hp === 'number') {
                const amt = Math.max(0, Number(payload?.amount) || 0);
                player.hp = Math.min(player.maxHp ?? 100, player.hp + amt);
                if (player._onHpChange) {
                    try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'heal', damaged: false }); } catch (e) {}
                }
            }
            return;
        }
        if (cmd === 'player.respawn') {
            const player = this.scene3d?.player;
            if (player && player._pos) {
                // Восстанавливаем HP
                player.hp = player.maxHp ?? 100;
                if (player._onHpChange) {
                    try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'respawn', damaged: false }); } catch (e) {}
                }
                // Возвращаем модель если была спрятана при смерти
                if (player._modelRoot) player._modelRoot.setEnabled(true);
                // Телепорт на spawnPoint сцены
                const sp = this.scene3d?._spawnPoint
                    || this.scene3d?.scene?.metadata?.spawnPoint
                    || { x: 0, y: 1, z: 0 };
                const halfH = player.HALF_H ?? 0.9;
                try { player._pos.set(sp.x, sp.y + halfH, sp.z); } catch (e) {}
                // Сбросим скорость падения
                if (player._velocity) {
                    try { player._velocity.set(0, 0, 0); } catch (e) {}
                }
            }
            return;
        }
        if (cmd === 'player.setSpawn') {
            // Назначить активную точку возрождения. Меняем scene3d._spawnPoint —
            // им пользуется player.respawn и логика смерти.
            const s = this.scene3d;
            if (s && payload) {
                let sp = null;
                if (typeof payload.ref === 'string') {
                    // ref объекта: встаём НАД ним (центр + полувысота + зазор).
                    const ref = payload.ref;
                    if (ref.indexOf('block:') === 0) {
                        const [bx, by, bz] = ref.slice(6).split(',').map(Number);
                        if ([bx, by, bz].every(Number.isFinite)) {
                            sp = { x: bx, y: by + 1.1, z: bz };
                        }
                    } else {
                        const tgt = this._resolveTweenTarget(ref);
                        if (tgt && tgt.data) {
                            const d = tgt.data;
                            const topOff = (d.sy != null ? d.sy * 0.5 : 0.5) + 0.1;
                            sp = { x: d.x, y: (d.y || 0) + topOff, z: d.z };
                        }
                    }
                } else if (Number.isFinite(payload.x)) {
                    sp = { x: payload.x, y: payload.y, z: payload.z };
                }
                if (sp && typeof s.setSpawnPoint === 'function') {
                    s.setSpawnPoint(sp.x, sp.y, sp.z);
                }
            }
            return;
        }
        // === NPC API (Фаза 4.1) ===
        if (cmd === 'npc.spawn') {
            // payload: { modelType, ref, x, y, z, rotationY, hp, name, speed }
            const nm = this.scene3d?.npcManager;
            if (nm && payload) {
                if (!this._localToReal) this._localToReal = new Map();
                const p = nm.spawnNpc(payload.modelType, {
                    x: payload.x, y: payload.y, z: payload.z,
                    rotationY: payload.rotationY,
                    hp: payload.hp, name: payload.name, speed: payload.speed,
                });
                Promise.resolve(p).then((npcId) => {
                    if (npcId == null) {
                        this._log('error', 'spawnNpc не удался: ' + payload.modelType);
                        return;
                    }
                    // Локальный ref воркера → реальный 'npc:<id>'.
                    if (payload.ref) {
                        this._localToReal.set(payload.ref, 'npc:' + npcId);
                        // Проигрываем команды, отправленные скриптом сразу
                        // после spawnNpc (follow/moveTo/say) — они ждали
                        // резолва ref в очереди.
                        this._flushPendingNpcCmds(payload.ref, npcId);
                    }
                    // Сообщаем воркеру маппинг localRef → npcId, чтобы
                    // npc.onDeath по локальному ref находил правильного NPC.
                    if (payload.ref) {
                        const sb = this.sandboxes.find(s => s.scriptId === scriptId);
                        if (sb && sb.worker) {
                            try {
                                sb.worker.postMessage({
                                    cmd: 'npcSpawned',
                                    payload: { localRef: payload.ref, npcId },
                                });
                            } catch (e) { /* ignore */ }
                        }
                    }
                }).catch((err) => {
                    this._log('error', 'spawnNpc failed: ' + (err?.message || err));
                });
            }
            return;
        }
        if (cmd === 'npc.moveTo') {
            // _npcCmd откладывает команду, если NPC ещё не создан (async).
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.moveTo(nid, payload.x, payload.z));
            return;
        }
        if (cmd === 'npc.follow') {
            this._npcCmd(payload?.ref, (nid) => {
                // target — ref объекта или 'player'. Резолвим локальный ref
                // в реальный (объект мог быть заспавнен скриптом).
                let target = payload?.target;
                if (typeof target === 'string' && this._localToReal?.has(target)) {
                    target = this._localToReal.get(target);
                }
                this.scene3d?.npcManager?.follow(nid, target);
            });
            return;
        }
        if (cmd === 'npc.setAttacking') {
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on));
            return;
        }
        if (cmd === 'npc.stop') {
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.stopNpc(nid));
            return;
        }
        if (cmd === 'npc.setSpeed') {
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.setSpeed(nid, payload?.speed));
            return;
        }
        if (cmd === 'npc.say') {
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.say(nid, payload?.text, payload?.duration));
            return;
        }
        if (cmd === 'npc.damage') {
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.damage(nid, payload?.amount));
            return;
        }
        if (cmd === 'npc.remove') {
            this._npcCmd(payload?.ref, (nid) =>
                this.scene3d?.npcManager?.removeNpc(nid));
            return;
        }
        // === Constraints / связи объектов (Фаза 5) ===
        if (cmd === 'constraint.create') {
            // payload: { kind: 'weld'|'hinge'|'spring', localRef, ... }
            const cm = this.scene3d?.constraintManager;
            if (cm && payload) {
                let id = null;
                if (payload.kind === 'weld') {
                    id = cm.addWeld(payload.refA, payload.refB);
                } else if (payload.kind === 'hinge') {
                    id = cm.addHinge(payload.ref, {
                        pivotX: payload.pivotX, pivotZ: payload.pivotZ,
                        angle: payload.angle,
                    });
                } else if (payload.kind === 'spring') {
                    id = cm.addSpring(payload.ref, {
                        stiffness: payload.stiffness, damping: payload.damping,
                    });
                }
                if (id == null) {
                    this._log('error', 'не удалось создать связь ' + payload.kind);
                } else if (payload.localRef) {
                    // Маппинг localRef → реальный id (как у NPC).
                    if (!this._constraintLocalToReal) this._constraintLocalToReal = new Map();
                    this._constraintLocalToReal.set(payload.localRef, id);
                }
            }
            return;
        }
        if (cmd === 'constraint.hingeAngle') {
            const cid = this._resolveConstraintId(payload?.ref);
            if (cid != null) this.scene3d?.constraintManager?.setHingeAngle(cid, payload?.deg);
            return;
        }
        if (cmd === 'constraint.springPush') {
            const cid = this._resolveConstraintId(payload?.ref);
            if (cid != null) {
                this.scene3d?.constraintManager?.pushSpring(
                    cid, payload?.vx, payload?.vy, payload?.vz);
            }
            return;
        }
        if (cmd === 'constraint.remove') {
            const cid = this._resolveConstraintId(payload?.ref);
            if (cid != null) this.scene3d?.constraintManager?.remove(cid);
            return;
        }
        // === Beam / Trail — лучи и следы (Фаза 5.2) ===
        // === Placement mode (задача 11) ===
        if (cmd === 'placement.start') {
            const pm = this._ensurePlacementManager();
            if (pm && payload) {
                pm.setCallbacks({
                    onPlace: (res) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeConfirm', ...res }); },
                    onCancel: () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeCancel' }); },
                    onMove: (mv) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeMove', ...mv }); },
                });
                try { pm.start(payload.itemKey, payload.opts || {}); }
                catch (e) { this._log('error', 'placement.start: ' + (e?.message || e)); }
            }
            return;
        }
        if (cmd === 'placement.cancel') { this.scene3d?.placementManager?.cancel(); return; }
        if (cmd === 'placement.confirm') { this.scene3d?.placementManager?.confirm(); return; }
        if (cmd === 'placement.rotate') { this.scene3d?.placementManager?.rotate(payload?.deg); return; }
        if (cmd === 'inventoryUi.create') {
            const im = this._ensureShopInventory();
            if (im && payload) {
                try { im.create(payload, (item) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'invUiSlotClick', key: item.key, item }); }); }
                catch (e) { this._log('error', 'inventoryUi.create: ' + (e?.message || e)); }
            }
            return;
        }
        if (cmd === 'inventoryUi.setBalance') {
            this.scene3d?.shopInventoryUi?.setBalance(payload?.currency, payload?.amount);
            this.scene3d?.placementManager?.setBalance(payload?.currency, payload?.amount);
            return;
        }
        if (cmd === 'inventoryUi.remove') { this.scene3d?.shopInventoryUi?.remove(); return; }

        // === Экран загрузки (задача 12) ===
        if (cmd === 'loading.show') {
            const ls = this._ensureLoadingScreen();
            if (ls && payload) {
                try {
                    const id = ls.show(payload.opts || {});
                    // replyId может отсутствовать (стартовый экран) — всё равно шлём
                    // loadingShown для game.loading.isVisible() (задача 05).
                    for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
                } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
            }
            return;
        }
        if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
        if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
        if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
        if (cmd === 'loading.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; }
        if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }

        // === Damage Floaters (задача 40) ===
        if (cmd === 'fx.damageFloater') {
            try {
                let pos = payload?.position;
                if (typeof pos === 'string') {
                    if (pos === 'player') {
                        const pl = this.scene3d?.player;
                        const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
                        pos = p ? { x: p.x, y: p.y, z: p.z } : null;
                    } else {
                        const tgt = this._resolveTweenTarget(pos);
                        pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
                    }
                }
                if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
            } catch (e) { /* ignore */ }
            return;
        }
        if (cmd === 'fx.autoMobFloaters') {
            try {
                if (this.scene3d?.npcManager) {
                    this.scene3d.npcManager._autoFloater = payload?.enabled
                        ? { opts: payload?.opts || {} } : null;
                }
            } catch (e) { /* ignore */ }
            return;
        }

        if (cmd === 'fx.create') {
            // payload: { kind: 'beam'|'trail', localRef, ... }
            const bm = this.scene3d?.beamManager;
            if (bm && payload) {
                let id = null;
                if (payload.kind === 'beam') {
                    id = bm.addBeam({
                        from: payload.from, to: payload.to,
                        color: payload.color, width: payload.width,
                        // Задача 08: расширенные опции луча.
                        texture: payload.texture, customTextureUrl: payload.customTextureUrl,
                        textureMode: payload.textureMode, textureSpeed: payload.textureSpeed,
                        textureScale: payload.textureScale,
                        strokeColor: payload.strokeColor, strokeWidth: payload.strokeWidth,
                        colorSequence: payload.colorSequence,
                        transparencySequence: payload.transparencySequence,
                        widthSequence: payload.widthSequence,
                        faceMode: payload.faceMode, segments: payload.segments,
                        curved: payload.curved, curveHeight: payload.curveHeight,
                        attachOffset: payload.attachOffset, ignoreDepth: payload.ignoreDepth,
                    });
                } else if (payload.kind === 'trail') {
                    id = bm.addTrail(payload.ref, {
                        color: payload.color, width: payload.width,
                        lifetime: payload.lifetime,
                    });
                }
                if (id == null) {
                    this._log('error', 'не удалось создать ' + payload.kind);
                } else if (payload.localRef) {
                    if (!this._fxLocalToReal) this._fxLocalToReal = new Map();
                    this._fxLocalToReal.set(payload.localRef, id);
                }
            }
            return;
        }
        if (cmd === 'fx.beamColor') {
            const fid = this._resolveFxId(payload?.ref);
            if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color);
            return;
        }
        // === Задача 08: стрелка-указатель + расширенное управление лучом ===
        if (cmd === 'fx.createPointer') {
            const bm = this.scene3d?.beamManager;
            if (bm && payload) {
                const id = bm.addPointer({
                    from: payload.from, to: payload.to, preset: payload.preset,
                    color: payload.color, texture: payload.texture,
                    customTextureUrl: payload.customTextureUrl,
                    textureSpeed: payload.textureSpeed, width: payload.width,
                    strokeColor: payload.strokeColor, colorSequence: payload.colorSequence,
                    curved: payload.curved, curveHeight: payload.curveHeight,
                    faceMode: payload.faceMode, attachOffset: payload.attachOffset,
                });
                if (id == null) {
                    this._log('error', 'не удалось создать стрелку-указатель');
                } else if (payload.localRef) {
                    if (!this._fxLocalToReal) this._fxLocalToReal = new Map();
                    this._fxLocalToReal.set(payload.localRef, id);
                }
            }
            return;
        }
        if (cmd === 'fx.pointerTarget') {
            const fid = this._resolveFxId(payload?.ref);
            if (fid != null) this.scene3d?.beamManager?.setPointerTarget(fid, payload?.to);
            return;
        }
        if (cmd === 'fx.pointerUpdate') {
            const fid = this._resolveFxId(payload?.ref);
            const bm = this.scene3d?.beamManager;
            if (fid != null && bm) {
                const o = payload?.opts || {};
                if (o.preset) bm.applyPointerPreset(fid, o.preset);
                bm.updateBeam(fid, o);
            }
            return;
        }
        if (cmd === 'fx.beamUpdate') {
            const fid = this._resolveFxId(payload?.ref);
            if (fid != null) this.scene3d?.beamManager?.updateBeam(fid, payload?.opts || {});
            return;
        }
        if (cmd === 'fx.beamVisible') {
            const fid = this._resolveFxId(payload?.ref);
            if (fid != null) this.scene3d?.beamManager?.setVisible(fid, payload?.visible !== false);
            return;
        }
        if (cmd === 'fx.beamEndpoints') {
            const fid = this._resolveFxId(payload?.ref);
            if (fid != null) {
                this.scene3d?.beamManager?.setBeamEndpoints(
                    fid, payload?.from, payload?.to);
            }
            return;
        }
        if (cmd === 'fx.remove') {
            const fid = this._resolveFxId(payload?.ref);
            if (fid != null) this.scene3d?.beamManager?.remove(fid);
            return;
        }
        // === Звук — game.sound.* (Фаза 5.5) ===
        // Пользовательский звук из библиотеки проекта (Фаза 5.5).
        // Встроенные пресеты ({name} без soundId) обрабатывает старый
        // обработчик ниже — здесь только {soundId}.
        if (cmd === 'sound.play' && payload && typeof payload.soundId === 'string') {
            const sm = this.scene3d?.soundManager;
            if (sm && this.scene3d?.soundLibrary?.count() > 0) {
                // attachRef может быть локальным ref от scene.spawn — резолвим.
                let attachRef = payload.attachRef;
                if (typeof attachRef === 'string' && attachRef !== 'player'
                    && this._localToReal?.has(attachRef)) {
                    attachRef = this._localToReal.get(attachRef);
                }
                const instId = sm.play(payload.soundId, {
                    volume: payload.volume,
                    loop: payload.loop,
                    at: payload.at,
                    attachRef,
                });
                if (instId != null && payload.localRef) {
                    if (!this._soundLocalToReal) this._soundLocalToReal = new Map();
                    this._soundLocalToReal.set(payload.localRef, instId);
                }
            }
            return;
        }
        if (cmd === 'sound.stop') {
            const ref = payload?.ref;
            if (ref != null && this.scene3d?.soundManager) {
                const instId = this._soundLocalToReal?.has(ref)
                    ? this._soundLocalToReal.get(ref) : Number(ref);
                if (Number.isFinite(instId)) {
                    this.scene3d.soundManager.stopSound(instId);
                }
            }
            return;
        }
        // === Tool / инвентарь API (Фаза 4.2) ===
        if (cmd === 'inventory.give') {
            // payload: { kind, modelTypeId, name, params }
            const inv = this.scene3d?.inventory;
            if (inv && payload) {
                const idx = inv.add({
                    kind: payload.kind || 'item',
                    modelTypeId: payload.modelTypeId || null,
                    name: payload.name || 'Предмет',
                    params: payload.params || {},
                });
                if (idx < 0) {
                    this._log('error', 'инвентарь полон — предмет не добавлен');
                } else if (payload.equip) {
                    // Сразу сделать активным и снарядить (для giveTool).
                    inv.setActive(idx);
                    const item = inv.slots[idx];
                    if (item && item.kind === 'weapon' && this.scene3d?.weapons) {
                        try { this.scene3d.weapons.equip(item); } catch (e) {}
                    }
                }
            }
            return;
        }
        // === Задача 44: drag-drop инвентарь (invUI) ===
        if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
        if (cmd === 'inv2.add') { try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {} return; }
        if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
        if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
        if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
        if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
        if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
        if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }

        if (cmd === 'inventory.remove') {
            // payload: { modelTypeId? , name? } — убрать первый совпавший слот.
            const inv = this.scene3d?.inventory;
            if (inv && payload) {
                const slots = inv.slots;
                for (let i = 0; i < slots.length; i++) {
                    const s = slots[i];
                    if (!s) continue;
                    const matchModel = payload.modelTypeId && s.modelTypeId === payload.modelTypeId;
                    const matchName = payload.name && s.name === payload.name;
                    if (matchModel || matchName) {
                        // Если убираем активное оружие — снять модель из руки.
                        if (i === inv.activeIndex && this.scene3d?.weapons) {
                            try { this.scene3d.weapons.unequip(); } catch (e) {}
                        }
                        inv.removeSlot(i);
                        break;
                    }
                }
            }
            return;
        }
        if (cmd === 'inventory.clear') {
            const inv = this.scene3d?.inventory;
            if (inv) {
                try { this.scene3d?.weapons?.unequip(); } catch (e) {}
                inv.clear();
            }
            return;
        }
        // === Мультиплеер-API: общее состояние комнаты (Фаза 4.3) ===
        if (cmd === 'room.set') {
            // payload: { key, value }
            if (payload && typeof payload.key === 'string') {
                if (!this._roomState) this._roomState = {};
                const changed = this._roomState[payload.key] !== payload.value;
                this._roomState[payload.key] = payload.value;
                // Если есть Colyseus-комната — отправляем серверу (он
                // обновит общее state; серверная схема — отдельная задача).
                try {
                    this.scene3d?._mpSync?.room?.send?.('scriptRoomSet', {
                        key: payload.key, value: payload.value,
                    });
                } catch (e) { /* ignore */ }
                // Локально сразу рассылаем событие изменения всем скриптам.
                if (changed) {
                    this.routeGlobalEvent('roomChange', {
                        key: payload.key, value: payload.value,
                    });
                }
            }
            return;
        }
        if (cmd === 'mp.sendTo') {
            // payload: { sessionId, name, data } — адресное сообщение игроку.
            if (payload) {
                const mp = this.scene3d?._mpSync;
                if (mp && mp.room && typeof mp.room.send === 'function') {
                    // С комнатой — через сервер (релей по sessionId).
                    try {
                        mp.room.send('scriptMessage', {
                            to: payload.sessionId,
                            name: payload.name,
                            data: payload.data,
                        });
                    } catch (e) { /* ignore */ }
                } else if (payload.sessionId === 'local') {
                    // Single-player: сообщение «себе» — доставляем сразу.
                    this.routeGlobalEvent('mpMessage', {
                        from: 'local', name: payload.name, data: payload.data,
                    });
                }
            }
            return;
        }
        // === Команды / Teams (Фаза 4.4) ===
        if (cmd === 'teams.create') {
            // payload: { name, color }
            if (payload && typeof payload.name === 'string' && payload.name) {
                if (!this._teams) this._teams = new Map();
                this._teams.set(payload.name, {
                    name: payload.name,
                    color: typeof payload.color === 'string' ? payload.color : '#888888',
                });
            }
            return;
        }
        if (cmd === 'teams.remove') {
            if (payload && this._teams) {
                this._teams.delete(payload.name);
                // Если игрок был в этой команде — сбрасываем.
                if (this._localPlayerTeam === payload.name) {
                    this._localPlayerTeam = null;
                }
            }
            return;
        }
        if (cmd === 'player.setTeam') {
            // payload: { team } — null/'' убирает команду.
            const t = payload?.team;
            let applied = null;
            if (t == null || t === '') {
                this._localPlayerTeam = null;
                applied = '';
            } else if (typeof t === 'string') {
                // Назначаем только если команда существует.
                if (this._teams?.has(t)) {
                    this._localPlayerTeam = t;
                    applied = t;
                } else {
                    this._log('error', 'команда не создана: ' + t);
                }
            }
            // С Colyseus-комнатой — синхронизируем команду на сервер,
            // чтобы остальные игроки видели её в Player.team.
            if (applied != null) {
                try {
                    this.scene3d?._mpSync?.room?.send?.('setTeam', { team: applied });
                } catch (e) { /* ignore */ }
            }
            return;
        }
        if (cmd === 'player.setSpeed') {
            const player = this.scene3d?.player;
            if (player) {
                const m = Number(payload?.mul);
                if (Number.isFinite(m) && m > 0) player._speedMul = m;
            }
            return;
        }
        // Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md:
        // game.player.equipAccessory(itemId) — надеть аксессуар прямо
        // из скрипта игры (например выдать всем хеллоуинскую шапку при
        // спавне). itemId — числовой id из rublox_items.
        // Бэк фильтрует только published — на сервере ничего не настроишь.
        if (cmd === 'player.equipAccessory') {
            const player = this.scene3d?.player;
            const itemId = Number(payload?.itemId);
            if (!player || !Number.isFinite(itemId) || itemId <= 0) return;
            (async () => {
                try {
                    // Грузим item через публичный catalog (только published)
                    const resp = await fetch(`/api-storys/rublox/catalog/${itemId}`);
                    if (!resp.ok) return;
                    const item = await resp.json();
                    if (item && typeof player.equipAccessory === 'function') {
                        await player.equipAccessory(item);
                    }
                } catch (e) {
                    // eslint-disable-next-line no-console
                    console.warn('[GameRuntime] equipAccessory failed', e);
                }
            })();
            return;
        }
        if (cmd === 'player.unequipSlot') {
            const player = this.scene3d?.player;
            const slot = String(payload?.slot || '');
            if (player && slot && typeof player.unequipSlot === 'function') {
                player.unequipSlot(slot);
            }
            return;
        }
        if (cmd === 'player.unequipAll') {
            const player = this.scene3d?.player;
            if (player && typeof player.unequipAll === 'function') {
                player.unequipAll();
            }
            return;
        }
        if (cmd === 'player.setJumpPower') {
            const player = this.scene3d?.player;
            if (player) {
                const m = Number(payload?.mul);
                if (Number.isFinite(m) && m > 0) player._jumpPowerMul = m;
            }
            return;
        }
        if (cmd === 'player.setGravityMul') {
            // Множитель гравитации (для GD-стиля нужно ~1.23 — поднимает 22 до 27).
            // Не зависит от gravityDir — работает в обоих направлениях.
            const player = this.scene3d?.player;
            if (player) {
                const m = Number(payload?.mul);
                if (Number.isFinite(m) && m > 0) player._gravityMul = m;
            }
            return;
        }
        if (cmd === 'player.setShipMode') {
            // GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль).
            const player = this.scene3d?.player;
            if (player) player._shipMode = !!payload?.enabled;
            return;
        }
        if (cmd === 'player.setUfoMode') {
            // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе.
            const player = this.scene3d?.player;
            if (player) player._ufoMode = !!payload?.enabled;
            return;
        }
        if (cmd === 'player.setWaveMode') {
            // GD-гейммод Wave: движение под ±45° (Space зажат — вверх, отпущен — вниз).
            const player = this.scene3d?.player;
            if (player) player._waveMode = !!payload?.enabled;
            return;
        }
        if (cmd === 'player.setVy') {
            // Прямое задание vy (для трамплинов, jump orb, boost-зон).
            const player = this.scene3d?.player;
            if (player) {
                const v = Number(payload?.vy);
                if (Number.isFinite(v)) player._vy = v;
            }
            return;
        }
        if (cmd === 'player.setRobotMode') {
            // GD-гейммод Robot: variable-jump (высота = длительности удержания Space).
            const player = this.scene3d?.player;
            if (player) {
                player._robotMode = !!payload?.enabled;
                if (!player._robotMode) player._robotBoostLeft = 0;
            }
            return;
        }
        if (cmd === 'player.setDoubleJump') {
            const player = this.scene3d?.player;
            if (player) player._doubleJumpEnabled = !!payload?.enabled;
            return;
        }
        if (cmd === 'player.playAnimation') {
            const player = this.scene3d?.player;
            if (player && typeof player.playEmote === 'function') {
                const ok = player.playEmote(payload?.name);
                if (!ok) {
                    this._log('error', 'playAnimation: эмоция не найдена — '
                        + payload?.name + ' (доступно: wave, dance, cheer, sit)');
                }
            }
            return;
        }
        if (cmd === 'player.stopAnimation') {
            const player = this.scene3d?.player;
            if (player && typeof player.stopEmote === 'function') player.stopEmote();
            return;
        }
        if (cmd === 'player.setIceFriction') {
            const player = this.scene3d?.player;
            if (player) {
                const v = Number(payload?.value);
                if (Number.isFinite(v)) {
                    player._iceFriction = Math.max(0, Math.min(1, v));
                }
            }
            return;
        }
        if (cmd === 'player.setAutoRun') {
            const player = this.scene3d?.player;
            if (player) {
                const s = Number(payload?.speed);
                if (Number.isFinite(s)) player._autoRunSpeed = Math.max(0, s);
            }
            return;
        }
        if (cmd === 'player.boostJump') {
            const player = this.scene3d?.player;
            if (player) {
                const s = Number(payload?.strength);
                if (Number.isFinite(s) && s > 0) {
                    // boostJump учитывает текущую гравитацию: при flipped — толкает к потолку (vy<0)
                    const gDir = player._gravityDir || 1;
                    const base = player.JUMP_VELOCITY * (player._jumpPowerMul || 1);
                    player._vy = base * s * gDir;
                }
            }
            return;
        }
        if (cmd === 'player.flipGravity') {
            // Меняет направление гравитации (как blue orb в GD): +1 ↔ -1
            const player = this.scene3d?.player;
            if (player) {
                player._gravityDir = (player._gravityDir || 1) > 0 ? -1 : 1;
                // Сбрасываем "second jump used" чтобы после флипа доступен прыжок
                player._doubleJumpUsed = false;
            }
            return;
        }
        if (cmd === 'player.setGravityDir') {
            // Явно задать направление: dir=1 (вниз) или -1 (вверх).
            const player = this.scene3d?.player;
            if (player) {
                const d = Number(payload?.dir);
                if (d === 1 || d === -1) {
                    player._gravityDir = d;
                    player._doubleJumpUsed = false;
                }
            }
            return;
        }
        if (cmd === 'player.getGravityDir') {
            // Возвращает текущее значение через broadcast-style "reply"
            // Скрипту это нужно через геттер game.player.gravityDir — см. shim в Worker
            return;
        }
        // === HUD / Input / App ===
        if (cmd === 'hud.setVisible') {
            try {
                const v = !!payload?.visible;
                this.scene3d?._setStdHudVisible?.(v);
            } catch (e) {}
            return;
        }
        if (cmd === 'hud.setHotbarVisible') {
            try { this.scene3d?._setHotbarVisible?.(!!payload?.visible); } catch (e) {}
            return;
        }
        if (cmd === 'hud.setHpVisible') {
            try { this.scene3d?._setHpVisible?.(!!payload?.visible); } catch (e) {}
            return;
        }
        if (cmd === 'input.setCursorMode') {
            try {
                const mode = payload?.mode === 'ui' ? 'ui' : 'game';
                const player = this.scene3d?.player;
                if (player?.setUiCursorMode) {
                    player.setUiCursorMode(mode === 'ui');
                    if (mode === 'ui') {
                        try { document.exitPointerLock?.(); } catch (e) {}
                        // Подписываемся на mouse-события и транслируем в Worker.
                        if (player.setUiMouseMoveCallback) {
                            let lastMM = 0;
                            player.setUiMouseMoveCallback((x, y) => {
                                const now = performance.now();
                                if (now - lastMM < 20) return;
                                lastMM = now;
                                this.routeGlobalEvent('mouseMove', { x, y });
                            });
                        }
                        if (player.setUiMouseDownCallback) {
                            player.setUiMouseDownCallback((x, y) => {
                                this.routeGlobalEvent('mouseDown', { x, y });
                            });
                        }
                        if (player.setUiMouseUpCallback) {
                            player.setUiMouseUpCallback((x, y) => {
                                this.routeGlobalEvent('mouseUp', { x, y });
                            });
                        }
                    } else if (player._requestPointerLockSafe) {
                        // Отписываемся при возврате в game-режим
                        if (player.setUiMouseMoveCallback) {
                            player.setUiMouseMoveCallback(null);
                        }
                        if (player.setUiMouseDownCallback) {
                            player.setUiMouseDownCallback(null);
                        }
                        if (player.setUiMouseUpCallback) {
                            player.setUiMouseUpCallback(null);
                        }
                        try { player._requestPointerLockSafe(); } catch (e) {}
                    }
                    // Сообщить редактору/плееру чтобы синхронизировать UI-state
                    try { this.scene3d?._onCursorModeChange?.(mode); } catch (e) {}
                }
            } catch (e) {}
            return;
        }
        if (cmd === 'app.exit') {
            try {
                // На Майнкрафтия-плеере это шло на свой роут /kubikon3d
                // (лента игр). В выделенном плеере (player.rublox.pro)
                // таких роутов нет — переходим на ленту Рублокса.
                window.location.assign(this._resolveExternalUrl('/kubikon3d'));
            } catch (e) {}
            return;
        }
        if (cmd === 'app.navigate') {
            try {
                const url = String(payload?.url || '');
                if (url) window.location.assign(this._resolveExternalUrl(url));
            } catch (e) {}
            return;
        }
        // === Универсальное хранилище сейвов (game.save.*) ===
        if (cmd === 'save.get') {
            this._saveGet(scriptId, payload);
            return;
        }
        if (cmd === 'save.getAll') {
            this._saveGetAll(scriptId, payload);
            return;
        }
        if (cmd === 'save.set') {
            this._saveSet(payload);
            return;
        }
        if (cmd === 'save.merge') {
            this._saveMerge(payload);
            return;
        }
        if (cmd === 'save.leaderboard') {
            this._saveLeaderboard(scriptId, payload);
            return;
        }
        if (cmd === 'economy.reward') {
            this._economyReward(scriptId, payload);
            return;
        }
        if (cmd === 'economy.dailyCheck') {
            this._economyDailyCheck(scriptId, payload);
            return;
        }
        if (cmd === 'economy.getBalance') {
            this._economyGetBalance(scriptId, payload);
            return;
        }
        if (cmd === 'economy.spend') {
            this._economySpend(scriptId, payload);
            return;
        }
        if (cmd === 'camera.shake') {
            const player = this.scene3d?.player;
            if (player) {
                const amp = Number(payload?.amp);
                const dur = Number(payload?.dur);
                if (Number.isFinite(amp) && Number.isFinite(dur) && amp > 0 && dur > 0) {
                    player._cameraShakeAmp = amp;
                    player._cameraShakeLeft = dur;
                }
            }
            return;
        }
        // === Камера: FOV, привязка, катсцены (Фаза 5.7) ===
        if (cmd === 'camera.fov') {
            this.scene3d?.player?.setCameraFov?.(payload?.degrees);
            return;
        }
        if (cmd === 'camera.focus') {
            // payload: { ref, distance, height } — следить за объектом.
            const player = this.scene3d?.player;
            if (player && payload && typeof payload.ref === 'string') {
                const ref = payload.ref;
                // getTarget резолвит позицию объекта каждый кадр.
                const getTarget = () => {
                    const tgt = this._resolveTweenTarget(ref);
                    if (tgt && tgt.data) {
                        return { x: tgt.data.x, y: tgt.data.y, z: tgt.data.z };
                    }
                    return null;
                };
                player.cameraFocusOn(getTarget, {
                    distance: payload.distance, height: payload.height,
                });
            }
            return;
        }
        if (cmd === 'camera.cutscene') {
            // payload: { points: [{x,y,z}], lookAt: [{x,y,z}], segDuration }
            const player = this.scene3d?.player;
            if (player && payload && Array.isArray(payload.points)) {
                player.cameraCutscene(
                    payload.points, payload.lookAt, payload.segDuration,
                    // onDone — событие скрипту.
                    () => this.routeGlobalEvent('cutsceneDone', {}),
                );
            }
            return;
        }
        if (cmd === 'camera.reset') {
            this.scene3d?.player?.cameraReset?.();
            return;
        }
        if (cmd === 'player.setInputBlocked') {
            // Задача 13: блок управления (главное меню — игрок наблюдатель).
            this.scene3d?.player?.setInputBlocked?.(!!payload?.blocked);
            return;
        }
        if (cmd === 'player.setSkinVisible') {
            const player = this.scene3d?.player;
            if (player) {
                const v = !!payload?.visible;
                player._skinVisibleScripted = v;
                // Применяем сразу — но также флаг будет применяться каждый
                // кадр в _tick (на случай если меши ещё не загружены сейчас).
                if (Array.isArray(player._modelMeshes)) {
                    for (const m of player._modelMeshes) {
                        try { m.setEnabled(v); } catch (e) {}
                    }
                }
            }
            return;
        }
        if (cmd === 'player.setCameraMode') {
            const player = this.scene3d?.player;
            if (player && typeof payload?.mode === 'string') {
                const valid = ['first', 'third', 'front', 'sideview'];
                if (valid.includes(payload.mode)) {
                    player._cameraMode = payload.mode;
                    try { player._applyCameraMode?.(); } catch (e) {}
                }
            }
            return;
        }
        if (cmd === 'player.setCrouch') {
            const player = this.scene3d?.player;
            if (player) {
                const want = !!payload?.enabled;
                player._scriptForcedCrouch = want;
                if (want !== player._crouching) {
                    player._crouching = want;
                    const newHalfH = want ? player.HALF_H_CROUCH : player.HALF_H_NORMAL;
                    // КРИТИЧНО: _pos — центр капсулы. При смене HALF_H
                    // центр надо сдвинуть на ту же дельту, иначе «низ ног»
                    // (_pos.y - HALF_H) меняется и персонажа подкидывает
                    // вверх при приседе. Сдвигаем — низ ног остаётся на месте.
                    const dH = newHalfH - player.HALF_H;
                    player.HALF_H = newHalfH;
                    if (player._pos) player._pos.y += dH;
                }
            }
            return;
        }
        if (cmd === 'player.setFacing') {
            // Развернуть модель игрока на угол yaw (радианы). Полезно
            // в кат-сценах, когда игрок стоит лицом куда нужно.
            const player = this.scene3d?.player;
            if (player) {
                const yaw = Number(payload?.yaw);
                if (Number.isFinite(yaw)) {
                    player._modelYaw = yaw;
                    if (player._modelRoot) player._modelRoot.rotation.y = yaw;
                }
            }
            return;
        }
        if (cmd === 'player.emote') {
            // Проиграть эмоцию персонажа (wave/dance/cheer/sit/paint).
            // Работает только для R15-скинов.
            const player = this.scene3d?.player;
            if (player && typeof player.playEmote === 'function') {
                const name = payload?.name;
                if (typeof name === 'string') {
                    try { player.playEmote(name); } catch (e) { /* ignore */ }
                }
            }
            return;
        }
        if (cmd === 'player.stopEmote') {
            const player = this.scene3d?.player;
            if (player && typeof player.stopEmote === 'function') {
                try { player.stopEmote(); } catch (e) { /* ignore */ }
            }
            return;
        }
        if (cmd === 'timer.start' || cmd === 'timer.stop' || cmd === 'timer.submit') {
            // Делегируем в scene3d — у него есть колбэки для UI/API
            const fn = this.scene3d?.[cmd === 'timer.start' ? '_timerStart'
                : cmd === 'timer.stop' ? '_timerStop' : '_timerSubmit'];
            if (typeof fn === 'function') {
                try { fn.call(this.scene3d); } catch (e) { /* ignore */ }
            }
            return;
        }
        if (cmd === 'self.move') {
            this._applySelfMove(payload);
            return;
        }
        if (cmd === 'scene.rotate') {
            try {
                const ry = Number(payload?.rotationY);
                if (!Number.isFinite(ry)) return;
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                const rid = this._resolvePrimitiveId(payload?.id);
                const data = rid != null ? pm.instances.get(rid) : null;
                if (data) {
                    data.rotationY = ry;
                    if (data.mesh?.rotation) {
                        data.mesh.rotation.y = ry;
                        if (data._worldMatrixFrozen) {
                            try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
                            data._worldMatrixFrozen = false;
                        }
                    }
                }
                // snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у,
                // только rotationY обновился, для скрипта это прозрачно.
            } catch (e) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] scene.rotate failed', e);
            }
            return;
        }
        if (cmd === 'scene.setRotation') {
            try {
                const rx = Number(payload?.rx);
                const ry = Number(payload?.ry);
                const rz = Number(payload?.rz);
                if (!Number.isFinite(rx) || !Number.isFinite(ry) || !Number.isFinite(rz)) return;
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                const rid = this._resolvePrimitiveId(payload?.id);
                const data = rid != null ? pm.instances.get(rid) : null;
                if (data) {
                    data.rotationX = rx;
                    data.rotationY = ry;
                    data.rotationZ = rz;
                    if (data.mesh?.rotation) {
                        data.mesh.rotation.x = rx;
                        data.mesh.rotation.y = ry;
                        data.mesh.rotation.z = rz;
                        if (data._worldMatrixFrozen) {
                            try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
                            data._worldMatrixFrozen = false;
                        }
                    }
                }
            } catch (e) {
                console.warn('[GameRuntime] scene.setRotation failed', e);
            }
            return;
        }
        if (cmd === 'scene.setCollide') {
            try {
                const canCollide = !!payload?.canCollide;
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                const rid = this._resolvePrimitiveId(payload?.id);
                const data = rid != null ? pm.instances.get(rid) : null;
                if (data) {
                    data.canCollide = canCollide;
                    if (data.mesh?.metadata) data.mesh.metadata.canCollide = canCollide;
                    this.scene3d?.physics?.setSpatialDirty?.();
                }
            } catch (e) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] scene.setCollide failed', e);
            }
            return;
        }
        // === Небо и атмосфера (задача 16) ===
        // === Лидерборды и достижения (задача 20) ===
        if (cmd === 'leaderstats.define') {
            try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
            return;
        }
        if (cmd === 'leaderstats.set') {
            try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
            return;
        }
        if (cmd === 'leaderstats.add') {
            try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
            return;
        }
        if (cmd === 'achievements.define') {
            try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
            return;
        }
        if (cmd === 'achievements.unlock') {
            try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
            return;
        }
        if (cmd === 'achievements.bindToStat') {
            try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
            return;
        }
        if (cmd === 'achievements.setButtonVisible') {
            try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
            return;
        }
        if (cmd === 'achievements.openPage') {
            try { this.scene3d?.achievements?.openPage(); } catch (e) {}
            return;
        }

        if (cmd === 'scene.setSkybox') {
            try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
            return;
        }
        if (cmd === 'scene.setClouds') {
            try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
            return;
        }
        if (cmd === 'scene.setFog') {
            try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
            return;
        }
        if (cmd === 'scene.skyboxFadeTo') {
            try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
            return;
        }
        if (cmd === 'scene.skyboxSunDir') {
            try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
            return;
        }

        if (cmd === 'scene.setScale') {
            try {
                const k = Number(payload?.scale);
                if (!Number.isFinite(k) || k < 0) return;
                const pm = this.scene3d?.primitiveManager;
                const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
                const data = (pm && rid != null) ? pm.instances.get(rid) : null;
                if (data?.mesh) {
                    if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
                    data.mesh.scaling.set(k, k, k);
                }
            } catch (e) {}
            return;
        }

        if (cmd === 'scene.setColor') {
            try {
                const color = payload?.color;
                if (typeof color !== 'string') return;
                // Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager.
                const ref = payload?.id ?? payload?.ref;
                if (typeof ref === 'string' && ref.startsWith('block:')) {
                    const parts = ref.slice(6).split(',').map(Number);
                    if (parts.length === 3 && parts.every(Number.isFinite)) {
                        this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color);
                    }
                    return;
                }
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
                const data = rid != null ? pm.instances.get(rid) : null;
                if (data) {
                    data.color = color;
                    if (data.mesh?.material) {
                        const c = Color3.FromHexString(color);
                        data.mesh.material.diffuseColor = c;
                        // Если материал neon — обновляем emissive тоже
                        if (data.material === 'neon') {
                            data.mesh.material.emissiveColor = c;
                        }
                        if (data.material === 'studs') {
                            data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45);
                        }
                    }
                }
            } catch (e) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] scene.setColor failed', e);
            }
            return;
        }
        if (cmd === 'scene.setOpacity') {
            try {
                const id = this._resolvePrimitiveId(payload?.ref);
                const pm = this.scene3d?.primitiveManager;
                if (id != null && pm) pm.updateInstance(id, { opacity: payload.opacity });
            } catch (e) {
                console.warn('[GameRuntime] scene.setOpacity failed', e);
            }
            return;
        }
        if (cmd === 'scene.setScale') {
            try {
                const id = this._resolvePrimitiveId(payload?.ref);
                const pm = this.scene3d?.primitiveManager;
                if (id != null && pm) {
                    pm.updateInstance(id, { sx: payload.sx, sy: payload.sy, sz: payload.sz });
                }
            } catch (e) {
                console.warn('[GameRuntime] scene.setScale failed', e);
            }
            return;
        }
        if (cmd === 'scene.setMaterial') {
            try {
                const id = this._resolvePrimitiveId(payload?.ref);
                const pm = this.scene3d?.primitiveManager;
                if (id != null && pm) pm.updateInstance(id, { material: payload.material });
            } catch (e) {
                console.warn('[GameRuntime] scene.setMaterial failed', e);
            }
            return;
        }
        if (cmd === 'scene.clone') {
            try {
                const id = this._resolvePrimitiveId(payload?.ref);
                const pm = this.scene3d?.primitiveManager;
                if (id == null || !pm) return;
                const src = pm.instances.get(id);
                if (!src) return;
                const newId = pm.addInstance(src.type, {
                    x: (src.x || 0) + (Number(payload.dx) || 0),
                    y: (src.y || 0) + (Number(payload.dy) || 0),
                    z: (src.z || 0) + (Number(payload.dz) || 0),
                    sx: src.sx, sy: src.sy, sz: src.sz,
                    color: src.color, material: src.material,
                    rotationY: src.rotationY,
                });
                if (newId != null) {
                    if (!this._localToReal) this._localToReal = new Map();
                    this._localToReal.set(payload.newRef, 'primitive:' + newId);
                    this.scheduleSceneSnapshot();
                }
            } catch (e) {
                console.warn('[GameRuntime] scene.clone failed', e);
            }
            return;
        }
        if (cmd === 'self.registerInteract') {
            try {
                const t = payload?.target;
                if (!t) return;
                // ref объекта-носителя скрипта
                const ref = (t.kind && (t.ref ?? t.id) != null)
                    ? (t.kind + ':' + (t.ref ?? t.id)) : null;
                if (!ref) return;
                // не дублируем — один объект = одна запись
                if (!this._interactables.some(it => it.ref === ref)) {
                    this._interactables.push({
                        ref,
                        target: t,
                        text: payload.text || 'Взаимодействовать',
                        distance: Number(payload.distance) || 4,
                        key: payload.key || 'e',
                        holdDuration: Number(payload.holdDuration) || 0,
                        isInst: false,
                    });
                }
            } catch (e) {
                console.warn('[GameRuntime] self.registerInteract failed', e);
            }
            return;
        }
        if (cmd === 'inst.registerInteract') {
            try {
                const ref = payload?.ref;
                if (ref && !this._interactables.some(it => it.ref === ref)) {
                    this._interactables.push({
                        ref, target: null,
                        text: payload.text || 'Взаимодействовать',
                        distance: Number(payload.distance) || 4,
                        key: payload.key || 'e',
                        holdDuration: Number(payload.holdDuration) || 0,
                        isInst: true,
                    });
                }
            } catch (e) { /* ignore */ }
            return;
        }
        if (cmd === 'scene.setLabel') {
            try {
                const ref = payload?.ref;
                const text = payload?.text;
                if (typeof ref !== 'string') return;
                // ленивое создание менеджера меток
                if (!this.scene3d._labelManager) {
                    this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
                }
                const lm = this.scene3d._labelManager;
                // резолвим меш объекта (примитив или модель)
                const tgt = this._resolveTweenTarget(ref);
                const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode);
                if (mesh) {
                    lm.setLabel(ref, mesh, text, payload?.opts || {});
                }
            } catch (e) {
                console.warn('[GameRuntime] scene.setLabel failed', e);
            }
            return;
        }
        if (cmd === 'scene.clearLabel') {
            try {
                const lm = this.scene3d?._labelManager;
                if (lm && typeof payload?.ref === 'string') lm.clearLabel(payload.ref);
            } catch (e) {
                console.warn('[GameRuntime] scene.clearLabel failed', e);
            }
            return;
        }
        if (cmd === 'scene.setData') {
            try {
                const { ref, key, value } = payload || {};
                if (typeof ref !== 'string' || typeof key !== 'string') return;
                if (!this._objectData[ref]) this._objectData[ref] = {};
                this._objectData[ref][key] = value;
                this.scheduleDataSnapshot();
            } catch (e) {
                console.warn('[GameRuntime] scene.setData failed', e);
            }
            return;
        }
        // === Теги объектов (Фаза 5.6) — game.scene.tag/untag/getTagged ===
        // Теги хранятся как массив в _objectData[ref].__tags — переиспользуем
        // готовый канал dataSnapshot, отдельная синхронизация не нужна.
        if (cmd === 'scene.tag' || cmd === 'scene.untag') {
            try {
                const { ref, tag } = payload || {};
                if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return;
                if (!this._objectData[ref]) this._objectData[ref] = {};
                const cur = Array.isArray(this._objectData[ref].__tags)
                    ? this._objectData[ref].__tags : [];
                this._objectData[ref].__tags = cmd === 'scene.tag'
                    ? (cur.includes(tag) ? cur : [...cur, tag])
                    : cur.filter(t => t !== tag);
                this.scheduleDataSnapshot();
            } catch (e) {
                console.warn('[GameRuntime] scene.tag failed', e);
            }
            return;
        }
        // === Collision groups (Фаза 5.9) — проходимость объекта/группы ===
        // physics.passThrough — игрок проходит сквозь объект (объект виден).
        // target: ref одного объекта ИЛИ тег (тогда применяется ко всей
        // группе объектов с этим тегом — теги = collision groups).
        if (cmd === 'physics.passThrough') {
            try {
                const { target, on } = payload || {};
                if (typeof target !== 'string' || !target) return;
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                // canCollide = !on (passThrough=true → коллизия выключена).
                const canCollide = !on;
                // Собираем список ref: либо один объект, либо все с тегом.
                let refs;
                if (target.indexOf(':') >= 0) {
                    refs = [target];   // похоже на ref объекта
                } else {
                    // Тег — все объекты с ним.
                    refs = [];
                    for (const r of Object.keys(this._objectData)) {
                        const bag = this._objectData[r];
                        if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(target)) {
                            refs.push(r);
                        }
                    }
                }
                for (const r of refs) {
                    const rid = this._resolvePrimitiveId(r);
                    if (rid != null) pm.updateInstance(rid, { canCollide });
                }
                // Сбрасываем кэш spatial-grid физики — иначе grid до 50мс
                // держит старое состояние, и при возврате твёрдости (on=false)
                // UNSTUCK не видит стену, игрок застревает в ней.
                this.scene3d?.physics?.invalidateSpatialGrid?.();
            } catch (e) {
                console.warn('[GameRuntime] physics.passThrough failed', e);
            }
            return;
        }
        if (cmd === 'physics.setVelocity' || cmd === 'physics.applyImpulse') {
            try {
                const id = this._resolvePrimitiveId(payload?.ref);
                const pm = this.scene3d?.primitiveManager;
                const dm = this.scene3d?.dynamics;
                if (id == null || !pm || !dm) return;
                const data = pm.instances.get(id);
                if (!data) return;
                const isImpulse = cmd === 'physics.applyImpulse';
                const vx = isImpulse ? payload.ix : payload.vx;
                const vy = isImpulse ? payload.iy : payload.vy;
                const vz = isImpulse ? payload.iz : payload.vz;
                const ok = dm.applyToInstance(data, vx, vy, vz, isImpulse ? 'impulse' : 'set');
                if (!ok) {
                    this._log('error', cmd + ': объект закреплён (anchored) — '
                        + 'физика работает только для незакреплённых объектов');
                }
            } catch (e) {
                console.warn('[GameRuntime] ' + cmd + ' failed', e);
            }
            return;
        }
        if (cmd === 'physics.explode') {
            try {
                const { x, y, z, radius, damage, force } = payload || {};
                const r = Number(radius) || 3;
                // визуальный эффект взрыва
                this._handleCommand(scriptId, 'scene.particles', {
                    type: 'explosion', position: { x, y, z },
                    duration: 1.2, count: 2, color: null,
                });
                // урон игроку если в радиусе
                const player = this.scene3d?.player;
                if (player && Number(damage) > 0) {
                    const pp = player._pos || player.position;
                    if (pp) {
                        const dx = pp.x - x, dy = (pp.y || 0) - y, dz = pp.z - z;
                        if (dx*dx + dy*dy + dz*dz <= r*r) {
                            try { player.takeDamage(Number(damage), 'explosion'); } catch (e) {}
                        }
                    }
                }
                // убиваем мобов в радиусе
                const zm = this.scene3d?.zombieManager;
                if (zm && typeof zm.getMobsSnapshot === 'function') {
                    const mobs = zm.getMobsSnapshot();
                    for (const m of mobs) {
                        const dx = m.x - x, dy = (m.y || 0) - y, dz = m.z - z;
                        if (dx*dx + dy*dy + dz*dz <= r*r) {
                            try { zm.killById(m.id); } catch (e) {}
                        }
                    }
                }
            } catch (e) {
                console.warn('[GameRuntime] physics.explode failed', e);
            }
            return;
        }
        if (cmd === 'tween.start') {
            this._startTween(scriptId, payload);
            return;
        }
        if (cmd === 'tween.cancel') {
            const tid = payload?.tweenId;
            if (tid != null) {
                const i = this._tweens.findIndex(t => t.tweenId === tid && t.scriptId === scriptId);
                if (i >= 0) this._tweens.splice(i, 1);
            }
            return;
        }
        if (cmd === 'scene.setTexture') {
            // Установить динамическую текстуру примитива из dataURL.
            // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура).
            try {
                const dataUrl = payload?.dataUrl;
                if (typeof dataUrl !== 'string') return;
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                const rid = this._resolvePrimitiveId(payload?.id);
                if (rid != null) pm.setTexture(rid, dataUrl);
            } catch (e) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] scene.setTexture failed', e);
            }
            return;
        }
        // === AUDIO: GD-музыка и SFX ===
        if (cmd === 'audio.playSfx') {
            try {
                const am = this.scene3d?.gameAudioManager;
                if (am && payload?.name) am.playSfx(payload.name);
            } catch (e) {
                console.warn('[GameRuntime] audio.playSfx failed', e);
            }
            return;
        }
        if (cmd === 'audio.playMusic') {
            try {
                const am = this.scene3d?.gameAudioManager;
                if (am && payload?.trackId) am.playMusic(payload.trackId);
            } catch (e) {
                console.warn('[GameRuntime] audio.playMusic failed', e);
            }
            return;
        }
        if (cmd === 'audio.stopMusic') {
            try {
                const am = this.scene3d?.gameAudioManager;
                if (am) am.stopMusic();
            } catch (e) {
                console.warn('[GameRuntime] audio.stopMusic failed', e);
            }
            return;
        }
        if (cmd === 'audio.setMuted') {
            try {
                const am = this.scene3d?.gameAudioManager;
                if (am) am.setMuted(!!payload?.muted);
            } catch (e) {
                console.warn('[GameRuntime] audio.setMuted failed', e);
            }
            return;
        }
        if (cmd === 'scene.setVisible') {
            try {
                let kind = payload?.kind;
                let id = payload?.id;
                if ((kind == null || id == null) && typeof payload?.ref === 'string') {
                    const colon = payload.ref.indexOf(':');
                    if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); }
                }
                const visible = !!payload?.visible;
                if (id == null) return;
                if (kind === 'primitive') {
                    const pm = this.scene3d?.primitiveManager;
                    if (!pm) return;
                    const rid = this._resolvePrimitiveId(id);
                    const data = rid != null ? pm.instances.get(rid) : null;
                    if (data) {
                        data.visible = visible;
                        if (data.mesh) data.mesh.setEnabled(visible);
                    }
                } else if (kind === 'model') {
                    const mm = this.scene3d?.modelManager;
                    if (!mm) return;
                    let data = mm.instances.get(id);
                    if (!data && typeof id === 'string') {
                        const n = Number(id);
                        if (Number.isFinite(n)) data = mm.instances.get(n);
                    }
                    if (data) {
                        data.visible = visible;
                        if (data.rootMesh) data.rootMesh.setEnabled(visible);
                    }
                }
            } catch (e) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] scene.setVisible failed', e);
            }
            return;
        }
        if (cmd === 'scene.setFolderYaw') {
            try {
                const fm = this.scene3d?.folderManager;
                if (!fm) return;
                const name = payload?.folderName;
                const angle = Number(payload?.angle);
                const pivot = payload?.pivot;
                if (typeof name !== 'string' || !Number.isFinite(angle) || !pivot) return;
                const folder = fm.findByName(name);
                if (!folder) return;
                fm.setFolderYawY(folder.id, angle, pivot);
                this.scheduleSceneSnapshot();
            } catch (e) {
                // eslint-disable-next-line no-console
                console.warn('[GameRuntime] scene.setFolderYaw failed', e);
            }
            return;
        }
        if (cmd === 'self.delete') {
            this._applySelfDelete(payload);
            return;
        }
        if (cmd === 'scene.spawn') {
            this._applySceneSpawn(scriptId, payload);
            return;
        }
        if (cmd === 'scene.delete') {
            this._applySceneDelete(payload);
            return;
        }
        if (cmd === 'ui.set' || cmd === 'ui.flash' || cmd === 'ui.clear') {
            // Просто пробрасываем в onHud колбэк — UI на стороне React сам отрисует
            if (this._onHud) {
                try { this._onHud({ cmd, payload }); } catch (e) { /* ignore */ }
            }
            return;
        }
        if (cmd === 'sound.play') {
            this._playSound(payload);
            return;
        }
        if (cmd === 'scene.particles') {
            this._spawnParticles(payload);
            return;
        }
        if (cmd === 'mob.kill') {
            try {
                const id = Number(payload?.id);
                if (Number.isFinite(id) && this.scene3d?.zombieManager) {
                    this.scene3d.zombieManager.killById(id);
                }
            } catch (e) {
                this._log('error', 'mob.kill failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'gui.update') {
            // payload: { id, patch }
            try {
                let id = payload?.id;
                const patch = payload?.patch || {};
                if (typeof id !== 'string') return;
                // Резолвим локальный ref (тот что вернул gui.create) → реальный id
                if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id);
                this.scene3d?.updateGuiElement?.(id, patch);
                this.scheduleGuiSnapshot();
            } catch (e) {
                this._log('error', 'gui.update failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'gui.create') {
            try {
                const type = payload?.type;
                const opts = { ...(payload?.opts || {}) };
                const localRef = payload?.localRef;
                if (typeof type !== 'string') return;
                // Помечаем как созданный скриптом — чтобы НЕ попал в
                // сериализацию проекта (иначе автосейв сохранит его в БД
                // и после Stop он «вернётся» из сохранённого проекта).
                opts._scriptCreated = true;
                // Резолвим parentId если это локальный ref из предыдущего create
                if (opts.parentId && this._guiLocalToReal?.has(opts.parentId)) {
                    opts.parentId = this._guiLocalToReal.get(opts.parentId);
                }
                const realId = this.scene3d?.createGuiElement?.(type, opts);
                if (realId && localRef) {
                    if (!this._guiLocalToReal) this._guiLocalToReal = new Map();
                    if (!this._guiRealToLocal) this._guiRealToLocal = new Map();
                    this._guiLocalToReal.set(localRef, realId);
                    this._guiRealToLocal.set(realId, localRef);
                }
                this.scheduleGuiSnapshot();
            } catch (e) {
                this._log('error', 'gui.create failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'gui.remove') {
            try {
                let id = payload?.id;
                if (typeof id !== 'string') return;
                const localId = id;
                if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id);
                this.scene3d?.removeGuiElement?.(id);
                // Чистим mapping чтобы не утекало
                if (this._guiLocalToReal?.has(localId)) this._guiLocalToReal.delete(localId);
                this.scheduleGuiSnapshot();
            } catch (e) {
                this._log('error', 'gui.remove failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'broadcast') {
            // Рассылаем именованное сообщение всем sandbox'ам
            this.routeGlobalEvent('message', {
                name: String(payload?.name || ''),
                data: payload?.data ?? null,
            });
            return;
        }
        if (cmd === 'player.crosshair') {
            const type = String(payload?.type || 'none').toLowerCase();
            try { this.scene3d?.setCrosshair?.(type); } catch (e) { /* ignore */ }
            if (this._onCrosshair) {
                try { this._onCrosshair(type); } catch (e) { /* ignore */ }
            }
            return;
        }
        // === Задача 07: скины игрока ===
        if (cmd === 'player.setSkin') {
            const player = this.scene3d?.player;
            const slug = payload?.slug;
            if (player && typeof slug === 'string' && slug) {
                const typeId = this._resolveSkinTypeId(slug);
                // Помечаем доступным (setSkin неявно разблокирует).
                this._ensureSkinState();
                this._skinState.unlocked.add(slug);
                this._skinState.current = slug;
                // Асинхронная перезагрузка модели; по завершении шлём skinChanged.
                Promise.resolve(player.reloadSkin?.(typeId)).then(() => {
                    this.routeGlobalEvent?.('skinChanged', { slug });
                    try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {}
                }).catch((e) => {
                    this._log('error', 'setSkin failed: ' + (e?.message || e));
                });
            }
            return;
        }
        if (cmd === 'player.unlockSkin') {
            const slug = payload?.slug;
            if (typeof slug === 'string' && slug) {
                this._ensureSkinState();
                this._skinState.unlocked.add(slug);
                this.routeGlobalEvent?.('skinUnlocked', { slug });
            }
            return;
        }
        if (cmd === 'player.openSkinShop') {
            this._ensureSkinState();
            try { this.scene3d?._openSkinShop?.(); } catch (e) {}
            return;
        }
        if (cmd === 'player.closeSkinShop') {
            try { this.scene3d?._closeSkinShop?.(); } catch (e) {}
            return;
        }
        if (cmd === 'player.setSkinCoins') {
            this._ensureSkinState();
            const n = Number(payload?.amount);
            if (Number.isFinite(n)) {
                this._skinState.coins = Math.max(0, Math.floor(n));
                this._broadcastSkinsSnapshot();
            }
            return;
        }
        // Покупка скина из встроенного магазина (намерение от React-оверлея
        // или из скрипта). Списывает локальные рублики, разблокирует, надевает.
        if (cmd === 'player.buySkin') {
            this._ensureSkinState();
            const slug = payload?.slug;
            const price = Number(payload?.price) || 0;
            if (typeof slug !== 'string' || !slug) return;
            const st = this._skinState;
            const owned = st.unlocked.has(slug);
            if (owned) {
                // Уже куплен — просто надеть.
                this._handleCommand(scriptId, 'player.setSkin', { slug });
                return;
            }
            if (st.coins < price) {
                // Не хватает — сообщаем оверлею.
                try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {}
                return;
            }
            st.coins -= price;
            st.unlocked.add(slug);
            this._handleCommand(scriptId, 'player.setSkin', { slug });
            this._broadcastSkinsSnapshot();
            try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {}
            return;
        }
        // === Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock ===
        if (cmd === 'player.setCameraZoom') {
            const player = this.scene3d?.player;
            if (player && typeof player.setCameraZoom === 'function') {
                try { player.setCameraZoom(payload?.distance); } catch (e) {}
            }
            return;
        }
        if (cmd === 'player.setCameraZoomLimits') {
            const player = this.scene3d?.player;
            if (player && typeof player.setCameraZoomLimits === 'function') {
                try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {}
            }
            return;
        }
        if (cmd === 'player.setShiftLock') {
            const player = this.scene3d?.player;
            if (player && typeof player.setShiftLock === 'function') {
                try { player.setShiftLock(payload?.on); } catch (e) {}
            }
            return;
        }
        // === Задача 02: environment API ===
        if (cmd === 'environment.setSkyColor') {
            try {
                const hex = String(payload?.color || '');
                const scene = this.scene3d?.scene;
                if (scene && hex) {
                    // Парсим #rrggbb → clearColor
                    const m = hex.match(/^#?([0-9a-f]{6})$/i);
                    if (m) {
                        const n = parseInt(m[1], 16);
                        const r = ((n >> 16) & 0xff) / 255;
                        const g = ((n >> 8) & 0xff) / 255;
                        const b = (n & 0xff) / 255;
                        if (scene.clearColor) {
                            scene.clearColor.r = r;
                            scene.clearColor.g = g;
                            scene.clearColor.b = b;
                            scene.clearColor.a = 1;
                        }
                    }
                }
            } catch (e) {
                this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'environment.setFog') {
            try {
                const env = this.scene3d?.environment;
                if (env && typeof env.setFog === 'function') {
                    env.setFog(payload?.enabled, payload?.color, payload?.density);
                }
            } catch (e) {}
            return;
        }
        if (cmd === 'environment.setTimeOfDay') {
            try {
                const env = this.scene3d?.environment;
                if (env && typeof env.setTimeOfDay === 'function') {
                    env.setTimeOfDay(payload?.hours);
                }
            } catch (e) {}
            return;
        }
        if (cmd === 'graphics.set') {
            try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
            return;
        }
        // === Задача 03: GUI tween ===
        if (cmd === 'gui.tween') {
            try {
                const guiId = payload?.id;
                if (typeof guiId !== 'string' || !guiId) return;
                const gm = this.scene3d?.guiManager;
                if (!gm) return;
                // Резолв localRef → realId если есть
                let realId = guiId;
                if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId);
                const el = gm.elements?.find(e => e.id === realId);
                if (!el) return;
                if (!this._guiTweens) this._guiTweens = [];
                // Снимок начальных значений по тем ключам что есть в props
                const props = payload.props || {};
                const propKeys = Object.keys(props);
                // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id,
                // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей.
                // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый.
                for (let j = this._guiTweens.length - 1; j >= 0; j--) {
                    const old = this._guiTweens[j];
                    if (old.realId !== realId) continue;
                    const oldKeys = Object.keys(old.target);
                    const overlap = oldKeys.some(k => propKeys.includes(k));
                    if (overlap) this._guiTweens.splice(j, 1);
                }
                const start = {};
                for (const k of propKeys) {
                    if (k in el) start[k] = el[k];
                    else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1);
                }
                this._guiTweens.push({
                    tweenId: payload.tweenId,
                    scriptId,
                    realId,
                    start, target: { ...props },
                    elapsed: 0,
                    duration: Math.max(0.001, Number(payload.duration) || 0.5),
                    delay: Math.max(0, Number(payload.delay) || 0),
                    easing: payload.easing || 'ease',
                    repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0,
                    reverses: !!payload.reverses,
                    iter: 0,
                    dir: 1,  // 1 = вперёд, -1 = обратно (для reverses)
                });
            } catch (e) {
                this._log('error', 'gui.tween failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'gui.cancelTween') {
            const tid = payload?.tweenId;
            if (tid != null && this._guiTweens) {
                const i = this._guiTweens.findIndex(t => t.tweenId === tid);
                if (i >= 0) this._guiTweens.splice(i, 1);
            }
            return;
        }
        // === Задача 04: модал-сцены ===
        if (cmd === 'modal.open') {
            try {
                const mm = this.scene3d?.modalManager;
                if (!mm) return;
                // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно
                const opts = { ...(payload?.opts || {}) };
                if (Array.isArray(opts.spotlights)) {
                    opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r);
                }
                if (opts.cameraOverride && opts.cameraOverride.target) {
                    opts.cameraOverride = {
                        ...opts.cameraOverride,
                        target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target,
                    };
                }
                const modalId = mm.open(opts);
                // Подписка чтобы автоматически слать tweenDone-стиль событий
                // на конкретный скрипт (тот кто открыл) — для onClose.
                if (!mm._runtimeBoundOnClose) {
                    mm._runtimeBoundOnClose = true;
                    mm.onClose((closedId) => {
                        // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn
                        this.routeGlobalEvent?.('modalClosed', { id: closedId });
                    });
                }
                // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open)
                const sb = this.sandboxes.find(s => s.scriptId === scriptId);
                if (sb && payload?.replyId != null) {
                    sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId });
                }
            } catch (e) {
                this._log('error', 'modal.open failed: ' + (e?.message || e));
            }
            return;
        }
        if (cmd === 'modal.close') {
            try {
                const mm = this.scene3d?.modalManager;
                mm?.close?.(payload?.modalId);
            } catch (e) {}
            return;
        }
        if (cmd === 'modal.update') {
            try {
                const mm = this.scene3d?.modalManager;
                mm?.update?.(payload?.modalId, payload?.patch);
            } catch (e) {}
            return;
        }
        // === Задача 01: Billboard 3D-таблички (см. BillboardUiManager) ===
        if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') {
            // Резолв ref → primitiveId.
            // Worker может прислать ref сразу после game.scene.spawn — до
            // того как main spawn'нул примитив и обновил _localToReal.
            // Откладываем команду до резолва.
            let ref = payload?.ref;
            if (typeof ref === 'string' && ref.includes('_local_')
                    && !this._localToReal?.has(ref)) {
                this._pendingResolveQueue = this._pendingResolveQueue || [];
                this._pendingResolveQueue.push({ cmd, payload, scriptId });
                return;
            }
            try {
                if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
                let id = null;
                if (typeof ref === 'string' && ref.startsWith('primitive:')) {
                    id = Number(ref.slice('primitive:'.length));
                } else if (Number.isFinite(ref)) {
                    id = Number(ref);
                }
                if (!Number.isFinite(id) || id == null) return;
                const data = this.scene3d?.primitiveManager?.instances?.get(id);
                if (!data || data.type !== 'billboard') return;
                const mgr = this.scene3d?.billboardUiManager;
                if (!mgr) return;

                if (cmd === 'billboard.set') {
                    mgr.applyToMesh(data, {
                        template: payload.template || data.billboard?.template || 'shop-item',
                        face: payload.face || data.billboard?.face || 'camera',
                        content: payload.content || data.billboard?.content,
                        elements: payload.elements || data.billboard?.elements,
                    });
                    this.scheduleSceneSnapshot?.();
                } else if (cmd === 'billboard.update') {
                    // 2 формы: с elementId (точечно) или без (patch content)
                    if (typeof payload.elementId === 'string') {
                        mgr.update(data, payload.elementId, payload.patch || {});
                    } else {
                        mgr.update(data, payload.patch || {});
                    }
                    this.scheduleSceneSnapshot?.();
                } else if (cmd === 'billboard.onClick') {
                    const buttonId = String(payload.buttonId || 'buy');
                    const realRef = 'primitive:' + id;
                    mgr.onClick(data, buttonId, () => {
                        const sb = this.sandboxes.find(s => s.scriptId === scriptId);
                        if (sb && typeof sb.sendGlobalEvent === 'function') {
                            // billboardClick роутится в worker'е через globalEvent-ветку
                            // (см. ScriptSandboxWorker.js cmd === 'globalEvent').
                            sb.sendGlobalEvent({
                                type: 'billboardClick',
                                ref: realRef,
                                button: buttonId,
                            });
                        }
                    });
                }
            } catch (e) {
                this._log('error', cmd + ' failed: ' + (e?.message || e));
            }
            return;
        }
        // eslint-disable-next-line no-console
        console.warn('[GameRuntime] unknown cmd', cmd);
    }

    /**
     * Создать объект из скрипта.
     * payload: { kind: 'block'|'model'|'primitive', subType, x, y, z, ref, ... }
     * После создания обновляем `_localToReal` мапу — локальный ref ↔ реальный id.
     */
    _applySceneSpawn(scriptId, payload) {
        if (!payload) return;
        const { kind, subType, ref } = payload;
        if (!this._localToReal) this._localToReal = new Map();
        try {
            if (kind === 'block') {
                // color — для окрашиваемых блоков (studs-block); иначе игнорируется.
                this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color);
                // Для блоков ref детерминированный, но запоминаем — чтобы при
                // Stop удалить заспавненные скриптом блоки (см. stop()).
                if (ref) this._localToReal.set(ref, ref);
                this.scheduleSceneSnapshot();
            } else if (kind === 'model') {
                // addInstance возвращает Promise<id> (async из-за GLB)
                const opts = payload;
                const p = this.scene3d?.modelManager?.addInstance(
                    subType, opts.x, opts.y, opts.z, opts.rotationY || 0
                );
                Promise.resolve(p).then((instId) => {
                    if (instId == null) return;
                    if (opts.name) {
                        const data = this.scene3d?.modelManager?.instances?.get(instId);
                        if (data) data.name = opts.name;
                    }
                    this._localToReal.set(ref, 'model:' + instId);
                    this._notifySpawnResolved(ref, 'model:' + instId);
                    this._drainPendingResolveQueue?.(ref);
                    this.scheduleSceneSnapshot();
                }).catch((err) => {
                    this._log('error', 'spawn model failed: ' + (err?.message || err));
                });
            } else if (kind === 'userModel') {
                // Пользовательская воксельная модель: subType = 'user:<id>'.
                // addInstance возвращает Promise<id>.
                const opts = payload;
                const p = this.scene3d?.userModelManager?.addInstance(
                    subType, opts.x, opts.y, opts.z, opts.rotationY || 0,
                    (opts.scale && Number(opts.scale) > 0) ? { scale: Number(opts.scale) } : {},
                );
                Promise.resolve(p).then((instId) => {
                    if (instId == null) return;
                    if (opts.name) {
                        const data = this.scene3d?.userModelManager?.instances?.get(instId);
                        if (data) data.name = opts.name;
                    }
                    this._localToReal.set(ref, 'usermodel:' + instId);
                    this._notifySpawnResolved(ref, 'usermodel:' + instId);
                    this._drainPendingResolveQueue?.(ref);
                    this.scheduleSceneSnapshot();
                }).catch((err) => {
                    this._log('error', 'spawn user model failed: ' + (err?.message || err));
                });
            } else if (kind === 'primitive') {
                const opts = payload;
                const id = this.scene3d?.primitiveManager?.addInstance(subType, {
                    x: opts.x, y: opts.y, z: opts.z,
                    sx: opts.sx, sy: opts.sy, sz: opts.sz,
                    color: opts.color, material: opts.material,
                    rotationY: opts.rotationY,
                    name: opts.name,
                    brightness: opts.brightness, range: opts.range,
                    effect: opts.effect,
                    // textureAsset — картинка из ассетов проекта на грани.
                    ...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}),
                    // anchored:false → объект падает (физика unanchored).
                    // canCollide:false → проходимый (зона-триггер).
                    ...(opts.anchored != null ? { anchored: opts.anchored } : {}),
                    ...(opts.canCollide != null ? { canCollide: opts.canCollide } : {}),
                    ...(opts.visible != null ? { visible: opts.visible } : {}),
                });
                if (id != null) {
                    this._localToReal.set(ref, 'primitive:' + id);
                    this._notifySpawnResolved(ref, 'primitive:' + id);
                    this._drainPendingResolveQueue?.(ref);
                    const data = this.scene3d?.primitiveManager?.instances?.get(id);
                    if (data) {
                        // Помечаем как заспавненный скриптом — движок шлёт
                        // для таких onPlayerTouch (нужно для «поймай объект»).
                        data._scriptSpawned = true;
                        // Если unanchored — регистрируем в физике на лету,
                        // иначе он не падает (start() уже отработал).
                        if (opts.anchored === false) {
                            this.scene3d?.dynamics?.registerPrimitive(data);
                        }
                    }
                    this.scheduleSceneSnapshot();
                }
            } else if (kind === 'vehicle') {
                const opts = payload;
                const p = this.scene3d?.vehicleManager?.spawn({
                    model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
                    params: opts.params, x: opts.x, y: opts.y, z: opts.z,
                    rotationY: opts.rotationY || 0, ref,
                });
                Promise.resolve(p).then((vid) => {
                    if (vid == null) return;
                    const realRef = 'vehicle:' + vid;
                    this._localToReal.set(ref, realRef);
                    this._notifySpawnResolved(ref, realRef);
                    const veh = this.scene3d?.vehicleManager?.getById?.(vid);
                    if (veh && !this._interactables.some(it => it.ref === realRef)) {
                        this._interactables.push({
                            ref: realRef, target: null,
                            text: 'Enter', objectName: veh.name,
                            distance: Math.max(4, veh.half.d + 2), key: 'f',
                            holdDuration: 0.4, isInst: true, isVehicle: true,
                        });
                    }
                    this.scheduleSceneSnapshot();
                }).catch((err) => {
                    this._log('error', 'spawn vehicle failed: ' + (err?.message || err));
                });
            }
        } catch (e) {
            this._log('error', 'scene.spawn failed: ' + (e?.message || e));
        }
    }

    /** Удалить объект по ref (поддерживает локальный ref от spawn и реальный). */
    _applySceneDelete(payload) {
        if (!payload?.ref) return;
        let ref = payload.ref;
        // Резолвим локальный ref → реальный
        if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
        // Ref всё ещё локальный ('_local_') — модель ещё не зарезолвилась
        // (асинхронная загрузка GLB). Откладываем удаление: оно сработает
        // в _notifySpawnResolved, когда реальный id появится. Без этого
        // removeInstance(NaN) промахивался и объект «осиротевал» на сцене.
        if (ref.indexOf('_local_') >= 0) {
            if (!this._pendingDeletes) this._pendingDeletes = new Set();
            this._pendingDeletes.add(ref);
            return;
        }
        try {
            const colon = ref.indexOf(':');
            if (colon < 0) return;
            const kind = ref.slice(0, colon);
            const rest = ref.slice(colon + 1);
            if (kind === 'block') {
                const [xs, ys, zs] = rest.split(',');
                this.scene3d?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs));
            } else if (kind === 'model') {
                this.scene3d?.modelManager?.removeInstance(Number(rest));
            } else if (kind === 'primitive') {
                this.scene3d?.primitiveManager?.removeInstance(Number(rest));
            }
            // Удалили — снимаем mapping
            for (const [k, v] of (this._localToReal || new Map()).entries()) {
                if (v === ref) this._localToReal.delete(k);
            }
            this.scheduleSceneSnapshot();
        } catch (e) {
            this._log('error', 'scene.delete failed: ' + (e?.message || e));
        }
    }

    /**
     * Запланировать рассылку sceneSnapshot всем sandbox'ам в следующем кадре.
     * Делается отложенно чтобы при массовом spawn (например в onKey) отправить
     * snapshot один раз, а не N раз.
     */
    scheduleSceneSnapshot() {
        if (this._snapshotPending) return;
        this._snapshotPending = true;
        // microtask — следующий кадр render-loop'а почти наверняка
        Promise.resolve().then(() => {
            this._snapshotPending = false;
            this._broadcastSceneSnapshot();
        });
    }

    /** Рассылка snapshot всем sandbox'ам. */
    _broadcastSceneSnapshot() {
        if (!this._isRunning || this.sandboxes.length === 0) return;
        const snap = this._buildSceneSnapshot();
        for (const sb of this.sandboxes) {
            sb.sendSceneSnapshot(snap);
        }
    }

    /** Запланировать рассылку GUI-snapshot всем sandbox'ам в следующем microtask. */
    scheduleGuiSnapshot() {
        if (this._guiSnapshotPending) return;
        this._guiSnapshotPending = true;
        Promise.resolve().then(() => {
            this._guiSnapshotPending = false;
            this._broadcastGuiSnapshot();
        });
    }

    _broadcastGuiSnapshot() {
        if (!this._isRunning || this.sandboxes.length === 0) return;
        const snap = this._buildGuiSnapshot();
        for (const sb of this.sandboxes) {
            sb.sendGuiSnapshot(snap);
        }
    }

    /** Запланировать рассылку snapshot атрибутов объектов (game.scene.setData). */
    scheduleDataSnapshot() {
        if (this._dataSnapshotPending) return;
        this._dataSnapshotPending = true;
        Promise.resolve().then(() => {
            this._dataSnapshotPending = false;
            this._broadcastDataSnapshot();
        });
    }

    _broadcastDataSnapshot() {
        if (!this._isRunning || this.sandboxes.length === 0) return;
        for (const sb of this.sandboxes) {
            sb.sendDataSnapshot(this._objectData);
        }
    }

    _buildGuiSnapshot() {
        const list = this.scene3d?.getGuiElements?.() || [];
        return list.map(g => ({
            id: g.id, type: g.type, name: g.name,
            parentId: g.parentId || null,
            x: g.x, y: g.y, w: g.w, h: g.h, anchor: g.anchor,
            visible: g.visible !== false,
            text: g.text, textColor: g.textColor, textSize: g.textSize,
            bgColor: g.bgColor, bgOpacity: g.bgOpacity,
            imageUrl: g.imageUrl,
            placeholder: g.placeholder,
        }));
    }

    /** Собрать snapshot сцены для синхронных game.scene.find/all/getPosition в Worker'ах. */
    _buildSceneSnapshot() {
        const blocks = [];
        const models = [];
        const primitives = [];
        const s = this.scene3d;
        if (s?.blockManager) {
            for (const proxy of s.blockManager.blocks.values()) {
                const md = proxy.metadata;
                if (!md?.isBlock) continue;
                blocks.push({
                    ref: 'block:' + md.gridX + ',' + md.gridY + ',' + md.gridZ,
                    type: md.blockTypeId,
                    x: md.gridX, y: md.gridY, z: md.gridZ,
                });
            }
        }
        if (s?.modelManager) {
            for (const data of s.modelManager.instances.values()) {
                models.push({
                    ref: 'model:' + data.instanceId,
                    type: data.modelTypeId,
                    x: data.x, y: data.y, z: data.z,
                    name: data.name || null,
                });
            }
        }
        if (s?.primitiveManager) {
            for (const data of s.primitiveManager.instances.values()) {
                primitives.push({
                    ref: 'primitive:' + data.id,
                    type: data.type,
                    x: data.x, y: data.y, z: data.z,
                    // размеры/поворот нужны для game.physics.raycast (ray vs AABB)
                    sx: data.sx != null ? data.sx : 1,
                    sy: data.sy != null ? data.sy : 1,
                    sz: data.sz != null ? data.sz : 1,
                    rotationY: data.rotationY || 0,
                    visible: data.visible !== false,
                    name: data.name || null,
                });
            }
        }
        return { blocks, models, primitives };
    }

    _applySelfMove(payload) {
        if (!payload || !payload.target) return;
        const t = payload.target;
        const { x, y, z } = payload;
        if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return;
        try {
            if (t.kind === 'model') {
                let id = t.id ?? t.ref;
                const mm = this.scene3d?.modelManager;
                if (!mm) return;
                // Локальный ref '_local_N' от scene.spawn → реальный id.
                if (typeof id === 'string' && id.indexOf('_local_') === 0
                    && this._localToReal) {
                    const real = this._localToReal.get('model:' + id);
                    if (real) {
                        const c2 = real.indexOf(':');
                        id = c2 >= 0 ? real.slice(c2 + 1) : real;
                    }
                }
                let data = mm.instances.get(id);
                if (!data && typeof id === 'string') {
                    const n = Number(id);
                    if (Number.isFinite(n)) data = mm.instances.get(n);
                }
                if (data) {
                    data.x = x; data.y = y; data.z = z;
                    if (data.rootMesh?.position) {
                        data.rootMesh.position.set(x, y, z);
                        if (data._worldMatrixFrozen) {
                            try { data.rootMesh.unfreezeWorldMatrix?.(); } catch (e) {}
                            if (Array.isArray(data.meshes)) {
                                for (const m of data.meshes) {
                                    try { m?.unfreezeWorldMatrix?.(); } catch (e) {}
                                }
                            }
                            data._worldMatrixFrozen = false;
                        }
                    }
                }
            } else if (t.kind === 'primitive') {
                const pm = this.scene3d?.primitiveManager;
                if (!pm) return;
                // _resolvePrimitiveId умеет и числовой id, и локальный
                // ref '_local_N' (от scene.spawn) — без этого scene.move
                // не находит объект, заспавненный скриптом.
                const rid = this._resolvePrimitiveId(t.id ?? t.ref);
                const data = rid != null ? pm.instances.get(rid) : null;
                if (data) {
                    data.x = x; data.y = y; data.z = z;
                    if (data.mesh?.position) {
                        data.mesh.position.set(x, y, z);
                        if (data._worldMatrixFrozen) {
                            try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
                            data._worldMatrixFrozen = false;
                        }
                    }
                }
            } else if (t.kind === 'userModel') {
                // userModel-инстанс: отдельная нода (rootNode), не thin-instance.
                // Двигаем root.position + обновляем data.x/y/z.
                const id = t.id ?? t.ref;
                const um = this.scene3d?.userModelManager;
                if (!um) return;
                let data = um.instances.get(id);
                if (!data && typeof id === 'string') {
                    const n = Number(id);
                    if (Number.isFinite(n)) data = um.instances.get(n);
                }
                if (data) {
                    data.x = x; data.y = y; data.z = z;
                    if (data.rootNode?.position) {
                        data.rootNode.position.set(x, y, z);
                    }
                }
            }
            // НЕ шлём sceneSnapshot при move — позиция объекта в snapshot всё
            // равно стейл (sandbox использует findOne и сам не зависит от
            // координат в snapshot). Иначе при анимации платформ (десятки
            // scene.move в секунду) шлём весь snapshot 11000+ объектов в worker
            // через структурный postMessage — это может стоить сотни мс.
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[GameRuntime] self.move failed', e);
        }
    }

    _applySelfDelete(payload) {
        if (!payload || !payload.target) return;
        const t = payload.target;
        try {
            if (t.kind === 'block') {
                const r = t.ref || t;
                this.scene3d?.blockManager?.removeBlock(r.x, r.y, r.z);
            } else if (t.kind === 'model') {
                const id = t.id ?? t.ref;
                this.scene3d?.modelManager?.removeInstance(id);
            } else if (t.kind === 'primitive') {
                const id = t.id ?? t.ref;
                this.scene3d?.primitiveManager?.removeInstance(id);
            }
            // Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте).
            if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) {
                const ref = t.kind + ':' + (t.ref ?? t.id);
                this._interactables = this._interactables.filter(it => it.ref !== ref);
            }
            this.scheduleSceneSnapshot();
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[GameRuntime] self.delete failed', e);
        }
    }

    _log(level, text) {
        if (this._onLog) {
            try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ }
        }
    }

    /**
     * Воспроизвести встроенный звуковой эффект через Web Audio API.
     * Все звуки генерируются процедурно — никаких mp3-файлов, нагрузка минимальная.
     * Поддерживаемые: jump, pickup, win, lose, click, hit, coin.
     */
    _playSound(payload) {
        if (!payload || typeof payload.name !== 'string') return;
        const name = payload.name;
        const volume = Number.isFinite(payload.volume) ? Math.max(0, Math.min(2, payload.volume)) : 1;
        const pitch = Number.isFinite(payload.pitch) ? Math.max(0.25, Math.min(4, payload.pitch)) : 1;
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const t = ctx.currentTime;
            // Описание звуков: одна или несколько oscillator-волн с envelope
            switch (name) {
                case 'jump':       this._sfxJump(ctx, t, volume, pitch); break;
                case 'pickup':     this._sfxPickup(ctx, t, volume, pitch); break;
                case 'win':        this._sfxWin(ctx, t, volume, pitch); break;
                case 'lose':       this._sfxLose(ctx, t, volume, pitch); break;
                case 'click':      this._sfxClick(ctx, t, volume, pitch); break;
                case 'hit':        this._sfxHit(ctx, t, volume, pitch); break;
                case 'coin':       this._sfxCoin(ctx, t, volume, pitch); break;
                default:
                    this._log('warn', `Неизвестный звук: ${name}`);
            }
        } catch (e) {
            // ignore
        }
    }

    // === Звуковые пресеты (Web Audio) ===
    _sfxOsc(ctx, t, type, freq0, freq1, dur, vol) {
        const osc = ctx.createOscillator();
        osc.type = type;
        osc.frequency.setValueAtTime(freq0, t);
        if (freq1 != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq1), t + dur);
        const g = ctx.createGain();
        g.gain.setValueAtTime(0, t);
        g.gain.linearRampToValueAtTime(vol, t + 0.005);
        g.gain.exponentialRampToValueAtTime(0.001, t + dur);
        osc.connect(g).connect(ctx.destination);
        osc.start(t);
        osc.stop(t + dur + 0.02);
    }
    _sfxJump(ctx, t, vol, pitch) {
        // Похож на встроенный звук прыжка PlayerController.
        this._sfxOsc(ctx, t, 'sine', 720 * pitch, 440 * pitch, 0.16, 0.22 * vol);
        this._sfxOsc(ctx, t, 'sine', 110 * pitch,  60 * pitch, 0.07, 0.35 * vol);
    }
    _sfxPickup(ctx, t, vol, pitch) {
        // Восходящие два тона — «пик-апнул!»
        this._sfxOsc(ctx, t, 'square', 880 * pitch, 1320 * pitch, 0.10, 0.20 * vol);
        this._sfxOsc(ctx, t + 0.08, 'square', 1320 * pitch, 1760 * pitch, 0.12, 0.16 * vol);
    }
    _sfxCoin(ctx, t, vol, pitch) {
        // Классический «динь-динь»
        this._sfxOsc(ctx, t, 'sine', 988 * pitch, 988 * pitch, 0.06, 0.25 * vol);
        this._sfxOsc(ctx, t + 0.05, 'sine', 1318 * pitch, 1318 * pitch, 0.18, 0.25 * vol);
    }
    _sfxWin(ctx, t, vol, pitch) {
        // Мажорный аккорд C-E-G по очереди
        const notes = [523, 659, 784];
        notes.forEach((f, i) => {
            this._sfxOsc(ctx, t + i * 0.08, 'triangle', f * pitch, f * pitch, 0.30, 0.22 * vol);
        });
    }
    _sfxLose(ctx, t, vol, pitch) {
        // Нисходящий «провал»
        this._sfxOsc(ctx, t, 'sawtooth', 440 * pitch, 110 * pitch, 0.45, 0.22 * vol);
        this._sfxOsc(ctx, t + 0.08, 'sawtooth', 330 * pitch, 80 * pitch, 0.50, 0.18 * vol);
    }
    _sfxClick(ctx, t, vol, pitch) {
        // Короткий «тик»
        this._sfxOsc(ctx, t, 'square', 1500 * pitch, 800 * pitch, 0.04, 0.15 * vol);
    }
    /**
     * Создать ParticleSystem в указанной точке. Авто-удаляется через duration сек.
     * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon.
     */
    _spawnParticles(payload) {
        if (!payload || !this.scene3d?._spawnParticleEffect) return;
        try {
            this.scene3d._spawnParticleEffect(payload);
        } catch (e) {
            this._log('error', 'spawnParticles failed: ' + (e?.message || e));
        }
    }

    _sfxHit(ctx, t, vol, pitch) {
        // Глухой «тук»: низкий sine + шумовой burst
        this._sfxOsc(ctx, t, 'sine', 180 * pitch, 80 * pitch, 0.10, 0.30 * vol);
        // Шум через короткий buffer-noise
        const bufLen = Math.floor(ctx.sampleRate * 0.06);
        const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
        const data = buf.getChannelData(0);
        for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufLen);
        const src = ctx.createBufferSource();
        src.buffer = buf;
        const lp = ctx.createBiquadFilter();
        lp.type = 'lowpass';
        lp.frequency.value = 1000 * pitch;
        const g = ctx.createGain();
        g.gain.value = 0.18 * vol;
        src.connect(lp).connect(g).connect(ctx.destination);
        src.start(t);
    }

    // === Универсальное хранилище сейвов (game.save.*) ===
    _saveProjectId() {
        return this.scene3d?._currentProjectId || this.scene3d?.projectId || null;
    }
    _saveBaseUrl(namespace) {
        const pid = this._saveProjectId();
        const uid = this.scene3d?._currentUserId;
        if (!pid || !uid) return null;
        const ns = encodeURIComponent(namespace || 'default');
        return `${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}/${ns}`;
    }
    _saveReply(scriptId, reqId, result) {
        for (const sb of this.sandboxes) {
            if (sb.scriptId === scriptId) {
                try { sb.worker.postMessage({ cmd: 'saveResponse', payload: { reqId, result } }); } catch (e) {}
                return;
            }
        }
    }
    _saveGet(scriptId, payload) {
        const reqId = payload?.reqId;
        const url = this._saveBaseUrl(payload?.namespace);
        if (!url) { this._saveReply(scriptId, reqId, null); return; }
        // GET savegame теперь тоже требует JWT (бэк ужесточили после
        // Этапа 4 — выдаёт 401 без, 403 если чужой). Используем те же
        // headers что _saveSet/_saveMerge.
        const headers = {};
        try {
            const t = localStorage.getItem('Authorization');
            if (t) headers.Authorization = t;
        } catch (e) {}
        fetch(url, { headers }).then(r => r.json())
            .then(j => this._saveReply(scriptId, reqId, j.data ?? null))
            .catch(() => this._saveReply(scriptId, reqId, null));
    }
    _saveGetAll(scriptId, payload) {
        const reqId = payload?.reqId;
        const pid = this._saveProjectId();
        const uid = this.scene3d?._currentUserId;
        if (!pid || !uid) { this._saveReply(scriptId, reqId, {}); return; }
        const headers = {};
        try {
            const t = localStorage.getItem('Authorization');
            if (t) headers.Authorization = t;
        } catch (e) {}
        fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`, { headers })
            .then(r => r.json())
            .then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
            .catch(() => this._saveReply(scriptId, reqId, {}));
    }
    // Превращает относительный путь (/kubikon/gd, /kubikon3d, /app/...)
    // во ВНЕШНИЙ URL правильного хоста, потому что в выделенном плеере
    // (player.rublox.pro) этих SPA-роутов нет.
    //
    // Карта роутов (rublox.pro вместо mnk — у юзеров плеера нет сессии
    // на mnk, разные домены/localStorage; получался 401):
    //   - http(s)://...                  → как есть (уже абсолютный)
    //   - /kubikon/gd*                   → rublox.pro/app/gd (порт меню GD)
    //   - /kubikon/play/N                → ticket-flow в плеер уже идёт;
    //                                      сюда попасть можно только через
    //                                      app.navigate из скрипта уровня
    //                                      (Например 'играть ещё раз' →
    //                                      /kubikon/play/296?play=1&t=ts).
    //                                      Парсим id и шлём прямо в плеер.
    //   - /kubikon*, /kubikon3d*         → rublox.pro/app (лента игр)
    //   - /app, /app/*                   → rublox.pro
    //   - всё остальное                  → rublox.pro/app (фоллбек)
    //
    // На localhost — dev-порт rublox-site (3004), на проде — rublox.pro.
    _resolveExternalUrl(url) {
        try {
            // VITE_RUBLOX_HOME = главный сайт-витрина (default: https://rublox.pro/app).
            // На dev rublox-site обычно крутится на :3004 — можно переопределить.
            const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
            const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
            const rubloxBase = RUBLOX_HOME.replace(/\/app\/?$/, '');  // .../app → ...

            if (!url) return RUBLOX_HOME;
            if (/^https?:\/\//i.test(url)) return url;

            // /kubikon/play/<id> — рестарт уровня. Перезагружаем плеер сам.
            const playMatch = url.match(/^\/kubikon\/play\/(\d+)/);
            if (playMatch) {
                const playerBase = typeof window !== 'undefined'
                    ? `${window.location.protocol}//${window.location.host}`
                    : '';
                return `${playerBase}/${playMatch[1]}`;
            }
            // Legacy /kubikon/* роуты — редирект на главный сайт.
            if (url.startsWith('/kubikon/gd')) return rubloxBase + '/app/gd';
            if (url.startsWith('/kubikon'))    return rubloxBase + '/app';
            if (url.startsWith('/app'))        return rubloxBase + url;
            return rubloxBase + '/app';
        } catch (e) {
            const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
            return env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
        }
    }
    // ВАЖНО: POST savegame/merge на бэке требует JWT (no_token → 401).
    // Оригинальный код Майнкрафтии fetch БЕЗ Authorization-заголовка
    // (этот же баг там тоже есть — сохранения GD-прогресса не работали
    // молча, потому что .catch(()=>{}) глушит). В плеере добавляем JWT
    // через зеркало localStorage['Authorization'] (см. auth/ticketExchange.js
    // saveJWT — он кладёт JWT в оба ключа).
    _saveAuthHeaders() {
        const h = { 'Content-Type': 'application/json' };
        try {
            const t = localStorage.getItem('Authorization');
            if (t) h.Authorization = t;
        } catch (e) {}
        return h;
    }
    /** Задача 20: сохранить прогресс игрока в БД (для leaderstats/achievements). */
    saveProgress(namespace, data) {
        const url = this._saveBaseUrl(namespace);
        if (!url) return;
        try {
            fetch(url, {
                method: 'POST', headers: this._saveAuthHeaders(),
                body: JSON.stringify({ data }),
            }).catch(() => {});
        } catch (e) { /* ignore */ }
    }
    /** Задача 20: загрузить прогресс из БД (cb(data|null)). */
    loadProgress(namespace, cb) {
        const url = this._saveBaseUrl(namespace);
        if (!url) { cb && cb(null); return; }
        fetch(url, { headers: this._saveAuthHeaders() })
            .then(r => r.json())
            .then(j => cb && cb(j.data ?? null))
            .catch(() => cb && cb(null));
    }

    _saveSet(payload) {
        const url = this._saveBaseUrl(payload?.namespace);
        if (!url) return;
        try {
            fetch(url, {
                method: 'POST',
                headers: this._saveAuthHeaders(),
                body: JSON.stringify({ data: payload.data }),
            }).catch(() => {});
        } catch (e) {}
    }
    _saveMerge(payload) {
        const url = this._saveBaseUrl(payload?.namespace);
        if (!url) return;
        try {
            fetch(url + '/merge', {
                method: 'POST',
                headers: this._saveAuthHeaders(),
                body: JSON.stringify({
                    patch: payload.patch || {},
                    increment: payload.increment || {},
                    max: payload.max || {},
                }),
            }).catch(() => {});
        } catch (e) {}
    }
    _saveLeaderboard(scriptId, payload) {
        const reqId = payload?.reqId;
        const pid = this._saveProjectId();
        if (!pid) { this._saveReply(scriptId, reqId, []); return; }
        const params = new URLSearchParams({
            namespace: payload?.namespace || '',
            key: payload?.key || '',
            order: payload?.order || 'desc',
            limit: '20',
        });
        fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/leaderboard?${params}`)
            .then(r => r.json())
            .then(j => this._saveReply(scriptId, reqId, j.entries || []))
            .catch(() => this._saveReply(scriptId, reqId, []));
    }

    // ============== ECONOMY API (GD-reward через storys) ==============
    // Каждый метод асинхронно делает HTTP-запрос с JWT в заголовке Authorization.
    // Ответ возвращается в Worker через postMessage cmd='economyResponse'.

    _economyReply(scriptId, reqId, result) {
        for (const sb of this.sandboxes) {
            if (sb.scriptId === scriptId) {
                try { sb.worker.postMessage({ cmd: 'economyResponse', payload: { reqId, result } }); } catch (e) {}
                return;
            }
        }
    }

    _economyAuthHeaders() {
        const h = { 'Content-Type': 'application/json' };
        try {
            const t = localStorage.getItem('Authorization');
            if (t) h.Authorization = t;
        } catch (e) {}
        return h;
    }

    _economyReward(scriptId, payload) {
        const reqId = payload?.reqId;
        const aid = String(payload?.achievementId || '');
        if (!aid) { this._economyReply(scriptId, reqId, { ok: false, error: 'no_id' }); return; }
        fetch(`${STORYS_addres}/kubikon3d/gd/reward`, {
            method: 'POST',
            headers: this._economyAuthHeaders(),
            body: JSON.stringify({ achievement_id: aid }),
        })
            .then(r => r.json())
            .then(j => this._economyReply(scriptId, reqId, j || { ok: false }))
            .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) }));
    }

    _economyDailyCheck(scriptId, payload) {
        const reqId = payload?.reqId;
        fetch(`${STORYS_addres}/kubikon3d/gd/daily-check`, {
            method: 'POST',
            headers: this._economyAuthHeaders(),
            body: JSON.stringify({}),
        })
            .then(r => r.json())
            .then(j => this._economyReply(scriptId, reqId, j || { awarded: false }))
            .catch(e => this._economyReply(scriptId, reqId, { awarded: false, error: String(e) }));
    }

    _economyGetBalance(scriptId, payload) {
        const reqId = payload?.reqId;
        // Алмазы — user/api/v1/users/diamond, рейтинг — user/api/v1/users/rating.
        // Делаем оба запроса параллельно.
        const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user');
        const headers = this._economyAuthHeaders();
        Promise.all([
            fetch(`${USER_BASE}/api/v1/users/diamond`, { headers }).then(r => r.json()).catch(() => ({ count: 0 })),
            fetch(`${USER_BASE}/api/v1/users/rating`, { headers }).then(r => r.json()).catch(() => ({ rating: 0 })),
        ]).then(([dm, rt]) => {
            this._economyReply(scriptId, reqId, {
                diamonds: Number(dm.count || 0),
                rating: Number(rt.rating || 0),
            });
        }).catch(() => this._economyReply(scriptId, reqId, { diamonds: 0, rating: 0 }));
    }

    _economySpend(scriptId, payload) {
        const reqId = payload?.reqId;
        const amount = Number(payload?.amount || 0);
        const reason = String(payload?.reason || 'gd_spend');
        if (amount < 1) { this._economyReply(scriptId, reqId, { ok: false, error: 'invalid_amount' }); return; }
        const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user');
        fetch(`${USER_BASE}/api/v1/users/diamond/spend`, {
            method: 'POST',
            headers: this._economyAuthHeaders(),
            body: JSON.stringify({ amount, reason }),
        })
            .then(r => r.json())
            .then(j => this._economyReply(scriptId, reqId, j || { ok: false }))
            .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) }));
    }
}
