SocketService.js

const { Parent, MESSAGE_EVENT } = require('./thread');
const SocketWrapper = require('./SocketWrapper');

const socketio = require('socket.io');
const { Object3D, constant } = require('@ud-viz/game_shared');

/**
 * @callback SocketCallback
 * @param {SocketWrapper} socketWrapper
 */

/**
 * @callback ReadyForGameCallback
 * @param {SocketWrapper} socketWrapper
 * @param {string} entryGameObject3DUUID
 * @param {object} readyForGameParams
 */

/**
 * @classdesc Websocket game service, create threads to simulate gameobject + socket
 */
const SocketService = class {
  /**
   *
   * @param {import('http').Server} httpServer - http server
   * @param {object} options - options
   * @param {number} [options.pingInterval=2000] - ping interval of the socket connection in ms
   * @param {number} [options.pingTimeout=5000] - ping timeout in ms
   * @param {Array<SocketCallback>} [options.socketConnectionCallbacks=[]] - callback to apply when socket is connected
   * @param {Array<SocketCallback>} [options.socketDisconnectionCallbacks=[]] - callback to apply when socket is disconnected
   * @param {Array<ReadyForGameCallback>} [options.socketReadyForGamePromises=[]] - callback to apply when socket is ready for game
   */
  constructor(httpServer, options = {}) {
    /**
     * @type {socketio.Server}
     */
    this.io = new socketio.Server(httpServer, {
      pingInterval: options.pingInterval || 2000,
      pingTimeout: options.pingTimeout || 20000, // 20sec
    });

    /** @type {Array<SocketCallback>} */
    this.socketConnectionCallbacks = options.socketConnectionCallbacks || [];

    /** @type {Array<SocketCallback>} */
    this.socketDisconnectionCallbacks =
      options.socketDisconnectionCallbacks || [];

    /** @type {Array<ReadyForGameCallback>} */
    this.socketReadyForGamePromises = options.socketReadyForGamePromises || [];

    this.io.on('connection', this.onSocketConnection.bind(this));

    /**
     *  threads running a gamecontext
     *  
     @type {Object<string,Parent>} */
    this.threads = {};

    /** 
     * socket wrappers currently connected
     * 
     @type {Object<string,SocketWrapper>}  */
    this.socketWrappers = {};
  }

  /**
   * Stop threads + disconnect socket client + close websocket connection
   *
   * @returns {Promise} - a promise resolving when all thread have been closed
   */
  stop() {
    const promises = [];

    for (const key in this.threads) {
      const thread = this.threads[key];
      promises.push(thread.worker.terminate());
    }

    this.io.disconnectSockets();

    this.io.close();

    return Promise.all(promises);
  }

  /**
   * Launch thread running game context simulation
   *
   * @param {Array<object>} gameObjects3D - array of gameobject3D json to simulate
   * @param {string} threadProcessPath - path to the thread process
   * @param {string=} entryGameObject3DUUID - uuid of default gameobject to connect socket connected
   * @returns {Promise} - a promises resolving when all thread have been initialized
   */
  loadGameThreads(gameObjects3D, threadProcessPath, entryGameObject3DUUID) {
    const promises = [];

    gameObjects3D = gameObjects3D.map((json) => Object3D.parseJSON(json));

    // default gameobject3D when socket connect
    this.defaultEntryGameObject3DUUID =
      entryGameObject3DUUID || gameObjects3D[0].uuid;

    gameObjects3D.forEach((gameObject3D) => {
      this.threads[gameObject3D.uuid] = new Parent(threadProcessPath);
      promises.push(
        this.threads[gameObject3D.uuid].apply(MESSAGE_EVENT.INIT, {
          gameObject3D: gameObject3D,
        })
      );

      this.threads[gameObject3D.uuid].on(
        MESSAGE_EVENT.CURRENT_STATE,
        (state) => {
          this.threads[gameObject3D.uuid].socketWrappers.forEach((sW) => {
            sW.sendState(state);
          });
        }
      );
    });

    return Promise.all(promises);
  }

  /**
   * init
   *
   * @param {socketio.Socket} socket - new socket connected to game service
   */
  onSocketConnection(socket) {
    const socketWrapper = new SocketWrapper(socket);
    this.socketWrappers[socket.id] = socketWrapper; // register

    // wait for client to be ready for game
    socket.on(
      constant.WEBSOCKET.MSG_TYPE.READY_FOR_GAME,
      (readyForGameParams) => {
        const entryGameObject3DUUID =
          readyForGameParams.entryGameObject3DUUID ||
          this.defaultEntryGameObject3DUUID;

        if (!this.threads[entryGameObject3DUUID]) {
          console.warn('no thread');
          return;
        }

        // apply promises
        const promises = [];
        this.socketReadyForGamePromises.forEach((c) => {
          const p = c(socketWrapper, entryGameObject3DUUID, readyForGameParams);
          if (p) promises.push(p);
        });

        Promise.all(promises).then(() => {
          this.threads[entryGameObject3DUUID].addSocketWrapper(socketWrapper);
        });
      }
    );

    socket.on('disconnect', () => {
      console.log('socket', socket.id, 'disconnected');
      // remove socketwrapper in thread
      for (const key in this.threads) {
        const s = this.threads[key].socketWrappers.filter((el) => {
          if (el.socket.id == socket.id) {
            return true;
          }
        });
        if (s.length > 0) {
          if (s.length != 1)
            throw new Error('socket should only be there once');

          this.threads[key].removeSocketWrapper(s[0]);

          this.socketDisconnectionCallbacks.forEach((c) => {
            c(s[0], this.threads[key]);
          });
        }
      }

      delete this.socketWrappers[socket.id]; // remove from current socket connected
    });

    // apply callbacks
    this.socketConnectionCallbacks.forEach((c) => {
      c(socketWrapper);
    });
  }
};

module.exports = SocketService;