import { Icon, Quest, QuestBook, QuestVisibility } from "#types/bqQuestBook.ts"; import { diff } from "just-diff"; import { Changed, YesIgnoreNo, QuestChange, Replacements, SavedPorter, SpecialModifierHandler, } from "#types/actionQBTypes.ts"; import upath from "upath"; import fs from "fs"; import PortQBData from "./questPorting/portQBData.ts"; import { input, select } from "@inquirer/prompts"; import { configFolder, configOverridesFolder, rootDirectory, storageFolder, } from "#globals"; import logInfo, { logError, logWarn } from "#utils/log.ts"; import colors from "colors"; import { getUniqueToArray } from "#utils/util.ts"; import sortKeys from "sort-keys"; import lodash from "lodash"; let data: PortQBData; export const emptyQuestName = "Gap"; export const emptyQuestDescription = "Unused Gap Quest. Prevents Overriding Of IDs."; export const emptyQuestVisibility: QuestVisibility = "HIDDEN"; export const emptyQuestIconId = "minecraft:air"; export const emptyQuestTaskId = "bq_standard:checkbox"; export const emptyQuest: Quest = { "preRequisites:11": [], "properties:10": { "betterquesting:10": { "autoclaim:1": 0, "desc:8": "Unused Gap Quest. Prevents Overriding Of IDs.", "globalshare:1": 0, "icon:10": { "Count:3": 0, "Damage:2": 0, "OreDict:8": "", "id:8": "minecraft:air", }, "ignoresview:1": 0, "ismain:1": 0, "issilent:1": 1, "lockedprogress:1": 0, "name:8": "Gap", "partysinglereward:1": 0, "questlogic:8": "AND", "repeat_relative:1": 1, "repeattime:3": -1, "simultaneous:1": 0, "snd_complete:8": "minecraft:entity.player.levelup", "snd_update:8": "minecraft:entity.player.levelup", "tasklogic:8": "AND", "visibility:8": "HIDDEN", }, }, "questID:3": 0, "rewards:9": {}, "tasks:9": { "0:10": { "index:3": 0, "taskID:8": "bq_standard:checkbox", }, }, }; export const defaultPorter = { savedQuestMap: [], ignoreQuestsNormal: [], ignoreQuestsExpert: [], alwaysAskQuestsNormal: [], alwaysAskQuestsExpert: [], } as SavedPorter; /* Paths */ export const cfgNormalPath = upath.join( configFolder, "betterquesting", "DefaultQuests.json", ); export const cfgExpertPath = upath.join( configFolder, "betterquesting", "saved_quests", "ExpertQuests.json", ); export const cfgOverrideNormalPath = upath.join( configOverridesFolder, "normal", "betterquesting", "DefaultQuests.json", ); export const cfgOverrideExpertPath = upath.join( configOverridesFolder, "expert", "betterquesting", "DefaultQuests.json", ); export const savedQuestPorter = upath.join(storageFolder, "savedQBPorter.json"); const nomiCoinMatcher = /^nomilabs:nomicoin[0-9]*$/; export function setupUtils(dataIn: PortQBData): void { data = dataIn; } export function removeFormatting(input: string): string { if (!input.includes("§")) return input; const builder: string[] = []; for (let i = 0; i < input.length; i++) { const char = input.charAt(i); if (char === "§") { i++; // Skip Next Character continue; } builder.push(char); } return builder.join(""); } export function stripRewards(quest: Quest, shouldCheck = false, log = false) { for (const rewardKey of Object.keys(quest["rewards:9"])) { const reward = quest["rewards:9"][rewardKey]; if ( !reward || reward["rewardID:8"] !== "bq_standard:item" || !reward["rewards:9"] ) continue; for (const itemKey of Object.keys(reward["rewards:9"])) { const item: Icon = reward["rewards:9"][itemKey]; if (item && item["id:8"] && nomiCoinMatcher.test(item["id:8"])) { if (shouldCheck) throw new Error( `Expert Quest with ID ${quest["questID:3"]} has Nomi Coin Reward!`, ); if (log) logWarn( `Removing Nomi Coin Reward for Expert Quest with ID ${quest["questID:3"]}...`, ); delete reward["rewards:9"][itemKey]; } } if (Object.keys(reward["rewards:9"]).length === 0) delete quest["rewards:9"][rewardKey]; else quest["rewards:9"][rewardKey] = reward; } } let cachedQuestByName: Map; /** * Finds the corresponding quest on the qb to change, using the data cache. If object is not found in the data cache, asks the client questions to determine the quest. * @param sourceId The id of the quest on the source qb. * @param sourceQuest The Source Quest, if it is not just `data.currentIDsToQuests.get(sourceId)`. * @return Returns the quest that is found, or undefined if the quest should be skipped. */ export async function findQuest( sourceId: number, sourceQuest?: Quest, ): Promise { if (data.ignoreQuests.has(sourceId)) return undefined; if (data.foundQuests.has(sourceId)) return data.foundQuests.get(sourceId); // If no source quest, default behaviour if (!sourceQuest) sourceQuest = data.currentIDsToQuests.get(sourceId); // If still no source quest, throw if (!sourceQuest) throw new Error( `Request Find Quest for id ${sourceId}, which is not in IDs to Quests!`, ); logInfo( colors.magenta( `Finding Corresponding Quest for Source Quest with ID ${sourceId} and Name ${name(sourceQuest)}...`, ), ); // Try Find by ID const questById = data.toChangeIDsToQuests.get(sourceId); if (questById && !isEmptyQuest(questById)) { // Ask the client if the corresponding id on the corresponding qb is correct const correctQuestById = await isRightQuest( `Does the Corresponding Quest have ID ${sourceId} and Name ${name(questById)}?`, ); if (correctQuestById === "YES") { logInfo("Using Quest..."); await finalizeFoundQuest(sourceId, () => data.foundQuests.set(sourceId, questById), ); return questById; } if (correctQuestById === "IGNORE") { logInfo("Ignoring..."); await finalizeFoundQuest(sourceId, () => data.ignoreQuests.add(sourceId)); return undefined; } } // Generate Quest By Name if Needed if (!cachedQuestByName) { cachedQuestByName = new Map(); [...data.toChangeIDsToQuests.values()].forEach((item) => cachedQuestByName.set(removeFormatting(name(item)), item), ); } // Try Find by Name const removeFormattedName = removeFormatting(name(sourceQuest)); const questByName = cachedQuestByName.get(removeFormattedName); if (questByName && !isEmptyQuest(questByName)) { // Ask the client if the corresponding id on the corresponding qb is correct const correctQuestByName = await isRightQuest( `Does the Corresponding Quest have ID ${id(questByName)} and Name ${name(questByName)}?`, ); if (correctQuestByName === "YES") { logInfo("Using Quest..."); await finalizeFoundQuest(sourceId, () => data.foundQuests.set(sourceId, questByName), ); return questByName; } if (correctQuestByName === "IGNORE") { logInfo("Cancelling..."); await finalizeFoundQuest(sourceId, () => data.ignoreQuests.add(sourceId)); return undefined; } } // Finally, ask for the specific ID let foundBySpecificID: YesIgnoreNo = "NO"; let questBySpecificID: Quest | undefined = undefined; while (foundBySpecificID === "NO") { const specID = parseInt( await input({ message: "Please Provide a Specific Quest ID to be used as the Corresponding Quest. Enter -1 to Skip/Cancel this Quest!", validate: (value) => { const numValue = parseInt(value); if (numValue === -1) return true; // Allow Cancelling if (isNaN(numValue) || numValue < 0) { return "Please Enter a Number Value >= 0!"; } return true; }, }), ); if (specID === -1) { logInfo("Cancelling..."); foundBySpecificID = "IGNORE"; break; } questBySpecificID = data.toChangeIDsToQuests.get(specID); if (!questBySpecificID) { logError(`${specID} is not a Quest ID in the Quest Book being Changed!`); continue; } if (isEmptyQuest(questBySpecificID)) { logError( `${specID} is a Empty Quest! Enter -1 to Skip/Cancel this Quest, not the ID of an Empty Quest!`, ); continue; } foundBySpecificID = await isRightQuest( `Are You Sure you Would Like to use Quest with ID ${specID} and Name ${name(questBySpecificID)}?`, ); } if (foundBySpecificID === "IGNORE" || !questBySpecificID) await finalizeFoundQuest(sourceId, () => data.ignoreQuests.add(sourceId)); else await finalizeFoundQuest(sourceId, () => data.foundQuests.set(sourceId, questBySpecificID), ); return questBySpecificID; } async function finalizeFoundQuest(sourceID: number, addToList: () => void) { if (data.alwaysAskQuests.has(sourceID)) { logInfo( "This Quest is set to Ask Each Time. If this is not Desirable, Change this in the Saved Porter!", ); return; } const askEachTime = await booleanSelect( "Should we Ask the Corresponding ID for this Quest Every Time?", "Yes", "No", false, ); if (askEachTime) data.alwaysAskQuests.add(sourceID); else addToList(); } async function isRightQuest(message: string): Promise { return (await select({ message: message, choices: [ { name: "Yes", value: "YES", }, { name: "No", value: "NO", }, { name: "Skip/Ignore this Quest", value: "IGNORE", }, ], })) as YesIgnoreNo; } export async function booleanSelect( message: string, trueMsg = "Yes", falseMsg = "No", defaultTo = true, ): Promise { return ( await select({ message: message, choices: [ { name: trueMsg, value: true, }, { name: falseMsg, value: false, }, ], default: defaultTo, }) ).valueOf(); } export function id(quest: Quest): number { return quest["questID:3"]; } export function name(quest: Quest): string { return quest["properties:10"]["betterquesting:10"]["name:8"]; } export function dependencies(quest: Quest): number[] { return quest["preRequisites:11"]; } /** * Paths to Ignore in Quest Change Calculation. * Prerequisites handled separately. * Prerequisites Types handled by Prerequisites. * Rewards not ported (too different across Quest Books) **/ const ignoreRootPaths = new Set([ "preRequisites:11", "preRequisiteTypes:7", "rewards:9", ]); /** * Special Handling in Modified. The Path that is added to the changes list should have -CUSTOM appended to the end, to distinguish this from other changes. */ const specialModifierHandlers: SpecialModifierHandler[] = [ (old, current, changes) => { const diff = getUniqueToArray( old["preRequisites:11"], current["preRequisites:11"], ); // Unique to old array: Removed if (diff.arr1Unique.length > 0 || diff.arr2Unique.length > 0) { changes.push({ path: ["preRequisites-CUSTOM"], op: "replace", value: diff, }); } }, ]; export function getChanged( currentQuests: Quest[], oldQuests: Quest[], ): Changed { // i is current iter, j is old iter let i = 0; let j = 0; const changed: Changed = { added: [], modified: [], removed: [] }; while (i < currentQuests.length && j < oldQuests.length) { const currentQuestID = id(currentQuests[i]); const oldQuestID = id(oldQuests[j]); if (currentQuestID == oldQuestID) { let questDiff = diff(oldQuests[j], currentQuests[i]) as QuestChange[]; if (questDiff.length !== 0) { questDiff = questDiff.filter( (change) => typeof change.path[0] !== "string" || !ignoreRootPaths.has(change.path[0]), ); for (const handler of specialModifierHandlers) { handler(oldQuests[j], currentQuests[i], questDiff); } if (isEmptyQuest(currentQuests[i])) changed.removed.push(oldQuests[j]); else changed.modified.push({ currentQuest: currentQuests[i], oldQuest: oldQuests[j], change: questDiff, }); } i++; j++; continue; } if (!data.currentIDsToQuests.has(oldQuestID)) { logWarn( `A quest has been removed directly! (ID ${id(oldQuests[j])}, Name '${name( oldQuests[j], )}') This is NOT recommended! IDs may overlay in the future! Replace quests with empty ones instead!`, ); changed.removed.push(oldQuests[j]); j++; continue; } changed.added.push(currentQuests[i]); i++; } if (i < currentQuests.length) { changed.added.push(...currentQuests.slice(i)); } else if (j < currentQuests.length) { changed.removed.push(...currentQuests.slice(i)); } return changed; } export function isEmptyQuest(quest: Quest): boolean { return ( questIsSilent(quest) && emptyName(quest) && emptyDesc(quest) && emptyVisibility(quest) && emptyIcon(quest) && questHasNoRewards(quest) && emptyTasks(quest) ); } function emptyName(quest: Quest): boolean { const questName = name(quest); return questName === emptyQuestName || !questName; } function emptyDesc(quest: Quest): boolean { const questDesc = quest["properties:10"]["betterquesting:10"]["desc:8"]; return questDesc === emptyQuestDescription || !questDesc; } function emptyVisibility(quest: Quest): boolean { const questVisibility = quest["properties:10"]["betterquesting:10"]["visibility:8"]; return questVisibility === emptyQuestVisibility; } function emptyIcon(quest: Quest): boolean { const questIcon = quest["properties:10"]["betterquesting:10"]["icon:10"]; return ( !questIcon || questIcon["id:8"] === emptyQuestIconId || !questIcon["id:8"] ); } function questIsSilent(quest: Quest): boolean { return quest["properties:10"]["betterquesting:10"]["issilent:1"] !== 0; } function questHasNoRewards(quest: Quest): boolean { return !quest["rewards:9"] || Object.keys(quest["rewards:9"]).length === 0; } function emptyTasks(quest: Quest): boolean { return ( !quest["tasks:9"] || Object.keys(quest["tasks:9"]).length === 0 || (Object.keys(quest["tasks:9"]).length === 1 && (!quest["tasks:9"]["0:10"] || !quest["tasks:9"]["0:10"]["taskID:8"] || quest["tasks:9"]["0:10"]["taskID:8"] === emptyQuestTaskId)) ); } export function stringifyQB(qb: QuestBook): string { // Formatting Changes const replacements: Replacements[] = [ { search: //g, replacement: "\\u003e", }, { search: /&/g, replacement: "\\u0026", }, { search: /=/g, replacement: "\\u003d", }, { search: /'/g, replacement: "\\u0027", }, ]; qb = sortKeysRecursiveIgnoreArray(qb, (key1, key2): number => { const defaultVal = key2 < key1 ? 1 : -1; if (!key1.includes(":") || !key2.includes(":")) return defaultVal; const num1 = Number.parseInt(key1.split(":")[0]); const num2 = Number.parseInt(key2.split(":")[0]); if (Number.isNaN(num1) || Number.isNaN(num2)) return defaultVal; return num1 - num2; }); let parsed = JSON.stringify(qb, null, 2).replace( /("[a-zA-Z_]+:[56]":\s)(-?[0-9]+)(,?)$/gm, "$1$2.0$3", ); // Add '.0' to any Float/Double Values that are Integers for (const replacement of replacements) { parsed = parsed.replace(replacement.search, replacement.replacement); } return parsed; } /** * Use our own, instead of sortKeysRecursive, to ignore sorting of arrays. */ function sortKeysRecursiveIgnoreArray( object: T, compare: (a: string, b: string) => number, ): T { const result = sortKeys(object as Record, { compare }) as T; // We can modify results, Object.Keys returns a static array Object.keys(result).forEach(function (key) { const current = lodash.get(result, key); if (current) { if (typeof current === "object") { lodash.set(result, key, sortKeys(current, { compare })); return; } } }); return result; } export async function save(toSave: QuestBook): Promise { const save = await booleanSelect("Would you like to Save Changes?"); if (!save) return; const shouldSavePorter = await booleanSelect( "Would you like to Save the Quest Porter?", ); if (shouldSavePorter) await savePorter(); const parsed = stringifyQB(toSave); for (const path of data.outputPaths) { await fs.promises.writeFile(upath.join(rootDirectory, path), parsed); } logInfo(`Saved Files: ${data.outputPaths.join(", ")}!`); logInfo( colors.green( colors.bold( "Remember to import the JSON Files into your Instance to format them!", ), ), ); } async function savePorter() { logInfo("Saving Porter..."); let porter: SavedPorter; // Keys of Porter Are Checked on Import // Porter Definitely Has a Value for Each Key if (!data.savedPorter) { if (fs.existsSync(savedQuestPorter)) { porter = await readFromPorter(false); } else porter = defaultPorter; } else porter = data.savedPorter; // Save Map porter.savedQuestMap = []; for (const sourceID of data.foundQuests.keys()) { const sourceQuest = data.foundQuests.get(sourceID); if (!sourceQuest) continue; const targetID = id(sourceQuest); let normalID: number, expertID: number; switch (data.type) { case "NORMAL": normalID = sourceID; expertID = targetID; break; case "EXPERT": normalID = targetID; expertID = sourceID; break; } porter.savedQuestMap.push({ normal: normalID, expert: expertID, }); porter.savedQuestMap.sort((a, b) => a.normal - b.normal); } // Save Ignore const ignoreArr = [...data.ignoreQuests]; if (data.type === "NORMAL") porter.ignoreQuestsNormal = ignoreArr; else porter.ignoreQuestsExpert = ignoreArr; // Save Always Ask const alwaysAskArr = [...data.alwaysAskQuests]; if (data.type === "NORMAL") porter.alwaysAskQuestsNormal = alwaysAskArr; else porter.alwaysAskQuestsExpert = alwaysAskArr; // Write Porter to File return fs.promises.writeFile( savedQuestPorter, JSON.stringify(porter, null, 2), ); } export async function readFromPorter( replaceExisting: boolean, ): Promise { const savedPorter = JSON.parse( await fs.promises.readFile(savedQuestPorter, "utf-8"), ) as SavedPorter; // Make Sure Porter has Every Key for (const key of Object.keys(defaultPorter)) { // @ts-expect-error Cannot use String as Key if (!savedPorter[key]) savedPorter[key] = defaultPorter[key]; } // Add in Map if (replaceExisting) data.foundQuests.clear(); for (const savedQuestPath of savedPorter.savedQuestMap) { if ( Number.isNaN(savedQuestPath.normal) || Number.isNaN(savedQuestPath.expert) ) throw new Error("ID must be a number!"); let sourceID: number, targetID: number; switch (data.type) { case "NORMAL": sourceID = savedQuestPath.normal; targetID = savedQuestPath.expert; break; case "EXPERT": sourceID = savedQuestPath.expert; targetID = savedQuestPath.normal; break; } if (!data.currentIDsToQuests.has(sourceID)) throw new Error("ID must be a valid quest!"); const targetQuest = data.toChangeIDsToQuests.get(targetID); if (!targetQuest) throw new Error("ID must be a valid quest!"); if (!data.foundQuests.has(sourceID)) data.foundQuests.set(sourceID, targetQuest); } // Ignore & Always Ask data.ignoreQuests = addToOrReplaceSet( replaceExisting, data.type === "NORMAL" ? savedPorter.ignoreQuestsNormal : savedPorter.ignoreQuestsExpert, data.ignoreQuests, ); data.alwaysAskQuests = addToOrReplaceSet( replaceExisting, data.type === "NORMAL" ? savedPorter.alwaysAskQuestsNormal : savedPorter.alwaysAskQuestsExpert, data.alwaysAskQuests, ); data.savedPorter = savedPorter; return savedPorter; } function addToOrReplaceSet( replaceExisting: boolean, array: T[], set: Set, ): Set { if (replaceExisting) return new Set(array); array.forEach((value) => set.add(value)); return set; }