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})
+
+ 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}">+ + | +
+ ${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}">+ + | +
+ | + <${spanOrAnchor} href="!qt-questrelationship currentquest=${questId}|action=add|type=mutuallyexclusive|quest=${H.buildDropdownString(questId)}" style="${renderButtonStyle} ${styles.smallButton}">+ + | +
Weather | |
${QUEST_TRACKER_CURRENT_WEATHER['weatherType']} | |
Location | |
${H.returnCurrentLocation(QUEST_TRACKER_WeatherLocation)} | Change |
Temperature | ${temperatureDisplay} |
${QUEST_TRACKER_CURRENT_WEATHER['scaleDescriptions']['temperature']} | |
Precipitation | ${precipitationDisplay} |
${QUEST_TRACKER_CURRENT_WEATHER['scaleDescriptions']['precipitation']} | |
Wind | ${windSpeedDisplay} |
${QUEST_TRACKER_CURRENT_WEATHER['scaleDescriptions']['wind']} | |
Humidity | ${humidityDisplay}% |
${QUEST_TRACKER_CURRENT_WEATHER['scaleDescriptions']['humidity']} | |
Cloud Cover | ${cloudCoverDisplay}% |
${QUEST_TRACKER_CURRENT_WEATHER['scaleDescriptions']['cloudCover']} | |
Visibility | ${visibilityDisplay} |
${QUEST_TRACKER_CURRENT_WEATHER['scaleDescriptions']['visibility']} |
`;
+ html += l.buildQuestListHTML(flattenedLogic, columnInstructionsMap, 0);
+ html += `
+
|
There doesn't seem to be any Quests. You need to create a quest or Import from the Handouts.
+ `; + } else { + const filteredQuests = QUEST_TRACKER_globalQuestArray + .map(quest => { + const questData = QUEST_TRACKER_globalQuestData[quest.id]; + if (questData) { + const normalizedData = Object.keys(questData).reduce((acc, key) => { + acc[key.toLowerCase()] = questData[key]; + return acc; + }, {}); + return H.applyFilter(QUEST_TRACKER_FILTER.filter, normalizedData) + ? { ...quest, ...normalizedData } + : null; + } + return null; + }) + .filter(Boolean); + menu += H.renderQuestList(filteredQuests, QUEST_TRACKER_FILTER.groupBy); + } + menu += ` +This menu displays all the rumours currently associated with quests. Use the options below to filter, navigate through locations, and modify rumours.
`; + if (Object.keys(QUEST_TRACKER_globalQuestData).length === 0) { + menu += `There are no quests available. You need to create quests or import from handouts.
`; + } else { + const filteredQuests = Object.keys(QUEST_TRACKER_globalQuestData) + .map(questId => { + const questData = QUEST_TRACKER_globalQuestData[questId] || {}; + const normalizedData = Object.keys(questData).reduce((acc, key) => { + acc[key.toLowerCase()] = questData[key]; + return acc; + }, {}); + if (!H.applyFilter(QUEST_TRACKER_RUMOUR_FILTER.filter, normalizedData)) return null; + const questRumours = QUEST_TRACKER_globalRumours[questId] || {}; + let rumourCount = Object.values(questRumours) + .reduce((sum, statusRumours) => + sum + Object.values(statusRumours) + .reduce((locSum, locationRumours) => + locSum + Object.keys(locationRumours).length, + 0), 0); + return { + id: questId, + name: questData.name || `Quest: ${questId}`, + rumourCount + }; + }) + .filter(Boolean) + .sort((a, b) => a.name.localeCompare(b.name)); + menu += H.renderQuestList(filteredQuests, QUEST_TRACKER_RUMOUR_FILTER.groupBy, 'rumour'); + } + menu += ` +${questData.description || "No description available."}
`; + const questRumours = QUEST_TRACKER_globalRumours[questId] || {}; + const allStatuses = Object.values(statusMapping); + if (allStatuses.length > 0) { + menu += `${status} ${rumourCount} rumour${rumourCount === 1 ? '' : 's'} |
+ + Show + | +
There are no rumours available; either refresh the data, or start adding manually.
+This menu displays all the rumours currently associated with ${questDisplayName} under the status "${statusName}". Use the options below to update, add, or remove rumours.
To add new lines into the rumours use %NEWLINE%. To add in quotation marks you need to use ".
Error: Locations table not found. Please check if the table exists in the game.
+${cleanRumour} | ++ 👁 + | ++ c + | ++ - + | ++ ${rumourType === 'priority' ? 'p' : 'b'} + | ++ ${rumourData.once ? '1' : '∞'} + | ++ T + | +
No rumours | ||||||
+ | + + + | +
${quest.description || 'No description available.'}
+ + Edit Title + + Edit Description + +Error: Locations table not found. Please check if the table exists in the game.
Error: Quest Groups table not found. Please check if the table exists in the game.
There doesn't seem to be any Events, you need to create a quest or Import from the Handouts.
+ `; + } else { + menu += `${event.description || 'No description available.'}
+ + Edit Event Name + + Edit Description + +This menu displays all the triggers currently associated with quests, dates, reactions, events, and scripts.
`; + if (scriptTriggers.length > 0) { + menu += `No triggers found. Click 'Add Trigger' to create one.
`; + } else { + if (allTriggers.length !== 0) menu += `This script trigger executes when activated.
`; + break; + } + effectsSection = `+ | Effect | +${effectCat} | +
+ | Event | +${effectEventName} | +
+ | Type | +${effect.type === null ? 'Choose Type' : 'Enabled'} | +
+ | Value | +${effect.type !== null ? `${effectValue}` : `${effectValue}`} | +
+ | Quest | +${effectQuestName} | +
+ | Type | +${effectType} | +
+ | Value | +${effect.type !== null ? `${effectValue}` : `${effectValue}`} | +
+ | Trigger | +${effect.id === null ? `Set Trigger` : `${H.getTriggerName(effect.id)}`} | +
+ | Type | +${triggerEffectType} | +
+ | Value | +${triggerEffectValue} | +
Delete | ++ | |
Add Effect |
Enabled | ${enabled ? 'Enabled' : 'Disabled'} |
Active | ${active ? 'Enabled' : 'Disabled'} |
Trigger Type | ${capitalizedType} |
Triggering Event | ${activationSection} |