feat: bunch of new stuff + proper dependency handling

This commit is contained in:
zkldi
2022-11-25 03:46:11 +00:00
parent cbce1a31f4
commit a6d89f748e
28 changed files with 567 additions and 232 deletions
+5 -1
View File
@@ -43,7 +43,11 @@ const staticIndexes: Partial<Record<Databases, Array<Index>>> = {
],
"game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)],
"game-settings": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE), index({ rivals: 1 })],
"folder-chart-lookup": [index({ chartID: 1, folderID: 1 }, UNIQUE)],
"folder-chart-lookup": [
index({ chartID: 1, folderID: 1 }, UNIQUE),
index({ chartID: 1 }),
index({ folderID: 1 }),
],
goals: [index({ goalID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), index({ name: "text" })],
"goal-subs": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })],
quests: [
@@ -45,7 +45,7 @@ t.test("#UpdateGoalsForUser", (t) => {
progress: 0,
progressHuman: "0",
timeAchieved: null,
timeSet: 0,
wasAssignedStandalone: false,
userID: 1,
};
@@ -257,8 +257,8 @@ t.test("#ProcessGoal", (t) => {
progress: 6,
progressHuman: "EX HARD CLEAR",
timeAchieved: 1000,
timeSet: 1000,
wasInstantlyAchieved: true,
wasAssignedStandalone: false,
userID: 1,
};
@@ -30,8 +30,8 @@ t.test("#UpdateUsersQuests", (t) => {
progress: 0,
timeAchieved: null,
lastInteraction: null,
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
})
);
@@ -164,8 +164,8 @@ t.test("#UpdateUsersQuests", (t) => {
progress: 0,
timeAchieved: null,
lastInteraction: null,
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
});
const res = await UpdateUsersQuests(
@@ -275,8 +275,8 @@ t.test("#UpdateUsersQuests", (t) => {
progress: 0,
timeAchieved: null,
lastInteraction: null,
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
},
{
achieved: false,
@@ -288,8 +288,8 @@ t.test("#UpdateUsersQuests", (t) => {
progress: 0,
timeAchieved: null,
lastInteraction: null,
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
},
{
achieved: false,
@@ -301,8 +301,8 @@ t.test("#UpdateUsersQuests", (t) => {
progress: 0,
timeAchieved: null,
lastInteraction: null,
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
},
{
achieved: false,
@@ -313,8 +313,8 @@ t.test("#UpdateUsersQuests", (t) => {
progress: 0,
timeAchieved: null,
lastInteraction: null,
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
},
]);
+1 -1
View File
@@ -432,8 +432,8 @@ t.test("#GetRelevantGoals", (t) => {
outOfHuman: "HARD CLEAR",
progress: null,
progressHuman: "NO DATA",
timeSet: Date.now(),
userID: 1,
wasAssignedStandalone: false,
}))
);
});
+154 -10
View File
@@ -419,16 +419,17 @@ export async function ConstructGoal(
* Subscribes a user to the provided goal document. Handles deduping goals naturally
* and general good stuff.
*
* @param cancelIfAchieved - Don't subscribe to the goal if subscribing would cause
* the user to immediately achieve the goal. This is disabled for quest subscriptions,
* but enabled for manual assignment.
* @param isStandaloneAssigment - is this a "standalone assignment?", as in, not a
* consequence of a quest assignment. Standalone assignments are not allowed to be
* instantly-achieved. if they are, it will fail with
* SubscribeFailReasons.ALREADY_ACHIEVED.
*
* Returns null if the user is already subscribed to this goal.
*/
export async function SubscribeToGoal(
userID: integer,
goalDocument: GoalDocument,
cancelIfAchieved = true
isStandaloneAssignment: boolean
) {
const goalExists = await db.goals.findOne({ goalID: goalDocument.goalID });
@@ -443,7 +444,34 @@ export async function SubscribeToGoal(
});
if (userAlreadySubscribed) {
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
// A quest trying to assign an already subscribed goal should know that.
// (not that it cares)
if (!isStandaloneAssignment) {
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
}
// if the user was already standalone-subscribed, ignore another standalone
// assignment.
if (userAlreadySubscribed.wasAssignedStandalone) {
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
}
// otherwise, this is a standalone assignment to a goal that was already assigned
// as a consequence of a quest. Mark it as standalone
await db["goal-subs"].update(
{
userID,
goalID: goalDocument.goalID,
},
{
$set: {
wasAssignedStandalone: true,
},
}
);
// return this goal sub document, it's fast!
return { ...userAlreadySubscribed, wasAssignedStandalone: true };
}
const result = await EvaluateGoalForUser(goalDocument, userID, logger);
@@ -452,7 +480,9 @@ export async function SubscribeToGoal(
throw new Error(`Couldn't evaluate goal? See previous logs.`);
}
if (result.achieved && cancelIfAchieved) {
// standalone assignments shouldn't be allowed to assign instantly-achieved
// goals
if (result.achieved && isStandaloneAssignment) {
return SubscribeFailReasons.ALREADY_ACHIEVED;
}
@@ -466,12 +496,12 @@ export async function SubscribeToGoal(
userID,
lastInteraction: null,
timeAchieved: result.achieved ? Date.now() : null,
timeSet: Date.now(),
game: goalDocument.game,
playtype: goalDocument.playtype,
goalID: goalDocument.goalID,
achieved: result.achieved,
wasInstantlyAchieved: result.achieved,
wasAssignedStandalone: isStandaloneAssignment,
};
await db["goal-subs"].insert(goalSub);
@@ -492,10 +522,10 @@ export function GetQuestsThatContainGoal(goalID: string) {
*
* If this query matches none, an empty array is returned.
*/
export async function GetParentQuestSubs(
export async function GetQuestSubsWhichDependOnThisGoalSub(
goalSub: GoalSubscriptionDocument
): Promise<Array<QuestSubscriptionDocument & { quest: QuestDocument }>> {
const parents: Array<QuestSubscriptionDocument & { quest: QuestDocument }> = await db[
const dependencies: Array<QuestSubscriptionDocument & { quest: QuestDocument }> = await db[
"quest-subs"
].aggregate([
{
@@ -531,7 +561,121 @@ export async function GetParentQuestSubs(
},
]);
return parents;
return dependencies;
}
/**
* Given a goalSub, unsubscribe from it.
*
* On success, this will return null. On failure, this will return a failure reason.
* For example, if this goalSub has parent quests involved that prevent its removal, it
* will return those as an array.
*
* @param preventStandaloneRemoval - Some goalsubs might be marked as "standalone". These
* goals have been explicitly and deliberately assigned by the user, and should therefore
* only be explicitly un-assigned.
*/
export async function UnsubscribeFromGoal(
goalSub: GoalSubscriptionDocument,
preventStandaloneRemoval: boolean
) {
const dependencies = await GetGoalDependencies(goalSub);
switch (dependencies.reason) {
case "HAS_QUEST_DEPENDENCIES":
// never remove a goalSub if it has quests depending on it
return dependencies;
case "WAS_STANDALONE": {
// only prevent standalone removal if we're told to
if (preventStandaloneRemoval) {
return dependencies;
}
break;
}
// no handling necessary, orphaned goals should never happen.
case "WAS_ORPHAN":
}
// if we have no reason to prevent the removal, remove it.
await db["goal-subs"].remove({
userID: goalSub.userID,
goalID: goalSub.goalID,
});
return null;
}
/**
* Get the reason why a goal was assigned to a user.
* This is either "WAS_STANDALONE" -- the user assigned this goal directly and deliberately
* or "HAS_QUEST_DEPENDENCIES" -- the user was assigned this goal as the consequence
* of a quest subscription.
*
* Failing that, the goal will return "WAS_ORPHAN", there's no reason this goal
* should be subscribed to the user -- it's safe to remove for any reason.
*/
export async function GetGoalDependencies(goalSub: GoalSubscriptionDocument) {
const parentQuests = await GetQuestSubsWhichDependOnThisGoalSub(goalSub);
if (parentQuests.length) {
return {
reason: "HAS_QUEST_DEPENDENCIES",
parentQuests,
} as const;
}
if (goalSub.wasAssignedStandalone) {
return {
reason: "WAS_STANDALONE",
} as const;
}
return { reason: "WAS_ORPHAN" } as const;
}
/**
* For a given UGPT, unsubscribe from all their goals that no longer have any parent,
* for example, a quest was removed, now they are left with some stranded goals that we
* don't want to keep around.
*/
export async function UnsubscribeFromOrphanedGoalSubs(
userID: integer,
game: Game,
playtype: Playtype
) {
const goalSubs = await db["goal-subs"].find({ game, playtype, userID });
const maybeToRemove = await Promise.all(
goalSubs.map(async (goalSub) => {
const deps = await GetGoalDependencies(goalSub);
if (deps.reason === "WAS_ORPHAN") {
return goalSub.goalID;
}
return null;
})
);
// impressive that ts can't resolve this without a cast
const toRemove = maybeToRemove.filter((e) => e !== null) as Array<string>;
if (toRemove.length > 0) {
logger.info(
`Removing ${toRemove.length} goals from user ${userID} on ${FormatGame(
game,
playtype
)} as they were orphanned.`
);
await db["goal-subs"].remove({
userID,
goalID: { $in: toRemove },
});
}
}
/**
+107 -56
View File
@@ -1,4 +1,9 @@
import { EvaluateGoalForUser, SubscribeToGoal } from "./goals";
import {
EvaluateGoalForUser,
SubscribeToGoal,
UnsubscribeFromGoal,
UnsubscribeFromOrphanedGoalSubs,
} from "./goals";
import db from "external/mongo/db";
import { SubscribeFailReasons } from "lib/constants/err-codes";
import CreateLogCtx from "lib/logger/logger";
@@ -112,14 +117,38 @@ export async function EvaluateQuestProgress(userID: integer, quest: QuestDocumen
const goalResults: Array<EvaluatedGoalResult> = await Promise.all(
goals.map(async (goal) => {
if (isSubscribedToQuest) {
const goalSub = goalSubMap.get(goal.goalID);
let goalSub = goalSubMap.get(goal.goalID);
if (!goalSub) {
logger.error(
`User ${userID} has a corrupt subscription to quest '${quest.name}', They do not have all the goals in this quest assigned.`
// shouldn't happen. Let's just correct the user silently.
logger.warn(
`User ${userID} has a corrupt subscription to quest '${quest.name}', They do not have all the goals in this quest assigned. Automatically subscribing them to the new goal.`
);
throw new Error(`User has corrupt subscription to quest. Cannot calculate.`);
const newGoalSub = await SubscribeToGoal(userID, goal, false);
if (newGoalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
logger.error(
`User ${userID} wasn't subscribed to a goal (${goal.goalID}), but subscription failed because they were already subscribed. This should never happen.`
);
throw new Error(
`Quest subscription was corrupt and we failed to subscribe the user to the missing goal.`
);
}
if (newGoalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
// lol, wut
logger.error(
`Impossible via typesystem: attempted resubscription for user ${userID} on goal ${goal.goalID}, was rejected for being already achieved. Not possible, as we allow already achieved goals here.`
);
throw new Error(
`Quest subscription was corrupt and we failed to subscribe the user to the missing goal.`
);
}
goalSub = newGoalSub;
}
return {
@@ -178,18 +207,18 @@ interface QuestSubscriptionReturns {
* Subscribes the given user to a provided quest. If the user is already subscribed,
* null is returned.
*
* @param cancelIfAchieved - Don't subscribe to the quest if subscribing would cause
* @param denyInstantAchievement - Don't subscribe to the quest if subscribing would cause
* the user to immediately achieve it.
*/
export async function SubscribeToQuest(
userID: integer,
quest: QuestDocument,
cancelIfAchieved: false
denyInstantAchievement: false
): Promise<QuestSubscriptionReturns | SubscribeFailReasons.ALREADY_SUBSCRIBED>;
export async function SubscribeToQuest(
userID: integer,
quest: QuestDocument,
cancelIfAchieved = true
denyInstantAchievement = true
): Promise<
| QuestSubscriptionReturns
| SubscribeFailReasons.ALREADY_ACHIEVED
@@ -206,7 +235,7 @@ export async function SubscribeToQuest(
const result = await EvaluateQuestProgress(userID, quest);
if (result.achieved && cancelIfAchieved) {
if (result.achieved && denyInstantAchievement) {
return SubscribeFailReasons.ALREADY_ACHIEVED;
}
@@ -217,7 +246,6 @@ export async function SubscribeToQuest(
userID,
questID: quest.questID,
wasInstantlyAchieved: result.achieved,
timeSet: Date.now(),
game: quest.game,
playtype: quest.playtype,
achieved: result.achieved,
@@ -238,13 +266,6 @@ export async function SubscribeToQuest(
return { questSub, goals: result.goals, goalResults: result.goalResults };
}
export async function UnsubscribeFromQuest(userID: integer, questID: string) {
await db["quest-subs"].remove({
userID,
questID,
});
}
/**
* Given a questID, update all of its subscriptions to potentially subscribe to any
* new goals added to it.
@@ -261,54 +282,84 @@ export async function UpdateQuestSubscriptions(questID: string) {
const maybeQuest = await db.quests.findOne({ questID });
// if the quest was deleted, we have to take a more manual approach.
if (!maybeQuest) {
logger.info(
`Quest ${questID} has been deleted. Unsubscribing ${subscriptions.length} users.`
// first, remove all subs to this quest
await db["quest-subs"].remove({
questID,
});
// then, this presents us with an interesting problem.
// We can't actually know what goals this user was subscribed to as a result
// of this quest, because said quest no longer exists.
// To mitigate this, we just prune all goalsubs that no longer have any
// dependencies
await Promise.all(
subscriptions.map((e) => UnsubscribeFromOrphanedGoalSubs(e.userID, e.game, e.playtype))
);
return Promise.all(subscriptions.map((e) => UnsubscribeFromQuest(e.userID, e.questID)));
logger.info(
`Quest ${questID} has been deleted. Unsubscribed ${subscriptions.length} users.`
);
return;
}
const goals = await GetGoalsInQuest(maybeQuest);
// the easiest way to do this? unsubscribe all users from the quest, then subscribe
// them all again.
await Promise.all(subscriptions.map((e) => UnsubscribeFromQuest(e, maybeQuest)));
const goalSubscriptionPromises = [];
await Promise.all(subscriptions.map((e) => SubscribeToQuest(e.userID, maybeQuest, false)));
for (const sub of subscriptions) {
for (const goal of goals) {
// attempt to subscribe to all goals in this quest.
// even if they're already subscribed, it's not a problem -- will just fail fast.
const goalSubPromise = SubscribeToGoal(sub.userID, goal, false);
goalSubscriptionPromises.push(goalSubPromise);
await BulkSendNotification(
`The quest '${maybeQuest.name}' has received an update.`,
subscriptions.map((e) => e.userID),
{
type: "QUEST_CHANGED",
content: {
questID,
},
}
}
const subscriptionResults = await Promise.all(goalSubscriptionPromises);
const newStuff = subscriptionResults.filter(
(e) => e !== SubscribeFailReasons.ALREADY_SUBSCRIBED
).length;
if (newStuff !== 0) {
logger.info(
`Updating subscriptions for '${maybeQuest.name}' resulted in ${newStuff} updates.`
);
await BulkSendNotification(
`The quest '${maybeQuest.name}' has changed, You have been automatically subscribed to some new goals.`,
subscriptions.map((e) => e.userID),
{
type: "QUEST_CHANGED",
content: {
questID,
},
}
);
}
return subscriptionResults;
);
}
/**
* Unsubscribe from a quest. This will also unsubscribe the user from any goals they're
* subscribed to as a result.
*
* Returns nothing.
*/
export async function UnsubscribeFromQuest(
questSub: QuestSubscriptionDocument,
quest: QuestDocument
// preventStandaloneRemoval: boolean
) {
// todo questline subs maybe
const goalIDs = GetGoalIDsFromQuest(quest);
// remove the quest sub
// (preventing HAS_QUEST_DEPENDENCIES when this is the quest we're removing anyway)
await db["quest-subs"].remove({
questID: questSub.questID,
userID: questSub.userID,
});
const goalSubs = await db["goal-subs"].find({
userID: questSub.userID,
goalID: { $in: goalIDs },
});
// unsub the user from all goals we can. If we can't unsub from a goal, that's
// not a problem, we weren't meant to unsubscribe from it.
await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
}
// todo
// export async function GetQuestDependencies() {}
/**
* Given an array of user goal subscriptions, return all the quests this user is
* subscribed to that subsume these goals.
@@ -336,7 +387,7 @@ export async function GetParentQuests(
const quests = await db.quests.find({
questID: { $in: questSubIDs },
"quest.questData.goals.goalID": { $in: goalSubs.map((e) => e.goalID) },
"questData.goals.goalID": { $in: goalSubs.map((e) => e.goalID) },
});
return quests;
@@ -6,9 +6,10 @@ import CreateLogCtx from "lib/logger/logger";
import { ServerConfig } from "lib/setup/config";
import {
ConstructGoal,
GetParentQuestSubs,
GetQuestSubsWhichDependOnThisGoalSub,
GetQuestsThatContainGoal,
SubscribeToGoal,
UnsubscribeFromGoal,
} from "lib/targets/goals";
import p from "prudence";
import { RequirePermissions } from "server/middleware/auth";
@@ -166,7 +167,7 @@ router.post(
});
}
const goalSub = await SubscribeToGoal(user.id, goal);
const goalSub = await SubscribeToGoal(user.id, goal, true);
if (goalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
return res.status(409).json({
@@ -252,29 +253,29 @@ router.delete(
GetGoalSubscription,
RequirePermissions("manage_targets"),
async (req, res) => {
const goalID = req.params.goalID;
const { user, game, playtype } = GetUGPT(req);
const goalSub = GetTachiData(req, "goalSubDoc");
const parentQuests = await GetParentQuestSubs(goalSub);
const fail = await UnsubscribeFromGoal(goalSub, true);
if (parentQuests.length) {
return res.status(400).json({
success: false,
description: `This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: ${parentQuests
.map((e) => `'${e.quest.name}'`)
.join(", ")}.`,
});
if (fail) {
switch (fail.reason) {
case "WAS_STANDALONE":
// can't happen. mightaswell handle it though.
return res.status(400).json({
success: false,
description: `This goal was assigned by you and can't be removed as a consequence of another action.`,
});
case "HAS_QUEST_DEPENDENCIES":
return res.status(400).json({
success: false,
description: `This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: ${fail.parentQuests
.map((e) => `'${e.quest.name}'`)
.join(", ")}.`,
});
}
}
await db["goal-subs"].remove({
userID: user.id,
goalID,
game,
playtype,
});
return res.status(200).json({
success: true,
description: `Removed this goal from your subscriptions.`,
@@ -209,7 +209,19 @@ router.delete(
user,
});
await UnsubscribeFromQuest(user.id, quest.questID);
const questSub = await db["quest-subs"].findOne({
userID: user.id,
questID: quest.questID,
});
if (!questSub) {
return res.status(409).json({
success: false,
description: `Can't unsubscribe from a quest you were never subscribed to.`,
});
}
await UnsubscribeFromQuest(questSub, quest);
return res.status(200).json({
success: true,
@@ -99,6 +99,7 @@ router.get("/on-chart/:chartID", async (req, res) => {
user.id,
new Set([chartID]),
logger,
false,
true
);
+2 -2
View File
@@ -591,8 +591,8 @@ export const HC511UserGoal: GoalSubscriptionDocument = {
outOfHuman: "HARD CLEAR",
progress: null,
progressHuman: "NO DATA",
timeSet: 0,
userID: 1,
wasAssignedStandalone: false,
};
export const TestingIIDXFolderSP10: FolderDocument = {
@@ -665,8 +665,8 @@ export const TestingIIDXSPQuestSub: QuestSubscriptionDocument = {
questID: "example_quest_id",
progress: 4,
timeAchieved: null,
timeSet: 1900,
wasInstantlyAchieved: false,
wasAssignedStandalone: false,
};
let KTDATA_CACHE:
+4
View File
@@ -216,6 +216,10 @@ export function FormatBMSTables(bmsTables: Array<{ table: string; level: string
}
export function HumanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
if (arr.length === 1) {
return arr[0];
}
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
}