A mod loader for FlatMMO: mod base (panels, dock, manager) plus fetch-and-run mods from GitHub sources (each a repo with a mods/index.json). Install once; add sources and toggle mods in the manager.
// ==UserScript==
// @name Flat Mod Loader
// @namespace flatmmo
// @version 1.3.0
// @description A mod loader for FlatMMO: mod base (panels, dock, manager) plus fetch-and-run mods from GitHub sources (each a repo with a mods/index.json). Install once; add sources and toggle mods in the manager.
// @author Frappe
// @match *://flatmmo.com/play.php*
// @grant none
// @require https://update.greasyfork.org/scripts/544062/FlatMMOPlus.js
// @license MIT
// ==/UserScript==
/*
* Flat Mod Loader (FML). Runs on top of FlatMMOPlus (page context, @grant none).
* Exposes window.FML: a Mod base, a draggable/dockable Panel, a keyed List, game
* helpers, a manager UI, and a mod loader that fetches mods from GitHub "sources" (a repo
* with a mods/index.json) and injects the enabled ones. Mods are plain JS using the FML
* API - no userscript header or @require needed.
*/
(function () {
"use strict";
const VERSION = "1.3.0";
if (window.FML && window.FML.version >= VERSION) return;
const DOCK_W = 280; // px width of the right-side dock sidebar
// The bank / global-market overlay (`.storage` in the game's own CSS) is sized with
// min-width/min-height instead of a fixed box, so a wide postings grid or long item
// list makes it grow past the canvas's actual 1536x896 footprint. Our dock and chat
// bar are anchored to the canvas's rect, so once the overlay outgrows the canvas it
// spills out from under them instead of the other way around. Cap it to the canvas's
// box and let it scroll internally so it can never extend past where we've docked.
if (!document.getElementById("fml-game-layout-fix")) {
const gameFixStyle = document.createElement("style");
gameFixStyle.id = "fml-game-layout-fix";
gameFixStyle.textContent = `.storage { max-width: 1536px !important; max-height: 896px !important; overflow: auto !important; }`;
document.head.appendChild(gameFixStyle);
}
// ---- small utils ----------------------------------------------------------
const util = {
fmt(n) {
n = Math.floor(Number(n) || 0);
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "b";
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "m";
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
},
pretty(name) {
if (typeof format_snake_case === "function") return format_snake_case(name);
return String(name || "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
},
clampPct(v, d) {
return Math.max(0, Math.min(100, Number(v == null ? d : v))) / 100;
},
};
// Thin, direct accessors for the game globals our mods keep re-reading.
const game = {
npcs() { return typeof npcs !== "undefined" ? npcs : window.npcs; },
groundItems() { return typeof ground_items !== "undefined" ? ground_items : window.ground_items; },
getVar(key) { return window.get_var(key); },
level(xp) { return window.get_level(xp); },
xpForLevel(level) { return window.get_xp_required(level); },
localName() { return typeof Globals !== "undefined" ? Globals.local_username : undefined; },
attackNpc(uuid) { window.send_unrepeatable_bytes_1s("CLICKS_NPC=" + uuid); },
clickGroundItem(uuid) { Globals.websocket.send("CLICKED_GROUND_ITEM=" + uuid); },
switchPanel(id) { window.switch_panels(id); },
};
// ---- keyed list (row reuse + reorder) -------------------------------------
class FMLList {
constructor(container) {
this.container = container;
this.rows = {};
this._order = "";
}
row(key, create) {
let r = this.rows[key];
if (!r || !r.isConnected) { r = this.rows[key] = create(); }
return r;
}
has(key) { return !!this.rows[key]; }
keys() { return Object.keys(this.rows); }
setOrder(keys) {
const k = keys.join(",");
if (k === this._order) return;
for (const key of keys) if (this.rows[key]) this.container.appendChild(this.rows[key]);
this._order = k;
}
remove(key) {
if (this.rows[key]) { this.rows[key].remove(); delete this.rows[key]; }
this._order = "";
}
clear() {
for (const k in this.rows) this.rows[k].remove();
this.rows = {};
this._order = "";
}
}
// ---- draggable / collapsible styled panel ---------------------------------
function ensurePanelStyle() {
if (document.getElementById("fml-panel-style")) return;
const style = document.createElement("style");
style.id = "fml-panel-style";
style.textContent = `
.fml-panel {
position: fixed; z-index: 60;
display: flex; flex-direction: column;
width: var(--fml-w, 220px); max-height: var(--fml-h, 340px);
border-radius: 10px; background: var(--fml-bg, #0e1116);
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
font-family: sans-serif; color: #fff; overflow: hidden; user-select: none;
}
.fml-header {
flex: 0 0 auto; display: flex; align-items: center; gap: 6px;
padding: 4px 6px 4px 8px; background: rgba(255,255,255,0.06);
border-bottom: 1px solid rgba(255,255,255,0.10); cursor: grab; touch-action: none;
}
.fml-header:active { cursor: grabbing; }
.fml-titlewrap { display: flex; align-items: center; gap: 6px; flex: 0 1 auto; min-width: 0; cursor: pointer; }
.fml-chevron { font-size: 9px; color: #aeb8c4; transition: transform .15s; }
.fml-panel.fml-collapsed .fml-chevron { transform: rotate(-90deg); }
.fml-panel.fml-collapsed .fml-body { display: none; }
.fml-title {
font-size: 12px; font-weight: bold; letter-spacing: .04em; text-transform: uppercase;
color: #cdd6e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.fml-actions { display: flex; align-items: center; gap: 5px; flex-shrink: 0; margin-left: auto; }
.fml-btn {
height: 22px; padding: 0 8px; border-radius: 6px; cursor: pointer;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16);
font-size: 11px; font-weight: bold; color: #dfe6ee;
display: flex; align-items: center; gap: 4px; transition: background .12s, filter .12s;
}
.fml-btn:hover { background: rgba(255,255,255,0.18); filter: brightness(1.1); }
.fml-btn img { width: 16px; height: 16px; object-fit: contain; image-rendering: pixelated; }
.fml-close-btn { padding: 0 7px; font-size: 15px; line-height: 1; }
.fml-close-btn:hover { background: rgba(224,64,58,0.5); filter: none; }
.fml-menu-btn { padding: 0 7px; font-size: 14px; line-height: 1; }
.fml-menu {
position: fixed; z-index: 10000; min-width: 168px; padding: 4px; display: none; flex-direction: column; gap: 1px;
background: #12161c; border: 1px solid rgba(255,255,255,0.16); border-radius: 8px;
box-shadow: 0 6px 22px rgba(0,0,0,0.6); font-family: sans-serif;
}
.fml-menu.fml-open { display: flex; }
.fml-menu-item { padding: 6px 10px; border-radius: 6px; font-size: 12px; color: #dfe6ee; cursor: pointer; white-space: nowrap; }
.fml-menu-item:hover { background: rgba(255,255,255,0.12); }
.fml-menu-sep { height: 1px; margin: 3px 4px; background: rgba(255,255,255,0.10); }
.fml-body { flex: 1 1 auto; overflow-y: auto; padding: 5px; display: flex; flex-direction: column; gap: 4px; }
.fml-panel.fml-collapsed { height: auto !important; }
.fml-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; z-index: 2; }
.fml-resize::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border-right: 2px solid rgba(255,255,255,0.4); border-bottom: 2px solid rgba(255,255,255,0.4); }
.fml-panel.fml-collapsed .fml-resize { display: none; }
#fml-dock {
position: fixed; z-index: 59; display: none; flex-direction: column; gap: 6px;
width: var(--fml-dock-w, ${DOCK_W}px); box-sizing: border-box; padding: 6px;
overflow-y: auto; overflow-x: hidden;
background: rgba(10,13,18,0.92); border: 1px solid rgba(255,255,255,0.14);
border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.5);
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.25) transparent;
}
#fml-dock.fml-dock-show { display: flex; }
#fml-dock::-webkit-scrollbar { width: 8px; }
#fml-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 8px; }
#fml-dock.fml-dock-hint { border-color: rgba(120,170,255,0.9); box-shadow: 0 0 0 2px rgba(120,170,255,0.35), 0 2px 12px rgba(0,0,0,0.5); }
.fml-dock-empty { margin: auto; padding: 12px 10px; text-align: center; font: 12px/1.4 sans-serif; color: #9fb0c0; border: 2px dashed rgba(255,255,255,0.18); border-radius: 8px; }
.fml-dock-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; z-index: 2; }
.fml-dock-resize::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border-right: 2px solid rgba(255,255,255,0.4); border-bottom: 2px solid rgba(255,255,255,0.4); }
.fml-panel.fml-docked { position: relative; left: auto; top: auto; width: 100%; box-shadow: none; flex: 0 0 auto; }
.fml-panel.fml-docked .fml-header { cursor: grab; }
.fml-panel.fml-docked .fml-resize { display: none; }
`;
document.head.appendChild(style);
}
class FMLPanel {
constructor(mod, opts = {}) {
this.mod = mod;
this.collapsible = opts.collapsible !== false;
this.draggable = opts.draggable !== false;
this.collapsed = !!opts.collapsed;
this.resizable = opts.resizable !== false;
this.closable = opts.closable !== false;
this.dockable = opts.dockable !== false;
this._width = opts.width || null;
this._maxHeight = opts.maxHeight || null;
this.docked = this.dockable && FML._loadDocked(mod.id);
this._pos = FML._loadPos(mod.id);
this._size = this.resizable ? FML._loadSize(mod.id) : null;
mod._panel = this;
ensurePanelStyle();
this._build(opts.title || (mod.opts.about && mod.opts.about.name) || mod.id);
}
_build(title) {
const el = document.createElement("div");
el.className = "fml-panel";
el.id = "fml-" + this.mod.id;
const header = document.createElement("div");
header.className = "fml-header";
const tw = document.createElement("div");
tw.className = "fml-titlewrap";
const chev = document.createElement("span");
chev.className = "fml-chevron";
chev.textContent = "\u25BC";
const t = document.createElement("span");
t.className = "fml-title";
t.textContent = title;
if (this.collapsible) tw.appendChild(chev);
tw.appendChild(t);
// The title area (chevron + name) is the collapse toggle - a plain click
// target, deliberately excluded from the drag handler so it shows the click
// cursor rather than the grab cursor.
tw.onclick = () => { if (this.collapsible) this.toggleCollapse(); };
const actions = document.createElement("div");
actions.className = "fml-actions";
header.appendChild(tw);
header.appendChild(actions);
// Base-owned options menu (hamburger): built-in reset + client addMenuItem().
const menuBtn = document.createElement("button");
menuBtn.className = "fml-btn fml-menu-btn";
menuBtn.textContent = "\u2630";
menuBtn.title = "Options";
menuBtn.onclick = (e) => { e.stopPropagation(); this._toggleMenu(); };
actions.appendChild(menuBtn);
this.menuBtn = menuBtn;
this.menu = document.createElement("div");
this.menu.className = "fml-menu";
document.body.appendChild(this.menu);
this._menuItems = [];
if (this.closable) {
const close = document.createElement("button");
close.className = "fml-btn fml-close-btn";
close.title = "Close (re-enable from the FML manager)";
close.textContent = "\u00D7";
close.onclick = (e) => { e.stopPropagation(); this.close(); };
actions.appendChild(close);
this._closeBtn = close;
}
const body = document.createElement("div");
body.className = "fml-body";
el.appendChild(header);
el.appendChild(body);
this.el = el;
this.header = header;
this.titleEl = t;
this.actions = actions;
this.body = body;
if (this.collapsed) el.classList.add("fml-collapsed");
if (this.docked) el.classList.add("fml-docked");
if (this.resizable) {
el.classList.add("fml-resizable");
const handle = document.createElement("div");
handle.className = "fml-resize";
handle.title = "Drag to resize";
el.appendChild(handle);
this.handle = handle;
}
this._wireHeader();
if (this.resizable) this._wireResize();
this._rebuildMenu();
}
addHeaderButton({ text, img, title, onClick }) {
const b = document.createElement("button");
b.className = "fml-btn";
if (img) { const i = document.createElement("img"); i.src = img; i.alt = title || ""; b.appendChild(i); }
if (text) b.appendChild(document.createTextNode(text));
if (title) b.title = title;
b.onclick = (e) => { e.stopPropagation(); onClick(e); };
if (this._closeBtn) this.actions.insertBefore(b, this._closeBtn);
else this.actions.appendChild(b);
return b;
}
// Add an entry to the panel's hamburger options menu. { label, onClick }.
addMenuItem({ label, onClick }) {
const item = { label, onClick };
this._menuItems.push(item);
this._rebuildMenu();
return item;
}
_rebuildMenu() {
const menu = this.menu;
menu.innerHTML = "";
const add = (label, fn) => {
const mi = document.createElement("div");
mi.className = "fml-menu-item";
mi.textContent = label;
mi.onclick = (e) => { e.stopPropagation(); this._closeMenu(); fn(e); };
menu.appendChild(mi);
};
for (const it of this._menuItems) add(it.label, it.onClick);
// No menu items -> hide the hamburger button entirely.
if (this.menuBtn) this.menuBtn.style.display = this._menuItems.length ? "" : "none";
}
_toggleMenu() {
if (this.menu.classList.contains("fml-open")) this._closeMenu();
else this._openMenu();
}
_openMenu() {
const menu = this.menu;
menu.classList.add("fml-open");
const r = this.menuBtn.getBoundingClientRect();
const mw = menu.offsetWidth || 168, mh = menu.offsetHeight || 0;
const left = Math.max(6, Math.min(r.right - mw, window.innerWidth - mw - 6));
let top = r.bottom + 4;
if (top + mh > window.innerHeight - 6) top = Math.max(6, r.top - mh - 4);
menu.style.left = left + "px";
menu.style.top = top + "px";
this._onDocDown = (e) => { if (e.target !== this.menuBtn && !menu.contains(e.target)) this._closeMenu(); };
setTimeout(() => document.addEventListener("pointerdown", this._onDocDown), 0);
}
_closeMenu() {
if (this.menu) this.menu.classList.remove("fml-open");
if (this._onDocDown) { document.removeEventListener("pointerdown", this._onDocDown); this._onDocDown = null; }
}
setTitle(text) { this.titleEl.textContent = text; }
setCollapsed(v) { this.collapsed = v; this.el.classList.toggle("fml-collapsed", v); }
toggleCollapse() { this.setCollapsed(!this.collapsed); }
close() { FML.setConfig(this.mod, "enabled", false); }
mount() {
if (!this._resizeBound) {
this._resizeBound = () => this.layout();
window.addEventListener("resize", this._resizeBound);
}
if (this.docked) {
FML.dock.add(this);
} else if (!this.el.isConnected) {
document.body.appendChild(this.el);
}
this.applySettings(this.mod.settings);
}
unmount() {
if (this._resizeBound) { window.removeEventListener("resize", this._resizeBound); this._resizeBound = null; }
this._closeMenu();
if (this.menu) this.menu.remove();
if (this.docked) FML.dock.remove(this, false); // keep docked state so re-enable restores it
this.el.remove();
}
applySettings() {
if (this._width) this.el.style.setProperty("--fml-w", this._width + "px");
if (this._maxHeight) this.el.style.setProperty("--fml-h", this._maxHeight + "px");
// Panel background + opacity are GLOBAL (managed in the loader's "Global
// settings"), not per-mod. Docked panels sit in the dock's own panel, so they
// stay fully opaque and only take the shared background colour.
const g = FML.globalSettings();
this.el.style.setProperty("--fml-bg", g.panelBg);
this.el.style.opacity = this.docked ? "1" : String(util.clampPct(g.panelOpacity, 92));
// A user-dragged size overrides the width/max-height derived from settings.
if (!this.docked && this.resizable && this._size) this._applySize(this._size.w, this._size.h);
this.layout();
}
resetPosition() {
this._pos = null;
FML._savePos(this.mod.id, null);
if (this.resizable) {
this._size = null;
FML._saveSize(this.mod.id, null);
this.el.style.width = "";
this.el.style.height = "";
this.el.style.maxHeight = "";
}
this.layout();
}
layout() {
if (this.docked) return; // the dock positions docked panels in its stack
const el = this.el;
const canvas = document.getElementById("canvas");
const rect = canvas
? canvas.getBoundingClientRect()
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
let left, top;
if (this._pos) {
left = this._pos.left;
top = this._pos.top;
} else {
// First load: top-left of the play area. Drag anywhere; the spot is saved.
left = rect.left + 8;
top = rect.top + 8;
}
// Keep a grabbable sliver on screen no matter what.
left = Math.max(0, Math.min(left, window.innerWidth - 40));
top = Math.max(0, Math.min(top, window.innerHeight - 20));
el.style.left = left + "px";
el.style.top = top + "px";
}
_wireHeader() {
this.header.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (e.target.closest("button")) return; // header action buttons
if (e.target.closest(".fml-titlewrap")) return; // title = collapse toggle, not a drag handle
this._startDrag(e);
});
}
// One header drag handles everything: move a floating panel, and dock / undock /
// reorder by dragging over (or out of) the side dock - no dock button needed.
_startDrag(e) {
const rect = this.el.getBoundingClientRect();
const offX = e.clientX - rect.left, offY = e.clientY - rect.top;
const startX = e.clientX, startY = e.clientY;
let moved = false;
if (this.dockable) FML.dock.beginDropHint();
const move = (ev) => {
if (!moved && Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 4) return;
moved = true;
const overDock = this.dockable && FML.dock.pointInDock(ev.clientX, ev.clientY);
if (overDock) {
if (!this.docked) this.setDocked(true);
FML.dock.dragTo(this, ev.clientY);
FML.dock.highlight(true);
} else {
if (this.docked) { this._pos = { left: ev.clientX - offX, top: ev.clientY - offY }; this.setDocked(false); }
FML.dock.highlight(false);
if (this.draggable) { this._pos = { left: ev.clientX - offX, top: ev.clientY - offY }; this.layout(); }
}
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
if (this.dockable) FML.dock.endDropHint();
if (!moved) return;
if (this.docked) FML.dock.persistOrder();
else if (this.draggable) FML._savePos(this.mod.id, this._pos);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
e.preventDefault();
}
_applySize(w, h) {
this._size = { w, h };
this.el.style.width = w + "px";
this.el.style.height = h + "px";
this.el.style.maxHeight = "none"; // let the drag exceed the settings cap
}
_wireResize() {
this.handle.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX, startY = e.clientY;
const rect = this.el.getBoundingClientRect();
const startW = rect.width, startH = rect.height;
const move = (ev) => {
const w = Math.max(140, Math.min(startW + (ev.clientX - startX), window.innerWidth - 20));
const h = Math.max(80, Math.min(startH + (ev.clientY - startY), window.innerHeight - 20));
this._applySize(w, h);
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
FML._saveSize(this.mod.id, this._size);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
});
}
// ---- docking ----
toggleDock() { this.setDocked(!this.docked); }
setDocked(v) {
if (v === this.docked) return;
this.docked = v;
FML._saveDocked(this.mod.id, v);
this.el.classList.toggle("fml-docked", v);
if (v) {
// Drop floating inline styles so the docked CSS (relative, 100% width) applies.
this.el.style.left = "";
this.el.style.top = "";
this.el.style.width = "";
this.el.style.height = "";
this.el.style.maxHeight = "";
FML.dock.add(this);
this.applySettings(); // docked -> fully opaque
} else {
FML.dock.remove(this, true);
this.el.style.height = "";
this.el.style.maxHeight = "";
if (!this.el.isConnected) document.body.appendChild(this.el);
this.applySettings();
}
}
}
// ---- mod base --------------------------------------------------------------
class FMLMod extends FlatMMOPlusPlugin {
constructor(id, opts) {
opts = opts || {};
opts.config = opts.config || [];
if (!opts.config.some((c) => c.id === "enabled")) {
opts.config.unshift({ id: "enabled", type: "checkbox", label: "Enabled", default: true });
}
const meta = FML._modMeta(id);
opts.about = { name: meta.name, author: meta.author, description: meta.description };
super(id, opts);
this._timers = [];
this._active = false;
this._ready = false;
this._settings = {};
FML._register(this);
// Register synchronously so FlatMMOPlus loads the mod's config right away - the
// loader reads it via setConfig() immediately after injecting the mod. (The
// "onStart runs before the subclass constructor finishes" hazard is handled by
// deferring _apply() in onLogin(), not by delaying registration.)
FML._registerWithFlatMMOPlus(this);
}
get settings() { return this._settings; }
// Interval that is auto-cleared when the mod stops/disables.
every(ms, fn) { const h = setInterval(fn, ms); this._timers.push(h); return h; }
_clearTimers() { this._timers.forEach(clearInterval); this._timers = []; }
// FlatMMOPlus lifecycle -> our simpler onStart/onStop/onSettings hooks.
// Defer _apply() to a microtask: FlatMMOPlus calls onLogin() synchronously from
// registerPlugin() (inside super()), i.e. BEFORE the mod subclass constructor body
// has run. The microtask lets `new Mod()` fully return (fields set) before onStart().
onLogin() { this._ready = true; queueMicrotask(() => this._apply()); }
onConfigsChanged() { if (this._ready) this._apply(); }
_apply() {
const s = {};
for (const c of this.opts.config) s[c.id] = this.getConfig(c.id);
this._settings = s;
if (s.enabled === false) {
if (this._active) { this._active = false; this._clearTimers(); if (this.onStop) this.onStop(); }
return;
}
if (!this._active) { this._active = true; if (this.onStart) this.onStart(); }
if (this.onSettings) this.onSettings(s);
}
}
// ---- shared config preset for panel mods ----------------------------------
// Deprecated: panel background + opacity are now GLOBAL (see the manager's "Global
// settings"). Kept as a no-op so any mod that still spreads it keeps loading.
function panelConfig() { return []; }
// ---- global panel settings (shared background + opacity) ------------------
const GLOBAL_DEFAULTS = { panelBg: "#0e1116", panelOpacity: 92, dockWidth: DOCK_W };
function globalSettings() {
const bg = localStorage.getItem("fml.global.panelBg");
const op = localStorage.getItem("fml.global.panelOpacity");
const dw = localStorage.getItem("fml.global.dockWidth");
const dh = localStorage.getItem("fml.global.dockHeight");
return {
panelBg: bg || GLOBAL_DEFAULTS.panelBg,
panelOpacity: op == null ? GLOBAL_DEFAULTS.panelOpacity : Number(op),
dockWidth: dw == null ? GLOBAL_DEFAULTS.dockWidth : Number(dw),
// null = auto (matches the canvas height); a number is a user-dragged override.
dockHeight: dh == null ? null : Number(dh),
};
}
function setGlobal(key, value) {
localStorage.setItem("fml.global." + key, String(value));
FML._applyGlobal();
}
// Subscribe to global-settings changes (for non-panel mods like the chat bar).
function onGlobal(fn) {
FML._globalListeners.push(fn);
return () => { const i = FML._globalListeners.indexOf(fn); if (i >= 0) FML._globalListeners.splice(i, 1); };
}
// ---- config persistence (drive FlatMMOPlus programmatically) --------------
function setConfig(mod, key, value) {
// Ensure mod.config exists WITHOUT calling FlatMMOPlusPlugin.getConfig() (FlatMMOPlus's
// own base class): its auto-load path calls FlatMMOPlus.loadPluginConfigs as a static,
// but that's a prototype method - so it throws if the mod isn't registered yet. Load it
// the working way (on the instance) when registered, else start from an empty object.
if (!mod.config) {
const fmp = window.FlatMMOPlus;
if (fmp && typeof fmp.loadPluginConfigs === "function" && mod.id in fmp.plugins) {
fmp.loadPluginConfigs(mod.id);
}
if (!mod.config) mod.config = {};
}
mod.config[key] = value;
localStorage.setItem(`flatmmoplus.${mod.id}.config`, JSON.stringify(mod.config));
if (mod.onConfigsChanged) mod.onConfigsChanged();
}
const BOOL = ["checkbox", "bool", "boolean"];
const INT = ["integer", "int"];
const FLOAT = ["number", "float", "num"];
const RANGE = ["range"];
const STR = ["string", "text"];
const SEL = ["select"];
const COL = ["color"];
// ---- manager UI -----------------------------------------------------------
function ensureManagerStyle() {
if (document.getElementById("fml-manager-style")) return;
const style = document.createElement("style");
style.id = "fml-manager-style";
style.textContent = `
#fml-launcher {
position: fixed; left: 8px; bottom: 8px; z-index: 90;
padding: 3px 8px; border-radius: 6px; cursor: pointer;
background: rgba(14,17,22,0.85); color: #cfe3ff; border: 1px solid rgba(255,255,255,0.18);
font-family: sans-serif; font-size: 11px; font-weight: bold; opacity: 0.75;
}
#fml-launcher:hover { opacity: 1; }
#fml-manager-overlay {
position: fixed; inset: 0; z-index: 100000;
background: rgba(0,0,0,0.55); display: flex; align-items: flex-start; justify-content: center;
padding: 6vh 12px 12px; box-sizing: border-box;
}
#fml-manager {
width: 460px; max-width: 100%; max-height: 88vh; overflow-y: auto; overscroll-behavior: contain;
background: #12161c; color: #e6ecf3; border: 1px solid rgba(255,255,255,0.16);
border-radius: 10px; padding: 14px; font-family: sans-serif; box-shadow: 0 6px 30px rgba(0,0,0,0.6);
}
#fml-manager h3 { margin: 0 0 10px; font-size: 15px; }
.fml-card { border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
.fml-card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: rgba(255,255,255,0.04); cursor: pointer; }
.fml-card-name { flex: 1 1 auto; font-weight: bold; font-size: 13px; }
.fml-switch { position: relative; width: 38px; height: 20px; flex-shrink: 0; }
.fml-switch input { opacity: 0; width: 0; height: 0; }
.fml-slider { position: absolute; inset: 0; border-radius: 20px; background: #444; transition: .15s; }
.fml-slider::before { content: ""; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: #fff; transition: .15s; }
.fml-switch input:checked + .fml-slider { background: #3ba55d; }
.fml-switch input:checked + .fml-slider::before { transform: translateX(18px); }
.fml-card-body { padding: 8px 10px; display: none; border-top: 1px solid rgba(255,255,255,0.08); }
.fml-card.fml-open .fml-card-body { display: block; }
.fml-field { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; }
.fml-field label { flex: 1 1 auto; color: #b9c4d0; }
.fml-field input[type=number], .fml-field input[type=text], .fml-field select { width: 120px; }
.fml-field .fml-rangeval { width: 34px; text-align: right; color: #9fb0c0; }
.fml-reset-pos { margin-top: 8px; font-size: 11px; color: #cfe3ff; cursor: pointer; text-decoration: underline; }
#fml-manager .fml-close { float: right; cursor: pointer; color: #9fb0c0; font-weight: bold; }
.fml-mods { margin-bottom: 12px; }
.fml-mods-title { font-size: 13px; font-weight: bold; margin: 2px 0 8px; color: #cfe3ff; }
.fml-src-add { display: flex; gap: 6px; margin-bottom: 8px; }
.fml-src-add input { flex: 1 1 auto; min-width: 0; padding: 4px 6px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.18); background: #0d1117; color: #e6ecf3; }
.fml-btn { padding: 3px 10px; border-radius: 6px; cursor: pointer; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.18); color: #e6ecf3; font-weight: bold; }
.fml-btn:hover { background: rgba(255,255,255,0.2); }
.fml-manager-footer { margin-top: 12px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end; }
.fml-nuke { background: rgba(224,64,58,0.22); border-color: rgba(224,64,58,0.5); color: #ffd7d4; }
.fml-nuke:hover { background: rgba(224,64,58,0.45); }
.fml-src-empty { color: #8a94a0; font-size: 12px; }
.fml-src { border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
.fml-src-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255,255,255,0.04); }
.fml-src-url { flex: 1 1 auto; min-width: 0; font-size: 11px; color: #b9c4d0; word-break: break-all; }
.fml-src-rm { cursor: pointer; color: #9fb0c0; font-weight: bold; flex-shrink: 0; }
.fml-src-rm:hover { color: #e0403a; }
.fml-src-note { padding: 6px 10px; font-size: 12px; color: #8a94a0; }
.fml-src-err { color: #e0857f; }
.fml-mod { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-top: 1px solid rgba(255,255,255,0.06); }
.fml-mod-info { flex: 1 1 auto; min-width: 0; }
.fml-mod-name { font-size: 13px; font-weight: bold; }
.fml-mod-desc { font-size: 11px; color: #9fb0c0; }
.fml-src .fml-card { border: 0; border-radius: 0; margin: 0; border-top: 1px solid rgba(255,255,255,0.06); }
`;
document.head.appendChild(style);
}
function renderField(mod, cfg) {
const field = document.createElement("div");
field.className = "fml-field";
const label = document.createElement("label");
label.textContent = cfg.label || cfg.id;
const value = mod.getConfig(cfg.id);
let input;
if (BOOL.includes(cfg.type)) {
input = document.createElement("input");
input.type = "checkbox";
input.checked = !!value;
input.onchange = () => setConfig(mod, cfg.id, input.checked);
} else if (RANGE.includes(cfg.type)) {
input = document.createElement("input");
input.type = "range";
input.min = cfg.min ?? 0; input.max = cfg.max ?? 100; input.step = cfg.step ?? 1;
input.value = value;
const val = document.createElement("span");
val.className = "fml-rangeval";
val.textContent = value;
input.oninput = () => { val.textContent = input.value; setConfig(mod, cfg.id, parseInt(input.value)); };
field.appendChild(label); field.appendChild(input); field.appendChild(val);
return field;
} else if (INT.includes(cfg.type) || FLOAT.includes(cfg.type)) {
input = document.createElement("input");
input.type = "number";
if (cfg.min != null) input.min = cfg.min;
if (cfg.max != null) input.max = cfg.max;
if (cfg.step != null) input.step = cfg.step;
input.value = value;
const parse = INT.includes(cfg.type) ? parseInt : parseFloat;
input.onchange = () => setConfig(mod, cfg.id, parse(input.value));
} else if (SEL.includes(cfg.type)) {
input = document.createElement("select");
(cfg.options || []).forEach((opt) => {
const o = document.createElement("option");
const ov = typeof opt === "object" ? opt.value : opt;
o.value = ov; o.textContent = typeof opt === "object" ? opt.label : opt;
if (ov === value) o.selected = true;
input.appendChild(o);
});
input.onchange = () => setConfig(mod, cfg.id, input.value);
} else if (COL.includes(cfg.type)) {
input = document.createElement("input");
input.type = "color";
input.value = value || "#000000";
input.oninput = () => setConfig(mod, cfg.id, input.value);
} else {
input = document.createElement("input");
input.type = "text";
input.value = value == null ? "" : value;
input.onchange = () => setConfig(mod, cfg.id, input.value);
}
field.appendChild(label);
field.appendChild(input);
return field;
}
// One unified entry per mod: name + enable toggle in the head, and the
// mod's live settings (once loaded) in the expandable body - no separate card.
function buildModCard(s, m) {
const mod = loader.mod(m);
const enabled = loader.isEnabled(s, m);
const card = document.createElement("div");
card.className = "fml-card";
const head = document.createElement("div");
head.className = "fml-card-head";
const name = document.createElement("div");
name.className = "fml-card-name";
name.textContent = m.name || m.id;
const sw = document.createElement("label");
sw.className = "fml-switch";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = enabled;
cb.onchange = () => (cb.checked ? loader.enable(s, m) : loader.disable(s, m));
sw.onclick = (e) => e.stopPropagation(); // toggle shouldn't expand the card
const slider = document.createElement("span");
slider.className = "fml-slider";
sw.appendChild(cb); sw.appendChild(slider);
head.appendChild(name);
head.appendChild(sw);
const body = document.createElement("div");
body.className = "fml-card-body";
if (m._error) {
const err = document.createElement("div");
err.className = "fml-src-err";
err.style.cssText = "font-size:12px;margin-bottom:4px;";
err.textContent = "Error: " + m._error;
body.appendChild(err);
}
if (m.description) {
const d = document.createElement("div");
d.className = "fml-mod-desc";
d.style.marginBottom = "6px";
d.textContent = m.description;
body.appendChild(d);
}
if (mod) {
for (const cfg of mod.opts.config) {
if (cfg.id === "enabled") continue;
body.appendChild(renderField(mod, cfg));
}
const reset = document.createElement("div");
reset.className = "fml-reset-pos";
reset.textContent = "Reset mod settings";
reset.title = "Clear this mod's saved settings and panel position/size back to defaults.";
reset.onclick = () => {
if (!window.confirm("Reset " + (m.name || m.id) + " to default settings?\n\nThis clears its saved settings and panel position, size and dock state.")) return;
loader.resetMod(m);
};
body.appendChild(reset);
} else {
const n = document.createElement("div");
n.className = "fml-mod-desc";
n.textContent = enabled ? "Loading…" : "Enable to load and configure.";
body.appendChild(n);
}
head.onclick = () => card.classList.toggle("fml-open");
card.appendChild(head);
card.appendChild(body);
return card;
}
// Collapsible "Global settings" card: shared panel background + opacity that every
// mod panel uses. Live-applies on change (docked panels + chat ignore opacity).
function buildGlobalCard() {
const g = globalSettings();
const card = document.createElement("div");
card.className = "fml-card";
const head = document.createElement("div");
head.className = "fml-card-head";
const name = document.createElement("div");
name.className = "fml-card-name";
name.textContent = "\u2699 Global settings";
head.appendChild(name);
head.onclick = () => card.classList.toggle("fml-open");
const body = document.createElement("div");
body.className = "fml-card-body";
const bgField = document.createElement("div");
bgField.className = "fml-field";
const bgLabel = document.createElement("label");
bgLabel.textContent = "Panel background";
const bgInput = document.createElement("input");
bgInput.type = "color";
bgInput.value = g.panelBg;
bgInput.oninput = () => setGlobal("panelBg", bgInput.value);
bgField.append(bgLabel, bgInput);
const opField = document.createElement("div");
opField.className = "fml-field";
const opLabel = document.createElement("label");
opLabel.textContent = "Panel opacity (%)";
const opInput = document.createElement("input");
opInput.type = "range";
opInput.min = 10; opInput.max = 100; opInput.step = 5;
opInput.value = g.panelOpacity;
const opVal = document.createElement("span");
opVal.className = "fml-rangeval";
opVal.textContent = g.panelOpacity;
opInput.oninput = () => { opVal.textContent = opInput.value; setGlobal("panelOpacity", parseInt(opInput.value)); };
opField.append(opLabel, opInput, opVal);
const note = document.createElement("div");
note.className = "fml-mod-desc";
note.style.marginTop = "4px";
note.textContent = "Applies to all mod panels (docked panels ignore opacity). Drag the dock's bottom-right handle to resize it.";
body.append(bgField, opField, note);
card.append(head, body);
return card;
}
const manager = {
open() {
ensureManagerStyle();
if (document.getElementById("fml-manager-overlay")) return;
const overlay = document.createElement("div");
overlay.id = "fml-manager-overlay";
// Pin the page while the manager is open so scrolling the window doesn't move it.
const prevOverflow = document.documentElement.style.overflow;
const closeOverlay = () => { overlay.remove(); document.documentElement.style.overflow = prevOverflow; };
overlay.onclick = (e) => { if (e.target === overlay) closeOverlay(); };
const box = document.createElement("div");
box.id = "fml-manager";
const title = document.createElement("h3");
title.textContent = "Flat Mod Loader";
const close = document.createElement("span");
close.className = "fml-close";
close.textContent = "\u00D7";
close.onclick = () => closeOverlay();
title.appendChild(close);
box.appendChild(title);
box.appendChild(buildGlobalCard());
const modsEl = document.createElement("div");
modsEl.className = "fml-mods";
box.appendChild(modsEl);
this._modsEl = modsEl;
this.renderMods();
const footer = document.createElement("div");
footer.className = "fml-manager-footer";
const nuke = document.createElement("button");
nuke.className = "fml-btn fml-nuke";
nuke.textContent = "Nuke all configs";
nuke.title = "Clear every mod's saved settings, panel positions and stale configs, then reload. Your mod sources are kept.";
nuke.onclick = () => {
if (!window.confirm("Nuke all Flat Mod Loader configs?\n\nThis clears every mod's settings, panel positions/sizes, the dock layout, which mods are enabled, and any stale saved configs (including old Quick Actions), then reloads the page.\n\nYour mod SOURCES are kept.")) return;
FML._nukeData();
location.reload();
};
footer.appendChild(nuke);
box.appendChild(footer);
overlay.appendChild(box);
document.body.appendChild(overlay);
document.documentElement.style.overflow = "hidden";
},
refreshMods() { if (this._modsEl && this._modsEl.isConnected) this.renderMods(); },
renderMods() {
const host = this._modsEl;
if (!host) return;
host.innerHTML = "";
const h = document.createElement("div");
h.className = "fml-mods-title";
h.textContent = "Mod sources";
host.appendChild(h);
const addRow = document.createElement("div");
addRow.className = "fml-src-add";
const input = document.createElement("input");
input.type = "text";
input.placeholder = "GitHub repo URL...";
const addBtn = document.createElement("button");
addBtn.className = "fml-btn";
addBtn.textContent = "Add";
const submit = () => { const v = input.value.trim(); if (v) { loader.addSource(v); input.value = ""; } };
addBtn.onclick = submit;
input.onkeydown = (e) => { if (e.key === "Enter") submit(); };
addRow.appendChild(input); addRow.appendChild(addBtn);
host.appendChild(addRow);
if (loader.sources.length === 0) {
const none = document.createElement("div");
none.className = "fml-src-empty";
none.textContent = "No sources yet. Add a GitHub repo that has a mods/index.json.";
host.appendChild(none);
return;
}
for (const s of loader.sources) {
const src = document.createElement("div");
src.className = "fml-src";
const head = document.createElement("div");
head.className = "fml-src-head";
const url = document.createElement("span");
url.className = "fml-src-url";
url.textContent = s.url;
const rm = document.createElement("span");
rm.className = "fml-src-rm";
rm.textContent = "\u00D7";
rm.title = "Remove source";
rm.onclick = () => loader.removeSource(s.url);
head.appendChild(url); head.appendChild(rm);
src.appendChild(head);
if (s.loading) {
const n = document.createElement("div"); n.className = "fml-src-note"; n.textContent = "Loading…"; src.appendChild(n);
} else if (s.error) {
const n = document.createElement("div"); n.className = "fml-src-note fml-src-err"; n.textContent = s.error; src.appendChild(n);
} else if (s.mods.length === 0) {
const n = document.createElement("div"); n.className = "fml-src-note"; n.textContent = "No mods found."; src.appendChild(n);
}
for (const m of s.mods) src.appendChild(buildModCard(s, m));
host.appendChild(src);
}
},
injectLauncher() {
ensureManagerStyle();
if (document.getElementById("fml-launcher")) return;
const b = document.createElement("div");
b.id = "fml-launcher";
b.textContent = "Mods";
b.title = "Open Flat Mod Loader";
b.onclick = () => manager.open();
document.body.appendChild(b);
},
};
// ---- dock sidebar ---------------------------------------------------------
const dock = {
el: null,
_panels: [],
_hinting: false,
_empty: null,
ensure() {
if (this.el) return;
ensurePanelStyle();
const el = document.createElement("div");
el.id = "fml-dock";
document.body.appendChild(el);
this.el = el;
el.style.setProperty("--fml-dock-w", globalSettings().dockWidth + "px");
this._wireResize();
window.addEventListener("resize", () => this.layout());
this.layout();
},
// Drag the bottom-right handle to resize the dock (both width and height, like a
// panel's own resize grip). Width always persists; height persists too and then
// overrides the default canvas-derived height until dragged again.
_wireResize() {
const handle = document.createElement("div");
handle.className = "fml-dock-resize";
this.el.appendChild(handle);
handle.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX, startY = e.clientY;
const rect = this.el.getBoundingClientRect();
const startW = rect.width, startH = rect.height;
const move = (ev) => {
const w = Math.max(180, Math.min(startW + (ev.clientX - startX), window.innerWidth - 20));
const h = Math.max(120, Math.min(startH + (ev.clientY - startY), window.innerHeight - 20));
this.el.style.setProperty("--fml-dock-w", w + "px");
this.el.style.height = h + "px";
this.layout(true); // keep left/top synced; don't let layout stomp our live height
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
const r = this.el.getBoundingClientRect();
localStorage.setItem("fml.global.dockHeight", String(Math.round(r.height)));
setGlobal("dockWidth", Math.round(r.width));
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
});
},
// Re-applies the global dock width (called from FML._applyGlobal on change).
applyGlobal() {
if (!this.el) return;
this.el.style.setProperty("--fml-dock-w", globalSettings().dockWidth + "px");
this.layout();
},
layout(skipHeight) {
if (!this.el) return;
// Read the actual rendered width (not the saved setting) so this stays accurate
// mid-drag, when the CSS var has already changed but nothing's persisted yet.
const dockW = this.el.getBoundingClientRect().width || globalSettings().dockWidth;
const canvas = document.getElementById("canvas");
const rect = canvas
? canvas.getBoundingClientRect()
: { right: window.innerWidth - dockW - 10, top: 0, height: window.innerHeight };
let left = Math.min(rect.right + 6, window.innerWidth - dockW - 4);
left = Math.max(0, left);
this.el.style.left = left + "px";
this.el.style.top = Math.max(0, rect.top) + "px";
// A user-dragged height overrides the default (match-the-canvas) height.
if (!skipHeight) {
const dockH = globalSettings().dockHeight;
this.el.style.height = (dockH != null ? dockH : rect.height) + "px";
}
},
add(panel) {
this.ensure();
if (!this._panels.includes(panel)) this._panels.push(panel);
const order = FML._loadDockOrder();
if (!order.includes(panel.mod.id)) { order.push(panel.mod.id); FML._saveDockOrder(order); }
this._reorder();
this.el.classList.add("fml-dock-show");
this._updateEmpty();
this.layout();
},
remove(panel, forget) {
const i = this._panels.indexOf(panel);
if (i >= 0) this._panels.splice(i, 1);
if (forget) FML._saveDockOrder(FML._loadDockOrder().filter((x) => x !== panel.mod.id));
if (this.el && panel.el.parentNode === this.el) this.el.removeChild(panel.el);
this._updateEmpty();
if (this.el && this._panels.length === 0 && !this._hinting) this.el.classList.remove("fml-dock-show");
},
_reorder() {
const order = FML._loadDockOrder();
this._panels.sort((a, b) => order.indexOf(a.mod.id) - order.indexOf(b.mod.id));
for (const p of this._panels) this.el.appendChild(p.el);
},
// Live reorder while a docked panel is dragged: place it by pointer Y.
dragTo(panel, y) {
const arr = this._panels.filter((p) => p !== panel);
let idx = arr.length;
for (let i = 0; i < arr.length; i++) {
const r = arr[i].el.getBoundingClientRect();
if (y < r.top + r.height / 2) { idx = i; break; }
}
arr.splice(idx, 0, panel);
if (arr.some((p, i) => this._panels[i] !== p)) {
this._panels = arr;
for (const p of this._panels) this.el.appendChild(p.el);
}
},
persistOrder() {
FML._saveDockOrder(this._panels.map((p) => p.mod.id));
},
// --- drag-and-drop docking (panels dock by being dragged over this sidebar) ---
pointInDock(x, y) {
if (!this.el) return false;
const r = this.el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
},
beginDropHint() {
this.ensure();
this._hinting = true;
this.el.classList.add("fml-dock-show");
this.layout();
this._updateEmpty();
},
endDropHint() {
this._hinting = false;
this.highlight(false);
this._updateEmpty();
if (this.el && this._panels.length === 0) this.el.classList.remove("fml-dock-show");
},
highlight(on) {
if (this.el) this.el.classList.toggle("fml-dock-hint", !!on);
},
_updateEmpty() {
if (!this.el) return;
const need = this._hinting && this._panels.length === 0;
if (need && !this._empty) {
this._empty = document.createElement("div");
this._empty.className = "fml-dock-empty";
this._empty.textContent = "Drop here to dock";
this.el.appendChild(this._empty);
} else if (!need && this._empty) {
this._empty.remove();
this._empty = null;
}
},
};
// ---- mod loader -----------------------------------------------------------
// A GitHub repo URL (…/owner/repo or …/tree/branch) maps to raw candidate bases;
// any other URL (e.g. http://localhost:8611/) is used as a base directly.
function normalizeSource(url) {
url = String(url || "").trim();
const gh = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/([^/]+))?\/?$/i);
if (gh) {
const branches = gh[3] ? [gh[3]] : ["main", "master"];
return branches.map((b) => `https://raw.githubusercontent.com/${gh[1]}/${gh[2]}/${b}/`);
}
return [url.endsWith("/") ? url : url + "/"];
}
function bust(u) { return u + (u.indexOf("?") >= 0 ? "&" : "?") + "t=" + Date.now(); }
function injectMod(code, name) {
const el = document.createElement("script");
el.textContent = "//# sourceURL=fml-mod/" + (name || "mod") + ".js\n" + code;
document.documentElement.appendChild(el);
}
const loader = {
sources: [], // [{ url, base, mods:[{id,name,version,description,entry,_error}], error, loading }]
_loaded: {}, // "url|id" -> true (already injected this page load)
key(url, id) { return url + "|" + id; },
init() {
this.sources = FML._loadSources().map((url) => ({ url, base: null, mods: [], error: null, loading: true }));
this.sources.forEach((s) => this.refresh(s));
},
async refresh(s) {
s.loading = true; s.error = null;
manager.refreshMods();
let idx = null, base = null;
for (const c of normalizeSource(s.url)) {
try {
const r = await fetch(bust(c + "mods/index.json"), { cache: "no-store" });
if (!r.ok) continue;
idx = await r.json(); base = c; break;
} catch (e) { /* try next candidate */ }
}
s.loading = false;
if (!idx) { s.error = "Couldn't read mods/index.json (check the URL / branch / CORS)."; manager.refreshMods(); return; }
s.base = base;
s.mods = (Array.isArray(idx) ? idx : idx.mods || []).filter((m) => m && m.id);
manager.refreshMods();
for (const m of s.mods) if (FML._loadModEnabled(this.key(s.url, m.id))) this.load(s, m);
},
mod(m) { return FML._mods.find((x) => x.id === m.id) || null; },
async load(s, m) {
const k = this.key(s.url, m.id);
if (!this._loaded[k]) {
m._error = null;
const entry = m.entry || ("mods/" + m.id + "/mod.js");
try {
// The index lists each mod's content hash; if it matches what we cached
// last time, reuse the cached source instead of re-fetching the file.
const cached = m.hash ? FML._loadModCache(m.id) : null;
let code;
if (cached && cached.hash === m.hash) {
code = cached.code;
} else {
const r = await fetch(bust(s.base + entry), { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
code = await r.text();
if (m.hash) FML._saveModCache(m.id, m.hash, code);
}
injectMod(code, m.id); // runs synchronously → the mod registers now
this._loaded[k] = true;
} catch (e) {
m._error = String((e && e.message) || e);
manager.refreshMods();
return;
}
}
// Switch the freshly-registered (or already-loaded) mod ON, overriding any
// stale saved `enabled:false` - so a single toggle is enough to run it.
const p = this.mod(m);
if (p) setConfig(p, "enabled", true);
manager.refreshMods();
},
enable(s, m) { FML._saveModEnabled(this.key(s.url, m.id), true); this.load(s, m); },
disable(s, m) {
FML._saveModEnabled(this.key(s.url, m.id), false);
const p = this.mod(m);
if (p) setConfig(p, "enabled", false);
manager.refreshMods();
},
isEnabled(s, m) { return FML._loadModEnabled(this.key(s.url, m.id)); },
// Reset ONE mod to defaults (like "Nuke", scoped): wipe its saved settings +
// panel position/size/dock, then restart it live so it rebuilds with defaults.
resetMod(m) {
const p = this.mod(m);
const wasActive = !!(p && p._active);
if (wasActive) setConfig(p, "enabled", false); // onStop: tear down panel/hooks
FML._nukeMod(m.id);
if (p) {
p.config = {}; // drop in-memory settings
if (wasActive) setConfig(p, "enabled", true); // onStart: rebuild with defaults
}
manager.refreshMods();
},
addSource(url) {
url = String(url || "").trim();
if (!url || this.sources.some((x) => x.url === url)) return;
const s = { url, base: null, mods: [], error: null, loading: true };
this.sources.push(s);
FML._saveSources(this.sources.map((x) => x.url));
this.refresh(s);
},
removeSource(url) {
const s = this.sources.find((x) => x.url === url);
if (s) for (const m of s.mods) this.disable(s, m);
this.sources = this.sources.filter((x) => x.url !== url);
FML._saveSources(this.sources.map((x) => x.url));
manager.refreshMods();
},
};
// ---- namespace ------------------------------------------------------------
const FML = {
version: VERSION,
Mod: FMLMod,
Panel: FMLPanel,
List: FMLList,
util,
game,
panelConfig,
setConfig,
manager,
dock,
loader,
globalSettings,
setGlobal,
onGlobal,
_globalListeners: (window.FML && window.FML._globalListeners) || [],
_applyGlobal() {
dock.applyGlobal();
for (const p of this._mods) { if (p._panel) { try { p._panel.applySettings(); } catch (e) {} } }
for (const fn of this._globalListeners.slice()) { try { fn(globalSettings()); } catch (e) {} }
},
_mods: (window.FML && window.FML._mods) || [],
_register(mod) { this._mods.push(mod); },
_modMeta(id) {
for (const s of loader.sources) {
const m = (s.mods || []).find((mm) => mm.id === id);
if (m) return m;
}
return null;
},
// FlatMMOPlus itself still calls these "plugins" - that's its own external API name,
// kept here (registerPlugin) at the boundary so the rest of FML can say "mod".
_registerWithFlatMMOPlus(mod) {
if (window.FlatMMOPlus && typeof window.FlatMMOPlus.registerPlugin === "function") {
window.FlatMMOPlus.registerPlugin(mod);
} else {
const iv = setInterval(() => {
if (window.FlatMMOPlus && typeof window.FlatMMOPlus.registerPlugin === "function") {
clearInterval(iv);
window.FlatMMOPlus.registerPlugin(mod);
}
}, 250);
}
},
_loadPos(id) {
try { return JSON.parse(localStorage.getItem(`fml.${id}.pos`)) || null; } catch (e) { return null; }
},
_savePos(id, pos) {
if (pos) localStorage.setItem(`fml.${id}.pos`, JSON.stringify(pos));
else localStorage.removeItem(`fml.${id}.pos`);
},
_loadSize(id) {
try { return JSON.parse(localStorage.getItem(`fml.${id}.size`)) || null; } catch (e) { return null; }
},
_saveSize(id, size) {
if (size) localStorage.setItem(`fml.${id}.size`, JSON.stringify(size));
else localStorage.removeItem(`fml.${id}.size`);
},
// Panels default to DOCKED. Absent preference → docked; "1"/"0" are explicit
// choices (so an undock persists instead of reverting to the docked default).
_loadDocked(id) { const v = localStorage.getItem(`fml.${id}.docked`); return v === null ? true : v === "1"; },
_saveDocked(id, v) { localStorage.setItem(`fml.${id}.docked`, v ? "1" : "0"); },
_loadDockOrder() { try { return JSON.parse(localStorage.getItem("fml.dock.order")) || []; } catch (e) { return []; } },
_saveDockOrder(arr) { localStorage.setItem("fml.dock.order", JSON.stringify(arr)); },
_loadSources() { try { return JSON.parse(localStorage.getItem("fml.sources")) || []; } catch (e) { return []; } },
_saveSources(arr) { localStorage.setItem("fml.sources", JSON.stringify(arr)); },
_loadModEnabled(key) { return localStorage.getItem("fml.mod." + key + ".enabled") === "1"; },
_saveModEnabled(key, v) { if (v) localStorage.setItem("fml.mod." + key + ".enabled", "1"); else localStorage.removeItem("fml.mod." + key + ".enabled"); },
// Cache of a mod's fetched source, keyed by mod id + the hash from index.json.
// Lets the loader skip re-downloading a mod's entry file when its hash hasn't changed.
_loadModCache(id) {
try { return JSON.parse(localStorage.getItem("fml.mod." + id + ".cache")) || null; } catch (e) { return null; }
},
_saveModCache(id, hash, code) {
try { localStorage.setItem("fml.mod." + id + ".cache", JSON.stringify({ hash, code })); } catch (e) { /* storage full/unavailable - just skip caching */ }
},
// Wipe all loader/mod data (mod configs, panel positions, dock, enabled flags,
// and legacy keys). Keeps `fml.sources` so mods reappear after the reload.
_nukeData() {
const keep = new Set(["fml.sources"]);
const kill = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k || keep.has(k)) continue;
if (k.startsWith("fml.") || k.startsWith("flatmmoplus.") || k === "fmmoQuickActionsConfig") kill.push(k);
}
kill.forEach((k) => localStorage.removeItem(k));
return kill.length;
},
// Wipe ONE mod's saved data: its config + all fml.<id>.* keys (panel pos/size/dock
// and any saved layout). Keeps the loader's enabled flag so the mod stays installed.
_nukeMod(id) {
const kill = ["flatmmoplus." + id + ".config", "fml.mod." + id + ".cache"];
const prefix = "fml." + id + ".";
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(prefix)) kill.push(k);
}
kill.forEach((k) => localStorage.removeItem(k));
return kill.length;
},
};
window.FML = FML;
window.FMLMod = FMLMod;
window.FMLPanel = FMLPanel;
window.FMLList = FMLList;
// Wait for the game UI, then add the launcher + a /fml chat command.
const bootIv = setInterval(() => {
if (!document.getElementById("canvas")) return;
clearInterval(bootIv);
manager.injectLauncher();
if (window.FlatMMOPlus && typeof window.FlatMMOPlus.registerCustomChatCommand === "function") {
window.FlatMMOPlus.registerCustomChatCommand("fml", () => manager.open(), "Open Flat Mod Loader");
}
loader.init();
}, 300);
})();