class _InitiativeTrackerNetworkingP2pMetaV1 { constructor () { this.rows = []; this.serverInfo = null; this.serverPeer = null; } } class _InitiativeTrackerNetworkingP2pMetaV0 { constructor () { this.rows = []; this.serverInfo = null; } } export class InitiativeTrackerNetworking { constructor ({board}) { this._board = board; this._p2pMetaV1 = new _InitiativeTrackerNetworkingP2pMetaV1(); this._p2pMetaV0 = new _InitiativeTrackerNetworkingP2pMetaV0(); } /* -------------------------------------------- */ sendStateToClients ({fnGetToSend}) { return this._sendMessageToClients({fnGetToSend}); } sendShowImageMessageToClients ({imageHref}) { return this._sendMessageToClients({ fnGetToSend: () => ({ type: "showImage", payload: { imageHref, }, }), }); } _sendMessageToClients ({fnGetToSend}) { let toSend = null; // region V1 if (this._p2pMetaV1.serverPeer) { if (!this._p2pMetaV1.serverPeer.hasConnections()) return; toSend ||= fnGetToSend(); this._p2pMetaV1.serverPeer.sendMessage(toSend); } // endregion // region V0 if (this._p2pMetaV0.serverInfo) { this._p2pMetaV0.rows = this._p2pMetaV0.rows.filter(row => !row.isDeleted); this._p2pMetaV0.serverInfo = this._p2pMetaV0.serverInfo.filter(row => { if (row.isDeleted) { row.server.close(); return false; } return true; }); toSend ||= fnGetToSend(); try { this._p2pMetaV0.serverInfo.filter(info => info.server.isActive).forEach(info => info.server.sendMessage(toSend)); } catch (e) { setTimeout(() => { throw e; }); } } // endregion } /* -------------------------------------------- */ /** * @param opts * @param opts.doUpdateExternalStates * @param [opts.$btnStartServer] * @param [opts.$btnGetToken] * @param [opts.$btnGetLink] * @param [opts.fnDispServerStoppedState] * @param [opts.fnDispServerRunningState] */ async startServerV1 (opts) { opts = opts || {}; if (this._p2pMetaV1.serverPeer) { await this._p2pMetaV1.serverPeer.pInit(); return { isRunning: true, token: this._p2pMetaV1.serverPeer?.token, }; } try { if (opts.$btnStartServer) opts.$btnStartServer.prop("disabled", true); this._p2pMetaV1.serverPeer = new PeerVeServer(); await this._p2pMetaV1.serverPeer.pInit(); if (opts.$btnGetToken) opts.$btnGetToken.prop("disabled", false); if (opts.$btnGetLink) opts.$btnGetLink.prop("disabled", false); this._p2pMetaV1.serverPeer.on("connection", connection => { const pConnected = new Promise(resolve => { connection.on("open", () => { resolve(true); opts.doUpdateExternalStates(); }); }); const pTimeout = MiscUtil.pDelay(5 * 1000, false); Promise.race([pConnected, pTimeout]) .then(didConnect => { if (!didConnect) { JqueryUtil.doToast({content: `Connecting to "${connection.label.escapeQuotes()}" has taken more than 5 seconds! The connection may need to be re-attempted.`, type: "warning"}); } }); }); $(window).on("beforeunload", evt => { const message = `The connection will be closed`; (evt || window.event).message = message; return message; }); if (opts.fnDispServerRunningState) opts.fnDispServerRunningState(); return { isRunning: true, token: this._p2pMetaV1.serverPeer?.token, }; } catch (e) { if (opts.fnDispServerStoppedState) opts.fnDispServerStoppedState(); if (opts.$btnStartServer) opts.$btnStartServer.prop("disabled", false); this._p2pMetaV1.serverPeer = null; JqueryUtil.doToast({content: `Failed to start server! ${VeCt.STR_SEE_CONSOLE}`, type: "danger"}); setTimeout(() => { throw e; }); } return { isRunning: false, token: this._p2pMetaV1.serverPeer?.token, }; } handleClick_playerWindowV1 ({doUpdateExternalStates}) { const {$modalInner} = UiUtil.getShowModal({ title: "Configure Player View", isUncappedHeight: true, isHeight100: true, cbClose: () => { if (this._p2pMetaV1.rows.length) this._p2pMetaV1.rows.forEach(row => row.$row.detach()); if (this._p2pMetaV1.serverPeer) this._p2pMetaV1.serverPeer.offTemp("connection"); }, }); const $wrpHelp = UiUtil.$getAddModalRow($modalInner, "div"); const fnDispServerStoppedState = () => { $btnStartServer.html(` Start Server`).prop("disabled", false); $btnGetToken.prop("disabled", true); $btnGetLink.prop("disabled", true); }; const fnDispServerRunningState = () => { $btnStartServer.html(` Server Running`).prop("disabled", true); $btnGetToken.prop("disabled", false); $btnGetLink.prop("disabled", false); }; const $btnStartServer = $(``) .click(async () => { const {isRunning} = await this.startServerV1({doUpdateExternalStates, $btnStartServer, $btnGetToken, $btnGetLink, fnDispServerStoppedState, fnDispServerRunningState}); if (!isRunning) return; this._p2pMetaV1.serverPeer.onTemp("connection", showConnected); showConnected(); }); const $btnGetToken = $(``).appendTo($wrpHelp) .click(async () => { await MiscUtil.pCopyTextToClipboard(this._p2pMetaV1.serverPeer.token); JqueryUtil.showCopiedEffect($btnGetToken); }); const $btnGetLink = $(``).appendTo($wrpHelp) .click(async () => { const cleanOrigin = window.location.origin.replace(/\/+$/, ""); const url = `${cleanOrigin}/inittrackerplayerview.html#v1:${this._p2pMetaV1.serverPeer.token}`; await MiscUtil.pCopyTextToClipboard(url); JqueryUtil.showCopiedEffect($btnGetLink); }); if (this._p2pMetaV1.serverPeer) fnDispServerRunningState(); else fnDispServerStoppedState(); $$`

The Player View is part of a peer-to-peer system to allow players to connect to a DM's initiative tracker. Players should use the Initiative Tracker Player View page to connect to the DM's instance. As a DM, the usage is as follows:

  1. Start the server.
  2. Copy your link/token and share it with your players.
  3. Wait for them to connect!

${$btnStartServer}${$btnGetLink}${$btnGetToken}

Please note that this system is highly experimental. Your experience may vary.

`.appendTo($wrpHelp); UiUtil.addModalSep($modalInner); const $wrpConnected = UiUtil.$getAddModalRow($modalInner, "div").addClass("flx-col"); const showConnected = () => { if (!this._p2pMetaV1.serverPeer) return $wrpConnected.html(`
No clients connected.
`); let stack = `
Connected Clients:
"; $wrpConnected.html(stack); }; if (this._p2pMetaV1.serverPeer) this._p2pMetaV1.serverPeer.onTemp("connection", showConnected); showConnected(); } // nop on receiving a message; we want to send only // TODO expand this, to allow e.g. players to set statuses or assign damage/healing (at DM approval?) _playerWindowV0_DM_MESSAGE_RECEIVER = function () {}; _playerWindowV0_DM_ERROR_HANDLER = function (err) { if (!this.isClosed) { // TODO: this could be better at handling `err.error == "RTCError: User-Initiated Abort, reason=Close called"` JqueryUtil.doToast({ content: `Server error:\n${err ? (err.message || err.error || err) : "(Unknown error)"}`, type: "danger", }); } }; async _playerWindowV0_pGetServerTokens ({rowMetas}) { const targetRows = rowMetas.filter(it => !it.isDeleted).filter(it => !it.isActive); if (targetRows.every(it => it.isActive)) { return JqueryUtil.doToast({ content: "No rows require Server Token generation!", type: "warning", }); } let anyInvalidNames = false; targetRows.forEach(row => { row.$iptName.removeClass("error-background"); if (!row.$iptName.val().trim()) { anyInvalidNames = true; row.$iptName.addClass("error-background"); } }); if (anyInvalidNames) return; const names = targetRows.map(row => { row.isActive = true; row.$iptName.attr("disabled", true); row.$btnGenServerToken.attr("disabled", true); return row.$iptName.val(); }); if (this._p2pMetaV0.serverInfo) { await this._p2pMetaV0.serverInfo; const serverInfo = await PeerUtilV0.pInitialiseServersAddToExisting( names, this._p2pMetaV0.serverInfo, this._playerWindowV0_DM_MESSAGE_RECEIVER, this._playerWindowV0_DM_ERROR_HANDLER, ); return targetRows.map((row, i) => { row.name = serverInfo[i].name; row.serverInfo = serverInfo[i]; row.$iptTokenServer.val(serverInfo[i].textifiedSdp).attr("disabled", false); serverInfo[i].rowMeta = row; row.$iptTokenClient.attr("disabled", false); row.$btnAcceptClientToken.attr("disabled", false); return serverInfo[i].textifiedSdp; }); } else { this._p2pMetaV0.serverInfo = (async () => { this._p2pMetaV0.serverInfo = await PeerUtilV0.pInitialiseServers(names, this._playerWindowV0_DM_MESSAGE_RECEIVER, this._playerWindowV0_DM_ERROR_HANDLER); targetRows.forEach((row, i) => { row.name = this._p2pMetaV0.serverInfo[i].name; row.serverInfo = this._p2pMetaV0.serverInfo[i]; row.$iptTokenServer.val(this._p2pMetaV0.serverInfo[i].textifiedSdp).attr("disabled", false); this._p2pMetaV0.serverInfo[i].rowMeta = row; row.$iptTokenClient.attr("disabled", false); row.$btnAcceptClientToken.attr("disabled", false); }); })(); await this._p2pMetaV0.serverInfo; return targetRows.map(row => row.serverInfo.textifiedSdp); } } handleClick_playerWindowV0 ({doUpdateExternalStates}) { const {$modalInner} = UiUtil.getShowModal({ title: "Configure Player View", isUncappedHeight: true, isHeight100: true, cbClose: () => { if (this._p2pMetaV0.rows.length) this._p2pMetaV0.rows.forEach(row => row.$row.detach()); }, }); const $wrpHelp = UiUtil.$getAddModalRow($modalInner, "div"); const $btnAltGenAll = $(``).click(() => $btnGenServerTokens.click()); const $btnAltCopyAll = $(``).click(() => $btnCopyServers.click()); $$`

The Player View is part of a peer-to-peer (i.e., serverless) system to allow players to connect to a DM's initiative tracker. Players should use the Initiative Tracker Player View page to connect to the DM's instance. As a DM, the usage is as follows:

  1. Add the required number of players, and input (preferably unique) player names.
  2. Click "${$btnAltGenAll}," which will generate a "server token" per player. You can click "${$btnAltCopyAll}" to copy them all as a single block of text, or click on the "Server Token" values to copy them individually. Distribute these tokens to your players (via a messaging service of your choice; we recommend Discord). Each player should paste their token into the Initiative Tracker Player View, following the instructions provided therein.
  3. Get a resulting "client token" from each player via a messaging service of your choice. Then, either:
    1. Click the "Accept Multiple Clients" button, and paste in text containing multiple client tokens. This will try to find tokens in any text, ignoring everything else. Pasting a chatroom log (containing, for example, usernames and timestamps mixed with tokens) is the expected usage.
    2. Paste each token into the appropriate "Client Token" field and "Accept Client" on each. A token can be identified by the slugified player name in the first few characters.

Once a player's client has been "accepted," it will receive updates from the DM's tracker. Please note that this system is highly experimental. Your experience may vary.

`.appendTo($wrpHelp); UiUtil.addModalSep($modalInner); const $wrpTop = UiUtil.$getAddModalRow($modalInner, "div"); const $btnAddClient = $(``).click(() => addClientRow()); const $btnCopyServers = $(``) .click(async () => { const targetRows = this._p2pMetaV0.rows.filter(it => !it.isDeleted && !it.$iptTokenClient.attr("disabled")); if (!targetRows.length) { JqueryUtil.doToast({ content: `No free server tokens to copy. Generate some!`, type: "warning", }); } else { await MiscUtil.pCopyTextToClipboard(targetRows.map(it => it.$iptTokenServer.val()).join("\n\n")); JqueryUtil.showCopiedEffect($btnGenServerTokens); } }); const $btnAcceptClients = $(``) .click(() => { const {$modalInner, doClose} = UiUtil.getShowModal({title: "Accept Multiple Clients"}); const $iptText = $(``) .keydown(() => $iptText.removeClass("error-background")); const $btnAccept = $(``) .click(async () => { $iptText.removeClass("error-background"); const txt = $iptText.val(); if (!txt.trim() || !PeerUtilV0.containsAnyTokens(txt)) { $iptText.addClass("error-background"); } else { const connected = await PeerUtilV0.pConnectClientsToServers(this._p2pMetaV0.serverInfo, txt); this._board.doBindAlertOnNavigation(); connected.forEach(serverInfo => { serverInfo.rowMeta.$iptTokenClient.val(serverInfo._tempTokenToDisplay || "").attr("disabled", true); serverInfo.rowMeta.$btnAcceptClientToken.attr("disabled", true); delete serverInfo._tempTokenToDisplay; }); doClose(); doUpdateExternalStates(); } }); $$`

Paste text containing one or more client tokens, and click "Accept Multiple Clients"

${$iptText}
${$btnAccept}
`.appendTo($modalInner); }); $$`
Add a player (client): ${$btnAddClient}
Copy all un-paired server tokens: ${$btnCopyServers}
Mass-accept clients: ${$btnAcceptClients}
`.appendTo($wrpTop); UiUtil.addModalSep($modalInner); const $btnGenServerTokens = $(``) .click(() => this._playerWindowV0_pGetServerTokens({rowMetas: this._p2pMetaV0.rows})); UiUtil.$getAddModalRow($modalInner, "div") .append($$`
Player Name
Server Token
${$btnGenServerTokens}
Client Token
`); const _get$rowTemplate = ( $iptName, $iptTokenServer, $btnGenServerToken, $iptTokenClient, $btnAcceptClientToken, $btnDeleteClient, ) => $$`
${$iptName}
${$iptTokenServer}
${$btnGenServerToken}
${$iptTokenClient}
${$btnAcceptClientToken}
${$btnDeleteClient}
`; const clientRowMetas = []; const addClientRow = () => { const rowMeta = {id: CryptUtil.uid()}; clientRowMetas.push(rowMeta); const $iptName = $(``) .keydown(evt => { $iptName.removeClass("error-background"); if (evt.key === "Enter") $btnGenServerToken.click(); }); const $iptTokenServer = $(``) .click(async () => { await MiscUtil.pCopyTextToClipboard($iptTokenServer.val()); JqueryUtil.showCopiedEffect($iptTokenServer); }).disableSpellcheck(); const $btnGenServerToken = $(``) .click(() => this._playerWindowV0_pGetServerTokens({rowMetas: [rowMeta]})); const $iptTokenClient = $(``) .keydown(evt => { $iptTokenClient.removeClass("error-background"); if (evt.key === "Enter") $btnAcceptClientToken.click(); }).disableSpellcheck(); const $btnAcceptClientToken = $(``) .click(async () => { const token = $iptTokenClient.val(); if (PeerUtilV0.isValidToken(token)) { try { await PeerUtilV0.pConnectClientsToServers([rowMeta.serverInfo], token); this._board.doBindAlertOnNavigation(); $iptTokenClient.prop("disabled", true); $btnAcceptClientToken.prop("disabled", true); doUpdateExternalStates(); } catch (e) { JqueryUtil.doToast({ content: `Failed to accept client token! Are you sure it was valid? (See the log for more details.)`, type: "danger", }); setTimeout(() => { throw e; }); } } else $iptTokenClient.addClass("error-background"); }); const $btnDeleteClient = $(``) .click(() => { rowMeta.$row.remove(); rowMeta.isDeleted = true; if (rowMeta.serverInfo) { rowMeta.serverInfo.server.close(); rowMeta.serverInfo.isDeleted = true; } const ix = clientRowMetas.indexOf(rowMeta); if (~ix) clientRowMetas.splice(ix, 1); if (!clientRowMetas.length) addClientRow(); }); rowMeta.$row = _get$rowTemplate( $iptName, $iptTokenServer, $btnGenServerToken, $iptTokenClient, $btnAcceptClientToken, $btnDeleteClient, ).appendTo($wrpRowsInner); rowMeta.$iptName = $iptName; rowMeta.$iptTokenServer = $iptTokenServer; rowMeta.$btnGenServerToken = $btnGenServerToken; rowMeta.$iptTokenClient = $iptTokenClient; rowMeta.$btnAcceptClientToken = $btnAcceptClientToken; this._p2pMetaV0.rows.push(rowMeta); return rowMeta; }; const $wrpRows = UiUtil.$getAddModalRow($modalInner, "div"); const $wrpRowsInner = $(`
`).appendTo($wrpRows); if (this._p2pMetaV0.rows.length) this._p2pMetaV0.rows.forEach(row => row.$row.appendTo($wrpRowsInner)); else addClientRow(); } async pHandleDoConnectLocalV0 ({clientView}) { // generate a stub/fake row meta const rowMeta = { id: CryptUtil.uid(), $row: $(), $iptName: $(``), $iptTokenServer: $(), $btnGenServerToken: $(), $iptTokenClient: $(), $btnAcceptClientToken: $(), }; this._p2pMetaV0.rows.push(rowMeta); const serverTokens = await this._playerWindowV0_pGetServerTokens({rowMetas: [rowMeta]}); const clientData = await PeerUtilV0.pInitialiseClient( serverTokens[0], msg => clientView.handleMessage(msg), () => {}, // ignore local errors ); clientView.clientData = clientData; await PeerUtilV0.pConnectClientsToServers([rowMeta.serverInfo], clientData.textifiedSdp); } }