From a6d89f748e9fbeea553d80edaefd951dddb90976 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 25 Nov 2022 03:45:20 +0000 Subject: [PATCH] feat: bunch of new stuff + proper dependency handling --- .../games/_game/_playtype/GPTChartPage.tsx | 133 ++++++++++---- .../_playtype/folders/FolderQuestsPage.tsx | 4 +- .../app/pages/dashboard/utils/QuestEditor.tsx | 2 +- client/src/app/routes/GameRoutes.tsx | 2 +- .../src/components/game/quests/QuestsPage.tsx | 2 +- .../tables/dropdowns/ScoreDropdown.tsx | 2 +- .../dropdowns/components/TargetInfo.tsx | 20 ++- .../components/tables/goals/GoalSubTable.tsx | 75 -------- client/src/components/targets/GoalSubInfo.tsx | 74 ++++++++ .../components/{ => targets}/quests/Quest.tsx | 36 +++- .../quests/editor/EditableQuest.tsx | 0 client/src/components/util/LinkButton.tsx | 9 +- .../src/components/util/SelectLinkButton.tsx | 3 + client/src/util/misc.ts | 8 + common/src/lib/schemas.ts | 2 - common/src/types.ts | 10 +- docs/docs/schemas/goal-sub.md | 2 - server/src/external/mongo/indexes.ts | 6 +- .../framework/goals/goals.test.ts | 4 +- .../framework/quests/quests.test.ts | 12 +- server/src/lib/targets/goals.test.ts | 2 +- server/src/lib/targets/goals.ts | 164 ++++++++++++++++-- server/src/lib/targets/quests.ts | 163 +++++++++++------ .../_game/_playtype/targets/goals/router.ts | 41 ++--- .../_game/_playtype/targets/quests/router.ts | 14 +- .../games/_game/_playtype/targets/router.ts | 1 + server/src/test-utils/test-data.ts | 4 +- server/src/utils/misc.ts | 4 + 28 files changed, 567 insertions(+), 232 deletions(-) delete mode 100644 client/src/components/tables/goals/GoalSubTable.tsx create mode 100644 client/src/components/targets/GoalSubInfo.tsx rename client/src/components/{ => targets}/quests/Quest.tsx (86%) rename client/src/components/{ => targets}/quests/editor/EditableQuest.tsx (100%) diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx index 90b70156f..378ca1f4c 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx @@ -1,5 +1,5 @@ import { APIFetchV1, UnsuccessfulAPIFetchResponse } from "util/api"; -import { CreateUserMap } from "util/data"; +import { CreateChartLink, CreateUserMap } from "util/data"; import { SelectRightChart } from "util/misc"; import { MillisToSince } from "util/time"; import useSetSubheader from "components/layout/header/useSetSubheader"; @@ -13,12 +13,11 @@ import ApiError from "components/util/ApiError"; import Icon from "components/util/Icon"; import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; -import SelectButton from "components/util/SelectButton"; import { UserContext } from "context/UserContext"; import React, { useContext, useMemo, useState } from "react"; import { Col, Row } from "react-bootstrap"; import { useQuery } from "react-query"; -import { Link, useParams } from "react-router-dom"; +import { Link, Route, Switch, useParams } from "react-router-dom"; import { ChartDocument, FormatDifficulty, @@ -32,11 +31,17 @@ import { import { ChartPBLeaderboardReturn, ChartRivalsReturn, + GoalsOnChartReturn, UGPTChartLeaderboardAdjacent, } from "types/api-returns"; import { GamePT, SetState } from "types/react"; import { PBDataset } from "types/tables"; import useLUGPTSettings from "components/util/useLUGPTSettings"; +import TargetInfo from "components/tables/dropdowns/components/TargetInfo"; +import useApiQuery from "components/util/query/useApiQuery"; +import Divider from "components/util/Divider"; +import { TargetsContext } from "context/TargetsContext"; +import SelectLinkButton from "components/util/SelectLinkButton"; // This component forms a wrapper around the Real GPT Chart Page // which handles the case where activeChart == null. @@ -93,7 +98,9 @@ function InternalGPTChartPage({ } & GamePT) { const { user } = useContext(UserContext); - const [mode, setMode] = useState<"leaderboard" | "adjacent" | "rivals">("leaderboard"); + const [mode, setMode] = useState<"leaderboard" | "adjacent" | "rivals" | "targets">( + "leaderboard" + ); const { data, error } = useQuery( ["PBInfo", `${chart.chartID}`], @@ -174,44 +181,108 @@ function InternalGPTChartPage({ userMap.set(user.id, user); } + const base = CreateChartLink(chart, game); + return ( - -

Leaderboard

- - -
- + +
+ Best 100 - - + + Your Position - + {user && ( - - - VS. Rivals - + <> + + + Vs. Rivals + + + + Goals & Quests + + )}
- + + + + + + + + + ); } +function ChartTargetInfo({ + user, + game, + playtype, + chart, + song, +}: { + user: UserDocument; + chart: ChartDocument; + song: SongDocument; +} & GamePT) { + const { reloadTargets } = useContext(TargetsContext); + const [shouldReload, setShouldReload] = useState(0); + + const { error, data } = useApiQuery( + `/users/${user.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${chart.chartID}`, + undefined, + // force a reload of this data when the user adds a new goal + [shouldReload.toString()] + ); + + return ( + + + { + // reload local query, then reload global targets. + setShouldReload(shouldReload + 1); + reloadTargets(); + }, + }} + /> + + ); +} + function ChartLeaderboardTable({ data, userMap, @@ -325,21 +396,13 @@ function TopShowcase({ - + ); } -function PlayCard({ - pb, - user, - name, -}: { - pb: PBScoreDocument; - user: UserDocument; - name: string; -}) { +function PlayCard({ pb, user, name }: { pb: PBScoreDocument; user: UserDocument; name: string }) { return ( diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx index 574c8a96f..4de3896db 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx @@ -1,5 +1,4 @@ import { CreateGoalSubDataset, CreateUserMap } from "util/data"; -import GoalSubTable from "components/tables/goals/GoalSubTable"; import SetNewGoalModal from "components/targets/SetNewGoalModal"; import ApiError from "components/util/ApiError"; import Divider from "components/util/Divider"; @@ -10,6 +9,7 @@ import { Button, Col } from "react-bootstrap"; import { FolderDocument } from "tachi-common"; import { GoalsOnChartReturn, GoalsOnFolderReturn } from "types/api-returns"; import { UGPT } from "types/react"; +import GoalSubInfo from "components/targets/GoalSubInfo"; export default function FolderQuestsPage({ folder, @@ -68,7 +68,7 @@ function FolderQuestsInner({ const userMap = CreateUserMap([reqUser]); return ( - - + ({ const [shouldRefresh, forceRefresh] = useReducer((state) => state + 1, 0); - // when a user isn't logged in, skip ever making this request. const { error: targetError, data: targetData } = useApiQuery( `/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${ chart.chartID }`, undefined, [shouldRefresh], + // when a user isn't logged in, skip ever making this request. currentUser === null ); diff --git a/client/src/components/tables/dropdowns/components/TargetInfo.tsx b/client/src/components/tables/dropdowns/components/TargetInfo.tsx index 202a21455..886b2cf18 100644 --- a/client/src/components/tables/dropdowns/components/TargetInfo.tsx +++ b/client/src/components/tables/dropdowns/components/TargetInfo.tsx @@ -8,8 +8,10 @@ import React, { useState } from "react"; import { Button, Col } from "react-bootstrap"; import { GoalsOnChartReturn } from "types/api-returns"; import { UGPT } from "types/react"; -import { ChartDocument, SongDocument } from "tachi-common"; -import GoalSubTable from "components/tables/goals/GoalSubTable"; +import { ChartDocument, FormatChart, SongDocument } from "tachi-common"; +import Icon from "components/util/Icon"; +import GoalSubInfo from "components/targets/GoalSubInfo"; +import DebugContent from "components/util/DebugContent"; export default function TargetInfo({ data, @@ -39,19 +41,21 @@ export default function TargetInfo({ const userMap = CreateUserMap([reqUser]); + const dataset = CreateGoalSubDataset(data, userMap); + return (
- +

Your Goals on {FormatChart(game, song, chart)}

+ + + + [] = [ - ["Goal", "Goal", StrSOV((x) => x.__related.goal.name)], - [ - "Progress", - "Progress", - NumericSOV((x) => (x.progress === null ? -Infinity : x.progress / x.outOf)), - ], - ["Goal Set", "Goal Set", NumericSOV((x) => x.timeSet)], - ]; - - if (showUser) { - headers.unshift(["User", "User", StrSOV((x) => x.__related.user.username)]); - } - - return ( - k.__related.user.username, - goal: (k) => k.__related.goal.name, - timestamp: (k) => k.timeAchieved, - } - : undefined - } - rowFunction={(d) => ( - - {showUser && ( - - )} - - - {d.achieved ? ( - Achieved! - ) : ( - <> - {d.progressHuman ?? "N/A"} - /{d.outOfHuman} - - )} - - - - )} - /> - ); -} diff --git a/client/src/components/targets/GoalSubInfo.tsx b/client/src/components/targets/GoalSubInfo.tsx new file mode 100644 index 000000000..c6e2294ad --- /dev/null +++ b/client/src/components/targets/GoalSubInfo.tsx @@ -0,0 +1,74 @@ +import Divider from "components/util/Divider"; +import React, { useMemo, useState } from "react"; +import { Col, Form } from "react-bootstrap"; +import { GamePT } from "types/react"; +import { GoalSubDataset } from "types/tables"; +import { InnerQuestSectionGoal } from "./quests/Quest"; + +export default function GoalSubInfo({ dataset }: { dataset: GoalSubDataset } & GamePT) { + const [hideAchieved, setHideAchieved] = useState(false); + + const { directGoals, folderGoals } = useMemo(() => { + let baseDataset = dataset; + + if (hideAchieved) { + baseDataset = baseDataset.filter((e) => e.achieved === false); + } + + return { + directGoals: baseDataset.filter( + (e) => + e.__related.goal.charts.type === "single" || + e.__related.goal.charts.type === "multi" + ), + folderGoals: baseDataset.filter((e) => e.__related.goal.charts.type === "folder"), + }; + }, [hideAchieved]); + + return ( + <> + +
+
+ setHideAchieved(!hideAchieved)} + checked={hideAchieved} + label="Hide Achieved Goals" + /> +
+
+ + + + {directGoals.length !== 0 && ( +
+ {directGoals.map((e, i) => ( +
+ e.name)} + /> +
+ ))} +
+ )} + {folderGoals.length !== 0 && directGoals.length !== 0 && } + {folderGoals.length !== 0 && ( +
+ {folderGoals.map((e, i) => ( +
+ e.name)} + /> +
+ ))} +
+ )} + {folderGoals.length === 0 && directGoals.length === 0 && ( +
You've got no goals related to this chart.
+ )} + + + ); +} diff --git a/client/src/components/quests/Quest.tsx b/client/src/components/targets/quests/Quest.tsx similarity index 86% rename from client/src/components/quests/Quest.tsx rename to client/src/components/targets/quests/Quest.tsx index 154f2eae1..f8dffa3df 100644 --- a/client/src/components/quests/Quest.tsx +++ b/client/src/components/targets/quests/Quest.tsx @@ -1,5 +1,6 @@ import { APIFetchV1 } from "util/api"; import { FormatTime } from "util/time"; +import { HumanisedJoinArray } from "util/misc"; import Card from "components/layout/page/Card"; import Divider from "components/util/Divider"; import Icon from "components/util/Icon"; @@ -168,7 +169,15 @@ function QuestSectionComponent({ ); } -function InnerQuestSectionGoal({ goal, note }: { goal: GoalDocument; note?: string }) { +export function InnerQuestSectionGoal({ + goal, + note, + dependencies, +}: { + goal: GoalDocument; + note?: string; + dependencies?: string[]; +}) { const { goalSubs } = useContext(TargetsContext); const { game, playtype } = goal; @@ -242,7 +251,30 @@ function InnerQuestSectionGoal({ goal, note }: { goal: GoalDocument; note?: stri
)}
- {note && {note}} +
+ {note && {note}} + {dependencies && ( + + )} +
); } + +function FormatGoalDependencies({ deps, isStandalone }: { deps: string[]; isStandalone: boolean }) { + let str; + if (isStandalone && deps.length === 0) { + str = `Assigned standalone.`; + } else if (isStandalone && deps.length > 0) { + str = `Assigned standalone and from ${HumanisedJoinArray(deps, "and")}`; + } else if (deps.length === 0) { + return null; + } else { + str = `From ${HumanisedJoinArray(deps, "and")}`; + } + + return {str}; +} diff --git a/client/src/components/quests/editor/EditableQuest.tsx b/client/src/components/targets/quests/editor/EditableQuest.tsx similarity index 100% rename from client/src/components/quests/editor/EditableQuest.tsx rename to client/src/components/targets/quests/editor/EditableQuest.tsx diff --git a/client/src/components/util/LinkButton.tsx b/client/src/components/util/LinkButton.tsx index 5c74fcb6b..05e4090e5 100644 --- a/client/src/components/util/LinkButton.tsx +++ b/client/src/components/util/LinkButton.tsx @@ -8,14 +8,21 @@ export default function LinkButton({ children, style, onClick, + disabled = false, }: { to: string; className?: string; style?: CSSProperties; onClick?: () => void; + disabled?: boolean; } & JustChildren) { return ( - + {children} ); diff --git a/client/src/components/util/SelectLinkButton.tsx b/client/src/components/util/SelectLinkButton.tsx index 7d229f55a..be3b57313 100644 --- a/client/src/components/util/SelectLinkButton.tsx +++ b/client/src/components/util/SelectLinkButton.tsx @@ -10,14 +10,17 @@ export default function SelectLinkButton({ offVariant = "outline-secondary", to, matchIfStartsWith = false, + disabled = false, }: { onVariant?: ButtonVariant; offVariant?: ButtonVariant; to: string; matchIfStartsWith?: boolean; + disabled?: boolean; } & JustChildren) { return ( (elements: T[], element: T, i: integer) { export function DeleteInPosition(elements: T[], i: integer) { return [...elements.slice(0, i), ...elements.slice(i + 1)]; } + +export function HumanisedJoinArray(arr: Array, lastJoiner = "or") { + if (arr.length === 1) { + return arr[0]; + } + + return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`; +} diff --git a/common/src/lib/schemas.ts b/common/src/lib/schemas.ts index 439a7dfcf..0f0908c12 100644 --- a/common/src/lib/schemas.ts +++ b/common/src/lib/schemas.ts @@ -657,7 +657,6 @@ const PRE_SCHEMAS = { game: p.isIn(games), playtype: isValidPlaytype, achieved: "boolean", - timeSet: p.isPositive, timeAchieved: p.nullable(p.isPositive), lastInteraction: p.nullable(p.isPositive), progress: "?number", @@ -670,7 +669,6 @@ const PRE_SCHEMAS = { userID: p.isPositiveNonZeroInteger, game: p.isIn(games), playtype: isValidPlaytype, - timeSet: p.isPositive, achieved: "boolean", timeAchieved: p.nullable(p.isPositive), progress: p.isInteger, diff --git a/common/src/types.ts b/common/src/types.ts index 7b0175e07..f54562c3b 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -597,13 +597,16 @@ export type GoalSubscriptionDocument = MongoDBDocument & { userID: integer; game: Game; playtype: Playtype; - timeSet: integer; lastInteraction: integer | null; progress: number | null; progressHuman: string; outOf: number; outOfHuman: string; wasInstantlyAchieved: boolean; + + // Was this goal assigned "standalone"? I.e. a user explicitly subscribed to this. + // instead of it being a result of a quest subscription. + wasAssignedStandalone: boolean; } & ( | { achieved: false; @@ -990,10 +993,13 @@ export type QuestSubscriptionDocument = MongoDBDocument & { userID: integer; game: Game; playtype: Playtype; - timeSet: integer; progress: integer; lastInteraction: integer | null; wasInstantlyAchieved: boolean; + + // Was this quest assigned "standalone"? I.e. a user explicitly subscribed to this - + // instead of it being a result of a questline subscription. + wasAssignedStandalone: boolean; } & ( | { achieved: false; diff --git a/docs/docs/schemas/goal-sub.md b/docs/docs/schemas/goal-sub.md index eb1def1cc..361e96631 100644 --- a/docs/docs/schemas/goal-sub.md +++ b/docs/docs/schemas/goal-sub.md @@ -19,7 +19,6 @@ type GoalSubscriptionDocument = MongoDBDocument & { userID: integer; game: Game; playtype: Playtype; - timeSet: integer; lastInteraction: integer | null; progress: number | null; progressHuman: string; @@ -44,7 +43,6 @@ type GoalSubscriptionDocument = MongoDBDocument & { | `userID` | This is the user this goal subscription belongs to. | | `game`, `playtype` | These fields are both *technically* redundant. However, for optimisation reasons, they are copied over from the goal document field. | | `achieved` | Whether this goal has been achieved or not. | -| `timeSet` | The time the user set this goal. | | `timeAchieved` | The time this user achieved this goal. If the user has not achieved this goal, it is set as `null`. | | `lastInteraction` | The last time this user did something that shifted their progress/outOf on this goal. This is initialised to null. | | `progress` | The user's raw progress towards this goal. This is a number, and should not be displayed to the user. | diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index c91efede5..d7a9a0003 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -43,7 +43,11 @@ const staticIndexes: Partial>> = { ], "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: [ diff --git a/server/src/lib/score-import/framework/goals/goals.test.ts b/server/src/lib/score-import/framework/goals/goals.test.ts index c1f33a1ab..baa1c44ca 100644 --- a/server/src/lib/score-import/framework/goals/goals.test.ts +++ b/server/src/lib/score-import/framework/goals/goals.test.ts @@ -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, }; diff --git a/server/src/lib/score-import/framework/quests/quests.test.ts b/server/src/lib/score-import/framework/quests/quests.test.ts index 201e81aec..cbff4e1a0 100644 --- a/server/src/lib/score-import/framework/quests/quests.test.ts +++ b/server/src/lib/score-import/framework/quests/quests.test.ts @@ -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, }, ]); diff --git a/server/src/lib/targets/goals.test.ts b/server/src/lib/targets/goals.test.ts index 1350e31ce..5140e922f 100644 --- a/server/src/lib/targets/goals.test.ts +++ b/server/src/lib/targets/goals.test.ts @@ -432,8 +432,8 @@ t.test("#GetRelevantGoals", (t) => { outOfHuman: "HARD CLEAR", progress: null, progressHuman: "NO DATA", - timeSet: Date.now(), userID: 1, + wasAssignedStandalone: false, })) ); }); diff --git a/server/src/lib/targets/goals.ts b/server/src/lib/targets/goals.ts index 71ed9c2da..9a8775402 100644 --- a/server/src/lib/targets/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -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> { - const parents: Array = await db[ + const dependencies: Array = 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; + + 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 }, + }); + } } /** diff --git a/server/src/lib/targets/quests.ts b/server/src/lib/targets/quests.ts index 0b00b9c36..3cdcc255e 100644 --- a/server/src/lib/targets/quests.ts +++ b/server/src/lib/targets/quests.ts @@ -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 = 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; 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; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 8120bcb94..f57c0400a 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -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.`, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts index 506d37e2a..bed1e7cfe 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -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, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index e19b28f90..7c7aaf7a3 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -99,6 +99,7 @@ router.get("/on-chart/:chartID", async (req, res) => { user.id, new Set([chartID]), logger, + false, true ); diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 17f12c83e..2faf805eb 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -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: diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 53ff2b0a7..6470ec384 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -216,6 +216,10 @@ export function FormatBMSTables(bmsTables: Array<{ table: string; level: string } export function HumanisedJoinArray(arr: Array, lastJoiner = "or") { + if (arr.length === 1) { + return arr[0]; + } + return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`; }