Last active
November 13, 2022 04:54
-
-
Save wesleywerner/fbcb0131687c067e922678c90b0c4480 to your computer and use it in GitHub Desktop.
Urban Dead character memories
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name UD Remembrance | |
// @version 4 | |
// @author Wesley Werner (aka Wez) | |
// @description Lists memories of previous turns. | |
// @namespace http://wiki.urbandead.com/index.php/User:Wez | |
// @updateURL https://gist.github.com/wesleywerner/fbcb0131687c067e922678c90b0c4480/raw/ud.remembrance.user.js | |
// @downloadURL https://gist.github.com/wesleywerner/fbcb0131687c067e922678c90b0c4480/raw/ud.remembrance.user.js | |
// @grant GM.getValue | |
// @grant GM.setValue | |
// @require https://openuserjs.org/src/libs/sizzle/GM_config.js | |
// @match https://urbandead.com/map.cgi* | |
// @match https://www.urbandead.com/map.cgi* | |
// @exclude https://urbandead.com/map.cgi?logout | |
// @exclude https://www.urbandead.com/map.cgi?logout | |
// ==/UserScript== | |
/* Urban Dead Remembrance | |
* | |
* Remembers location and action messages from previous turns, and displays them at the bottom of the game page. | |
* | |
* It supports multiple characters. This works by finding the character name in the profile link on the game page, | |
* if you have other scripts that remove the profile link, it may not detect the character name correctly. | |
* If this happens a warning is printed to the console log, the result is that all character memories are merged into one. | |
* | |
* If you find this script does not play well with other scripts, report it on the gist page and I will do my best to | |
* update Remembrance - https://gist.github.com/wesleywerner/fbcb0131687c067e922678c90b0c4480 | |
* | |
* Licensed under GNU GPL V2 http://www.gnu.org/copyleft/gpl.html | |
* | |
* [Feature List] | |
* - Runs asynchronously via promises. | |
* - Stores memories per character in persistent storage. | |
* - Lists past memories of location and actions. | |
* - Displays the relative turn number, date and time per memory. | |
* - Hover over the date to see a tooltip of the number of days since that memory. | |
* - Allows forgetting all memories for the current character. | |
* | |
* [Version History] | |
* | |
* 2020-05-31 version 1 | |
* | |
* 2020-06-06 version 2 | |
* + Remember all messages including "Since your last turn" | |
* and "You have run out of Action Points". | |
* | |
* 2022-11-06 version 3 | |
* + Fixed include urls for https | |
* | |
* 2022-11-13 version 4 | |
* + Hide older memories from view, a new button reveals them. | |
* + Pin memories to keep them forever. | |
* + Add config for number of memories to store. | |
* + If character name detection fails, show a warning message. | |
* + Enhance memory timestamps: format "relative hours/days/weeks/months (absolute timestamp)", right aligned, encased in block style. | |
*/ | |
// Enable for debugging and development. | |
// * Stores memories under a different key. | |
// * Outputs debugging objects to console. | |
const DEBUG = false; | |
// The storage key used for saving data | |
const STORE_KEY = 'com.urbandead.remembrance.'; | |
// The unique name of the memories display element. | |
// Used to detect and remove any existing such elements (in case of browser back/forwards navigation) | |
const DISPLAY_ID = 'remembrance_display'; | |
const MEMORY_CLASS = 'ud-memory'; | |
// Default Values | |
const DEFAULT_MAX_MEMORIES = 50; | |
const DEFAULT_DATE_STYLE = 'both'; | |
const DEFAULT_DATE_FORMAT = 'friendly'; | |
const DEFAULT_HIDE_HOURS = 36; | |
/* | |
* Extract the player name from the DOM. | |
*/ | |
function get_player_name() { | |
let player_name = ''; | |
let name_node = document.evaluate('//td[@class="cp"]/div[@class="gt"]/a', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | |
if (!name_node) { | |
console.warn('Remembrance: Could not detect the player name, most likely because the player name anchor has been removed by another user script. This means that the memories of all your characters will be merged.'); | |
} else { | |
player_name = name_node.innerText; | |
console.info('Remembrance: Recalling past transgressions for '+player_name); | |
} | |
// Seperation of concerns | |
if (DEBUG) { | |
player_name += '.debug'; | |
} | |
return player_name; | |
} | |
/* | |
* Returns a new empty storage structure. | |
*/ | |
function new_storage(player_name) { | |
return {items:[],name:player_name}; | |
} | |
/* | |
* Loads stored history from GreaseMonkey into the `storage` variable. | |
*/ | |
function load_memories() { | |
const a_promise = new Promise((resolve, reject) => { | |
// Get the player name | |
let player_name = get_player_name(); | |
// Read value via Promise | |
GM.getValue(STORE_KEY+player_name, '') | |
.then((stream) => { | |
let storage; | |
// Parse the stream if not empty | |
if (stream) { | |
storage = JSON.parse(stream); | |
} else { | |
storage = new_storage(player_name); | |
} | |
resolve(storage); | |
}); | |
}); | |
return a_promise; | |
} | |
/* | |
* Adds new location and action texts to memory. | |
*/ | |
function add_memories(storage) { | |
const a_promise = new Promise((resolve, reject) => { | |
let location_text; | |
// load the location message | |
let location_div = document.evaluate('//td[@class="gp"]/div[@class="gt"]', document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(0); | |
location_text = location_div.innerHTML; | |
// Iterate over all paragraphs. | |
let action_text = ''; | |
let paragraphs = document.evaluate('//td[@class="gp"]/p', document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); | |
let thisp = paragraphs.iterateNext(); | |
while (thisp) { | |
// stop on 'Possible actions:' | |
if (thisp.innerText == 'Possible actions:') break; | |
// include this paragraph content. | |
action_text += '<p>'+thisp.innerHTML+'</p>'; | |
// include messages since your last turn | |
if (thisp.innerText == 'Since your last turn:') { | |
let since_list = document.evaluate('//td[@class="gp"]/ul', document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(0); | |
action_text += since_list.outerHTML; | |
} | |
// read the next paragraph | |
thisp = paragraphs.iterateNext(); | |
} | |
// test if the message is the most recent stored | |
let last_message = storage.items[storage.items.length-1] || {location:null, action:null}; | |
if (last_message) { | |
if (last_message.location != location_text || last_message.action != action_text) { | |
// record this memory | |
const date_options = { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }; | |
let now = new Date(); | |
let now_formatted = now.toLocaleDateString(undefined, date_options) +' '+ now.toLocaleTimeString(); | |
storage.items.push({action:action_text, location:location_text, date:now_formatted}); | |
} | |
} | |
resolve(storage); | |
}); | |
return a_promise; | |
} | |
/* | |
* Remove older memories. | |
*/ | |
function cull_memories(storage) { | |
const a_promise = new Promise((resolve, reject) => { | |
let max_memories = (GM_config.get('max_memories') || DEFAULT_MAX_MEMORIES) + 1; | |
let remove_count = Math.max(0, storage.items.length - max_memories); | |
let pinned = []; | |
for (var i=0; i<remove_count; i++) { | |
let removed = storage.items.shift(); | |
if (removed.pinned) { | |
removed.moved_pin = true; | |
pinned.push(removed); | |
} | |
} | |
pinned.forEach((el)=>storage.items.unshift(el)); | |
resolve(storage); | |
}); | |
return a_promise; | |
} | |
/* | |
* Clears all memories. | |
*/ | |
function forget_memories_action() { | |
if (prompt('Really forget all your memories? Enter "affirmative" to confirm.') == 'affirmative') { | |
let player_name = get_player_name(); | |
let storage = new_storage(player_name); | |
record_memories(storage) | |
.then(display_memories); | |
} | |
} | |
/* | |
* Writes memories into persistent storage. | |
*/ | |
function record_memories(storage) { | |
const a_promise = new Promise((resolve, reject) => { | |
GM.setValue(STORE_KEY+storage.name, JSON.stringify(storage)) | |
.then(()=>{resolve(storage)}); | |
}); | |
return a_promise; | |
} | |
/* | |
* List memories on the screen. | |
*/ | |
function display_memories(storage) { | |
const a_promise = new Promise((resolve, reject) => { | |
if (DEBUG) { | |
console.info('Remembrance: Listing Memories.'); | |
console.log(storage); | |
} | |
const date_style = GM_config.get('date_style') || DEFAULT_DATE_STYLE; | |
const date_format = GM_config.get('date_format') || DEFAULT_DATE_FORMAT; | |
if (DEBUG) { | |
console.info('date style/format',date_style,date_format); | |
} | |
// Remove existing display blocks -- if user navigates through browser history. | |
document.getElementsByName(DISPLAY_ID).forEach((n)=>n.remove()) | |
let table_td = create_element('remembrance container'); | |
// Count and clamp our memories | |
const memory_count = storage.items.length-2; | |
// List 'no memories' flavour text | |
if (memory_count <= -1) { | |
table_td.appendChild(create_element('em', {'content':'You have no recent memories'})); | |
} | |
// Flag if there are more memories than what is shown. | |
let show_older_link = false; | |
// Test memory age against hide_hours. | |
const hide_hours = GM_config.get('hide_hours') || DEFAULT_HIDE_HOURS; | |
// For every memory we have | |
for (let i=memory_count; i>=0; i--) { | |
// Load memory data | |
let date_content = storage.items[i].date; | |
let location_content = storage.items[i].location; | |
let action_content = storage.items[i].action; | |
let is_pinned = storage.items[i].pinned; | |
let moved_pin = storage.items[i].moved_pin; | |
// Memory container | |
let memory_div = create_element('memory container'); | |
memory_div.dataset.id = i; | |
// Turn number | |
let turn_number = memory_count - i + 1; | |
var turn_description = (turn_number==1 && 'previous turn' || (turn_number+' turns ago')); | |
if (moved_pin) turn_description = '(pinned)'; | |
let turn_el = create_element('turn number', {'description':turn_description}); | |
// Append Date | |
if (date_content) { | |
let date_el = create_element('memory date', {'style':date_style, 'format':date_format, 'value':date_content}); | |
if (date_el.dataset.hours > hide_hours) { | |
if (!show_older_link) { | |
table_td.appendChild(create_element('show older link')); | |
show_older_link = true; | |
} | |
memory_div.style.display = 'none'; | |
} | |
turn_el.appendChild(date_el); | |
} | |
// Add turn & location | |
memory_div.appendChild(turn_el); | |
memory_div.appendChild(create_element('memory content', {'content':location_content})); | |
// Add player action | |
if (action_content) { | |
memory_div.appendChild(create_element('p', {'content':action_content})); | |
} | |
memory_div.appendChild(create_element('pin memory', {'pinned':is_pinned})); | |
table_td.appendChild(memory_div); | |
} | |
if (memory_count > 0) { | |
table_td.appendChild(create_element('forget link')); | |
} | |
resolve(storage); | |
}); | |
return a_promise; | |
} | |
/* | |
* Show all memories (including hidden). | |
*/ | |
function display_older_memories() { | |
let els = document.querySelectorAll('.ud-memory'); | |
els.forEach((el)=>{el.style.display=''}); | |
this.remove(); | |
} | |
/* | |
* Toggles the pinned state of memories. | |
*/ | |
function toggle_pin() { | |
let pin_el = this; | |
let memory_div = pin_el.parentElement; | |
load_memories().then(function(storage) { | |
let memory = storage.items[memory_div.dataset.id]; | |
memory.pinned = !memory.pinned; | |
record_memories(storage); | |
if (memory.pinned) { | |
pin_el.style.background = '#232'; | |
pin_el.title = 'This memory is pinned'; | |
} else { | |
pin_el.style.background = ''; | |
pin_el.title = 'Pin this memory to keep it from getting removed'; | |
} | |
}); | |
} | |
/* | |
* A helper to create the various DOM elements. | |
*/ | |
function create_element(what, arg) { | |
switch (what) { | |
case 'em': | |
{ | |
let em = document.createElement('em'); | |
em.appendChild(document.createTextNode(arg.content)); | |
return em; | |
} | |
case 'p': | |
{ | |
let p = document.createElement('p'); | |
p.innerHTML = arg.content; | |
return p; | |
} | |
case 'remembrance container': | |
{ | |
// Find the document table | |
let table_body = document.evaluate('//table/tbody', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | |
// Build a memories row | |
let table_tr = document.createElement('tr'); | |
let table_td = document.createElement('td'); | |
table_td.classList.add('gp'); | |
// Name this node | |
table_tr.setAttribute('name', DISPLAY_ID); | |
// Add cells and rows to the document | |
table_tr.appendChild(document.createElement('td')); | |
table_tr.appendChild(table_td); | |
table_body.appendChild(table_tr); | |
// Remembrance heading | |
let header_div = create_element('header'); | |
table_td.appendChild(header_div); | |
// Add a DEBUG banner | |
if (DEBUG) { | |
header_div.appendChild(create_element('debug banner')); | |
} | |
// Show warning if player name cannot be read | |
let player_name = get_player_name(); | |
if (player_name == '' || player_name == '.debug') { | |
header_div.appendChild(create_element('character name warning')); | |
} | |
return table_td; | |
} | |
case 'header': | |
{ | |
let header_div = document.createElement('div'); | |
header_div.classList.add('gt'); | |
let header_text = document.createElement('H2'); | |
header_text.appendChild(document.createTextNode('You Remember')); | |
header_div.appendChild(header_text); | |
header_text.appendChild(create_element('config link')); | |
return header_div; | |
} | |
case 'forget link': | |
{ | |
let forget_link = document.createElement('a'); | |
forget_link.appendChild(document.createTextNode('forget')); | |
forget_link.style.float = 'right'; | |
forget_link.classList.add('y'); // button style | |
forget_link.addEventListener('click', forget_memories_action); | |
forget_link.setAttribute('href', '#/'); | |
let forget_el = document.createElement('p'); | |
forget_el.appendChild(forget_link); | |
return forget_el; | |
} | |
case 'show older link': | |
{ | |
let anchor = document.createElement('a'); | |
anchor.appendChild(document.createTextNode('Older memories')); | |
anchor.classList.add('y'); | |
anchor.addEventListener('click', display_older_memories); | |
anchor.setAttribute('href', '#/'); | |
let forget_el = document.createElement('p'); | |
forget_el.appendChild(anchor); | |
return forget_el; | |
} | |
case 'talk anchor': | |
{ | |
let talk_anchor = document.createElement('a'); | |
talk_anchor.appendChild(document.createTextNode('[talk page]')); | |
talk_anchor.setAttribute('href', 'http://wiki.urbandead.com/index.php/User_talk:Wez'); | |
talk_anchor.setAttribute('target', '_blank'); | |
return talk_anchor; | |
} | |
case 'config link': | |
{ | |
let config_link = document.createElement('a'); | |
config_link.innerHTML = '⚙'; // gear | |
config_link.style.paddingLeft = '1em'; | |
config_link.title = 'Configure Remembrance'; | |
config_link.href = '#/'; | |
config_link.addEventListener('click', show_config_ui); | |
return config_link; | |
} | |
case 'memory date': | |
{ | |
let date_element = document.createElement('em'); | |
date_element.style.float = 'right'; | |
// Try parse the date and add a element title of the number of days since | |
try { | |
const ms_in_second = 1000; | |
const sec_in_min = 60; | |
const min_in_hour = 60; | |
const hr_in_day = 24; | |
const parsed_date = new Date(arg.value); | |
const today = new Date(); | |
const mins_diff = Math.floor((today - parsed_date) / (ms_in_second*sec_in_min)); | |
const hours_diff = Math.floor(mins_diff / min_in_hour); | |
const days_diff = Math.floor(hours_diff / hr_in_day); | |
// Store hours on element dataset | |
date_element.dataset.hours = hours_diff; | |
let rel_text = ''; | |
if (hours_diff == 0) { | |
rel_text = `${mins_diff} minutes ago`; | |
} else if (days_diff <= 3) { | |
rel_text = `${hours_diff} hours ago`; | |
} else { | |
rel_text = `${days_diff} days ago`; | |
} | |
let abs_text = parsed_date.toString(); | |
if (arg.format == 'friendly') { | |
abs_text = `${parsed_date.toDateString()} ${parsed_date.toLocaleTimeString()}`; | |
} else if (arg.format == 'locale') { | |
abs_text = parsed_date.toLocaleString(); | |
} else if (arg.format == 'full') { | |
abs_text = parsed_date.toString(); | |
} | |
if (arg.style == 'both') { | |
date_element.appendChild(document.createTextNode(`${rel_text} (${abs_text})`)); | |
} else if (arg.style == 'relative') { | |
date_element.appendChild(document.createTextNode(rel_text)); | |
} else if (arg.style == 'absolute') { | |
date_element.appendChild(document.createTextNode(abs_text)); | |
} | |
} | |
catch (error) {} | |
return date_element; | |
} | |
case 'memory container': | |
{ | |
let div = document.createElement('div'); | |
div.classList.add('gt'); | |
div.classList.add(MEMORY_CLASS); | |
return div; | |
} | |
case 'memory content': | |
{ | |
let el = document.createElement('p'); | |
el.innerHTML = arg.content; | |
return el; | |
} | |
case 'turn number': | |
{ | |
let el = document.createElement('p'); | |
el.classList.add('gut'); | |
el.appendChild(document.createTextNode(arg.description)); | |
return el; | |
} | |
case 'character name warning': | |
{ | |
let warn_el = document.createElement('p'); | |
warn_el.classList.add('gut'); | |
const warning_text = 'Warning: Remembrance cannot find your character name on this page. ' | |
+ 'If you use other scripts that remove the profile link it may break this detection. ' | |
+ 'This means that all your characters will share memories. ' | |
+ 'If you find this script does not play well with others, report it on my talk page and I will do my best to apply a fix. '; | |
warn_el.appendChild(document.createTextNode(warning_text)); | |
warn_el.appendChild(create_element('talk anchor')); | |
return warn_el; | |
} | |
case 'debug banner': | |
{ | |
let debug_header = document.createElement('H2'); | |
debug_header.appendChild(document.createTextNode('THIS SCRIPT IS IN DEVELOPER MODE')); | |
debug_header.classList.add('gut'); | |
debug_header.style.float = 'right'; | |
return debug_header; | |
} | |
case 'pin memory': | |
{ | |
let el = document.createElement('a'); | |
el.href = '#/'; | |
el.appendChild(document.createTextNode('📌')); | |
el.classList.add('y'); // button style | |
if (arg.pinned) { | |
el.title = 'This memory is pinned'; | |
el.style.background = '#232'; | |
} else { | |
el.title = 'Pin this memory to keep it from getting removed'; | |
} | |
el.addEventListener('click', toggle_pin); | |
return el; | |
} | |
} | |
} | |
/* | |
* Initialize configuration module. | |
*/ | |
function init_config() { | |
const a_promise = new Promise((resolve, reject) => { | |
GM_config.init( | |
{ | |
'id': 'Remembrance', | |
'title': 'Remembrance Settings', | |
'fields': | |
{ | |
'date_style': | |
{ | |
'label': 'Date Style', | |
'type': 'select', | |
'options': [DEFAULT_DATE_STYLE, 'relative', 'absolute'], | |
'default': DEFAULT_DATE_STYLE | |
}, | |
'date_format': | |
{ | |
'label': 'Date Format', | |
'type': 'select', | |
'options': [DEFAULT_DATE_FORMAT, 'locale', 'full'], | |
'default': DEFAULT_DATE_FORMAT | |
}, | |
'max_memories': | |
{ | |
'label': 'Max Memories', | |
'title': 'Store this many memories, discarding older ones as new ones are made.', | |
'type': 'int', | |
'default': DEFAULT_MAX_MEMORIES, | |
'min': DEFAULT_MAX_MEMORIES, | |
'max': 500 | |
}, | |
'hide_hours': | |
{ | |
'label': 'Hide Hours', | |
'title': 'Hide memories older than this many hours. Clicking the OLDER memories link will reveal all of them.', | |
'type': 'int', | |
'default': DEFAULT_HIDE_HOURS, | |
'min': 1, | |
'max': 168 // one week | |
} | |
}, | |
'events': | |
{ | |
'save': close_config_ui | |
}, | |
'css': '#Remembrance {background:#565;color:#bcb} a {color:#f99 !important} input {background:#787; color:white;}' | |
}); | |
resolve(); | |
}); | |
return a_promise; | |
} | |
/* | |
* Display the configuration page. | |
*/ | |
function show_config_ui() { | |
GM_config.open(); | |
} | |
/* | |
* Close the configuration page and reload memories. | |
*/ | |
function close_config_ui() { | |
GM_config.close(); | |
refresh_memories(); | |
} | |
/* | |
* | |
*/ | |
function refresh_memories() { | |
init_config() | |
.then(load_memories) | |
.then(add_memories) | |
.then(cull_memories) | |
.then(record_memories) | |
.then(display_memories) | |
.catch((error) => { console.error('Remembrance: '+error); }); | |
} | |
/* | |
* Main | |
*/ | |
refresh_memories(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment