class LiveSession { constructor(store) { this._wss = "wss://live.clocktower.online:8080/"; // this._wss = "ws://localhost:8081/"; // uncomment if using local server with NODE_ENV=development this._socket = null; this._isSpectator = true; this._gamestate = []; this._store = store; this._pingInterval = 30 * 1000; // 30 seconds between pings this._pingTimer = null; this._reconnectTimer = null; this._players = {}; // map of players connected to a session this._pings = {}; // map of player IDs to ping // reconnect to previous session if (this._store.state.session.sessionId) { this.connect(this._store.state.session.sessionId); } } /** * Open a new session for the passed channel. * @param channel * @private */ _open(channel) { this.disconnect(); this._socket = new WebSocket( this._wss + channel + "/" + (this._isSpectator ? this._store.state.session.playerId : "host") ); this._socket.addEventListener("message", this._handleMessage.bind(this)); this._socket.onopen = this._onOpen.bind(this); this._socket.onclose = err => { this._socket = null; clearInterval(this._pingTimer); this._pingTimer = null; if (err.code !== 1000) { // connection interrupted, reconnect after 3 seconds this._store.commit("session/setReconnecting", true); this._reconnectTimer = setTimeout( () => this.connect(channel), 3 * 1000 ); } else { this._store.commit("session/setSessionId", ""); if (err.reason) alert(err.reason); } }; } /** * Send a message through the socket. * @param command * @param params * @private */ _send(command, params) { if (this._socket && this._socket.readyState === 1) { this._socket.send(JSON.stringify([command, params])); } } /** * Send a message directly to a single playerId, if provided. * Otherwise broadcast it. * @param playerId player ID or "host", optional * @param command * @param params * @private */ _sendDirect(playerId, command, params) { if (playerId) { this._send("direct", { [playerId]: [command, params] }); } else { this._send(command, params); } } /** * Open event handler for socket. * @private */ _onOpen() { if (this._isSpectator) { this._sendDirect( "host", "getGamestate", this._store.state.session.playerId ); } else { this.sendGamestate(); } this._ping(); } /** * Send a ping message with player ID and ST flag. * @private */ _ping() { this._handlePing(); this._send("ping", [ this._isSpectator ? this._store.state.session.playerId : Object.keys(this._players).length, "latency" ]); clearTimeout(this._pingTimer); this._pingTimer = setTimeout(this._ping.bind(this), this._pingInterval); } /** * Handle an incoming socket message. * @param data * @private */ _handleMessage({ data }) { let command, params; try { [command, params] = JSON.parse(data); } catch (err) { console.log("unsupported socket message", data); } switch (command) { case "getGamestate": this.sendGamestate(params); break; case "edition": this._updateEdition(params); break; case "fabled": this._updateFabled(params); break; case "gs": this._updateGamestate(params); break; case "player": this._updatePlayer(params); break; case "claim": this._updateSeat(params); break; case "ping": this._handlePing(params); break; case "nomination": if (!this._isSpectator) return; if (!params) { // create vote history record this._store.commit( "session/addHistory", this._store.state.players.players ); } this._store.commit("session/nomination", { nomination: params }); break; case "swap": if (!this._isSpectator) return; this._store.commit("players/swap", params); break; case "move": if (!this._isSpectator) return; this._store.commit("players/move", params); break; case "remove": if (!this._isSpectator) return; this._store.commit("players/remove", params); break; case "marked": if (!this._isSpectator) return; this._store.commit("session/setMarkedPlayer", params); break; case "isNight": if (!this._isSpectator) return; this._store.commit("toggleNight", params); break; case "isVoteHistoryAllowed": if (!this._isSpectator) return; this._store.commit("session/setVoteHistoryAllowed", params); this._store.commit("session/clearVoteHistory"); break; case "votingSpeed": if (!this._isSpectator) return; this._store.commit("session/setVotingSpeed", params); break; case "clearVoteHistory": if (!this._isSpectator) return; this._store.commit("session/clearVoteHistory"); break; case "isVoteInProgress": if (!this._isSpectator) return; this._store.commit("session/setVoteInProgress", params); break; case "vote": this._handleVote(params); break; case "lock": this._handleLock(params); break; case "bye": this._handleBye(params); break; case "pronouns": this._updatePlayerPronouns(params); break; } } /** * Connect to a new live session, either as host or spectator. * Set a unique playerId if there isn't one yet. * @param channel */ connect(channel) { if (!this._store.state.session.playerId) { this._store.commit( "session/setPlayerId", Math.random() .toString(36) .substr(2) ); } this._pings = {}; this._store.commit("session/setPlayerCount", 0); this._store.commit("session/setPing", 0); this._isSpectator = this._store.state.session.isSpectator; this._open(channel); } /** * Close the current session, if any. */ disconnect() { this._pings = {}; this._store.commit("session/setPlayerCount", 0); this._store.commit("session/setPing", 0); this._store.commit("session/setReconnecting", false); clearTimeout(this._reconnectTimer); if (this._socket) { if (this._isSpectator) { this._sendDirect("host", "bye", this._store.state.session.playerId); } this._socket.close(1000); this._socket = null; } } /** * Publish the current gamestate. * Optional param to reduce traffic. (send only player data) * @param playerId * @param isLightweight */ sendGamestate(playerId = "", isLightweight = false) { if (this._isSpectator) return; this._gamestate = this._store.state.players.players.map(player => ({ name: player.name, id: player.id, isDead: player.isDead, isVoteless: player.isVoteless, pronouns: player.pronouns, ...(player.role && player.role.team === "traveler" ? { roleId: player.role.id } : {}) })); if (isLightweight) { this._sendDirect(playerId, "gs", { gamestate: this._gamestate, isLightweight }); } else { const { session, grimoire } = this._store.state; const { fabled } = this._store.state.players; this.sendEdition(playerId); this._sendDirect(playerId, "gs", { gamestate: this._gamestate, isNight: grimoire.isNight, isVoteHistoryAllowed: session.isVoteHistoryAllowed, nomination: session.nomination, votingSpeed: session.votingSpeed, lockedVote: session.lockedVote, isVoteInProgress: session.isVoteInProgress, markedPlayer: session.markedPlayer, fabled: fabled.map(f => (f.isCustom ? f : { id: f.id })), ...(session.nomination ? { votes: session.votes } : {}) }); } } /** * Update the gamestate based on incoming data. * @param data * @private */ _updateGamestate(data) { if (!this._isSpectator) return; const { gamestate, isLightweight, isNight, isVoteHistoryAllowed, nomination, votingSpeed, votes, lockedVote, isVoteInProgress, markedPlayer, fabled } = data; const players = this._store.state.players.players; // adjust number of players if (players.length < gamestate.length) { for (let x = players.length; x < gamestate.length; x++) { this._store.commit("players/add", gamestate[x].name); } } else if (players.length > gamestate.length) { for (let x = players.length; x > gamestate.length; x--) { this._store.commit("players/remove", x - 1); } } // update status for each player gamestate.forEach((state, x) => { const player = players[x]; const { roleId } = state; // update relevant properties ["name", "id", "isDead", "isVoteless", "pronouns"].forEach(property => { const value = state[property]; if (player[property] !== value) { this._store.commit("players/update", { player, property, value }); } }); // roles are special, because of travelers if (roleId && player.role.id !== roleId) { const role = this._store.state.roles.get(roleId) || this._store.getters.rolesJSONbyId.get(roleId); if (role) { this._store.commit("players/update", { player, property: "role", value: role }); } } else if (!roleId && player.role.team === "traveler") { this._store.commit("players/update", { player, property: "role", value: {} }); } }); if (!isLightweight) { this._store.commit("toggleNight", !!isNight); this._store.commit("session/setVoteHistoryAllowed", isVoteHistoryAllowed); this._store.commit("session/nomination", { nomination, votes, votingSpeed, lockedVote, isVoteInProgress }); this._store.commit("session/setMarkedPlayer", markedPlayer); this._store.commit("players/setFabled", { fabled: fabled.map(f => this._store.state.fabled.get(f.id) || f) }); } } /** * Publish an edition update. ST only * @param playerId */ sendEdition(playerId = "") { if (this._isSpectator) return; const { edition } = this._store.state; let roles; if (!edition.isOfficial) { roles = this._store.getters.customRolesStripped; } this._sendDirect(playerId, "edition", { edition: edition.isOfficial ? { id: edition.id } : edition, ...(roles ? { roles } : {}) }); } /** * Update edition and roles for custom editions. * @param edition * @param roles * @private */ _updateEdition({ edition, roles }) { if (!this._isSpectator) return; this._store.commit("setEdition", edition); if (roles) { this._store.commit("setCustomRoles", roles); if (this._store.state.roles.size !== roles.length) { const missing = []; roles.forEach(({ id }) => { if (!this._store.state.roles.get(id)) { missing.push(id); } }); alert( `This session contains custom characters that can't be found. ` + `Please load them before joining! ` + `Missing roles: ${missing.join(", ")}` ); this.disconnect(); this._store.commit("toggleModal", "edition"); } } } /** * Publish a fabled update. ST only */ sendFabled() { if (this._isSpectator) return; const { fabled } = this._store.state.players; this._send( "fabled", fabled.map(f => (f.isCustom ? f : { id: f.id })) ); } /** * Update fabled roles. * @param fabled * @private */ _updateFabled(fabled) { if (!this._isSpectator) return; this._store.commit("players/setFabled", { fabled: fabled.map(f => this._store.state.fabled.get(f.id) || f) }); } /** * Publish a player update. * @param player * @param property * @param value */ sendPlayer({ player, property, value }) { if (this._isSpectator || property === "reminders") return; const index = this._store.state.players.players.indexOf(player); if (property === "role") { if (value.team && value.team === "traveler") { // update local gamestate to remember this player as a traveler this._gamestate[index].roleId = value.id; this._send("player", { index, property, value: value.id }); } else if (this._gamestate[index].roleId) { // player was previously a traveler delete this._gamestate[index].roleId; this._send("player", { index, property, value: "" }); } } else { this._send("player", { index, property, value }); } } /** * Update a player based on incoming data. Player only. * @param index * @param property * @param value * @private */ _updatePlayer({ index, property, value }) { if (!this._isSpectator) return; const player = this._store.state.players.players[index]; if (!player) return; // special case where a player stops being a traveler if (property === "role") { if (!value && player.role.team === "traveler") { // reset to an unknown role this._store.commit("players/update", { player, property: "role", value: {} }); } else { // load role, first from session, the global, then fail gracefully const role = this._store.state.roles.get(value) || this._store.getters.rolesJSONbyId.get(value) || {}; this._store.commit("players/update", { player, property: "role", value: role }); } } else { // just update the player otherwise this._store.commit("players/update", { player, property, value }); } } /** * Publish a player pronouns update * @param player * @param value * @param isFromSockets */ sendPlayerPronouns({ player, value, isFromSockets }) { //send pronoun only for the seated player or storyteller //Do not re-send pronoun data for an update that was recieved from the sockets layer if ( isFromSockets || (this._isSpectator && this._store.state.session.playerId !== player.id) ) return; const index = this._store.state.players.players.indexOf(player); this._send("pronouns", [index, value]); } /** * Update a pronouns based on incoming data. * @param index * @param value * @private */ _updatePlayerPronouns([index, value]) { const player = this._store.state.players.players[index]; this._store.commit("players/update", { player, property: "pronouns", value, isFromSockets: true }); } /** * Handle a ping message by another player / storyteller * @param playerIdOrCount * @param latency * @private */ _handlePing([playerIdOrCount = 0, latency] = []) { const now = new Date().getTime(); if (!this._isSpectator) { // remove players that haven't sent a ping in twice the timespan for (let player in this._players) { if (now - this._players[player] > this._pingInterval * 2) { delete this._players[player]; delete this._pings[player]; } } // remove claimed seats from players that are no longer connected this._store.state.players.players.forEach(player => { if (player.id && !this._players[player.id]) { this._store.commit("players/update", { player, property: "id", value: "" }); } }); // store new player data if (playerIdOrCount) { this._players[playerIdOrCount] = now; const ping = parseInt(latency, 10); if (ping && ping > 0 && ping < 30 * 1000) { // ping to Players this._pings[playerIdOrCount] = ping; const pings = Object.values(this._pings); this._store.commit( "session/setPing", Math.round(pings.reduce((a, b) => a + b, 0) / pings.length) ); } } } else if (latency) { // ping to ST this._store.commit("session/setPing", parseInt(latency, 10)); } // update player count if (!this._isSpectator || playerIdOrCount) { this._store.commit( "session/setPlayerCount", this._isSpectator ? playerIdOrCount : Object.keys(this._players).length ); } } /** * Handle a player leaving the sessions. ST only * @param playerId * @private */ _handleBye(playerId) { if (this._isSpectator) return; delete this._players[playerId]; this._store.commit( "session/setPlayerCount", Object.keys(this._players).length ); } /** * Claim a seat, needs to be confirmed by the Storyteller. * Seats already occupied can't be claimed. * @param seat either -1 to vacate or the index of the seat claimed */ claimSeat(seat) { if (!this._isSpectator) return; const players = this._store.state.players.players; if (players.length > seat && (seat < 0 || !players[seat].id)) { this._send("claim", [seat, this._store.state.session.playerId]); } } /** * Update a player id associated with that seat. * @param index seat index or -1 * @param value playerId to add / remove * @private */ _updateSeat([index, value]) { if (this._isSpectator) return; const property = "id"; const players = this._store.state.players.players; // remove previous seat const oldIndex = players.findIndex(({ id }) => id === value); if (oldIndex >= 0 && oldIndex !== index) { this._store.commit("players/update", { player: players[oldIndex], property, value: "" }); } // add playerId to new seat if (index >= 0) { const player = players[index]; if (!player) return; this._store.commit("players/update", { player, property, value }); } // update player session list as if this was a ping this._handlePing([true, value, 0]); } /** * Distribute player roles to all seated players in a direct message. * This will be split server side so that each player only receives their own (sub)message. */ distributeRoles() { if (this._isSpectator) return; const message = {}; this._store.state.players.players.forEach((player, index) => { if (player.id && player.role) { message[player.id] = [ "player", { index, property: "role", value: player.role.id } ]; } }); if (Object.keys(message).length) { this._send("direct", message); } } /** * A player nomination. ST only * This also syncs the voting speed to the players. * Payload can be an object with {nomination} property or just the nomination itself, or undefined. * @param payload [nominator, nominee]|{nomination} */ nomination(payload) { if (this._isSpectator) return; const nomination = payload ? payload.nomination || payload : payload; const players = this._store.state.players.players; if ( !nomination || (players.length > nomination[0] && players.length > nomination[1]) ) { this.setVotingSpeed(this._store.state.session.votingSpeed); this._send("nomination", nomination); } } /** * Set the isVoteInProgress status. ST only */ setVoteInProgress() { if (this._isSpectator) return; this._send("isVoteInProgress", this._store.state.session.isVoteInProgress); } /** * Send the isNight status. ST only */ setIsNight() { if (this._isSpectator) return; this._send("isNight", this._store.state.grimoire.isNight); } /** * Send the isVoteHistoryAllowed state. ST only */ setVoteHistoryAllowed() { if (this._isSpectator) return; this._send( "isVoteHistoryAllowed", this._store.state.session.isVoteHistoryAllowed ); } /** * Send the voting speed. ST only * @param votingSpeed voting speed in seconds, minimum 1 */ setVotingSpeed(votingSpeed) { if (this._isSpectator) return; if (votingSpeed) { this._send("votingSpeed", votingSpeed); } } /** * Set which player is on the block. ST only * @param playerIndex, player id or -1 for empty */ setMarked(playerIndex) { if (this._isSpectator) return; this._send("marked", playerIndex); } /** * Clear the vote history for everyone. ST only */ clearVoteHistory() { if (this._isSpectator) return; this._send("clearVoteHistory"); } /** * Send a vote. Player or ST * @param index Seat of the player * @param sync Flag whether to sync this vote with others or not */ vote([index]) { const player = this._store.state.players.players[index]; if ( this._store.state.session.playerId === player.id || !this._isSpectator ) { // send vote only if it is your own vote or you are the storyteller this._send("vote", [ index, this._store.state.session.votes[index], !this._isSpectator ]); } } /** * Handle an incoming vote, but only if it is from ST or unlocked. * @param index * @param vote * @param fromST */ _handleVote([index, vote, fromST]) { const { session, players } = this._store.state; const playerCount = players.players.length; const indexAdjusted = (index - 1 + playerCount - session.nomination[1]) % playerCount; if (fromST || indexAdjusted >= session.lockedVote - 1) { this._store.commit("session/vote", [index, vote]); } } /** * Lock a vote. ST only */ lockVote() { if (this._isSpectator) return; const { lockedVote, votes, nomination } = this._store.state.session; const { players } = this._store.state.players; const index = (nomination[1] + lockedVote - 1) % players.length; this._send("lock", [this._store.state.session.lockedVote, votes[index]]); } /** * Update vote lock and the locked vote, if it differs. Player only * @param lock * @param vote * @private */ _handleLock([lock, vote]) { if (!this._isSpectator) return; this._store.commit("session/lockVote", lock); if (lock > 1) { const { lockedVote, nomination } = this._store.state.session; const { players } = this._store.state.players; const index = (nomination[1] + lockedVote - 1) % players.length; if (this._store.state.session.votes[index] !== vote) { this._store.commit("session/vote", [index, vote]); } } } /** * Swap two player seats. ST only * @param payload */ swapPlayer(payload) { if (this._isSpectator) return; this._send("swap", payload); } /** * Move a player to another seat. ST only * @param payload */ movePlayer(payload) { if (this._isSpectator) return; this._send("move", payload); } /** * Remove a player. ST only * @param payload */ removePlayer(payload) { if (this._isSpectator) return; this._send("remove", payload); } } export default store => { // setup const session = new LiveSession(store); // listen to mutations store.subscribe(({ type, payload }, state) => { switch (type) { case "session/setSessionId": if (state.session.sessionId) { session.connect(state.session.sessionId); } else { window.location.hash = ""; session.disconnect(); } break; case "session/claimSeat": session.claimSeat(payload); break; case "session/distributeRoles": if (payload) { session.distributeRoles(); } break; case "session/nomination": case "session/setNomination": session.nomination(payload); break; case "session/setVoteInProgress": session.setVoteInProgress(payload); break; case "session/voteSync": session.vote(payload); break; case "session/lockVote": session.lockVote(); break; case "session/setVotingSpeed": session.setVotingSpeed(payload); break; case "session/clearVoteHistory": session.clearVoteHistory(); break; case "session/setVoteHistoryAllowed": session.setVoteHistoryAllowed(); break; case "toggleNight": session.setIsNight(); break; case "setEdition": session.sendEdition(); break; case "players/setFabled": session.sendFabled(); break; case "session/setMarkedPlayer": session.setMarked(payload); break; case "players/swap": session.swapPlayer(payload); break; case "players/move": session.movePlayer(payload); break; case "players/remove": session.removePlayer(payload); break; case "players/set": case "players/clear": case "players/add": session.sendGamestate("", true); break; case "players/update": if (payload.property === "pronouns") { session.sendPlayerPronouns(payload); } else { session.sendPlayer(payload); } break; } }); // check for session Id in hash const sessionId = window.location.hash.substr(1); if (sessionId) { store.commit("session/setSpectator", true); store.commit("session/setSessionId", sessionId); store.commit("toggleGrimoire", false); } };