// ==UserScript==
// @name Grundo's Cafe - Games Journal
// @namespace https://www.grundos.cafe/
// @version 0.9
// @description Keeps track of games rewards history (Community sharing can be disabled in the settings)
// @author yon
// @match *://*.grundos.cafe/games*
// @icon https://www.google.com/s2/favicons?sz=64&domain=grundos.cafe
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @require https://unpkg.com/jquery/dist/jquery.min.js
// @require https://unpkg.com/gridjs/dist/gridjs.umd.js
// ==/UserScript==
var version = 0.9;
(async function() {
'use strict';
try {
if (document.URL.includes('grundos.cafe/games/#journal')) {
await showJournal();
return;
}
if (document.URL.endsWith('grundos.cafe/games/')) {
showJournalLink();
}
else if (document.URL.includes('grundos.cafe/games/html5/')) {
showJournalLink();
await displayPastRewardsIfNeeded();
await interceptAndLogReward();
}
} catch (error) {
showError();
throw error;
}
})();
async function interceptAndLogReward() {
var originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
try {
if (method === 'POST' && url.endsWith('/games/process/')) {
this.addEventListener('load', async function() {
// Example: "Success! You get a x2 NP \nFeatured Game Bonus!\nYou've also been awarded\na Secret Laboratory Map 3!"
let responseText = this.responseText.replaceAll('\n', ' ');
if (responseText.includes("You've also been awarded")) {
let reward = responseText;
let match = responseText.match(/You've also been awarded a(?:n)? (.+?)(?=\!)/i);
if (match) {
reward = match[1];
}
else {
match = responseText.match(/You've also been awarded (.+?)(?=\!)/i);
if (match) {
reward = match[1];
}
}
let isFeaturedGame = (responseText.includes('Featured Game') || $('main > div > strong:contains(featured today)').length == 1) ? 'TRUE' : 'FALSE';
let game = $('h1[id="game-header"]')[0]?.textContent;
await logReward(game, reward, isFeaturedGame);
}
});
}
} catch (error) {
showError();
}
originalOpen.apply(this, arguments);
};
}
async function logReward(game, reward, isFeaturedGame) {
let newReward = {'date': getDateString(), 'game': game, 'url': document.URL, 'reward': reward, 'is_featured_game': isFeaturedGame};
console.log(newReward);
let gamesData = await GM.getValue('gc_games_rewards', {'version': version});
if (gamesData['game'] === undefined) {
gamesData['game'] = [];
}
gamesData['game'].push(newReward);
await GM.setValue('gc_games_rewards', gamesData);
await sendRewardIfNeeded(newReward);
}
function getDateString() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are zero-based
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
const second = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
async function sendRewardIfNeeded(newReward) {
let settings = await GM.getValue('gc_games_settings', getDefaultGamesSettings());
let setting = settings["share_rewards"];
if (setting === "public" || setting === "private") {
let username = $('div[id="userinfo"] a[href^="/userlookup/?user="]')[0].href.split('/?user=')[1];
let privacy = setting;
let formId = '1FAIpQLSfS9W682NADVesEGVN_VO0ZBjgE7PRBPUDj3Qpmnu9sZjVWzA';
const query = {
"1350867233": version,
"1534307698": newReward['date'],
"1053758418": newReward['game'],
"153236007": newReward['url'],
"1007726898": newReward['reward'],
"1344427827": newReward['is_featured_game'],
"1550065838": username,
"815139160": privacy,
};
let formLink = `https://docs.google.com/forms/d/e/${formId}/formResponse?usp=pp_url`;
for (const [key, value] of Object.entries(query)) {
formLink += `&entry.${key}=${value}`;
}
let opts = {
mode: 'no-cors',
referrer: 'no-referrer',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
let response = fetch(formLink, opts)
.then(response => {
console.log("Game data submitted");
})
.catch(error => {
console.log("Error:", error);
console.log(response)
});
}
}
async function displayPastRewardsIfNeeded() {
let settings = await GM.getValue('gc_games_settings', getDefaultGamesSettings());
let setting = settings["display_rewards"];
if (setting !== "yes") {
return;
}
let game = $('h1[id="game-header"]')[0]?.textContent;
if (!game) {
return;
}
let gamesData = await GM.getValue('gc_games_rewards', {'version': version});
let gameData = gamesData[game];
if (!gameData) {
return;
}
await displayPastRewardsFromGameData(gameData);
}
async function displayPastRewardsFromGameData(gameData) {
const prizesElement = document.createElement('div');
prizesElement.className = 'prizes';
const prizesTextElement = document.createElement('p');
prizesTextElement.className = 'prizes-text center';
prizesTextElement.textContent = 'Here are all the rewards you have got so far:';
prizesElement.appendChild(prizesTextElement);
const prizesListElement = document.createElement('div');
prizesListElement.className = 'itemList';
let settings = await GM.getValue('gc_games_settings', getDefaultGamesSettings());
let setting = settings["rewards_sort_order"];
if (setting === "desc") {
gameData = gameData.reverse();
}
setting = settings["aggregate_rewards"];
if (setting === "yes") {
let aggregatedCounts = {};
let gameDataFiltered = [];
for (let rowData of gameData) {
let prizeName = rowData['reward'];
if (prizeName in aggregatedCounts) {
aggregatedCounts[prizeName] += 1;
}
else
{
aggregatedCounts[prizeName] = 1;
gameDataFiltered.push(rowData);
}
}
for (let rowData of gameDataFiltered) {
let prizeName = rowData['reward'];
rowData['count'] = aggregatedCounts[prizeName];
}
gameData = gameDataFiltered;
}
gameData.forEach(rowData => {
if (!rowData['reward']) {
return;
}
let prizeName = rowData['reward'];
let count = rowData['count'] ?? 0; // generated from aggregate_rewards setting, 0 means 1 but the count won't show
let shownPrizeName = prizeName;
if (count > 0) {
shownPrizeName += ' x' + count;
}
const invItem = document.createElement('div');
invItem.className = 'shop-item';
/* TODO?
const img = document.createElement('img');
img.className = 'med-image border-1';
img.src = prizeImage;*/
const itemDiv = document.createElement('div');
itemDiv.className = 'item-info';
itemDiv.innerHTML = `<span>${shownPrizeName}</span>`;
const linksDiv = document.createElement('div');
linksDiv.id = prizeName + '-links';
linksDiv.className = 'searchhelp';
linksDiv.setAttribute('style', `display: flex; justify-content: center; align-items: center; gap: 4px;`);
const formattedName = prizeName.replaceAll(' ', '%20');
const swLink = document.createElement('a');
swLink.href = `/market/wizard/?query=${formattedName}`;
swLink.target = '_blank';
const swImg = document.createElement('img');
swImg.src = 'https://grundoscafe.b-cdn.net/misc/wiz.png';
swLink.appendChild(swImg);
linksDiv.appendChild(swLink);
const sdbLink = document.createElement('a');
sdbLink.href = `/safetydeposit/?page=1&query=${formattedName}&exact=1`;
sdbLink.target = '_blank';
const sdbImg = document.createElement('img');
sdbImg.src = 'https://grundoscafe.b-cdn.net/misc/sdb.gif';
sdbLink.appendChild(sdbImg);
linksDiv.appendChild(sdbLink);
const tpLink = document.createElement('a');
tpLink.href = `/island/tradingpost/browse/?query=${formattedName}`;
tpLink.target = '_blank';
const tpImg = document.createElement('img');
tpImg.src = 'https://grundoscafe.b-cdn.net/misc/tp.png';
tpLink.appendChild(tpImg);
linksDiv.appendChild(tpLink);
const wlLink = document.createElement('a');
wlLink.href = `/wishlist/search/?query=${formattedName}`;
wlLink.target = '_blank';
const wlImg = document.createElement('img');
wlImg.src = 'https://grundoscafe.b-cdn.net/misc/wish_icon.png';
wlLink.appendChild(wlImg);
linksDiv.appendChild(wlLink);
//invItem.appendChild(img);
invItem.appendChild(itemDiv);
invItem.appendChild(linksDiv);
prizesListElement.appendChild(invItem);
});
prizesElement.appendChild(prizesListElement);
$('div[id="page_content"]').append(prizesElement);
return prizesElement;
}
function showJournalLink() {
let pageContents = $('div[id="page_content"]');
if (pageContents.length > 0) {
const htmlString = `
<div id="games_journal_header" style="display: flex; justify-content: flex-end; margin-bottom: 12px;">
<div id="games_journal_redirection">
<a href="https://www.grundos.cafe/games/#journal" target="_blank">Go to Journal</a>
</div>
</div>`;
pageContents[0].insertAdjacentHTML("beforeend", htmlString);
}
}
async function showJournal() {
let journalElement = getJournalElement();
replacePageWithElement(journalElement);
await addExportButtonListener(journalElement);
await addResetButtonListener(journalElement);
await addSettingsButtonListener(journalElement);
await loadGrid();
}
function getJournalElement() {
let journalElement = document.createElement('div');
let html = `
<center>
<div id="games_journal">
<h1>Games Journal</h1>
<button id="games_export">Export</button>
<button id="games_reset">Reset</button>
<div id="games_history"></div>
<div id="games_settings"></div>
</div>
</center>`;
journalElement.innerHTML = html;
let cssLink = document.createElement("link");
cssLink.rel = "stylesheet";
cssLink.href = "https://unpkg.com/gridjs/dist/theme/mermaid.min.css";
journalElement.appendChild(cssLink);
var styleElement = document.createElement('style');
styleElement.textContent = `
#games_journal {
h1 { margin-bottom: 24px; }
#games_export { font-size: 16px; padding: 8px 16px; }
#games_reset { font-size: 16px; padding: 8px 16px; }
#games_history { margin: 24px; }
#games_settings { margin: 24px; }
.gridjs-search { float: initial; width: "100%" }
.gridjs-search-input { width: 100% }
}`;
journalElement.appendChild(styleElement);
return journalElement;
}
async function loadGrid() {
let rowsPerPage = 15;
let rows = await getJournalHistoryRows();
// start loading grid data
let grid = new gridjs.Grid({
columns: getJournalHistoryColumns(),
data: rows,
pagination: {
limit: rowsPerPage,
summary: true
},
resizable: true,
search: {
debounceTimeout: 0
},
sort: {
multiColumn: true
},
autoWidth: true
}).render(document.getElementById("games_history"));
// show loading message
let loadingElement = showLoadingMessage();
// wait for grid to finish loading
let expectedRows = Math.min(rows.length, rowsPerPage);
await waitForGridCompleteLoad(expectedRows);
// hide loading message
hideLoadingMessage(loadingElement);
fixGridJsTable();
}
function getJournalHistoryColumns() {
return ["Date", "Game", "Reward", "Is Featured?"];
}
async function getJournalHistoryRows() {
let gamesData = await GM.getValue('gc_games_rewards', {'version': version});
let tableData = [];
let games = Object.keys(gamesData).filter(name => gamesData.hasOwnProperty(name) && name !== 'version').sort();
for (const game of games) {
let gameData = gamesData[game];
for (let i in gameData) {
let rowData = gameData[i];
let date = rowData['date'];
let gameName = rowData['game'];
let reward = rowData['reward'] ?? '';
let isFeaturedGame = rowData['is_featured_game'];
tableData.push([date, gameName, reward, isFeaturedGame]);
}
}
return tableData;
}
function showLoadingMessage() {
let loadingElement = document.createElement('div');
loadingElement.innerHTML = `
<h2>Loading...</h2>
<div class="loader"></div>
`;
var styleElement = document.createElement('style');
styleElement.textContent = `
h2 { color: #3498db; /* Blue */ }
.loader {
border: 16px solid #f3f3f3; /* Light grey */
border-top: 16px solid #3498db; /* Blue */
border-radius: 50%;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
loadingElement.appendChild(styleElement);
$('div[class="gridjs-head"]')[0].parentNode.appendChild(loadingElement);
return loadingElement;
}
function hideLoadingMessage(loadingElement) {
loadingElement.style.display = "none";
}
async function waitForGridCompleteLoad(expectedRows) {
let done = false;
while (!done) {
// wait 100 ms
await new Promise(r => setTimeout(r, 100));
// check
let currentRows = $('table[role="grid"] > tbody > tr');
if (currentRows && currentRows.length === expectedRows) {
done = true;
}
}
}
function fixGridJsTable() {
// issue: the table does not load fully before interacting with it
// small hack: interacting by sorting by date descending
// todo: I need to either change library or find a better fix
// sort asc
$('table[role="grid"] > thead > tr > th[data-column-id="date"] > button')[0].click();
// sort desc
$('table[role="grid"] > thead > tr > th[data-column-id="date"] > button')[0].click();
}
function replacePageWithElement(element) {
let page = document.querySelector('html');
page.parentNode.replaceChild(element, page);
}
async function addExportButtonListener(journalElement) {
let gamesData = await GM.getValue('gc_games_rewards', {'version': version});
let exportElement = $('button[id="games_export"]');
exportElement.click(exportFunction);
function exportFunction() {
const filename = `${getDateString()}_games_journal.json`;
const jsonString = JSON.stringify(gamesData, null, 2); // The third argument is for indentation
const blob = new Blob([jsonString], { type: 'application/json' });
const downloadLink = document.createElement('a');
downloadLink.href = window.URL.createObjectURL(blob);
downloadLink.download = filename;
journalElement.appendChild(downloadLink);
downloadLink.click();
journalElement.removeChild(downloadLink);
};
}
async function addResetButtonListener(journalElement) {
let resetElement = $('button[id="games_reset"]');
resetElement.click(resetFunction);
async function resetFunction() {
if (confirm("Do you really want to reset your Games Journal? This action cannot be undone (but you can export it first).") == true) {
await GM.deleteValue('gc_games_rewards');
}
};
}
async function addSettingsButtonListener(journalElement) {
let settingsElement = $('div[id="games_settings"]')[0];
let html = `
<details>
<summary>Settings</summary>
<div id="games_settings_parameters"></div>
</details>`;
settingsElement.innerHTML = html;
var styleElement = document.createElement('style');
styleElement.textContent = `
summary { font-weight: bold; font-size: 28px; }
label { font-size: 24px; }
select { font-size: 20px; margin-left: 8px; }
#games_settings_save { font-size: 16px; padding: 8px 16px; }`;
settingsElement.appendChild(styleElement);
let settingsDetailsElement = $('div[id="games_settings_parameters"]')[0];
let defaultSettings = getDefaultGamesSettings();
let currentSettings = await GM.getValue('gc_games_settings', defaultSettings);
let parameters = {
"share_rewards": {
"label": "ε(´。•᎑•`)っ 💕 Share your future prize rewards with the community 💕",
"possibleValues": {
"public": "Yes",
"private": "Yes but keep my name hidden (only visible to admin)",
"no": "No"
},
"defaultValue": defaultSettings["share_rewards"]
},
"display_rewards": {
"label": "Display rewards below games",
"possibleValues": {
"yes": "Yes",
"no": "No"
},
"defaultValue": defaultSettings["display_rewards"]
},
"rewards_sort_order": {
"label": "Display rewards in the following order",
"possibleValues": {
"desc": "Latest first",
"asc": "Oldest first"
},
"defaultValue": defaultSettings["rewards_sort_order"]
},
"aggregate_rewards": {
"label": "Aggregate game rewards per item",
"possibleValues": {
"yes": "Yes",
"no": "No"
},
"defaultValue": defaultSettings["aggregate_rewards"]
},
};
for (const [parameter, values] of Object.entries(parameters)) {
const label = document.createElement("label");
label.setAttribute("for", parameter);
label.textContent = values["label"] + ":";
const select = document.createElement("select");
select.id = parameter;
let parameterCurrentSetting = currentSettings[parameter] ?? values["defaultValue"];
for (const [value, label] of Object.entries(values["possibleValues"])) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
if (value == parameterCurrentSetting) {
option.selected = true;
}
select.appendChild(option);
}
const paragraph = document.createElement("p");
paragraph.appendChild(label);
paragraph.appendChild(select);
settingsDetailsElement.appendChild(paragraph);
}
var saveSettingsElement = document.createElement('button');
saveSettingsElement.id = 'games_settings_save';
saveSettingsElement.textContent = 'Save settings';
saveSettingsElement.addEventListener('click', saveSettingsFunction);
async function saveSettingsFunction() {
let newSettings = {};
for (const [parameter, values] of Object.entries(parameters)) {
let selectElement = document.getElementById(parameter);
newSettings[parameter] = selectElement.value;
}
await GM.setValue('gc_games_settings', newSettings);
console.log('New settings:', newSettings);
alert('Settings have been saved.')
};
settingsDetailsElement.appendChild(saveSettingsElement);
}
function getDefaultGamesSettings() {
let defaultSettings = {
"share_rewards": "private",
"display_rewards": "yes",
"rewards_sort_order": "desc",
"aggregate_rewards": "yes"
};
return defaultSettings;
}
function showError() {
let errorMessage = `
<div class="error-message">
<p>Oops! Something went wrong with the Games Journal script.</p>
<p>Please check you have the latest option from <a href="https://greasyfork.org/en/scripts/488478-grundo-s-cafe-games-journal">here</a></p>
<p>If it still does not work, please keep the tab open (or save the HTML) and contact Yon#epyslone, the script probably needs an update.</p>
</div>
`;
$('div[id="page_content"]').prepend(errorMessage);
}