// ==UserScript==
// @name ShuffleIt SpecTools
// @namespace http://tampermonkey.net/
// @version 1.11
// @description Spectate Tools for Shuffle It
// @author ceviri
// @match https://dominion.games/
// @require http://code.jquery.com/jquery-3.3.1.min.js
// @resource ST_css http://ceviri.me/static/woodcutter/st_css.css?v=103
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
ST = {
gameId: 0,
inLobby: () => angular.element(document.body).controller().shouldShowLobbyPage,
inScore: () => angular.element(document.body).controller().shouldShowScorePage,
inGame: () => angular.element(document.body).controller().shouldShowGamePage,
cleanse: logString => {
while (logString.match(/(<br\/>)|(<\/br>)/)) {
logString = logString.replace(/(<br\/>)|(<\/br>)/, "\n");
}
let mainPattern = /<((span)|(div))[^<>]*?>([^<>]*?)<\/\1>/;
while (logString.match(mainPattern)) {
logString = logString.replace(mainPattern, (match, p1, p2, p3, p4) => p4);
}
return logString.replace(/^\n/, "");
},
logHistory: {},
overwriteOldLog: function (gameLog, game) {
let oldIndex = 0;
let newIndex = 0;
let oldLog = ST.logHistory[ST.gameId];
while (newIndex < gameLog.logEntries.length && oldIndex < oldLog.length) {
let entry = gameLog.logEntries[newIndex];
let oldEntry = oldLog[oldIndex];
if (oldEntry.replaced == true || oldEntry.index < entry.index) {
oldIndex++;
} else if (entry.index == oldEntry.index) {
oldEntry.logEntry = ST.cleanse(parseLogEntry(entry, game));
newIndex++;
oldIndex++;
} else {
ST.logHistory[ST.gameId].splice(oldIndex, 0, {
index: entry.index,
logEntry: ST.cleanse(parseLogEntry(entry, game)),
messages: [],
replaced: false,
protected: false
});
newIndex++;
}
}
if (oldIndex < oldLog.length) {
oldLog[oldIndex].messages.unshift("### [Undo Begin] ###");
oldLog[oldLog.length - 1].messages.push("### [Undo End] ###");
for (let i = oldIndex; i < oldLog.length; i++) {
oldLog[i].replaced = true;
oldLog[i].protected = true;
}
}
},
insertNewLogEntry: game => entry => {
let newEntry = {
index: entry.index,
logEntry: ST.cleanse(parseLogEntry(entry, game)),
messages: [],
replaced: false,
protected: false
};
if (ST.gameId in ST.logHistory) {
let oldLog = ST.logHistory[ST.gameId];
let index = oldLog.findIndex(e => e.index == entry.index && e.replaced == false);
if (index > -1) {
oldLog.slice(index + 1).forEach(e => {
if (!e.protected)
e.logEntry = "";
e.replaced = true;
});
oldLog[index].logEntry = newEntry.logEntry;
ST.recolorScrollerLine(entry);
} else {
oldLog.push(newEntry);
if (ST.getOption("logScroll") == "Yes") {
ST.newScrollerLine(entry, oldLog.filter(e => e.replaced == false).length - 1);
}
}
} else {
ST.logHistory[ST.gameId] = [newEntry];
if (ST.getOption("logScroll") == "Yes") {
ST.newScrollerLine(newEntry, 0);
}
}
},
renderedScrollLines: 0,
renderLogScroller: () => {
if ($('.side-bar .st-scroll-container').length == 0){
var scroller = `<div class='st-scroll-container'>
<div class='st-scroll-final' onclick='ST.scroll(-1)'
</div>`
$('.side-bar').append(scroller);
$('.game-log').css('right', '5%');
$('.game-log').css('width', '95%');
}
},
newScrollerLine: (entry, idx) => {
if (idx > ST.renderedScrollLines) {
let colors = ['#B85454', '#1C1CB4', '#1B6B1B', '#F6F626', '#C48A1F', '#9B559B'];
let altColors = ['#883131', '#15156E', '#0C4F0C', '#EBC321', '#B36908', '#70278E'];
entry.logArguments.forEach(a => {
if (a.type == 6) {
let bgCol = colors[a.argument.owner];
let altCol = altColors[a.argument.owner];
let bg = `linear-gradient(90deg, ${altCol} 0%, ${bgCol} 60%, ${bgCol} 80%, ${altCol} 100%)`;
$(
`<div class="st-scroll-line" onclick="ST.scroll(${idx})"
style="flex-grow: 0; background: ${bg}; background-color: ${bgCol};"></div>`
).insertBefore('.st-scroll-final');
}
});
ST.renderedScrollLines = idx;
let target = $(".st-scroll-line").last();
target.css("flex-grow", parseInt(target.css("flex-grow")) + 1);
}
},
recolorScrollerLine: (entry) => {
let colors = ['#B85454', '#1C1CB4', '#1B6B1B', '#F6F626', '#C48A1F', '#9B559B'];
let altColors = ['#883131', '#15156E', '#0C4F0C', '#EBC321', '#B36908', '#70278E'];
entry.logArguments.forEach(a => {
if (a.type == 6) {
let bgCol = colors[a.argument.owner];
let altCol = altColors[a.argument.owner];
let bg = `linear-gradient(90deg, ${altCol} 0%, ${bgCol} 60%, ${bgCol} 80%, ${altCol} 100%)`;
let target = $(".st-scroll-line").last();
target.css("background", bg);
target.css("background-color", bgCol);
return;
}
});
},
scroll: (i) => {
let ypos;
i = Math.min(i, document.querySelectorAll('.game-log .log-line').length);
if (i > -1) {
let target = document.querySelectorAll('.game-log .log-line')[i];
ypos = target.offsetTop - document.querySelector('.game-log').offsetTop;
} else {
ypos = document.querySelector('.game-log').scrollHeight - document.querySelector('.game-log').clientHeight;
}
document.querySelector('.game-log').scrollTo({
top: ypos,
behavior: "smooth"
});
},
boonHexLock: 0,
boonHexThreshold: 1000,
hideBoonHex: () => {
var study = angular.element($('.landscape-study-container'));
var visible = study.length > 0 &&
study.controller().isHexBoon &&
study.controller().shouldShowLandStudyWindow;
if (visible) {
if (ST.boonHexLock == 0) {
study.controller().shouldShowLandStudyWindow = false;
study.scope().$digest();
}
} else {
if (ST.boonHexLock == 0) {
ST.boonHexLock = 1;
setTimeout(() => {ST.boonHexLock = 0;}, ST.boonHexThreshold);
}
}
},
spliceChat: (chatName) => () => {
let chat = angular.element(document.body).injector().get("chat");
chat.chatLines.splice(-1);
angular.element($(chatName)).scope().$digest();
},
insertMessageIntoLog: (message) => {
let playerName = activeMeta.playerNames[parseInt(message.sender.slice(1))];
let gameLog = ST.logHistory[ST.gameId];
gameLog[gameLog.length - 1].messages.push(`${playerName}: ${message.message}`);
},
lastTime: new Date().getTime(),
newIncomingMessage: (event, chatMessage) => {
function sufficientTimeHasPassed() {
let time = new Date().getTime();
let b = time - ST.lastTime > 30000;
ST.lastTime = time;
return b;
}
function shouldRingBell(chatMessage) {
return (chatMessage.sender.id !== activeMeta.model.me.id) && sufficientTimeHasPassed();
}
let get = angular.element(document.body).injector().get;
let playerName = activeMeta.playerNames[parseInt(chatMessage.sender.slice(1))];
let muteList = GM_getValue("ST_mutelist", "").split("~");
let validMessage = false;
if (!muteList.includes(playerName)) {
if (ST.getOption("chatFilter") == "Yes") {
switch (ST.getOption("filterMode")) {
case "Players":
validMessage = activeGame.model.players.map(p=>p.name).indexOf(playerName) != -1;
break;
case "All":
validMessage = true;
break;
}
} else {
validMessage = true;
}
}
if (validMessage) {
let idx = 0;
if (ST.inGame()) {
idx = 1;
ST.insertMessageIntoLog(chatMessage);
} else if (ST.inScore()) {
idx = 2;
}
let chatName = [".table-chat", ".game-chat", ".game-chat"][idx];
let m = parseChatMessage(chatMessage, activeMeta, activeGame);
get('chat').chatLines.push(m);
if (shouldRingBell(chatMessage)) {
get('soundService').play(SOUNDS.PING);
}
get('metaBroadcaster').send(Events.CHAT_MESSAGE_PROCESSED);
ST.renderCopyButton(idx);
ST.renderFilterButton(idx);
}
},
copyChat: () => {
let gameIds = Object.keys(ST.logHistory).sort();
let maxLength = 0;
for (id of gameIds) {
for (entry of ST.logHistory[id]) {
maxLength = Math.max(maxLength, entry.logEntry.length);
}
}
maxLength = Math.min(maxLength, 80);
let lines = [];
for (id of gameIds) {
lines.push(ST.logHistory[id].filter(e => e.logEntry.length > 0 || e.messages.length > 0)
.map(e => {
if (e.messages.length > 0)
return e.messages.map((m, i) => `${(i == 0 ? e.logEntry : '').padEnd(maxLength)}|| ${m}`).join("\n");
else
return `${(e.logEntry.padEnd(maxLength))}||`;
}).join("\n"));
}
GM_setClipboard(lines.join("\n"));
},
renderCopyButton: (modeIdx) => {
let container = [null, ".end-buttons-area", ".game-log-results"][modeIdx];
let copyButton = `
<input
class="end-turn-button chat-grab"
type=button
onclick="ST.copyChat()"
value="Copy Chat"
>`;
if (container != null) {
if ($(`${container} .chat-grab`).length == 0) {
if (modeIdx == 2 || !activeGame.heroIsPlayer()) {
$(container).append(copyButton);
}
}
}
},
renderFilterButton: (modeIdx) => {
if (ST.getOption("chatFilter") == "Yes") {
var mode = ST.getOption("filterMode");
var filterLabel = mode[0];
var button = `<div class='chat-filter-button' onclick='ST.filterMode()'> ${filterLabel} </div>`;
let container = [null, ".game-area", ".score-panel"][modeIdx];
if (container != null) {
if ($(`${container} .chat-filter-button`).length == 0) {
$(container).append(button);
} else {
$(`${container} .chat-filter-button`).html(filterLabel);
}
}
}
},
filterMode: () => {
ST.incrementOption("filterMode");
ST.renderFilterButton(ST.inGame() ? 1 : 2);
},
renderPlayerLevel: () => {
if (ST.getOption("playerLevel") == "Yes") {
var log = angular.element(document.body).injector().get("log");
var players = [activeGame.model.hero];
players = players.concat(activeGame.model.opponents);
var data = players.map(p => log.entries[0].string
.match(`${p.name}</span>: (.*?)</div>`));
var ratings = data.filter(x => x != null).map(x => Math.floor(x[1]));
$('.opponent-name-counter-pane').each(function(i) {
if (i < ratings.length) {
var inner = `<div class='rating-display'>${ratings[i]}</div>`;
} else {
var inner = '';
}
if ($(this).children('.rating-display').length == 0) {
$(this).append(inner);
} else {
$(this).children('.rating-display').replaceWith(inner);
}
});
}
},
showingSettings: false,
toggleSettings: () => {
ST.showingSettings = !ST.showingSettings;
ST.renderSettings();
},
options: [
{label: "Filter Chat", name: "chatFilter", modes: ["Yes", "No"]},
{label: "Chat Filtering Mode", name: "filterMode", modes: ["All", "Players", "None"]},
{label: "Show Player Levels", name: "playerLevel", modes: ["No", "Yes"]},
{label: "Show Log Scroller", name: "logScroll", modes: ["Yes", "No"]},
{label: "Autohide Boons/Hexes", name: "boonHex", modes: ["Yes", "No"]},
{label: "Automatically Leave Games", name: "leaveGames", modes: ["No", "Yes (5s)", "Yes (1s)"]},
],
getOption: name => {
let i = ST.options.findIndex(o => o.name == name);
return GM_getValue("ST_" + name, ST.options[i].modes[0]);
},
setOption: (name, value) => {
return GM_setValue("ST_" + name, value);
},
incrementOption: name => {
let i = ST.options.findIndex(o => o.name == name);
let modes = ST.options[i].modes;
let idx = modes.indexOf(ST.getOption(name));
ST.setOption(name, modes[(idx + 1) % modes.length]);
ST.renderSettings();
},
unmute: index => {
let oldList = GM_getValue("ST_mutelist", "");
if (oldList) {
let names = oldList.split("~");
names.splice(index, 1);
GM_setValue("ST_mutelist", names.join("~"));
}
$(".mute-list > .st-option-value").each((i, e) => {
if (i > index) {
$(e).attr("onclick", `ST.unmute(${i - 1})`)
}
})
$(".mute-list").eq(index).remove();
},
menuMute: () => {
let oldList = GM_getValue("ST_mutelist", "");
let newName = $(".st-mute-input").val();
if (!oldList.split("~").includes(newName)) {
if (oldList)
GM_setValue("ST_mutelist", `${oldList}~${newName}`);
else
GM_setValue("ST_mutelist", newName);
}
$(".st-options-container").append(
`<div class="st-option mute-list">
<div class="st-option-label">${newName}</div>
<div class="st-option-value" onclick="ST.unmute(${$(".mute-list").length})">Remove</div>
</div>`
);
},
mute: name => {
let oldList = GM_getValue("ST_mutelist", "");
if (!oldList.split("~").includes(name)) {
if (confirm(`Mute ${name}?`)) {
if (oldList)
GM_setValue("ST_mutelist", `${oldList}~${name}`);
else
GM_setValue("ST_mutelist", name);
}
} else {
alert(`You've already muted ${name}.`)
}
},
renderSettingsContents: () => {
if ($('.st-options-window').length > 0) {
ST.options.forEach ((o, i) => {
$(".st-option > .st-option-value").eq(i).html(ST.getOption(o.name));
});
} else {
let options = "";
ST.options.forEach(o => {
options += `
<div class="st-option">
<div class="st-option-label">${o.label}</div>
<div class="st-option-value" onclick="ST.incrementOption('${o.name}')">${ST.getOption(o.name)}</div>
</div>
`
});
let muteRaw = GM_getValue("ST_mutelist", "");
let muteList = "";
if (muteRaw) {
muteList = muteRaw.split("~").map((n, i) =>
`<div class="st-option mute-list">
<div class="st-option-label">${n}</div>
<div class="st-option-value" onclick="ST.unmute(${i})">Remove</div>
</div>`
).join("");
}
$('.main-lobby-page').append(`
<div class="st-options-window">
<div class="st-options-border">
<div class="st-options-border-top">
<div class="st-options-title"> SpecTools Options </div>
</div>
<div class="st-options-container">
${options}
<br style="margin-top: 2vh;">
<div class="st-option">
<div class="st-option-label">Mute List</div>
</div>
${muteList}
</div>
<div class="st-mute-container">
<input class="st-mute-input" type="text">
<div class="st-mute-submit" onclick="ST.menuMute()">Mute</div>
</div>
</div>
</div>`
);
}
},
renderSettings: () => {
if (ST.inLobby()) {
if (ST.showingSettings) {
ST.renderSettingsContents();
$('.window-container').hide();
$('.st-options-window').show();
} else {
$('.window-container').show();
$('.st-options-window').hide();
}
if ($('.bottom-lobby-links').length > 0) {
if ($('.bottom-lobby-links .st-settings').length == 0) {
$('.bottom-lobby-links').prepend(
'<div class="bottom-lobby-link st-settings" onclick="ST.toggleSettings()">SpecTools Options</div>'
)
}
}
}
},
onJoinGame: function (event, info) {
ST.gameId = info.gameId;
ST.renderedScrollLines = 0;
},
onGameLog: (gameLog, game) => {
if (ST.gameId in ST.logHistory) {
ST.overwriteOldLog(gameLog, game);
} else {
gameLog.logEntries.forEach(ST.insertNewLogEntry(game))
}
if (ST.getOption("logScroll") == "Yes") {
ST.renderedScrollLines = 0;
ST.renderLogScroller();
gameLog.logEntries.forEach(ST.newScrollerLine);
}
},
onLogEntry: (logEntry, game) => {
ST.insertNewLogEntry(game)(logEntry)
if (ST.getOption("boonHex") == "Yes") {
ST.hideBoonHex();
}
},
onChatMessage: (message) => {
ST.newIncomingMessage(null, message);
},
stopTimer: () => {
clearTimeout(ST.quitTimer);
$('.spinner').remove();
},
onGameEnd: (info, conn) => {
let duration;
switch (ST.getOption("leaveGames")) {
case "No":
return;
case "Yes (1s)":
$(".modal-window").append(`<div class="spinner fast" onclick="ST.stopTimer()"></div>`);
duration = 1000;
break;
case "Yes (5s)":
$(".modal-window").append(`<div class="spinner" onclick="ST.stopTimer()"></div>`);
duration = 5000;
break;
}
ST.quitTimer = setTimeout(conn.disconnect, duration);
},
hasReplacedChat: false,
startup: function () {
if (typeof angular.element(document.body).scope() == 'undefined') {
angular.reloadWithDebugInfo();
}
GM_addStyle (GM_getResourceText ("ST_css"));
angular.element(document.body).injector().invoke([
'$rootScope', 'log', 'game', 'gameServerConnection', function(rootScope, log, game, conn) {
rootScope.$on(Events.JOIN_GAME_SERVER, ST.onJoinGame);
rootScope.$on(Events.GAME_PAGE_LOADED, () => {
angular.element(document.body).scope().$$postDigest(() => {
ST.renderCopyButton(1);
ST.renderPlayerLevel();
ST.renderFilterButton(1);
});
});
rootScope.$on(Events.SCORE_PAGE_LOADED, () => {
angular.element(document.body).scope().$$postDigest(() => {
ST.renderCopyButton(2);
ST.renderFilterButton(2);
});
});
rootScope.$on(Events.LOBBY_PAGE_LOADED, ST.renderSettings);
rootScope.$on(Events.NEW_GAME_LOG, function (event, gameLog) {
ST.onGameLog(gameLog, game);
});
rootScope.$on(Events.NEW_LOG_ENTRY, function (event, logEntry) {
ST.onLogEntry(logEntry, game);
});
rootScope.$on(Events.CHAT_MESSAGE_RECEIVED, function (event, chatMessage) {
if (!ST.hasReplacedChat) {
let c = angular.element(document.body).injector().get('$rootScope').$$childHead;
while (!('chatMessageReceived' in c.$$listeners)) {
if (c.$$nextSibling)
c = c.$$nextSibling;
else
break;
}
c.$$listeners.chatMessageReceived = [];
ST.hasReplacedChat = true;
}
ST.onChatMessage(chatMessage);
});
rootScope.$on(Events.GAME_ENDED, function (event, info) {
angular.element(document.body).scope().$$postDigest(() => {
ST.onGameEnd(info, conn);
});
});
}]);
}
}
ST.startup();