mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-28 01:48:11 +03:00
feat: bunch of new stuff + proper dependency handling
This commit is contained in:
@@ -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<ChartPBData, UnsuccessfulAPIFetchResponse>(
|
||||
["PBInfo", `${chart.chartID}`],
|
||||
@@ -174,44 +181,108 @@ function InternalGPTChartPage({
|
||||
userMap.set(user.id, user);
|
||||
}
|
||||
|
||||
const base = CreateChartLink(chart, game);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12} className="text-center mb-2">
|
||||
<h4 className="display-4">Leaderboard</h4>
|
||||
</Col>
|
||||
<Col xs={12} className="d-flex justify-content-center mb-8">
|
||||
<div className="btn-group">
|
||||
<SelectButton value={mode} setValue={setMode} id="leaderboard">
|
||||
<Col xs={12} className="d-flex mb-8">
|
||||
<div className="btn-group w-100">
|
||||
<SelectLinkButton to={base}>
|
||||
<Icon type="trophy" />
|
||||
Best 100
|
||||
</SelectButton>
|
||||
<SelectButton
|
||||
value={mode}
|
||||
setValue={setMode}
|
||||
id="adjacent"
|
||||
disabled={!data.adjacent}
|
||||
>
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/me`} disabled={!data.adjacent}>
|
||||
<Icon type="user" />
|
||||
Your Position
|
||||
</SelectButton>
|
||||
</SelectLinkButton>
|
||||
{user && (
|
||||
<SelectButton value={mode} setValue={setMode} id="rivals">
|
||||
<Icon type="user" />
|
||||
VS. Rivals
|
||||
</SelectButton>
|
||||
<>
|
||||
<SelectLinkButton to={`${base}/rivals`} disabled={!data.adjacent}>
|
||||
<Icon type="users" />
|
||||
Vs. Rivals
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/targets`}>
|
||||
<Icon type="scroll" />
|
||||
Goals & Quests
|
||||
</SelectLinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<TopShowcase data={data} user={user} userMap={userMap} />
|
||||
<Col xs={12} className="mt-4">
|
||||
<ChartLeaderboardTable
|
||||
{...{ data, game, playtype, user, userMap, chart, song, mode }}
|
||||
/>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty/targets"
|
||||
>
|
||||
<ChartTargetInfo {...{ chart, game, playtype, song, user: user! }} />
|
||||
</Route>
|
||||
|
||||
<Route path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty">
|
||||
<ChartLeaderboardTable
|
||||
{...{
|
||||
data,
|
||||
game,
|
||||
playtype,
|
||||
user,
|
||||
userMap,
|
||||
chart,
|
||||
song,
|
||||
mode: mode as any,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
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<GoalsOnChartReturn>(
|
||||
`/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 (
|
||||
<Col xs={12} className="text-center">
|
||||
<Divider />
|
||||
<TargetInfo
|
||||
{...{
|
||||
chart,
|
||||
data,
|
||||
error,
|
||||
game,
|
||||
playtype,
|
||||
reqUser: user,
|
||||
song,
|
||||
onGoalSet: () => {
|
||||
// reload local query, then reload global targets.
|
||||
setShouldReload(shouldReload + 1);
|
||||
reloadTargets();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLeaderboardTable({
|
||||
data,
|
||||
userMap,
|
||||
@@ -325,21 +396,13 @@ function TopShowcase({
|
||||
<PlayCard name="Best Play" pb={bestPlay} user={bestUser} />
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<PlayCard name="Your Score" pb={thisUsersPlay} user={user!} />
|
||||
<PlayCard name="Your PB" pb={thisUsersPlay} user={user!} />
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayCard({
|
||||
pb,
|
||||
user,
|
||||
name,
|
||||
}: {
|
||||
pb: PBScoreDocument;
|
||||
user: UserDocument;
|
||||
name: string;
|
||||
}) {
|
||||
function PlayCard({ pb, user, name }: { pb: PBScoreDocument; user: UserDocument; name: string }) {
|
||||
return (
|
||||
<Card header={name}>
|
||||
<Row className="align-items-center">
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
<GoalSubTable
|
||||
<GoalSubInfo
|
||||
dataset={CreateGoalSubDataset(data, userMap)}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeAtPosition, DeleteInPosition } from "util/misc";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import EditableQuest from "components/quests/editor/EditableQuest";
|
||||
import EditableQuest from "components/targets/quests/editor/EditableQuest";
|
||||
import AddNewQuestModal from "components/targets/AddNewQuestModal";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
|
||||
@@ -225,7 +225,7 @@ function SongChartRoutes({ game, playtype }: GamePT) {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route exact path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty">
|
||||
<Route path="/dashboard/games/:game/:playtype/songs/:songID/:difficulty">
|
||||
<GPTChartPage
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Col, Form, Row } from "react-bootstrap";
|
||||
import { FormatGame, GetGameConfig, QuestDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { GPTQuestsReturn } from "types/api-returns";
|
||||
import Quest from "components/quests/Quest";
|
||||
import Quest from "components/targets/quests/Quest";
|
||||
|
||||
export default function QuestsPage({ game, playtype }: GamePT) {
|
||||
useSetSubheader(
|
||||
|
||||
@@ -74,13 +74,13 @@ export default function ScoreDropdown<I extends IDStrings = IDStrings>({
|
||||
|
||||
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<GoalsOnChartReturn>(
|
||||
`/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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-100">
|
||||
<Col xs={12}>
|
||||
<GoalSubTable
|
||||
small
|
||||
dataset={CreateGoalSubDataset(data, userMap)}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
/>
|
||||
<h1>Your Goals on {FormatChart(game, song, chart)}</h1>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<GoalSubInfo dataset={dataset} game={game} playtype={playtype} />
|
||||
</Col>
|
||||
|
||||
<Divider />
|
||||
<Button variant="outline-success" onClick={() => setShow(true)}>
|
||||
<Icon type="bullseye" />
|
||||
Set New Goal
|
||||
</Button>
|
||||
<SetNewGoalModal
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { NumericSOV, StrSOV } from "util/sorts";
|
||||
import React from "react";
|
||||
import { COLOUR_SET } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { GoalSubDataset } from "types/tables";
|
||||
import TimestampCell from "../cells/TimestampCell";
|
||||
import UserCell from "../cells/UserCell";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import GoalSubTitleCell from "../cells/GoalSubTitleCell";
|
||||
|
||||
export default function GoalSubTable({
|
||||
game,
|
||||
playtype,
|
||||
dataset,
|
||||
showUser = false,
|
||||
small = false,
|
||||
}: GamePT & { dataset: GoalSubDataset; showUser?: boolean; small?: boolean }) {
|
||||
const headers: Header<GoalSubDataset[0]>[] = [
|
||||
["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 (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName="Goals"
|
||||
noTopDisplayStr={small}
|
||||
searchFunctions={
|
||||
!small
|
||||
? {
|
||||
user: (k) => k.__related.user.username,
|
||||
goal: (k) => k.__related.goal.name,
|
||||
timestamp: (k) => k.timeAchieved,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowFunction={(d) => (
|
||||
<tr>
|
||||
{showUser && (
|
||||
<UserCell game={game} playtype={playtype} user={d.__related.user} />
|
||||
)}
|
||||
<GoalSubTitleCell game={game} playtype={playtype} data={d} />
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: d.achieved
|
||||
? ChangeOpacity(COLOUR_SET.green, 0.2)
|
||||
: ChangeOpacity(COLOUR_SET.red, 0.2),
|
||||
}}
|
||||
>
|
||||
{d.achieved ? (
|
||||
<b>Achieved!</b>
|
||||
) : (
|
||||
<>
|
||||
<b>{d.progressHuman ?? "N/A"}</b>
|
||||
<small>/{d.outOfHuman}</small>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<TimestampCell time={d.timeSet} />
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Col xs={12}>
|
||||
<div className="pl-6">
|
||||
<div className="d-flex w-100 justify-content-start">
|
||||
<Form.Check
|
||||
onChange={() => setHideAchieved(!hideAchieved)}
|
||||
checked={hideAchieved}
|
||||
label="Hide Achieved Goals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
{directGoals.length !== 0 && (
|
||||
<div className="pl-6">
|
||||
{directGoals.map((e, i) => (
|
||||
<div className="pb-2 text-left" key={i}>
|
||||
<InnerQuestSectionGoal
|
||||
goal={e.__related.goal}
|
||||
dependencies={e.__related.parentQuests.map((e) => e.name)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{folderGoals.length !== 0 && directGoals.length !== 0 && <Divider />}
|
||||
{folderGoals.length !== 0 && (
|
||||
<div className="pl-6">
|
||||
{folderGoals.map((e, i) => (
|
||||
<div className="pb-2 text-left" key={i}>
|
||||
<InnerQuestSectionGoal
|
||||
goal={e.__related.goal}
|
||||
dependencies={e.__related.parentQuests.map((e) => e.name)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{folderGoals.length === 0 && directGoals.length === 0 && (
|
||||
<div>You've got no goals related to this chart.</div>
|
||||
)}
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+34
-2
@@ -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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{note && <Muted>{note}</Muted>}
|
||||
<div>
|
||||
{note && <Muted>{note}</Muted>}
|
||||
{dependencies && (
|
||||
<FormatGoalDependencies
|
||||
isStandalone={goalSub.wasAssignedStandalone}
|
||||
deps={dependencies}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Muted>{str}</Muted>;
|
||||
}
|
||||
@@ -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 (
|
||||
<Link onClick={onClick} to={to} className={`btn ${className}`} style={style}>
|
||||
<Link
|
||||
onClick={onClick}
|
||||
to={to}
|
||||
className={`btn ${disabled ? "disabled" : ""} ${className}`}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<LinkButton
|
||||
disabled={disabled}
|
||||
to={to}
|
||||
className={`btn-${
|
||||
DoesMatchRoute(window.location.href, to, !matchIfStartsWith)
|
||||
|
||||
@@ -563,3 +563,11 @@ export function ChangeAtPosition<T>(elements: T[], element: T, i: integer) {
|
||||
export function DeleteInPosition<T>(elements: T[], i: integer) {
|
||||
return [...elements.slice(0, i), ...elements.slice(i + 1)];
|
||||
}
|
||||
|
||||
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]!}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-2
@@ -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;
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Vendored
+5
-1
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-20
@@ -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.`,
|
||||
|
||||
+13
-1
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]!}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user