Skip to content

Instantly share code, notes, and snippets.

@wesleywerner
Last active November 13, 2022 04:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wesleywerner/fbcb0131687c067e922678c90b0c4480 to your computer and use it in GitHub Desktop.
Save wesleywerner/fbcb0131687c067e922678c90b0c4480 to your computer and use it in GitHub Desktop.
Urban Dead character memories
// ==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 = '&#9881'; // 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