Home Reference Source

cables_dev/cables_ui/src/ui/multiplayer/sc_connection.js

import { Logger, Events } from "cables-shared-client";
import PacoConnector from "./sc_paconnector.js";

import ScState from "./sc_state.js";
import ScUiMultiplayer from "./sc_ui_multiplayer.js";
import { notify, notifyError } from "../elements/notification.js";
import Gui from "../gui.js";
import { PatchConnectionSender } from "./patchconnection.js";

export default class ScConnection extends Events
{
    constructor(cfg)
    {
        super();

        this.PING_INTERVAL = 5000;
        this.PINGS_TO_TIMEOUT = 5;
        this.OWN_PINGS_TO_TIMEOUT = 5;

        this._log = new Logger("scconnection");
        this._verboseLog = false;

        this._scConfig = cfg;
        this._active = cfg.hasOwnProperty("enabled") ? cfg.enabled : false;
        this._lastPingReceived = this.getTimestamp();
        this._lastPingSent = null;

        this._socket = null;
        this._connected = false;
        this._connectedSince = null;
        this._inSessionSince = null;

        this._paco = null;
        this._pacoEnabled = false;
        this._patchConnection = new PatchConnectionSender(gui.corePatch());
        this._pacoSynced = false;
        this._pacoChannel = null;
        this._pacoLoopReady = false;

        this.patchChannelName = this._scConfig.patchChannel;
        this.userChannelName = this._scConfig.userChannel;
        this.userPatchChannelName = this._scConfig.userPatchChannel;
        this.multiplayerCapable = this._scConfig.multiplayerCapable;
        if (cfg)
        {
            this._init((isActive) =>
            {
                let showMultiplayerUi = (isActive && this.multiplayerCapable);
                if (this.showGuestUsers) showMultiplayerUi = true;
                if (gui.isRemoteClient) showMultiplayerUi = false;

                if (showMultiplayerUi)
                {
                    this._multiplayerUi = new ScUiMultiplayer(this);
                    this._chat = new CABLES.UI.Chat(gui.mainTabs, this);
                }
            });
        }
    }

    getTimestamp()
    {
        return (performance.timing.navigationStart + performance.now());
    }

    get showGuestUsers()
    {
        return gui && gui.project() && gui.project() && gui.project().settings && gui.project().visibility === "public";
    }

    get netMouseCursorDelay() { return 100; }

    get netTimelineScrollDelay() { return 100; }

    get chat() { return this._chat; }

    get state() { return this._state; }

    get connected() { return this._connected; }

    get client() { return this.state.clients[this.clientId]; }

    get clientId() { return this._socket.clientId; }

    get followers() { return this.state.followers; }

    get clients() { return this.state.clients; }

    get synced()
    {
        if (!this._pacoEnabled) { return true; }
        else { return this._pacoSynced; }
    }

    get inMultiplayerSession() { return this._pacoEnabled; }

    get hasOtherMultiplayerCapableClients()
    {
        if (!this.state) return false;
        let clientsInSession = false;
        const clients = Object.values(this.clients);
        for (let i = 0; i < clients.length; i++)
        {
            const client = clients[i];
            if (client.clientId === this.clientId) continue;
            if (client.multiplayerCapable)
            {
                clientsInSession = true;
                break;
            }
        }
        return clientsInSession;
    }

    get runningMultiplayerSession()
    {
        if (!this.state) return false;
        let clientsInSession = false;
        const clients = Object.values(this.clients);
        for (let i = 0; i < clients.length; i++)
        {
            const client = clients[i];
            if (client.inMultiplayerSession)
            {
                clientsInSession = true;
                break;
            }
        }
        return clientsInSession;
    }

    get onlyRemoteClientsConnected()
    {
        if (!this.state) return false;
        let onlyRemoteClients = true;
        const clients = Object.values(this.clients);
        for (let i = 0; i < clients.length; i++)
        {
            const client = clients[i];
            if (!client.inMultiplayerSession) continue;
            if (!client.isRemoteClient) onlyRemoteClients = false;
        }
        return onlyRemoteClients;
    }

    enableVerboseLogging()
    {
        this._verboseLog = true;
    }

    isConnected()
    {
        return this._connected;
    }

    getPilot()
    {
        return this.state.getPilot();
    }

    hasPilot()
    {
        return this.state.hasPilot();
    }

    canSaveInMultiplayer()
    {
        if (this._pacoEnabled)
        {
            return this.connected && this.client && this.client.isPilot;
        }
        else
        {
            return true;
        }
    }



    showChat()
    {
        this._chat.show();
    }

    setPacoPaused(paused)
    {
        if (this._paco) this._paco.paused = paused;
    }

    startMultiplayerSession()
    {
        if (this.runningMultiplayerSession)
        {
            this.joinMultiplayerSession();
        }
        else
        {
            if (this.multiplayerCapable)
            {
                if (!this.client.isRemoteClient)
                {
                    this.client.isPilot = true;
                    // this.sendNotification(this.client.username + " just started a multiplayer session");
                }
                this._inSessionSince = this.getTimestamp();
                this.client.inMultiplayerSession = true;
                this._sendPing(true);
                this._state.emitEvent("enableMultiplayer", { "username": this.client.username, "clientId": this.clientId, "started": true });
            }
        }
    }

    joinMultiplayerSession()
    {
        // if (gui && !gui.isRemoteClient)
        // {
        //     gui.setRestriction(Gui.RESTRICT_MODE_FOLLOWER);
        // }
        this.client.isPilot = false;
        this.client.following = null;
        this.client.inMultiplayerSession = true;
        this._inSessionSince = this.getTimestamp();
        this._state.emitEvent("enableMultiplayer", { "username": this.client.username, "clientId": this.clientId, "started": false });
        this._sendPing();
    }

    reconnectRemoteViewer()
    {
        let startSessionListener = null;

        if (!this.runningMultiplayerSession)
        {
            startSessionListener = this.on("multiplayerEnabled", () => { this._reconnectViewer(startSessionListener); });
            this.startMultiplayerSession();
        }
        else
        {
            this._reconnectViewer(startSessionListener);
        }
    }

    _reconnectViewer(startSessionListener)
    {
        if (startSessionListener) this._state.off(startSessionListener);
        gui.setRestriction(Gui.RESTRICT_MODE_FULL);
        this.client.isPilot = true;
        this.client.following = null;
        this.client.inMultiplayerSession = true;
        this._inSessionSince = this.getTimestamp();
        this._state.emitEvent("enableMultiplayer", { "username": this.client.username, "clientId": this.clientId, "started": true });
        this._sendPing(true);
        this._startPacoSend(this.clientId, true);
    }

    startRemoteViewer(doneCallback)
    {
        const listener = () => { this._state.off(listenerId); doneCallback(); };
        const listenerId = this._state.on("enableMultiplayer", listener);

        if (!this.inMultiplayerSession)
        {
            if (this.runningMultiplayerSession)
            {
                this.joinMultiplayerSession();
            }
            else
            {
                this.startMultiplayerSession();
            }
        }
        else
        {
            doneCallback();
        }
    }

    leaveMultiplayerSession()
    {
        this.client.isPilot = false;
        this._pacoChannel = this._socket.unsubscribe(this.userPatchChannelName + "/paco");
        this._pacoEnabled = false;
        this.client.inMultiplayerSession = false;
        this.client.following = null;
        this._inSessionSince = null;
        this.emitEvent("netLeaveSession");
        this._sendPing();
    }

    sendCurrentVersion()
    {
        if (this.client.isPilot)
        {
            this._startPacoSend(this.clientId, true);
        }
    }

    _startPacoSend(requestedBy, forceResync = false)
    {
        if (this.inMultiplayerSession)
        {
            if (!this._paco)
            {
                this._paco = new PacoConnector(this, this._patchConnection);
                this._patchConnection.connectors.push(this._paco);
            }

            const json = gui.corePatch().serialize({ "asObject": true });
            const payload = {
                "patch": JSON.stringify(json),
                "requestedBy": requestedBy,
                "forceResync": forceResync
            };
            this._paco.send(CABLES.PACO_LOAD, payload);
            this._pacoSynced = true;
            if (gui.scene().timer)
            {
                this.sendUi("timelineControl", { "command": "setPlay", "value": gui.scene().timer.isPlaying(), "time": gui.scene().timer.getTime() });
            }
            this.state.emitEvent("patchSynchronized");
        }
    }

    requestPilotPatch()
    {
        if (this.inMultiplayerSession && !this.client.isPilot)
        {
            this.sendPaco({ "requestedBy": this.client.clientId }, "resync");
        }
    }

    track(eventCategory, eventAction, eventLabel, meta = {})
    {
        if (!this._scConfig.enableTracking) return;

        const payload = {
            eventCategory,
            eventAction,
            eventLabel,
            meta
        };
        this.sendControl("track", payload);
    }

    sendNotification(title, text)
    {
        this._send(this.patchChannelName, "info", { "name": "notify", title, text });
    }

    sendInfo(name, text)
    {
        this._send(this.patchChannelName, "info", { "name": "info", text });
    }

    sendControl(name, payload)
    {
        payload = payload || {};
        payload.name = name;

        this._send(this.patchChannelName, "control", payload);
    }

    sendUi(name, payload, sendOnEmptyClientList = false)
    {
        if (sendOnEmptyClientList || this.state.getNumClients() > 1)
        {
            payload = payload || {};
            payload.name = name;
            this._send(this.patchChannelName, "ui", payload);
        }
    }

    sendChat(text)
    {
        // remove html
        const el = document.createElement("div");
        el.innerHTML = text;
        text = el.textContent || el.innerText || "";
        this._send(this.patchChannelName, "chat", { "name": "chatmsg", text, "username": gui.user.username });
    }

    sendPaco(payload, name = "paco")
    {
        if (!this._pacoEnabled) return;
        if (this.client && (!this.client.isRemoteClient || name === "resync"))
        {
            payload.name = name || "paco";
            this._send(this.userPatchChannelName, "paco", payload);
        }
    }

    _init(doneCallback)
    {
        if (!this._active)
        {
            this._log.info("CABLES-SOCKETCLUSTER NOT ACTIVE, WON'T SEND MESSAGES (enable in config)");
            doneCallback(false);
        }

        this._token = this._scConfig.token;
        this._socket = socketClusterClient.create(this._scConfig);
        this._socket.patchChannelName = this.patchChannelName;
        this._socket.userChannelName = this.userChannelName;
        this._socket.userPatchChannelName = this.userPatchChannelName;

        this._state = new ScState(this);
        if (this.multiplayerCapable)
        {
            this._state.on("becamePilot", () =>
            {
                this._sendPing();
                this._startPacoSend(this.clientId);
            });

            this._state.on("enableMultiplayer", (payload) =>
            {
                this._pacoEnabled = true;

                (async () =>
                {
                    if (!this._pacoEnabled) return;
                    if (!this._pacoChannel)
                    {
                        this._pacoChannel = this._socket.subscribe(this.userPatchChannelName + "/paco");
                        if (!this._pacoLoopReady)
                        {
                            this._pacoLoopReady = true;
                            for await (const msg of this._pacoChannel)
                            {
                                this._handlePacoMessage(msg);
                                this.emitEvent("netActivityIn");
                            }
                        }
                    }
                })();

                if (!payload.started)
                {
                    this.requestPilotPatch();
                }
                this.emitEvent("multiplayerEnabled");
            });
        }

        (async () =>
        {
            for await (const { error } of this._socket.listener("error"))
            {
                if (!this.isConnected()) return;
                if (this.inMultiplayerSession)
                {
                    // notifyError("multiplayer server disconnected!", "wait for reconnection to rejoin session");
                    this.leaveMultiplayerSession();
                }
                // socketcluster reports "hung up" errors during own reconnection/keepalive phase
                if (error.code !== 1006 && error.code !== 4001) this._log.info(error.code + " - " + error.message);
                this._connected = false;

                this.emitEvent("connectionError", error);
                this.emitEvent("connectionChanged");
                this.emitEvent("netActivityIn");
            }
        })();
        (async () =>
        {
            for await (const event of this._socket.listener("connect"))
            {
                this.emitEvent("netActivityIn");
                // this._log.verbose("sc connected!");
                this._connected = true;
                this._connectedSince = this.getTimestamp();

                this.emitEvent("connectionChanged");

                // send me patch
                this._updateMembers();

                if (this.client.isRemoteClient)
                {
                    this.joinMultiplayerSession();
                }
                else
                {
                    this._reconnectViewer();
                }
            }
        })();

        (async () =>
        {
            const controlChannel = this._socket.subscribe(this.patchChannelName + "/control");

            for await (const msg of controlChannel)
            {
                this._handleControlChannelMessage(msg);
                this.emitEvent("netActivityIn");
            }
        })();

        (async () =>
        {
            const uiChannel = this._socket.subscribe(this.patchChannelName + "/ui");
            for await (const msg of uiChannel)
            {
                this._handleUiChannelMsg(msg);
                this.emitEvent("netActivityIn");
            }
        })();

        if (this.userChannelName)
        {
            (async () =>
            {
                const userChannel = this._socket.subscribe(this.userChannelName + "/activity");
                for await (const msg of userChannel)
                {
                    if (msg && msg.data)
                    {
                        gui.updateActivityFeedIcon(msg.data);
                    }
                }
            })();
        }

        (async () =>
        {
            const infoChannel = this._socket.subscribe(this.patchChannelName + "/info");
            for await (const msg of infoChannel)
            {
                this._handleInfoChannelMsg(msg);
                this.emitEvent("netActivityIn");
            }
        })();

        (async () =>
        {
            const chatChannel = this._socket.subscribe(this.patchChannelName + "/chat");
            for await (const msg of chatChannel)
            {
                this._handleChatChannelMsg(msg);
                this.emitEvent("netActivityIn");
            }
        })();

        window.addEventListener("beforeunload", () =>
        {
            if (!this.client) return;

            this.client.isDisconnected = true;
            if (this.inMultiplayerSession)
            {
                this.leaveMultiplayerSession(true);
            }
            else
            {
                this._sendPing();
            }
            if (this._socket && this._socket.destroy)
            {
                this._socket.destroy();
            }
        });

        doneCallback(true);
    }

    _updateMembers()
    {
        this.sendControl("pingMembers", {});
        this._lastPingSent = this.getTimestamp();

        setTimeout(() =>
        {
            this._updateMembers();
        }, this.PING_INTERVAL);
    }

    _sendPing(startedSession = false)
    {
        const x = gui.patchView.patchRenderer.viewBox ? gui.patchView.patchRenderer.viewBox.mousePatchX : null;
        const y = gui.patchView.patchRenderer.viewBox ? gui.patchView.patchRenderer.viewBox.mousePatchY : null;
        const subPatch = gui.patchView.getCurrentSubPatch();
        const zoom = gui.patchView.patchRenderer.viewBox ? gui.patchView.patchRenderer.viewBox.zoom : null;
        const scrollX = gui.patchView.patchRenderer.viewBox ? gui.patchView.patchRenderer.viewBox.scrollX : null;
        const scrollY = gui.patchView.patchRenderer.viewBox ? gui.patchView.patchRenderer.viewBox.scrollY : null;

        const payload = {
            "username": gui.user.usernameLowercase,
            "userid": gui.user.id,
            "connectedSince": this._connectedSince,
            "inSessionSince": this._inSessionSince,
            "isRemoteClient": gui.isRemoteClient,
            "inMultiplayerSession": this.client.inMultiplayerSession,
            "multiplayerCapable": this.multiplayerCapable,
            "startedSession": startedSession
        };

        if (this.client)
        {
            payload.isPilot = this.client.isPilot;
            payload.following = this.client.following;
            if (this.client.isDisconnected)
            {
                payload.isDisconnected = true;
            }
            if (this.inMultiplayerSession)
            {
                payload.x = x;
                payload.y = y;
                payload.subpatch = subPatch;
                payload.zoom = zoom;
                payload.scrollX = scrollX;
                payload.scrollY = scrollY;
            }
        }
        if (payload.isRemoteClient && CABLESUILOADER.talkerAPI && !payload.isDisconnected)
        {
            payload.platform = platform;
            this.sendControl("pingAnswer", payload);
        }
        else
        {
            this.sendControl("pingAnswer", payload);
        }
    }

    _send(channel, topic, payload)
    {
        if (!this.client) return;

        if (this._active && this._connected)
        {
            try
            {
                // try to serialize payload to handle errors in scconnection early
                JSON.stringify(payload);
                const finalPayload = {
                    "token": this._token,
                    "clientId": this.client.clientId,
                    "username": this.client.username,
                    topic,
                    ...payload,
                };

                this.emitEvent("netActivityOut");
                const perf = CABLES.UI.uiProfiler.start("[sc] send");
                const scTopic = channel + "/" + topic;
                this._logVerbose("send:", scTopic, finalPayload);
                this._socket.transmitPublish(scTopic, finalPayload);
                perf.finish();
            }
            catch (e)
            {
                this._log.info("failed to serialize object before send, ignoring", payload);
            }
        }
    }

    _handleChatChannelMsg(msg)
    {
        if (!this.client) return;
        this._logVerbose("received:", this.patchChannelName + "/chat", msg);

        if (msg.name === "chatmsg")
        {
            this.emitEvent("onChatMessage", msg);
        }
    }

    _handlePacoMessage(msg)
    {
        if (!this.client) return;
        if (msg.clientId === this._socket.clientId) return;
        this._logVerbose("received:", this.patchChannelName + "/paco", msg);

        if (this.inMultiplayerSession && msg.name === "paco")
        {
            if (!this.client.isRemoteClient) return;

            const foreignRequest = (msg.data && msg.data.vars && msg.data.vars.requestedBy && this.client) && (msg.data.vars.requestedBy !== this.clientId);

            if (!this._paco)
            {
                if (msg.data.event !== CABLES.PACO_LOAD)
                {
                    return;
                }

                this._log.info("first paco message !");
                this._paco = new PacoConnector(this, this._patchConnection);
                this._patchConnection.connectors.push(this._paco);
                this._synchronizePatch(msg.data);
            }
            else if (msg.data.event === CABLES.PACO_LOAD)
            {
                if (!foreignRequest || (msg.data.vars && msg.data.vars.forceResync))
                {
                    this._synchronizePatch(msg.data, msg.data.vars.forceResync);
                }
            }
            else
            {
                const perf = CABLES.UI.uiProfiler.start("[sc] paco receive");
                this._paco.receive(msg.data);
                perf.finish();
                this._pacoSynced = true;
                this.state.emitEvent("patchSynchronized");
            }
        }
        else if (msg.name === "resync")
        {
            if (msg.clientId === this._socket.clientId) return;

            let startSessionListener = null;
            const resyncPatch = () =>
            {
                if (startSessionListener) this.off(startSessionListener);
                if (this._pacoEnabled && this.client) // && this.client.isPilot)
                {
                    this._log.info("RESYNC sending paco patch....");
                    this._startPacoSend(msg.clientId);
                }
            };
            if (this.inMultiplayerSession)
            {
                resyncPatch();
            }
        }
    }

    _synchronizePatch(data)
    {
        if (!this._paco) return;
        this._pacoSynced = false;
        this.state.emitEvent("startPatchSync");
        const perf = CABLES.UI.uiProfiler.start("[sc] paco sync");
        const cbId = gui.corePatch().on("patchLoadEnd", () =>
        {
            this._log.verbose("patchloadend in paco");
            gui.corePatch().off(cbId);
            this._pacoSynced = true;
            this.state.emitEvent("patchSynchronized");
            perf.finish();
        });
        gui.patchView.clearPatch();
        this._paco.receive(data);
    }

    _handleControlChannelMessage(msg)
    {
        if (!this.client) return;
        this._logVerbose("received:", this.patchChannelName + "/control", msg);


        if (msg.name === "pingMembers")
        {
            const timeOutSeconds = this.PING_INTERVAL * this.OWN_PINGS_TO_TIMEOUT;
            const pingOutTime = this.getTimestamp() - timeOutSeconds;
            if (this._lastPingReceived < pingOutTime)
            {
                msg.seconds = timeOutSeconds / 1000;
                this.emitEvent("onPingTimeout", msg);
                this._log.info("didn't receive ping for more than", msg.seconds, "seconds");
            }
            if (msg.clientId !== this.clientId)
            {
                this._sendPing();
                this._lastPingSent = this.getTimestamp();
            }
            else
            {
                this._lastPingReceived = msg.lastSeen;
            }
        }
        if (msg.name === "pingAnswer")
        {
            msg.lastSeen = this.getTimestamp();
            this._lastPingReceived = msg.lastSeen;
            this.emitEvent("onPingAnswer", msg);
        }
        if (msg.name === "pilotRequest")
        {
            if (msg.clientId === this._socket.clientId) return;
            this.emitEvent("onPilotRequest", msg);
        }
        if (msg.name === "reloadOp")
        {
            if (!this.inMultiplayerSession) return;
            if (msg.clientId === this._socket.clientId) return;
            this.emitEvent("reloadOp", msg);
        }
    }

    _handleUiChannelMsg(msg)
    {
        if (!this.client) return;
        this._logVerbose("received:", this.patchChannelName + "/ui", msg);

        if (msg.clientId === this._socket.clientId) return;
        this.emitEvent(msg.name, msg);
    }

    _handleInfoChannelMsg(msg)
    {
        if (!this.client) return;
        this._logVerbose("received:", this.patchChannelName + "/info", msg);

        if (msg.clientId === this._socket.clientId) return;
        this.emitEvent("onInfoMessage", msg);
    }

    _logVerbose(prefix, channel, msg)
    {
        if (this._verboseLog)
        {
            const { token, ...logMsg } = msg;
            this._logVerbose(prefix, channel, logMsg);
        }
    }
}