// ==UserScript== // @name MWI Solo Character Export // @namespace https://github.com/mwi-solo-character-export // @version 0.1.0 // @author Krymoore // @description Capture your own Milky Way Idle character's full stats and copy them to your clipboard for import into sftb.mpalmer.io. // @icon https://www.milkywayidle.com/favicon.ico // @downloadURL https://sftb.mpalmer.io/mwi-solo-character-export.user.js // @updateURL https://sftb.mpalmer.io/mwi-solo-character-export.user.js // @match https://www.milkywayidle.com/* // @match https://test.milkywayidle.com/* // @grant GM_openInTab // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function() { "use strict"; function createBus() { const handlers = new Map(); function on(type, fn) { let set = handlers.get(type); if (!set) { set = new Set(); handlers.set(type, set); } set.add(fn); } function off(type, fn) { handlers.get(type)?.delete(fn); } function emit(type, payload) { handlers.get(type)?.forEach((fn) => { try { fn(payload); } catch (err) { console.error(`[MWI Export] Bus handler for "${type}" threw:`, err); } }); } return { on, off, emit }; } var Bus = createBus(); var WS_HOST_MATCH = /api(-test)?\.milkywayidle\.com/; function installSocketHook() { const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const NativeWebSocket = win.WebSocket; class PatchedWebSocket extends NativeWebSocket { constructor(url, protocols) { super(url, protocols); if (WS_HOST_MATCH.test(String(url))) { this.addEventListener("message", (event) => { if (typeof event.data !== "string") return; let msg; try { msg = JSON.parse(event.data); } catch { return; } if (msg && typeof msg.type === "string") { Bus.emit(msg.type, msg); Bus.emit("*", msg); } }); const nativeSend = this.send.bind(this); this.send = (data) => { if (typeof data === "string") try { const parsed = JSON.parse(data); console.debug("[MWI-OUT]", parsed?.type, parsed); } catch {} return nativeSend(data); }; } } } win.WebSocket = PatchedWebSocket; } function createDataStore() { let characterData = null; let clientData = null; let lastUpdated = null; const readyListeners = new Set(); function isReady() { return characterData !== null; } function notifyReady() { const ready = isReady(); readyListeners.forEach((fn) => fn(ready)); } Bus.on("init_character_data", (msg) => { characterData = msg; lastUpdated = Date.now(); notifyReady(); }); Bus.on("init_client_data", (msg) => { clientData = msg; notifyReady(); }); function onReadyChange(fn) { readyListeners.add(fn); } return { getCharacterData: () => characterData, getClientData: () => clientData, getLastUpdated: () => lastUpdated, isReady, onReadyChange }; } var DataStore = createDataStore(); var SCRIPT_VERSION = "0.1.0"; var INVENTORY_LOCATION_HRID = "/item_locations/inventory"; function buildLookups(clientData) { return { itemDetailMap: clientData?.itemDetailMap ?? {}, houseRoomDetailMap: clientData?.houseRoomDetailMap ?? {}, achievementDetailMap: clientData?.achievementDetailMap ?? {}, abilityDetailMap: clientData?.abilityDetailMap ?? {}, skillDetailMap: clientData?.skillDetailMap ?? {} }; } function hridToName(map, hrid) { return map[hrid]?.name ?? hrid; } function slotFromLocationHrid(locationHrid) { return locationHrid.replace("/item_locations/", ""); } function normalizeSkills(skills, lookups) { return (skills ?? []).map((skill) => ({ skillHrid: skill.skillHrid, name: hridToName(lookups.skillDetailMap, skill.skillHrid), level: skill.level, experience: skill.experience })); } function normalizeEquipment(characterData, lookups) { if (characterData.characterItems) return characterData.characterItems.filter((item) => item.itemLocationHrid !== INVENTORY_LOCATION_HRID).map((item) => ({ slot: slotFromLocationHrid(item.itemLocationHrid), itemHrid: item.itemHrid, name: hridToName(lookups.itemDetailMap, item.itemHrid), enhancementLevel: item.enhancementLevel ?? 0, count: item.count })); if (characterData.wearableItemMap) return Object.entries(characterData.wearableItemMap).map(([slot, item]) => ({ slot: slotFromLocationHrid(slot), itemHrid: item.itemHrid, name: hridToName(lookups.itemDetailMap, item.itemHrid), enhancementLevel: item.enhancementLevel ?? 0, count: item.count ?? 1 })); return []; } function normalizeInventory(characterData, lookups) { return (characterData.characterItems ?? []).filter((item) => item.itemLocationHrid === INVENTORY_LOCATION_HRID).map((item) => ({ itemHrid: item.itemHrid, name: hridToName(lookups.itemDetailMap, item.itemHrid), count: item.count, enhancementLevel: item.enhancementLevel ?? 0 })); } function normalizeHouseRooms(houseRoomMap, lookups) { return Object.entries(houseRoomMap ?? {}).map(([roomHrid, room]) => ({ roomHrid, name: hridToName(lookups.houseRoomDetailMap, roomHrid), level: room.level })); } function normalizeAchievements(achievements, lookups) { return (achievements ?? []).map((achievement) => ({ achievementHrid: achievement.achievementHrid, name: hridToName(lookups.achievementDetailMap, achievement.achievementHrid), isCompleted: achievement.isCompleted })); } function normalizeAbilities(characterData, lookups) { if (characterData.characterAbilities) return characterData.characterAbilities.map((ability) => ({ abilityHrid: ability.abilityHrid, name: hridToName(lookups.abilityDetailMap, ability.abilityHrid), level: ability.level, experience: ability.experience, equipped: ability.slotNumber > 0, slotNumber: ability.slotNumber })); if (characterData.equippedAbilities) return characterData.equippedAbilities.map((ability, index) => ({ abilityHrid: ability.abilityHrid, name: hridToName(lookups.abilityDetailMap, ability.abilityHrid), level: ability.level, experience: null, equipped: true, slotNumber: index + 1 })); return []; } function normalize(characterData, clientData, dataAsOf) { const lookups = buildLookups(clientData); return { meta: { exportedAt: new Date().toISOString(), scriptVersion: SCRIPT_VERSION, hasClientData: clientData !== null, dataAsOf: dataAsOf !== null ? new Date(dataAsOf).toISOString() : null }, character: { id: characterData.character.id, name: characterData.character.name, gameMode: characterData.character.gameMode, combatLevel: characterData.combatLevel }, guild: characterData.guildId !== void 0 ? { id: characterData.guildId, name: characterData.guildName, role: characterData.guildRole } : null, skills: normalizeSkills(characterData.characterSkills, lookups), equipment: normalizeEquipment(characterData, lookups), inventory: normalizeInventory(characterData, lookups), houseRooms: normalizeHouseRooms(characterData.characterHouseRoomMap, lookups), achievements: normalizeAchievements(characterData.characterAchievements, lookups), abilities: normalizeAbilities(characterData, lookups), raw: { characterData, clientData } }; } function toPublicExport(normalized) { return { meta: normalized.meta, character: normalized.character, skills: normalized.skills, equipment: normalized.equipment, inventory: normalized.inventory, abilities: normalized.abilities }; } function copyToClipboard(data) { const json = JSON.stringify(data, null, 2); if (typeof GM_setClipboard !== "undefined") GM_setClipboard(json, "text"); else navigator.clipboard.writeText(json); } var CONTAINER_STYLE = ` position: fixed; bottom: 16px; right: 16px; z-index: 999999; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; background: #1e1e2a; color: #f0f0f5; font: 13px/1.4 system-ui, sans-serif; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); min-width: 200px; `; var ROW_STYLE = "display: flex; align-items: center; gap: 8px;"; function makeButton(text, onClick) { const button = document.createElement("button"); button.textContent = text; button.setAttribute("style", "cursor: pointer; padding: 4px 10px; border: none; border-radius: 4px; background: #4c6ef5; color: white; font: inherit;"); button.addEventListener("click", onClick); return button; } function mount(callbacks) { const container = document.createElement("div"); container.id = "mwi-solo-export-panel"; container.setAttribute("style", CONTAINER_STYLE); const row1 = document.createElement("div"); row1.setAttribute("style", ROW_STYLE); const dot = document.createElement("span"); dot.setAttribute("style", "width: 8px; height: 8px; border-radius: 50%; background: #888; flex-shrink: 0;"); const label = document.createElement("span"); label.textContent = "MWI Export"; label.style.flex = "1"; const exportButton = makeButton("Copy JSON", callbacks.onExport); row1.append(dot, label, exportButton); const row2 = document.createElement("div"); row2.setAttribute("style", ROW_STYLE); const importButton = makeButton("Import to Website", callbacks.onImportToWebsite); importButton.style.flex = "1"; row2.append(importButton); container.append(row1, row2); function attach() { document.body.appendChild(container); } if (document.body) attach(); else document.addEventListener("DOMContentLoaded", attach, { once: true }); let notifyTimeout; return { setReady(ready) { dot.style.background = ready ? "#2f9e44" : "#888"; }, notify(message) { const original = label.textContent; label.textContent = message; clearTimeout(notifyTimeout); notifyTimeout = setTimeout(() => { label.textContent = original; }, 3e3); } }; } var IMPORT_URL = "https://sftb.mpalmer.io/import-character"; console.log("[MWI Solo Export] script starting, document.readyState =", document.readyState); try { installSocketHook(); console.log("[MWI Solo Export] socket hook installed"); } catch (err) { console.error("[MWI Solo Export] failed to install socket hook:", err); } function handleExportClick() { if (!DataStore.isReady()) { panel.notify("No data yet — reload the page"); return; } const normalized = normalize(DataStore.getCharacterData(), DataStore.getClientData(), DataStore.getLastUpdated()); copyToClipboard(toPublicExport(normalized)); panel.notify(normalized.meta.hasClientData ? `Copied ${normalized.character.name} to clipboard` : `Copied ${normalized.character.name} (no item/skill names yet — try again in a few sec)`); } function handleImportClick() { if (typeof GM_openInTab !== "undefined") GM_openInTab(IMPORT_URL, { active: true, insert: true }); else window.open(IMPORT_URL, "_blank", "noopener"); } var panel; try { panel = mount({ onExport: handleExportClick, onImportToWebsite: handleImportClick }); DataStore.onReadyChange((ready) => panel.setReady(ready)); console.log("[MWI Solo Export] panel mounted"); } catch (err) { console.error("[MWI Solo Export] failed to mount panel:", err); } if (typeof GM_registerMenuCommand !== "undefined") { GM_registerMenuCommand("Copy Character Data", handleExportClick); GM_registerMenuCommand("Open Import Page", handleImportClick); console.log("[MWI Solo Export] menu commands registered"); } else console.warn("[MWI Solo Export] GM_registerMenuCommand unavailable — check @grant"); })();