Adds configurable worship teleport, /stuck, /dig, key bindings, and /tb buttons under your inventory (FlatMMOPlus plugin)
// ==UserScript==
// @name FlatMMO Quick Actions
// @namespace com.flatmmo.quick-actions
// @version 4.0
// @description Adds configurable worship teleport, /stuck, /dig, key bindings, and /tb buttons under your inventory (FlatMMOPlus plugin)
// @author You
// @license MIT
// @match *://flatmmo.com/play.php*
// @grant none
// @require https://update.greasyfork.org/scripts/544062/FlatMMOPlus.js
// ==/UserScript==
(function () {
'use strict';
// ================= CONFIG =================
// If buttons don't work, right-click the relevant element in-game,
// choose Inspect, and update the selector below with its id/class.
const INVENTORY_SELECTOR = '#ui-panel-inventory-content';
const STORAGE_KEY = 'fmmoQuickActionsConfig';
// ============================================
// Every button the control panel can show/hide/reorder.
// type "worship" = click the real worship tile; type "tb" = send the teleport-book
// command straight over the websocket; type "panel" = switch to a game panel via
// FlatMMOPlus.setPanel(); type "keybinds" = open the key bindings modal; type "stuck"
// = send the /stuck chat command.
const MASTER_ITEMS = {
ev: { type: 'worship', label: 'Everbrook', img: 'teleport_everbrook', requiredLevel: 5, requiredPoints: 3 },
mv: { type: 'worship', label: 'Mystic Vale', img: 'teleport_mysticvale', requiredLevel: 10, requiredPoints: 3 },
om: { type: 'worship', label: 'Omboko', img: 'teleport_omboko', requiredLevel: 20, requiredPoints: 3 },
dh: { type: 'worship', label: 'Dock Haven', img: 'teleport_dock_haven', requiredLevel: 25, requiredPoints: 3 },
jo: { type: 'worship', label: 'Jafa Outpost', img: 'teleport_jafa_outpost', requiredLevel: 40, requiredPoints: 3 },
fv: { type: 'worship', label: 'Frostvale', img: 'teleport_frostvale', requiredLevel: 45, requiredPoints: 3 },
mp: { type: 'worship', label: 'Mass Pickup', img: 'mass_pickup', requiredLevel: 55, requiredPoints: 1 },
dig: { type: 'worship', label: 'Dig', img: 'dig', requiredLevel: 9, requiredPoints: 0 },
rs: { type: 'worship', label: 'Remote Sell', img: 'remote_sell', requiredLevel: 7, requiredPoints: 5 },
tm: { type: 'worship', label: 'Timers', img: 'timers', requiredLevel: 15, requiredPoints: 0 },
fc: { type: 'worship', label: 'Focus', img: 'focus', requiredLevel: 30, requiredPoints: 5 },
hb: { type: 'worship', label: 'Hell Bury', img: 'auto_hell_burying', requiredLevel: 35, requiredPoints: 0 },
cl: { type: 'worship', label: 'Clarity', img: 'clarity', requiredLevel: 60, requiredPoints: 15 },
ch: { type: 'tb', label: 'Chef\u2019s House', scroll: 'chefs_house_teleport_scroll', displayImg: 'images/items/chefs_hat.png' },
th: { type: 'tb', label: 'Thieves Hideout', scroll: 'thieves_hideout_teleport_scroll', displayImg: 'images/icons/stealing.png' },
gh: { type: 'tb', label: 'Greenhouse', scroll: 'greenhouse_teleport_scroll', displayImg: 'images/icons/farming.png' },
rg: { type: 'tb', label: 'Rogue\u2019s Grave', scroll: 'rogue_teleport_scroll', displayImg: 'images/items/sand.png' },
pm: { type: 'tb', label: 'Phantos Mansion', scroll: 'phantos_teleport_scroll', displayImg: 'images/ui/cemetery_child.png' },
stuck: { type: 'stuck', label: 'stuck' },
hunting: { type: 'panel', label: 'Hunting', panelId: 'hunting', imgSrc: 'images/icons/hunting_large.png' },
keybinds: { type: 'keybinds', label: 'Key Bindings', imgSrc: 'images/ui/settings.png' },
};
// Default layout: matches what you already had set up.
const DEFAULT_CONFIG = {
perRow: 5,
order: ['ev', 'rs', 'dig', 'mv', 'tm', 'om', 'dh', 'fc', 'hb', 'jo', 'fv', 'mp', 'cl', 'stuck', 'hunting', 'keybinds', 'ch', 'th', 'gh', 'rg', 'pm'],
enabled: {
ev: false, mv: false, om: false, dh: false, jo: false, fv: false,
mp: false, dig: false, ch: false, rg: false, gh: false, pm: false, stuck: false, hunting: false, keybinds: false,
rs: false, tm: false, fc: false, hb: false, cl: false, th: false,
},
hotkeys: {},
};
function structuredCloneConfig(cfg) {
return JSON.parse(JSON.stringify(cfg));
}
function loadConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return structuredCloneConfig(DEFAULT_CONFIG);
const parsed = JSON.parse(raw);
// Make sure any newly-added master items not yet in a saved config still show up (disabled) rather than crashing.
const merged = structuredCloneConfig(DEFAULT_CONFIG);
merged.perRow = parsed.perRow || merged.perRow;
if (Array.isArray(parsed.order)) {
merged.order = parsed.order.filter((id) => MASTER_ITEMS[id]);
Object.keys(MASTER_ITEMS).forEach((id) => {
if (!merged.order.includes(id)) merged.order.push(id);
});
}
if (parsed.enabled) {
Object.keys(merged.enabled).forEach((id) => {
if (typeof parsed.enabled[id] === 'boolean') merged.enabled[id] = parsed.enabled[id];
});
}
if (parsed.hotkeys && typeof parsed.hotkeys === 'object') {
Object.keys(parsed.hotkeys).forEach((id) => {
if (MASTER_ITEMS[id] && typeof parsed.hotkeys[id] === 'string') {
merged.hotkeys[id] = parsed.hotkeys[id];
}
});
}
return merged;
} catch (e) {
console.warn('FlatMMO+ Quick Actions: could not load saved config, using defaults.', e);
return structuredCloneConfig(DEFAULT_CONFIG);
}
}
function saveConfig(cfg) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
}
let currentConfig = loadConfig();
// ---- Worship level/points, read live from the game's own UI ----
function getWorshipLevel() {
const el = document.getElementById('ui-skill-worship-level');
return el ? parseInt(el.textContent, 10) || 0 : 0;
}
function getWorshipPoints() {
const el = document.querySelector('display-backend-value[data-id="warship-points"]');
return el ? parseInt(el.textContent, 10) || 0 : 0;
}
function applyWorshipButtonState(btn, entry) {
const points = getWorshipPoints();
const meetsPoints = points >= (entry.requiredPoints || 0);
if (meetsPoints) {
btn.style.opacity = '1';
btn.style.filter = 'none';
btn.disabled = false;
btn.title = entry.label;
} else {
btn.style.opacity = '0.4';
btn.style.filter = 'grayscale(70%)';
btn.disabled = true;
btn.title = `${entry.label} \u2014 needs ${entry.requiredPoints} worship points (you have ${points})`;
}
}
function refreshWorshipButtonStates() {
document.querySelectorAll('#fmmo-quick-actions [data-worship-id]').forEach((btn) => {
const entry = MASTER_ITEMS[btn.dataset.worshipId];
if (entry) applyWorshipButtonState(btn, entry);
});
}
let worshipPointsObserver = null;
function watchWorshipPoints() {
const el = document.querySelector('display-backend-value[data-id="warship-points"]');
if (!el) {
// Not rendered yet (e.g. not logged in) - try again shortly.
setTimeout(watchWorshipPoints, 500);
return;
}
if (worshipPointsObserver) return; // already watching
worshipPointsObserver = new MutationObserver(refreshWorshipButtonStates);
worshipPointsObserver.observe(el, { childList: true, characterData: true, subtree: true });
}
// ---- Action handlers ----
// These use the real FlatMMOPlus.sendMessage() / setPanel() APIs instead of
// simulating clicks/keystrokes wherever the wire protocol is known.
function clickWorshipTile(imgFragment) {
const findTile = () => document.querySelector(`#ui-panel-worship-content img[src*="${imgFragment}"]`);
let img = findTile();
if (img) {
(img.closest('.worship-entry') || img).click();
return;
}
// Tile isn't in the DOM yet (worship tab hasn't been opened this session).
// Switch to it via the real FlatMMOPlus API, click the tile, then switch back.
const previousPanel = window.FlatMMOPlus.currentPanel;
window.FlatMMOPlus.setPanel('worship');
setTimeout(() => {
img = findTile();
if (img) {
(img.closest('.worship-entry') || img).click();
} else {
alert('FlatMMO+ Quick Actions: could not find that worship teleport tile even after opening the Worship tab.');
}
if (previousPanel) window.FlatMMOPlus.setPanel(previousPanel);
}, 150);
}
function teleBookTeleport(scroll) {
window.FlatMMOPlus.sendMessage(`TELE_BOOK=${scroll}`);
}
function openKeyBindings() {
// These are called directly since they were plain top-level functions.
if (typeof window.close_modal === 'function') {
try {
window.close_modal('settings-modal');
} catch (e) {
// ignore - settings modal may not have been open anyway
}
}
if (typeof window.open_key_bindings_modal === 'function') {
window.open_key_bindings_modal();
} else {
alert(
'FlatMMO+ Quick Actions: could not find open_key_bindings_modal(). The game may have changed its scripts.'
);
}
}
function describeKeyEvent(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
parts.push(e.code);
return parts.join('+');
}
function prettifyCombo(combo) {
if (!combo) return '';
return combo
.split('+')
.map((part) => {
if (part.startsWith('Key')) return part.slice(3);
if (part.startsWith('Digit')) return part.slice(5);
return part;
})
.join('+');
}
function isTypingTarget(target) {
if (!target) return false;
const tag = target.tagName ? target.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea' || target.isContentEditable;
}
let hotkeyRecordingActive = false;
// Global hotkeys work even if the matching button isn't currently shown in the panel.
document.addEventListener(
'keydown',
(e) => {
if (hotkeyRecordingActive) return; // a hotkey is being recorded in the settings menu right now
if (document.getElementById('fmmo-quick-actions-settings')) return; // don't fire while configuring
if (isTypingTarget(e.target)) return; // don't fire while typing in chat, etc.
if (!currentConfig.hotkeys) return;
const combo = describeKeyEvent(e);
const match = Object.keys(currentConfig.hotkeys).find(
(id) => currentConfig.hotkeys[id] === combo
);
if (match) {
e.preventDefault();
e.stopPropagation();
executeItem(match);
}
},
true
);
function executeItem(id) {
const entry = MASTER_ITEMS[id];
if (!entry) return;
if (entry.type === 'worship') {
const points = getWorshipPoints();
if (points < (entry.requiredPoints || 0)) {
alert(
`${entry.label} needs ${entry.requiredPoints} worship points, you currently have ${points}.`
);
return;
}
return clickWorshipTile(entry.img);
}
if (entry.type === 'tb') return teleBookTeleport(entry.scroll);
if (entry.type === 'panel') return window.FlatMMOPlus.setPanel(entry.panelId);
if (entry.type === 'keybinds') return openKeyBindings();
if (entry.type === 'stuck') return window.FlatMMOPlus.sendMessage('CHAT=/stuck');
}
// ---- UI building ----
function getIconSrc(entry) {
if (entry.type === 'worship') return `images/worship/${entry.img}.png`;
if (entry.type === 'tb') return entry.displayImg || `images/items/${entry.scroll}.png`;
if (entry.type === 'panel' || entry.type === 'keybinds') return entry.imgSrc;
return null; // 'stuck' has no icon
}
function makeButton(label, onClick) {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = `
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
border: 1px solid rgb(205, 205, 205);
background-color: rgb(225, 225, 225);
color: #222;
`;
btn.addEventListener('click', onClick);
return btn;
}
function makeImageButton(label, imgSrc, onClick) {
const btn = document.createElement('button');
btn.title = label;
btn.style.cssText = `
padding: 4px;
width: 56px;
height: 56px;
cursor: pointer;
border-radius: 4px;
border: 1px solid rgb(205, 205, 205);
background-color: rgb(225, 225, 225);
display: flex;
align-items: center;
justify-content: center;
`;
const img = document.createElement('img');
img.src = imgSrc;
img.alt = label;
img.style.cssText = 'width: 100%; height: 100%; object-fit: contain;';
btn.appendChild(img);
btn.addEventListener('click', onClick);
return btn;
}
function buildButtonFor(id) {
const entry = MASTER_ITEMS[id];
const iconSrc = getIconSrc(entry);
if (!iconSrc) {
// 'stuck'
return makeButton(entry.label, () => executeItem(id));
}
const btn = makeImageButton(entry.label, iconSrc, () => executeItem(id));
if (entry.type === 'worship') {
btn.dataset.worshipId = id;
applyWorshipButtonState(btn, entry);
}
return btn;
}
function chunk(arr, size) {
const rows = [];
for (let i = 0; i < arr.length; i += size) {
rows.push(arr.slice(i, i + size));
}
return rows;
}
function buildPanel() {
const panel = document.createElement('div');
panel.id = 'fmmo-quick-actions';
panel.style.cssText = `
margin-top: 8px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
border-radius: 6px;
`;
const enabledIds = currentConfig.order.filter((id) => currentConfig.enabled[id]);
const rows = chunk(enabledIds, currentConfig.perRow);
rows.forEach((row) => {
const rowDiv = document.createElement('div');
rowDiv.style.cssText = 'display: flex; gap: 6px;';
row.forEach((id) => {
rowDiv.appendChild(buildButtonFor(id));
});
panel.appendChild(rowDiv);
});
return panel;
}
function refreshPanel() {
const old = document.getElementById('fmmo-quick-actions');
if (!old) return;
const fresh = buildPanel();
old.replaceWith(fresh);
}
function openSettingsPanel() {
if (document.getElementById('fmmo-quick-actions-settings')) return; // already open
const overlay = document.createElement('div');
overlay.id = 'fmmo-quick-actions-settings';
overlay.style.cssText = `
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
`;
const box = document.createElement('div');
box.style.cssText = `
background: rgb(240, 230, 210);
border: 2px solid rgb(180, 160, 130);
border-radius: 8px;
padding: 16px;
width: 460px;
max-height: 80vh;
overflow-y: auto;
font-family: sans-serif;
color: #222;
`;
const title = document.createElement('div');
title.textContent = 'Quick Actions Settings';
title.style.cssText = 'font-weight: bold; font-size: 14px; margin-bottom: 8px;';
box.appendChild(title);
const perRowRow = document.createElement('div');
perRowRow.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 10px; font-size: 13px;';
const perRowLabel = document.createElement('label');
perRowLabel.textContent = 'Buttons per row:';
const perRowInput = document.createElement('input');
perRowInput.type = 'number';
perRowInput.min = '1';
perRowInput.max = '10';
perRowInput.value = currentConfig.perRow;
perRowInput.style.cssText = 'width: 50px;';
perRowRow.appendChild(perRowLabel);
perRowRow.appendChild(perRowInput);
box.appendChild(perRowRow);
// Working copy of order so Cancel doesn't mutate saved config
let workingOrder = currentConfig.order.slice();
const workingEnabled = Object.assign({}, currentConfig.enabled);
const workingHotkeys = Object.assign({}, currentConfig.hotkeys);
const list = document.createElement('div');
box.appendChild(list);
function renderList() {
list.innerHTML = '';
workingOrder.forEach((id, index) => {
const item = MASTER_ITEMS[id];
const row = document.createElement('div');
row.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid rgb(210, 195, 170);
font-size: 13px;
`;
const locked = item.type === 'worship' && getWorshipLevel() < (item.requiredLevel || 0);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
if (locked) {
workingEnabled[id] = false; // can't be enabled until the level requirement is met
}
checkbox.checked = !!workingEnabled[id];
checkbox.disabled = locked;
checkbox.addEventListener('change', () => {
workingEnabled[id] = checkbox.checked;
});
const label = document.createElement('span');
label.textContent = locked
? `${item.label} (requires level ${item.requiredLevel})`
: item.label;
label.style.cssText = locked ? 'flex: 1; opacity: 0.5;' : 'flex: 1;';
const iconSrc = getIconSrc(item);
let iconEl;
if (iconSrc) {
iconEl = document.createElement('img');
iconEl.src = iconSrc;
iconEl.alt = '';
iconEl.style.cssText = 'width: 20px; height: 20px; object-fit: contain;';
} else {
iconEl = document.createElement('span');
iconEl.style.cssText = 'display: inline-block; width: 20px;';
}
const upBtn = document.createElement('button');
upBtn.textContent = '\u2191';
upBtn.disabled = index === 0;
upBtn.style.cssText = 'cursor: pointer;';
upBtn.addEventListener('click', () => {
[workingOrder[index - 1], workingOrder[index]] = [workingOrder[index], workingOrder[index - 1]];
renderList();
});
const downBtn = document.createElement('button');
downBtn.textContent = '\u2193';
downBtn.disabled = index === workingOrder.length - 1;
downBtn.style.cssText = 'cursor: pointer;';
downBtn.addEventListener('click', () => {
[workingOrder[index + 1], workingOrder[index]] = [workingOrder[index], workingOrder[index + 1]];
renderList();
});
const hotkeyBtn = document.createElement('button');
hotkeyBtn.style.cssText = 'cursor: pointer; font-size: 11px; min-width: 78px;';
function updateHotkeyLabel() {
const combo = workingHotkeys[id];
hotkeyBtn.textContent = combo ? prettifyCombo(combo) : 'Set key';
}
updateHotkeyLabel();
hotkeyBtn.addEventListener('click', () => {
hotkeyBtn.textContent = 'Press a key\u2026';
hotkeyRecordingActive = true;
const captureListener = (ev) => {
ev.preventDefault();
ev.stopPropagation();
// Ignore a standalone modifier press; wait for the real key.
if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return;
if (ev.key === 'Escape') {
document.removeEventListener('keydown', captureListener, true);
hotkeyRecordingActive = false;
updateHotkeyLabel();
return;
}
workingHotkeys[id] = describeKeyEvent(ev);
document.removeEventListener('keydown', captureListener, true);
hotkeyRecordingActive = false;
updateHotkeyLabel();
};
document.addEventListener('keydown', captureListener, true);
});
const clearHotkeyBtn = document.createElement('button');
clearHotkeyBtn.textContent = '\u2715';
clearHotkeyBtn.title = 'Clear hotkey';
clearHotkeyBtn.style.cssText = 'cursor: pointer; font-size: 11px;';
clearHotkeyBtn.addEventListener('click', () => {
delete workingHotkeys[id];
updateHotkeyLabel();
});
row.appendChild(checkbox);
row.appendChild(iconEl);
row.appendChild(label);
row.appendChild(upBtn);
row.appendChild(downBtn);
row.appendChild(hotkeyBtn);
row.appendChild(clearHotkeyBtn);
list.appendChild(row);
});
}
renderList();
const buttonRow = document.createElement('div');
buttonRow.style.cssText = 'display: flex; gap: 8px; margin-top: 12px; justify-content: flex-end;';
const resetBtn = document.createElement('button');
resetBtn.textContent = 'Reset to defaults';
resetBtn.style.cssText = 'margin-right: auto; cursor: pointer;';
resetBtn.addEventListener('click', () => {
const def = structuredCloneConfig(DEFAULT_CONFIG);
workingOrder = def.order.slice();
Object.keys(workingEnabled).forEach((k) => delete workingEnabled[k]);
Object.assign(workingEnabled, def.enabled);
Object.keys(workingHotkeys).forEach((k) => delete workingHotkeys[k]);
Object.assign(workingHotkeys, def.hotkeys);
perRowInput.value = def.perRow;
renderList();
});
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'cursor: pointer;';
cancelBtn.addEventListener('click', closeOverlay);
function doSave() {
currentConfig = {
perRow: Math.max(1, parseInt(perRowInput.value, 10) || DEFAULT_CONFIG.perRow),
order: workingOrder,
enabled: workingEnabled,
hotkeys: workingHotkeys,
};
saveConfig(currentConfig);
closeOverlay();
refreshPanel();
}
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.style.cssText = 'cursor: pointer; font-weight: bold;';
saveBtn.addEventListener('click', doSave);
buttonRow.appendChild(resetBtn);
buttonRow.appendChild(cancelBtn);
buttonRow.appendChild(saveBtn);
box.appendChild(buttonRow);
function onKeyDown(e) {
if (hotkeyRecordingActive) return; // let the row's own key-capture listener handle this instead
if (e.key === 'Escape') {
e.stopPropagation();
doSave();
}
}
function closeOverlay() {
hotkeyRecordingActive = false;
document.removeEventListener('keydown', onKeyDown, true);
overlay.remove();
}
overlay.appendChild(box);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeOverlay();
});
document.addEventListener('keydown', onKeyDown, true);
document.body.appendChild(overlay);
}
function attachGearButton() {
if (document.getElementById('fmmo-gear-btn')) return; // already attached
const titleBar = document.querySelector('#ui-panel-inventory .ui-panel-title');
if (!titleBar) return;
const gearBtn = document.createElement('span');
gearBtn.id = 'fmmo-gear-btn';
gearBtn.className = 'hover';
gearBtn.textContent = '\u2699\uFE0F';
gearBtn.title = 'Configure Quick Actions buttons';
gearBtn.style.cssText =
'float: right; cursor: pointer; font-size: 12px; vertical-align: middle; margin-right: 6px;';
gearBtn.addEventListener('click', openSettingsPanel);
// Appended after the bank icon in the DOM, so with float:right it lands
// immediately to its left.
titleBar.appendChild(gearBtn);
}
function attachPanel() {
if (document.getElementById('fmmo-quick-actions')) return; // already attached
const inventory = document.querySelector(INVENTORY_SELECTOR);
const panel = buildPanel();
if (inventory) {
inventory.insertAdjacentElement('afterend', panel);
} else {
panel.style.position = 'fixed';
panel.style.bottom = '10px';
panel.style.right = '10px';
panel.style.zIndex = 9999;
document.body.appendChild(panel);
console.warn(
'FlatMMO+ Quick Actions: inventory panel not found, using floating fallback. ' +
'Edit INVENTORY_SELECTOR at the top of the script.'
);
}
}
function initUI() {
// The game UI may still be a tick behind onLogin, so retry briefly.
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (document.querySelector(INVENTORY_SELECTOR) || attempts > 20) {
clearInterval(interval);
attachPanel();
attachGearButton();
}
}, 500);
// React instantly to worship point changes instead of polling on a timer
watchWorshipPoints();
}
// ---- FlatMMOPlus plugin registration ----
class QuickActionsPlugin extends FlatMMOPlusPlugin {
constructor() {
super('quickActions', {
about: {
name: GM_info.script.name,
version: GM_info.script.version,
author: GM_info.script.author,
description: GM_info.script.description,
},
});
}
// Called by FlatMMOPlus once login is detected (or immediately on
// registration if already logged in) - the real hook for "the game UI
// is ready", replacing the old blind polling-from-page-load approach.
onLogin() {
initUI();
}
}
const plugin = new QuickActionsPlugin();
FlatMMOPlus.registerPlugin(plugin);
})();