Greasy Fork

Greasy Fork is available in English.

MWI 市场插件

事件驱动的插件,用于在 MWI 和 Milkyway Market 之间实现无缝数据同步

// ==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()
})()