/**
 * RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel.
 *
 * Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены.
 *   - game.Workspace.Children = массив RbxPart обёрток над примитивами
 *   - script.Parent для target-скриптов = реальный RbxPart
 *   - RbxPart.Touched — RbxSignal который фейерится из BabylonScene при overlap
 *   - RbxPart.Position/Size/Color/Anchored/CanCollide — пишутся через setProp(part, ...)
 *     методы, которые шлют partSet в main thread (применяется к Babylon-сцене)
 *   - Humanoid с Health setter → playerSet команда
 *
 * ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах
 * передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо
 * этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит
 * через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле.
 */

// ---------- Scheduler (для task.delay/defer) ----------
const SCHEDULER = {
    sleeping: [],   // [{wakeAt, run}]
    now: () => performance.now(),
};

// ---------- Базовые сигналы ----------
const HEARTBEAT_SIGNAL = makeSignal();
const STEPPED_SIGNAL = makeSignal();

// Очередь handler'ов которые надо запустить на следующем tickScheduler.
// Этим мы выходим из C-boundary — wait() внутри handler'а становится
// безопасным yield в собственной coroutine, потому что handler стартует
// уже из main loop, а не из синхронного JS-callback.
const _pendingHandlerQueue = [];

function makeSignal() {
    const sig = {
        __isSignal: true,
        connections: [],
    };
    sig.Connect = function (fn) {
        if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {}, Connected: false };
        sig.connections.push(fn);
        const conn = { Connected: true };
        conn.Disconnect = function () {
            const i = sig.connections.indexOf(fn);
            if (i >= 0) sig.connections.splice(i, 1);
            conn.Connected = false;
        };
        conn.disconnect = conn.Disconnect;
        return conn;
    };
    sig.connect = sig.Connect;
    sig.Fire = function (...args) {
        for (const fn of [...sig.connections]) {
            // Кладём в очередь, чтобы handler стартовал не в текущем
            // JS-callback (откуда yield запрещён), а из tickScheduler
            // в своей coroutine. Безопасно для wait() внутри.
            _pendingHandlerQueue.push({ fn, args });
        }
    };
    sig.fire = sig.Fire;
    // Wait() возвращает -1 как маркер "yield 1 кадр" — наш Lua-prelude
    // оборачивает все Signal:Wait через __rbxl_signal_wait который при
    // получении -1 делает rbx_wait(0.05) (yield в coroutine).
    sig.Wait = () => -1;
    sig.wait = sig.Wait;
    return sig;
}

// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ----------
class RbxVector3 {
    constructor(x = 0, y = 0, z = 0) { this.X = +x; this.Y = +y; this.Z = +z; }
    static new(x, y, z) { return new RbxVector3(x, y, z); }
    get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
    get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
    get Unit() {
        const m = Math.hypot(this.X, this.Y, this.Z) || 1;
        return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
    }
    get unit() { return this.Unit; }
    Normalize() { return this.Unit; }
    Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
    Cross(b) {
        return new RbxVector3(
            this.Y * b.Z - this.Z * b.Y,
            this.Z * b.X - this.X * b.Z,
            this.X * b.Y - this.Y * b.X,
        );
    }
    Lerp(b, t) {
        return new RbxVector3(
            this.X + (b.X - this.X) * t,
            this.Y + (b.Y - this.Y) * t,
            this.Z + (b.Z - this.Z) * t,
        );
    }
}
RbxVector3.zero = new RbxVector3(0, 0, 0);
RbxVector3.one = new RbxVector3(1, 1, 1);
RbxVector3.xAxis = new RbxVector3(1, 0, 0);
RbxVector3.yAxis = new RbxVector3(0, 1, 0);
RbxVector3.zAxis = new RbxVector3(0, 0, 1);

class RbxColor3 {
    constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; }
    static new(r, g, b) { return new RbxColor3(r, g, b); }
    static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); }
    static fromHSV(h, s, v) {
        const i = Math.floor(h * 6); const f = h * 6 - i;
        const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s);
        const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6];
        return new RbxColor3(r, g, b);
    }
    static fromHex(hex) {
        const s = String(hex || '').replace('#', '');
        if (s.length !== 6) return new RbxColor3();
        return new RbxColor3(
            parseInt(s.slice(0, 2), 16) / 255,
            parseInt(s.slice(2, 4), 16) / 255,
            parseInt(s.slice(4, 6), 16) / 255,
        );
    }
    Lerp(b, t) {
        return new RbxColor3(
            this.R + (b.R - this.R) * t,
            this.G + (b.G - this.G) * t,
            this.B + (b.B - this.B) * t,
        );
    }
    toHex() {
        const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0');
        return '#' + h(this.R) + h(this.G) + h(this.B);
    }
}

class RbxUDim {
    constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; }
    static new(s, o) { return new RbxUDim(s, o); }
}
class RbxUDim2 {
    constructor(sx = 0, ox = 0, sy = 0, oy = 0) {
        this.X = new RbxUDim(sx, ox); this.Y = new RbxUDim(sy, oy);
    }
    static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
    static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
    static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); }
}
class RbxVector2 {
    constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; }
    static new(x, y) { return new RbxVector2(x, y); }
}
class RbxCFrame {
    constructor(x = 0, y = 0, z = 0) {
        this.X = +x; this.Y = +y; this.Z = +z;
        this.Position = new RbxVector3(x, y, z);
        this.p = this.Position;
    }
    static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
    static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); }
    static Angles() { return new RbxCFrame(); }
    static fromEulerAnglesXYZ() { return new RbxCFrame(); }
}

// ---------- Instance / Part ----------
let _instanceMethods = null;
function makeInstanceMethods() {
    if (_instanceMethods) return _instanceMethods;
    _instanceMethods = {
        GetChildren: function () { return [...(this.Children || [])]; },
        GetDescendants: function () {
            const out = [];
            const visit = (n) => {
                for (const c of n.Children || []) { out.push(c); visit(c); }
            };
            visit(this);
            return out;
        },
        FindFirstChild: function (name, recursive) {
            for (const c of this.Children || []) {
                if (c.Name === name) return c;
                if (recursive) {
                    const f = c.FindFirstChild && c.FindFirstChild(name, true);
                    if (f) return f;
                }
            }
            return undefined;
        },
        FindFirstChildOfClass: function (cls) {
            for (const c of this.Children || []) {
                if (c.ClassName === cls) return c;
            }
            return undefined;
        },
        FindFirstAncestor: function (name) {
            let p = this.Parent;
            while (p) { if (p.Name === name) return p; p = p.Parent; }
            return undefined;
        },
        FindFirstAncestorOfClass: function (cls) {
            let p = this.Parent;
            while (p) { if (p.ClassName === cls) return p; p = p.Parent; }
            return undefined;
        },
        WaitForChild: function (name) {
            // В Roblox WaitForChild блокирует пока ребёнок не появится. У нас
            // нет yield с произвольных JS-функций, поэтому возвращаем либо
            // существующего ребёнка, либо ленивый stub-Folder чтобы избежать
            // падений типа "attempt to index a nil value" в импортированных
            // скриптах. Stub автоматически добавляется в Children.
            const existing = this.FindFirstChild(name);
            if (existing) return existing;
            try {
                const stub = newInstance('Folder', String(name));
                stub.Parent = this;
                if (this.Children) this.Children.push(stub);
                return stub;
            } catch (_) {
                return undefined;
            }
        },
        IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; },
        GetFullName: function () {
            const parts = [];
            let p = this;
            while (p && p.ClassName !== 'DataModel') {
                parts.unshift(p.Name);
                p = p.Parent;
            }
            return parts.join('.');
        },
        Destroy: function () {
            this.Destroyed = true;
            // Если это Part с примитивом — шлём sceneDelete
            if (this.__primId != null && this.__sendDestroy) {
                try { this.__sendDestroy(this.__primId); } catch (_) {}
            }
            if (this.Parent && this.Parent.Children) {
                const i = this.Parent.Children.indexOf(this);
                if (i >= 0) this.Parent.Children.splice(i, 1);
                this.Parent = undefined;
            }
        },
        Clone: function () {
            // Поверхностный клон — достаточно для большинства Roblox-паттернов
            // (Tool/Pellet/Bomb клонируются и parent'ятся в Workspace).
            // Глубокий клон не делаем — Children копируются по ссылке (как в Roblox
            // Clone() это deep copy, но у нас нет полной physical model).
            try {
                const copy = Object.assign({}, this);
                copy.Children = (this.Children || []).slice();
                copy.Parent = undefined;
                return copy;
            } catch (_) {
                return undefined;
            }
        },
        // Старый Roblox API: lowercase :clone()
        clone: function () { return this.Clone && this.Clone(); },
        // model:makeJoints() — заглушка (Welds мы не делаем)
        MakeJoints: function () {},
        makeJoints: function () {},
        BreakJoints: function () {},
        breakJoints: function () {},
        Remove: function () { this.Parent = undefined; },
        remove: function () { this.Parent = undefined; },
        GetAttribute: function (n) { return (this.Attributes || {})[n]; },
        SetAttribute: function (n, v) {
            if (!this.Attributes) this.Attributes = {};
            this.Attributes[n] = v;
        },
        GetPropertyChangedSignal: function () { return this.Changed; },
    };
    return _instanceMethods;
}

// Создаёт stub-signal который ничего не делает — для unknown свойств Instance
// которые скрипты пытаются использовать как сигнал (script.Parent.Selected:Connect).
function makeStubSignal() {
    const sig = makeSignal();
    // Помечаем чтобы знать что это stub (для возможной отладки)
    sig.__stub = true;
    return sig;
}

// Callable proxy: сам вызывается как function (ничего не делает), также имеет
// поля Connect/Disconnect и Fire/fire — то есть выглядит и как метод, и как
// сигнал, и как объект. Используется для unknown method-like свойств.
function makeStubCallable() {
    const fn = function () { return undefined; };
    fn.__stub = true;
    fn.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; };
    fn.connect = fn.Connect;
    fn.Fire = function () {};
    fn.fire = fn.Fire;
    fn.Wait = function () { return undefined; };
    fn.wait = fn.Wait;
    return fn;
}

// Эвристика: какие имена свойств вероятно сигналы?
// В Roblox сигналы заканчиваются на: Changed, Added, Removed, Began, Ended,
// Clicked, Activated, Touched, Selected, Deselected, Equipped, Unequipped, и т.д.
function isProbablySignalName(prop) {
    if (typeof prop !== 'string') return false;
    return /^(Mouse|Touch|Input|Render|Step|Heart|Render|On|Char|Player|Selected|Deselect|Equipped|Unequipped|Activated|Click|Changed|Added|Removed|Began|Ended|Died|Spawned|Reached|Loaded|Hover)/.test(prop)
        || /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop);
}

// Универсальный object-stub: ведёт себя как сигнал, как Instance, как Tool/Folder.
// НЕ function — иначе wasmoon мапит в Lua-function и Lua-индексация `.field`
// падает с "attempt to index a function value".
function makeObjectStub(name) {
    const target = {
        __stubName: name || 'stub',
        // Signal API
        Connect() { return { Disconnect() {}, disconnect() {}, Connected: false }; },
        connect() { return this.Connect(); },
        Wait() { return undefined; },
        wait() { return undefined; },
        Fire() {},
        fire() {},
        Disconnect() {},
        disconnect() {},
        // Instance read-API
        FindFirstChild() { return undefined; },
        FindFirstChildOfClass() { return undefined; },
        FindFirstAncestor() { return undefined; },
        FindFirstAncestorOfClass() { return undefined; },
        GetChildren() { return []; },
        GetDescendants() { return []; },
        IsA() { return false; },
        GetFullName() { return name || 'stub'; },
        Destroy() {},
        Clone() { return makeObjectStub(name); },
        GetAttribute() { return undefined; },
        SetAttribute() {},
        GetPropertyChangedSignal() { return makeObjectStub('Changed'); },
        // Tool/Animation/Sound — частые no-op методы
        Activate() {}, Deactivate() {}, Equip() {}, Unequip() {},
        Play() {}, Stop() {}, Pause() {}, Resume() {},
        AdjustSpeed() {}, LoadAnimation() { return makeObjectStub('Animation'); },
        TakeDamage() {}, MoveTo() {},
        // Базовые поля
        Parent: undefined,
        Name: name || 'stub',
        ClassName: 'Folder',
        Children: [],
    };
    target.WaitForChild = function (childName) { return makeObjectStub(childName); };
    return new Proxy(target, {
        get(t, prop) {
            if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) {
                return t[prop];
            }
            if (typeof prop !== 'string') return undefined;
            if (prop === 'then' || prop === 'catch' || prop === 'finally' ||
                prop === 'toJSON' || prop === 'constructor' || prop === 'prototype' ||
                prop.startsWith('__') || prop.startsWith('Symbol')) {
                return undefined;
            }
            const child = makeObjectStub(prop);
            t[prop] = child;
            return child;
        },
        set(t, prop, value) { t[prop] = value; return true; },
    });
}

function newInstance(className, name) {
    const m = makeInstanceMethods();
    const target = {
        ClassName: className || 'Instance',
        Name: name || className || 'Instance',
        Parent: undefined,
        Children: [],
        Destroyed: false,
        Attributes: {},
        ChildAdded: makeSignal(),
        ChildRemoved: makeSignal(),
        AncestryChanged: makeSignal(),
        Changed: makeSignal(),
        GetChildren: m.GetChildren,
        GetDescendants: m.GetDescendants,
        FindFirstChild: m.FindFirstChild,
        FindFirstChildOfClass: m.FindFirstChildOfClass,
        FindFirstAncestor: m.FindFirstAncestor,
        FindFirstAncestorOfClass: m.FindFirstAncestorOfClass,
        WaitForChild: m.WaitForChild,
        IsA: m.IsA,
        GetFullName: m.GetFullName,
        Destroy: m.Destroy,
        Clone: m.Clone,
        GetAttribute: m.GetAttribute,
        SetAttribute: m.SetAttribute,
        GetPropertyChangedSignal: m.GetPropertyChangedSignal,
    };
    // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали.
    let proxyRef;
    proxyRef = new Proxy(target, {
        get(t, prop) {
            // Существующее свойство всегда возвращаем как есть (включая методы)
            if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) {
                return t[prop];
            }
            // Не-строки и Symbol.* — undefined чтобы wasmoon не путался
            if (typeof prop !== 'string') return undefined;
            // wasmoon JS-internal ключи — undefined
            if (prop === 'then' || prop === 'catch' || prop === 'finally' ||
                prop === 'toJSON' || prop === 'toString' || prop === 'valueOf' ||
                prop === 'constructor' || prop === 'prototype' ||
                prop.startsWith('__') || prop.startsWith('Symbol')) {
                return undefined;
            }
            // Object-stub: ведёт себя как сигнал (Connect), как Instance
            // (WaitForChild, GetChildren), как Tool (Activate). НЕ function —
            // иначе Lua упадёт с "attempt to index a function value".
            const stub = makeObjectStub(prop);
            t[prop] = stub;
            return stub;
        },
        set(t, prop, value) {
            // Авто-управление иерархией при `inst.Parent = X`:
            // 1) удаляем себя из Children старого Parent
            // 2) пушим в Children нового Parent
            // 3) фейерим ChildAdded/ChildRemoved
            if (prop === 'Parent') {
                const oldP = t.Parent;
                if (oldP && oldP.Children) {
                    const i = oldP.Children.indexOf(proxyRef);
                    if (i >= 0) {
                        oldP.Children.splice(i, 1);
                        try { oldP.ChildRemoved && oldP.ChildRemoved.Fire(proxyRef); } catch (_) {}
                    }
                }
                t[prop] = value;
                if (value && value.Children && value.Children.indexOf(proxyRef) < 0) {
                    value.Children.push(proxyRef);
                    try { value.ChildAdded && value.ChildAdded.Fire(proxyRef); } catch (_) {}
                }
                // Спец-регистрация для ClickDetector — чтобы клик по Part
                // мог сфейерить MouseClick через fireTargetEvent.
                if (t.ClassName === 'ClickDetector' && value) {
                    try { value._clickDetector = proxyRef; } catch (_) {}
                }
                try { t.AncestryChanged && t.AncestryChanged.Fire(proxyRef, value); } catch (_) {}
                return true;
            }
            t[prop] = value;
            return true;
        },
        has(t, prop) {
            // Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на
            // условиях вроде if obj.SomeField then ...)
            return true;
        },
    });
    return proxyRef;
}

/**
 * Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов) —
 * запись свойств идёт через метод __SetProp, которое мы экспортируем
 * глобально как `__rbxl_part_set(part, prop, value)`.
 */
function newPart(primData, sendFn) {
    const p = newInstance('Part', primData.name || `Part_${primData.id}`);
    p.__primId = primData.id;
    p.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id });
    p.Touched = makeSignal();
    p.TouchEnded = makeSignal();
    p.Material = 'Plastic';

    // Внутренний state: реальные значения хранятся здесь, в Lua через getter/setter.
    p._state = {
        Position: new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0),
        Size: new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1),
        Color: primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5),
        Anchored: !!primData.anchored,
        CanCollide: primData.canCollide !== false,
        Transparency: primData.opacity != null ? (1 - primData.opacity) : 0,
    };

    // Setter'ы шлют partSet → BabylonScene.primitiveManager через handleLuaCommand.
    const send = (prop, value) => {
        try { sendFn('partSet', { primId: p.__primId, prop, value }); } catch (_) {}
    };
    Object.defineProperty(p, 'Position', {
        get() { return p._state.Position; },
        set(v) {
            if (!v) return;
            const nv = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0);
            p._state.Position = nv;
            send('position', { x: nv.X, y: nv.Y, z: nv.Z });
        },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'Size', {
        get() { return p._state.Size; },
        set(v) {
            if (!v) return;
            const nv = new RbxVector3(v.X || 1, v.Y || 1, v.Z || 1);
            p._state.Size = nv;
            send('size', { sx: nv.X, sy: nv.Y, sz: nv.Z });
        },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'Color', {
        get() { return p._state.Color; },
        set(v) {
            if (!v) return;
            const nv = v instanceof RbxColor3 ? v : new RbxColor3(v.R || 0, v.G || 0, v.B || 0);
            p._state.Color = nv;
            // handleLuaCommand ожидает строку для color
            send('color', nv.toHex());
        },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'BrickColor', {
        get() { return { Color: p._state.Color, Name: 'Custom' }; },
        set(v) { if (v && v.Color) p.Color = v.Color; },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'Anchored', {
        get() { return p._state.Anchored; },
        set(v) {
            p._state.Anchored = !!v;
            send('anchored', !!v);
        },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'CanCollide', {
        get() { return p._state.CanCollide; },
        set(v) {
            p._state.CanCollide = !!v;
            send('canCollide', !!v);
        },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'Transparency', {
        get() { return p._state.Transparency; },
        set(v) {
            const nv = Math.max(0, Math.min(1, Number(v) || 0));
            p._state.Transparency = nv;
            // handleLuaCommand ожидает number для opacity
            send('opacity', 1 - nv);
        },
        enumerable: true, configurable: true,
    });
    Object.defineProperty(p, 'CFrame', {
        get() {
            const pos = p._state.Position;
            return new RbxCFrame(pos.X, pos.Y, pos.Z);
        },
        set(v) {
            if (v && v.Position) p.Position = v.Position;
            else if (v && v.X != null) p.Position = new RbxVector3(v.X, v.Y, v.Z);
        },
        enumerable: true, configurable: true,
    });
    return p;
}

// ---------- Регистрация в Lua ----------
export function registerRobloxShim(lua, opts) {
    const { send } = opts;
    const global = lua.global;

    // === Базовые типы ===
    global.set('Vector3', {
        new: (x, y, z) => new RbxVector3(x, y, z),
        zero: RbxVector3.zero, one: RbxVector3.one,
        xAxis: RbxVector3.xAxis, yAxis: RbxVector3.yAxis, zAxis: RbxVector3.zAxis,
        FromNormalId: () => new RbxVector3(),
    });
    global.set('Color3', {
        new: (r, g, b) => new RbxColor3(r, g, b),
        fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b),
        fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v),
        fromHex: (hex) => RbxColor3.fromHex(hex),
    });
    // BrickColor — старая система цветов Roblox по имени
    const BRICK_COLORS = {
        'White': [1, 1, 1], 'Black': [0.1, 0.1, 0.1], 'Grey': [0.6, 0.6, 0.6],
        'Bright red': [0.77, 0.2, 0.2], 'Bright blue': [0.05, 0.4, 0.7],
        'Bright green': [0.3, 0.8, 0.2], 'Bright yellow': [1, 0.85, 0.1],
        'Bright orange': [0.85, 0.5, 0.15], 'Bright violet': [0.45, 0.2, 0.65],
        'Dark blue': [0.05, 0.15, 0.4], 'Dark green': [0.15, 0.4, 0.2],
        'Dark red': [0.4, 0.1, 0.1], 'Lime green': [0.7, 0.95, 0.3],
        'Pink': [1, 0.55, 0.7], 'Brown': [0.4, 0.25, 0.15],
        'Reddish brown': [0.45, 0.2, 0.15], 'Sand red': [0.85, 0.6, 0.55],
        'Medium blue': [0.4, 0.65, 0.85], 'Cyan': [0, 0.8, 0.8],
        'Magenta': [0.85, 0, 0.85], 'Really red': [1, 0, 0], 'Really blue': [0, 0, 1],
        'Really black': [0, 0, 0], 'Really white': [1, 1, 1],
    };
    function _brickToColor3(name) {
        const rgb = BRICK_COLORS[name] || [0.5, 0.5, 0.5];
        return new RbxColor3(rgb[0], rgb[1], rgb[2]);
    }
    global.set('BrickColor', {
        new(nameOrR, g, b) {
            // BrickColor.new("Bright red") или BrickColor.new(r, g, b)
            const name = typeof nameOrR === 'string' ? nameOrR : 'White';
            const c = typeof nameOrR === 'string'
                ? _brickToColor3(nameOrR)
                : new RbxColor3(nameOrR, g, b);
            return { Color: c, Name: name, Number: 1, R: c.R, G: c.G, B: c.B,
                     r: c.R, g: c.G, b: c.B };
        },
        random() { return { Color: new RbxColor3(Math.random(), Math.random(), Math.random()), Name: 'Random' }; },
        White() { return this.new('White'); },
        Black() { return this.new('Black'); },
        Gray() { return this.new('Grey'); },
        Red() { return this.new('Bright red'); },
        Yellow() { return this.new('Bright yellow'); },
        Green() { return this.new('Bright green'); },
        Blue() { return this.new('Bright blue'); },
        DarkGray() { return this.new('Dark stone grey'); },
        palette(n) { return this.new('White'); },
    });
    // Ray — луч, используется в raycast
    global.set('Ray', {
        new(origin, direction) { return { Origin: origin, Direction: direction }; },
    });
    // Region3 — куб в пространстве
    global.set('Region3', {
        new(min, max) { return { Min: min, Max: max, CFrame: { Position: min }, Size: max }; },
    });
    global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
    global.set('UDim2', {
        new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy),
        fromScale: (sx, sy) => RbxUDim2.fromScale(sx, sy),
        fromOffset: (ox, oy) => RbxUDim2.fromOffset(ox, oy),
    });
    global.set('Vector2', { new: (x, y) => new RbxVector2(x, y) });
    global.set('CFrame', {
        new: (x, y, z) => RbxCFrame.new(x, y, z),
        lookAt: (e, t) => RbxCFrame.lookAt(e, t),
        Angles: RbxCFrame.Angles,
        fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
    });

    // === Enum ===
    const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }]));
    global.set('Enum', {
        KeyCode: mkE(['W','A','S','D','Space','LeftShift','LeftControl','F','E','Q','R','T','Y','U','I','O','P','G','H','J','K','L','Z','X','C','V','B','N','M','Tab','Return','Escape','Backspace','Up','Down','Left','Right','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Zero']),
        UserInputType: mkE(['MouseButton1','MouseButton2','MouseButton3','MouseMovement','MouseWheel','Touch','Keyboard']),
        Material: mkE(['Plastic','Wood','Metal','Neon','Glass','Sand','Ice','Grass','Concrete']),
        HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']),
        EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']),
        EasingDirection: mkE(['In','Out','InOut']),
        // Часто используемые в туториалах
        InfoType: mkE(['Asset','BundleDetails','Subscription','GamePass','UserProductsInExperience']),
        SortOrder: mkE(['Name','Custom','LayoutOrder']),
        FillDirection: mkE(['Horizontal','Vertical']),
        HorizontalAlignment: mkE(['Left','Center','Right']),
        VerticalAlignment: mkE(['Top','Center','Bottom']),
        Font: mkE(['Legacy','Arial','SourceSans','Code','Highway','SciFi','Cartoon','Gotham','GothamBold']),
        TextXAlignment: mkE(['Left','Center','Right']),
        TextYAlignment: mkE(['Top','Center','Bottom']),
        ScaleType: mkE(['Stretch','Slice','Tile','Fit','Crop']),
        AspectType: mkE(['FitWithinMaxSize','ScaleWithParentSize']),
        DominantAxis: mkE(['Width','Height']),
        BorderMode: mkE(['Outline','Middle','Inset']),
        FormFactor: mkE(['Symmetric','Brick','Plate','Custom']),
        PartType: mkE(['Ball','Block','Cylinder','Wedge','CornerWedge']),
        SurfaceType: mkE(['Smooth','Glue','Weld','Studs','Inlet','Universal']),
        ContextActionResult: mkE(['Pass','Sink']),
        UserInputState: mkE(['Begin','Change','End','Cancel','None']),
    });

    // TweenInfo — конструктор объекта с параметрами анимации
    // Сигнатура: TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime)
    global.set('TweenInfo', {
        new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) {
            return {
                Time: time || 1,
                EasingStyle: easingStyle,
                EasingDirection: easingDirection,
                RepeatCount: repeatCount || 0,
                Reverses: !!reverses,
                DelayTime: delayTime || 0,
            };
        },
    });

    // NumberSequence, ColorSequence — упрощённые конструкторы для GUI-эффектов
    global.set('NumberSequence', {
        new(...args) { return { Keypoints: [], __ns: true }; },
    });
    global.set('ColorSequence', {
        new(...args) { return { Keypoints: [], __cs: true }; },
    });
    global.set('NumberRange', {
        new(min, max) { return { Min: min, Max: max == null ? min : max }; },
    });
    global.set('Rect', {
        new(minX, minY, maxX, maxY) { return { Min: { X: minX, Y: minY }, Max: { X: maxX, Y: maxY } }; },
    });

    // === print / warn ===
    const stringify = (v) => {
        if (v == null) return 'nil';
        if (typeof v === 'string') return v;
        if (typeof v === 'number') return String(v);
        if (typeof v === 'boolean') return v ? 'true' : 'false';
        if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`;
        if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`;
        if (typeof v === 'object') {
            if (v.Name) return String(v.Name);
            return '[object]';
        }
        try { return String(v); } catch (_) { return '?'; }
    };
    global.set('print', (...args) => {
        send('log', { level: 'info', text: args.map(stringify).join('\t') });
    });
    global.set('warn', (...args) => {
        send('log', { level: 'warn', text: args.map(stringify).join('\t') });
    });

    // require(ModuleScript) — в Roblox загружает модуль. У нас модулей нет —
    // возвращаем undefined (Lua nil) чтобы скрипты типа local mod = require(...)
    // не падали. require строкой (стандартный Lua) перехватывать не будем.
    global.set('require', (mod) => {
        // Если передали Instance-stub — возвращаем сам stub (чтобы хоть
        // что-то можно было сделать с возвращённым значением).
        if (mod && typeof mod === 'object') return mod;
        return undefined;
    });

    // === task.* + wait ===
    // task.wait/wait — реальный yield через coroutines. Юзер пишет:
    //   while true do part.Position = ... ; task.wait(0.1) end
    // Это работает потому что **скрипт сам запускается как coroutine**
    // (см. LuaSharedSandbox._startSingleScript → мы оборачиваем код в pcall,
    // НО для yield нам нужно завернуть в coroutine.create). Делаем это
    // через Lua-prelude: глобальная функция `_run_in_coroutine(fn)`.
    global.set('task', {
        spawn: (fn) => {
            try { if (typeof fn === 'function') fn(); } catch (e) {
                send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` });
            }
        },
        delay: (sec, fn) => {
            if (typeof fn !== 'function') return;
            SCHEDULER.sleeping.push({
                wakeAt: SCHEDULER.now() + (Number(sec) || 0) * 1000,
                run: () => { try { fn(); } catch (e) {
                    send('log', { level: 'error', text: `[task.delay] ${e?.message || e}` });
                } },
            });
        },
        defer: (fn) => {
            if (typeof fn !== 'function') return;
            SCHEDULER.sleeping.push({
                wakeAt: SCHEDULER.now(),
                run: () => { try { fn(); } catch (_) {} },
            });
        },
        synchronize: () => {},
        desynchronize: () => {},
    });
    // task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже)

    // === DataModel ===
    const game = newInstance('DataModel', 'game');
    const workspace = newInstance('Workspace', 'Workspace');
    workspace.Parent = game;
    workspace.Gravity = 196.2;
    workspace.CurrentCamera = newInstance('Camera', 'Camera');
    workspace.CurrentCamera.Parent = workspace;
    workspace.Children.push(workspace.CurrentCamera);
    workspace.Terrain = newInstance('Terrain', 'Terrain');
    workspace.Terrain.Parent = workspace;
    workspace.Children.push(workspace.Terrain);
    game.Children.push(workspace);
    game.Workspace = workspace;

    const players = newInstance('Players', 'Players');
    players.Parent = game;
    players.PlayerAdded = makeSignal();
    players.PlayerRemoving = makeSignal();
    game.Children.push(players);
    game.Players = players;

    const localPlayer = newInstance('Player', 'Player');
    localPlayer.Parent = players;
    localPlayer.UserId = 1;
    // PlayerGui — контейнер для GUI принадлежащих игроку. В Rublox это no-op
    // (overlay глобальный), но Roblox-скрипты часто делают gui.Parent = playerGui.
    const playerGui = newInstance('PlayerGui', 'PlayerGui');
    playerGui.Parent = localPlayer;
    localPlayer.Children.push(playerGui);
    localPlayer.PlayerGui = playerGui;
    localPlayer.DisplayName = 'Player';
    localPlayer.Name = 'Player';
    localPlayer.Neutral = true;       // не в команде по умолчанию
    localPlayer.Team = undefined;
    localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) };
    localPlayer.Kick = function () {};
    localPlayer.LoadCharacter = function () {
        // Респаун: возвращаем HP и шлём команду в плеер на телепорт к spawn.
        // Plus сбрасываем humanoid.Health на MaxHealth.
        try {
            if (humanoid && humanoid.MaxHealth) {
                humanoid.Health = humanoid.MaxHealth;
            }
            send('playerSet', { prop: 'respawn', value: true });
        } catch (_) {}
    };
    localPlayer.HasAppearanceLoaded = function () { return true; };
    // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически
    // клонируется в Backpack каждого спавнящегося игрока.
    const backpack = newInstance('Backpack', 'Backpack');
    backpack.Parent = localPlayer;
    localPlayer.Children.push(backpack);
    localPlayer.Backpack = backpack;
    // Глобальный Mouse — единственный экземпляр на игрока, привязан к окну
    // браузера. Реальные Button1Down/Hit фейерятся в GameRuntime.
    const playerMouse = (function makePlayerMouse() {
        const m = newInstance('Mouse', 'Mouse');
        m.Button1Down = makeSignal();
        m.Button1Up = makeSignal();
        m.Button2Down = makeSignal();
        m.Button2Up = makeSignal();
        m.Move = makeSignal();
        m.KeyDown = makeSignal();
        m.KeyUp = makeSignal();
        m.WheelForward = makeSignal();
        m.WheelBackward = makeSignal();
        m.Idle = makeSignal();
        // m.Icon reactive — меняет CSS cursor на canvas
        let _icon = '';
        Object.defineProperty(m, 'Icon', {
            get() { return _icon; },
            set(v) {
                _icon = String(v || '');
                // rbxassetid → стрелочный курсор-прицел (наш дефолт)
                const cssCursor = _icon && _icon.includes('rbxasset')
                    ? 'crosshair' : (_icon ? 'crosshair' : 'default');
                send('mouseIconChanged', { icon: _icon, cssCursor });
            },
        });
        m.X = 0; m.Y = 0;
        m.ViewSizeX = 1920; m.ViewSizeY = 1080;
        m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0),
                  Lookvector: new RbxVector3(0, 0, -1) };
        m.Origin = { Position: new RbxVector3(0, 5, 0) };
        m.Target = undefined;
        m.TargetFilter = undefined;
        m.TargetSurface = 'Top';
        return m;
    })();
    localPlayer.GetMouse = function () { return playerMouse; };
    localPlayer.playerMouse = playerMouse;
    players.Children.push(localPlayer);
    players.LocalPlayer = localPlayer;

    // === Tool registry ===
    // Tracks все Tool-инстансы — для UI (hotbar) и equip-flow.
    // GameRuntime читает API equipTool/unequipTool на main-loop.
    const allTools = []; // [Tool, ...] в порядке создания (для hotbar 1-9)
    let equippedTool = null;

    const character = newInstance('Model', 'Player');
    character.Parent = localPlayer;
    localPlayer.Children.push(character);
    localPlayer.Character = character;
    localPlayer.CharacterAdded = makeSignal();
    localPlayer.CharacterRemoving = makeSignal();
    localPlayer.CharacterAppearanceLoaded = makeSignal();

    const humanoid = newInstance('Humanoid', 'Humanoid');
    humanoid.Parent = character;
    let _hp = 100, _maxHp = 100, _ws = 16, _jp = 50;
    Object.defineProperty(humanoid, 'Health', {
        get() { return _hp; },
        set(v) {
            _hp = Math.max(0, Math.min(_maxHp, Number(v) || 0));
            try { humanoid.HealthChanged.Fire(_hp); } catch (_) {}
            send('playerSet', { prop: 'health', value: _hp });
        },
    });
    Object.defineProperty(humanoid, 'MaxHealth', {
        get() { return _maxHp; },
        set(v) {
            _maxHp = Math.max(1, Number(v) || 100);
            if (_hp > _maxHp) humanoid.Health = _maxHp;
            send('playerSet', { prop: 'maxHealth', value: _maxHp });
        },
    });
    Object.defineProperty(humanoid, 'WalkSpeed', {
        get() { return _ws; },
        set(v) { _ws = Number(v) || 16; send('playerSet', { prop: 'walkSpeed', value: _ws }); },
    });
    Object.defineProperty(humanoid, 'JumpPower', {
        get() { return _jp; },
        set(v) { _jp = Number(v) || 50; send('playerSet', { prop: 'jumpPower', value: _jp }); },
    });
    humanoid.Died = makeSignal();
    humanoid.HealthChanged = makeSignal();
    humanoid.Touched = makeSignal();
    humanoid.StateChanged = makeSignal();
    humanoid.TakeDamage = function (n) {
        const v = Math.max(0, (this.Health || 100) - (Number(n) || 0));
        this.Health = v;
        this.HealthChanged.Fire(v);
        if (v === 0) {
            // Creator-tag: ищем creator-ObjectValue в Humanoid.Children для kill feed
            let killerName = null;
            for (const c of (this.Children || [])) {
                if (c && c.Name === 'creator' && c.Value) {
                    killerName = String(c.Value.Name || c.Value.DisplayName || '?');
                    break;
                }
            }
            if (killerName) {
                send('killFeed', { killer: killerName, victim: localPlayer.Name || 'Player', weapon: '' });
            }
            this.Died.Fire();
            // В Roblox после Died игрок респавнится — у нас через playerSet=respawn
            setTimeout(() => {
                this.Health = this.MaxHealth || 100;
                this.HealthChanged.Fire(this.Health);
                send('playerSet', { prop: 'health', value: this.Health });
            }, 2000);
        }
        send('playerSet', { prop: 'health', value: v });
    };
    humanoid.MoveTo = function () {};
    humanoid.LoadAnimation = function () { return { Play: () => {}, Stop: () => {}, AdjustSpeed: () => {} }; };
    character.Children.push(humanoid);
    character.Humanoid = humanoid;

    const hrp = newInstance('Part', 'HumanoidRootPart');
    hrp.Parent = character;
    hrp._position = new RbxVector3(0, 5, 0);
    hrp.Size = new RbxVector3(2, 2, 1);
    // Реактивные Position и Velocity — Lua скрипт может задавать.
    Object.defineProperty(hrp, 'Position', {
        get() { return hrp._position; },
        set(v) {
            if (!v) return;
            hrp._position = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0);
            try { send('playerSet', { prop: 'position',
                value: { x: hrp._position.X, y: hrp._position.Y, z: hrp._position.Z } }); }
            catch (_) {}
        },
    });
    let _hrpCFrame = null;
    Object.defineProperty(hrp, 'CFrame', {
        get() { return _hrpCFrame || { Position: hrp._position, p: hrp._position }; },
        set(v) {
            if (!v) return;
            _hrpCFrame = v;
            const pos = v.Position || v.p || v;
            if (pos && pos.X !== undefined) {
                hrp._position = new RbxVector3(pos.X, pos.Y, pos.Z);
                try { send('playerSet', { prop: 'position',
                    value: { x: pos.X, y: pos.Y, z: pos.Z } }); }
                catch (_) {}
            }
        },
    });
    Object.defineProperty(hrp, 'Velocity', {
        get() { return hrp._velocity || new RbxVector3(0, 0, 0); },
        set(v) {
            if (!v) return;
            hrp._velocity = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0);
            if (v.Y > 10) {
                try { send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); }
                catch (_) {}
            }
        },
    });
    character.Children.push(hrp);
    character.HumanoidRootPart = hrp;
    character.PrimaryPart = hrp;

    // === Сервисы ===
    const services = {};
    const makeService = (name) => {
        if (services[name]) return services[name];
        const s = newInstance(name, name);
        s.Parent = game;
        game.Children.push(s);
        services[name] = s;
        game[name] = s;
        return s;
    };
    makeService('ReplicatedStorage');
    makeService('ServerStorage');
    makeService('StarterGui');
    makeService('StarterPack');
    makeService('StarterPlayer');

    // Teams сервис — PvP-команды (TeamBeacon Black/Blue/Red/Green в Roblox Battle)
    const teams = makeService('Teams');
    teams.Children = [];
    teams.GetTeams = function () { return teams.Children.slice(); };
    teams.GetChildren = function () { return teams.Children.slice(); };

    const uis = makeService('UserInputService');
    uis.InputBegan = makeSignal();
    uis.InputChanged = makeSignal();
    uis.InputEnded = makeSignal();

    // TweenService — реальная интерполяция через Heartbeat
    const tw = makeService('TweenService');
    const activeTweens = []; // [{inst, props, duration, startAt, startVals, onDone}]
    tw.Create = function (inst, info, propGoals) {
        // info: TweenInfo (duration, EasingStyle, ...) — упрощённо берём только duration
        const duration = (info && (info.Time || info.duration)) || 1;
        const tween = {
            __completed: makeSignal(),
            Completed: undefined,
            Play() {
                if (!inst || !propGoals) return;
                const startVals = {};
                for (const k of Object.keys(propGoals)) {
                    try { startVals[k] = inst[k]; } catch (_) {}
                }
                activeTweens.push({
                    inst, props: propGoals, duration,
                    startAt: performance.now(),
                    startVals,
                    onDone: () => tween.__completed.Fire(),
                });
            },
            Pause() {},
            Cancel() {},
        };
        tween.Completed = tween.__completed;
        return tween;
    };
    function _stepTweens(_dt) {
        if (activeTweens.length === 0) return;
        const now = performance.now();
        for (let i = activeTweens.length - 1; i >= 0; i--) {
            const t = activeTweens[i];
            const elapsed = (now - t.startAt) / 1000;
            const k = Math.min(1, elapsed / t.duration);
            for (const prop of Object.keys(t.props)) {
                const goal = t.props[prop];
                const start = t.startVals[prop];
                if (!start || !goal) continue;
                if (start instanceof RbxVector3 && goal instanceof RbxVector3) {
                    try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {}
                } else if (start instanceof RbxColor3 && goal instanceof RbxColor3) {
                    try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {}
                } else if (typeof start === 'number' && typeof goal === 'number') {
                    try { t.inst[prop] = start + (goal - start) * k; } catch (_) {}
                }
            }
            if (k >= 1) {
                activeTweens.splice(i, 1);
                try { t.onDone(); } catch (_) {}
            }
        }
    }

    const http = makeService('HttpService');
    http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } };
    http.JSONDecode = function (s) { try { return JSON.parse(s); } catch (_) { return undefined; } };
    http.GenerateGUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
        const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16);
    });

    const lighting = makeService('Lighting');
    lighting.Ambient = new RbxColor3(0.5, 0.5, 0.5);
    lighting.Brightness = 1;
    lighting.ClockTime = 14;
    lighting.TimeOfDay = "14:00:00";
    lighting.OutdoorAmbient = new RbxColor3(0.5, 0.5, 0.5);
    lighting.FogEnd = 100000;
    lighting.FogStart = 0;
    lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75);
    lighting._minutes = 14 * 60;
    lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; };
    let _lastLightSent = 0;
    lighting.SetMinutesAfterMidnight = function (m) {
        lighting._minutes = (Number(m) || 0) % 1440;
        lighting.ClockTime = lighting._minutes / 60;
        // Тротлинг: не чаще раза в 250мс. Скрипты Day/Night обновляют это
        // каждый кадр (100+ Hz), это убивает WASM.
        const now = performance.now();
        if (now - _lastLightSent < 250) return;
        _lastLightSent = now;
        send('lightingTimeUpdate', { hour: lighting.ClockTime });
    };
    lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); };
    lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); };
    makeService('Chat');
    const soundService = makeService('SoundService');
    soundService.PlayLocalSound = function (sound) {
        if (sound && typeof sound.Play === 'function') sound.Play();
    };
    makeService('PathfindingService');

    // CollectionService — теги на инстансах
    const cs = makeService('CollectionService');
    const tagMap = new Map();          // tag → Set<instance>
    const instTags = new WeakMap();    // instance → Set<tag>
    const tagAddSignals = new Map();   // tag → Signal (InstanceAddedSignal)
    const tagRemoveSignals = new Map(); // tag → Signal (InstanceRemovedSignal)
    cs.AddTag = function (inst, tag) {
        if (!inst || !tag) return;
        let set = tagMap.get(tag);
        if (!set) { set = new Set(); tagMap.set(tag, set); }
        if (set.has(inst)) return;
        set.add(inst);
        let tags = instTags.get(inst);
        if (!tags) { tags = new Set(); instTags.set(inst, tags); }
        tags.add(tag);
        const sig = tagAddSignals.get(tag);
        if (sig) try { sig.Fire(inst); } catch (_) {}
    };
    cs.RemoveTag = function (inst, tag) {
        const set = tagMap.get(tag);
        if (set) set.delete(inst);
        const tags = instTags.get(inst);
        if (tags) tags.delete(tag);
        const sig = tagRemoveSignals.get(tag);
        if (sig) try { sig.Fire(inst); } catch (_) {}
    };
    cs.HasTag = function (inst, tag) {
        const set = tagMap.get(tag);
        return !!(set && set.has(inst));
    };
    cs.GetTagged = function (tag) {
        const set = tagMap.get(tag);
        return set ? [...set] : [];
    };
    cs.GetTags = function (inst) {
        const tags = instTags.get(inst);
        return tags ? [...tags] : [];
    };
    cs.GetInstanceAddedSignal = function (tag) {
        let sig = tagAddSignals.get(tag);
        if (!sig) { sig = makeSignal(); tagAddSignals.set(tag, sig); }
        return sig;
    };
    cs.GetInstanceRemovedSignal = function (tag) {
        let sig = tagRemoveSignals.get(tag);
        if (!sig) { sig = makeSignal(); tagRemoveSignals.set(tag, sig); }
        return sig;
    };

    // Debris — удаление инстансов через N секунд
    const debris = makeService('Debris');
    debris.AddItem = function (inst, lifetime) {
        if (!inst || typeof inst.Destroy !== 'function') return;
        const t = Math.max(0, Number(lifetime) || 0);
        setTimeout(() => {
            try { inst.Destroy(); } catch (_) {}
        }, t * 1000);
    };

    makeService('MarketplaceService');

    const ds = makeService('DataStoreService');
    ds.GetDataStore = function () {
        return {
            GetAsync: () => undefined, SetAsync: () => {}, UpdateAsync: () => {},
            RemoveAsync: () => {}, IncrementAsync: () => {},
        };
    };

    const ctx = makeService('ContextActionService');
    ctx.BindAction = () => {};
    ctx.UnbindAction = () => {};

    const runService = makeService('RunService');
    runService.Heartbeat = HEARTBEAT_SIGNAL;
    runService.Stepped = STEPPED_SIGNAL;
    runService.RenderStepped = HEARTBEAT_SIGNAL;
    runService.IsClient = () => true;
    runService.IsServer = () => true;
    runService.IsRunning = () => true;
    runService.IsStudio = () => false;

    game.GetService = function (name) {
        if (name === 'Workspace') return workspace;
        if (name === 'Players') return players;
        return services[name] || makeService(name);
    };
    // Старый Roblox API: game:service(name) lowercase
    game.service = game.GetService;
    game.GetServiceFromName = game.GetService;
    game.FindService = function (name) { return services[name] || null; };
    game.JobId = '';
    game.PlaceId = 0;
    game.GameId = 0;
    game.CreatorId = 0;
    game.CreatorType = 'User';

    // Players API extensions
    players.GetPlayers = function () { return [players.LocalPlayer].filter(Boolean); };
    players.GetPlayerFromCharacter = function (character) {
        if (character && players.LocalPlayer && players.LocalPlayer.Character === character) {
            return players.LocalPlayer;
        }
        return undefined;
    };
    players.playerFromCharacter = players.GetPlayerFromCharacter;
    players.PlayerAdded = makeSignal();
    players.PlayerRemoving = makeSignal();
    players.ChildAdded = makeSignal();

    global.set('game', game);
    global.set('Game', game);
    global.set('workspace', workspace);
    global.set('Workspace', workspace);

    // === Instance.new ===
    // === Helper: создание GUI-элемента через game.gui.create ===
    // Roblox: Frame/TextLabel/TextButton/ImageLabel/TextBox/ScrollingFrame.
    // Шлём gui.create команду в main thread → GuiManager создаёт элемент.
    // Возвращаем Lua-объект с setter'ами для основных свойств.
    let _nextGuiLocalRef = 0;
    function newGuiInstance(robloxClass) {
        const localRef = `_gui_lua_${_nextGuiLocalRef++}`;
        const inst = newInstance(robloxClass, robloxClass);
        inst.__guiLocalRef = localRef;
        inst.__guiClass = robloxClass;
        // Маппим Roblox-класс на тип в GuiManager
        const guiType = ({
            Frame: 'frame',
            TextLabel: 'text',
            TextButton: 'button',
            ImageLabel: 'image',
            ImageButton: 'button',
            TextBox: 'textbox',
            ScrollingFrame: 'scroll',
        })[robloxClass] || 'frame';
        // Внутренние стейты
        inst._gui = {
            type: guiType,
            text: '',
            bgColor: '#3a2820',
            bgOpacity: 1,
            textColor: '#f0e6d8',
            textSize: 16,
            x: 50, y: 50, w: 20, h: 10,
            visible: true,
        };
        // Шлём create при первом обращении (lazy) или сейчас — лучше сейчас, чтобы
        // не было гонок при моментальной правке свойств после Instance.new.
        send('gui.create', {
            type: guiType,
            opts: { ...inst._gui, _scriptCreated: true },
            localRef,
        });
        // Сигналы (для кнопок)
        if (robloxClass === 'TextButton' || robloxClass === 'ImageButton') {
            inst.MouseButton1Click = makeSignal();
            inst.MouseEnter = makeSignal();
            inst.MouseLeave = makeSignal();
            inst.Activated = inst.MouseButton1Click;
        }
        // Setters
        const updateField = (field, value) => {
            inst._gui[field] = value;
            send('gui.update', { id: localRef, patch: { [field]: value } });
        };
        Object.defineProperty(inst, 'Text', {
            get() { return inst._gui.text; },
            set(v) { updateField('text', String(v ?? '')); },
            enumerable: true,
        });
        Object.defineProperty(inst, 'Visible', {
            get() { return inst._gui.visible; },
            set(v) { updateField('visible', !!v); },
            enumerable: true,
        });
        Object.defineProperty(inst, 'BackgroundColor3', {
            get() { return RbxColor3.fromHex(inst._gui.bgColor); },
            set(v) {
                if (!v) return;
                const hex = v.toHex ? v.toHex() : (v instanceof RbxColor3 ? v.toHex() : '#3a2820');
                updateField('bgColor', hex);
            },
            enumerable: true,
        });
        Object.defineProperty(inst, 'BackgroundTransparency', {
            get() { return 1 - (inst._gui.bgOpacity ?? 1); },
            set(v) { updateField('bgOpacity', 1 - Math.max(0, Math.min(1, +v || 0))); },
            enumerable: true,
        });
        Object.defineProperty(inst, 'TextColor3', {
            get() { return RbxColor3.fromHex(inst._gui.textColor); },
            set(v) {
                if (!v) return;
                const hex = v.toHex ? v.toHex() : '#f0e6d8';
                updateField('textColor', hex);
            },
            enumerable: true,
        });
        Object.defineProperty(inst, 'TextSize', {
            get() { return inst._gui.textSize; },
            set(v) { updateField('textSize', Math.max(8, Math.min(72, +v || 16))); },
            enumerable: true,
        });
        // Position: UDim2 → x,y проценты (Roblox-style: scale=%, offset=px)
        // Упрощённо берём scale*100 как x/y; offset игнорируем.
        Object.defineProperty(inst, 'Position', {
            get() {
                return new RbxUDim2(inst._gui.x / 100, 0, inst._gui.y / 100, 0);
            },
            set(v) {
                if (!v) return;
                const xPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10;
                const yPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10;
                inst._gui.x = xPct;
                inst._gui.y = yPct;
                send('gui.update', { id: localRef, patch: { x: xPct, y: yPct } });
            },
            enumerable: true,
        });
        Object.defineProperty(inst, 'Size', {
            get() {
                return new RbxUDim2(inst._gui.w / 100, 0, inst._gui.h / 100, 0);
            },
            set(v) {
                if (!v) return;
                const wPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10;
                const hPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10;
                inst._gui.w = wPct;
                inst._gui.h = hPct;
                send('gui.update', { id: localRef, patch: { w: wPct, h: hPct } });
            },
            enumerable: true,
        });
        // Destroy — удаление GUI
        const origDestroy = inst.Destroy;
        inst.Destroy = function () {
            try { send('gui.remove', { id: localRef }); } catch (_) {}
            origDestroy.call(inst);
        };
        return inst;
    }

    // Регистрация в guiByLocalRef для дальнейшей маршрутизации событий клика
    const guiByLocalRef = new Map();

    // Счётчик для новых Part'ов, создаваемых через Instance.new("Part"):
    // primitiveManager.addInstance даст уникальный id, мы используем временный
    // отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой
    // негативный id, primitiveManager заменит на свой. Для простоты — даём
    // высокий positive id (10000+ random) и primitiveManager его использует
    // если не занят.
    let _nextNewPartId = 100000 + Math.floor(Math.random() * 10000);

    global.set('Instance', {
        new: (className, parent) => {
            let inst;
            if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') {
                // Реальный примитив на сцене: шлём sceneCreate, регистрируем в partById
                const newId = _nextNewPartId++;
                const fakePrim = {
                    id: newId,
                    name: `Part_${newId}`,
                    x: 0, y: 0, z: 0,
                    sx: 4, sy: 1, sz: 2,
                    color: '#A0A0A0',
                    anchored: true,
                    canCollide: true,
                };
                send('sceneCreate', {
                    primId: newId,
                    type: className === 'WedgePart' ? 'wedge' : 'cube',
                    x: 0, y: 0, z: 0,
                    sx: 4, sy: 1, sz: 2,
                    color: '#A0A0A0',
                    anchored: true,
                    canCollide: true,
                });
                inst = newPart(fakePrim, send);
                partById.set(newId, inst);
            } else if (className === 'RemoteEvent') {
                inst = newInstance('RemoteEvent', 'RemoteEvent');
                inst.OnServerEvent = makeSignal();
                inst.OnClientEvent = makeSignal();
                inst.FireServer = function (...a) { this.OnServerEvent.Fire(localPlayer, ...a); };
                inst.FireClient = function (_p, ...a) { this.OnClientEvent.Fire(...a); };
                inst.FireAllClients = function (...a) { this.OnClientEvent.Fire(...a); };
            } else if (className === 'SpecialMesh' || className === 'BlockMesh'
                    || className === 'CylinderMesh' || className === 'FileMesh') {
                inst = newInstance(className, className);
                inst.MeshType = { Name: 'Brick', Value: 0 };
                inst.MeshId = '';
                inst.TextureId = '';
                inst.Scale = new RbxVector3(1, 1, 1);
                inst.Offset = new RbxVector3(0, 0, 0);
                inst.VertexColor = new RbxVector3(1, 1, 1);
            } else if (className === 'ClickDetector') {
                // ClickDetector — клик по 3D-объекту (нужен Тиру и т.п.).
                // Регистрация в part._clickDetector происходит автоматически
                // через Proxy.set когда юзер делает clickDet.Parent = part.
                inst = newInstance('ClickDetector', 'ClickDetector');
                inst.MouseClick = makeSignal();
                inst.MouseHoverEnter = makeSignal();
                inst.MouseHoverLeave = makeSignal();
                inst.MaxActivationDistance = 32;
            } else if (className === 'BindableEvent') {
                inst = newInstance('BindableEvent', 'BindableEvent');
                inst.Event = makeSignal();
                inst.Fire = function (...a) { this.Event.Fire(...a); };
            } else if (className === 'BindableFunction') {
                // BindableFunction — синхронный RPC внутри клиента.
                // OnInvoke = single-callback; Invoke вызывает его и возвращает значение.
                inst = newInstance('BindableFunction', 'BindableFunction');
                inst.OnInvoke = undefined; // юзер ставит function
                inst.Invoke = function (...args) {
                    if (typeof this.OnInvoke === 'function') {
                        try { return this.OnInvoke(...args); } catch (_) { return undefined; }
                    }
                    return undefined;
                };
            } else if (className === 'RemoteFunction') {
                inst = newInstance('RemoteFunction', 'RemoteFunction');
                inst.OnServerInvoke = undefined;
                inst.OnClientInvoke = undefined;
                inst.InvokeServer = function (...args) {
                    if (typeof this.OnServerInvoke === 'function') {
                        try { return this.OnServerInvoke(localPlayer, ...args); } catch (_) {}
                    }
                    return undefined;
                };
                inst.InvokeClient = function (_p, ...args) {
                    if (typeof this.OnClientInvoke === 'function') {
                        try { return this.OnClientInvoke(...args); } catch (_) {}
                    }
                    return undefined;
                };
            } else if (className === 'Message' || className === 'Hint') {
                // Roblox Message — текстовая надпись по центру экрана,
                // когда .Parent = workspace или nil. Hint — то же но мельче.
                inst = newInstance(className, className);
                let _txt = '';
                Object.defineProperty(inst, 'Text', {
                    get() { return _txt; },
                    set(v) { _txt = String(v || ''); send('hudMessage', { kind: className, text: _txt, visible: !!inst.Parent }); },
                });
                // При смене Parent: nil → скрываем, workspace → показываем
                const _origParent = Object.getOwnPropertyDescriptor(inst, 'Parent');
                Object.defineProperty(inst, 'Parent', {
                    get() { return this._parent; },
                    set(v) {
                        this._parent = v;
                        send('hudMessage', { kind: className, text: _txt, visible: !!v });
                    },
                });
            } else if (className === 'Humanoid') {
                inst = newInstance('Humanoid', 'Humanoid');
                inst.Health = 100; inst.MaxHealth = 100;
                inst.Died = makeSignal(); inst.HealthChanged = makeSignal();
                inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); };
            } else if (className === 'Sound') {
                // Sound — процедурные звуки через _playSound.
                // SoundId → имя процедурного звука (rbxassetid игнорится).
                inst = newInstance('Sound', 'Sound');
                inst.SoundId = '';
                inst.Volume = 1;
                inst.PlaybackSpeed = 1;
                inst.Pitch = 1;
                inst.Looped = false;
                inst.IsPlaying = false;
                inst.Played = makeSignal();
                inst.Ended = makeSignal();
                // Map SoundId/имя на встроенный звук (jump/pickup/win/lose/click/hit/coin).
                const _mapSoundName = (idOrName) => {
                    if (!idOrName) return 'click';
                    const s = String(idOrName).toLowerCase();
                    // Прямые ключи имеют приоритет
                    if (['jump','pickup','win','lose','click','hit','coin'].indexOf(s) >= 0) return s;
                    // Эвристика по части строки (для Roblox AssetID)
                    if (s.includes('jump')) return 'jump';
                    if (s.includes('pickup') || s.includes('collect')) return 'pickup';
                    if (s.includes('win') || s.includes('victory')) return 'win';
                    if (s.includes('lose') || s.includes('death')) return 'lose';
                    if (s.includes('hit') || s.includes('damage')) return 'hit';
                    if (s.includes('coin') || s.includes('gem')) return 'coin';
                    return 'click';
                };
                inst.Play = function () {
                    const name = _mapSoundName(this.SoundId || this.Name);
                    const pitch = +this.PlaybackSpeed || +this.Pitch || 1;
                    const volume = +this.Volume || 1;
                    send('sound.play', { name, volume, pitch });
                    this.IsPlaying = true;
                    this.Played.Fire();
                    // Простая модель: считаем что звук длится 0.5с
                    SCHEDULER.sleeping.push({
                        wakeAt: SCHEDULER.now() + 500,
                        run: () => {
                            this.IsPlaying = false;
                            this.Ended.Fire();
                            if (this.Looped) this.Play();
                        },
                    });
                };
                inst.Stop = function () { this.IsPlaying = false; };
                inst.Pause = function () { this.IsPlaying = false; };
                inst.Resume = function () { if (!this.IsPlaying) this.Play(); };
            } else if (className === 'ScreenGui') {
                // ScreenGui — логический корень GUI. В Rublox overlay глобальный,
                // поэтому ScreenGui это просто контейнер-no-op (без gui.create).
                inst = newInstance('ScreenGui', 'ScreenGui');
                inst.__isScreenGui = true;
                inst.Enabled = true;
            } else if (className === 'Frame' || className === 'TextLabel'
                || className === 'TextButton' || className === 'ImageLabel'
                || className === 'ImageButton' || className === 'TextBox'
                || className === 'ScrollingFrame') {
                inst = newGuiInstance(className);
                guiByLocalRef.set(inst.__guiLocalRef, inst);
            } else if (className === 'Team') {
                inst = newInstance('Team', 'Team');
                inst.TeamColor = { Name: 'Bright red', Color: new RbxColor3(0.77, 0.2, 0.2) };
                inst.Score = 0;
                inst.AutoAssignable = true;
                inst.PlayerAdded = makeSignal();
                inst.PlayerRemoved = makeSignal();
                inst.GetPlayers = function () {
                    return (players?.Children || []).filter(p => p.Team === this);
                };
                // Регистрация в teams сервисе при Parent = teams
                Object.defineProperty(inst, 'Parent', {
                    get() { return this._parent; },
                    set(v) {
                        this._parent = v;
                        if (v === teams && !teams.Children.includes(this)) {
                            teams.Children.push(this);
                        }
                    },
                });
            } else if (className === 'Tool' || className === 'HopperBin') {
                inst = newInstance(className, 'Tool');
                inst.Equipped = makeSignal();
                inst.Unequipped = makeSignal();
                inst.Activated = makeSignal();
                inst.Deactivated = makeSignal();
                inst.GripForward = new RbxVector3(0, -1, 0);
                inst.GripRight = new RbxVector3(1, 0, 0);
                inst.GripUp = new RbxVector3(0, 1, 0);
                inst.GripPos = new RbxVector3(0, 0, 0);
                inst.CanBeDropped = true;
                inst.Enabled = true;
                inst.RequiresHandle = true;
                inst.TextureId = '';
                inst.ToolTip = '';
                // Виртуальный Handle — Roblox-скрипты делают Tool.Handle.Position
                const handle = newInstance('Part', 'Handle');
                handle.Parent = inst;
                handle.Position = new RbxVector3(0, 5, 0);
                handle.Size = new RbxVector3(1, 1, 1);
                inst.Handle = handle;
                inst.Children = inst.Children || [];
                inst.Children.push(handle);
                // Регистрируем Tool, чтобы плеер показал его в hotbar
                allTools.push(inst);
                inst.__toolIndex = allTools.length;
                send('toolRegistered', {
                    index: inst.__toolIndex,
                    name: inst.Name || `Tool ${inst.__toolIndex}`,
                });
            } else if (className === 'IntValue' || className === 'NumberValue'
                    || className === 'BoolValue' || className === 'StringValue'
                    || className === 'ObjectValue' || className === 'CFrameValue'
                    || className === 'Vector3Value' || className === 'Color3Value'
                    || className === 'BrickColorValue' || className === 'RayValue') {
                inst = newInstance(className, className);
                let _val = className === 'BoolValue' ? false
                    : className === 'StringValue' ? ''
                    : (className === 'IntValue' || className === 'NumberValue') ? 0
                    : undefined;
                inst.Changed = makeSignal();
                // Реактивное поле Value — фейерим Changed + обновляем leaderstats
                // если этот *Value лежит внутри leaderstats-родителя (Roblox-pattern).
                Object.defineProperty(inst, 'Value', {
                    get() { return _val; },
                    set(v) {
                        _val = v;
                        try { inst.Changed.Fire(v); } catch (_) {}
                        // Если этот IntValue — leaderstat (родитель Name=leaderstats):
                        if (inst.Parent && inst.Parent.Name === 'leaderstats') {
                            send('leaderstatSet', {
                                playerName: inst.Parent.Parent?.Name || 'Player',
                                statName: inst.Name || 'Stat',
                                value: Number(v) || 0,
                            });
                        }
                    },
                });
            } else if (className === 'BodyForce' || className === 'BodyVelocity'
                    || className === 'BodyPosition' || className === 'BodyGyro'
                    || className === 'BodyAngularVelocity' || className === 'BodyThrust') {
                inst = newInstance(className, className);
                let _vel = new RbxVector3(0, 0, 0);
                Object.defineProperty(inst, 'velocity', {
                    get() { return _vel; },
                    set(v) {
                        _vel = v;
                        // Эвристика батута: BodyVelocity с +Y и Parent=Torso/HRP
                        // = "толкаем игрока вверх". Если это игрок — шлём jumpVelocity.
                        if (className === 'BodyVelocity' && v && v.Y > 10) {
                            const p = inst.Parent;
                            if (p && (p.Name === 'Torso' || p.Name === 'HumanoidRootPart' ||
                                      p.Name === 'UpperTorso')) {
                                send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 });
                            }
                        }
                    },
                });
                Object.defineProperty(inst, 'Velocity', {
                    get() { return _vel; },
                    set(v) { inst.velocity = v; },
                });
                inst.force = new RbxVector3(0, 0, 0);
                inst.Force = inst.force;
                inst.MaxForce = new RbxVector3(0, 0, 0);
                inst.P = 1000; inst.D = 100;
            } else if (className === 'Weld' || className === 'WeldConstraint'
                    || className === 'Motor6D' || className === 'Snap'
                    || className === 'HingeConstraint' || className === 'BallSocketConstraint'
                    || className === 'RopeConstraint' || className === 'SpringConstraint') {
                inst = newInstance(className, className);
                inst.Part0 = undefined; inst.Part1 = undefined;
                inst.C0 = { Position: new RbxVector3(0, 0, 0) };
                inst.C1 = { Position: new RbxVector3(0, 0, 0) };
                inst.Enabled = true;
            } else if (className === 'Sparkles' || className === 'ParticleEmitter'
                    || className === 'Smoke' || className === 'Fire' || className === 'Trail'
                    || className === 'Beam' || className === 'PointLight'
                    || className === 'SurfaceLight' || className === 'SpotLight') {
                inst = newInstance(className, className);
                inst.Enabled = true;
                inst.Color = new RbxColor3(1, 1, 1);
                inst.Rate = 20;
                inst.Lifetime = { Min: 1, Max: 1 };
                inst.Brightness = 1;
                inst.Range = 8;
                inst.__particleKind = className.toLowerCase();
                // Шлём событие "создан particle-effect" — GameRuntime может его
                // привязать к мешу на сцене (например, рукам игрока).
                send('particleCreated', {
                    kind: inst.__particleKind,
                    color: [inst.Color.R, inst.Color.G, inst.Color.B],
                });
            } else if (className === 'Mouse') {
                inst = newInstance('Mouse', 'Mouse');
                inst.Button1Down = makeSignal();
                inst.Button1Up = makeSignal();
                inst.Button2Down = makeSignal();
                inst.Button2Up = makeSignal();
                inst.Move = makeSignal();
                inst.KeyDown = makeSignal();
                inst.KeyUp = makeSignal();
                inst.WheelForward = makeSignal();
                inst.WheelBackward = makeSignal();
                inst.Icon = '';
                inst.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0) };
                inst.Target = undefined;
                inst.TargetSurface = 'Top';
                inst.X = 0; inst.Y = 0;
                inst.ViewSizeX = 1920; inst.ViewSizeY = 1080;
            } else {
                inst = newInstance(className, className);
            }
            if (parent) {
                inst.Parent = parent;
                if (parent.Children) {
                    parent.Children.push(inst);
                    if (parent.ChildAdded) parent.ChildAdded.Fire(inst);
                }
            }
            return inst;
        },
    });

    // === Leaderboard scan ===
    // Roblox-скрипт делает: Instance.new('IntValue').Name='leaderstats',
    //   stats.Parent = newPlayer, потом IntValue Reputation/Level внутри.
    // Поскольку наш Lua не вызывает Children.push при Parent= (Lua делает rawset),
    // мы периодически сканируем localPlayer на наличие leaderstats и шлём в плеер.
    // === Helpers для скриптов ===
    const partById = new Map();
    global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined);
    global.set('__rbxl_get_tool_by_name', (name) => allTools.find(t => t.Name === name) || undefined);
    global.set('__rbxl_send_error', (id, errStr) => {
        send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` });
    });

    // === Coroutines registry + task.wait через yield ===
    // Каждый скрипт стартует как coroutine. Когда юзер пишет task.wait(sec),
    // мы делаем coroutine.yield(sec). Main-loop резюмирует когда время вышло.
    const coroutines = new Map(); // id → coroutine
    const waitingCoros = []; // [{coId, wakeAt}]
    global.set('__rbxl_register_coroutine', (id, co) => {
        coroutines.set(String(id), co);
    });
    global.set('__rbxl_unregister_coroutine', (id) => {
        coroutines.delete(String(id));
    });
    global.set('__rbxl_get_co', (id) => coroutines.get(String(id)) || undefined);
    global.set('__rbxl_schedule_resume', (coId, delaySec) => {
        waitingCoros.push({
            coId: String(coId),
            wakeAt: SCHEDULER.now() + (Number(delaySec) || 0) * 1000,
        });
    });

    // Lua-prelude: task.wait через coroutine.yield + готовая resume-функция.
    // Главное: __rbxl_resume_co определена в Lua и вызывается из JS через
    // lua.global.get('__rbxl_resume_co') — это безопаснее чем doStringSync
    // потому что не парсит код заново и не создаёт re-entrant проблем.
    // Lua-side helper для логов (используется в task.wait/resume для отладки)
    global.set('__log', (level, text) => {
        send('log', { level: String(level || 'info'), text: String(text || '') });
    });

    lua.doStringSync(`
        local function rbx_wait(sec)
            sec = sec or 0
            -- Минимум 1 кадр (≈0.0166с). wait() и wait(0) в Roblox ждут до
            -- следующего Heartbeat — без этого while true do wait() end
            -- стал бы tight loop без yield и упёрся в WASM stack overflow.
            if sec < 0.016 then sec = 0.016 end
            local ret = coroutine.yield(sec)
            return ret or sec
        end

        -- Глобальный безопасный yield для любых stub-сигналов / любых
        -- "ждунов". Используется в Lua-обёртках вокруг WaitForChild и т.п.
        function __rbxl_yield_frame()
            coroutine.yield(0.05)
        end
        -- task — JS-object из shim ('userdata'/'table'). Сохраняем
        -- существующие методы (delay/spawn/defer) и добавляем wait.
        if type(task) == 'table' or type(task) == 'userdata' then
            local existing = task
            local jsDelay  = existing.delay
            local jsSpawn  = existing.spawn
            local jsDefer  = existing.defer
            task = {
                wait  = rbx_wait,
                delay = jsDelay or function(_, fn) if fn then fn() end end,
                spawn = jsSpawn or function(fn) if fn then fn() end end,
                defer = jsDefer or function(fn) if fn then fn() end end,
                synchronize   = function() end,
                desynchronize = function() end,
            }
        else
            task = { wait = rbx_wait }
        end
        wait = rbx_wait

        -- Roblox legacy globals
        tick = function() return os.time() end           -- секунды с epoch
        time = function() return os.clock() * 1000 end   -- ms аптайм
        delay = function(sec, fn)                         -- delay(sec, fn) — задержка + вызов
            if type(fn) ~= 'function' then return end
            local co = coroutine.create(function()
                rbx_wait(sec or 0)
                pcall(fn)
            end)
            coroutine.resume(co)
        end
        spawn = function(fn)                              -- spawn(fn) — запуск в отдельной coroutine
            if type(fn) ~= 'function' then return end
            local co = coroutine.create(function() pcall(fn) end)
            coroutine.resume(co)
        end
        -- LoadLibrary("RbxStamper"/"RbxUtility") — старый Roblox 2009.
        -- Возвращаем пустую таблицу-стаб чтобы скрипт не упал.
        LoadLibrary = function(name)
            return setmetatable({}, { __index = function() return function() end end })
        end
        require = require or function(_) return {} end

        function __rbxl_resume_co(co)
            if not co or coroutine.status(co) ~= 'suspended' then return nil end
            local ok, ret = coroutine.resume(co)
            if not ok then return false, tostring(ret) end
            if coroutine.status(co) == 'dead' then return nil end
            if type(ret) == 'number' then return ret end
            return 0
        end

        -- Запуск Lua-handler'а из очереди в собственной coroutine.
        -- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback,
        -- так что wait() внутри handler'а — yield в свою coroutine.
        __rbxl_next_handler_id = 0
        function __rbxl_drain_handler(fn, a1, a2, a3, a4)
            __rbxl_next_handler_id = __rbxl_next_handler_id + 1
            local handlerId = "handler_" .. __rbxl_next_handler_id
            local co = coroutine.create(function()
                debug.sethook(function()
                    coroutine.yield(0.016)
                end, "", 20000)
                __log("warn", "[lua-handler] " .. handlerId .. " starting")
                local ok, err = pcall(fn, a1, a2, a3, a4)
                if ok then
                    __log("warn", "[lua-handler] " .. handlerId .. " finished OK")
                else
                    __log("error", "[lua-handler] " .. handlerId .. " ERROR: " .. tostring(err))
                end
                return 1
            end)
            __rbxl_register_coroutine(handlerId, co)
            pcall(coroutine.resume, co)
            if coroutine.status(co) == 'dead' then
                __rbxl_unregister_coroutine(handlerId)
            end
            return 1
        end
    `);
    // Кешируем ссылку на Lua-функцию запуска handler'а
    const luaDrainHandler = lua.global.get('__rbxl_drain_handler');
    // Добавим Lua-side helper для лога
    global.set('__log', (level, text) => {
        send('log', { level: String(level || 'info'), text: String(text || '') });
    });
    // === Хелперы паритета с JS game.ui / game.scene ===
    // Красивый центрированный текст без рамки (как game.ui.showText).
    global.set('__rbxl_show_text', (text, duration, color) => {
        send('ui.showText', {
            text: String(text || ''),
            duration: Number(duration) || 2,
            color: color || '#ffffff',
        });
    });
    // Установка/удаление HUD-плашки в фиксированной позиции — паритет с
    // JS game.ui.set / game.ui.showInteractHint и аналогами.
    // opts = {x, y, color, size} (x,y в процентах 0-100; color — hex)
    global.set('__rbxl_hud_set', (id, text, x, y, color, size) => {
        const payload = { id: String(id || ''), text: text || null };
        if (text != null) {
            payload.opts = {
                x: Number(x) || 50,
                y: Number(y) || 75,
                color: color || '#ffe44a',
                size: Number(size) || 20,
            };
        }
        send('ui.set', payload);
    });
    // Спавн NPC — паритет с JS game.scene.spawnNpc(modelType, opts).
    // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать
    // в __rbxl_npc_say(ref, text, duration).
    let _nextNpcRef = 0;
    const _localToRealNpc = new Map();
    global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => {
        const ref = 'npc_lua_' + (_nextNpcRef++);
        send('npc.spawn', {
            modelType: String(modelType || 'character-a'),
            ref,
            x: +x || 0, y: +y || 0, z: +z || 0,
            name: name ? String(name) : undefined,
            hp: hp != null ? +hp : undefined,
            speed: speed != null ? +speed : undefined,
        });
        return ref;
    });
    global.set('__rbxl_npc_say', (ref, text, duration) => {
        send('npc.say', {
            ref: String(ref || ''),
            text: String(text || ''),
            duration: +duration || 3,
        });
    });
    global.set('__rbxl_npc_follow', (ref, targetRef) => {
        send('npc.follow', {
            ref: String(ref || ''),
            target: String(targetRef || 'player'),
        });
    });
    global.set('__rbxl_npc_stop', (ref) => {
        send('npc.stop', { ref: String(ref || '') });
    });
    // Позиция NPC — резолвится через GameRuntime по локальному ref.
    // GameRuntime в tick шлёт api.updateNpcPos(localRef, x, y, z).
    const _npcPositions = new Map();   // localRef → {x,y,z}
    global.set('__rbxl_npc_pos', (ref) => {
        const p = _npcPositions.get(String(ref || ''));
        if (!p) return { x: 0, y: 0, z: 0, ok: false };
        return { x: p.x, y: p.y, z: p.z, ok: true };
    });
    // Отдельные x/y/z — обходим wasmoon userdata-proxy.
    global.set('__rbxl_npc_x', (ref) => (_npcPositions.get(String(ref || ''))?.x ?? 0));
    global.set('__rbxl_npc_y', (ref) => (_npcPositions.get(String(ref || ''))?.y ?? 0));
    global.set('__rbxl_npc_z', (ref) => (_npcPositions.get(String(ref || ''))?.z ?? 0));
    global.set('__rbxl_npc_damage', (ref, amount) => {
        send('npc.damage', {
            ref: String(ref || ''),
            amount: +amount || 0,
        });
    });
    // Метка с именем/HP над NPC или примитивом — паритет с JS scene.setLabel.
    global.set('__rbxl_set_label', (ref, text, color, height) => {
        send('scene.setLabel', {
            ref: String(ref || ''),
            text: String(text || ''),
            opts: {
                color: color || '#ff5555',
                height: Number(height) || 3,
            },
        });
    });
    global.set('__rbxl_clear_label', (ref) => {
        send('scene.clearLabel', { ref: String(ref || '') });
    });
    // Регистрация коллбэка onDeath для NPC. GameRuntime шлёт globalEvent
    // 'npcDeath' с {ref} при смерти. Shim фильтрует по ref и зовёт.
    const _npcDeathCbs = new Map();  // ref → fn
    global.set('__rbxl_npc_on_death', (ref, fn) => {
        if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn);
    });
    const _npcClickCbs = new Map();  // localRef → fn
    global.set('__rbxl_npc_on_click', (ref, fn) => {
        if (typeof fn === 'function') _npcClickCbs.set(String(ref || ''), fn);
    });
    // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count).
    // Сначала определяем итем (один раз), потом добавляем.
    const _localInventory = new Map();
    const _definedItems = new Set();
    global.set('__rbxl_inventory_define', (itemId, name, color) => {
        const id = String(itemId || '');
        if (!id || _definedItems.has(id)) return;
        _definedItems.add(id);
        send('items.define', {
            def: {
                id,
                name: name ? String(name) : id,
                color: color || '#ffd700',
                stack: 99,
            },
        });
    });
    global.set('__rbxl_inventory_add', (itemId, count) => {
        const id = String(itemId || '');
        if (!id) return;
        const c = Number(count) || 1;
        _localInventory.set(id, (_localInventory.get(id) || 0) + c);
        send('inv2.add', { itemId: id, count: c });
    });
    global.set('__rbxl_inventory_has', (itemId) => {
        return (_localInventory.get(String(itemId || '')) || 0) > 0;
    });
    global.set('__rbxl_inventory_remove', (itemId, count) => {
        const id = String(itemId || '');
        const c = Number(count) || 1;
        const cur = _localInventory.get(id) || 0;
        const newCount = Math.max(0, cur - c);
        if (newCount === 0) _localInventory.delete(id);
        else _localInventory.set(id, newCount);
        send('inv2.remove', { itemId: id, count: c });
    });
    // Урон игроку — паритет с JS game.player.damage(amount).
    // У игрока есть i-frames (~0.5с), так что урон не каждый кадр.
    global.set('__rbxl_damage_player', (amount) => {
        send('player.damage', { amount: Number(amount) || 0 });
    });
    // Лечение игрока — паритет с JS game.player.heal(amount).
    global.set('__rbxl_heal_player', (amount) => {
        send('player.heal', { amount: Number(amount) || 0 });
    });
    // Счёт в углу — паритет с JS game.ui.score = N. null → скрыть.
    global.set('__rbxl_score_set', (value) => {
        const text = value == null ? null : ('Очки: ' + value);
        send('ui.set', { id: '__score', text });
    });
    // Камера-облёт — паритет с JS game.camera.cutscene(points, opts).
    // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив
    // объектов в wasmoon через C-boundary неудобен.
    global.set('__rbxl_camera_cutscene', (pointsFlat, segDuration, lookAtFlat) => {
        const parse = (s) => {
            const out = [];
            const arr = String(s || '').split(',').map((v) => Number(v) || 0);
            for (let i = 0; i + 2 < arr.length; i += 3) {
                out.push({ x: arr[i], y: arr[i + 1], z: arr[i + 2] });
            }
            return out;
        };
        send('camera.cutscene', {
            points: parse(pointsFlat),
            lookAt: lookAtFlat ? parse(lookAtFlat) : [],
            segDuration: Number(segDuration) || 1.5,
        });
    });
    const _cutsceneDoneCbs = [];
    global.set('__rbxl_on_cutscene_done', (fn) => {
        if (typeof fn === 'function') _cutsceneDoneCbs.push(fn);
    });
    // Подброс игрока — паритет с JS game.player.boostJump(strength).
    // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д.
    global.set('__rbxl_boost_jump', (strength) => {
        send('player.boostJump', { strength: Number(strength) || 1 });
    });
    // Эффекты частиц (confetti, sparks и т.п.) — как game.scene.spawnParticles.
    // BabylonScene._spawnParticleEffect ждёт payload.type и payload.position.
    global.set('__rbxl_spawn_particles', (kind, x, y, z, duration, count) => {
        send('scene.particles', {
            type: String(kind || 'confetti'),
            position: { x: +x, y: +y, z: +z },
            duration: Number(duration) || 2,
            count: Number(count) || 1,
        });
    });
    // Спавн примитива (паритет с JS game.scene.spawn) — кладёт в сцену
    // примитив с указанным состоянием (включая anchored/canCollide). Возвращает
    // id примитива (число) для дальнейших операций.
    let _nextSpawnedId = 800000 + Math.floor(Math.random() * 10000);
    global.set('__rbxl_spawn_part', (opts) => {
        try {
            const id = _nextSpawnedId++;
            const o = opts || {};
            send('sceneCreate', {
                primId: id,
                type: String(o.type || 'cube'),
                x: +o.x || 0, y: +o.y || 0, z: +o.z || 0,
                sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1,
                color: o.color || '#A0A0A0',
                anchored: o.anchored !== false,
                canCollide: o.canCollide !== false,
            });
            // Создаём Lua-side представление для скриптов
            const fakePrim = {
                id, name: o.name || `Spawned_${id}`,
                x: +o.x || 0, y: +o.y || 0, z: +o.z || 0,
                sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1,
                color: o.color || '#A0A0A0',
                anchored: o.anchored !== false,
                canCollide: o.canCollide !== false,
            };
            const part = newPart(fakePrim, send);
            partById.set(id, part);
            return part;
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[__rbxl_spawn_part]', e?.message || e);
            return null;
        }
    });
    // Позиция игрока для удобства — отдельные функции для x/y/z, чтобы
    // wasmoon не оборачивал результат в userdata-proxy.
    global.set('__rbxl_player_x', () => {
        const p = api._realPlayerPos || hrp._position || { X: 0 };
        return Number(p.x ?? p.X) || 0;
    });
    global.set('__rbxl_player_y', () => {
        const p = api._realPlayerPos || hrp._position || { Y: 0 };
        return Number(p.y ?? p.Y) || 0;
    });
    global.set('__rbxl_player_z', () => {
        const p = api._realPlayerPos || hrp._position || { Z: 0 };
        return Number(p.z ?? p.Z) || 0;
    });
    // Совместимость: __rbxl_player_pos() возвращает 3 числа (x, y, z).
    global.set('__rbxl_player_pos', () => {
        const p = api._realPlayerPos || hrp._position || { X: 0, Y: 0, Z: 0 };
        return {
            x: Number(p.x ?? p.X) || 0,
            y: Number(p.y ?? p.Y) || 0,
            z: Number(p.z ?? p.Z) || 0,
        };
    });
    // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync)
    const luaResumeCo = lua.global.get('__rbxl_resume_co');

    // === Setter Part-свойств (Position/Size/Color/...) ===
    // Юзер пишет: part.Position = Vector3.new(0, 10, 0)
    // В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила.
    // Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем
    // _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v).
    //
    // Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём
    // metatable на Lua-стороне (более чистый путь).

    // Возвращаем api для main-loop. api объявляется заранее, чтобы closures
    // вроде __rbxl_player_pos и updatePlayerPos могли его видеть.
    const api = {
        _realPlayerPos: null,
        // GameRuntime зовёт после npc.spawn-резолва: маппинг локального
        // ref ('npc_lua_N') на реальный ('npc:<id>'). Нужно для npcDeath.
        setNpcLocalRef(localRef, realRef) {
            _localToRealNpc.set(String(localRef), String(realRef));
        },
        // GameRuntime каждый кадр обновляет позиции NPC для Lua-скриптов.
        updateNpcPos(localRef, x, y, z) {
            _npcPositions.set(String(localRef), { x: +x, y: +y, z: +z });
        },
        onSceneSnapshot(snap) {
            try {
                const prims = snap?.primitives || [];
                // Сохраняем Camera/Terrain
                const kept = workspace.Children.filter(c =>
                    c.ClassName === 'Camera' || c.ClassName === 'Terrain'
                );
                workspace.Children.length = 0;
                workspace.Children.push(...kept);
                partById.clear();
                for (const p of prims) {
                    if (!p || p.id == null) continue;
                    const part = newPart(p, send);  // setters внутри шлют через send
                    part.Parent = workspace;
                    workspace.Children.push(part);
                    partById.set(Number(p.id), part);
                }
                // Teams из импорта .rbxl — создаём Team-инстансы в teams сервисе
                const teamsList = snap?.teams || [];
                if (teamsList.length > 0 && teams.Children.length === 0) {
                    for (const t of teamsList) {
                        const team = newInstance('Team', String(t.name || 'Team'));
                        team.TeamColor = {
                            Name: String(t.name || 'White'),
                            Color: RbxColor3.fromHex(t.color_hex || '#ffffff'),
                        };
                        team.Score = 0;
                        team.AutoAssignable = !!t.auto_assignable;
                        team.PlayerAdded = makeSignal();
                        team.PlayerRemoved = makeSignal();
                        team._parent = teams;
                        teams.Children.push(team);
                    }
                    // Авто-назначение игрока в первую auto_assignable команду
                    const first = teams.Children.find(t => t.AutoAssignable);
                    if (first) {
                        localPlayer.Team = first;
                        localPlayer.TeamColor = first.TeamColor;
                        localPlayer.Neutral = false;
                    }
                }
                // eslint-disable-next-line no-console
                console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts, ${teams.Children.length} teams`);
            } catch (e) {
                send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
            }
        },
        onGuiSnapshot() {},
        onDataSnapshot() {},

        /** Фейр PlayerAdded для уже существующих игроков после того как
         *  скрипты успели подключить хендлеры. Roblox-конвенция:
         *  Players.PlayerAdded не срабатывает для игроков уже на сервере.
         *  Мы дублируем чтобы простые скрипты вроде
         *  Players.PlayerAdded:Connect(...) работали из коробки. */
        fireExistingPlayers() {
            try {
                if (players?.PlayerAdded?.Fire) {
                    players.PlayerAdded.Fire(localPlayer);
                }
                // CharacterAdded — то же самое
                if (localPlayer?.CharacterAdded?.Fire && character) {
                    localPlayer.CharacterAdded.Fire(character);
                }
            } catch (_) {}
        },

        tickScheduler(_dt) {
            // 0a. Lua-handlers из очереди (signal.Fire отложил их сюда).
            // Запускаем каждый в своей coroutine — wait() внутри безопасен.
            if (_pendingHandlerQueue.length > 0) {
                const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length);
                for (const h of queue) {
                    try {
                        // ПРЯМОЙ вызов JS-обёртки Lua-функции (без передачи fn
                        // обратно в Lua через luaDrainHandler — это создаёт
                        // wasmoon Promise-detection crash на null.then).
                        // wasmoon вернёт Promise — ловим через .catch.
                        const result = h.fn(...(h.args || []));
                        if (result && typeof result.then === 'function') {
                            result.catch((err) => {
                                // eslint-disable-next-line no-console
                                console.warn('[handler-async-err]', err?.message || err);
                            });
                        }
                    } catch (e) {
                        // eslint-disable-next-line no-console
                        console.warn('[handler-sync-err]', e?.message || e);
                    }
                }
            }
            // 0b. Tweens
            _stepTweens(_dt);
            const now = SCHEDULER.now();
            // 1. task.delay / task.defer
            if (SCHEDULER.sleeping.length > 0) {
                const ready = [];
                const rest = [];
                for (const t of SCHEDULER.sleeping) {
                    if (t.wakeAt <= now) ready.push(t); else rest.push(t);
                }
                SCHEDULER.sleeping = rest;
                for (const t of ready) {
                    try { t.run(); } catch (_) {}
                }
            }
            // 2. Резюм coroutine'ов которые task.wait()
            const dueCoros = [];
            for (let i = waitingCoros.length - 1; i >= 0; i--) {
                if (waitingCoros[i].wakeAt <= now) {
                    dueCoros.push(waitingCoros[i]);
                    waitingCoros.splice(i, 1);
                }
            }
            for (const entry of dueCoros) {
                const co = coroutines.get(entry.coId);
                if (!co) continue;
                try {
                    const result = luaResumeCo(co);
                    if (result === null || result === undefined) {
                        coroutines.delete(entry.coId);
                    } else if (typeof result === 'number') {
                        waitingCoros.push({
                            coId: entry.coId,
                            wakeAt: SCHEDULER.now() + result * 1000,
                        });
                    } else if (result === false) {
                        coroutines.delete(entry.coId);
                    }
                } catch (e) {
                    send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` });
                    coroutines.delete(entry.coId);
                }
            }
        },
        fireHeartbeat(dt) {
            try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
            try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
            // Авто-детект Touched на спавненных частях (id >= 800000):
            // Спавненные через __rbxl_spawn_part примитивы (падающие кубы,
            // снаряды) Babylon не знает (target=null), поэтому делаем
            // proximity-check игрок↔part прямо в shim каждый кадр.
            //
            // Используем РАСШИРЕННЫЙ радиус (не строгий AABB), потому что
            // физтело куба отталкивается от игрока при контакте — куб может
            // успеть отскочить ДО следующего кадра. Расширяем зону на 1.2
            // единицы, чтобы поймать "почти-контакт".
            try {
                const pp = api._realPlayerPos;
                if (!pp) return;
                const phw = 0.4, phh = 0.9, phd = 0.4;
                const SLACK = 1.2;   // расширение зоны касания
                for (const [id, part] of partById.entries()) {
                    if (id < 800000) continue;
                    if (!part || part.Destroyed) continue;
                    if (!part.Touched || part.Touched.connections.length === 0) continue;
                    const pos = part._state?.Position;
                    const size = part._state?.Size;
                    if (!pos || !size) continue;
                    const hw = size.X / 2 + SLACK;
                    const hh = size.Y / 2 + SLACK;
                    const hd = size.Z / 2 + SLACK;
                    const overlap =
                        pp.x + phw > pos.X - hw && pp.x - phw < pos.X + hw &&
                        pp.y + phh > pos.Y - hh && pp.y - phh < pos.Y + hh &&
                        pp.z + phd > pos.Z - hd && pp.z - phd < pos.Z + hd;
                    if (overlap && !part.__lastTouching) {
                        part.__lastTouching = true;
                        try { part.Touched.Fire(hrp); } catch (_) {}
                    } else if (!overlap && part.__lastTouching) {
                        part.__lastTouching = false;
                        try { part.TouchEnded.Fire(hrp); } catch (_) {}
                    }
                }
            } catch (_) {}
        },
        fireTargetEvent(p) {
            if (!p) return;
            const id = p.primId ?? p.target;
            const part = partById.get(Number(id));
            if (!part) return;
            if (p.kind === 'touch' || p.kind === 'touched') {
                part.Touched.Fire(hrp);
            } else if (p.kind === 'untouch' || p.kind === 'untouched') {
                part.TouchEnded.Fire(hrp);
            } else if (p.kind === 'click') {
                // ClickDetector — стрельба по 3D-объектам.
                // Фейерим без аргумента (передача объектов в Lua через wasmoon
                // может крашить с null.then).
                try {
                    const cd = part._clickDetector;
                    if (cd && cd.MouseClick) cd.MouseClick.Fire();
                } catch (_) {}
                try {
                    if (part.Clicked) part.Clicked.Fire();
                } catch (_) {}
            }
        },
        fireGlobalEvent(p) {
            if (!p) return;
            if (p.type === 'playerTouch' && p.target != null) {
                let primId = null;
                if (typeof p.target === 'number') primId = p.target;
                else if (typeof p.target === 'string') {
                    const m = /^primitive:(\d+)$/.exec(p.target);
                    if (m) primId = +m[1];
                } else if (typeof p.target === 'object') {
                    primId = p.target.id ?? p.target.ref ?? null;
                }
                if (primId != null) {
                    const part = partById.get(Number(primId));
                    // НЕ фейерим part.Touched — это делает fireTargetEvent
                    // в routeEvent('touch'). Иначе двойной счёт.
                    if (part && humanoid.Touched) humanoid.Touched.Fire(part);
                }
            }
            // Cutscene камеры закончилась — фейерим зарегистрированные cb.
            if (p.type === 'cutsceneDone') {
                for (const fn of _cutsceneDoneCbs) {
                    _pendingHandlerQueue.push({ fn, args: [] });
                }
            }
            // NPC погиб — фейерим registered cb для конкретного локального ref.
            if (p.type === 'npcDeath' && p.npcId != null) {
                const realRef = 'npc:' + p.npcId;
                // Ищем локальный ref по реальному
                let localRef = null;
                for (const [k, v] of _localToRealNpc.entries()) {
                    if (v === realRef) { localRef = k; break; }
                }
                // Вызываем все cb с подходящим ref
                if (_npcDeathCbs.size > 0) {
                    for (const [ref, fn] of _npcDeathCbs.entries()) {
                        if (ref === realRef || ref === localRef) {
                            _pendingHandlerQueue.push({ fn, args: [] });
                        }
                    }
                }
            }
            // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id}
            if (p.type === 'guiClick') {
                const ref = p.localId || p.id;
                const guiEl = guiByLocalRef.get(ref);
                if (guiEl?.MouseButton1Click) {
                    guiEl.MouseButton1Click.Fire();
                }
            }
            // Tool equip/unequip — клавиши 1-9 в плеере шлют
            // {type:'equipTool', index:N}, {type:'unequipTool'}
            if (p.type === 'equipTool') {
                const idx = Number(p.index) - 1;
                if (idx < 0 || idx >= allTools.length) return;
                const tool = allTools[idx];
                if (equippedTool === tool) return;
                // Снимаем предыдущий
                if (equippedTool) {
                    try { equippedTool.Unequipped.Fire(); } catch (_) {}
                }
                equippedTool = tool;
                // В Roblox Tool при equip перемещается в Character
                tool.Parent = character;
                try { tool.Equipped.Fire(playerMouse); } catch (_) {}
            }
            if (p.type === 'unequipTool') {
                if (!equippedTool) return;
                try { equippedTool.Unequipped.Fire(); } catch (_) {}
                equippedTool.Parent = backpack;
                equippedTool = null;
            }
            if (p.type === 'toolActivated') {
                if (!equippedTool) return;
                try { equippedTool.Activated.Fire(); } catch (_) {}
            }
            if (p.type === 'toolDeactivated') {
                if (!equippedTool) return;
                try { equippedTool.Deactivated.Fire(); } catch (_) {}
            }
            // Mouse-события из плеера: клики, движение, клавиши при equipped Tool
            // BabylonScene шлёт глобальный 'click' при ЛКМ. Если в payload
            // target — это попадание по 3D-объекту. Для NPC фейерим cb.
            if (p.type === 'click' && p.target && p.target.kind === 'npc' && p.target.id != null) {
                const realRef = 'npc:' + p.target.id;
                let localRef = null;
                for (const [k, v] of _localToRealNpc.entries()) {
                    if (v === realRef) { localRef = k; break; }
                }
                for (const [ref, fn] of _npcClickCbs.entries()) {
                    if (ref === realRef || ref === localRef) {
                        _pendingHandlerQueue.push({ fn, args: [] });
                    }
                }
            }
            // BabylonScene шлёт глобальный 'click' при ЛКМ — это эквивалент
            // mouseButton1Down. Мапим в наши handler-ы.
            if (p.type === 'click' || p.type === 'mouseButton1Down') {
                if (p.hit) {
                    playerMouse.Hit.Position = new RbxVector3(p.hit.x, p.hit.y, p.hit.z);
                    playerMouse.Hit.p = playerMouse.Hit.Position;
                }
                try { playerMouse.Button1Down.Fire(); } catch (_) {}
                // Также фейерим UserInputService.InputBegan с UserInputType.MouseButton1
                try {
                    const uitEnum = global.get('Enum')?.UserInputType || {};
                    const inputObj = {
                        UserInputType: uitEnum.MouseButton1
                            || { Name: 'MouseButton1', Value: 'MouseButton1' },
                        KeyCode: { Name: 'Unknown', Value: 'Unknown' },
                    };
                    uis.InputBegan.Fire(inputObj, false);
                } catch (_) {}
            }
            if (p.type === 'mouseButton1Up') {
                try { playerMouse.Button1Up.Fire(); } catch (_) {}
                try {
                    const uitEnum = global.get('Enum')?.UserInputType || {};
                    const inputObj = {
                        UserInputType: uitEnum.MouseButton1
                            || { Name: 'MouseButton1', Value: 'MouseButton1' },
                        KeyCode: { Name: 'Unknown', Value: 'Unknown' },
                    };
                    uis.InputEnded.Fire(inputObj, false);
                } catch (_) {}
            }
            if (p.type === 'keyDown' || p.type === 'keydown') {
                const k = String(p.key || '').toLowerCase();
                try { playerMouse.KeyDown.Fire(k); } catch (_) {}
                // Также фейерим UserInputService.InputBegan с InputObject.
                // KeyCode должна быть та же ссылка что и Enum.KeyCode.E,
                // чтобы скрипт мог сравнивать input.KeyCode == Enum.KeyCode.E.
                try {
                    const keyEnum = global.get('Enum')?.KeyCode || {};
                    const kc = keyEnum[k.toUpperCase()]
                        || { Name: k.toUpperCase(), Value: k.toUpperCase() };
                    const inputObj = { UserInputType: 'Keyboard', KeyCode: kc };
                    uis.InputBegan.Fire(inputObj, false);
                } catch (_) {}
            }
            if (p.type === 'keyUp' || p.type === 'keyup') {
                const k = String(p.key || '').toLowerCase();
                try { playerMouse.KeyUp.Fire(k); } catch (_) {}
                try {
                    const keyEnum = global.get('Enum')?.KeyCode || {};
                    const kc = keyEnum[k.toUpperCase()]
                        || { Name: k.toUpperCase(), Value: k.toUpperCase() };
                    const inputObj = { UserInputType: 'Keyboard', KeyCode: kc };
                    uis.InputEnded.Fire(inputObj, false);
                } catch (_) {}
            }
        },
        // Tool registry (для GameRuntime: какой Tool сделать script.Parent)
        getToolByName(name) {
            return allTools.find(t => t.Name === name);
        },
        getAllTools() { return allTools.slice(); },
        // GameRuntime каждый кадр шлёт реальную позицию игрока сюда.
        // __rbxl_player_pos() её возвращает Lua-скриптам.
        updatePlayerPos(x, y, z) {
            api._realPlayerPos = { x: +x, y: +y, z: +z };
        },
        // Синхронизация позиций спавненных физических частей (падающие кубы).
        // GameRuntime каждый кадр зовёт это с актуальными координатами от
        // pm.instances — иначе наш AABB-touched-check считает позиции
        // устаревшими (на момент создания) и не ловит касание.
        updateSpawnedPos(id, x, y, z) {
            const part = partById.get(Number(id));
            if (part && part._state && part._state.Position) {
                part._state.Position = new RbxVector3(x, y, z);
            }
        },
        // Доступ к ключевым объектам (для тестов и отладки)
        partById, localPlayer, humanoid, character, workspace, players, game,
    };
    return api;
}
