feat: initial goals and quests

This commit is contained in:
zkldi
2022-11-15 04:16:16 +00:00
parent fc64076ddb
commit f889ecdd83
24 changed files with 1142 additions and 178 deletions
@@ -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;
}
+149 -7
View File
@@ -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;
}
+35
View File
@@ -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;
}
@@ -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({
@@ -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);