mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 01:08:29 +03:00
feat: initial goals and quests
This commit is contained in:
@@ -25,12 +25,6 @@ export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
|
||||
const boundaries = gptConfig.gradeBoundaries;
|
||||
const grades = gptConfig.grades;
|
||||
|
||||
if (!boundaries) {
|
||||
throw new Error(
|
||||
`Invalid call to GetGradeFromPercent! GPT ${game}:${playtype} does not use grade boundaries.`
|
||||
);
|
||||
}
|
||||
|
||||
// (hey, this for loop is backwards!)
|
||||
for (let i = boundaries.length - 1; i >= 0; i--) {
|
||||
if (percent + Number.EPSILON >= NotNullish(boundaries[i])) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db from "external/mongo/db";
|
||||
import { EvaluateGoalForUser } from "lib/targets/goals";
|
||||
import { EvaluateGoalForUser, GetRelevantGoals } from "lib/targets/goals";
|
||||
import { EmitWebhookEvent } from "lib/webhooks/webhooks";
|
||||
import type { KtLogger } from "lib/logger/logger";
|
||||
import type { Game, GoalDocument, GoalSubscriptionDocument, integer } from "tachi-common";
|
||||
@@ -184,106 +184,3 @@ export async function ProcessGoal(
|
||||
webhookEvent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the goals the user has set for this game and playtype.
|
||||
* Then, filters it based on the chartIDs involved in this import.
|
||||
*
|
||||
* This optimisation allows users to have *lots* of goals, but only ever
|
||||
* evaluate the ones we need to.
|
||||
* @returns An array of Goals, and an array of goalSubs.
|
||||
*/
|
||||
export async function GetRelevantGoals(
|
||||
game: Game,
|
||||
userID: integer,
|
||||
chartIDs: Set<string>,
|
||||
logger: KtLogger
|
||||
): Promise<{ goals: Array<GoalDocument>; goalSubsMap: Map<string, GoalSubscriptionDocument> }> {
|
||||
const goalSubs = await db["goal-subs"].find({ game, userID });
|
||||
|
||||
logger.verbose(`Found user has ${goalSubs.length} goals.`);
|
||||
|
||||
if (!goalSubs.length) {
|
||||
return { goals: [], goalSubsMap: new Map() };
|
||||
}
|
||||
|
||||
const goalIDs = goalSubs.map((e) => e.goalID);
|
||||
|
||||
const chartIDsArr: Array<string> = [];
|
||||
|
||||
for (const c of chartIDs) {
|
||||
chartIDsArr.push(c);
|
||||
}
|
||||
|
||||
const goals = await Promise.all([
|
||||
// this gets the relevantGoals for direct and multi
|
||||
db.goals.find({
|
||||
"charts.type": { $in: ["single", "multi"] },
|
||||
"charts.data": { $in: chartIDsArr },
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
db.goals.find({
|
||||
"charts.type": "any",
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
GetRelevantFolderGoals(goalIDs, chartIDsArr),
|
||||
]).then((r) => r.flat(1));
|
||||
|
||||
const goalSet = new Set(goals.map((e) => e.goalID));
|
||||
|
||||
const goalSubsMap: Map<string, GoalSubscriptionDocument> = new Map();
|
||||
|
||||
for (const goalSub of goalSubs) {
|
||||
if (!goalSet.has(goalSub.goalID)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// since these are guaranteed to be unique, lets make a hot map of goalID -> goalSubDocument, so we can
|
||||
// pull them in for post-processing and filter out the goalSubDocuments that aren't relevant.
|
||||
goalSubsMap.set(goalSub.goalID, goalSub);
|
||||
}
|
||||
|
||||
return {
|
||||
goals,
|
||||
goalSubsMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of goals where its folder contains any member
|
||||
* of chartIDsArr.
|
||||
*/
|
||||
export function GetRelevantFolderGoals(goalIDs: Array<string>, chartIDsArr: Array<string>) {
|
||||
// Slightly black magic - this is kind of like doing an SQL join.
|
||||
// it's weird to do this in mongodb, but this seems like the right
|
||||
// way to actually handle this.
|
||||
|
||||
const result: Promise<Array<GoalDocument>> = db.goals.aggregate([
|
||||
{
|
||||
$match: {
|
||||
"charts.type": "folder",
|
||||
goalID: { $in: goalIDs },
|
||||
},
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "folder-chart-lookup",
|
||||
localField: "charts.data",
|
||||
foreignField: "folderID",
|
||||
as: "folderCharts",
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
"folderCharts.chartID": { $in: chartIDsArr },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
folderCharts: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import type { FilterQuery } from "mongodb";
|
||||
import type {
|
||||
Game,
|
||||
GoalDocument,
|
||||
GoalSubscriptionDocument,
|
||||
integer,
|
||||
PBScoreDocument,
|
||||
Playtype,
|
||||
GoalSubscriptionDocument,
|
||||
QuestSubscriptionDocument,
|
||||
QuestDocument,
|
||||
QuestSubscriptionDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
@@ -96,7 +96,8 @@ export async function EvaluateGoalForUser(
|
||||
goal.playtype,
|
||||
goal.criteria.key,
|
||||
goal.criteria.value,
|
||||
null
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
if (res) {
|
||||
@@ -242,7 +243,8 @@ export function HumaniseGoalProgress(
|
||||
playtype: Playtype,
|
||||
key: GoalKeys,
|
||||
value: number,
|
||||
userPB: PBScoreDocument | null
|
||||
userPB: PBScoreDocument | null,
|
||||
isOutOf = false
|
||||
): string {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
@@ -254,6 +256,12 @@ export function HumaniseGoalProgress(
|
||||
);
|
||||
}
|
||||
|
||||
if (isOutOf) {
|
||||
return `${gptConfig.grades[value]} (${gptConfig.gradeBoundaries[value]?.toFixed(
|
||||
2
|
||||
)}%)`;
|
||||
}
|
||||
|
||||
return `${gptConfig.grades[value]} (${userPB?.scoreData.percent.toFixed(2) ?? "0"}%)`;
|
||||
}
|
||||
|
||||
@@ -264,12 +272,20 @@ export function HumaniseGoalProgress(
|
||||
);
|
||||
}
|
||||
|
||||
// these games care about the BP as progress towards the goal
|
||||
if (userPB && (game === "iidx" || game === "bms" || game === "pms")) {
|
||||
return `${gptConfig.lamps[value]} (BP: ${
|
||||
(userPB as PBWithBadPoor).scoreData.hitMeta.bp ?? "N/A"
|
||||
})`;
|
||||
}
|
||||
|
||||
// this game cares about where you died relative to the measures in the chart
|
||||
if (game === "itg") {
|
||||
return `${gptConfig.lamps[value]} (BP: ${
|
||||
(userPB as PBScoreDocument<"itg:Stamina">).scoreData.hitMeta.diedAt ?? "N/A"
|
||||
})`;
|
||||
}
|
||||
|
||||
return NotNullish(gptConfig.lamps[value]);
|
||||
}
|
||||
|
||||
@@ -390,10 +406,10 @@ export function GetQuestsThatContainGoal(goalID: string) {
|
||||
*
|
||||
* If this query matches none, an empty array is returned.
|
||||
*/
|
||||
export async function GetBlockingParentQuestSubs(
|
||||
export async function GetParentQuestSubs(
|
||||
goalSub: GoalSubscriptionDocument
|
||||
): Promise<Array<QuestSubscriptionDocument & { quest: QuestDocument }>> {
|
||||
const blockers: Array<QuestSubscriptionDocument & { quest: QuestDocument }> = await db[
|
||||
const parents: Array<QuestSubscriptionDocument & { quest: QuestDocument }> = await db[
|
||||
"quest-subs"
|
||||
].aggregate([
|
||||
{
|
||||
@@ -429,5 +445,131 @@ export async function GetBlockingParentQuestSubs(
|
||||
},
|
||||
]);
|
||||
|
||||
return blockers;
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the goals the user has set for this game and playtype.
|
||||
* Then, filters it based on the chartIDs involved in this import.
|
||||
*
|
||||
* This optimisation allows users to have *lots* of goals, but only ever
|
||||
* evaluate the ones we need to.
|
||||
*
|
||||
* @param onlyUnachieved - optionally, pass "onlyUnachieved=true" to limit this to
|
||||
* only goals that the user has not achieved.
|
||||
* @param excludeAny - optionally, pass "excludeAny=true" to ignore goals that match
|
||||
* "any" chart.
|
||||
* @returns An array of Goals, and an array of goalSubs.
|
||||
*/
|
||||
export async function GetRelevantGoals(
|
||||
game: Game,
|
||||
userID: integer,
|
||||
chartIDs: Set<string>,
|
||||
logger: KtLogger,
|
||||
onlyUnachieved = false,
|
||||
excludeAny = false
|
||||
): Promise<{ goals: Array<GoalDocument>; goalSubsMap: Map<string, GoalSubscriptionDocument> }> {
|
||||
const gsQuery: FilterQuery<GoalSubscriptionDocument> = {
|
||||
game,
|
||||
userID,
|
||||
};
|
||||
|
||||
if (onlyUnachieved) {
|
||||
gsQuery.achieved = false;
|
||||
}
|
||||
|
||||
const goalSubs = await db["goal-subs"].find(gsQuery);
|
||||
|
||||
logger.verbose(`Found user has ${goalSubs.length} goals.`);
|
||||
|
||||
if (!goalSubs.length) {
|
||||
return { goals: [], goalSubsMap: new Map() };
|
||||
}
|
||||
|
||||
const goalIDs = goalSubs.map((e) => e.goalID);
|
||||
|
||||
const chartIDsArr: Array<string> = [];
|
||||
|
||||
for (const c of chartIDs) {
|
||||
chartIDsArr.push(c);
|
||||
}
|
||||
|
||||
const promises = [
|
||||
// this gets the relevantGoals for direct and multi
|
||||
db.goals.find({
|
||||
"charts.type": { $in: ["single", "multi"] },
|
||||
"charts.data": { $in: chartIDsArr },
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
GetRelevantFolderGoals(goalIDs, chartIDsArr),
|
||||
];
|
||||
|
||||
if (!excludeAny) {
|
||||
promises.push(
|
||||
db.goals.find({
|
||||
"charts.type": "any",
|
||||
goalID: { $in: goalIDs },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const goals = await Promise.all(promises).then((r) => r.flat(1));
|
||||
|
||||
const goalSet = new Set(goals.map((e) => e.goalID));
|
||||
|
||||
const goalSubsMap: Map<string, GoalSubscriptionDocument> = new Map();
|
||||
|
||||
for (const goalSub of goalSubs) {
|
||||
if (!goalSet.has(goalSub.goalID)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// since these are guaranteed to be unique, lets make a hot map of goalID -> goalSubDocument, so we can
|
||||
// pull them in for post-processing and filter out the goalSubDocuments that aren't relevant.
|
||||
goalSubsMap.set(goalSub.goalID, goalSub);
|
||||
}
|
||||
|
||||
return {
|
||||
goals,
|
||||
goalSubsMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of goals where its folder contains any member
|
||||
* of chartIDsArr.
|
||||
*/
|
||||
export function GetRelevantFolderGoals(goalIDs: Array<string>, chartIDsArr: Array<string>) {
|
||||
// Slightly black magic - this is kind of like doing an SQL join.
|
||||
// it's weird to do this in mongodb, but this seems like the right
|
||||
// way to actually handle this.
|
||||
|
||||
const result: Promise<Array<GoalDocument>> = db.goals.aggregate([
|
||||
{
|
||||
$match: {
|
||||
"charts.type": "folder",
|
||||
goalID: { $in: goalIDs },
|
||||
},
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "folder-chart-lookup",
|
||||
localField: "charts.data",
|
||||
foreignField: "folderID",
|
||||
as: "folderCharts",
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
"folderCharts.chartID": { $in: chartIDsArr },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
folderCharts: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import CreateLogCtx from "lib/logger/logger";
|
||||
import { BulkSendNotification } from "lib/notifications/notifications";
|
||||
import type { EvaluatedGoalReturn } from "./goals";
|
||||
import type {
|
||||
Game,
|
||||
GoalDocument,
|
||||
GoalSubscriptionDocument,
|
||||
integer,
|
||||
Playtype,
|
||||
QuestDocument,
|
||||
QuestSubscriptionDocument,
|
||||
} from "tachi-common";
|
||||
@@ -316,3 +318,36 @@ export async function UpdateQuestSubscriptions(questID: string) {
|
||||
|
||||
return subscriptionResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of user goal subscriptions, return all the quests this user is
|
||||
* subscribed to that subsume these goals.
|
||||
*/
|
||||
export async function GetParentQuests(
|
||||
userID: integer,
|
||||
game: Game,
|
||||
playtype: Playtype,
|
||||
goalSubs: Array<GoalSubscriptionDocument>
|
||||
) {
|
||||
const questSubs: Array<{ questID: string }> = await db["quest-subs"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
userID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
questID: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const questSubIDs = questSubs.map((e) => e.questID);
|
||||
|
||||
const quests = await db.quests.find({
|
||||
questID: { $in: questSubIDs },
|
||||
"quest.questData.goals.goalID": { $in: goalSubs.map((e) => e.goalID) },
|
||||
});
|
||||
|
||||
return quests;
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import CreateLogCtx from "lib/logger/logger";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import {
|
||||
ConstructGoal,
|
||||
GetBlockingParentQuestSubs,
|
||||
GetParentQuestSubs,
|
||||
GetQuestsThatContainGoal,
|
||||
SubscribeToGoal,
|
||||
} from "lib/targets/goals";
|
||||
@@ -257,7 +257,7 @@ router.delete(
|
||||
|
||||
const goalSub = GetTachiData(req, "goalSubDoc");
|
||||
|
||||
const parentQuests = await GetBlockingParentQuestSubs(goalSub);
|
||||
const parentQuests = await GetParentQuestSubs(goalSub);
|
||||
|
||||
if (parentQuests.length) {
|
||||
return res.status(400).json({
|
||||
|
||||
+124
@@ -1,14 +1,21 @@
|
||||
import goalsRouter from "./goals/router";
|
||||
import questsRouter from "./quests/router";
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { GetRelevantGoals } from "lib/targets/goals";
|
||||
import { GetParentQuests } from "lib/targets/quests";
|
||||
import {
|
||||
GetRecentlyAchievedGoals,
|
||||
GetRecentlyAchievedQuests,
|
||||
GetRecentlyInteractedGoals,
|
||||
GetRecentlyInteractedQuests,
|
||||
} from "utils/db";
|
||||
import { GetFolderChartIDs } from "utils/folder";
|
||||
import { GetUGPT } from "utils/req-tachi-data";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
@@ -68,6 +75,123 @@ router.get("/recently-raised", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Find what unachieved targets this user has set that consider this chart.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/:game/:playtype/targets/on-chart/:chartID
|
||||
*/
|
||||
router.get("/on-chart/:chartID", async (req, res) => {
|
||||
const { game, playtype, user } = GetUGPT(req);
|
||||
|
||||
const chartID = req.params.chartID;
|
||||
|
||||
const chart = await db.charts[game].findOne({ chartID, playtype });
|
||||
|
||||
if (!chart) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Failed to find a chart with chartID '${chartID}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
const { goals, goalSubsMap } = await GetRelevantGoals(
|
||||
game,
|
||||
user.id,
|
||||
new Set([chartID]),
|
||||
logger,
|
||||
true
|
||||
);
|
||||
|
||||
const goalSubs = [...goalSubsMap.values()];
|
||||
|
||||
const quests = await GetParentQuests(user.id, game, playtype, goalSubs);
|
||||
|
||||
const questSubs = await db["quest-subs"].find({
|
||||
questID: { $in: quests.map((e) => e.questID) },
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found pertinent goals`,
|
||||
body: {
|
||||
goals,
|
||||
goalSubs,
|
||||
quests,
|
||||
questSubs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Find what unachieved targets this user has set that involve this folder.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/:game/:playtype/targets/on-folder/:folderID
|
||||
*/
|
||||
router.get("/on-folder/:folderID", async (req, res) => {
|
||||
const { game, playtype, user } = GetUGPT(req);
|
||||
|
||||
const folderID = req.params.folderID;
|
||||
|
||||
const folder = await db.folders.findOne({ folderID, playtype });
|
||||
|
||||
if (!folder) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Failed to find a folder with folderID '${folderID}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
const folderChartIDs = await GetFolderChartIDs(folderID);
|
||||
|
||||
const allGoalSubs = await db["goal-subs"].find({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
const goalIDs = allGoalSubs.map((e) => e.goalID);
|
||||
|
||||
// goals are relevant to a folder if it's part of a folder type goal
|
||||
// or if it's on any of the charts in the folder.
|
||||
// this is convenient for the UI, atleast.
|
||||
const goals = await Promise.all([
|
||||
db.goals.find({
|
||||
"charts.type": { $in: ["single", "multi"] },
|
||||
"charts.data": { $in: folderChartIDs },
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
db.goals.find({
|
||||
"charts.type": "folder",
|
||||
"charts.data": folderID,
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
]).then((r) => r.flat());
|
||||
|
||||
const goalSubs = await db["goal-subs"].find({
|
||||
goalID: { $in: goals.map((e) => e.goalID) },
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
const quests = await GetParentQuests(user.id, game, playtype, goalSubs);
|
||||
|
||||
const questSubs = await db["quest-subs"].find({
|
||||
questID: { $in: quests.map((e) => e.questID) },
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found pertinent goals`,
|
||||
body: {
|
||||
goals,
|
||||
goalSubs,
|
||||
quests,
|
||||
questSubs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.use("/goals", goalsRouter);
|
||||
router.use("/quests", questsRouter);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user