diff --git a/QuestTracker/1.2/QuestTracker.js b/QuestTracker/1.2/QuestTracker.js new file mode 100644 index 000000000..eb22a3615 --- /dev/null +++ b/QuestTracker/1.2/QuestTracker.js @@ -0,0 +1,6725 @@ +// Github: https://github.com/boli32/QuestTracker/blob/main/QuestTracker.js +// By: Boli (Steven Wrighton): Professional Software Developer, Enthusiatic D&D Player since 1993. +// Contact: https://app.roll20.net/users/3714078/boli +// Readme https://github.com/boli32/QuestTracker/blob/main/README.md + + +var QuestTracker = QuestTracker || (function () { + 'use strict'; + const getCalendarAndWeatherData = () => { + let CALENDARS = {}; + let WEATHER = {}; + if (state.CalenderData) { + if (state.CalenderData.CALENDARS) CALENDARS = state.CalenderData.CALENDARS; + if (state.CalenderData.WEATHER) WEATHER = state.CalenderData.WEATHER; + } + if (state.QUEST_TRACKER?.calendar) Object.assign(CALENDARS, state.QUEST_TRACKER.calendar); + return { CALENDARS, WEATHER }; + }; + const { CALENDARS, WEATHER } = getCalendarAndWeatherData(); + const statusMapping = { + 1: 'Unknown', + 2: 'Discovered', + 3: 'Started', + 4: 'Ongoing', + 5: 'Completed', + 6: 'Completed By Someone Else', + 7: 'Failed', + 8: 'Time ran out', + 9: 'Ignored' + }; + const frequencyMapping = { + 1: "Daily", + 2: "Weekly", + 3: "Monthly", + 4: "Yearly" + } + let QUEST_TRACKER_verboseErrorLogging = true; + let QUEST_TRACKER_globalQuestData = {}; + let QUEST_TRACKER_globalQuestArray = []; + let QUEST_TRACKER_globalRumours = {}; + let QUEST_TRACKER_Events = {}; + let QUEST_TRACKER_Calendar = {}; + let QUEST_TRACKER_Triggers = {}; + let QUEST_TRIGGER_DeleteList = []; + let QUEST_TRACKER_versionChecking = { + TriggerConversion: false, + RumourConversion: false, + EventConversion: false, + EffectConversion: false + } + let QUEST_TRACKER_QuestHandoutName = "QuestTracker Quests"; + let QUEST_TRACKER_RumourHandoutName = "QuestTracker Rumours"; + let QUEST_TRACKER_EventHandoutName = "QuestTracker Events"; + let QUEST_TRACKER_WeatherHandoutName = "QuestTracker Weather"; + let QUEST_TRACKER_CalendarHandoutName = "QuestTracker Calendar"; + let QUEST_TRACKER_TriggersHandoutName = "QuestTracker Triggers"; + let QUEST_TRACKER_rumoursByLocation = {}; + let QUEST_TRACKER_readableJSON = true; + let QUEST_TRACKER_pageName = "Quest Tree Page"; + let QUEST_TRACKER_TreeObjRef = {}; + let QUEST_TRACKER_questGrid = []; + let QUEST_TRACKER_jumpGate = true; + let QUEST_TRACKER_BASE_QUEST_ICON_URL = ''; // add your own image here. + let QUEST_TRACKER_ROLLABLETABLE_QUESTS = "qt-quests"; + let QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS = "qt-quest-groups"; + let QUEST_TRACKER_ROLLABLETABLE_LOCATIONS = "qt-locations"; + let QUEST_TRACKER_calenderType = 'gregorian'; + let QUEST_TRACKER_currentDate = CALENDARS[QUEST_TRACKER_calenderType]?.defaultDate; + let QUEST_TRACKER_defaultDate = CALENDARS[QUEST_TRACKER_calenderType]?.defaultDate; + let QUEST_TRACKER_currentWeekdayName = "Thursday"; + let QUEST_TRACKER_Location = 'northern temperate'; + let QUEST_TRACKER_WeatherLocation = 'plains'; + let QUEST_TRACKER_CURRENT_WEATHER = ""; + let QUEST_TRACKER_FILTER = {}; + let QUEST_TRACKER_RUMOUR_FILTER = {}; + let QUEST_TRACKER_FILTER_Visbility = false; + let QUEST_TRACKER_imperialMeasurements = { + temperature: false, + precipitation: false, + wind: true, + visibility: true + }; + let QUEST_TRACKER_WEATHER_TRENDS = { + dry: 0, + wet: 0, + heat: 0, + cold: 0, + wind: 0, + humid: 0, + visibility: 0, + cloudy: 0 + }; + let QUEST_TRACKER_FORCED_WEATHER_TRENDS = { + dry: false, + wet: false, + heat: false, + cold: false, + wind: false, + humid: false, + visibility: false, + cloudy: false + }; + let QUEST_TRACKER_HISTORICAL_WEATHER = {}; + let QUEST_TRACKER_WEATHER_DESCRIPTION = {}; + let QUEST_TRACKER_WEATHER = true; + const loadQuestTrackerData = () => { + initializeQuestTrackerState(); + QUEST_TRACKER_verboseErrorLogging = state.QUEST_TRACKER.verboseErrorLogging || true; + QUEST_TRACKER_globalQuestData = state.QUEST_TRACKER.globalQuestData; + QUEST_TRACKER_globalQuestArray = state.QUEST_TRACKER.globalQuestArray; + QUEST_TRACKER_globalRumours = state.QUEST_TRACKER.globalRumours; + QUEST_TRACKER_rumoursByLocation = state.QUEST_TRACKER.rumoursByLocation; + QUEST_TRACKER_readableJSON = state.QUEST_TRACKER.readableJSON || true; + QUEST_TRACKER_TreeObjRef = state.QUEST_TRACKER.TreeObjRef || {}; + QUEST_TRACKER_questGrid = state.QUEST_TRACKER.questGrid || []; + QUEST_TRACKER_jumpGate = state.QUEST_TRACKER.jumpGate || true; + QUEST_TRACKER_Events = state.QUEST_TRACKER.events || {}; + QUEST_TRACKER_Calendar = state.QUEST_TRACKER.calendar || {}; + QUEST_TRACKER_Triggers = state.QUEST_TRACKER.triggers || {}; + QUEST_TRACKER_versionChecking = state.QUEST_TRACKER.versionChecking || { + TriggerConversion: false, + RumourConversion: false, + EventConversion: false, + EffectConversion: false + } + QUEST_TRACKER_calenderType = state.QUEST_TRACKER.calenderType || 'gregorian'; + QUEST_TRACKER_currentDate = state.QUEST_TRACKER.currentDate || CALENDARS[QUEST_TRACKER_calenderType]?.defaultDate + QUEST_TRACKER_defaultDate = state.QUEST_TRACKER.defaultDate || CALENDARS[QUEST_TRACKER_calenderType]?.defaultDate + QUEST_TRACKER_Location = state.QUEST_TRACKER.location || 'northern temperate'; + QUEST_TRACKER_WeatherLocation = state.QUEST_TRACKER.weatherLocation || 'plains'; + QUEST_TRACKER_currentWeekdayName = state.QUEST_TRACKER.currentWeekdayName || 'Thursday'; + QUEST_TRACKER_FILTER = state.QUEST_TRACKER.filter || {}; + QUEST_TRACKER_RUMOUR_FILTER = state.QUEST_TRACKER.rumourFilter || {}; + QUEST_TRACKER_FILTER_Visbility = state.QUEST_TRACKER.filterVisibility || false; + QUEST_TRACKER_WEATHER_TRENDS = state.QUEST_TRACKER.weatherTrends || { + dry: 0, + wet: 0, + heat: 0, + cold: 0, + wind: 0, + humid: 0, + visibility: 0, + cloudy: 0 + }; + QUEST_TRACKER_FORCED_WEATHER_TRENDS = state.QUEST_TRACKER.forcedWeatherTrends || { + dry: false, + wet: false, + heat: false, + cold: false, + wind: false, + humid: false, + visibility: false, + cloudy: false + }; + QUEST_TRACKER_CURRENT_WEATHER = state.QUEST_TRACKER.currentWeather; + QUEST_TRACKER_HISTORICAL_WEATHER = state.QUEST_TRACKER.historicalWeather || {}; + QUEST_TRACKER_WEATHER_DESCRIPTION = state.QUEST_TRACKER.weatherDescription || {}; + QUEST_TRACKER_WEATHER = state.QUEST_TRACKER.weather || true; + QUEST_TRACKER_imperialMeasurements = state.QUEST_TRACKER.imperialMeasurements || { + temperature: false, + precipitation: false, + wind: true, + visibility: true + } + }; + const checkVersion = () => { + if (!QUEST_TRACKER_versionChecking.TriggerConversion) Triggers.convertAutoAdvanceToTriggers(); + if (!QUEST_TRACKER_versionChecking.RumourConversion) Rumours.convertRumoursToNewFormat(); + if (!QUEST_TRACKER_versionChecking.EventConversion) Calendar.convertEventsToNewFormat(); + if (!QUEST_TRACKER_versionChecking.EffectConversion) Triggers.convertEffectsToNewFormat(); + }; + const saveQuestTrackerData = () => { + state.QUEST_TRACKER.verboseErrorLogging = QUEST_TRACKER_verboseErrorLogging; + state.QUEST_TRACKER.globalQuestData = QUEST_TRACKER_globalQuestData; + state.QUEST_TRACKER.globalQuestArray = QUEST_TRACKER_globalQuestArray; + state.QUEST_TRACKER.globalRumours = QUEST_TRACKER_globalRumours; + state.QUEST_TRACKER.rumoursByLocation = QUEST_TRACKER_rumoursByLocation; + state.QUEST_TRACKER.readableJSON = QUEST_TRACKER_readableJSON; + state.QUEST_TRACKER.questGrid = QUEST_TRACKER_questGrid; + state.QUEST_TRACKER.jumpGate = QUEST_TRACKER_jumpGate; + state.QUEST_TRACKER.events = QUEST_TRACKER_Events; + state.QUEST_TRACKER.calendar = QUEST_TRACKER_Calendar; + state.QUEST_TRACKER.triggers = QUEST_TRACKER_Triggers; + state.QUEST_TRACKER.versionChecking = QUEST_TRACKER_versionChecking; + state.QUEST_TRACKER.currentDate = QUEST_TRACKER_currentDate; + state.QUEST_TRACKER.defaultDate = QUEST_TRACKER_defaultDate; + state.QUEST_TRACKER.calenderType = QUEST_TRACKER_calenderType; + state.QUEST_TRACKER.location = QUEST_TRACKER_Location; + state.QUEST_TRACKER.weatherLocation = QUEST_TRACKER_WeatherLocation; + state.QUEST_TRACKER.currentWeekdayName = QUEST_TRACKER_currentWeekdayName; + state.QUEST_TRACKER.currentWeather = QUEST_TRACKER_CURRENT_WEATHER; + state.QUEST_TRACKER.weatherTrends = QUEST_TRACKER_WEATHER_TRENDS; + state.QUEST_TRACKER.forcedWeatherTrends = QUEST_TRACKER_FORCED_WEATHER_TRENDS; + state.QUEST_TRACKER.historicalWeather = QUEST_TRACKER_HISTORICAL_WEATHER; + state.QUEST_TRACKER.weatherDescription = QUEST_TRACKER_WEATHER_DESCRIPTION; + state.QUEST_TRACKER.weather = QUEST_TRACKER_WEATHER; + state.QUEST_TRACKER.imperialMeasurements = QUEST_TRACKER_imperialMeasurements; + state.QUEST_TRACKER.TreeObjRef = QUEST_TRACKER_TreeObjRef; + state.QUEST_TRACKER.filter = QUEST_TRACKER_FILTER; + state.QUEST_TRACKER.rumourFilter = QUEST_TRACKER_RUMOUR_FILTER; + state.QUEST_TRACKER.filterVisibility = QUEST_TRACKER_FILTER_Visbility; + }; + const initializeQuestTrackerState = (forced = false) => { + if (!state.QUEST_TRACKER || Object.keys(state.QUEST_TRACKER).length === 0 || forced) { + state.QUEST_TRACKER = { + verboseErrorLogging: true, + globalQuestData: {}, + globalQuestArray: [], + globalRumours: {}, + rumoursByLocation: {}, + generations: {}, + readableJSON: true, + TreeObjRef: {}, + jumpGate: true, + events: {}, + calendar: {}, + triggers: {}, + versionChecking: { + TriggerConversion: false, + RumourConversion: false, + EventConversion: false, + EffectConversion: false + }, + calenderType: 'gregorian', + currentDate: CALENDARS[QUEST_TRACKER_calenderType]?.defaultDate, + defaultDate: CALENDARS[QUEST_TRACKER_calenderType]?.defaultDate, + location: 'northern temperate', + weatherLocation: 'plains', + currentWeather: null, + weatherTrends: { + dry: 0, + wet: 0, + heat: 0, + cold: 0 + }, + forcedWeatherTrends: { + dry: false, + wet: false, + heat: false, + cold: false + }, + historicalWeather: {}, + weather: true, + imperialMeasurements: { + temperature: false, + precipitation: false, + wind: true, + visibility: true + }, + filter: {}, + rumourFilter: {}, + filterVisibility: false + }; + if (!findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]) { + const tableQuests = createObj('rollabletable', { name: QUEST_TRACKER_ROLLABLETABLE_QUESTS }); + tableQuests.set('showplayers', false); + } + if (!findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS })[0]) { + const tableQuestGroups = createObj('rollabletable', { name: QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS }); + tableQuestGroups.set('showplayers', false); + } + let locationTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS })[0]; + if (!locationTable) { + locationTable = createObj('rollabletable', { name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS }); + locationTable.set('showplayers', false); + createObj('tableitem', { + _rollabletableid: locationTable.id, + name: 'Everywhere', + weight: 1 + }); + } + if (!findObjs({ type: 'handout', name: QUEST_TRACKER_QuestHandoutName })[0]) { + createObj('handout', { name: QUEST_TRACKER_QuestHandoutName }); + } + if (!findObjs({ type: 'handout', name: QUEST_TRACKER_RumourHandoutName })[0]) { + createObj('handout', { name: QUEST_TRACKER_RumourHandoutName }); + } + if (!findObjs({ type: 'handout', name: QUEST_TRACKER_EventHandoutName })[0]) { + createObj('handout', { name: QUEST_TRACKER_EventHandoutName }); + } + if (!findObjs({ type: 'handout', name: QUEST_TRACKER_WeatherHandoutName })[0]) { + createObj('handout', { name: QUEST_TRACKER_WeatherHandoutName }); + } + if (!findObjs({ type: 'handout', name: QUEST_TRACKER_CalendarHandoutName })[0]) { + createObj('handout', { name: QUEST_TRACKER_CalendarHandoutName }); + } + if (!findObjs({ type: 'handout', name: QUEST_TRACKER_TriggersHandoutName })[0]) { + createObj('handout', { name: QUEST_TRACKER_TriggersHandoutName }); + } + Utils.sendGMMessage("QuestTracker has been initialized."); + } + }; + const Utils = (() => { + const H = { + checkType: (input) => { + if (typeof input === 'string') { + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return 'DATE'; + } + return 'STRING'; + } else if (typeof input === 'boolean') { + return 'BOOLEAN'; + } else if (typeof input === 'number') { + return Number.isInteger(input) ? 'INT' : 'STRING'; + } else if (Array.isArray(input)) { + return 'ARRAY'; + } else if (typeof input === 'object' && input !== null) { + return 'OBJECT'; + } else { + return 'STRING'; + } + } + }; + const sendGMMessage = (message) => { + sendChat('Quest Tracker', `/w gm ${message}`, null, { noarchive: true }); + }; + const sendMessage = (message) => { + sendChat('Quest Tracker', `${message}`); + }; + const sendDescMessage = (message) => { + sendChat('', `/desc ${message}`); + }; + const normalizeKeys = (obj) => { + if (typeof obj !== 'object' || obj === null) return obj; + if (Array.isArray(obj)) return obj.map(item => normalizeKeys(item)); + return Object.keys(obj).reduce((acc, key) => { + const normalizedKey = key.toLowerCase(); + acc[normalizedKey] = normalizeKeys(obj[key]); + return acc; + }, {}); + }; + const stripJSONContent = (content) => { + content = content + .replace(/
/gi, '') + .replace(/<\/?[^>]+(>|$)/g, '') + .replace(/ /gi, ' ') + .replace(/&[a-z]+;/gi, ' ') + .replace(/\+/g, '') + .replace(/[\r\n]+/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); + const start = content.indexOf('{'); + const end = content.lastIndexOf('}'); + if (start === -1 || end === -1) { + log('Error: Valid JSON structure not found after stripping.'); + return '{}'; + } + const jsonContent = content.substring(start, end + 1).trim(); + return jsonContent; + }; + const updateHandoutField = (dataType = 'quest') => { + let handoutName; + switch (dataType.toLowerCase()) { + case 'rumour': + handoutName = QUEST_TRACKER_RumourHandoutName; + break; + case 'event': + handoutName = QUEST_TRACKER_EventHandoutName; + break; + case 'weather': + handoutName = QUEST_TRACKER_WeatherHandoutName; + break; + case 'quest': + handoutName = QUEST_TRACKER_QuestHandoutName; + break; + case 'calendar': + handoutName = QUEST_TRACKER_CalendarHandoutName; + break; + case 'triggers': + handoutName = QUEST_TRACKER_TriggersHandoutName; + break; + default: + return; + } + const handout = findObjs({ type: 'handout', name: handoutName })[0]; + if (errorCheck(146, 'exists', handout,'handout')) return; + handout.get('gmnotes', (notes) => { + const cleanedContent = Utils.stripJSONContent(notes); + let data; + try { + data = JSON.parse(cleanedContent); + data = normalizeKeys(data); + } catch (error) { + errorCheck(5, 'msg', null,`Failed to parse JSON data from GM notes: ${error.message}`); + return; + } + let updatedData; + switch (dataType.toLowerCase()) { + case 'rumour': + updatedData = QUEST_TRACKER_globalRumours; + break; + case 'event': + updatedData = QUEST_TRACKER_Events; + break; + case 'weather': + updatedData = QUEST_TRACKER_HISTORICAL_WEATHER; + break; + case 'calendar': + updatedData = QUEST_TRACKER_Calendar; + break; + case 'quest': + updatedData = QUEST_TRACKER_globalQuestData; + break; + case 'triggers': + updatedData = QUEST_TRACKER_Triggers; + break; + default: + return; + } + const updatedContent = QUEST_TRACKER_readableJSON + ? JSON.stringify(updatedData, null, 2) + .replace(/\n/g, '
') + .replace(/ {2}/g, '  ') + : JSON.stringify(updatedData); + handout.set('gmnotes', updatedContent, (err) => { + if (err) { + errorCheck(6, 'msg', null,`Failed to update GM notes for "${handoutName}": ${err.message}`); + switch (dataType.toLowerCase()) { + case 'rumour': + QUEST_TRACKER_globalRumours = JSON.parse(cleanedContent); + break; + case 'event': + QUEST_TRACKER_Events = JSON.parse(cleanedContent); + break; + case 'weather': + QUEST_TRACKER_HISTORICAL_WEATHER = JSON.parse(cleanedContent); + break; + case 'calendar': + QUEST_TRACKER_Calendar = JSON.parse(cleanedContent); + break; + case 'quest': + QUEST_TRACKER_globalQuestData = JSON.parse(cleanedContent); + break; + case 'triggers': + QUEST_TRACKER_TriggersHandoutName = JSON.parse(cleanedContent); + break; + default: + return; + } + } + }); + }); + saveQuestTrackerData(); + if (dataType === 'rumours') { + Rumours.calculateRumoursByLocation(); + } + }; + const togglereadableJSON = (value) => { + QUEST_TRACKER_readableJSON = (value === 'true'); + saveQuestTrackerData(); + updateHandoutField('quest'); + updateHandoutField('rumour'); + updateHandoutField('event'); + updateHandoutField('weather'); + updateHandoutField('calendar'); + updateHandoutField('triggers'); + }; + const toggleWeather = (value) => { + QUEST_TRACKER_WEATHER = (value === 'true'); + saveQuestTrackerData(); + }; + const toggleJumpGate = (value) => { + QUEST_TRACKER_jumpGate = (value === 'true'); + saveQuestTrackerData(); + }; + const toggleVerboseError = (value) => { + QUEST_TRACKER_verboseErrorLogging = (value === 'true'); + saveQuestTrackerData(); + }; + const toggleImperial = (type, value) => { + QUEST_TRACKER_imperialMeasurements[type] = (value === 'true'); + saveQuestTrackerData(); + }; + const toggleFilterVisibility = (value) => { + QUEST_TRACKER_FILTER_Visbility = (value === 'true'); + saveQuestTrackerData(); + }; + const sanitizeString = (input) => { + if (typeof input !== 'string') { + Utils.sendGMMessage('Error: Expected a string input.'); + return null; + } + const sanitizedString = input.replace(/[^a-zA-Z0-9_ ]/g, '_'); + return sanitizedString; + }; + const roll20MacroSanitize = (text) => { + return text + .replace(/\|/g, '|') + .replace(/,/g, ',') + .replace(/{/g, '{') + .replace(/}/g, '}') + .replace(/&/g, '&') + .replace(/ /g, ' ') + .replace(/=/g, '=') + .replace(/_/g, '_') + .replace(/\(/g, '(') + .replace(/\)/g, ')') + .replace(/\[/g, '[') + .replace(/\]/g, ']') + .replace(//g, '>') + .replace(/`/g, '`') + .replace(/\*/g, '*') + .replace(/!/g, '!') + .replace(/"/g, '"') + .replace(/#/g, '#') + .replace(/-/g, '-') + .replace(/@/g, '@') + .replace(/%/g, '%'); + }; + const inputAlias = (command) => { + const aliases = { + '!qt': '!qt-menu action=main', + '!qt-date advance': '!qt-date action=modify|unit=day|new=1', + '!qt-date retreat': '!qt-date action=modify|unit=day|new=-1' + }; + return aliases[command] || command; + }; + const getNestedProperty = (obj, path) => { + const keys = path.split('.'); + return keys.reduce((current, key) => (current && current[key] !== undefined ? current[key] : null), obj); + } + return { + sendGMMessage, + sendDescMessage, + sendMessage, + normalizeKeys, + stripJSONContent, + roll20MacroSanitize, + updateHandoutField, + togglereadableJSON, + toggleWeather, + toggleJumpGate, + toggleVerboseError, + toggleImperial, + toggleFilterVisibility, + sanitizeString, + inputAlias, + getNestedProperty + }; + })(); + const Import = (() => { + const H = { + importData: (handoutName, dataType) => { + let handout = findObjs({ type: 'handout', name: handoutName })[0]; + if (!handout) { + createObj('handout', { name: handoutName }); + } + handout.get('gmnotes', (notes) => { + const cleanedContent = Utils.stripJSONContent(notes); + try { + let parsedData = JSON.parse(cleanedContent); + const convertKeysToLowerCase = (obj) => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(item => convertKeysToLowerCase(item)); + } + return Object.keys(obj).reduce((acc, key) => { + const lowercaseKey = key.toLowerCase(); + acc[lowercaseKey] = convertKeysToLowerCase(obj[key]); + return acc; + }, {}); + }; + parsedData = convertKeysToLowerCase(parsedData); + if (dataType === 'Quest') { + parsedData = Utils.normalizeKeys(parsedData); + QUEST_TRACKER_globalQuestArray = []; + Object.keys(parsedData).forEach((questId) => { + const quest = parsedData[questId]; + quest.relationships = quest.relationships || { logic: 'AND', conditions: [] }; + QUEST_TRACKER_globalQuestArray.push({ id: questId, weight: quest.weight || 1 }); + }); + QUEST_TRACKER_globalQuestData = parsedData; + } else if (dataType === 'Rumour') { + parsedData = Utils.normalizeKeys(parsedData); + Object.keys(parsedData).forEach((questId) => { + Object.keys(parsedData[questId]).forEach((status) => { + Object.keys(parsedData[questId][status]).forEach((location) => { + let rumours = parsedData[questId][status][location]; + if (typeof rumours === 'object' && !Array.isArray(rumours)) { + parsedData[questId][status][location] = rumours; + } else { + parsedData[questId][status][location] = {}; + } + }); + }); + }); + QUEST_TRACKER_globalRumours = parsedData; + Rumours.calculateRumoursByLocation(); + } else if (dataType === 'Events') { + parsedData = Utils.normalizeKeys(parsedData); + QUEST_TRACKER_Events = parsedData; + } else if (dataType === 'Weather') { + parsedData = Utils.normalizeKeys(parsedData); + QUEST_TRACKER_HISTORICAL_WEATHER = parsedData; + } else if (dataType === 'Calendar') { + parsedData = Utils.normalizeKeys(parsedData); + QUEST_TRACKER_Calendar = parsedData; + } else if (dataType === 'Triggers') { + parsedData = Utils.normalizeKeys(parsedData); + QUEST_TRACKER_Triggers = parsedData; + } + saveQuestTrackerData(); + Utils.sendGMMessage(`${dataType} handout "${handoutName}" Imported.`); + } catch (error) { + errorCheck(8, 'msg', null,`Error parsing ${dataType} data: ${error.message}`); + } + }); + }, + syncQuestRollableTable: () => { + let questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + const questTableItems = findObjs({ type: 'tableitem', rollabletableid: questTable.id }); + const tableItemMap = {}; + questTableItems.forEach(item => { + tableItemMap[item.get('name')] = item; + }); + const questIdsInGlobalData = Object.keys(QUEST_TRACKER_globalQuestData); + questIdsInGlobalData.forEach(questId => { + if (!tableItemMap[questId]) { + createObj('tableitem', { + rollabletableid: questTable.id, + name: questId, + weight: 1 + }); + } + }); + questTableItems.forEach(item => { + const questId = item.get('name'); + if (!QUEST_TRACKER_globalQuestData[questId]) { + item.remove(); + } + }); + }, + validateRelationships: (relationships, questId) => { + const questName = questId.toLowerCase(); + const validateNestedConditions = (conditions) => { + if (!Array.isArray(conditions)) return true; + return conditions.every(condition => { + if (typeof condition === 'string') { + const lowerCondition = condition.toLowerCase(); + if (errorCheck(9, 'exists', QUEST_TRACKER_globalQuestData.hasOwnProperty(lowerCondition),`QUEST_TRACKER_globalQuestData.hasOwnProperty(${lowerCondition})`)) return false; + return true; + } else if (typeof condition === 'object' && condition.logic && Array.isArray(condition.conditions)) { + return validateNestedConditions(condition.conditions); + } + return false; + }); + }; + const conditionsValid = validateNestedConditions(relationships.conditions || []); + const mutuallyExclusive = Array.isArray(relationships.mutually_exclusive) + ? relationships.mutually_exclusive.map(exclusive => exclusive.toLowerCase()) + : []; + mutuallyExclusive.forEach(exclusive => { + if (errorCheck(10, 'exists', QUEST_TRACKER_globalQuestData.hasOwnProperty(exclusive),`QUEST_TRACKER_globalQuestData.hasOwnProperty(${exclusive})`)) return true; + else return false; + }); + }, + cleanUpDataFields: () => { + Object.keys(QUEST_TRACKER_globalQuestData).forEach(questId => { + const quest = QUEST_TRACKER_globalQuestData[questId]; + H.validateRelationships(quest.relationships || {}, questId); + }); + saveQuestTrackerData(); + Utils.updateHandoutField('quest'); + }, + refreshCalendarData: () => { + Object.keys(CALENDARS).forEach(key => delete CALENDARS[key]); + Object.assign(CALENDARS, state.CalenderData.CALENDARS); + Object.assign(CALENDARS, state.QUEST_TRACKER.calendar); + } + }; + const fullImportProcess = () => { + H.importData(QUEST_TRACKER_QuestHandoutName, 'Quest'); + H.importData(QUEST_TRACKER_RumourHandoutName, 'Rumour'); + H.importData(QUEST_TRACKER_EventHandoutName, 'Events'); + H.importData(QUEST_TRACKER_WeatherHandoutName, 'Weather'); + H.importData(QUEST_TRACKER_CalendarHandoutName, 'Calendar'); + H.importData(QUEST_TRACKER_TriggersHandoutName, 'Triggers'); + H.syncQuestRollableTable(); + Quest.cleanUpLooseEnds(); + H.cleanUpDataFields(); + H.refreshCalendarData(); + }; + return { + fullImportProcess + }; + })(); + const Triggers = (() => { + const H = { + generateNewTriggerId: () => { + const allTriggerIds = Object.entries(QUEST_TRACKER_Triggers).flatMap(([category, triggerGroups]) => { + if (category === "scripts") { + return Object.keys(triggerGroups); + } + return Object.values(triggerGroups).flatMap(triggerGroup => Object.keys(triggerGroup)); + }); + const highestTriggerNumber = allTriggerIds.reduce((max, id) => { + const match = id.match(/^trigger_(\d+)$/); + if (match) { + const number = parseInt(match[1], 10); + return number > max ? number : max; + } + return max; + }, 0); + const newTriggerNumber = highestTriggerNumber + 1; + return `trigger_${newTriggerNumber}`; + }, + generateNewEffectId: () => { + const triggers = QUEST_TRACKER_Triggers; + const allIds = Object.entries(triggers).flatMap(([category, triggerGroups]) => + category === "scripts" + ? Object.values(triggerGroups).filter(trigger => trigger.effects).flatMap(trigger => Object.keys(trigger.effects)) + : Object.values(triggerGroups).flatMap(triggerGroup => + Object.values(triggerGroup) + .filter(trigger => trigger.effects) + .flatMap(trigger => Object.keys(trigger.effects)) + ) + ); + const highestIdNumber = allIds.reduce((max, id) => { + const match = id.match(/^effect_(\d+)$/); + if (match) { + const number = parseInt(match[1], 10); + return number > max ? number : max; + } + return max; + }, 0); + const newIdNumber = highestIdNumber + 1; + return `effect_${newIdNumber}`; + }, + saveData: () => { + saveQuestTrackerData(); + Utils.updateHandoutField('triggers'); + }, + getTargetStructure: (type) => { + const structures = { + quest: QUEST_TRACKER_Triggers.quests, + date: QUEST_TRACKER_Triggers.dates, + reaction: QUEST_TRACKER_Triggers.reactions, + rumour: QUEST_TRACKER_Triggers.rumours, + event: QUEST_TRACKER_Triggers.events, + script: QUEST_TRACKER_Triggers.scripts, + }; + return structures[type] || null; + }, + cleanUpEmptyKeys: () => { + const targets = [ + 'quests.null', + 'dates.null', + 'reactions.null', + 'events.null', + 'rumours.null', + ]; + targets.forEach((path) => { + const pathParts = path.split('.'); + let current = QUEST_TRACKER_Triggers; + for (let i = 0; i < pathParts.length - 1; i++) { + current = current[pathParts[i]]; + } + const lastKey = pathParts[pathParts.length - 1]; + if (current[lastKey] && Object.keys(current[lastKey]).length === 0) delete current[lastKey]; + }); + }, + deleteEffectTriggers: (triggerId) => { + Object.entries(QUEST_TRACKER_Triggers).forEach(([category, parentObjects]) => { + if (category === "scripts") { + Object.entries(parentObjects).forEach(([scriptTriggerId, scriptTrigger]) => { + if (!scriptTrigger.effects) return; + Object.entries(scriptTrigger.effects).forEach(([effectId, effect]) => { + if (effect.effecttype === "trigger" && effect.id === triggerId) { + manageEffect(scriptTriggerId, effectId, "delete"); + } + }); + }); + return; + } + Object.entries(parentObjects).forEach(([parentId, triggers]) => { + Object.entries(triggers).forEach(([triggerKey, trigger]) => { + if (!trigger.effects) return; + Object.entries(trigger.effects).forEach(([effectId, effect]) => { + if (effect.effecttype === "trigger" && effect.id === triggerId) { + manageEffect(triggerKey, effectId, "delete"); + } + }); + }); + }); + }); + }, + fireTrigger: (triggerId) => { + const triggerPath = Triggers.locateItem(triggerId, "trigger"); + if (!triggerPath) return; + const trigger = Utils.getNestedProperty(QUEST_TRACKER_Triggers, triggerPath.replace("QUEST_TRACKER_Triggers.", "")); + if (!trigger || !trigger.effects || Object.keys(trigger.effects).length === 0) return; + Object.entries(trigger.effects).forEach(([effectId, effect]) => { + const { id, type, value, effecttype = "quest" } = effect; + switch (effecttype) { + case "quest": + Quest.manageQuestObject({ + action: "update", + field: type, + current: id, + old: null, + newItem: value, + }); + break; + case "event": + Calendar.manageEventObject({ + action: "update", + field: type, + current: id, + old: null, + newItem: value, + date: null + }); + break; + case "trigger": + switch(type){ + case 'delete': + deleteTrigger(id); + break; + case 'active': + Triggers.toggleTrigger(type, id, value); + break; + } + break; + } + }); + QUEST_TRIGGER_DeleteList.push(triggerId); + } + } + const initializeTriggersStructure = () => { + if (!QUEST_TRACKER_Triggers.quests) QUEST_TRACKER_Triggers.quests = {}; + if (!QUEST_TRACKER_Triggers.dates) QUEST_TRACKER_Triggers.dates = {}; + if (!QUEST_TRACKER_Triggers.reactions) QUEST_TRACKER_Triggers.reactions = {}; + if (!QUEST_TRACKER_Triggers.rumours) QUEST_TRACKER_Triggers.rumours = {}; + if (!QUEST_TRACKER_Triggers.scripts) QUEST_TRACKER_Triggers.scripts = {}; + if (!QUEST_TRACKER_Triggers.events) QUEST_TRACKER_Triggers.events = {}; + }; + const convertAutoAdvanceToTriggers = () => { + if (QUEST_TRACKER_versionChecking.TriggerConversion) return; + let triggersConverted = false; + initializeTriggersStructure(); + for (const [questId, questData] of Object.entries(QUEST_TRACKER_globalQuestData)) { + if (questData.autoadvance) { + for (const [status, date] of Object.entries(questData.autoadvance)) { + const newTriggerId = H.generateNewTriggerId(); + const newEffectId = H.generateNewEffectId(); + if (!QUEST_TRACKER_Triggers.dates[date]) QUEST_TRACKER_Triggers.dates[date] = {}; + QUEST_TRACKER_Triggers.dates[date][newTriggerId] = { + name: "Converted Trigger", + enabled: true, + quest_id: questId, + change: { type: 'status', value: status }, + effects: { + [newEffectId]: { + quest_id: questId, + change: { type: 'status', value: status } + } + } + }; + triggersConverted = true; + } + delete questData.autoadvance; + } + } + QUEST_TRACKER_versionChecking.TriggerConversion = true; + if (triggersConverted) { + errorCheck(176, 'msg', null, `Autoadvance converted to Triggers (v1.1 update).`); + } + H.saveData(); + }; + const convertEffectsToNewFormat = () => { + if (QUEST_TRACKER_versionChecking.EffectConversion) return; + let effectsConverted = false; + initializeTriggersStructure(); + for (const [triggerCategory, triggers] of Object.entries(QUEST_TRACKER_Triggers)) { + if (triggerCategory === "scripts") { + for (const [triggerId, trigger] of Object.entries(triggers)) { + if (trigger.effects) { + for (const [effectId, effect] of Object.entries(trigger.effects)) { + if (!effect.effecttype) { + trigger.effects[effectId] = { + id: effect.questid || null, + type: effect.type || null, + value: effect.value || null, + effecttype: "quest" + }; + effectsConverted = true; + } + } + } + } + } else { + for (const [parentId, triggerGroup] of Object.entries(triggers)) { + for (const [triggerId, trigger] of Object.entries(triggerGroup)) { + if (trigger.effects) { + for (const [effectId, effect] of Object.entries(trigger.effects)) { + if (!effect.effecttype) { + trigger.effects[effectId] = { + id: effect.questid || null, + type: effect.type || null, + value: effect.value || null, + effecttype: "quest" + }; + effectsConverted = true; + } + } + } + } + } + } + } + QUEST_TRACKER_versionChecking.EffectConversion = true; + if (effectsConverted) { + errorCheck(238, 'msg', null, `Effects converted to new format. (v1.2 update)`); + } + H.saveData(); + }; + const addTrigger = () => { + const newTriggerId = H.generateNewTriggerId(); + initializeTriggersStructure(); + if (!QUEST_TRACKER_Triggers.quests['null']) QUEST_TRACKER_Triggers.quests['null'] = {}; + QUEST_TRACKER_Triggers.quests['null'][newTriggerId] = { + name: "New Trigger", + enabled: false, + action: { type: null, effect: null }, + effects: {} + }; + H.saveData(); + }; + const addRumourTrigger = (rumourId) => { + const newTriggerId = H.generateNewTriggerId(); + initializeTriggersStructure(); + if (!QUEST_TRACKER_Triggers.rumours[rumourId]) QUEST_TRACKER_Triggers.rumours[rumourId] = {}; + QUEST_TRACKER_Triggers.rumours[rumourId][newTriggerId] = { + name: "New Trigger", + enabled: false, + effects: {} + }; + H.saveData(); + return newTriggerId; + }; + const initializeTrigger = (type, input = null) => { + initializeTriggersStructure(); + let sourceType; + switch (type) { + case 'quest': sourceType = 'date'; break; + case 'date': sourceType = 'quest'; break; + case 'reaction': sourceType = 'rumour'; break; + case 'rumour': sourceType = 'event'; break; + case 'event': sourceType = 'script'; break; + default: sourceType = 'reaction'; + } + const sourcePath = locateItem(input, 'trigger'); + const pathParts = sourcePath.split('.'); + const parentPath = pathParts.slice(0, -1).join('.'); + const triggerId = pathParts[pathParts.length - 1]; + const sourceParent = Utils.getNestedProperty(QUEST_TRACKER_Triggers, parentPath.replace('QUEST_TRACKER_Triggers.', '')); + const sourceTrigger = sourceParent ? sourceParent[triggerId] : null; + let targetStructure; + switch (type) { + case 'quest': targetStructure = QUEST_TRACKER_Triggers.quests; break; + case 'date': targetStructure = QUEST_TRACKER_Triggers.dates; break; + case 'reaction': targetStructure = QUEST_TRACKER_Triggers.reactions; break; + case 'rumour': targetStructure = QUEST_TRACKER_Triggers.rumours; break; + case 'event': targetStructure = QUEST_TRACKER_Triggers.events; break; + case 'script': targetStructure = QUEST_TRACKER_Triggers.scripts; break; + } + if (type === 'script') { + targetStructure[triggerId] = { + ...sourceTrigger, + name: sourceTrigger?.name || 'New Trigger', + enabled: sourceTrigger?.enabled ?? false, + effects: sourceTrigger?.effects || {}, + active: false + }; + } else { + const targetParentKey = 'null'; + const targetParent = targetStructure[targetParentKey] || (targetStructure[targetParentKey] = {}); + targetParent[triggerId] = { + ...sourceTrigger, + name: sourceTrigger?.name || 'New Trigger', + enabled: sourceTrigger?.enabled ?? false, + effects: sourceTrigger?.effects || {}, + action: type === 'quest' ? sourceTrigger?.action || { type: null, effect: null } : null + }; + } + switch (type) { + case 'quest': + break; + case 'date': + if (targetStructure[triggerId]?.action) delete targetStructure[triggerId].action; + break; + case 'reaction': + if (targetStructure[triggerId]?.action) delete targetStructure[triggerId].action; + if (targetStructure[triggerId]?.dateKey) delete targetStructure[triggerId].dateKey; + break; + case 'rumour': + case 'event': + case 'script': + if (targetStructure[triggerId]?.action) delete targetStructure[triggerId].action; + if (targetStructure[triggerId]?.dateKey) delete targetStructure[triggerId].dateKey; + if (targetStructure[triggerId]?.questId) delete targetStructure[triggerId].questId; + break; + default: + if (targetStructure[triggerId]?.active) delete targetStructure[triggerId].active; + } + if (sourceParent && sourceParent[triggerId]) { + delete sourceParent[triggerId]; + } + H.cleanUpEmptyKeys(); + H.saveData(); + }; + const toggleTrigger = (field, triggerId, value) => { + initializeTriggersStructure(); + const triggerPath = locateItem(triggerId, 'trigger'); + if (errorCheck(203, 'exists', triggerPath, 'triggerPath')) return; + const trigger = Utils.getNestedProperty(QUEST_TRACKER_Triggers, triggerPath.replace('QUEST_TRACKER_Triggers.', '')); + if (!trigger) { + errorCheck(204, 'msg', null, `Trigger not found at path: ${triggerPath}`); + return; + } + const isScript = triggerPath.startsWith("QUEST_TRACKER_Triggers.scripts"); + switch (field) { + case 'enabled': + trigger.enabled = value !== "false"; + break; + case 'active': + if (!isScript) { + errorCheck(235, 'msg', null, `'active' can only be toggled for script triggers.`); + return; + } + trigger.active = value !== "false"; + break; + + case 'name': + if (typeof value !== 'string' || value.trim() === '') { + errorCheck(204, 'msg', null, `Invalid name value: ${value}. Must be a non-empty string.`); + return; + } + trigger.name = value.trim(); + break; + + default: + errorCheck(205, 'msg', null, `Invalid field: ${field}. Use 'enabled', 'name', or 'active'.`); + return; + } + H.saveData(); + }; + const manageTriggerAction = (triggerId, { part, value }) => { + initializeTriggersStructure(); + const triggerPath = locateItem(triggerId, 'trigger'); + if (errorCheck(192, 'exists', triggerPath, 'triggerPath')) return; + const trigger = eval(triggerPath); + switch (part) { + case 'quest_id': { + trigger.quest_id = value; + break; + } + case 'rumour_id': { + if (!triggerPath.startsWith('QUEST_TRACKER_Triggers.rumours')) { + errorCheck(233, 'msg', null, `Cannot set a rumour ID on a non-rumour trigger.`); + return; + } + trigger.rumour_id = value; + break; + } + case 'triggering_field': { + trigger.change.type = value; + break; + } + case 'triggering_value': { + trigger.change.value = value; + break; + } + case 'date': { + if (!triggerPath.startsWith('QUEST_TRACKER_Triggers.dates')) { + errorCheck(193, 'msg', null, `Cannot set a date on a non-date trigger.`); + return; + } + trigger.date = value; + break; + } + case 'action': { + if (!triggerPath.startsWith('QUEST_TRACKER_Triggers.reactions')) { + errorCheck(195, 'msg', null, `Cannot set an action on a non-reaction trigger.`); + return; + } + trigger.action = value; + break; + } + default: { + errorCheck(194, 'msg', null, `Invalid part: ${part}.`); + return; + } + } + H.saveData(); + }; + const manageTriggerEffects = ({ action, value = {}, id = null }) => { + initializeTriggersStructure(); + const effectPath = locateItem(id, 'effect'); + if (!effectPath && action !== 'add') { + errorCheck(195, 'msg', null, `Effect with ID ${id} not found.`); + return; + } + let effects, effect; + if (effectPath) { + const effectKeyPath = effectPath.split('.effects.')[0]; + effects = eval(effectKeyPath); + effect = eval(effectPath); + } + switch (action) { + case 'add': { + if (errorCheck(196, 'exists', effects, 'effects')) return; + const newEffectId = H.generateNewEffectId(); + effects[newEffectId] = { + quest_id: null, + change: { type: null, value: null }, + ...value + }; + break; + } + case 'remove': { + if (errorCheck(197, 'exists', effect, 'effect')) return; + delete effects[id]; + break; + } + case 'edit': { + if (errorCheck(198, 'exists', effect, 'effect')) return; + effects[id] = { ...effect, ...value }; + break; + } + default: + errorCheck(199, 'msg', null, `Invalid action: ${action}. Use 'add', 'remove', or 'edit'.`); + return; + } + H.saveData(); + }; + const deleteTrigger = (triggerId) => { + initializeTriggersStructure(); + const triggerPath = locateItem(triggerId, 'trigger'); + if (!triggerPath) return; + const pathParts = triggerPath.split('.'); + const category = pathParts[1]; + const parentKey = pathParts[2]; + const triggerKey = pathParts[pathParts.length - 1]; + if (category === "scripts") { + if (QUEST_TRACKER_Triggers.scripts[triggerKey]) { + delete QUEST_TRACKER_Triggers.scripts[triggerKey]; + if (Object.keys(QUEST_TRACKER_Triggers.scripts).length === 0) delete QUEST_TRACKER_Triggers.scripts; + } + } else { + let parentObject = QUEST_TRACKER_Triggers[category]?.[parentKey]; + if (!parentObject) return; + if (parentObject[triggerKey]) delete parentObject[triggerKey]; + if (Object.keys(parentObject).length === 0) delete QUEST_TRACKER_Triggers[category][parentKey]; + if (Object.keys(QUEST_TRACKER_Triggers[category]).length === 0) delete QUEST_TRACKER_Triggers[category]; + } + H.deleteEffectTriggers(triggerId); + Object.entries(QUEST_TRACKER_Triggers.reactions).forEach(([reactionParent, reactionTriggers]) => { + Object.entries(reactionTriggers).forEach(([reactionTriggerId, reactionTrigger]) => { + if (reactionTrigger.action === triggerId) deleteTrigger(reactionTriggerId); + }); + }); + H.saveData(); + }; + const locateItem = (itemId, field) => { + for (const [type, category] of Object.entries(QUEST_TRACKER_Triggers)) { + if (type === "scripts" && field === "trigger" && category[itemId]) return `QUEST_TRACKER_Triggers.scripts.${itemId}`; + if (type === "dates" && field === "trigger") { + for (const [dateKey, dateTriggers] of Object.entries(category)) { + if (dateTriggers[itemId]) return `QUEST_TRACKER_Triggers.dates.${dateKey}.${itemId}`; + } + } + for (const [parentId, items] of Object.entries(category)) { + if (field === 'trigger' && items[itemId]) { + return `QUEST_TRACKER_Triggers.${type}.${parentId}.${itemId}`; + } + if (field === 'effect') { + for (const [triggerId, trigger] of Object.entries(items)) { + if (trigger.effects && trigger.effects[itemId]) return `QUEST_TRACKER_Triggers.${type}.${parentId}.${triggerId}.effects.${itemId}`; + } + } + } + } + return null; + }; + const managePrompt = (field, triggerId, value) => { + initializeTriggersStructure(); + const sourcePath = locateItem(triggerId, 'trigger'); + const pathParts = sourcePath.split('.'); + const parentPath = pathParts.slice(0, -1).join('.'); + const sourceParent = Utils.getNestedProperty( + QUEST_TRACKER_Triggers, + parentPath.replace('QUEST_TRACKER_Triggers.', '') + ); + const sourceTrigger = sourceParent ? sourceParent[triggerId] : null; + let targetStructure; + switch (field) { + case 'quest': targetStructure = QUEST_TRACKER_Triggers.quests; break; + case 'date': targetStructure = QUEST_TRACKER_Triggers.dates; break; + case 'reaction': targetStructure = QUEST_TRACKER_Triggers.reactions; break; + case 'rumour': targetStructure = QUEST_TRACKER_Triggers.rumours; break; + case 'event': targetStructure = QUEST_TRACKER_Triggers.events; break; + case 'script': targetStructure = QUEST_TRACKER_Triggers.scripts; break; + default: return; + } + let targetParentKey = value || 'null'; + const targetParent = targetStructure[targetParentKey] || (targetStructure[targetParentKey] = {}); + targetParent[triggerId] = { + ...sourceTrigger, + ...(field === 'quest' ? { action: sourceTrigger?.action || { type: null, effect: null } } : {}), + ...(field === 'date' ? { dateKey: value || 'null' } : {}) + }; + delete sourceParent[triggerId]; + if (Object.keys(sourceParent).length === 0) { + let sourceStructure; + switch (pathParts[1]) { + case 'quests': sourceStructure = QUEST_TRACKER_Triggers.quests; break; + case 'dates': sourceStructure = QUEST_TRACKER_Triggers.dates; break; + case 'reactions': sourceStructure = QUEST_TRACKER_Triggers.reactions; break; + case 'rumours': sourceStructure = QUEST_TRACKER_Triggers.rumours; break; + case 'events': sourceStructure = QUEST_TRACKER_Triggers.events; break; + case 'scripts': sourceStructure = QUEST_TRACKER_Triggers.scripts; break; + default: return; + } + delete sourceStructure[pathParts[2]]; + } + H.saveData(); + }; + const manageActionEffect = (field, triggerId, type) => { + Triggers.initializeTriggersStructure(); + const triggerPath = Triggers.locateItem(triggerId, "trigger"); + if (!triggerPath || !triggerPath.startsWith("QUEST_TRACKER_Triggers.quests")) return; + const trigger = Utils.getNestedProperty(QUEST_TRACKER_Triggers, triggerPath.replace("QUEST_TRACKER_Triggers.", "")); + if (!trigger || !trigger.action) return; + switch(field) { + case 'action': + trigger.action.type = type; + trigger.action.effect = null; + break; + case 'effect': + trigger.action.effect = type; + break; + } + H.saveData(); + }; + const manageEffect = (triggerId, effectId, action, key = null, value = null) => { + Triggers.initializeTriggersStructure(); + const triggerPath = Triggers.locateItem(triggerId, "trigger"); + if (!triggerPath || !triggerPath.startsWith("QUEST_TRACKER_Triggers")) { + errorCheck(230, "msg", null, `Trigger ID ${triggerId} not found.`); + return; + } + const trigger = Utils.getNestedProperty(QUEST_TRACKER_Triggers, triggerPath.replace("QUEST_TRACKER_Triggers.", "")); + if (!trigger || !trigger.effects) trigger.effects = {}; + const newEffectId = action === "add" ? H.generateNewEffectId() : null; + switch (action) { + case "add": + trigger.effects[newEffectId] = { + id: null, + type: null, + value: null, + effecttype: 'quest' + }; + break; + case "delete": + delete trigger.effects[effectId]; + break; + case "modify": + trigger.effects[effectId][key] = value; + break; + } + H.saveData(); + }; + const checkTriggers = (type, id = null) => { + switch (type) { + case "date": { + const currentDate = new Date(QUEST_TRACKER_currentDate); + Object.entries(QUEST_TRACKER_Triggers.dates).forEach(([dateKey, triggers]) => { + const triggerDate = new Date(dateKey); + if (triggerDate <= currentDate) { + Object.entries(triggers).forEach(([triggerId, trigger]) => { + if (trigger.enabled && (!id || triggerId === id)) H.fireTrigger(triggerId); + }); + } + }); + break; + } + case "quest": { + const questTriggers = QUEST_TRACKER_Triggers.quests[id] || {}; + Object.entries(questTriggers).forEach(([triggerId, trigger]) => { + if (!trigger.enabled || !trigger.action) return; + if (id && triggerId !== id) return; + const { type, effect } = trigger.action; + switch (type) { + case "hidden": + const isHidden = QUEST_TRACKER_globalQuestData[id]?.hidden; + if (String(isHidden) === effect) H.fireTrigger(triggerId); + break; + case "disabled": + const isDisabled = QUEST_TRACKER_globalQuestData[id]?.disabled; + if (String(isDisabled) === effect) H.fireTrigger(triggerId); + break; + case "status": + const currentStatus = Quest.getQuestStatus(id); + const statusId = Object.keys(statusMapping).find(key => statusMapping[key] === currentStatus); + if (effect === currentStatus) H.fireTrigger(triggerId); + break; + } + }); + break; + } + case "reaction": { + Object.entries(QUEST_TRACKER_Triggers.reactions).forEach(([reactionTriggerId, reactions]) => { + if (id && reactionTriggerId !== id) return; + Object.entries(reactions).forEach(([triggerId, trigger]) => { + if (trigger.enabled && (!id || triggerId === id)) H.fireTrigger(triggerId); + }); + }); + break; + } + case "rumour": { + Object.entries(QUEST_TRACKER_Triggers.rumours).forEach(([rumourTriggerId, rumours]) => { + if (id && rumourTriggerId !== id) return; + Object.entries(rumours).forEach(([triggerId, trigger]) => { + if (trigger.enabled && (!id || triggerId === id)) H.fireTrigger(triggerId); + }); + }); + break; + } + case "event": { + Object.entries(QUEST_TRACKER_Triggers.events).forEach(([eventTriggerId, events]) => { + if (id && eventTriggerId !== id) return; + Object.entries(events).forEach(([triggerId, trigger]) => { + if (trigger.enabled && (!id || triggerId === id)) H.fireTrigger(triggerId); + }); + }); + break; + } + case "script": { + Object.entries(QUEST_TRACKER_Triggers.scripts).forEach(([triggerId, trigger]) => { + if (trigger.enabled && (!id || triggerId === id)) H.fireTrigger(triggerId); + }); + break; + } + } + if (QUEST_TRIGGER_DeleteList.length > 0) { + QUEST_TRIGGER_DeleteList = QUEST_TRIGGER_DeleteList.filter(triggerId => { + if (!id || triggerId === id) { + deleteTrigger(triggerId); + return false; + } + return true; + }); + } + }; + const removeQuestsFromTriggers = (questId) => { + Triggers.initializeTriggersStructure(); + if (QUEST_TRACKER_Triggers.quests[questId]) { + Object.keys(QUEST_TRACKER_Triggers.quests[questId]).forEach((triggerId) => { + Triggers.deleteTrigger(triggerId); + }); + delete QUEST_TRACKER_Triggers.quests[questId]; + } + H.saveData(); + }; + return { + initializeTriggersStructure, + convertAutoAdvanceToTriggers, + convertEffectsToNewFormat, + addTrigger, + addRumourTrigger, + initializeTrigger, + toggleTrigger, + manageTriggerAction, + manageTriggerEffects, + deleteTrigger, + locateItem, + managePrompt, + manageActionEffect, + manageEffect, + checkTriggers, + removeQuestsFromTriggers + }; + })(); + const Quest = (() => { + const H = { + traverseConditions: (conditions, callback) => { + conditions.forEach(condition => { + if (typeof condition === 'string') { + callback(condition); + } else if (typeof condition === 'object' && condition.logic && Array.isArray(condition.conditions)) { + H.traverseConditions(condition.conditions, callback); + if (Array.isArray(condition.mutually_exclusive)) { + condition.mutually_exclusive.forEach(exclusiveQuest => { + callback(exclusiveQuest); + }); + } + } + }); + }, + updateQuestStatus: (questId, status) => { + const questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + if (!questTable) { + return; + } + const items = findObjs({ type: 'tableitem', rollabletableid: questTable.id }); + const item = items.find(i => i.get('name') === questId); + if (item) { + item.set('weight', status); + QUEST_TRACKER_globalQuestArray = QUEST_TRACKER_globalQuestArray.map(q => { + if (q.id === questId) { + q.weight = status; + } + return q; + }); + saveQuestTrackerData(); + } + }, + removeQuestFromRollableTable: (questId) => { + const questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + if (questTable) { + const item = findObjs({ type: 'tableitem', rollabletableid: questTable.id }) + .find(i => i.get('name') === questId); + if (item) { + item.remove(); + } + } + }, + getExclusions: (questId) => { + const questData = QUEST_TRACKER_globalQuestData[questId]; + if (!questData || !questData.relationships) { + return []; + } + let exclusions = new Set(); + if (Array.isArray(questData.relationships.mutually_exclusive)) { + questData.relationships.mutually_exclusive.forEach(exclusions.add, exclusions); + } + H.traverseConditions(questData.relationships.conditions || [], condition => { + if (typeof condition === 'string') { + exclusions.add(condition); + } + }); + if (questData.group) { + Object.keys(QUEST_TRACKER_globalQuestData).forEach(key => { + const otherQuest = QUEST_TRACKER_globalQuestData[key]; + if (otherQuest.group && otherQuest.group !== questData.group) { + exclusions.add(key); + } + }); + } + return Array.from(exclusions); + }, + modifyRelationshipObject: (currentRelationships, action, relationshipType, newItem, groupnum) => { + switch (relationshipType) { + case 'mutuallyExclusive': + switch (action) { + case 'add': + currentRelationships.mutually_exclusive = typeof currentRelationships.mutually_exclusive === 'string' ? [currentRelationships.mutually_exclusive] : (currentRelationships.mutually_exclusive || []); + if (!currentRelationships.mutually_exclusive.includes(newItem)) { + currentRelationships.mutually_exclusive.push(newItem); + } + break; + case 'remove': + currentRelationships.mutually_exclusive = currentRelationships.mutually_exclusive.filter( + exclusive => exclusive && exclusive !== newItem + ); + break; + default: + break; + } + break; + case 'single': + if (!Array.isArray(currentRelationships.conditions)) { + currentRelationships.conditions = []; + } + if (!currentRelationships.logic) { + currentRelationships.logic = 'AND'; + } + switch (action) { + case 'add': + const baseIndex = currentRelationships.conditions.findIndex(cond => typeof cond === 'object'); + if (baseIndex === -1) { + currentRelationships.conditions.push(newItem); + } else { + currentRelationships.conditions.splice(baseIndex, 0, newItem); + } + break; + case 'remove': + currentRelationships.conditions = currentRelationships.conditions.filter(cond => cond !== newItem); + break; + default: + break; + } + break; + case 'group': + if (groupnum === null || groupnum < 1) { + return currentRelationships; + } + if (groupnum >= currentRelationships.conditions.length || typeof currentRelationships.conditions[groupnum] !== 'object') { + currentRelationships.conditions[groupnum] = { logic: 'AND', conditions: [] }; + } + const group = currentRelationships.conditions[groupnum]; + if (typeof group === 'object' && group.logic && Array.isArray(group.conditions)) { + switch (action) { + case 'add': + if (!group.conditions.includes(newItem)) { + group.conditions.push(newItem); + } + break; + case 'remove': + group.conditions = group.conditions.filter(cond => cond !== newItem); + break; + default: + break; + } + } + break; + case 'logic': + currentRelationships.logic = currentRelationships.logic === 'AND' ? 'OR' : 'AND'; + break; + case 'grouplogic': + if (groupnum !== null && groupnum >= 1 && groupnum < currentRelationships.conditions.length) { + const group = currentRelationships.conditions[groupnum]; + if (typeof group === 'object' && group.logic) { + group.logic = group.logic === 'AND' ? 'OR' : 'AND'; + } + } + break; + case 'removegroup': + if (groupnum !== null && groupnum >= 1 && groupnum < currentRelationships.conditions.length) { + currentRelationships.conditions.splice(groupnum, 1); + } + break; + case 'addgroup': + currentRelationships.conditions.push({ + logic: 'AND', + conditions: [newItem] + }); + break; + default: + break; + } + return currentRelationships; + }, + generateNewQuestId: () => { + const existingQuestIds = Object.keys(QUEST_TRACKER_globalQuestData); + const highestQuestNumber = existingQuestIds.reduce((max, id) => { + const match = id.match(/^quest_(\d+)$/); + if (match) { + const number = parseInt(match[1], 10); + return number > max ? number : max; + } + return max; + }, 0); + const newQuestNumber = highestQuestNumber + 1; + return `quest_${newQuestNumber}`; + }, + removeQuestReferences: (questId) => { + Object.keys(QUEST_TRACKER_globalQuestData).forEach(otherQuestId => { + if (otherQuestId !== questId) { + const otherQuestData = QUEST_TRACKER_globalQuestData[otherQuestId]; + if (!otherQuestData || !otherQuestData.relationships) return; + const { conditions, mutually_exclusive } = otherQuestData.relationships; + if (Array.isArray(conditions) && conditions.includes(questId)) { + manageRelationship(otherQuestId, 'remove', 'single', questId); + } + if (Array.isArray(mutually_exclusive) && mutually_exclusive.includes(questId)) { + manageRelationship(otherQuestId, 'remove', 'mutuallyExclusive', questId); + } + if (Array.isArray(conditions)) { + conditions.forEach((condition, index) => { + if (typeof condition === 'object' && Array.isArray(condition.conditions)) { + if (condition.conditions.includes(questId)) { + manageRelationship(otherQuestId, 'remove', 'group', questId, index); + } + } + }); + } + } + }); + }, + getAllQuestGroups: () => { + let groupTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS })[0]; + if (!groupTable) return []; + let groupItems = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }); + return groupItems.map(item => item.get('name')); + }, + removeQuestsFromGroup: (groupTable, groupId) => { + const groupObject = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }).find(item => item.get('weight') == groupId); + if (!groupObject) return; + + Object.keys(QUEST_TRACKER_globalQuestData).forEach(questId => { + const quest = QUEST_TRACKER_globalQuestData[questId] || {}; + if (quest.group === groupId) { + delete quest.group; + } + }); + Utils.updateHandoutField('quest'); + }, + getNewGroupId: (groupTable) => { + let groupItems = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }); + if (!groupItems || groupItems.length === 0) return 1; + let maxWeight = groupItems.reduce((max, item) => Math.max(max, item.get('weight')), 0); + return maxWeight + 1; + }, + levenshteinDistance: (a, b) => { + if (!a.length) return b.length; + if (!b.length) return a.length; + const matrix = Array.from({ length: a.length + 1 }, (_, i) => Array(b.length + 1).fill(0)); + for (let i = 0; i <= a.length; i++) matrix[i][0] = i; + for (let j = 0; j <= b.length; j++) matrix[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + matrix[i][j] = + a[i - 1] === b[j - 1] + ? matrix[i - 1][j - 1] + : Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + 1); + } + } + return matrix[a.length][b.length]; + }, + getBestMatchingHandout: (questName) => { + const handouts = findObjs({ type: 'handout' }); + if (!handouts || handouts.length === 0) return null; + let bestMatch = null; + let bestDistance = Infinity; + handouts.forEach(handout => { + const handoutName = handout.get('name'); + const distance = H.levenshteinDistance(questName.toLowerCase(), handoutName.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + bestMatch = handout; + } + }); + if (bestMatch) return bestMatch.id; + else return null; + } + }; + const manageRelationship = (questId, action, relationshipType, newItem = null, groupnum = null) => { + let questData = QUEST_TRACKER_globalQuestData[questId]; + let currentRelationships = questData.relationships || { logic: 'AND', conditions: [], mutually_exclusive: [] }; + currentRelationships.conditions = currentRelationships.conditions || []; + currentRelationships.mutually_exclusive = currentRelationships.mutually_exclusive || []; + if (action === 'add' && newItem) { + let targetQuest = QUEST_TRACKER_globalQuestData[newItem]; + if (targetQuest && questData.group && !targetQuest.group) { + targetQuest.group = questData.group; + } else if (targetQuest && !questData.group && targetQuest.group) { + questData.group = targetQuest.group; + } + } + let updatedRelationships = H.modifyRelationshipObject(currentRelationships, action, relationshipType, newItem, groupnum); + Utils.updateHandoutField('quest') + }; + const getValidQuestsForDropdown = (questId) => { + const exclusions = H.getExclusions(questId); + const excludedQuests = new Set([questId, ...exclusions]); + const validQuests = Object.keys(QUEST_TRACKER_globalQuestData).filter(qId => { + return !excludedQuests.has(qId); + }); + if (validQuests.length === 0) { + return false; + } + return validQuests; + }; + const addQuest = () => { + const newQuestId = H.generateNewQuestId(); + const defaultQuestData = { + name: 'New Quest', + description: 'Description', + relationships: {}, + hidden: true, + disabled: false + }; + const questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + QUEST_TRACKER_globalQuestData[newQuestId] = defaultQuestData; + QUEST_TRACKER_globalQuestArray.push({ id: newQuestId, weight: 1 }); + if (questTable) { + createObj('tableitem', { + rollabletableid: questTable.id, + name: newQuestId, + weight: 1, + }); + } + Utils.updateHandoutField('quest') + }; + const removeQuest = (questId) => { + H.removeQuestReferences(questId); + H.removeQuestFromRollableTable(questId); + Rumours.removeAllRumoursForQuest(questId); + Triggers.removeQuestsFromTriggers(questId); + delete QUEST_TRACKER_globalQuestData[questId]; + QUEST_TRACKER_globalQuestArray = QUEST_TRACKER_globalQuestArray.filter(quest => quest.id !== questId); + Utils.updateHandoutField('quest'); + }; + const cleanUpLooseEnds = () => { + const processedPairs = new Set(); + Object.keys(QUEST_TRACKER_globalQuestData).forEach(questId => { + const quest = QUEST_TRACKER_globalQuestData[questId]; + const mutuallyExclusiveQuests = quest.relationships?.mutually_exclusive || []; + mutuallyExclusiveQuests.forEach(targetId => { + const pairKey = [questId, targetId].sort().join('-'); + if (!processedPairs.has(pairKey)) { + processedPairs.add(pairKey); + const targetQuest = QUEST_TRACKER_globalQuestData[targetId]; + if (targetQuest) { + const targetMutuallyExclusive = new Set(targetQuest.relationships?.mutually_exclusive || []); + if (!targetMutuallyExclusive.has(questId)) { + manageRelationship(targetId, 'add', 'mutuallyExclusive', questId); + Utils.sendGMMessage(`Added missing mutually exclusive relationship from ${targetId} to ${questId}.`); + } + } + } + }); + }); + }; + const getStatusNameByQuestId = (questId, questArray) => { + let quest = questArray.find(q => q.id === questId); + if (quest) { + return statusMapping[quest.weight] || 'Unknown'; + } + return 'Unknown'; + }; + const getQuestStatus = (questId) => { + const questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + if (!questTable) { + return 1; + } + const questItem = findObjs({ type: 'tableitem', rollabletableid: questTable.id }).find(item => item.get('name') === questId); + if (!questItem) { + return 1; + } + return questItem.get('weight'); + }; + const manageQuestObject = ({ action, field, current, old = '', newItem }) => { + const quest = QUEST_TRACKER_globalQuestData[current]; + switch (field) { + case 'status': + H.updateQuestStatus(current, newItem); + QuestPageBuilder.updateQuestStatusColor(current, newItem); + Rumours.calculateRumoursByLocation(); + break; + case 'hidden': + if (action === 'update') { + quest.hidden = !quest.hidden; + QuestPageBuilder.updateQuestVisibility(current, quest.hidden); + } + break; + case 'disabled': + if (action === 'update') { + quest.disabled = !quest.disabled; + } + break; + case 'name': + if (action === 'add') { + quest.name = newItem; + QuestPageBuilder.updateQuestText(current, newItem); + } else if (action === 'remove') { + quest.name = ''; + } + break; + case 'description': + if (action === 'add') { + quest.description = newItem; + QuestPageBuilder.updateQuestTooltip(current, newItem); + } else if (action === 'remove') { + quest.description = ''; + } + break; + case 'group': + if (action === 'add') { + quest.group = newItem; + } else if (action === 'remove') { + delete quest.group; + } + break; + default: + errorCheck(11, 'msg', null,`Unsupported action for type ( ${field} )`); + break; + } + Triggers.checkTriggers('quest',current); + Utils.updateHandoutField('quest'); + }; + const manageGroups = (action, newItem = null, groupId = null) => { + let groupTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS })[0]; + if (!groupTable) { + errorCheck(12, 'msg', null,`Quest groups table not found.`) + return; + } + switch (action) { + case 'add': + const allGroups = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }).map(item => item.get('name').toLowerCase()); + if (allGroups.includes(Utils.sanitizeString(newItem.toLowerCase()))) return; + const newWeight = H.getNewGroupId(groupTable); + if (newWeight === undefined || newWeight === null) return; + let newGroup = createObj('tableitem', { + rollabletableid: groupTable.id, + name: newItem, + weight: newWeight + }); + break; + case 'remove': + if (groupId === 1) return; + let groupToRemove = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }).find(item => item.get('weight') == groupId); + H.removeQuestsFromGroup(groupTable, groupId); + groupToRemove.remove(); + break; + case 'update': + const groupList = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }).map(item => item.get('name').toLowerCase()); + if (groupList.includes(Utils.sanitizeString(newItem.toLowerCase()))) return; + let groupToUpdate = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }).find(item => item.get('weight') == groupId); + if (groupToUpdate) { + groupToUpdate.set('name', newItem); + } + break; + } + }; + const findDirectlyLinkedQuests = (startingQuestId) => { + const linkedQuests = []; + const visited = new Set(); + function isDependentOnQuest(conditions, targetQuestId) { + if (!conditions) return false; + if (Array.isArray(conditions)) { + return conditions.every(cond => { + if (typeof cond === "string") { + return cond === targetQuestId; + } else if (typeof cond === "object" && cond.logic === "AND") { + return isDependentOnQuest(cond.conditions, targetQuestId); + } else if (typeof cond === "object" && cond.logic === "OR") { + return false; + } + return false; + }); + } else if (typeof conditions === "string") { + return conditions === targetQuestId; + } else if (typeof conditions === "object" && conditions.logic === "AND") { + return isDependentOnQuest(conditions.conditions, targetQuestId); + } + return false; + } + function traverse(questId) { + if (visited.has(questId)) return; + visited.add(questId); + Object.entries(QUEST_TRACKER_globalQuestData).forEach(([currentQuestId, quest]) => { + if (currentQuestId === questId || visited.has(currentQuestId)) return; + + const relationships = quest.relationships; + if (relationships?.logic === "AND") { + const conditions = relationships.conditions; + if (isDependentOnQuest(conditions, questId)) { + linkedQuests.push(currentQuestId); + traverse(currentQuestId); + } + } + }); + } + traverse(startingQuestId); + return linkedQuests; + }; + const linkHandout = (questId, key) => { + const quest = QUEST_TRACKER_globalQuestData[questId]; + if (key === "AUTO") { + const handoutId = H.getBestMatchingHandout(quest.name); + if (handoutId) linkHandout(questId, handoutId); + else { + const newHandout = createObj('handout', { name: quest.name }); + if (newHandout) linkHandout(questId, newHandout.id); + } + } + else quest.handout = key; + Utils.updateHandoutField('quest'); + }; + const removeHandout = (questId) => { + const quest = QUEST_TRACKER_globalQuestData[questId]; + if (quest && quest.handout) { + delete quest.handout; + } + Utils.updateHandoutField('quest'); + }; + return { + getStatusNameByQuestId, + getQuestStatus, + getValidQuestsForDropdown, + manageRelationship, + addQuest, + removeQuest, + cleanUpLooseEnds, + manageQuestObject, + manageGroups, + findDirectlyLinkedQuests, + linkHandout, + removeHandout + }; + })(); + const Calendar = (() => { + const H = { + generateNewEventId: () => { + const existingEventIds = Object.keys(QUEST_TRACKER_Events); + const highestEventNumber = existingEventIds.reduce((max, id) => { + const match = id.match(/^event_(\d+)$/); + return match ? Math.max(max, parseInt(match[1], 10)) : max; + }, 0); + return `event_${highestEventNumber + 1}`; + }, + checkEvent: () => { + if (!QUEST_TRACKER_Events || typeof QUEST_TRACKER_Events !== "object") return; + const todayEvents = H.findNextEvents(0, true); + todayEvents.forEach(([eventDate, eventName, eventID]) => { + if (eventID) { + const event = QUEST_TRACKER_Events[eventID]; + if (errorCheck(13, 'exists', event, 'event') || !event.enabled) return; + if (event.hidden === false) { + Utils.sendMessage(`${event.name} - ${event.description}`); + } else { + Utils.sendGMMessage(`Event triggered: ${event.name} - ${event.description}`); + } + if (!event.repeatable) { + delete QUEST_TRACKER_Events[eventID]; + Utils.updateHandoutField("event"); + } else { + const frequencyDays = event.frequency || 1; + const [year, month, day] = event.date.split("-").map(Number); + const nextDate = new Date(year, month - 1, day + frequencyDays) + .toISOString().split("T")[0]; + event.date = nextDate; + Utils.updateHandoutField("event"); + } + } else { + Utils.sendMessage(`Today is ${eventName}`); + } + }); + }, + evaluateLogic: (logic, year) => { + if (errorCheck(15, 'exists', logic,'logic')) return false; + if (errorCheck(16, 'exists', logic.operation,'logic.operation')) return false; + if (logic.conditions) { + if (logic.operation === "or") { + return logic.conditions.some((condition) => H.evaluateLogic(condition, year)); + } else if (logic.operation === "and") { + return logic.conditions.every((condition) => H.evaluateLogic(condition, year)); + } + errorCheck(17, 'msg', null,`Unsupported logic operation: ${logic.operation}`); + return false; + } + if (logic.operation === "mod") { + const result = (year % logic.operand) === logic.equals; + return logic.negate ? !result : result; + } + errorCheck(18, 'msg', null,`Unsupported condition operation: ${logic.operation}`); + return false; + }, + getDaysInMonth: (monthIndex, year) => { + const month = CALENDARS[QUEST_TRACKER_calenderType].months[monthIndex - 1]; + if (month.leap) { + const isLeapYear = H.evaluateLogic(month.leap.logic, year); + if (isLeapYear) { + return month.leap.days; + } + } + return month.days; + }, + getTotalDaysInYear: (year) => { + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + if (errorCheck(19, 'exists', calendar,'calendar')) return; + if (errorCheck(20, 'exists', calendar.months,'calendar.monthsn')) return; + return calendar.months.reduce((totalDays, monthObj, index) => { + const daysInMonth = H.getDaysInMonth(index + 1, year); + return totalDays + daysInMonth; + }, 0); + }, + calculateDateDifference: (target, baseYear, baseMonth, baseDay) => { + if (!target) return Infinity; + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + if (errorCheck(21, 'exists', calendar,'calendar')) return Infinity; + const { year: targetYear, month: targetMonth, day: targetDay } = target; + let totalDays = 0; + if (targetYear === baseYear) { + if (targetMonth === baseMonth) { + return targetDay - baseDay; + } + totalDays += H.getDaysInMonth(baseMonth, baseYear) - baseDay; + for (let m = baseMonth + 1; m < targetMonth; m++) { + totalDays += H.getDaysInMonth(m, baseYear); + } + totalDays += targetDay; + return totalDays; + } + totalDays += H.getDaysInMonth(baseMonth, baseYear) - baseDay; + for (let m = baseMonth + 1; m <= calendar.months.length; m++) { + totalDays += H.getDaysInMonth(m, baseYear); + } + for (let y = baseYear + 1; y < targetYear; y++) { + totalDays += H.getTotalDaysInYear(y); + } + for (let m = 1; m < targetMonth; m++) { + totalDays += H.getDaysInMonth(m, targetYear); + } + totalDays += targetDay; + return totalDays; + }, + isEventToday: (event, eventID) => { + if (!event.enabled) return []; + let { date, repeatable, frequency, name, weekdayname } = event; + let [eventYear, eventMonth, eventDay] = date.split("-").map(Number); + const [currentYear, currentMonth, currentDay] = QUEST_TRACKER_currentDate.split("-").map(Number); + if (!repeatable) { + return date === QUEST_TRACKER_currentDate ? [[QUEST_TRACKER_currentDate, name, eventID]] : []; + } + + const freqType = frequencyMapping[frequency]; + switch (freqType) { + case "Daily": + return [[QUEST_TRACKER_currentDate, name, eventID]]; + case "Weekly": + if (weekdayname && weekdayname === QUEST_TRACKER_currentWeekdayName) { + return [[QUEST_TRACKER_currentDate, name, eventID]]; + } + break; + case "Monthly": + const daysInMonth = H.getDaysInMonth(currentMonth, currentYear); + if (eventDay <= daysInMonth && eventMonth === currentMonth && eventDay === currentDay) { + return [[QUEST_TRACKER_currentDate, name, eventID]]; + } + break; + case "Yearly": + if (eventMonth === currentMonth && eventDay === currentDay) { + return [[QUEST_TRACKER_currentDate, name, eventID]]; + } + break; + } + return []; + }, + findNextEvents: (limit = 1, isToday = false) => { + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + const daysOfWeek = calendar.daysOfWeek || []; + const specialDays = calendar.significantDays || {}; + const events = QUEST_TRACKER_Events || {}; + const [currentYear, currentMonth, currentDay] = QUEST_TRACKER_currentDate.split("-").map(Number); + let upcomingEvents = []; + const todayEvents = []; + if (isToday) { + Object.entries(events).forEach(([eventID, event]) => { + if (!event.enabled) return; + const todaysOccurrences = H.isEventToday(event, eventID); + todayEvents.push(...todaysOccurrences); + }); + Object.entries(specialDays).forEach(([key, name]) => { + const [eventMonth, eventDay] = key.split("-").map(Number); + if (eventMonth === currentMonth && eventDay === currentDay) { + todayEvents.push([QUEST_TRACKER_currentDate, name, null]); + } + }); + return todayEvents; + } + const calculateNextOccurrences = (event, eventID, maxOccurrences) => { + let { date, repeatable, frequency, name, weekdayname } = event; + let [startYear, startMonth, startDay] = date.split("-").map(Number); + let [currentYear, currentMonth, currentDay] = QUEST_TRACKER_currentDate.split("-").map(Number); + let [eventYear, eventMonth, eventDay] = [startYear, startMonth, startDay]; + const occurrences = []; + const freqType = repeatable ? frequencyMapping[frequency] : null; + if (repeatable) { + if (`${startYear}-${String(startMonth).padStart(2, "0")}-${String(startDay).padStart(2, "0")}` < QUEST_TRACKER_currentDate) { + [eventYear, eventMonth, eventDay] = [currentYear, currentMonth, currentDay]; + } + switch (freqType) { + case "Daily": + break; + case "Weekly": + if (weekdayname) { + const targetWeekdayIndex = daysOfWeek.indexOf(weekdayname); + const currentWeekdayIndex = daysOfWeek.indexOf(QUEST_TRACKER_currentWeekdayName); + let daysToAdd = (targetWeekdayIndex - currentWeekdayIndex + daysOfWeek.length) % daysOfWeek.length; + if (daysToAdd === 0 && (eventYear === currentYear && eventMonth === currentMonth && eventDay === currentDay)) { + daysToAdd = daysOfWeek.length; + } + eventDay += daysToAdd; + if (eventDay > H.getDaysInMonth(eventMonth, eventYear)) { + eventDay -= H.getDaysInMonth(eventMonth, eventYear); + eventMonth++; + if (eventMonth > calendar.months.length) { + eventMonth = 1; + eventYear++; + } + } + } + break; + case "Monthly": + while ( + eventYear < currentYear || + (eventYear === currentYear && eventMonth < currentMonth) + ) { + eventMonth++; + if (eventMonth > calendar.months.length) { + eventMonth = 1; + eventYear++; + } + } + eventDay = Math.min(eventDay, H.getDaysInMonth(eventMonth, eventYear)); + break; + case "Yearly": + if (eventYear < currentYear) { + eventYear = currentYear; + } + break; + default: + break; + } + } + let occurrencesCount = 0; + while (occurrencesCount < maxOccurrences) { + const eventDate = `${eventYear}-${String(eventMonth).padStart(2, "0")}-${String(eventDay).padStart(2, "0")}`; + if (eventDate >= date) { + occurrences.push([eventDate, name, eventID]); + occurrencesCount++; + } + switch (freqType) { + case "Daily": + eventDay++; + if (eventDay > H.getDaysInMonth(eventMonth, eventYear)) { + eventDay -= H.getDaysInMonth(eventMonth, eventYear); + eventMonth++; + if (eventMonth > calendar.months.length) { + eventMonth = 1; + eventYear++; + } + } + break; + case "Weekly": + eventDay += daysOfWeek.length; + if (eventDay > H.getDaysInMonth(eventMonth, eventYear)) { + eventDay -= H.getDaysInMonth(eventMonth, eventYear); + eventMonth++; + if (eventMonth > calendar.months.length) { + eventMonth = 1; + eventYear++; + } + } + break; + case "Monthly": + eventMonth++; + if (eventMonth > calendar.months.length) { + eventMonth = 1; + eventYear++; + } + eventDay = Math.min(eventDay, H.getDaysInMonth(eventMonth, eventYear)); + break; + case "Yearly": + eventYear++; + break; + default: + break; + } + if (!repeatable) break; + } + return occurrences; + }; + Object.entries(events).forEach(([eventID, event]) => { + if (!event.enabled) return; + const eventOccurrences = calculateNextOccurrences(event, eventID, 5); + upcomingEvents.push(...eventOccurrences); + }); + Object.entries(specialDays).forEach(([key, name]) => { + const [eventMonth, eventDay] = key.split("-").map(Number); + let eventYear = currentYear; + if (eventMonth < currentMonth || (eventMonth === currentMonth && eventDay < currentDay)) { + eventYear++; + } + if (H.getDaysInMonth(eventMonth, eventYear) >= eventDay) { + const eventDate = `${eventYear}-${String(eventMonth).padStart(2, "0")}-${String(eventDay).padStart(2, "0")}`; + if (isToday) { + if (eventDate === QUEST_TRACKER_currentDate) { + todayEvents.push([eventDate, name, null]); + } + } else { + if (eventDate > QUEST_TRACKER_currentDate) { + upcomingEvents.push([eventDate, name, null]); + } + } + } + }); + upcomingEvents.sort((a, b) => { + const [aYear, aMonth, aDay] = a[0].split("-").map(Number); + const [bYear, bMonth, bDay] = b[0].split("-").map(Number); + return H.calculateDateDifference({ year: aYear, month: aMonth, day: aDay }, currentYear, currentMonth, currentDay) + - H.calculateDateDifference({ year: bYear, month: bMonth, day: bDay }, currentYear, currentMonth, currentDay); + }); + + return upcomingEvents.slice(0, limit); + }, + calculateWeekday: (year, month, day) => { + if (errorCheck(23, 'calendar', CALENDARS[QUEST_TRACKER_calenderType])) return; + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + if (errorCheck(24, 'calendar.daysOfWeek', calendar.daysOfWeek)) return; + if (errorCheck(25, 'calendar.startingWeekday', calendar.startingWeekday)) return; + if (errorCheck(26, 'calendar.startingYear', calendar.startingYear)) return; + const daysOfWeek = calendar.daysOfWeek; + const startingWeekday = calendar.startingWeekday; + const startingYear = calendar.startingYear; + let totalDays = 0; + for (let y = startingYear; y < year; y++) { + totalDays += H.getTotalDaysInYear(y); + } + for (let m = 1; m < month; m++) { + totalDays += typeof calendar.months[m - 1].days === "function" + ? calendar.months[m - 1].days(year) + : calendar.months[m - 1].days; + } + totalDays += day - 1; + return daysOfWeek[(daysOfWeek.indexOf(startingWeekday) + totalDays) % daysOfWeek.length]; + } + }; + const determineWeather = (date) => { + const W = { + getSeasonBoundaries: (year) => { + if (errorCheck(27, 'exists', CALENDARS[QUEST_TRACKER_calenderType]?.climates[QUEST_TRACKER_Location], `CALENDARS[${QUEST_TRACKER_calenderType}]?.climates[${QUEST_TRACKER_Location}]`)) return; + const climate = CALENDARS[QUEST_TRACKER_calenderType]?.climates[QUEST_TRACKER_Location]; + const boundaries = []; + const seasonStart = climate.seasonStart || {}; + for (const [seasonName, startMonth] of Object.entries(seasonStart)) { + let startDayOfYear = 0; + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + for (let i = 0; i < startMonth - 1; i++) { + const monthObj = calendar.months[i]; + startDayOfYear += typeof monthObj.days === "function" ? monthObj.days(year) : monthObj.days; + } + boundaries.push({ season: seasonName, startDayOfYear }); + } + boundaries.sort((a, b) => a.startDayOfYear - b.startDayOfYear); + const totalDaysInYear = H.getTotalDaysInYear(year); + boundaries.forEach((boundary, i) => { + const nextIndex = (i + 1) % boundaries.length; + boundary.endDayOfYear = + boundaries[nextIndex].startDayOfYear - 1 >= 0 + ? boundaries[nextIndex].startDayOfYear - 1 + : totalDaysInYear - 1; + }); + return boundaries; + }, + getCurrentSeason: (date) => { + const [year, month, day] = date.split("-").map(Number); + const boundaries = W.getSeasonBoundaries(year); + if (!boundaries || boundaries.length === 0) return null; + let dayOfYear = 0; + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + for (let i = 0; i < month - 1; i++) { + const monthObj = calendar.months[i]; + dayOfYear += typeof monthObj.days === "function" ? monthObj.days(year) : monthObj.days; + } + dayOfYear += day; + for (const { season, startDayOfYear, endDayOfYear } of boundaries) { + if (startDayOfYear <= endDayOfYear) { + if (dayOfYear >= startDayOfYear && dayOfYear <= endDayOfYear) { + return { season, dayOfYear }; + } + } else { + if (dayOfYear >= startDayOfYear || dayOfYear <= endDayOfYear) { + return { season, dayOfYear }; + } + } + } + return null; + }, + getSuddenSeasonalChangeProbability: (dayOfYear, boundaries) => { + const buffer = 5; + for (const { startDayOfYear, endDayOfYear } of boundaries) { + if (Math.abs(dayOfYear - startDayOfYear) <= buffer || Math.abs(dayOfYear - endDayOfYear) <= buffer) { + return 0.25; + } + } + return 0.05; + }, + applyForcedTrends: (rolls) => { + const { temperatureRoll, precipitationRoll, windRoll, humidityRoll, visibilityRoll, cloudCoverRoll } = rolls; + return { + temperatureRoll: QUEST_TRACKER_FORCED_WEATHER_TRENDS.heat + ? Math.min(100, temperatureRoll + 20) + : QUEST_TRACKER_FORCED_WEATHER_TRENDS.cold + ? Math.max(1, temperatureRoll - 20) + : temperatureRoll, + precipitationRoll: QUEST_TRACKER_FORCED_WEATHER_TRENDS.wet + ? Math.min(100, precipitationRoll + 20) + : QUEST_TRACKER_FORCED_WEATHER_TRENDS.dry + ? Math.max(1, precipitationRoll - 20) + : precipitationRoll, + windRoll: QUEST_TRACKER_FORCED_WEATHER_TRENDS.wind + ? Math.min(100, windRoll + 20) + : windRoll, + humidityRoll: QUEST_TRACKER_FORCED_WEATHER_TRENDS.humid + ? Math.min(100, humidityRoll + 20) + : humidityRoll, + visibilityRoll: QUEST_TRACKER_FORCED_WEATHER_TRENDS.visibility + ? Math.min(100, visibilityRoll + 20) + : visibilityRoll, + cloudCoverRoll: QUEST_TRACKER_FORCED_WEATHER_TRENDS.cloudy + ? Math.min(100, cloudCoverRoll + 20) + : cloudCoverRoll, + }; + }, + applyTrends: (rolls) => { + const { temperatureRoll, precipitationRoll, windRoll, humidityRoll, visibilityRoll, cloudCoverRoll } = rolls; + return { + temperatureRoll: + temperatureRoll + + (QUEST_TRACKER_WEATHER_TRENDS.heat || 0) * 2 - + (QUEST_TRACKER_WEATHER_TRENDS.cold || 0) * 2, + precipitationRoll: + precipitationRoll + + (QUEST_TRACKER_WEATHER_TRENDS.wet || 0) * 2 - + (QUEST_TRACKER_WEATHER_TRENDS.dry || 0) * 2, + windRoll: windRoll + (QUEST_TRACKER_WEATHER_TRENDS.wind || 0) * 2, + humidityRoll: humidityRoll + (QUEST_TRACKER_WEATHER_TRENDS.humid || 0) * 2, + visibilityRoll: visibilityRoll + (QUEST_TRACKER_WEATHER_TRENDS.visibility || 0) * 2, + cloudCoverRoll: cloudCoverRoll + (QUEST_TRACKER_WEATHER_TRENDS.cloudy || 0) * 2, + }; + }, + updateTrends: (rolls) => { + ["heat", "cold", "wet", "dry", "wind", "visibility", "cloudy"].forEach((trendType) => { + const roll = rolls[`${trendType}Roll`]; + if (["wind", "visibility", "cloudy"].includes(trendType) && roll < 75) { + QUEST_TRACKER_WEATHER_TRENDS[trendType] = 0; + } else if (roll > 75) { + QUEST_TRACKER_WEATHER_TRENDS[trendType] = + (QUEST_TRACKER_WEATHER_TRENDS[trendType] || 0) + 1; + } else if (QUEST_TRACKER_WEATHER_TRENDS[trendType]) { + QUEST_TRACKER_WEATHER_TRENDS[trendType] = 0; + } + }); + if (rolls.precipitationRoll > 75) QUEST_TRACKER_WEATHER_TRENDS.dry = 0; + if (rolls.temperatureRoll > 75) QUEST_TRACKER_WEATHER_TRENDS.cold = 0; + if (rolls.temperatureRoll < 25) QUEST_TRACKER_WEATHER_TRENDS.heat = 0; + }, + generateBellCurveRoll: (adj = 0) => { + const randomGaussian = () => { + let u = 0, v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + }; + const center = 50 + adj; + const lowerBound = Math.max(0, center - 25); + const upperBound = Math.min(100, center + 25); + let roll = Math.random() * (upperBound - lowerBound) + lowerBound; + let bias = roll <= center + ? Math.pow((roll - lowerBound) / (center - lowerBound), 2) + : Math.pow((upperBound - roll) / (upperBound - center), 2); + if (Math.random() < bias) { + return Math.round(roll * 100) / 100; + } else { + return W.generateBellCurveRoll(adj); + } + }, + adjustDailyFluctuation: (date, trendAdjustedRolls, suddenChangeProbability, seasonBoundary) => { + const previousWeather = QUEST_TRACKER_HISTORICAL_WEATHER[Object.keys(QUEST_TRACKER_HISTORICAL_WEATHER).reverse().find(d => d < date)]; + if (!previousWeather) return trendAdjustedRolls; + const maxChange = suddenChangeProbability > 0.05 ? 10 : 5; + const maxBoundaryChange = suddenChangeProbability > 0.05 ? 20 : 10; + const adjustedRolls = { ...trendAdjustedRolls }; + Object.keys(adjustedRolls).forEach((key) => { + const prevValue = previousWeather[key]; + if (prevValue !== undefined) { + const boundaryLimit = seasonBoundary ? maxBoundaryChange : maxChange; + const change = adjustedRolls[key] - prevValue; + if (Math.abs(change) > boundaryLimit) { + adjustedRolls[key] = prevValue + Math.sign(change) * boundaryLimit; + } + } + }); + return adjustedRolls; + } + }; + const [year, month, day] = date.split("-").map(Number); + const currentSeasonData = W.getCurrentSeason(date); + if (!currentSeasonData) return; + const { season, dayOfYear } = currentSeasonData; + const boundaries = W.getSeasonBoundaries(year); + const suddenChangeProbability = W.getSuddenSeasonalChangeProbability(dayOfYear, boundaries); + const rolls = { + temperatureRoll: W.generateBellCurveRoll(), + precipitationRoll: W.generateBellCurveRoll(), + windRoll: W.generateBellCurveRoll(-15), + humidityRoll: W.generateBellCurveRoll(), + visibilityRoll: W.generateBellCurveRoll(15), + cloudCoverRoll: W.generateBellCurveRoll(), + }; + const forcedAdjustedRolls = W.applyForcedTrends(rolls); + const trendAdjustedRolls = W.applyTrends(forcedAdjustedRolls); + W.updateTrends(trendAdjustedRolls); + const climateModifiers = CALENDARS[QUEST_TRACKER_calenderType]?.climates[QUEST_TRACKER_Location]?.modifiers; + trendAdjustedRolls.temperatureRoll += climateModifiers?.temperature?.[season] || 0; + trendAdjustedRolls.precipitationRoll += climateModifiers?.precipitation?.[season] || 0; + trendAdjustedRolls.windRoll += climateModifiers?.wind?.[season] || 0; + trendAdjustedRolls.humidityRoll += climateModifiers?.humid?.[season] || 0; + trendAdjustedRolls.visibilityRoll += climateModifiers?.visibility?.[season] || 0; + const nearBoundary = suddenChangeProbability > 0.05; + const isBoundaryDay = boundaries.some(({ startDayOfYear, endDayOfYear }) => + Math.abs(dayOfYear - startDayOfYear) <= 1 || Math.abs(dayOfYear - endDayOfYear) <= 1 + ); + const finalAdjustedRolls = W.adjustDailyFluctuation(date, trendAdjustedRolls, suddenChangeProbability, isBoundaryDay); + Object.keys(finalAdjustedRolls).forEach((key) => { + finalAdjustedRolls[key] = Math.max(1, Math.min(100, finalAdjustedRolls[key])); + }); + const weather = { + date, + season, + ...finalAdjustedRolls, + trends: { ...QUEST_TRACKER_WEATHER_TRENDS }, + forcedTrends: { ...QUEST_TRACKER_FORCED_WEATHER_TRENDS }, + nearBoundary, + }; + QUEST_TRACKER_HISTORICAL_WEATHER[date] = weather; + saveQuestTrackerData(); + Utils.updateHandoutField("weather"); + }; + const modifyDate = ({ type = "day", amount = 1, newDate = null }) => { + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + if (errorCheck(28, 'exists', calendar,'calendar')) return; + const L = { + formatDate: (year, month, day) => { + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + }, + wrapAround: () => { + while (day > H.getDaysInMonth(month, year)) { + day -= H.getDaysInMonth(month, year); + month++; + if (month > calendar.months.length) { + month = 1; + year++; + } + } + while (day < 1) { + month--; + if (month < 1) { + month = calendar.months.length; + year--; + } + day += H.getDaysInMonth(month, year); + } + }, + generateDateArray: () => { + const dates = []; + let targetDate = null; + if (type === "event") { + const closestEvent = H.findNextEvents(1); + if (!closestEvent || closestEvent.length === 0) { + Utils.sendGMMessage("No upcoming festivals, events, or significant dates found."); + return []; + } + targetDate = closestEvent[0][0]; + } + while (steps >= 0 || targetDate) { + dates.push(L.formatDate(year, month, day)); + if (type === "event" && targetDate) { + const [targetYear, targetMonth, targetDay] = targetDate.split("-").map(Number); + while ( + year !== targetYear || + month !== targetMonth || + day !== targetDay + ) { + day += direction; + L.wrapAround(); + dates.push(L.formatDate(year, month, day)); + } + break; + } + switch (type) { + case "day": + day += direction; + L.wrapAround(); + break; + case "week": + day += direction * calendar.daysOfWeek.length; + L.wrapAround(); + break; + case "month": + month += direction; + if (month > calendar.months.length) { + month -= calendar.months.length; + year++; + } else if (month < 1) { + month += calendar.months.length; + year--; + } + day = Math.min(day, H.getDaysInMonth(month, year)); + break; + case "year": + year += direction; + day = Math.min(day, H.getDaysInMonth(month, year)); + break; + default: + break; + } + steps--; + } + return dates; + }, + generateCompleteDateList: (startDate, endDate) => { + const [startYear, startMonth, startDay] = startDate.split("-").map(Number); + const [endYear, endMonth, endDay] = endDate.split("-").map(Number); + let currentYear = startYear, currentMonth = startMonth, currentDay = startDay; + const dateList = []; + while ( + currentYear < endYear || + (currentYear === endYear && currentMonth < endMonth) || + (currentYear === endYear && currentMonth === endMonth && currentDay <= endDay) + ) { + dateList.push(L.formatDate(currentYear, currentMonth, currentDay)); + currentDay++; + if (currentDay > H.getDaysInMonth(currentMonth, currentYear)) { + currentDay = 1; + currentMonth++; + if (currentMonth > calendar.months.length) { + currentMonth = 1; + currentYear++; + } + } + } + dateList.push(L.formatDate(endYear, endMonth, endDay)); + return dateList; + }, + validateISODate: (date) => { + const [y, m, d] = date.split("-").map(Number); + if (!y || !m || !d || m < 1 || m > calendar.months.length) { + errorCheck(29, 'msg', null,`Invalid ISO date format or date out of range for calendar: ${date}`); + return null; + } + const daysInMonth = H.getDaysInMonth(m, y); + if (d < 1 || d > daysInMonth) { + errorCheck(30, 'msg', null,`Day out of range for the specified month: ${date}`); + return null; + } + return { year: y, month: m, day: d }; + }, + isAfterCurrentDate: (eventYear, eventMonth, eventDay) => { + if (eventYear > year) return true; + if (eventYear === year && eventMonth > month) return true; + if (eventYear === year && eventMonth === month && eventDay > day) return true; + return false; + } + }; + let [year, month, day] = QUEST_TRACKER_currentDate.split("-").map(Number); + if (type === "set") { + const { year: newYear, month: newMonth, day: newDay } = L.validateISODate(newDate); + QUEST_TRACKER_currentDate = L.formatDate(newYear, newMonth, newDay); + saveQuestTrackerData(); + return; + } + let steps = Math.abs(amount); + let direction = Math.sign(amount); + const dateArray = L.generateDateArray(); + if (QUEST_TRACKER_WEATHER && dateArray.length > 0) { + dateArray.forEach((date) => { + if (!QUEST_TRACKER_HISTORICAL_WEATHER[date]) { + determineWeather(date); + } + }); + } + const [finalYear, finalMonth, finalDay] = dateArray[dateArray.length - 1].split("-").map(Number); + year = finalYear; + month = finalMonth; + day = finalDay; + QUEST_TRACKER_currentDate = L.formatDate(year, month, day); + QUEST_TRACKER_currentWeekdayName = H.calculateWeekday(year, month, day); + H.checkEvent(); + Triggers.checkTriggers('date'); + describeWeather(); + saveQuestTrackerData(); + Utils.sendMessage(`Date is now: ${Calendar.formatDateFull()}`) + Utils.sendDescMessage(QUEST_TRACKER_CURRENT_WEATHER['description']); + Menu.buildWeather({ isMenu: false }); + }; + const addEvent = () => { + const newEventId = H.generateNewEventId(); + const defaultEventData = { + name: 'New Event', + description: 'Description', + date: `${QUEST_TRACKER_defaultDate}`, + hidden: true, + enabled: false, + repeatable: false, + frequency: null + }; + QUEST_TRACKER_Events[newEventId] = defaultEventData; + Utils.updateHandoutField('event'); + }; + const getNextEvents = (number) => { + return H.findNextEvents(number); + }; + const removeEvent = (eventId) => { + delete QUEST_TRACKER_Events[eventId]; + Utils.updateHandoutField('event'); + }; + const manageEventObject = ({ action, field, current, old = '', newItem, date }) => { + const event = QUEST_TRACKER_Events[current]; + switch (field) { + case 'hidden': + event.hidden = !event.hidden; + break; + case 'enabled': + event.enabled = !event.enabled; + break; + case 'repeatable': + event.repeatable = !event.repeatable; + event.frequency = 1; + break; + case 'frequency': + event.frequency = newItem; + if (newItem === "2") { + const [year, month, day] = date.split("-").map(Number); + event.weekdayname = H.calculateWeekday(year, month, day); + } + break; + case 'name': + event.name = newItem; + break; + case 'date': + event.date = newItem; + if (event.frequency === "2" && event.repeatable) { + const [year, month, day] = newItem.split("-").map(Number); + event.weekdayname = H.calculateWeekday(year, month, day); + } + break; + case 'description': + event.description = newItem; + break; + default: + errorCheck(31, 'msg', null,`Unknown field command: ${field}`); + break; + } + Utils.updateHandoutField('event'); + }; + const setCalender = (calender) => { + QUEST_TRACKER_calenderType = calender; + const calendar = CALENDARS[calender]; + QUEST_TRACKER_currentDate = calendar.defaultDate; + QUEST_TRACKER_defaultDate = calendar.defaultDate; + const [year, month, day] = QUEST_TRACKER_currentDate.split("-").map(Number); + QUEST_TRACKER_currentWeekdayName = H.calculateWeekday(year, month, day); + const firstClimate = Object.keys(calendar.climates)[0]; + if (firstClimate) { + setClimate(firstClimate); + } + saveQuestTrackerData(); + }; + const setClimate = (climate) => { + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + QUEST_TRACKER_Location = climate; + saveQuestTrackerData(); + }; + const setWeatherTrend = (type, amount) => { + QUEST_TRACKER_WEATHER_TRENDS[type] = parseInt(QUEST_TRACKER_WEATHER_TRENDS[type], 10) || 0; + amount = parseInt(amount, 10); + QUEST_TRACKER_WEATHER_TRENDS[type] += amount; + saveQuestTrackerData(); + }; + const formatDateFull = () => { + const [year, month, day] = QUEST_TRACKER_currentDate.split("-").map(Number); + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + const monthName = calendar.months[month - 1].name; + const format = calendar.dateFormat || "{day}{ordinal} of {month}, {year}"; + const ordinal = (n) => { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; + }; + return format + .replace("{day}", day) + .replace("{ordinal}", ordinal(day)) + .replace("{month}", monthName) + .replace("{year}", year); + }; + const forceWeatherTrend = (field) => { + const fieldList = ["dry", "wet", "heat", "cold"]; + const isCurrentlyTrue = QUEST_TRACKER_FORCED_WEATHER_TRENDS[field]; + QUEST_TRACKER_FORCED_WEATHER_TRENDS[field] = !isCurrentlyTrue; + if (QUEST_TRACKER_FORCED_WEATHER_TRENDS[field] === true) { + fieldList + .filter((f) => f !== field) + .forEach((f) => { + QUEST_TRACKER_FORCED_WEATHER_TRENDS[f] = false; + }); + } + saveQuestTrackerData(); + }; + const getLunarPhase = (date, moonId) => { + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + if (errorCheck(153, 'exists', calendar.lunarCycle, `calendar.lunarCycle`)) return; + if (errorCheck(154, 'exists', calendar.lunarCycle[moonId], `calendar.lunarCycle[${moonId}]`)) return; + const { baselineNewMoon, cycleLength, phases, name } = calendar.lunarCycle[moonId]; + const baselineDate = new Date(baselineNewMoon); + const currentDate = new Date(date); + const daysSinceBaseline = (currentDate - baselineDate) / (1000 * 60 * 60 * 24); + const phase = (daysSinceBaseline % cycleLength + cycleLength) % cycleLength; + for (const { name: phaseName, start, end } of phases) { + if (phase >= start && phase < end) { + return `${name}: ${phaseName}`; + } + } + return `${name}: Unknown Phase`; + }; + const describeWeather = () => { + const L = { + meetsCondition: (value, cond) => { + if (cond.gte !== undefined && value < cond.gte) return false; + if (cond.lte !== undefined && value > cond.lte) return false; + return true; + }, + matchesConditions: (rolls, conditions, ignoreKeys = []) => { + for (const [metric, cond] of Object.entries(conditions)) { + if (ignoreKeys.includes(metric)) continue; + const val = rolls[metric]; + if (val === undefined) return false; + if (!L.meetsCondition(val, cond)) return false; + } + return true; + }, + countMatches: (rolls, conditions, ignoreKeys = []) => { + let matchCount = 0; + for (const [metric, cond] of Object.entries(conditions)) { + if (ignoreKeys.includes(metric)) continue; + const val = rolls[metric]; + if (val !== undefined && L.meetsCondition(val, cond)) { + matchCount++; + } + } + return matchCount; + }, + determineWeatherType: (rolls) => { + const WEATHER_TYPES = WEATHER.weather; + let matches = []; + for (const [typeName, typeData] of Object.entries(WEATHER_TYPES)) { + if (L.matchesConditions(rolls, typeData.conditions)) { + matches.push(typeName); + } + } + if (matches.length > 0) { + const chosenMatch = matches[Math.floor(Math.random() * matches.length)]; + return { type: chosenMatch }; + } + matches = []; + for (const [typeName, typeData] of Object.entries(WEATHER_TYPES)) { + if (L.matchesConditions(rolls, typeData.conditions, ['visibility'])) { + matches.push(typeName); + } + } + if (matches.length > 0) { + const chosenMatch = matches[Math.floor(Math.random() * matches.length)]; + return { type: chosenMatch }; + } + matches = []; + for (const [typeName, typeData] of Object.entries(WEATHER_TYPES)) { + if (L.matchesConditions(rolls, typeData.conditions, ['visibility', 'cloudCover'])) { + matches.push(typeName); + } + } + if (matches.length > 0) { + const chosenMatch = matches[Math.floor(Math.random() * matches.length)]; + return { type: chosenMatch }; + } + let bestType = null; + let bestCount = -1; + for (const [typeName, typeData] of Object.entries(WEATHER_TYPES)) { + const count = L.countMatches(rolls, typeData.conditions); + if (count > bestCount) { + bestCount = count; + bestType = typeName; + } + } + if (bestType) { + return { type: bestType }; + } + return { type: "unclassified normal weather" }; + }, + getScaleDescription: (metric, value) => { + const scaleEntries = Object.entries(WEATHER.scales[metric]); + const numericKeys = scaleEntries.map(([k]) => parseInt(k,10)).sort((a,b) => a - b); + let chosenKey = numericKeys[0]; + for (let k of numericKeys) { + if (k <= value) { + chosenKey = k; + } else { + break; + } + } + return WEATHER.scales[metric][chosenKey.toString()].description; + } + }; + const todayWeather = QUEST_TRACKER_HISTORICAL_WEATHER[QUEST_TRACKER_currentDate]; + if (!todayWeather) return; + const rolls = { + temperature: todayWeather.temperatureRoll, + precipitation: todayWeather.precipitationRoll, + wind: todayWeather.windRoll, + humidity: todayWeather.humidityRoll, + cloudCover: todayWeather.cloudCoverRoll, + visibility: todayWeather.visibilityRoll + }; + const result = L.determineWeatherType(rolls); + const chosenType = result.type; + let chosenWeatherData; + if (WEATHER.weather[chosenType]) { + chosenWeatherData = WEATHER.weather[chosenType]; + } else { + chosenWeatherData = { + descriptions: { + [QUEST_TRACKER_WeatherLocation]: { + "1": "Unclassified normal weather conditions." + } + } + }; + } + const envDescriptions = chosenWeatherData.descriptions[QUEST_TRACKER_WeatherLocation] || { "1": "No description available." }; + const envDescriptionKeys = Object.keys(envDescriptions); + const randomDescKey = envDescriptionKeys[Math.floor(Math.random() * envDescriptionKeys.length)]; + const chosenDescription = envDescriptions[randomDescKey]; + QUEST_TRACKER_CURRENT_WEATHER = { + weatherType: chosenType, + description: chosenDescription, + environment: WEATHER.enviroments[QUEST_TRACKER_WeatherLocation] ? WEATHER.enviroments[QUEST_TRACKER_WeatherLocation].name : QUEST_TRACKER_WeatherLocation, + rolls: { ...rolls }, + scaleDescriptions: { + temperature: L.getScaleDescription("temperature", rolls.temperature), + humidity: L.getScaleDescription("humidity", rolls.humidity), + wind: L.getScaleDescription("wind", rolls.wind), + precipitation: L.getScaleDescription("precipitation", rolls.precipitation), + cloudCover: L.getScaleDescription("cloudCover", rolls.cloudCover), + visibility: L.getScaleDescription("visibility", rolls.visibility) + } + }; + }; + const adjustLocation = (location) => { + if (WEATHER.enviroments.hasOwnProperty(location)) { + QUEST_TRACKER_WeatherLocation = location; + saveQuestTrackerData(); + } else return; + }; + const convertEventsToNewFormat = () => { + if (QUEST_TRACKER_versionChecking.EventConversion) return; + let eventsConverted = false; + if (!QUEST_TRACKER_Events || typeof QUEST_TRACKER_Events !== "object") return; + Object.entries(QUEST_TRACKER_Events).forEach(([eventID, event]) => { + if (!event.hasOwnProperty("enabled")) { + event.enabled = true; + eventsConverted = true; + } + }); + QUEST_TRACKER_versionChecking.EventConversion = true; + if (eventsConverted) { + errorCheck(237, 'msg', null, `Events converted to include 'enabled' field (v1.2 update).`); + Utils.updateHandoutField("event"); + saveQuestTrackerData(); + } + }; + return { + modifyDate, + addEvent, + removeEvent, + manageEventObject, + setCalender, + formatDateFull, + setClimate, + setWeatherTrend, + forceWeatherTrend, + getLunarPhase, + getNextEvents, + adjustLocation, + convertEventsToNewFormat + }; + })(); + const QuestPageBuilder = (() => { + const vars = { + DEFAULT_PAGE_UNIT: 70, + AVATAR_SIZE: 70, + TEXT_FONT_SIZE: 12, + PAGE_HEADER_WIDTH: 700, + PAGE_HEADER_HEIGHT: 150, + ROUNDED_RECT_WIDTH: 400, + ROUNDED_RECT_HEIGHT: 80, + ROUNDED_RECT_CORNER_RADIUS: 10, + VERTICAL_SPACING: 100, + HORIZONTAL_SPACING: 160, + DEFAULT_FILL_COLOR: '#CCCCCC', + DEFAULT_STATUS_COLOR: '#000000', + QUESTICON_WIDTH: 305, + GROUP_SPACING: 800, + QUESTICON_HEIGHT: 92 + }; + const H = { + adjustPageSettings: (page) => { + page.set({ + showgrid: false, + snapping_increment: 0, + diagonaltype: 'facing', + scale_number: 1, + }); + }, + adjustPageSizeToFitPositions: (page, questPositions) => { + const positions = Object.values(questPositions); + if (positions.length === 0) return; + const minX = Math.min(...positions.map(pos => pos.x)); + const maxX = Math.max(...positions.map(pos => pos.x)); + const minY = Math.min(...positions.map(pos => pos.y)); + const maxY = Math.max(...positions.map(pos => pos.y)); + const requiredWidthInPixels = (maxX - minX) + vars.ROUNDED_RECT_WIDTH + vars.HORIZONTAL_SPACING * 2; + const requiredHeightInPixels = (maxY - minY) + vars.ROUNDED_RECT_HEIGHT + vars.VERTICAL_SPACING * 2 + vars.PAGE_HEADER_HEIGHT; + const requiredWidthInUnits = Math.ceil(requiredWidthInPixels / vars.DEFAULT_PAGE_UNIT); + const requiredHeightInUnits = Math.ceil(requiredHeightInPixels / vars.DEFAULT_PAGE_UNIT); + page.set({ width: requiredWidthInUnits, height: requiredHeightInUnits }); + }, + clearPageObjects: (pageId, callback) => { + const pageElements = [ + ...findObjs({ _type: 'graphic', _pageid: pageId }), + ...findObjs({ _type: 'path', _pageid: pageId }), + ...findObjs({ _type: 'text', _pageid: pageId }) + ]; + pageElements.forEach(obj => obj.remove()); + if (typeof callback === 'function') callback(); + }, + buildPageHeader: (page) => { + const titleText = 'Quest Tracker Quest Tree'; + const descriptionText = 'A visual representation of all quests.'; + const pageWidth = page.get('width') * vars.DEFAULT_PAGE_UNIT; + const titleX = pageWidth / 2; + const titleY = 70; + D.drawText(page.id, titleX, titleY, titleText, '#000000', 'map', 32, 'Contrail One', null, 'center', 'middle'); + const descriptionY = titleY + 40; + D.drawText(page.id, titleX, descriptionY, descriptionText, '#666666', 'map', 18, 'Contrail One', null, 'center', 'middle'); + }, + storeQuestRef: (questId, type, objRef, target = null) => { + if (!QUEST_TRACKER_TreeObjRef[questId]) { + QUEST_TRACKER_TreeObjRef[questId] = { paths: {} }; + } + if (type === 'paths' && target) { + if (!QUEST_TRACKER_TreeObjRef[questId][type][target]) { + QUEST_TRACKER_TreeObjRef[questId][type][target] = []; + } + QUEST_TRACKER_TreeObjRef[questId][type][target].push(objRef); + } else { + QUEST_TRACKER_TreeObjRef[questId][type] = objRef; + } + saveQuestTrackerData(); + }, + replaceImageSize: (imgsrc) => { + return imgsrc.replace(/\/(med|original|max|min)\.(gif|jpg|jpeg|bmp|webp|png)(\?.*)?$/i, '/thumb.$2$3'); + }, + trimText: (text, maxLength = 150) => { + if (text.length > maxLength) { + return text.slice(0, maxLength - 3) + '...'; + } + return text; + }, + getStatusColor: (status) => { + switch (status) { + case 'Unknown': + return '#A9A9A9'; + case 'Discovered': + return '#ADD8E6'; + case 'Started': + return '#87CEFA'; + case 'Ongoing': + return '#FFD700'; + case 'Completed': + return '#32CD32'; + case 'Completed By Someone Else': + return '#4682B4'; + case 'Failed': + return '#FF6347'; + case 'Time ran out': + return '#FF8C00'; + case 'Ignored': + return '#D3D3D3'; + default: + return '#CCCCCC'; + } + }, + buildDAG: (questData, vars) => { + const questPositions = {}; + const groupMap = {}; + const mutualExclusivityClusters = []; + const visitedForClusters = new Set(); + const enabledQuests = Object.keys(questData).filter((questId) => !questData[questId]?.disabled); + function findMutualExclusivityCluster(startQuestId) { + const cluster = new Set(); + const stack = [startQuestId]; + while (stack.length > 0) { + const questId = stack.pop(); + if (!cluster.has(questId)) { + cluster.add(questId); + visitedForClusters.add(questId); + const mutuallyExclusiveQuests = + questData[questId]?.relationships?.mutually_exclusive || []; + mutuallyExclusiveQuests.forEach((meQuestId) => { + if (!cluster.has(meQuestId) && enabledQuests.includes(meQuestId)) { + stack.push(meQuestId); + } + }); + } + } + return cluster; + } + enabledQuests.forEach((questId) => { + if (!visitedForClusters.has(questId)) { + const cluster = findMutualExclusivityCluster(questId); + mutualExclusivityClusters.push(cluster); + } + }); + const questIdToClusterIndex = {}; + mutualExclusivityClusters.forEach((cluster, index) => { + cluster.forEach((questId) => { + questIdToClusterIndex[questId] = index; + }); + }); + const calculateInitialLevels = (questId, visited = new Set()) => { + if (visited.has(questId)) return questData[questId].level || 0; + visited.add(questId); + const prereqs = questData[questId]?.relationships?.conditions || []; + if (prereqs.length === 0) { + questData[questId].level = 0; + return 0; + } + const prereqLevels = prereqs.map((prereq) => { + let prereqId; + if (typeof prereq === "string") { + prereqId = prereq; + } else if (typeof prereq === "object" && prereq.conditions) { + prereqId = prereq.conditions[0]; + } + return calculateInitialLevels(prereqId, new Set(visited)) + 1; + }); + const level = Math.max(...prereqLevels); + questData[questId].level = level; + return level; + }; + enabledQuests.forEach((questId) => calculateInitialLevels(questId)); + enabledQuests.forEach((questId) => { + const group = questData[questId]?.group || "Default Group"; + if (!groupMap[group]) groupMap[group] = []; + groupMap[group].push(questId); + }); + const groupWidths = {}; + const groupOrder = Object.keys(groupMap); + Object.entries(groupMap).forEach(([groupName, groupQuests]) => { + const levels = {}; + groupQuests.forEach((questId) => { + const level = questData[questId].level; + if (!levels[level]) levels[level] = []; + levels[level].push(questId); + }); + const sortedLevels = Object.keys(levels).map(Number).sort((a, b) => a - b); + let maxLevelWidth = 0; + sortedLevels.forEach((level) => { + let questsAtLevel = levels[level]; + const totalQuests = questsAtLevel.length; + const clustersAtLevel = {}; + questsAtLevel.forEach((questId) => { + const clusterIndex = questIdToClusterIndex[questId] || null; + if (clusterIndex !== null) { + if (!clustersAtLevel[clusterIndex]) clustersAtLevel[clusterIndex] = new Set(); + clustersAtLevel[clusterIndex].add(questId); + } else { + if (!clustersAtLevel["no_cluster"]) clustersAtLevel["no_cluster"] = new Set(); + clustersAtLevel["no_cluster"].add(questId); + } + }); + const arrangedQuests = []; + Object.values(clustersAtLevel).forEach((cluster) => { + arrangedQuests.push(...Array.from(cluster)); + }); + levels[level] = arrangedQuests; + const levelWidth = + arrangedQuests.length * vars.ROUNDED_RECT_WIDTH + + (arrangedQuests.length - 1) * vars.HORIZONTAL_SPACING; + maxLevelWidth = Math.max(maxLevelWidth, levelWidth); + }); + groupWidths[groupName] = maxLevelWidth; + }); + const totalTreeWidth = groupOrder.reduce((sum, groupName, index) => { + return sum + groupWidths[groupName] + (index > 0 ? vars.GROUP_SPACING : 0); + }, 0); + let cumulativeGroupWidth = -totalTreeWidth / 2; + groupOrder.forEach((groupName) => { + const groupQuests = groupMap[groupName]; + const levels = {}; + groupQuests.forEach((questId) => { + const level = questData[questId].level; + if (!levels[level]) levels[level] = []; + levels[level].push(questId); + }); + const sortedLevels = Object.keys(levels).map(Number).sort((a, b) => a - b); + sortedLevels.forEach((level) => { + let questsAtLevel = levels[level]; + const totalQuests = questsAtLevel.length; + const arrangedQuests = levels[level]; + const levelWidth = + arrangedQuests.length * vars.ROUNDED_RECT_WIDTH + + (arrangedQuests.length - 1) * vars.HORIZONTAL_SPACING; + const levelStartX = cumulativeGroupWidth + (groupWidths[groupName] - levelWidth) / 2; + arrangedQuests.forEach((questId, index) => { + const x = + levelStartX + index * (vars.ROUNDED_RECT_WIDTH + vars.HORIZONTAL_SPACING); + const y = level * (vars.ROUNDED_RECT_HEIGHT + vars.VERTICAL_SPACING); + questPositions[questId] = { + x: x, + y: y, + group: groupName, + }; + }); + }); + cumulativeGroupWidth += groupWidths[groupName] + vars.GROUP_SPACING; + }); + return questPositions; + } + }; + const D = { + drawQuestTreeFromPositions: (page, questPositions, callback) => { + const totalWidth = page.get('width') * vars.DEFAULT_PAGE_UNIT; + Object.entries(questPositions).forEach(([questId, position]) => { + const questData = QUEST_TRACKER_globalQuestData[questId]; + if (!questData) { + errorCheck(32, 'msg', null,`Quest data for "${questId}" is missing.`); + return; + } + const x = position.x + totalWidth / 2; + const y = position.y + vars.PAGE_HEADER_HEIGHT + vars.VERTICAL_SPACING; + const isHidden = questData.hidden || false; + D.drawQuestGraphics(questId, questData, page.id, x, y, isHidden); + }); + if (typeof callback === 'function') callback(); + }, + drawQuestGraphics: (questId, questData, pageId, x, y, isHidden) => { + const questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + if (!questTable) { + errorCheck(33, 'msg', null,`Quests rollable table not found.`); + return; + } + const questTableItems = findObjs({ type: 'tableitem', rollabletableid: questTable.id }); + const questTableItem = questTableItems.find(item => item.get('name').toLowerCase() === questId.toLowerCase()); + if (!questTableItem) { + errorCheck(34, 'msg', null,`Rollable table item for quest "${questId}" not found.`); + return; + } + const statusWeight = questTableItem.get('weight'); + const statusName = statusMapping[statusWeight] || 'Unknown'; + const statusColor = H.getStatusColor(statusName); + let imgsrc = questTableItem.get('avatar'); + if (!imgsrc || !imgsrc.includes('https://')) { + imgsrc = QUEST_TRACKER_BASE_QUEST_ICON_URL; + } else { + imgsrc = H.replaceImageSize(imgsrc); + } + D.drawRoundedRectangle(pageId, x, y, vars.ROUNDED_RECT_WIDTH, vars.ROUNDED_RECT_HEIGHT, vars.ROUNDED_RECT_CORNER_RADIUS, statusColor, isHidden ? 'gmlayer' : 'map', questId); + const avatarSpacing = 10; + const avatarX = x; + const avatarY = y - (vars.ROUNDED_RECT_HEIGHT / 2) - (vars.AVATAR_SIZE / 2) - avatarSpacing; + if (imgsrc !== '') D.placeAvatar(pageId, avatarX, avatarY, vars.AVATAR_SIZE, imgsrc, isHidden ? 'gmlayer' : 'objects', questId); + }, + drawQuestTextAfterGraphics: (page, questPositions) => { + const totalWidth = page.get('width') * vars.DEFAULT_PAGE_UNIT; + Object.entries(questPositions).forEach(([questId, position]) => { + const questData = QUEST_TRACKER_globalQuestData[questId]; + if (!questData) { + errorCheck(35, 'msg', null,`Quest data for "${questId}" is missing.`); + return; + } + const x = position.x + totalWidth / 2; + const y = position.y + vars.PAGE_HEADER_HEIGHT + vars.VERTICAL_SPACING; + const isHidden = questData.hidden || false; + const textLayer = isHidden ? 'gmlayer' : 'objects'; + D.drawText( + page.id, + x, + y, + questData.name, + '#000000', + textLayer, + vars.TEXT_FONT_SIZE, + 'Contrail One', + questId, + 'center', + 'middle' + ); + }); + }, + drawQuestConnections: (pageId, questPositions) => { + const page = getObj('page', pageId); + const pageWidth = page.get('width') * vars.DEFAULT_PAGE_UNIT; + const offsetX = pageWidth / 2; + const incomingPaths = {}; + Object.entries(questPositions).forEach(([questId, position]) => { + const questData = QUEST_TRACKER_globalQuestData[questId]; + if (!questData) { + errorCheck(36, 'msg', null,`Quest data for "${questId}" is missing.`); + return; + } + (questData.relationships?.conditions || []).forEach(prereq => { + let prereqId = prereq; + if (typeof prereq === 'object' && prereq.conditions) { + prereqId = prereq.conditions[0]; + } + if (!incomingPaths[prereqId]) { + incomingPaths[prereqId] = []; + } + incomingPaths[prereqId].push(questId); + }); + }); + Object.entries(questPositions).forEach(([questId, position]) => { + const questData = QUEST_TRACKER_globalQuestData[questId]; + if (!questData) { + errorCheck(37, 'msg', null,`Quest data for "${questId}" is missing.`); + return; + } + const startX = position.x + offsetX; + const startY = position.y + vars.PAGE_HEADER_HEIGHT + vars.VERTICAL_SPACING; + const startPos = { + x: startX, + y: startY + }; + (questData.relationships?.conditions || []).forEach(prereq => { + let prereqId = prereq; + if (typeof prereq === 'object' && prereq.conditions) { + prereqId = prereq.conditions[0]; + } + const prereqPosition = questPositions[prereqId]; + if (!prereqPosition) return; + const endX = prereqPosition.x + offsetX; + const endY = prereqPosition.y + vars.PAGE_HEADER_HEIGHT + vars.VERTICAL_SPACING; + const endPos = { + x: endX, + y: endY + }; + let midY; + if (incomingPaths[prereqId].length > 1) { + midY = endPos.y + vars.VERTICAL_SPACING / 2; + } else { + midY = (startPos.y + endPos.y) / 2; + } + const isHidden = questData.hidden || QUEST_TRACKER_globalQuestData[prereqId]?.hidden; + const connectionColor = isHidden ? '#CCCCCC' : '#000000'; + const connectionLayer = isHidden ? 'gmlayer' : 'map'; + D.drawPath(pageId, startPos, endPos, connectionColor, connectionLayer, questId, prereqId, midY); + }); + }); + }, + drawPath: (pageId, startPos, endPos, color = '#FF0000', layer = 'objects', questId, pathToQuestId, controlY = null, isMutualExclusion = false) => { + let pathData; + let left, top, width, height; + controlY = (controlY === null) ? (startPos.y + endPos.y) / 2 : controlY; + if (isMutualExclusion) { + pathData = [ + ['M', startPos.x, startPos.y], + ['L', endPos.x, endPos.y] + ]; + } else { + pathData = [ + ['M', startPos.x, startPos.y], + ['L', startPos.x, controlY], + ['L', endPos.x, controlY], + ['L', endPos.x, endPos.y] + ]; + } + const minX = Math.min(startPos.x, endPos.x); + const maxX = Math.max(startPos.x, endPos.x); + const minY = Math.min(startPos.y, endPos.y, controlY); + const maxY = Math.max(startPos.y, endPos.y, controlY); + left = (minX + maxX) / 2; + top = (minY + maxY) / 2; + width = maxX - minX; + height = maxY - minY; + const adjustedPathData = pathData.map(command => { + const [cmd, ...coords] = command; + const adjustedCoords = coords.map((coord, index) => { + return coord - (index % 2 === 0 ? left : top); + }); + return [cmd, ...adjustedCoords]; + }); + const pathObj = createObj('path', { + _pageid: pageId, + layer: layer, + stroke: color, + fill: 'transparent', + path: JSON.stringify(adjustedPathData), + stroke_width: 2, + controlledby: '', + left: left, + top: top, + width: width, + height: height + }); + if (pathObj) { + if (isMutualExclusion) { + H.storeQuestRef(questId, 'mutualExclusion', pathObj.id, pathToQuestId); + H.storeQuestRef(pathToQuestId, 'mutualExclusion', pathObj.id, questId); + } else { + H.storeQuestRef(questId, 'paths', pathObj.id, pathToQuestId); + H.storeQuestRef(pathToQuestId, 'paths', pathObj.id, questId); + } + } + }, + drawMutuallyExclusiveConnections: (pageId, questPositions) => { + const page = getObj('page', pageId); + const pageWidth = page.get('width') * vars.DEFAULT_PAGE_UNIT; + const offsetX = pageWidth / 2; + const mutualExclusions = []; + Object.entries(QUEST_TRACKER_globalQuestData).forEach(([questId, questData]) => { + const mutuallyExclusiveWith = questData.relationships?.mutually_exclusive || []; + mutuallyExclusiveWith.forEach(otherQuestId => { + if (questId < otherQuestId) { + mutualExclusions.push([questId, otherQuestId]); + } + }); + }); + mutualExclusions.forEach(([questId1, questId2]) => { + const position1 = questPositions[questId1]; + const position2 = questPositions[questId2]; + if (!position1 || !position2) { + errorCheck(39, 'msg', null,`Position data for quests "${questId1}" or "${questId2}" is missing.`); + return; + } + const x1 = position1.x + offsetX; + const y1 = position1.y + vars.PAGE_HEADER_HEIGHT + vars.VERTICAL_SPACING; + const x2 = position2.x + offsetX; + const y2 = position2.y + vars.PAGE_HEADER_HEIGHT + vars.VERTICAL_SPACING; + const startPos = { x: x1, y: y1 }; + const endPos = { x: x2, y: y2 }; + const questData1 = QUEST_TRACKER_globalQuestData[questId1]; + const questData2 = QUEST_TRACKER_globalQuestData[questId2]; + const isHidden = questData1.hidden || questData2.hidden; + const connectionLayer = isHidden ? 'gmlayer' : 'map'; + D.drawPath(pageId, startPos, endPos, '#FF0000', connectionLayer, questId1, questId2, null, true); + }); + }, + drawText: (pageId, x, y, textContent, color = '#000000', layer = 'objects', font_size = vars.TEXT_FONT_SIZE, font_family = 'Arial', questId, text_align = 'center', vertical_align = 'middle') => { + const textObj = createObj('text', { + _pageid: pageId, + left: x, + top: y, + text: textContent, + font_size: font_size, + color: color, + layer: layer, + font_family: font_family, + text_align: text_align + }); + if (textObj) { + if (vertical_align !== 'middle') { + const textHeight = font_size; + let adjustedTop = y; + if (vertical_align === 'top') { + adjustedTop = y - (textHeight / 2); + } else if (vertical_align === 'bottom') { + adjustedTop = y + (textHeight / 2); + } + textObj.set('top', adjustedTop); + } + if (questId) { + H.storeQuestRef(questId, 'text', textObj.id); + } + } + }, + placeAvatar: (pageId, x, y, avatarSize, imgsrc, layer = 'objects', questId) => { + const questData = QUEST_TRACKER_globalQuestData[questId]; + let tooltipText = `${questData.description || 'No description available.'}`; + let trimmedText = H.trimText(tooltipText, 150); + let handoutLink = questData.handout ? `[Open Handout](http://journal.roll20.net/handout/${questData.handout})` : ''; + const avatarObj = createObj('graphic', { + _pageid: pageId, + left: x, + top: y, + width: avatarSize, + height: avatarSize, + layer: layer, + imgsrc: imgsrc, + tooltip: trimmedText, + controlledby: '', + gmnotes: ` + [Open Quest](!qt-menu action=quest|id=${questId}) + [Toggle Visibilty](!qt-quest action=update|field=hidden|current=${questId}|old=${questData.hidden}|new=${questData.hidden ? 'false ' : 'true'}) + [Change Status](!qt-quest action=update|field=status|current=${questId}|new=?{Change Status|Unknown,1|Discovered,2|Started,3|Ongoing,4|Completed,5|Completed By Someone Else,6|Failed,7|Time ran out,8|Ignored,9}) +
${handoutLink} + `, + name: `${questData.name || 'No description available.'}` + }); + if (avatarObj) { + H.storeQuestRef(questId, 'avatar', avatarObj.id); + } + }, + drawRoundedRectangle: (pageId, x, y, width, height, radius, statusColor, layer = 'objects', questId) => { + let pathData = []; + const w = width; + const h = height; + pathData = [ + ['M', -w / 2, -h / 2], + ['L', w / 2, -h / 2], + ['L', w / 2, h / 2], + ['L', -w / 2, h / 2], + ['L', -w / 2, -h / 2], + ['Z'] + ]; + const rectObj = createObj('path', { + _pageid: pageId, + layer: layer, + stroke: statusColor, + fill: "#FAFAD2", + path: JSON.stringify(pathData), + stroke_width: 4, + controlledby: '', + left: x, + top: y, + width: width, + height: height + }); + if (rectObj) { + H.storeQuestRef(questId, 'rectangle', rectObj.id); + } + }, + redrawQuestText: (questId) => { + let pageObj = findObjs({ _type: 'page', name: QUEST_TRACKER_pageName })[0]; + if (!pageObj) return; + const pageId = pageObj.id; + if (!QUEST_TRACKER_TreeObjRef[questId] || !QUEST_TRACKER_TreeObjRef[questId].text) return; + const textObjId = QUEST_TRACKER_TreeObjRef[questId].text; + const textObj = getObj('text', textObjId); + if (textObj) { + const questData = QUEST_TRACKER_globalQuestData[questId]; + if (!questData) { + errorCheck(152, 'msg', null,`Quest data for "${questId}" is missing.`); + return; + } + const isHidden = questData.hidden || false; + const textLayer = isHidden ? 'gmlayer' : 'objects'; + const x = textObj.get('left'); + const y = textObj.get('top'); + textObj.remove(); + D.drawText(pageId, x, y, questData.name, '#000000', textLayer, vars.TEXT_FONT_SIZE, 'Contrail One', questId); + } + } + }; + const buildQuestTreeOnPage = () => { + let questTreePage = findObjs({ _type: 'page', name: QUEST_TRACKER_pageName })[0]; + if (!questTreePage) { + errorCheck(40, 'msg', null,`Page "${QUEST_TRACKER_pageName}" not found. Please create the page manually.`); + return; + } + H.adjustPageSettings(questTreePage); + H.clearPageObjects(questTreePage.id, () => { + const questPositions = H.buildDAG(QUEST_TRACKER_globalQuestData, vars); + H.adjustPageSizeToFitPositions(questTreePage, questPositions); + H.buildPageHeader(questTreePage); + QUEST_TRACKER_TreeObjRef = {}; + D.drawQuestConnections(questTreePage.id, questPositions); + D.drawMutuallyExclusiveConnections(questTreePage.id, questPositions); + D.drawQuestTreeFromPositions(questTreePage, questPositions, () => { + D.drawQuestTextAfterGraphics(questTreePage, questPositions); + saveQuestTrackerData(); + }); + }); + }; + const updateQuestText = (questId, newText) => { + if (!QUEST_TRACKER_TreeObjRef[questId] || !QUEST_TRACKER_TreeObjRef[questId].text) return; + const textObjId = QUEST_TRACKER_TreeObjRef[questId].text; + const textObj = getObj('text', textObjId); + if (!textObj) return; + textObj.set('text', newText); + saveQuestTrackerData(); + }; + const updateQuestTooltip = (questId, newTooltip) => { + if (!QUEST_TRACKER_TreeObjRef[questId] || !QUEST_TRACKER_TreeObjRef[questId].avatar) return; + const avatarObjId = QUEST_TRACKER_TreeObjRef[questId].avatar; + const avatarObj = getObj('graphic', avatarObjId); + if (!avatarObj) return; + const trimmedTooltip = H.trimText(newTooltip, 150); + avatarObj.set('tooltip', trimmedTooltip); + saveQuestTrackerData(); + }; + const updateQuestStatusColor = (questId, statusNumber) => { + if (!QUEST_TRACKER_TreeObjRef[questId] || !QUEST_TRACKER_TreeObjRef[questId].rectangle) return; + const rectangleObjId = QUEST_TRACKER_TreeObjRef[questId].rectangle; + const rectangleObj = getObj('path', rectangleObjId); + if (!rectangleObj) return; + const statusName = statusMapping[statusNumber] || 'Unknown'; + const statusColor = H.getStatusColor(statusName); + rectangleObj.set('stroke', statusColor); + D.redrawQuestText(questId); + saveQuestTrackerData(); + }; + const updateQuestVisibility = (questId, makeHidden) => { + if (!QUEST_TRACKER_TreeObjRef[questId]) return; + const pageId = findObjs({ type: 'page', name: QUEST_TRACKER_pageName })[0].id; + if (typeof makeHidden === 'string') makeHidden = makeHidden.toLowerCase() === 'true'; + const targetLayer = makeHidden ? 'gmlayer' : 'map'; + const avatarLayer = makeHidden ? 'gmlayer' : 'objects'; + const textLayer = makeHidden ? 'gmlayer' : 'objects'; + for (const sourceQuestId in QUEST_TRACKER_TreeObjRef) { + const pathsToQuest = QUEST_TRACKER_TreeObjRef[sourceQuestId]?.paths?.[questId]; + if (pathsToQuest) { + pathsToQuest.forEach(segmentId => { + const pathObj = getObj('path', segmentId); + if (pathObj) { + pathObj.set({ + layer: targetLayer, + stroke: makeHidden ? '#CCCCCC' : '#000000' + }); + } + }); + } + } + ['rectangle', 'avatar', 'text'].forEach(element => { + const objId = QUEST_TRACKER_TreeObjRef[questId][element]; + const obj = getObj(element === 'rectangle' ? 'path' : 'graphic', objId); + if (obj) { + const layer = element === 'avatar' ? avatarLayer : textLayer; + obj.set('layer', layer); + } + }); + D.redrawQuestText(questId); + if (!makeHidden) { + saveQuestTrackerData(); + } + }; + return { + buildQuestTreeOnPage, + updateQuestText, + updateQuestTooltip, + updateQuestStatusColor, + updateQuestVisibility + }; + })(); + const Rumours = (() => { + const H = { + getNewRumourId: () => { + const existingRumourIds = []; + Object.values(QUEST_TRACKER_globalRumours).forEach(quest => { + Object.values(quest).forEach(status => { + Object.values(status).forEach(location => { + Object.keys(location).forEach(rumourId => { + if (location[rumourId] && typeof location[rumourId] === "object") { + const match = rumourId.match(/^rumour_(\d+)$/); + if (match) { + existingRumourIds.push(parseInt(match[1], 10)); + } + } + }); + }); + }); + }); + const highestRumourNumber = existingRumourIds.length > 0 ? Math.max(...existingRumourIds) : 0; + return `rumour_${highestRumourNumber + 1}`; + }, + getNewLocationId: (locationTable) => { + let locationItems = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }); + let maxWeight = locationItems.reduce((max, item) => { + return Math.max(max, item.get('weight')); + }, 0); + let newWeight = maxWeight + 1; + return newWeight; + }, + removeRumours: (locationTable, locationid) => { + const locationObject = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }) + .find(item => item.get('weight') == locationid); + if (!locationObject) return; + const cleanData = Utils.sanitizeString(locationObject.get('name')).toLowerCase(); + Object.entries(QUEST_TRACKER_globalRumours).forEach(([questId, questRumours]) => { + Object.entries(questRumours).forEach(([status, statusRumours]) => { + if (statusRumours[cleanData]) { + Object.entries(statusRumours[cleanData]).forEach(([rumourKey, rumourData]) => { + if (rumourData && typeof rumourData === "object") { + delete statusRumours[cleanData][rumourKey]; + } + }); + if (Object.keys(statusRumours[cleanData]).length === 0) { + delete statusRumours[cleanData]; + } + } + }); + }); + Utils.updateHandoutField('rumour'); + calculateRumoursByLocation(); + }, + saveData: () => { + saveQuestTrackerData(); + Utils.updateHandoutField('rumours'); + }, + findRumourLocation: (rumourId) => { + const locationTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS })[0]; + if (!locationTable) { + log("Error: Locations table not found."); + return null; + } + const locationItems = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }); + let locationMapping = locationItems.reduce((acc, location) => { + acc[Utils.sanitizeString(location.get('name').toLowerCase())] = location.get('weight'); + return acc; + }, {}); + for (const [questId, questRumours] of Object.entries(QUEST_TRACKER_globalRumours)) { + for (const [status, statusRumours] of Object.entries(questRumours)) { + for (const [location, locationRumours] of Object.entries(statusRumours)) { + if (locationRumours[rumourId]) { + const locationId = locationMapping[location] || null; + return { + questId, + status, + locationId, + once: locationRumours[rumourId].once || false + }; + } + } + } + } + return null; + } + }; + const convertRumoursToNewFormat = () => { + if (QUEST_TRACKER_versionChecking.RumourConversion) return; + let rumoursConverted = false; + for (const [questId, questData] of Object.entries(QUEST_TRACKER_globalRumours)) { + for (const [status, locations] of Object.entries(questData)) { + for (const [location, rumours] of Object.entries(locations)) { + for (const [rumourId, rumourText] of Object.entries(rumours)) { + QUEST_TRACKER_globalRumours[questId][status][location][rumourId] = { + text: rumourText, + type: "background", + once: false + }; + rumoursConverted = true; + } + } + } + } + QUEST_TRACKER_versionChecking.RumourConversion = true; + if (rumoursConverted) errorCheck(225, 'msg', null, `Rumours converted to new format (v1.2 update).`); + H.saveData(); + }; + const calculateRumoursByLocation = () => { + let rumoursByLocation = {}; + let questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + if (errorCheck(146, 'exists', questTable, `questTable`)) return; + let questItems = findObjs({ type: 'tableitem', rollabletableid: questTable.id }); + if (errorCheck(147, 'exists', questItems, `questItems`)) return; + Object.keys(QUEST_TRACKER_globalRumours).forEach(questId => { + let relevantItem = questItems.find(item => item.get('name').toLowerCase() === questId.toLowerCase()); + if (errorCheck(148, 'exists', relevantItem, `relevantItem for questId: ${questId}`)) return; + let relevantStatus = statusMapping[relevantItem.get('weight').toString()].toLowerCase(); + let questRumours = QUEST_TRACKER_globalRumours[questId] || {}; + if (questRumours[relevantStatus]) { + Object.keys(questRumours[relevantStatus] || {}).forEach(location => { + let locationRumours = questRumours[relevantStatus][location] || {}; + if (!rumoursByLocation[location]) rumoursByLocation[location] = {}; + Object.keys(locationRumours).forEach(rumourKey => { + const rumourObject = locationRumours[rumourKey]; + if (rumourObject && typeof rumourObject === "object") { + const rumourType = rumourObject.type || 'background'; + if (!rumoursByLocation[location][rumourType]) { + rumoursByLocation[location][rumourType] = {}; + } + rumoursByLocation[location][rumourType][rumourKey] = rumourObject.text; + } + }); + }); + } + }); + QUEST_TRACKER_rumoursByLocation = rumoursByLocation; + saveQuestTrackerData(); + }; + const sendRumours = (locationId, numberOfRumours) => { + let locationTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS })[0]; + if (errorCheck(41, 'exists', locationTable, `locationTable`)) return; + let locationItems = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }); + let location = locationItems.find(loc => loc.get('weight').toString() === locationId.toString()); + if (errorCheck(42, 'exists', location, `location`)) return; + const normalizedLocationId = Utils.sanitizeString(location.get('name')).toLowerCase(); + const locationRumours = QUEST_TRACKER_rumoursByLocation[normalizedLocationId] || { background: {}, priority: {} }; + const everywhereRumours = QUEST_TRACKER_rumoursByLocation['everywhere'] || { background: {}, priority: {} }; + let priorityList = [ + ...Object.entries(locationRumours.priority || {}), + ...Object.entries(everywhereRumours.priority || {}) + ]; + let backgroundList = [ + ...Object.entries(locationRumours.background || {}), + ...Object.entries(everywhereRumours.background || {}) + ]; + let priorityCount = Math.min(Math.ceil(numberOfRumours / 2), priorityList.length); + let backgroundCount = Math.min(numberOfRumours - priorityCount, backgroundList.length); + if (priorityList.length < priorityCount) { + backgroundCount = Math.min(numberOfRumours - priorityList.length, backgroundList.length); + priorityCount = priorityList.length; + } + const shuffleArray = (array) => array.sort(() => Math.random() - 0.5); + priorityList = shuffleArray(priorityList).slice(0, priorityCount); + backgroundList = shuffleArray(backgroundList).slice(0, backgroundCount); + const selectedRumours = [...priorityList, ...backgroundList]; + if (selectedRumours.length === 0) { + Utils.sendGMMessage(`No rumours available for this location.`); + return; + } + selectedRumours.forEach(([rumourId, rumourText]) => { + Utils.sendDescMessage(rumourText); + Triggers.checkTriggers('rumour', rumourId); + const rumourDetails = H.findRumourLocation(rumourId); + if (rumourDetails.once) { + manageRumourObject({ + action: 'remove', + questId: rumourDetails.questId, + newItem: '', + status: rumourDetails.status, + location: parseInt(rumourDetails.locationId, 10), + rumourId: rumourId + }); + } + }); + }; + const getLocationNameById = (locationId) => { + const locationTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS })[0]; + if (errorCheck(43, 'exists', locationTable,`locationTable`)) return null; + const locationItems = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }); + const locationItem = locationItems.find(item => item.get('weight').toString() === locationId.toString()); + if (errorCheck(44, 'exists', locationItem,`locationItem`)) return null; + return locationItem.get('name'); + }; + const removeAllRumoursForQuest = (questId) => { + if (!QUEST_TRACKER_globalRumours[questId]) return; + Object.keys(QUEST_TRACKER_globalRumours[questId]).forEach(status => { + const statusRumours = QUEST_TRACKER_globalRumours[questId][status] || {}; + Object.keys(statusRumours).forEach(location => { + const locationRumours = statusRumours[location]; + if (locationRumours && typeof locationRumours === 'object') { + Object.keys(locationRumours).forEach(rumourType => { + if (locationRumours[rumourType] && typeof locationRumours[rumourType] === 'object') { + Object.keys(locationRumours[rumourType]).forEach(rumourKey => { + delete locationRumours[rumourType][rumourKey]; + }); + } + }); + } + if (Object.keys(locationRumours.background || {}).length === 0 && + Object.keys(locationRumours.priority || {}).length === 0) { + delete statusRumours[location]; + } + }); + if (Object.keys(statusRumours).length === 0) { + delete QUEST_TRACKER_globalRumours[questId][status]; + } + }); + if (Object.keys(QUEST_TRACKER_globalRumours[questId]).length === 0) { + delete QUEST_TRACKER_globalRumours[questId]; + } + Utils.updateHandoutField('rumour'); + calculateRumoursByLocation(); + }; + const getAllLocations = () => { + let rollableTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS })[0]; + if (errorCheck(45, 'exists', rollableTable,`rollableTable`)) return []; + const tableItems = findObjs({ _type: 'tableitem', _rollabletableid: rollableTable.id }); + const locations = tableItems.map(item => item.get('name')); + return locations; + }; + const manageRumourLocation = (action, newItem = null, locationid = null) => { + const allLocations = Rumours.getAllLocations(); + let locationTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_LOCATIONS })[0]; + if (errorCheck(46, 'exists', locationTable,`locationTable`)) return; + switch (action) { + case 'add': + if (!newItem) return; + if (allLocations.some(loc => Utils.sanitizeString(loc.toLowerCase()) === Utils.sanitizeString(newItem.toLowerCase()))) return; + const newWeight = H.getNewLocationId(locationTable); + if (newWeight === undefined || newWeight === null) return; + let newLocation = createObj('tableitem', { + rollabletableid: locationTable.id, + name: newItem, + weight: newWeight + }); + break; + case 'remove': + if (!locationid || locationid === 1) return; + let locationR = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }).find(item => item.get('weight') == locationid); + H.removeRumours(locationTable,locationid) + locationR.remove(); + break; + case 'update': + if (allLocations.some(loc => Utils.sanitizeString(loc.toLowerCase()) === Utils.sanitizeString(newItem.toLowerCase())) || Utils.sanitizeString(newItem.toLowerCase()) === 'everywhere') return; + let locationU = findObjs({ type: 'tableitem', rollabletableid: locationTable.id }).find(item => item.get('weight') == locationid); + locationU.set('name', newItem); + break; + } + }; + const manageRumourObject = ({ action, questId, newItem = '', status, location, rumourId = '', type = 'background' }) => { + let locationString = getLocationNameById(location); + const sanitizedLocation = locationString ? Utils.sanitizeString(locationString.toLowerCase()) : ''; + if (!QUEST_TRACKER_globalRumours[questId]) QUEST_TRACKER_globalRumours[questId] = {}; + if (!QUEST_TRACKER_globalRumours[questId][status]) QUEST_TRACKER_globalRumours[questId][status] = {}; + const questRumours = QUEST_TRACKER_globalRumours[questId]; + const statusRumours = questRumours[status]; + if (!statusRumours[sanitizedLocation]) { + statusRumours[sanitizedLocation] = {}; + } + switch (action) { + case 'add': { + const newRumourKey = rumourId === '' ? H.getNewRumourId() : rumourId; + statusRumours[sanitizedLocation][newRumourKey] = { + text: newItem, + type: type, + once: false + }; + break; + } + case 'remove': { + if (statusRumours[sanitizedLocation][rumourId]) { + delete statusRumours[sanitizedLocation][rumourId]; + if (Object.keys(statusRumours[sanitizedLocation]).length === 0) { + delete statusRumours[sanitizedLocation]; + } + } + break; + } + case 'changeType': { + const rumourObj = statusRumours[sanitizedLocation][rumourId]; + if (rumourObj) { + rumourObj.type = rumourObj.type === 'priority' ? 'background' : 'priority'; + } + break; + } + case 'toggleOnce': { + const rumourObj = statusRumours[sanitizedLocation][rumourId]; + if (rumourObj) { + rumourObj.once = !rumourObj.once; + } + break; + } + default: + break; + } + Utils.updateHandoutField('rumour'); + calculateRumoursByLocation(); + }; + return { + calculateRumoursByLocation, + sendRumours, + manageRumourLocation, + getLocationNameById, + removeAllRumoursForQuest, + getAllLocations, + manageRumourObject, + convertRumoursToNewFormat + }; + })(); + const Menu = (() => { + const styles = { + menu: 'background-color: #fff; border: 1px solid #000; padding: 5px; border-radius: 5px; overflow: hidden;', + button: 'margin-top: 1px; display: inline-block; background-color: #000; border: 1px solid #292929 ; border-radius: 3px; padding: 2px; color: #fff; text-align: center;', + buttonDisabled: 'pointer-events: none; background-color: #666; border: 1px solid #292929; border-radius: 3px; padding: 2px; text-align: center; color: #000000;', + spanInline: 'display: inline-block; margin-top: 1px;', + smallButton: 'display: inline-block; width: 12px; height:16px;', + smallButtonContainer: 'text-align:center; width: 20px; padding:1px', + smallButtonAdd: 'text-align:right; width: 20px; padding:1px margin-right:1px', + smallerText: 'font-size: smaller', + list: 'list-style none; padding: 0; margin: 0; overflow: hidden;', + label: 'float: left; font-weight: bold;', + topBorder: 'border-top: 1px solid #ddd;', + bottomBorder: 'border-bottom: 1px solid #ddd;', + topMargin: 'margin-top: 20px;', + column: 'overflow: hidden; padding: 5px 0;', + marginRight: 'margin-right: 2px', + strikethrough: 'text-decoration: line-through;', + floatLeft: 'float: left;', + floatRight: 'float: right;', + floatClearRight: 'float: right; clear: right;', + overflow: 'overflow: hidden; margin:1px', + rumour: 'text-overflow: ellipsis;overflow: hidden;width: 100px;display: block;word-break: break-all;white-space: nowrap;', + link: 'color: #007bff; text-decoration: underline; cursor: pointer;', + questlink: 'color: #007bff; text-decoration: none; cursor: pointer; background-color: #FFFFFF;', + filterlink: 'color: #007bff; text-decoration: none; cursor: pointer; background-color: #FFFFFF; padding:0px;', + paddedfilterlink: 'color: #007bff; text-decoration: none; cursor: pointer; background-color: #FFFFFF; padding:5px;', + treeStyle: 'display: inline-block; position: relative; text-align: center; margin-top: 0px;', + questBox50: 'display: inline-block; width: 15px; height: 6px; padding: 5px; border: 1px solid #000; border-radius: 5px; background-color: #f5f5f5; text-align: center; position: relative; margin-right: 20px;', + verticalLineStyle: 'position: absolute; width: 2px; background-color: black;', + lineHorizontalRed: 'position: absolute; width: 24px; height: 2px; background-color: red; left: 57%;', + lineHorizontal: 'position: absolute; height: 2px; background-color: black;', + treeContainerStyle: 'position: relative; width: 100%; height: 100%; text-align: center; margin-top: 20px;', + ulStyle: 'list-style: none; position: relative; padding: 0; margin: 0; display: block; text-align: center;', + liStyle: 'display: inline-block; text-align: center; position: relative;', + spanText: 'bottom: -1px; position: absolute; left: -1px; right: 0px;', + centreImage: 'display: block; margin: auto; text-align: center;' + }; + const H = { + showActiveQuests: () => { + let AQMenu = ""; + const activeStatuses = [2, 3, 4]; + const activeQuests = QUEST_TRACKER_globalQuestArray + .filter(quest => { + const status = parseInt(Quest.getQuestStatus(quest.id), 10); + return activeStatuses.includes(status); + }) + .map(quest => quest.id); + if (activeQuests.length === 0) { + AQMenu += ``; + } else { + AQMenu += ``; + } + return AQMenu; + }, + showActiveRumours: () => { + let menu = ``; + return menu; + }, + showTriggers: () => { + let menu = ``; + return menu; + }, + calculateStartingGroupNum: (conditions, isInLogicGroup = false) => { + let count = 0; + if (isInLogicGroup) return count; + for (let i = 0; i < conditions.length; i++) { + if (typeof conditions[i] === 'object' && conditions[i].logic) { + break; + } + if (typeof conditions[i] === 'string') { + count++; + } + } + return count; + }, + calculateGroupNum: (condition, conditions, groupnum) => { + let count = 0; + for (let i = 0; i < conditions.length; i++) { + if (conditions[i] === condition) { + break; + } + if (typeof conditions[i] === 'object' && conditions[i].logic) { + count++; + } + } + return groupnum + count; + }, + formatConditions: (questId, conditions, parentLogic = 'AND', indent = false, groupnum = 0, isInLogicGroup = false) => { + if (!Array.isArray(conditions)) return ''; + let spanOrAnchor = `${H.buildDropdownString(questId) === '' ? 'span' : 'a'}`; + let renderButtonStyle = `${H.buildDropdownString(questId) === '' ? styles.buttonDisabled : styles.button}`; + groupnum += H.calculateStartingGroupNum(conditions, isInLogicGroup); + return conditions.map((condition, index) => { + const currentGroupNum = H.calculateGroupNum(condition, conditions, groupnum); + const displayIndex = index + 1; + const isLastCondition = displayIndex === conditions.length; + const isLastnonGroupCondition = (index + 1 < conditions.length && typeof conditions[index + 1] === 'object') || index === conditions.length - 1; + const isOnlyGroupCondition = conditions.length === 1 && typeof conditions[0] === 'object'; + if (typeof condition === 'string') { + return ` + + ${indent ? ` ` : ``} + ${H.getQuestName(condition)} + + + c + + + - + + + ${indent && isLastCondition ? ` + +   + + Add Relationship + + + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=group|groupnum=${currentGroupNum}|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ + + + ` : ''} + ${!indent && isLastnonGroupCondition ? ` + + + Add Relationship + + + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=single|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ + + + ` : ''} + `; + } else if (typeof condition === 'object' && condition.logic && Array.isArray(condition.conditions)) { + const subLogic = H.formatConditions(questId, condition.conditions, condition.logic, true, currentGroupNum, true); + const reverseLogic = condition.logic === 'AND' ? 'OR' : 'AND'; + let addRelasionshipRow = '' + if (currentGroupNum === 0) { + addRelasionshipRow += ` + + + Add Relationship + + + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=single|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ + + `; + } + return ` + ${addRelasionshipRow} + +   + ${condition.logic} + + + c + + + - + + + ${subLogic} + `; + } + }).join(''); + }, + buildDropdownString: (questId) => { + if (!Quest.getValidQuestsForDropdown(questId)) return ''; + else { + const validQuests = Quest.getValidQuestsForDropdown(questId); + if (validQuests.length === 1) return validQuests[0]; + validQuests.sort((a, b) => H.getQuestName(a).localeCompare(H.getQuestName(b))); + const dropdownString = validQuests.map(questId => { + return `${H.getQuestName(questId)},${questId}`; + }).join('|'); + return `?{Choose Quest|${dropdownString}}`; + } + }, + getQuestName: (questId) => { + return QUEST_TRACKER_globalQuestData[questId]?.name.replace(/[{}|&?]/g, '') || 'Unnamed Quest'; + }, + getEventName: (eventId) => { + return QUEST_TRACKER_Events[eventId]?.name.replace(/[{}|&?]/g, '') || 'Unnamed Event'; + }, + relationshipMenu: (questId) => { + const quest = QUEST_TRACKER_globalQuestData[questId]; + let htmlOutput = ""; + let spanOrAnchor = `${H.buildDropdownString(questId) === '' ? 'span' : 'a'}`; + let renderButtonStyle = `${H.buildDropdownString(questId) === '' ? styles.buttonDisabled : styles.button}`; + if (!quest || !quest.relationships || !Array.isArray(quest.relationships.conditions) || quest.relationships.conditions.length === 0) { + htmlOutput += `
+ + + + + + + + +
+ Add Relationship + + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=single|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ +
Add Relationship Group + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=addgroup|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ +
`; + } else { + const conditionsHtml = H.formatConditions(questId, quest.relationships.conditions, quest.relationships.logic || 'AND'); + htmlOutput += ` + + ${quest.relationships.conditions.length > 1 ? ` + + + ` : ''} + ${conditionsHtml} + + + + +
+ ${quest.relationships.logic || 'AND'} + + c +
+ Add Relationship Group + + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=addgroup|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ +
`; + } + let mutuallyExclusiveHtml = ""; + if (Array.isArray(quest.relationships.mutually_exclusive) && quest.relationships.mutually_exclusive.length > 0) { + mutuallyExclusiveHtml += quest.relationships.mutually_exclusive.map(exclusive => ` + + + ${H.getQuestName(exclusive)} + + + c + + + - + + + `).join(''); + } else { + mutuallyExclusiveHtml += `No mutually exclusive quests available.`; + } + htmlOutput += ` +
+

Mutually Exclusive Quests

+ + ${mutuallyExclusiveHtml} + + + + +
+ <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=mutuallyexclusive|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ +
`; + return htmlOutput; + }, + getValidQuestGroups: (questId) => { + let result = ''; + const quest = QUEST_TRACKER_globalQuestData[questId]; + const questGroupsTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS })[0]; + if (!questGroupsTable) return result; + const questGroups = findObjs({ type: 'tableitem', rollabletableid: questGroupsTable.id }); + if (quest && quest.group) { + if (questGroups.length === 1) { + return "remove"; + } + else { + result += 'Remove from Group,remove|'; + } + } + result += questGroups + .filter(group => parseInt(quest.group) !== parseInt(group.get('weight'))) + .map(group => `${group.get('name')},${group.get('weight')}`) + .join('|'); + if (result.includes('|')) return "?{Change Quest Grouping|" + result + "}"; + else { + const [f,s] = result.split(','); + return s; + } + }, + getQuestGroupNameByWeight: (weight) => { + if (!weight) return 'No Assigned Group'; + let groupTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTGROUPS })[0]; + if (!groupTable) { + Utils.sendGMMessage('Error: Quest Groups table not found. Please check if the table exists in the game.'); + return null; + } + let groupItems = findObjs({ type: 'tableitem', rollabletableid: groupTable.id }); + let group = groupItems.find(item => item.get('weight') == weight); + return group.get('name'); + }, + showUpcomingEvents: () => { + const upcomingEvents = Calendar.getNextEvents(5); + let menu = ""; + if (upcomingEvents.length === 0) { + menu += ``; + } else { + menu += ``; + } + + return menu; + }, + buildFrequencyDropdown: () => { + const dropdownString = Object.entries(frequencyMapping) + .map(([key, value]) => `|${value},${key}`) + .join(''); + return dropdownString; + }, + buildLocationDropdown: () => { + const dropdownString = Object.entries(WEATHER.enviroments) + .map(([key, value]) => `|${value.name},${key}`) + .join(''); + return dropdownString; + }, + returnCurrentLocation: (key) => { + const { WEATHER } = getCalendarAndWeatherData(); + if (WEATHER.enviroments && WEATHER.enviroments[key]) return WEATHER.enviroments[key].name; + else return "Unknown Location"; + }, + buildCalenderDropdown: () => { + const dropdownString = Object.entries(CALENDARS) + .map(([key, value]) => `|${value.name},${key}`) + .join(''); + return dropdownString; + }, + buildClimateDropdown: () => { + const currentCalendar = CALENDARS[QUEST_TRACKER_calenderType]; + const dropdownString = Object.keys(currentCalendar.climates) + .map((climate) => `|${climate.charAt(0).toUpperCase() + climate.slice(1)},${climate}`) + .join(""); + return dropdownString; + }, + hasMultipleMoons: (l) => { + if (Object.keys(l).length > 1) return true; + else return false; + }, + lunarPhases: () => { + const calendar = CALENDARS[QUEST_TRACKER_calenderType]; + if (errorCheck(155, 'exists', calendar.lunarCycle, `calendar.lunarCycle`)) return; + const currentDate = QUEST_TRACKER_currentDate; + let output = `Lunar Phase${H.hasMultipleMoons(calendar.lunarCycle) ? 's' : ''}`; + for (const moonId in calendar.lunarCycle) { + if (calendar.lunarCycle.hasOwnProperty(moonId)) { + const phase = Calendar.getLunarPhase(currentDate, moonId); + output += `${phase}`; + } + } + return output; + }, + displayQuestToken: (questId) => { + let quest = QUEST_TRACKER_globalQuestData[questId]; + if (errorCheck(158, 'exists', quest, `quest`)) return; + const questTable = findObjs({ type: 'rollabletable', name: QUEST_TRACKER_ROLLABLETABLE_QUESTS })[0]; + if (errorCheck(156, 'exists', questTable, `questTable`)) return; + const questTableItems = findObjs({ type: 'tableitem', rollabletableid: questTable.id }); + const questTableItem = questTableItems.find(item => item.get('name').toLowerCase() === questId.toLowerCase()); + if (errorCheck(157, 'exists', questTableItem, `questTableItem`)) return; + let imgsrc = questTableItem.get('avatar'); + if (!imgsrc) imgsrc = QUEST_TRACKER_BASE_QUEST_ICON_URL; + const strippedimgsrc = imgsrc.split('?')[0]; + if (strippedimgsrc) return `[${quest.name}](${strippedimgsrc})`; + else return ""; + }, + displayQuestHandout: (questId) => { + let quest = QUEST_TRACKER_globalQuestData[questId]; + if (errorCheck(159, 'exists', quest, `quest`)) return; + let html = `

Linked Handout

`; + let linkHandoutURL = `!qt-quest action=linkhandout|current=${questId}|key=?{How to Link|Auto Link,AUTO|Manual Link,?{Key}`; + if (!quest.handout) { + html += ` + + Link + `; + } + else { + const handout = findObjs({ _type: 'handout', id: quest.handout })[0]; + let handoutName = "Error: Fix Handout Link"; + let err = true; + if (!errorCheck(160, 'exists', handout, `handout`)) { + err = false; + handoutName = handout.get('name'); + } + let openHTML = err ? `` : `Open `; + html += `${handoutName} + + ${openHTML}${err ? 'Fix' : 'Change'} - + `; + } + return html; + }, + applyFilter: (filter, questData) => { + if (!filter || Object.keys(filter).length === 0) return true; + if (!questData) return false; + + return Object.entries(filter).every(([key, value]) => { + if (key === 'group') { + if (value === undefined || (Array.isArray(value) && value.length === 0)) return true; + if (Array.isArray(value)) return value.map(String).includes(String(questData.group)); + return false; + } else if (['handout', 'disabled', 'hidden'].includes(key)) { + if (value === undefined) return true; + if (value === true) return !!questData[key]; + if (value === false) return !questData[key]; + } else if (Array.isArray(value)) { + return value.includes(questData[key]); + } else if (typeof value === 'boolean') { + return questData[key] === value; + } else { + return `${questData[key]}` === `${value}`; + } + }); + }, + renderQuestList: (quests, groupBy, type = 'quest') => { + let menu = ''; + const groupedQuests = groupBy + ? quests.reduce((acc, quest) => { + let groupKey; + if (groupBy === 'handout') { + groupKey = quest.handout ? 'Linked Handout' : 'No Handout Linked'; + } else if (groupBy === 'disabled') { + groupKey = quest.disabled === true || quest.disabled === 'true' ? 'Disabled' : 'Enabled'; + } else if (groupBy === 'visibility') { + groupKey = quest.visibility === true || quest.visibility === 'true' ? 'Hidden' : 'Visible'; + } else if (groupBy === 'group') { + groupKey = H.getQuestGroupNameByWeight(quest.group) || 'Ungrouped'; + } else { + groupKey = quest[groupBy] || 'Ungrouped'; + } + if (!acc[groupKey]) acc[groupKey] = []; + acc[groupKey].push(quest); + return acc; + }, {}) + : { All: quests }; + Object.keys(groupedQuests).forEach(groupKey => { + menu += groupBy ? `

${groupKey}

` : ''; + const sortedQuests = groupedQuests[groupKey].sort((a, b) => { + const nameA = (a.name || '').toLowerCase(); + const nameB = (b.name || '').toLowerCase(); + return nameA.localeCompare(nameB); + }); + if (type === 'quest') { + menu += '