// ==UserScript==
// @name MWI Market Addon
// @name:zh-CN MWI 市场插件
// @namespace https://milkiway.market/
// @version v0.0.6
// @description Event-based addon for seamless data synchronization between MWI and Milkyway Market
// @description:zh-CN 事件驱动的插件,用于在 MWI 和 Milkyway Market 之间实现无缝数据同步
// @author mathewcst
// @author:zh-CN mathewcst
// @license MIT
// @match https://www.milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @match https://milkywayidle.com/*
// @match https://www.milkyway.market/*
// @match https://milkyway.market/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @icon https://www.google.com/s2/favicons?sz=64&domain=milkywayidle.com
// ==/UserScript==
/**
Credits to YangLeda and MWITools
This script wouldn't be possible without their work.
@see https://github.com/YangLeda/Userscripts-For-MilkyWayIdle
*/
;(function () {
'use strict'
// Configuration
const CONFIG = {
events: {
DATA_READY: 'mwi-character-data-ready',
DATA_REQUEST: 'mwi-request-character-data',
DATA_RESPONSE: 'mwi-character-data-response',
DATA_UPDATED: 'mwi-character-data-updated',
ERROR: 'mwi-data-error',
},
storage: {
CHAR_DATA: 'char_data',
LAST_SYNC: 'last_sync_timestamp',
},
validation: {
MAX_DATA_SIZE: 1024 * 100, // 100KB max
REQUIRED_FIELDS: ['character', 'characterSkills'],
},
}
/**
* Find skill by name
* @param {Object} rawData - The raw character data
* @param {string} name - The name of the skill to find
* @returns {number} The skill level, or 0 if not found
*/
function findSkillByName(rawData, name) {
if (!rawData.characterSkills || !Array.isArray(rawData.characterSkills)) {
return 0
}
const skill = rawData.characterSkills.find(
(skill) => skill.skillHrid === `/skills/${name}`,
)
return skill ? skill.level : 0
}
/**
* Filters character data to only include market-relevant information
* @param {Object} rawData - The raw character data
* @returns {Object} The filtered character data
*/
function filterCharacterData(rawData) {
if (!rawData || typeof rawData !== 'object') {
return null
}
try {
return {
// Character identification
characterId: rawData.character?.id,
characterName: rawData.character?.name,
characterLevel: findSkillByName(rawData, 'total_level'),
characterAvatar: rawData.character?.avatarHrid,
characterAvatarOutfit: rawData.character?.avatarOutfitHrid,
gameMode: rawData.gameMode,
// Skills (only crafting and gathering related)
skills: {
// Crafting skills
milking: findSkillByName(rawData, 'milking'),
foraging: findSkillByName(rawData, 'foraging'),
woodcutting: findSkillByName(rawData, 'woodcutting'),
cheesesmithing: findSkillByName(rawData, 'cheesesmithing'),
crafting: findSkillByName(rawData, 'crafting'),
tailoring: findSkillByName(rawData, 'tailoring'),
cooking: findSkillByName(rawData, 'cooking'),
brewing: findSkillByName(rawData, 'brewing'),
},
// Inventory (items and quantities)
inventory: (rawData.characterItems || [])
.filter((item) => item && item.itemHrid && item.count > 0)
.map((item) => ({
itemHrid: item.itemHrid,
itemLocationHrid: item.itemLocationHrid,
quantity: item.count,
enhancementLevel: item.enhancementLevel || 0,
})),
// House buffs that affect production
houseBuffs: rawData.houseActionTypeBuffsMap || {},
houseActionTypeBuffsMap: rawData.houseActionTypeBuffsMap || {},
// Active drink/food buffs
activeBuffs: rawData.actionTypeDrinkSlotsMap || {},
// MooPass buffs
mooPassBuffs: rawData.mooPassBuffs || [],
mooPassActionTypeBuffsMap: rawData.mooPassActionTypeBuffsMap || {},
// Community Buffs
communityBuffs: rawData.communityBuffs || [],
communityActionTypeBuffsMap: rawData.communityActionTypeBuffsMap || {},
// Equipment Buffs
equipmentTaskActionBuffs: rawData.equipmentTaskActionBuffs || [],
equipmentActionTypeBuffsMap: rawData.equipmentActionTypeBuffsMap || {},
// Stats
noncombatStats: rawData.noncombatStats || {},
// Metadata
timestamp: Date.now(),
}
} catch (error) {
console.error(
'%c[MWIMarket]%c Error filtering character data:',
'color: #ff4444; font-weight: bold',
'color: inherit',
error,
)
return null
}
}
/**
* Validates character data structure and content
* @param {Object} data - The character data to validate
* @returns {boolean} True if the data is valid, false otherwise
*/
function validateCharacterData(data) {
if (!data || typeof data !== 'object') {
return false
}
// Check data size
const dataSize = JSON.stringify(data).length
if (dataSize > CONFIG.validation.MAX_DATA_SIZE) {
console.warn(
'%c[MWIMarket]%c Data size %d exceeds maximum allowed size',
'color: #ff9944; font-weight: bold',
'color: inherit',
dataSize,
)
return false
}
// Check required fields
if (!data.characterId || !data.skills) {
console.warn(
'%c[MWIMarket]%c Missing required fields in character data',
'color: #ff9944; font-weight: bold',
'color: inherit',
)
return false
}
// Validate data types
if (
typeof data.characterId !== 'number' ||
typeof data.skills !== 'object'
) {
console.warn(
'%c[MWIMarket]%c Invalid data types in character data',
'color: #ff9944; font-weight: bold',
'color: inherit',
)
return false
}
return true
}
/**
* Dispatches a custom event with data
* @param {string} eventType - The type of event to dispatch
* @param {Object} data - The data to dispatch
* @param {Window} target - The target window to dispatch the event to
*/
function dispatchDataEvent(eventType, data, target = window) {
try {
const event = new CustomEvent(eventType, {
detail: data,
bubbles: true,
cancelable: true,
})
target.dispatchEvent(event)
console.log(
'%c[MWIMarket]%c Dispatched %s event',
'color: #44ff44; font-weight: bold',
'color: inherit',
eventType,
data,
)
} catch (error) {
console.error(
'%c[MWIMarket]%c Error dispatching %s event:',
'color: #ff4444; font-weight: bold',
'color: inherit',
eventType,
error,
)
}
}
/**
* Hooks the WebSocket to intercept character data
* @returns {void}
*/
function hookWS() {
const dataProperty = Object.getOwnPropertyDescriptor(
MessageEvent.prototype,
'data',
)
const oriGet = dataProperty.get
dataProperty.get = hookedGet
Object.defineProperty(MessageEvent.prototype, 'data', dataProperty)
function hookedGet() {
const socket = this.currentTarget
if (!(socket instanceof WebSocket)) {
return oriGet.call(this)
}
if (
socket.url.indexOf('api.milkywayidle.com/ws') <= -1 &&
socket.url.indexOf('api-test.milkywayidle.com/ws') <= -1
) {
return oriGet.call(this)
}
const message = oriGet.call(this)
Object.defineProperty(this, 'data', { value: message }) // Anti-loop
return handleMessage(message)
}
}
/**
* Handles WebSocket messages and dispatches events
* @param {string} message - The message to handle
* @returns {Object} The message
*/
function handleMessage(message) {
try {
const obj = JSON.parse(message)
if (obj.type === 'init_character_data') {
console.log(
'%c[MWIMarket]%c Character data received from WebSocket',
'color: #44ff44; font-weight: bold',
'color: inherit',
)
// Store raw data
GM_setValue(CONFIG.storage.CHAR_DATA, obj)
GM_setValue(CONFIG.storage.LAST_SYNC, Date.now())
// Filter and validate data
const filteredData = filterCharacterData(obj)
if (filteredData && validateCharacterData(filteredData)) {
// Dispatch event with filtered data
dispatchDataEvent(CONFIG.events.DATA_READY, {
data: filteredData,
source: 'websocket',
})
} else {
dispatchDataEvent(CONFIG.events.ERROR, {
error: 'Invalid character data structure',
timestamp: Date.now(),
})
}
}
} catch (error) {
console.error(
'%c[MWIMarket]%c Error handling WebSocket message:',
'color: #ff4444; font-weight: bold',
'color: inherit',
error,
)
}
return message
}
/**
* Sets up event listeners for data requests
* @returns {void}
*/
function setupEventListeners() {
// Listen for data requests
window.addEventListener(CONFIG.events.DATA_REQUEST, (event) => {
console.log(
'%c[MWIMarket]%c Received data request:',
'color: #4444ff; font-weight: bold',
'color: inherit',
event.detail,
)
// Get latest data
const rawData = GM_getValue(CONFIG.storage.CHAR_DATA, null)
const lastSync = GM_getValue(CONFIG.storage.LAST_SYNC, 0)
if (rawData) {
const filteredData = filterCharacterData(rawData)
if (filteredData && validateCharacterData(filteredData)) {
dispatchDataEvent(CONFIG.events.DATA_RESPONSE, {
requestId: event.detail?.requestId,
data: filteredData,
lastSync: lastSync,
source: 'storage',
})
} else {
dispatchDataEvent(CONFIG.events.ERROR, {
requestId: event.detail?.requestId,
error: 'No valid character data available',
timestamp: Date.now(),
})
}
} else {
dispatchDataEvent(CONFIG.events.ERROR, {
requestId: event.detail?.requestId,
error: 'No character data found',
timestamp: Date.now(),
})
}
})
// Listen for cross-tab data changes
GM_addValueChangeListener(
CONFIG.storage.CHAR_DATA,
(name, oldValue, newValue, remote) => {
if (remote && newValue) {
console.log(
'%c[MWIMarket]%c Character data updated in another tab',
'color: #44bbff; font-weight: bold',
'color: inherit',
)
const filteredData = filterCharacterData(newValue)
if (filteredData && validateCharacterData(filteredData)) {
dispatchDataEvent(CONFIG.events.DATA_UPDATED, {
data: filteredData,
source: 'cross-tab',
})
}
}
},
)
}
/**
* Market site specific functionality
* @returns {void}
*/
function initMarketSite() {
console.log(
'%c[MWIMarket]%c Initializing addon for market site',
'color: #ff44ff; font-weight: bold',
'color: inherit',
)
// Listen for character data events
window.addEventListener(CONFIG.events.DATA_READY, (event) => {
console.log(
'%c[MWIMarket]%c Character data ready on market site:',
'color: #44ff44; font-weight: bold',
'color: inherit',
event.detail,
)
// Market site can now use the data
processMarketData(event.detail.data)
})
window.addEventListener(CONFIG.events.DATA_RESPONSE, (event) => {
console.log(
'%c[MWIMarket]%c Character data response received:',
'color: #44ff44; font-weight: bold',
'color: inherit',
event.detail,
)
processMarketData(event.detail.data)
})
window.addEventListener(CONFIG.events.DATA_UPDATED, (event) => {
console.log(
'%c[MWIMarket]%c Character data updated:',
'color: #44bbff; font-weight: bold',
'color: inherit',
event.detail,
)
processMarketData(event.detail.data)
})
window.addEventListener(CONFIG.events.ERROR, (event) => {
console.error(
'%c[MWIMarket]%c Data error:',
'color: #ff4444; font-weight: bold',
'color: inherit',
event.detail,
)
})
}
/**
* Process character data on market site
* @param {Object} data - The character data to process
* @returns {void}
*/
function processMarketData(data) {
if (!data) return
// Store in localStorage for backward compatibility
localStorage.setItem(
'@milkiway-market/character-data',
JSON.stringify(data),
)
// Dispatch a custom event that the market site can listen to
window.dispatchEvent(
new CustomEvent('mwi-market-data-processed', {
detail: { data },
bubbles: true,
}),
)
}
/**
* Initialize based on current site
* @returns {void}
*/
function init() {
const isMarketSite = document.URL.includes('milkyway.market')
const isGameSite =
document.URL.includes('milkywayidle.com') &&
!document.URL.includes('milkyway.market')
if (isGameSite) {
console.log(
'%c[MWIMarket]%c Initializing addon for game site',
'color: #ff44ff; font-weight: bold',
'color: inherit',
)
hookWS()
} else if (isMarketSite) {
console.log(
'%c[MWIMarket]%c Initializing addon for market site',
'color: #ff44ff; font-weight: bold',
'color: inherit',
)
initMarketSite()
}
// Set up event listeners on both sites
if (isGameSite || isMarketSite) {
setupEventListeners()
}
}
// Initialize the addon
init()
})()