Questify: Various changes. (#962)

* Lock quicker Video Quests behind setting.

* More changes.

* Semantics.

* Semantics.
This commit is contained in:
Etorix 2026-04-09 14:00:55 -07:00 • committed by GitHub
parent e9e42296c4
commit a328c00fa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 394 additions and 163 deletions

View file

@ -21,12 +21,13 @@ import { JSX } from "react";
import { addIgnoredQuest, addRerenderCallback, autoFetchCompatible, fetchAndAlertQuests, maximumAutoFetchIntervalValue, minimumAutoFetchIntervalValue, questIsIgnored, removeIgnoredQuest, rerenderQuests, settings, startAutoFetchingQuests, stopAutoFetchingQuests, validateAndOverwriteIgnoredQuests } from "./settings";
import { ActiveQuestIntervalsMap, ExcludedQuestMap, GuildlessServerListItem, Quest, QuestIcon, QuestMap, QuestStatus, QuestTaskType, RGB } from "./utils/components";
import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getIgnoredQuestIDs, getQuestProgress, getQuestStatus, getQuestTarget, getQuestTask, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, setIgnoredQuestIDs, videoQuestLeeway, waitUntilEnrolled } from "./utils/misc";
import { adjustRGB, decimalToRGB, fetchAndDispatchQuests, formatLowerBadge, getFormattedNow, getIgnoredQuestIDs, getQuestProgress, getQuestStatus, getQuestTarget, getQuestTask, isDarkish, leftClick, middleClick, normalizeQuestName, q, QuestifyLogger, questPath, QuestsStore, refreshQuest, reportPlayGameQuestProgress, reportVideoQuestProgress, rightClick, setIgnoredQuestIDs, waitUntilEnrolled } from "./utils/misc";
const AuthorizedAppsStore = findStoreLazy("AuthorizedAppsStore");
let initialQuestDataFetched = false;
const QuestifyNative = VencordNative.pluginHelpers.Questify as PluginNative<typeof import("./native")>;
const patchedMobileQuests = new Set<string>();
const manuallyStoppedQuestIDs = new Set<string>();
export const activeQuestIntervals = new ActiveQuestIntervalsMap();
function questMenuUnignoreAllClicked(): void {
@ -276,11 +277,12 @@ function QuestTileContextMenu(children: React.ReactNode[], props: { quest: any;
const interval = activeQuestIntervals.get(props.quest.id);
if (interval) {
manuallyStoppedQuestIDs.add(props.quest.id);
QuestifyLogger.info(`[${getFormattedNow()}] Auto-Complete for Quest ${normalizeQuestName(props.quest.config.messages.questName)} stopped via context menu.`);
resetQuestsToResume(props.quest);
clearInterval(interval.progressTimeout);
clearTimeout(interval.rerenderTimeout);
activeQuestIntervals.delete(props.quest.id);
resetQuestsToResume(props.quest);
rerenderQuests();
}
}}
@ -288,7 +290,7 @@ function QuestTileContextMenu(children: React.ReactNode[], props: { quest: any;
<Menu.MenuItem
id={q("start-auto-complete")}
label="Start Auto-Complete"
action={() => { processQuestForAutoComplete(props.quest); }}
action={() => { processQuestForAutoComplete(props.quest, true); }}
/>) : null
}
<Menu.MenuItem
@ -571,9 +573,14 @@ function shouldPreloadQuestAssets(): boolean {
}
async function startVideoProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise<void> {
quest = refreshQuest(quest);
const questName = normalizeQuestName(quest.config.messages.questName);
const task = getQuestTask(quest);
const { completeVideoQuestsQuicker } = settings.store;
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const initialProgress = Math.floor(((new Date()).getTime() - (questEnrolledAt ?? new Date()).getTime()) / 1000) || 1;
const initialProgress = completeVideoQuestsQuicker
? Number(Math.max(1, ((new Date()).getTime() - (questEnrolledAt ?? new Date()).getTime()) / 1000).toFixed(6))
: Math.max(0, getQuestProgress(quest, task) || 0);
activeQuestIntervals.set(quest.id, { progressTimeout: null as any, rerenderTimeout: null as any, progress: initialProgress, type: "watch" });
// Max up to ~25 seconds into the future can be reported.
const { raw: reportTarget, adjusted: questTargetWithLeeway } = target;
@ -599,14 +606,64 @@ async function startVideoProgressTracking(quest: Quest, target: { raw: number; a
return;
}
const reportEverySec = 10;
let progressIntervalId: NodeJS.Timeout;
const playbackTickMs = 250;
const minimumReportDelta = 6;
const maximumReportDelta = 8;
const floatPrecision = 6;
let progressIntervalId: NodeJS.Timeout | null = null;
let renderIntervalId: NodeJS.Timeout | null = null;
let maximumPlaybackTimestamp = Math.floor(initialProgress);
let nextReportThreshold = 0;
let hasReportedInitialProgress = currentProgressScaled <= 0;
let lastPlaybackTickAt = performance.now();
function clampFloat(value: number): number {
return Number(value.toFixed(floatPrecision));
}
function randomBetween(min: number, max: number): number {
return clampFloat(min + Math.random() * (max - min));
}
function getNextReportThreshold(currentPlaybackPosition: number): number {
return clampFloat(currentPlaybackPosition + randomBetween(minimumReportDelta, maximumReportDelta));
}
function getProgressToReport(currentPlaybackPosition: number): number {
const cappedProgress = clampFloat(Math.min(reportTarget, currentPlaybackPosition));
const lowerBound = nextReportThreshold > 0
? nextReportThreshold
: clampFloat(Math.min(cappedProgress, 0.05));
const overshootAvailable = clampFloat(Math.max(0, cappedProgress - lowerBound));
return overshootAvailable > 0
? clampFloat(lowerBound + randomBetween(0, overshootAvailable))
: lowerBound;
}
function updateQuestIntervalProgress(progress: number): void {
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) {
intervalData.progress = Math.floor(progress);
}
}
function clearTrackingTimers(): void {
if (progressIntervalId) {
clearTimeout(progressIntervalId);
}
if (renderIntervalId) {
clearInterval(renderIntervalId);
}
}
async function handleSendComplete() {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
const success = await reportVideoQuestProgress(quest, reportTarget, QuestifyLogger);
clearTrackingTimers();
const success = await reportVideoQuestProgress(quest, maximumPlaybackTimestamp, QuestifyLogger);
activeQuestIntervals.delete(quest.id);
resetQuestsToResume(quest);
if (success) {
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
@ -624,70 +681,104 @@ async function startVideoProgressTracking(quest: Quest, target: { raw: number; a
}
}
if (timeRemaining < reportEverySec) {
const simulatedProgressToCover = reportTarget - currentProgressScaled;
const speedFactor = timeRemaining > 0 ? simulatedProgressToCover / timeRemaining : 0;
async function scheduleNextPlaybackTick(): Promise<void> {
progressIntervalId = setTimeout(async () => {
await handleSendComplete();
}, timeRemaining * 1000);
} else {
const simulatedProgressToCover = reportTarget - currentProgressScaled;
const speedFactor = simulatedProgressToCover / timeRemaining;
const progressIncrementPerInterval = reportEverySec * speedFactor;
const numberOfIntervals = Math.floor(timeRemaining / reportEverySec);
let intervalsRun = 0;
reportVideoQuestProgress(quest, initialProgress, QuestifyLogger);
progressIntervalId = setInterval(async () => {
intervalsRun++;
currentProgress += reportEverySec;
currentProgressScaled += progressIncrementPerInterval;
const progressToReport = Math.min(Math.floor(currentProgressScaled), reportTarget);
if (intervalsRun < numberOfIntervals || (currentProgress < reportTarget - videoQuestLeeway - (reportEverySec / 2))) {
await reportVideoQuestProgress(quest, progressToReport, QuestifyLogger);
if (!activeQuestIntervals.has(quest.id)) {
return;
}
if (intervalsRun >= numberOfIntervals) {
clearInterval(progressIntervalId);
const now = performance.now();
const playbackElapsedSeconds = clampFloat((now - lastPlaybackTickAt) / 1000);
lastPlaybackTickAt = now;
const timeSpentInIntervals = numberOfIntervals * reportEverySec;
const finalWaitTime = Math.max(0, timeRemaining - timeSpentInIntervals);
progressIntervalId = setTimeout(handleSendComplete, finalWaitTime * 1000);
currentProgress = clampFloat(Math.min(questTargetWithLeeway, currentProgress + playbackElapsedSeconds));
currentProgressScaled = clampFloat(Math.min(reportTarget, currentProgressScaled + playbackElapsedSeconds * speedFactor));
maximumPlaybackTimestamp = Math.max(maximumPlaybackTimestamp, Math.floor(currentProgressScaled));
updateQuestIntervalProgress(currentProgress);
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) intervalData.progressTimeout = progressIntervalId;
if (hasReportedInitialProgress && currentProgressScaled >= nextReportThreshold && currentProgress < questTargetWithLeeway) {
const progressToReport = getProgressToReport(currentProgressScaled);
const reported = await reportVideoQuestProgress(quest, progressToReport, QuestifyLogger);
if (!reported) {
clearTrackingTimers();
activeQuestIntervals.delete(quest.id);
QuestifyLogger.error(`[${getFormattedNow()}] Failed to report progress for Quest ${questName}.`);
return;
}
nextReportThreshold = getNextReportThreshold(progressToReport);
}
}, reportEverySec * 1000);
}
const renderIntervalId = setInterval(() => {
if (currentProgress >= questTargetWithLeeway) {
await handleSendComplete();
return;
}
await scheduleNextPlaybackTick();
}, playbackTickMs);
const intervalData = activeQuestIntervals.get(quest.id);
if (!!intervalData) {
intervalData.progress += 1;
} else {
clearInterval(renderIntervalId);
if (intervalData && progressIntervalId) {
intervalData.progressTimeout = progressIntervalId;
}
}
rerenderQuests();
}, 1000);
updateQuestIntervalProgress(currentProgress);
renderIntervalId = setInterval(() => rerenderQuests(), 1000);
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) {
intervalData.progressTimeout = progressIntervalId;
intervalData.rerenderTimeout = renderIntervalId;
}
if (timeRemaining <= 0) {
await handleSendComplete();
return;
}
if (currentProgressScaled > 0) {
const initialProgressToReport = clampFloat(Math.min(reportTarget, currentProgressScaled));
void (async () => {
const reported = await reportVideoQuestProgress(quest, initialProgressToReport, QuestifyLogger);
if (!activeQuestIntervals.has(quest.id)) {
return;
}
if (!reported) {
clearTrackingTimers();
activeQuestIntervals.delete(quest.id);
QuestifyLogger.error(`[${getFormattedNow()}] Failed to report initial progress for Quest ${questName}.`);
return;
}
hasReportedInitialProgress = true;
nextReportThreshold = getNextReportThreshold(initialProgressToReport);
})();
}
lastPlaybackTickAt = performance.now();
await scheduleNextPlaybackTick();
}
async function startPlayGameProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise<void> {
quest = refreshQuest(quest);
const questName = normalizeQuestName(quest.config.messages.questName);
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const task = getQuestTask(quest);
const questTarget = target.adjusted;
const initialProgress = getQuestProgress(quest, task) || 0;
const remaining = Math.max(0, questTarget - initialProgress);
const heartbeatInterval = 20; // Heartbeats must be at most 2 minutes apart.
const maximumHeartbeatDurationMs = 60 * 1000;
const heartbeatBufferMs = 1 * 1000;
activeQuestIntervals.set(quest.id, { progressTimeout: null as any, rerenderTimeout: null as any, progress: initialProgress, type: "play" });
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} will be completed in the background in ${remaining} seconds.`);
@ -708,76 +799,93 @@ async function startPlayGameProgressTracking(quest: Quest, target: { raw: number
return;
}
let progressTimeoutId: NodeJS.Timeout | null = null;
let renderIntervalId: NodeJS.Timeout | null = null;
function getHeartbeatDurationMs(progress: number): number {
const remainingMs = Math.max(0, (questTarget - progress) * 1000);
return remainingMs <= maximumHeartbeatDurationMs
? remainingMs + heartbeatBufferMs
: maximumHeartbeatDurationMs;
}
function clearTrackingTimers(): void {
if (progressTimeoutId) {
clearTimeout(progressTimeoutId);
}
if (renderIntervalId) {
clearTimeout(renderIntervalId);
}
}
function handleQuestComplete(): void {
clearTrackingTimers();
activeQuestIntervals.delete(quest.id);
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
if (settings.store.notifyOnQuestComplete) {
showNotification({
title: "Quest Completed!",
body: `The ${questName} Quest has completed.`,
dismissOnClick: true,
onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
});
}
}
async function scheduleNextHeartbeat(progress: number): Promise<void> {
const heartbeatDurationMs = getHeartbeatDurationMs(progress);
progressTimeoutId = setTimeout(async () => {
const result = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
if (result.progress === null) {
clearTrackingTimers();
activeQuestIntervals.delete(quest.id);
QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`);
return;
}
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData) {
intervalData.progress = result.progress;
}
if (result.completed || result.progress >= questTarget) {
handleQuestComplete();
return;
}
await scheduleNextHeartbeat(result.progress);
}, heartbeatDurationMs);
const intervalData = activeQuestIntervals.get(quest.id);
if (intervalData && progressTimeoutId) {
intervalData.progressTimeout = progressTimeoutId;
}
}
const initial = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
const progressIntervalId = setInterval(async () => {
const result = await reportPlayGameQuestProgress(quest, false, QuestifyLogger, { attempts: 3, delay: 2500 });
if (initial.progress === null) {
activeQuestIntervals.delete(quest.id);
QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`);
return;
}
if (result.progress === null) {
clearInterval(progressIntervalId);
activeQuestIntervals.delete(quest.id);
QuestifyLogger.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName}.`);
return;
}
const isComplete = result.progress >= questTarget;
const timeRemaining = questTarget - result.progress;
const intervalData = activeQuestIntervals.get(quest.id);
intervalData && (intervalData.progress = result.progress);
if (isComplete) {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger, { attempts: 3, delay: 2500 });
activeQuestIntervals.delete(quest.id);
if (success) {
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
if (settings.store.notifyOnQuestComplete) {
showNotification({
title: "Quest Completed!",
body: `The ${questName} Quest has completed.`,
dismissOnClick: true,
onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
});
}
} else {
QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`);
}
} else if (timeRemaining < heartbeatInterval) {
clearInterval(progressIntervalId);
clearTimeout(renderIntervalId);
setTimeout(async () => {
const success = await reportPlayGameQuestProgress(quest, true, QuestifyLogger, { attempts: 3, delay: 2500 });
activeQuestIntervals.delete(quest.id);
if (success) {
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
if (settings.store.notifyOnQuestComplete) {
showNotification({
title: "Quest Completed!",
body: `The ${questName} Quest has completed.`,
dismissOnClick: true,
onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
});
}
} else {
QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`);
}
}, (timeRemaining + 1) * 1000);
}
}, heartbeatInterval * 1000);
const renderIntervalId = setInterval(() => {
renderIntervalId = setInterval(() => {
const intervalData = activeQuestIntervals.get(quest.id);
if (!!intervalData) {
intervalData.progress += 1;
} else {
clearInterval(renderIntervalId);
if (renderIntervalId) {
clearInterval(renderIntervalId);
}
}
rerenderQuests();
@ -787,9 +895,15 @@ async function startPlayGameProgressTracking(quest: Quest, target: { raw: number
if (intervalData) {
intervalData.progress = initial.progress || initialProgress;
intervalData.progressTimeout = progressIntervalId;
intervalData.rerenderTimeout = renderIntervalId;
}
if (initial.completed || initial.progress >= questTarget) {
handleQuestComplete();
return;
}
await scheduleNextHeartbeat(initial.progress);
}
async function startAchievementActivityProgressTracking(quest: Quest, target: { raw: number; adjusted: number; }): Promise<void> {
@ -912,7 +1026,7 @@ function canQuestAutoComplete(quest: Quest): boolean {
return false;
}
function processQuestForAutoComplete(quest: Quest): boolean {
function processQuestForAutoComplete(quest: Quest, force: boolean = false): boolean {
const questName = normalizeQuestName(quest.config.messages.questName);
const task = getQuestTask(quest);
@ -924,6 +1038,12 @@ function processQuestForAutoComplete(quest: Quest): boolean {
const isAchievement = task?.type === QuestTaskType.ACHIEVEMENT_IN_ACTIVITY;
const canAutoComplete = canQuestAutoComplete(quest);
if (force) {
manuallyStoppedQuestIDs.delete(quest.id);
} else if (manuallyStoppedQuestIDs.has(quest.id)) {
return false;
}
if (quest.userStatus?.completedAt || existingInterval) {
return false;
} else if (!task) {
@ -962,6 +1082,10 @@ function shouldDisableQuestAcceptedButton(quest: Quest): boolean | null {
function getQuestUnacceptedButtonText(quest: Quest): string | null {
const { completeGameQuestsInBackground, completeVideoQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
if (activeQuestIntervals.has(quest.id) || !!quest.userStatus?.enrolledAt) {
return null;
}
const task = getQuestTask(quest);
const { adjusted: target } = getQuestTarget(task);
const targetFormatted = `${String(Math.floor(target / 60)).padStart(2, "0")}:${String(target % 60).padStart(2, "0")}`;
@ -980,10 +1104,12 @@ function getQuestUnacceptedButtonText(quest: Quest): string | null {
}
function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false): string | null {
const { completeGameQuestsInBackground, completeVideoQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const { completeVideoQuestsQuicker, completeGameQuestsInBackground, completeVideoQuestsInBackground, completeAchievementQuestsInBackground } = settings.store;
quest = refreshQuest(quest);
const task = getQuestTask(quest);
const intervalData = activeQuestIntervals.get(quest.id);
const questEnrolledAt = quest.userStatus?.enrolledAt ? new Date(quest.userStatus.enrolledAt) : null;
const isWatch = task?.type === QuestTaskType.WATCH_VIDEO || task?.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
const isPlay = task?.type === QuestTaskType.PLAY_ON_DESKTOP || task?.type === QuestTaskType.PLAY_ON_XBOX || task?.type === QuestTaskType.PLAY_ON_PLAYSTATION || task?.type === QuestTaskType.PLAY_ACTIVITY;
@ -992,10 +1118,14 @@ function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false
if (questEnrolledAt) {
if (((isPlay && completeGameQuestsInBackground && IS_DISCORD_DESKTOP) || (isWatch && completeVideoQuestsInBackground))) {
const { adjusted: durationWithLeeway } = getQuestTarget(task);
const currentProgress = getQuestProgress(quest, task) || 0;
const currentProgress = getQuestProgress(quest, task) ?? 0;
const progress = Math.min(currentProgress, durationWithLeeway);
const timeRemaining = Math.max(0, durationWithLeeway - progress);
const canCompleteImmediately = isWatch && questEnrolledAt && ((new Date().getTime() - questEnrolledAt.getTime()) / 1000) >= durationWithLeeway;
const canCompleteImmediately = isWatch && (
completeVideoQuestsQuicker
? !!questEnrolledAt && ((new Date().getTime() - questEnrolledAt.getTime()) / 1000) >= durationWithLeeway
: !timeRemaining
);
const progressFormatted = `${String(Math.floor(timeRemaining / 60)).padStart(2, "0")}:${String(timeRemaining % 60).padStart(2, "0")}`;
const progressFormattedAsPreposition = timeRemaining >= 60
? `${Math.floor(timeRemaining / 60)}m ${timeRemaining % 60}s`
@ -1023,6 +1153,7 @@ function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false
function getQuestPanelPercentComplete({ quest, percentCompleteText }: { quest: Quest; percentCompleteText?: string; }): { percentComplete: number; } | { percentComplete: number; percentCompleteText: string; } | null {
if (!quest) { return null; }
quest = refreshQuest(quest);
const task = getQuestTask(quest);
if (!task) { return null; }
@ -1187,7 +1318,7 @@ function getQuestAcceptedButtonProps(quest: Quest, text: string, disabled: boole
return {
disabled: shouldDisableQuestAcceptedButton(quest) ?? disabled,
text: getQuestAcceptedButtonText(quest) ?? text,
onClick: () => { const startingAutocomplete = processQuestForAutoComplete(quest); !startingAutocomplete && onClick ? onClick() : null; },
onClick: () => { const startingAutocomplete = processQuestForAutoComplete(quest, true); !startingAutocomplete && onClick ? onClick() : null; },
icon: () => { }
};
}
@ -1198,7 +1329,11 @@ function resetQuestsToResume(quest?: Quest): void {
settings.store.resumeQuestIDs.watch = settings.store.resumeQuestIDs.watch.filter(id => id !== quest.id);
settings.store.resumeQuestIDs.achievement = settings.store.resumeQuestIDs.achievement.filter(id => id !== quest.id);
} else {
settings.store.resumeQuestIDs = settings.def.resumeQuestIDs.default;
settings.store.resumeQuestIDs = {
watch: [],
play: [],
achievement: [],
};
}
}
@ -1612,14 +1747,14 @@ export default definePlugin({
{
// Resume Video Quest.
match: /(tooltipText:\i.intl.string\(\i.\i.\i\),onClick:\(\)=>)(\(0,\i.\i\)\({quest)/,
replace: "$1!$self.processQuestForAutoComplete(arguments[0].quest)&&$2"
replace: "$1!$self.processQuestForAutoComplete(arguments[0].quest,true)&&$2"
},
{
// Start Play Game and Play Activity Quests.
// Video Quests are handled in the next patch group.
// Also set the unaccepted button text to "Complete".
match: /(\i,tooltipText:null,onClick:async\(\)=>{)/,
replace: "$self.getQuestUnacceptedButtonText(arguments[0].quest)??$1const startingAutoComplete=arguments[0].isVideoQuest?false:$self.processQuestForAutoComplete(arguments[0].quest);"
replace: "$self.getQuestUnacceptedButtonText(arguments[0].quest)??$1const startingAutoComplete=arguments[0].isVideoQuest?false:$self.processQuestForAutoComplete(arguments[0].quest,true);"
},
{
// Set the accepted button text to "Complete", "Completing", or "Resume" based on progress.
@ -1649,11 +1784,11 @@ export default definePlugin({
replacement: [
{
match: /(?=let{quest:)/,
replace: "const questifyText=$self.getQuestUnacceptedButtonText(arguments[0].quest);"
replace: "const questifyText=$self.getQuestUnacceptedButtonText(arguments[0].quest)??$self.getQuestAcceptedButtonText(arguments[0].quest);"
},
{
match: /(?<=}\),)(\i\?\.\(\))/,
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)"
replace: "!$self.processQuestForAutoComplete(arguments[0].quest,true)&&($1)"
},
{
match: /(?<=,text:)(\i),icon:\i/,
@ -1667,7 +1802,7 @@ export default definePlugin({
replacement: [
{
match: /(?=let{quest:)/,
replace: "const questifyText=$self.getQuestUnacceptedButtonText(arguments[0].quest);"
replace: "const questifyText=$self.getQuestUnacceptedButtonText(arguments[0].quest)??$self.getQuestAcceptedButtonText(arguments[0].quest);"
},
{
match: /(?<=,text:)(\i)/g,
@ -1675,7 +1810,7 @@ export default definePlugin({
},
{
match: /(?<="primary",onClick:)(\i)/,
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&$1"
replace: "()=>{!$self.processQuestForAutoComplete(arguments[0].quest,true)&&$1}"
}
]
},
@ -1696,7 +1831,7 @@ export default definePlugin({
{
// Initial and subsequent select drop down for picking or changing a platform.
match: /(select:)(\i)(,serialize:\i=>{)/g,
replace: "$1(platform)=>{$self.processQuestForAutoComplete(arguments[0].quest),$2(platform)}$3"
replace: "$1(platform)=>{$self.processQuestForAutoComplete(arguments[0].quest,true),$2(platform)}$3"
},
{
// The Quest Accepted button is disabled by default. If the user reloads the client, they need a way

View file

@ -686,6 +686,7 @@ function DisableQuestsSetting(): JSX.Element {
disableFriendsListActiveNowPromotion,
disableMembersListActivelyPlayingIcon,
makeMobileQuestsDesktopCompatible,
completeVideoQuestsQuicker,
completeVideoQuestsInBackground,
completeGameQuestsInBackground,
completeAchievementQuestsInBackground,
@ -702,6 +703,7 @@ function DisableQuestsSetting(): JSX.Element {
"disableFriendsListActiveNowPromotion",
"disableMembersListActivelyPlayingIcon",
"makeMobileQuestsDesktopCompatible",
"completeVideoQuestsQuicker",
"completeVideoQuestsInBackground",
"completeGameQuestsInBackground",
"completeAchievementQuestsInBackground",
@ -722,6 +724,7 @@ function DisableQuestsSetting(): JSX.Element {
{ label: "Complete Watch Video Quests in Background", value: "video-quests-background", selected: completeVideoQuestsInBackground, type: "modification" },
{ label: "Complete Play Game/Activity Quests in Background", value: "game-quests-background", selected: completeGameQuestsInBackground, type: "modification" },
{ label: "Complete Task in Activity Quests in Background", value: "achievement-quests-background", selected: completeAchievementQuestsInBackground, type: "modification" },
{ label: "Complete Watch Video Quests Quicker", value: "video-quests-quicker", selected: completeVideoQuestsQuicker, type: "modification" },
{ label: "Make Mobile Quests Desktop Compatible", value: "mobile-desktop-compatible", selected: makeMobileQuestsDesktopCompatible, type: "modification" },
{ label: "Notify on Auto-Complete", value: "notify-on-complete", selected: notifyOnQuestComplete, type: "modification" },
];
@ -752,6 +755,7 @@ function DisableQuestsSetting(): JSX.Element {
settings.store.disableFriendsListActiveNowPromotion = enabled.includes("friends-list");
settings.store.disableMembersListActivelyPlayingIcon = enabled.includes("members-list");
settings.store.makeMobileQuestsDesktopCompatible = enabled.includes("mobile-desktop-compatible");
settings.store.completeVideoQuestsQuicker = enabled.includes("video-quests-quicker");
settings.store.completeVideoQuestsInBackground = enabled.includes("video-quests-background");
settings.store.completeGameQuestsInBackground = enabled.includes("game-quests-background");
settings.store.completeAchievementQuestsInBackground = enabled.includes("achievement-quests-background");
@ -824,6 +828,10 @@ function DisableQuestsSetting(): JSX.Element {
The <span className={q("inline-code-block")}>Complete Task in Activity Quests in Background</span> option
will immediately mark Task in Activity Quests as completed when started.
<br /><br />
The <span className={q("inline-code-block")}>Complete Video Quests Quicker</span> option
uses Discord's progress leeway and elapsed enrollment time for Video Quests. When disabled, Video
Quests wait for the full duration at 1x speed, which may appear less suspicious to their systems.
<br /><br />
You still must start the Quests manually. The first click will start the Quests in the background.
For Video Quests, subsequent clicks will open the video modal as normal. For Activity Quests, subsequent clicks
will open the activity as normal. To abort the Quests, you can open the context menu on the Quest tile and
@ -1694,6 +1702,15 @@ export const settings = definePluginSettings({
default: true,
hidden: true
},
completeVideoQuestsQuicker: {
type: OptionType.BOOLEAN,
description: "Use Discord's progress leeway and elapsed enrollment time for Video Quest auto-completion.",
default: false,
hidden: true,
onChange: () => {
rerenderQuests();
}
},
completeVideoQuestsInBackground: {
type: OptionType.BOOLEAN,
description: "Complete Video Quests in the background after the video duration has passed.",

View file

@ -22,13 +22,28 @@ export const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannel
export class ActiveQuestIntervalsMap extends Map<string, { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; type: string; }> {
set(key: string, value: { progressTimeout: NodeJS.Timeout; rerenderTimeout: NodeJS.Timeout; progress: number; type: string; }): this {
const { resumeQuestIDs } = settings.store;
settings.store.resumeQuestIDs = {
watch: resumeQuestIDs.watch.filter((id: string) => id !== key),
play: resumeQuestIDs.play.filter((id: string) => id !== key),
achievement: resumeQuestIDs.achievement.filter((id: string) => id !== key),
};
settings.store.resumeQuestIDs[value.type].push(key);
return super.set(key, value);
}
delete(key: string): boolean {
const types = Object.keys(settings.def.resumeQuestIDs.default);
for (const type of types) { settings.store.resumeQuestIDs[type] = settings.store.resumeQuestIDs[type].filter((id: string) => id !== key); }
const { resumeQuestIDs } = settings.store;
settings.store.resumeQuestIDs = {
watch: resumeQuestIDs.watch.filter((id: string) => id !== key),
play: resumeQuestIDs.play.filter((id: string) => id !== key),
achievement: resumeQuestIDs.achievement.filter((id: string) => id !== key),
};
return super.delete(key);
}
}

View file

@ -6,7 +6,7 @@
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
import { findByPropsLazy } from "@webpack";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { FluxDispatcher, RestAPI, UserStore } from "@webpack/common";
import { activeQuestIntervals } from "..";
@ -16,12 +16,19 @@ import { Quest, QuestStatus, QuestTask, QuestTaskType, RGB } from "./components"
export const q = classNameFactory("questify-");
export const QuestifyLogger = new Logger("Questify");
export const QuestsStore = findByPropsLazy("getQuest");
export const reportProgress = findByCodeLazy(".QUESTS_VIDEO_PROGRESS(");
export const sendHeartbeat = findByCodeLazy(".QUESTS_HEARTBEAT(");
export const questPath = "/quest-home";
export const videoQuestLeeway = 24;
export const leftClick = 0;
export const middleClick = 1;
export const rightClick = 2;
type HeartbeatDispatchResult =
| { type: "success"; userStatus: Quest["userStatus"]; }
| { type: "failure"; error: unknown; }
| { type: "timeout"; };
export function setIgnoredQuestIDs(questIDs: string[], userId?: string): void {
const currentUserID = userId ?? UserStore.getCurrentUser()?.id;
if (!currentUserID) return;
@ -76,8 +83,9 @@ export function getQuestProgress(quest: Quest, task: QuestTask) {
export function getQuestTarget(task: QuestTask) {
const isWatch = task.type === QuestTaskType.WATCH_VIDEO || task.type === QuestTaskType.WATCH_VIDEO_ON_MOBILE;
const { completeVideoQuestsQuicker } = settings.store;
const raw = task.target;
const adjusted = Math.max(0, raw - (isWatch ? videoQuestLeeway : 0));
const adjusted = Math.max(0, raw - (isWatch && completeVideoQuestsQuicker ? videoQuestLeeway : 0));
return { raw, adjusted };
}
@ -262,22 +270,9 @@ export async function reportVideoQuestProgress(quest: Quest, currentProgress: nu
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await RestAPI.post({
url: `/quests/${quest.id}/video-progress`,
body: { timestamp: currentProgress }
});
await reportProgress(quest.id, currentProgress);
if (!response || !response.body) {
logger?.warn(`[${getFormattedNow()}] No response body received while reporting video progress for Quest ${questName} on attempt ${attempt}/${maxAttempts}.`);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
continue;
}
logger?.info(`[${getFormattedNow()}] Quest ${questName} progress reported: ${currentProgress} seconds.`);
logger?.info(`[${getFormattedNow()}] Quest ${questName} progress reported: ${currentProgress.toFixed(6)} seconds.`);
return true;
} catch (error) {
logger?.error(`[${getFormattedNow()}] Failed to report progress for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, error);
@ -292,31 +287,96 @@ export async function reportVideoQuestProgress(quest: Quest, currentProgress: nu
return false;
}
export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolean, logger?: Logger, options?: { attempts?: number, delay?: number; }): Promise<{ progress: number | null; }> {
function getPlayGameProgressValue(userStatus: Quest["userStatus"]): number | null {
const progressMap = userStatus?.progress;
if (!progressMap) {
return null;
}
const progressPlayType = progressMap.PLAY_ON_DESKTOP?.value
?? progressMap.PLAY_ON_XBOX?.value
?? progressMap.PLAY_ON_PLAYSTATION?.value
?? progressMap.PLAY_ACTIVITY?.value;
return progressPlayType ?? null;
}
function waitForHeartbeatDispatchResult(questId: string, timeoutMs: number): Promise<HeartbeatDispatchResult> {
return new Promise(resolve => {
const successEvent = "QUESTS_SEND_HEARTBEAT_SUCCESS";
const failureEvent = "QUESTS_SEND_HEARTBEAT_FAILURE";
const timeoutId = setTimeout(() => {
cleanup();
resolve({ type: "timeout" });
}, timeoutMs);
function cleanup() {
clearTimeout(timeoutId);
FluxDispatcher.unsubscribe(successEvent, onSuccess);
FluxDispatcher.unsubscribe(failureEvent, onFailure);
}
function onSuccess(data: { questId?: string; userStatus?: Quest["userStatus"]; }) {
if (data.questId !== questId) {
return;
}
cleanup();
resolve({ type: "success", userStatus: data.userStatus ?? null });
}
function onFailure(data: { questId?: string; error?: unknown; }) {
if (data.questId !== questId) {
return;
}
cleanup();
resolve({ type: "failure", error: data.error });
}
FluxDispatcher.subscribe(successEvent, onSuccess);
FluxDispatcher.subscribe(failureEvent, onFailure);
});
}
export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolean, logger?: Logger, options?: { attempts?: number, delay?: number; timeout?: number; applicationId?: string; streamKey?: string; }): Promise<{ progress: number | null; completed: boolean; }> {
const questName = normalizeQuestName(quest.config.messages.questName);
if (!quest.userStatus?.enrolledAt) {
logger?.warn(`[${getFormattedNow()}] Cannot send heartbeat for unenrolled Quest ${questName}.`);
return { progress: null };
return { progress: null, completed: false };
} else if (quest.userStatus?.completedAt) {
return { progress: null };
return { progress: null, completed: true };
}
const maxAttempts = options?.attempts ?? 1;
const delay = options?.delay ?? 2500;
const timeout = options?.timeout ?? 10000;
const streamKey = options?.streamKey ?? `call:${quest.id}:1`;
const applicationId = options?.applicationId ?? quest.config.application.id;
const questPlayType = quest.config.taskConfigV2?.tasks.PLAY_ON_DESKTOP || quest.config.taskConfigV2?.tasks.PLAY_ON_XBOX || quest.config.taskConfigV2?.tasks.PLAY_ON_PLAYSTATION || quest.config.taskConfigV2?.tasks.PLAY_ACTIVITY;
if (!questPlayType) {
logger?.warn(`[${getFormattedNow()}] Could not recognize the Quest type for Quest ${questName}.`);
return { progress: null, completed: false };
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await RestAPI.post({
url: `/quests/${quest.id}/heartbeat`,
body: {
stream_key: `call:${quest.id}:1`,
terminal
}
const dispatchResultPromise = waitForHeartbeatDispatchResult(quest.id, timeout);
await sendHeartbeat({
questId: quest.id,
streamKey,
applicationId,
terminal,
});
if (!response || !response.body) {
logger?.warn(`[${getFormattedNow()}] No response body received while sending heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}.`);
const dispatchResult = await dispatchResultPromise;
if (dispatchResult.type === "failure") {
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, dispatchResult.error);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
@ -325,18 +385,22 @@ export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolea
continue;
}
const { body } = snakeToCamel(response);
const progressPlayType = body.progress.PLAY_ON_DESKTOP || body.progress.PLAY_ON_XBOX || body.progress.PLAY_ON_PLAYSTATION || body.progress.PLAY_ACTIVITY;
const questPlayType = quest.config.taskConfigV2?.tasks.PLAY_ON_DESKTOP || quest.config.taskConfigV2?.tasks.PLAY_ON_XBOX || quest.config.taskConfigV2?.tasks.PLAY_ON_PLAYSTATION || quest.config.taskConfigV2?.tasks.PLAY_ACTIVITY;
const progress = progressPlayType?.value || 1;
if (dispatchResult.type === "timeout") {
logger?.warn(`[${getFormattedNow()}] Timed out waiting for heartbeat dispatch for Quest ${questName} on attempt ${attempt}/${maxAttempts}.`);
if (!questPlayType) {
logger?.warn(`[${getFormattedNow()}] Could not recognize the Quest type for Quest ${questName}.`);
return { progress: null };
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
continue;
}
const updatedQuest = refreshQuest(quest);
const progress = getPlayGameProgressValue(dispatchResult.userStatus) ?? getQuestProgress(updatedQuest, questPlayType) ?? 0;
const completed = !!dispatchResult.userStatus?.completedAt || !!updatedQuest.userStatus?.completedAt;
logger?.info(`[${getFormattedNow()}] Heartbeat sent for Quest ${questName} with progress: ${progress}/${questPlayType.target}.`);
return { progress };
return { progress, completed };
} catch (error) {
logger?.error(`[${getFormattedNow()}] Failed to send heartbeat for Quest ${questName} on attempt ${attempt}/${maxAttempts}:`, error);
@ -347,7 +411,7 @@ export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolea
}
logger?.warn(`[${getFormattedNow()}] Exhausted all ${maxAttempts} attempts to send heartbeat for Quest ${questName}.`);
return { progress: null };
return { progress: null, completed: false };
}
export function getBadgeSize(value: string, negative: boolean): number {