Skip to content

Instantly share code, notes, and snippets.

@bash-tp
Created March 3, 2021 15:52
Show Gist options
  • Save bash-tp/fcfb8947c0f186e60f007f37c54933c2 to your computer and use it in GitHub Desktop.
Save bash-tp/fcfb8947c0f186e60f007f37c54933c2 to your computer and use it in GitHub Desktop.
TagPro VCR - BETA, FOR TESTING ONLY
// ==UserScript==
// @name TagPro VCR
// @description Record TagPro socket data
// @version 0.9.0
// @author Kera, bash#
// @icon https://bash-tp.github.io/tagpro-vcr/images/vhs.png
// @namespace https://github.com/bash-tp/
// @downloadUrl https://bash-tp.github.io/tagpro-vcr/tagpro-vcr.user.js
// @updateUrl https://bash-tp.github.io/tagpro-vcr/tagpro-vcr.meta.js
// @match *://*.koalabeast.com/*
// @match *://*.jukejuice.com/*
// @match *://*.newcompte.fr/*
// @require https://wzrd.in/standalone/debug@latest
// @require https://unpkg.com/idb/build/iife/index-min.js
// ==/UserScript==
(function (createDebug, tagpro, idb, tagproConfig) {
'use strict';
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var createDebug__default = /*#__PURE__*/_interopDefaultLegacy(createDebug);
var tagpro__default = /*#__PURE__*/_interopDefaultLegacy(tagpro);
var tagproConfig__default = /*#__PURE__*/_interopDefaultLegacy(tagproConfig);
const now = () => Date.now();
function dateToString(d, filename = false) {
const dash = filename ? "" : "-";
const space = filename ? "" : " ";
const colon = filename ? "" : ":";
let str = d.getFullYear() + dash +
("0" + (d.getMonth() + 1)).slice(-2) + dash +
("0" + d.getDate()).slice(-2) + space +
("0" + d.getHours()).slice(-2) + colon +
("0" + d.getMinutes()).slice(-2);
if (filename) {
str += ("0" + d.getSeconds()).slice(-2);
}
return str;
}
function addPacketListeners(socket, events, onPacket) {
const packetListeners = new Map();
for (const type of events) {
const cb = function (data) {
onPacket(now(), type, data);
};
packetListeners.set(type, cb);
socket.on(type, cb);
}
function cancel() {
for (const [type, callback] of packetListeners) {
socket.removeListener(type, callback);
}
packetListeners.clear();
}
return { cancel };
}
function saveFile(data, filename, type = 'application/x-ndjson') {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
// window.open(url, '_blank');
URL.revokeObjectURL(url);
}
// Copied from https://stackoverflow.com/a/48254637
function stringify(obj, isP = false) {
const cache = new Set();
return JSON.stringify(obj, function (key, value) {
// Avoid incompatibility with the TagPro Player Monitor script
if (isP && (key === 'monitor')) {
return;
}
if (typeof value === 'object' && value !== null) {
if (cache.has(value)) {
// Circular reference found
try {
// If this value does not reference a parent it can be deduped
return JSON.parse(JSON.stringify(value));
}
catch (err) {
// discard key if value cannot be deduped
return;
}
}
// Store value in our set
cache.add(value);
}
return value;
});
}
class BasicRecorder {
constructor() {
this.started = false;
this.packets = [];
}
record(time, type, ...args) {
if (!this.started) {
this.started = true;
this.firstPacketTime = time;
}
// NOTE: Have to stringify, since the TagPro game modifies the packets and adds circular references.
const packet = stringify([time - this.firstPacketTime, type, ...args], type === 'p');
this.packets.push(packet);
}
end() {
return Promise.resolve(this.packets.join('\n'));
}
}
const dbName = 'tagpro-vcr-db';
const dbVersion = 1;
const dbStore = 'games';
class GameStorage {
constructor(maxGames) {
this.maxGames = maxGames;
this.init();
}
async init() {
this.db = await idb.openDB(dbName, dbVersion, {
upgrade(db) {
db.createObjectStore(dbStore);
}
});
}
async saveGame(key, game) {
await this.db.put(dbStore, game, key);
const saved = await this.db.getAllKeys(dbStore);
while (saved.length > this.maxGames) {
const oldest = saved.shift();
this.db.delete(dbStore, oldest);
}
}
async listGames() {
return await this.db.getAll(dbStore);
}
}
function isInGame(tagpro) {
return tagpro.state > 0;
}
function isTopLevelPage() {
return !!document.querySelector('#site-nav');
}
function readyAsync(tagpro) {
return new Promise(resolve => tagpro.ready(resolve));
}
const vcrEnabled = 'vcrEnabled';
const vcrDownload = 'vcrDownload';
const vcrWelcome = 'vcrWelcome';
class VcrSettings {
constructor() {
this._enabled = true;
this._download = false;
this._save = 10;
this._welcome = '';
this._enabled = this.getCookieBoolean(vcrEnabled, this._enabled);
this._download = this.getCookieBoolean(vcrDownload, this._download);
this._welcome = this.getCookieString(vcrWelcome, this._welcome);
}
getCookieBoolean(name, dflt) {
const cookie = $.cookie(name);
return cookie === 'true' ? true : cookie === 'false' ? false : dflt;
}
getCookieString(name, dflt) {
const cookie = $.cookie(name);
return cookie !== null && cookie !== void 0 ? cookie : dflt;
}
setCookie(name, value) {
$.cookie(name, String(value), { expires: 36500, path: '/', domain: tagproConfig__default['default'].cookieHost });
}
get enabled() { return this._enabled; }
set enabled(enabled) { this.setCookie(vcrEnabled, enabled); this._enabled = enabled; }
get download() { return this._download; }
set download(download) { this.setCookie(vcrDownload, download); this._download = download; }
get save() { return this._save; }
get welcome() { return this._welcome; }
set welcome(version) { this.setCookie(vcrWelcome, version); this._welcome = version; }
}
const version = '0.9.0';
class VcrWindow {
constructor(settings, storage) {
this.settings = settings;
this.storage = storage;
this.done = false;
}
addVcrLink() {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = '#';
link.innerText = 'VCR';
link.addEventListener('click', this.showVcrWindow.bind(this));
li.id = 'nav-vcr';
li.appendChild(link);
const nav = document.querySelector('#site-nav > ul');
nav.appendChild(li);
}
showVcrWelcomeIfNeeded() {
const previous = this.settings.welcome || 'Unknown';
if (previous === version)
return;
const container = document.querySelector('#userscript-top + .container');
container.innerHTML = `
<h1>Hi there, TagPro VCR User!</h1>
<p>
Sorry to interrupt but you'll only see this message when the userscript is first installed
or udpated. If you've used the TagPro VCR before, things might be a bit different.
</p>
<p>
Click on the VCR tab to get started (in the navigation bar above, all the way to the right).
Then click back on the TAGPRO tab to play. Have fun!
</p>
<h3 class="header-title">Changelog</h3>
<p>
Installed now: ${version}<br />
Previous version: ${previous}
</p>
<p><u>Version 1.0.0</u></p>
<ul class="bullet-list">
<li>Games are now saved in the browser by default.</li>
<li>Added the VCR tab with details and settings.</li>
<li>Added this automatic welcome screen.</li>
</ul>
`;
this.settings.welcome = version;
}
async showVcrWindow() {
if (this.done)
return;
this.done = true;
const container = document.querySelector('#userscript-top + .container');
const activeTab = document.querySelector('.active-tab');
const vcrTab = document.querySelector('#nav-vcr');
let newHTML;
const vcrEnabledChecked = this.settings.enabled ? "checked" : "";
const vcrDownloadChecked = this.settings.download ? "checked" : "";
const vcrSaveChecked = this.settings.download ? "" : "checked";
const settings = `
<p>&nbsp;</p>
<div class="row form-group">
<h4 class="header-title">Settings (reload page after changing)</h4>
<input id="vcrEnabled" type="checkbox" ${vcrEnabledChecked} /><label class="checkbox-inline" for="vcrEnabled">Recorder enabled (save new games)</label><br /><br />
<input id="vcrSave" type="radio" name="vcrDownloadRadio" value="false" ${vcrSaveChecked} /><label class="radio-inline" for="vcrSave">Save game files here in the browser</label><br />
<input id="vcrDownload" type="radio" name="vcrDownloadRadio" value="true" ${vcrDownloadChecked} /><label class="radio-inline" for="vcrDownload">Download game files after each game</label>
</div>
`;
const playButton = `
<div class="row form-group">
<a class="btn btn-primary" href="https://bash-tp.github.io/tagpro-vcr/" target="_blank">Play Your Recordings</a>
</div>
`;
if (this.storage) {
let table = `
<table class="table table-stripped row form-group">
<thead>
<th>Start</th>
<th>Map</th>
<th>Group?</th>
<th>Duration</th>
<th>Team</th>
<th>Name</th>
<th>Winner?</th>
<th>Download</th>
</thead>
<tbody>
`;
this.games = (await this.storage.listGames()).reverse();
this.games.forEach((game, idx) => {
const duration = new Date(game.duration).toISOString().substr(14, 5);
table += `
<tr>
<td>${game.start}</td>
<td>${game.map}</td>
<td>${game.group ? "Yes" : "No"}</td>
<td>${duration}</td>
<td>${game.team}</td>
<td>${game.name}</td>
<td>${game.winner ? "Yes" : "No"}</td>
<td>
<a class="btn btn-secondary btn-tiny" href="#" id="vcrFile" data-idx="${idx}">Download</a>
</td>
</tr>
`;
});
if (this.games.length === 0) {
table += `
<tr>
<td colspan="8"><i>No games saved yet</i></td>
</tr>
`;
}
table += `
</tbody>
</table>
`;
newHTML = `
${playButton}
<div class="row form-group">
Your ${this.storage.maxGames} most recent games will be stored in the browser.
You can download a game file from the list. Then click the button above to
visit the player website, and upload your game file to watch the replay.
</div>
${table}
${settings}
`;
}
else {
newHTML = `
${playButton}
<div class="row form-group">
Game files will automatically be downloaded after each game.
Click the button to visit the player website, and upload a
game file to watch the replay.
</div>
${settings}
`;
}
container.innerHTML = newHTML;
document.querySelectorAll('#vcrFile').forEach(link => {
link.addEventListener('click', this.downloadFile.bind(this));
});
document.querySelector('#vcrEnabled').addEventListener('click', this.setEnabled.bind(this));
document.querySelector('#vcrDownload').addEventListener('click', this.setDownload.bind(this));
document.querySelector('#vcrSave').addEventListener('click', this.setDownload.bind(this));
activeTab.classList.remove('active-tab');
vcrTab.classList.add('active-tab');
}
downloadFile(ev) {
const target = ev.target;
const idx = +target.getAttribute("data-idx");
const game = this.games[idx];
const start = new Date(game.timestamp);
const timestamp = dateToString(start, true);
saveFile(game.data, `tagpro-recording-${timestamp}.ndjson`);
}
setEnabled(ev) {
const target = ev.target;
this.settings.enabled = target.checked;
}
setDownload(ev) {
const target = ev.target;
this.settings.download = (target.value === 'true');
}
}
const debug = createDebug__default['default']('vcr');
debug.enabled = true;
const settings = new VcrSettings();
let storage;
if (!settings.download) {
storage = new GameStorage(settings.save);
}
const vcrWindow = new VcrWindow(settings, storage);
(async function () {
await readyAsync(tagpro__default['default']);
if (isInGame(tagpro__default['default']) && settings.enabled) {
debug('Recording.');
startRecording(tagpro__default['default']);
}
else if (isTopLevelPage()) {
debug('Injecting link to VCR.');
vcrWindow.addVcrLink();
vcrWindow.showVcrWelcomeIfNeeded();
}
else {
debug('Not in game.');
}
})();
function startRecording(tp) {
const recorder = new BasicRecorder();
let start = new Date();
const game = {
timestamp: start.valueOf(),
start: dateToString(start),
map: 'Unknown',
group: false,
duration: 0,
team: 'Unknown',
name: 'Unknown',
winner: false,
data: ''
};
const teams = {
red: "Red",
blue: "Blue"
};
const metadata = {
server: location.hostname,
port: location.port,
time: start.valueOf(),
tagproVersion: tp.version
};
const getPlayer = () => {
if (tp.playerId && !tp.spectator) {
return tp.players[tp.playerId];
}
return undefined;
};
recorder.record(now(), 'recorder-metadata', metadata);
// NOTE: removing $ prefix.
const events = Object.keys(tp.rawSocket['_callbacks']).map(e => (e.startsWith('$') ? e.substr(1) : e));
const listeners = addPacketListeners(tp.rawSocket, events, recorder.record.bind(recorder));
tp.rawSocket.on('time', (e) => {
if (e.state === 1) {
start = new Date();
}
});
tp.rawSocket.on('map', (e) => {
game.map = e.info.name;
});
tp.rawSocket.on('spectator', (e) => {
game.name = '[Spectator]';
game.team = 'Spectator';
});
tp.rawSocket.on('groupId', (e) => {
game.group = (e !== null);
});
tp.rawSocket.on('teamNames', (e) => {
teams.red = e.redTeamName;
teams.blue = e.blueTeamName;
});
tp.rawSocket.on('p', (e) => {
const me = getPlayer();
if (me) {
game.name = me.name;
game.team = me.team === 1 ? teams.red : me.team === 2 ? teams.blue : "Unknown";
}
});
tp.rawSocket.on('end', (e) => {
game.duration = now() - start.valueOf();
const me = getPlayer();
if (me) {
game.winner = (me.team === 1 && e.winner === "red") || (me.team === 2 && e.winner === "blue");
}
});
window.addEventListener('beforeunload', async function (ev) {
const end = now();
game.duration || (game.duration = end - start.valueOf());
debug('Getting ready to save');
listeners.cancel();
recorder.record(end, 'recorder-summary', game);
const data = await recorder.end();
if (settings.download) {
const timestamp = dateToString(start, true);
saveFile(data, `tagpro-recording-${timestamp}.ndjson`);
}
else {
game.data = data;
storage.saveGame(game.timestamp, game);
}
});
}
}(debug, tagpro, idb, tagproConfig));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment