From 34bd38252f2f415aec81be4fd8f3a0f1b1da4bf6 Mon Sep 17 00:00:00 2001 From: zkldi Date: Sun, 9 May 2021 20:50:28 +0100 Subject: [PATCH] milestone support in one --- server/pnpm-lock.yaml | 6 +- server/src/core/milestone-core.ts | 82 +++++++++++ .../framework/milestones/milestones.ts | 128 ++++++++++++++++++ .../framework/score-import-main.ts | 8 +- 4 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 server/src/core/milestone-core.ts create mode 100644 server/src/score-import/framework/milestones/milestones.ts diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index acc3f774f..fb96de9a9 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -62,7 +62,7 @@ dependencies: express-async-errors: 3.1.1_express@4.17.1 express-session: 1.17.1 fast-json-stable-hash: 1.0.2 - kamaitachi-common: github.com/zkldi/Kamaitachi-common/67f10b22a38cee3c270d74d2091466aef45c2318 + kamaitachi-common: github.com/zkldi/Kamaitachi-common/f10a13f67d186ca9caa474092270f0995ff6c24d mongodb: 3.6.6 monk: 7.3.4 multer: 1.4.2 @@ -4638,8 +4638,8 @@ packages: '@types/yoga-layout': 1.9.2 dev: true - github.com/zkldi/Kamaitachi-common/67f10b22a38cee3c270d74d2091466aef45c2318: - resolution: {tarball: https://codeload.github.com/zkldi/Kamaitachi-common/tar.gz/67f10b22a38cee3c270d74d2091466aef45c2318} + github.com/zkldi/Kamaitachi-common/f10a13f67d186ca9caa474092270f0995ff6c24d: + resolution: {tarball: https://codeload.github.com/zkldi/Kamaitachi-common/tar.gz/f10a13f67d186ca9caa474092270f0995ff6c24d} name: kamaitachi-common version: 0.1.0 dependencies: diff --git a/server/src/core/milestone-core.ts b/server/src/core/milestone-core.ts new file mode 100644 index 000000000..a2c37f6d9 --- /dev/null +++ b/server/src/core/milestone-core.ts @@ -0,0 +1,82 @@ +import { + MilestoneDocument, + UserMilestoneDocument, + integer, + GoalImportInfo, +} from "kamaitachi-common"; + +/** + * Processes and updates a user's milestones from their Goal Import Info (i.e. what is returned + * about goals from imports) + */ +export function ProcessMilestoneFromGII( + milestone: MilestoneDocument, + gii: Map +) { + let goalIDs = GetGoalIDsFromMilestone(milestone); + + let progress = 0; + + for (const goalID of goalIDs) { + let userInfo = gii.get(goalID); + + if (!userInfo) { + continue; + } + + if (!userInfo.achieved) { + continue; + } + + progress++; + } + + let outOf = CalculateMilestoneOutOf(milestone, goalIDs); + + // milestone achieved! + if (progress >= outOf) { + return { achieved: true, progress }; + } + + return { achieved: false, progress }; +} + +/** + * Retrieves the goalID documents in a single array from the + * nested structure of milestones. + */ +function GetGoalIDsFromMilestone(milestone: MilestoneDocument) { + // this sucks - maybe a nicer way to do this, because nested + // maps are just ugly + return milestone.milestoneData.map((e) => e.goals.map((e) => e.goalID)).flat(1); +} + +/** + * Work out how many goals need to be achieved for this + * milestone to be considered completed. + */ +function CalculateMilestoneOutOf(milestone: MilestoneDocument, goalIDs: string[]): integer { + if (milestone.criteria.type === "all") { + return goalIDs.length; + } else if (milestone.criteria.type === "abs") { + if (milestone.criteria.value === null) { + throw new Error( + `Invalid milestone ${milestone.milestoneID} - abs and null are not compatible.` + ); + } + + return milestone.criteria.value!; + } else if (milestone.criteria.type === "proportion") { + if (milestone.criteria.value === null) { + throw new Error( + `Invalid milestone ${milestone.milestoneID} - proportion and null are not compatible.` + ); + } + + return Math.floor(milestone.criteria.value * goalIDs.length); + } + + throw new Error( + `Invalid milestone.criteria.type of ${milestone.criteria.type} -- milestoneID ${milestone.milestoneID}` + ); +} diff --git a/server/src/score-import/framework/milestones/milestones.ts b/server/src/score-import/framework/milestones/milestones.ts new file mode 100644 index 000000000..08399f153 --- /dev/null +++ b/server/src/score-import/framework/milestones/milestones.ts @@ -0,0 +1,128 @@ +import { KtLogger } from "../../../types"; +import { + integer, + Game, + Playtypes, + GoalImportInfo, + UserMilestoneDocument, + MilestoneImportInfo, +} from "kamaitachi-common"; +import db from "../../../db/db"; +import { ProcessMilestoneFromGII } from "../../../core/milestone-core"; +import { BulkWriteUpdateOneOperation } from "mongodb"; + +export async function UpdateUsersMilestones( + importGoalInfo: GoalImportInfo[], + game: Game, + playtypes: Playtypes[Game][], + userID: integer, + logger: KtLogger +) { + let userGoalInfoMap: Map = new Map(); + + let goalIDs = []; + for (const e of importGoalInfo) { + userGoalInfoMap.set(e.goalID, e.new); + goalIDs.push(e.goalID); + } + + let { milestones, userMilestones } = await GetRelevantMilestones( + goalIDs, + game, + playtypes, + userID, + logger + ); + + // create a map here to avoid linear searching when + // co-iterating + let userMilestoneMap = new Map(); + for (const um of userMilestones) { + userMilestoneMap.set(um.milestoneID, um); + } + + let importGoalMap = new Map(); + + for (const ig of importGoalInfo) { + importGoalMap.set(ig.goalID, ig.new); + } + + let bwrite: BulkWriteUpdateOneOperation[] = []; + + let importMilestoneInfo: MilestoneImportInfo[] = []; + + for (const milestone of milestones) { + let { achieved, progress } = ProcessMilestoneFromGII(milestone, importGoalMap); + + let userMilestone = userMilestoneMap.get(milestone.milestoneID); + + if (!userMilestone) { + logger.severe( + `Invalid state achieved in milestone processing - processed milestone that user did not have? ${milestone.milestoneID}` + ); + + throw new Error("Invalid state achieved in milestone processing."); + } + + bwrite.push({ + updateOne: { + filter: { milestoneID: milestone.milestoneID }, + update: { + $set: { + achieved, + progress, + }, + }, + }, + }); + + if (progress !== userMilestone.progress) { + importMilestoneInfo.push({ + milestoneID: userMilestone.milestoneID, + old: { + progress: userMilestone.progress, + achieved: userMilestone.achieved, + }, + new: { + progress, + achieved, + }, + }); + } + + if (achieved && !userMilestone.achieved) { + // @todo emit some sort of event + } + } + + if (bwrite.length !== 0) { + await db["user-milestones"].bulkWrite(bwrite, { ordered: false }); + } + + return importMilestoneInfo; +} + +async function GetRelevantMilestones( + goalIDs: string[], + game: Game, + playtypes: Playtypes[Game][], + userID: integer, + logger: KtLogger +) { + let userMilestones = await db["user-milestones"].find({ + game, + playtype: { $in: playtypes }, + userID, + }); + + logger.debug(`Found ${userMilestones.length} user-milestones.`); + + let milestones = await db.milestones.find({ + milestoneID: { $in: userMilestones.map((e) => e.milestoneID) }, + "milestoneData.goals.goalID": { $in: goalIDs }, + }); + + logger.debug(`Found ${milestones.length} relevant milestones.`); + + return { userMilestones, milestones }; +} diff --git a/server/src/score-import/framework/score-import-main.ts b/server/src/score-import/framework/score-import-main.ts index 3d2f67462..851483d78 100644 --- a/server/src/score-import/framework/score-import-main.ts +++ b/server/src/score-import/framework/score-import-main.ts @@ -16,6 +16,7 @@ import { ProcessPBs } from "./pb/process-pbs"; import { UpdateUsersGamePlaytypeStats } from "./user-game-stats/update-ugs"; import db from "../../db/db"; import { UpdateUsersGoals } from "./goals/goals"; +import { UpdateUsersMilestones } from "./milestones/milestones"; export default async function ScoreImportMain( user: PublicUserDocument, @@ -91,7 +92,11 @@ export default async function ScoreImportMain( // Evaluate and update the users goals. This returns information about goals that have changed. let goalInfo = await UpdateUsersGoals(game, user.id, chartIDs, logger); - // --- 8. Finalise Import Document --- + // --- 8. Milestones --- + // Evaluate and update the users milestones. This returns... + let milestoneInfo = await UpdateUsersMilestones(goalInfo, game, playtypes, user.id, logger); + + // --- 9. Finalise Import Document --- // Create and Save an import document to the database, and finish everything up! const ImportDocument: ImportDocument = { importType, @@ -105,6 +110,7 @@ export default async function ScoreImportMain( userID: user.id, classDeltas, goalInfo, + milestoneInfo, userIntent, };