@needle-tools/networking
Version:
Networking backend functionality for Needle Engine
356 lines (315 loc) • 11.5 kB
JavaScript
const Utils = require("./networking_utils");
// const Stats = require("./stats");
// Stats.init();
const ClientSettings = require("./clientSettings");
class Room {
constructor(id) {
this.id = id;
this.viewId = Utils.getUniqueIDWithSalt(this.id);
this.clients = [];
this.clientSettings = []; // contains info if someone is allowed to make edits
this.owners = {};
this._updateLastMessageTime();
this.state = {};
/** @type {Map<string, string[]>} this state gets deleted from the room when a user disconnects */
this.userBoundState = new Map();
this.isDirty = false;
this.clientTimeout = 30;
// Stats.onRoomOpened(this);
}
stateSize() {
return this.state ? Object.keys(this.state).length : 0;
}
userCount() {
return this.clients.length;
}
async loadState() {
try {
const res = await Utils.loadState(this.id);
if (res) {
console.log("Successfully loaded state, will now merge and send to clients");
this.state = { ...res, ...this.state };
for (const client of this.clients) {
this._sendState(client);
}
}
} catch (err) {
console.error("error loading room state", this.id, err);
}
};
/**
* @param {boolean} force
*/
async saveState(force) {
try {
if (!this.isDirty && !force) return;
console.log("SAVING ROOM STATE:", this.id);
this.isDirty = false;
await Utils.saveState(this.id, this.state);
console.log("ROOM STATE SAVED:", this.id);
} catch (err) {
console.error("ERR: saving room state", this.id, err);
}
};
async clearState() {
this.isDirty = false;
this.state = {}
await Utils.saveState(this.id, this.state);
};
_updateLastMessageTime() {
this.lastMessageTime = Date.now();
};
timeSinceLastMessage() {
if (this.lastMessageTime) {
const dt = Date.now() - this.lastMessageTime;
return dt;
}
return -1;
};
validateClients() {
const now = Date.now();
const timeout = 1000 * this.clientTimeout;
for (let i = this.clients.length - 1; i >= 0; i--) {
const client = this.clients[i];
if (client._lastMessageTime && now - client._lastMessageTime > timeout) {
console.log(`User Timeout: ${client.id}`);
this.remove(client);
}
}
};
close() {
console.log("closing room " + this.id);
for (let i = this.clients.length - 1; i >= 0; i--) {
const client = this.clients[i];
this.remove(client);
}
// Stats.onRoomClosed(this);
};
add(ws, settings) {
if (!this.clients) return;
if (ws.room === this) return;
if (ws.room) {
console.error("can not join multiple rooms for now");
return;
}
ws.room = this;
this.clients.push(ws);
if (!settings) settings = new ClientSettings();
this.clientSettings.push(settings);
console.log(ws.id + " joined " + this.id + " > total users: " + this.clients.length);
const inRoomIds = [];
for (const client of this.clients) {
inRoomIds.push(client.id);
}
// if the user is only in view mode dont leak the original room name
const roomId = settings.allowEditing ? this.id : this.viewId;
Utils.sendUpdate_JoinedRoom(ws, roomId, inRoomIds, this.viewId, settings.allowEditing);
const data = { userId: ws.id };
for (const client of this.clients) {
if (client === ws) continue;
console.log(client.id + " -> " + ws.id);
Utils.sendUpdate_UserJoinedRoom(client, data);
}
this._sendState(ws);
// event after all state has been sent
Utils.sendUpdate_RoomStateSent(ws, roomId);
};
remove(ws) {
if (!this.clients) return;
if (ws.room !== this) return;
ws.room = null;
const index = this.clients.indexOf(ws);
this.clients.splice(index, 1);
this.clientSettings.splice(index, 1);
this.isDirty = true;
// remove owner
for (const key in this.owners) {
if (this.owners[key] == ws.id) {
const guid = key;
console.log(`Removed owner \"${ws.id}\" for \"${guid}\"`);
this.owners[key] = null;
Utils.sendUpdate_LostOwnership(ws, guid);
for (const client of this.clients) {
Utils.sendUpdate_LostOwnershipBroadcast(client, guid);
}
}
}
console.log(`User \"${ws.id}\" left room \"${this.id}\" > total users: ${this.clients.length}`);
Utils.sendUpdate_LeftRoom(ws, this.id);
// send user left event to all users still in room
const data = { userId: ws.id };
for (const client of this.clients) {
if (client === ws) continue;
Utils.sendUpdate_UserLeftRoom(client, data);
}
this.__deleteUserBoundState(ws);
};
__deleteUserBoundState(ws) {
const userId = ws.id;
const array = this.userBoundState.get(userId);
if (array) {
console.log(`Deleting user bound state for ${userId}: ${array.length} entries`);
for (const guid of array) {
if (this.state[guid]) {
delete this.state[guid];
}
}
this.userBoundState.delete(userId);
}
}
broadcast(ws, data, isBinary) {
// if(!isBinary && typeof data !== "string") {
// console.log("STRINGIFY", data, typeof data);
// data = JSON.stringify(data);
// }
for (let i = 0; i < this.clients.length; i++) {
const client = this.clients[i];
if (client !== ws && client.readyState === client.OPEN) client.send(data);
}
};
handleMessage(ws, type, payload, rawData) {
const index = this.clients.indexOf(ws);
if (index < 0) return;
const settings = this.clientSettings[index];
const allowEditing = settings.allowEditing;
// TODO: we still want to send SOME messages (even if the client is in view mode) to other clients
// e.g. camera or avatar position, so maybe we just want to broadcast to other clients once on connection
// if the new person is in view mode?
// alternativelly payload should contain if this message should still be sent to everyone
this._updateLastMessageTime();
ws._lastMessageTime = Date.now();
if (type === "delete-all-state" && allowEditing) {
console.log("Clearing state in room: " + this.id,);
this.clearState();
this.broadcast(null, "all-room-state-deleted");
return;
}
const guid = payload.guid;
let allowSave = allowEditing; // never save if editing is not allowed
// kind of special message because we still want this to go out to others
// but at the same time handle some of it's content server side
// e.g. remove previously saved state related to any guid that
// comes with this message
if (type === "instance-destroyed" || type === "delete-state") {
if (allowEditing) {
// save this message only if something was not previously created via networking message
// e.g. an object that shipped with the scene and then got deleted
// in that case we want to keep the deletion message
allowSave = this.state[guid] === undefined;
if (this.state[guid]) delete this.state[guid];
logStateSize(this.state);
}
}
if (type === "ping") {
const res = JSON.stringify({ key: "pong", data: payload });
ws.send(res);
return;
}
// peer id changed or updated
if (type === "peer-update-id") {
//console.log(type, payload);
const { id: peerId } = payload;
ws.peerId = peerId;
const msg = { key: type, data: { id: ws.id, peerId: ws.peerId } };
this.broadcast(ws, JSON.stringify(msg));
for (const client of this.clients) {
if (client === ws) continue;
msg.data.id = client.id;
msg.data.peerId = client.peerId;
ws.send(JSON.stringify(msg));
}
}
if (type === "request-ownership") {
if (!allowEditing) return;
console.log("request ownership", guid);
const previousOwner = this.owners[guid];
this.owners[guid] = ws.id;
if (previousOwner !== undefined) {
if (previousOwner === ws.id) return; // is already owned by this person
for (let i = 0; i < this.clients.length; i++) {
const client = this.clients[i];
if (client.id === previousOwner) {
Utils.sendUpdate_LostOwnership(client, guid);
break;
}
}
}
Utils.sendUpdate_GainedOwnership(ws, guid);
for (const client of this.clients) {
Utils.sendUpdate_GainedOwnershipBroadcast(client, ws.id, guid);
}
}
// remove ownership
else if (type === "remove-ownership") {
if (!allowEditing) return;
const currentOwner = this.owners[guid];
// only the current owner can remove their own ownership
if (currentOwner !== undefined) {
if (currentOwner === ws.id) {
this.owners[guid] = undefined;
if (currentOwner !== undefined) {
Utils.sendUpdate_LostOwnership(ws, guid);
for (const client of this.clients) {
if (client === ws) continue;
Utils.sendUpdate_LostOwnershipBroadcast(client, guid);
}
}
}
}
} else if (type === "request-is-owner") {
const id = ws.id;
const currentOwner = this.owners[guid];
const isOwner = currentOwner === id;
Utils.sendUpdate_IsOwner(ws, guid, isOwner);
} else if (type === "request-has-owner") {
const currentOwner = this.owners[guid];
const hasOwner = currentOwner !== undefined && currentOwner !== null;
Utils.sendUpdate_HasOwner(ws, guid, hasOwner);
}
// everything not specific
else {
if (!allowEditing) return;
this.isDirty = true;
if (guid !== undefined) {
if (this.owners[guid] && this.owners[guid] !== ws.id) return;
// if state should be deleted when the user disconnects register it here
if (payload.deleteStateOnDisconnect === true) {
let array = this.userBoundState.get(ws.id);
if (!array) {
array = [];
this.userBoundState.set(ws.id, array);
}
array.push(guid);
}
this.broadcast(ws, rawData);
if (allowSave) this._saveState(guid, type, payload, rawData);
} else {
this.broadcast(ws, rawData);
if (allowSave) this._saveState(guid, type, payload, rawData);
}
}
};
_saveState(guid, type, payload, rawData) {
if (payload.dontSave === true) return;
if (!guid) return;
const state = this.state;
let entry = state[guid];
if (!entry) entry = state[guid] = {};
entry[type] = rawData;
};
_sendState(ws) {
logStateSize(this.state);
for (const guid in this.state) {
const objectState = this.state[guid];
for (const type in objectState) {
const rawData = objectState[type];
ws.send(rawData);
}
}
};
}
module.exports = Room;
function logStateSize(stateObject) {
const stateSize = stateObject ? Object.keys(stateObject).length : 0;
console.log("State has " + stateSize + " entries");
}