From 839fac7bbcdac8829a52bb900d4edf5aeacc11b7 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Tue, 18 Oct 2022 19:04:29 +0100 Subject: [PATCH] refactor!: rename milestones to quests and milestone-sets to questlines --- CHANGELOG.md | 6 +- bot/src/server/server.ts | 2 +- bot/src/slashCommands/commands/faq.ts | 32 +- .../_game/_playtype/rivals/RivalsMainPage.tsx | 2 +- .../_game/_playtype/targets/TargetsPage.tsx | 6 +- client/src/components/user/UGPTHeader.tsx | 2 +- client/src/types/seeds.ts | 14 +- client/src/util/seeds.ts | 20 +- common/src/lib/schemas.ts | 33 +- common/src/seeds/types.ts | 12 +- common/src/types.ts | 50 +-- common/src/webhooks.ts | 16 +- .../{milestone-sets.json => questlines.json} | 0 .../{milestones.json => quests.json} | 0 docs/docs/api/routes/gpt-targets.md | 98 +++--- docs/docs/api/routes/import.md | 2 +- docs/docs/api/routes/ugpt-targets.md | 54 +-- docs/docs/api/terminology.md | 4 +- docs/docs/api/webhooks/main.md | 2 +- docs/docs/schemas/goal-sub.md | 2 +- .../{goals-milestones.md => goals-quests.md} | 40 +-- docs/docs/tachi-server/import/main.md | 6 +- docs/docs/tachi-server/import/milestones.md | 20 -- docs/docs/tachi-server/import/quests.md | 20 ++ docs/docs/wiki/features.md | 34 +- docs/mkdocs.yml | 4 +- server/src/external/mongo/db.ts | 12 +- server/src/external/mongo/indexes.ts | 16 +- .../lib/notifications/notifications.test.ts | 20 +- .../framework/milestones/milestones.ts | 184 ---------- .../quests.test.ts} | 108 +++--- .../score-import/framework/quests/quests.ts | 175 ++++++++++ .../score-importing/score-import-main.ts | 28 +- .../lib/score-mutation/delete-scores.test.ts | 2 +- .../lib/score-mutation/update-score.test.ts | 2 +- server/src/lib/targets/goals.ts | 81 ++--- server/src/lib/targets/milestones.ts | 323 ------------------ server/src/lib/targets/quests.ts | 318 +++++++++++++++++ server/src/scripts/sync-database.ts | 31 +- .../_playtype/targets/goals/router.test.ts | 26 +- .../_game/_playtype/targets/goals/router.ts | 6 +- .../targets/milestone-sets/router.test.ts | 104 ------ .../targets/milestone-sets/router.ts | 84 ----- .../targets/milestones/router.test.ts | 271 --------------- .../_playtype/targets/milestones/router.ts | 171 ---------- .../targets/questlines/router.test.ts | 99 ++++++ .../_playtype/targets/questlines/router.ts | 84 +++++ .../_playtype/targets/quests/router.test.ts | 263 ++++++++++++++ .../_game/_playtype/targets/quests/router.ts | 166 +++++++++ .../games/_game/_playtype/targets/router.ts | 32 +- .../_playtype/targets/goals/router.test.ts | 46 +-- .../_game/_playtype/targets/goals/router.ts | 18 +- .../_playtype/targets/milestones/router.ts | 231 ------------- .../{milestones => quests}/router.test.ts | 126 +++---- .../_game/_playtype/targets/quests/router.ts | 231 +++++++++++++ .../_game/_playtype/targets/router.test.ts | 60 ++-- .../games/_game/_playtype/targets/router.ts | 28 +- .../api/v1/users/_userID/router.test.ts | 2 +- .../{milestone-sets.json => quest-subs.json} | 0 .../{milestone-subs.json => questlines.json} | 0 .../mock-db/{milestones.json => quests.json} | 0 server/src/test-utils/setup.ts | 7 +- server/src/test-utils/test-data.ts | 28 +- .../src/utils/calculations/recalc-scores.ts | 4 +- server/src/utils/db.ts | 102 +++--- server/src/utils/types.ts | 12 +- 66 files changed, 1978 insertions(+), 2004 deletions(-) rename database-seeds/collections/{milestone-sets.json => questlines.json} (100%) rename database-seeds/collections/{milestones.json => quests.json} (100%) rename docs/docs/tachi-server/implementation-details/{goals-milestones.md => goals-quests.md} (65%) delete mode 100644 docs/docs/tachi-server/import/milestones.md create mode 100644 docs/docs/tachi-server/import/quests.md delete mode 100644 server/src/lib/score-import/framework/milestones/milestones.ts rename server/src/lib/score-import/framework/{milestones/milestones.test.ts => quests/quests.test.ts} (64%) create mode 100644 server/src/lib/score-import/framework/quests/quests.ts delete mode 100644 server/src/lib/targets/milestones.ts create mode 100644 server/src/lib/targets/quests.ts delete mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts delete mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts delete mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts delete mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts create mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts create mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts create mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.test.ts create mode 100644 server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts delete mode 100644 server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts rename server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/{milestones => quests}/router.test.ts (51%) create mode 100644 server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts rename server/src/test-utils/mock-db/{milestone-sets.json => quest-subs.json} (100%) rename server/src/test-utils/mock-db/{milestone-subs.json => questlines.json} (100%) rename server/src/test-utils/mock-db/{milestones.json => quests.json} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fd71043..5030c5f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,8 @@ not generally be shown to end users. - [FEAT] Add an endpoint for reverting imports. - [FEAT] Rivals have been added. - [FEAT] Goals have been added. -- [FEAT] Milestones have been added. -- [FEAT] Milestone Sets have been added. +- [FEAT] Quests have been added. +- [FEAT] Questlines have been added. - [FEAT] ITG Stamina Support has been added. - [FEAT] Batch Manual imports can now pass `classes`, to hand-declare certain classes, such as IIDX Dans, WACCA Stage Ups, or similar. - [FEAT] Added support for an IIDX DP tierlist. @@ -57,7 +57,7 @@ not generally be shown to end users. - [ENH] Showcase Stats now have the option to limit returned charts to just those you've played. - [ENH] WACCA Internal decimal levels are now displayed on the song page. - [ENH] Force Static Import now disables itself after doing one static import. This is a safety feature to prevent you from spamming your profile with pb imports. -- [ENH] Milestones will automatically update and resync off of database-seed changes. +- [ENH] Quests will automatically update and resync off of database-seed changes. - [ENH] The beatoraja IR now says how many people have played a chart on its way to de-orphaning. - [ENH] Server administrators can now delete other's scores. - [ENH] Server administrators can now revert other's imports. diff --git a/bot/src/server/server.ts b/bot/src/server/server.ts index 0ebbc25e4..b5a790df1 100644 --- a/bot/src/server/server.ts +++ b/bot/src/server/server.ts @@ -150,7 +150,7 @@ app.post("/webhook", ValidateWebhookRequest, async (req, res) => { break; } - case "milestone-achieved/v1": + case "quest-achieved/v1": default: { // get around to updating in time. // to define new webhooks, and the bot might not diff --git a/bot/src/slashCommands/commands/faq.ts b/bot/src/slashCommands/commands/faq.ts index 9a23ffb07..a3d5c2bbc 100644 --- a/bot/src/slashCommands/commands/faq.ts +++ b/bot/src/slashCommands/commands/faq.ts @@ -42,13 +42,13 @@ When creating a goal, you can control the following things: - Get 9,234,567 or better on any chart. \`\`\` `, - whatare_milestones: `Milestones are structured groups of goals. Players can subscribe to milestones, and that will automatically subscribe them to all of the goals inside it. + whatare_quests: `Quests are structured groups of goals. Players can subscribe to quests, and that will automatically subscribe them to all of the goals inside it. -**Milestones are very similar in functionality to things like bingo cards.** You group a bunch of goals that share a common incentive (Breaking into level 12s, maybe?), and slowly check them off. +**Quests are very similar in functionality to things like bingo cards.** You group a bunch of goals that share a common incentive (Breaking into level 12s, maybe?), and slowly check them off. -Milestones can group their goals into subsections -- this doesn't affect the milestone, but can visually break up different types of goals, such as timing goals vs. clearing goals. Milestone creators can also add notes to goals, to express why a goal is in the milestone. +Quests can group their goals into subsections -- this doesn't affect the quest, but can visually break up different types of goals, such as timing goals vs. clearing goals. Quest creators can also add notes to goals, to express why a goal is in the quest. -**Example Milestone** +**Example Quest** \`\`\` Name: Breaking Into Level 17s (SDVX) Description: These goals are for players aiming to break the 17 wall in SDVX. They focus on all skill sets you should be aiming for around this level. @@ -67,14 +67,14 @@ AAA 3 Level 16s (NOTE: Just a generic accuracy test) AA 10 Level 16s \`\`\` -[Here's an example milestone screenshot from Tachi v1, if the above explanation isn't clear enough!](https://cdn.discordapp.com/attachments/795824903906394142/965662379654393976/unknown.png) +[Here's an example Quest screenshot from Tachi v1, if the above explanation isn't clear enough!](https://cdn.discordapp.com/attachments/795824903906394142/965662379654393976/unknown.png) `, - whatare_sets: `Milestones themselves can be grouped up into ordered sets. This allows users to make a 'scale' of milestones that go up in difficulty. + whatare_questlines: `Quests themselves can be grouped up into ordered quest-lines. This allows users to make a 'scale' of quests that go up in difficulty. -For a real world example, [LIFE4](https://life4ddr.com/rank-requirements/) would be considered a milestone set: +For a real world example, [LIFE4](https://life4ddr.com/rank-requirements/) would be considered a questline: \`\`\` -Milestone Set: LIFE4 +Questline: LIFE4 Copper I Copper II @@ -89,22 +89,22 @@ Bronze V Silver I Silver II Silver III -... (LIFE4 has like 50 separate milestones, so I can't fit them all here, but you get the idea) +... (LIFE4 has like 50 separate quests, so I can't fit them all here, but you get the idea) \`\`\` `, - creating_milestones: `At the moment, there is no UI for users to create milestones. In the future, there will be a way to do this! + creating_quests: `At the moment, there is no UI for users to create quests. In the future, there will be a way to do this! -For now, all milestones are built-in to Tachi. Ideally, we want the built-in Tachi milestones to be interesting for players -- they should be effective targets for an improving player!`, - good_milestones: `A good milestone should never make the player feel like they're wasting their time. +For now, all quests are built-in to Tachi. Ideally, we want the built-in Tachi quests to be interesting for players -- they should be effective targets for an improving player!`, + good_quests: `A good quest should never make the player feel like they're wasting their time. You shouldn't make goals like playing everything in a large folder, since those are more tests of endurance than skill. They're just going to be wasting their time! -You should also avoid redundant goals -- all the goals in the milestone should ideally be around the same difficulty! Something like Clear 10 18s and Clear 25 18s are obviously redundant, but this also applies to goals like AAA Easy_Chart and Way_harder_chart. Every goal and part of a goal in a milestone should be something a player can reasonably have last to achieve.`, - builtin_milestone_set: `I want every game on Tachi to have atleast one fairly comprehensive milestone set (see \`/faq whatare_sets\`). These should be useful for players of all skill levels, so that they have some good ideas for goals to set around their level. +You should also avoid redundant goals -- all the goals in the quest should ideally be around the same difficulty! Something like Clear 10 18s and Clear 25 18s are obviously redundant, but this also applies to goals like AAA Easy_Chart and Way_harder_chart. Every goal and part of a goal in a quest should be something a player can reasonably have last to achieve.`, + builtin_questline: `I want every game on Tachi to have atleast one fairly comprehensive questline (see \`/faq whatare_questlines\`). These should be useful for players of all skill levels, so that they have some good ideas for goals to set around their level. -I'm ideally looking for something that the average player can just set and have fun checking off. The set itself should have the milestones fairly distinguished, players shouldn't be pushing multiple milestones in the same set at the same time. +I'm ideally looking for something that the average player can just set and have fun checking off. The questline itself should have the quests fairly distinguished, players shouldn't be pushing multiple quests in the same questline at the same time. -Since I don't play a lot of the the games that ${ServerConfig.name} supports, we need your help to come up with some good drafts! Your game might have a dedicated \`-milestones\` channel. Check it out and discuss with others!`, +Since I don't play a lot of the the games that ${ServerConfig.name} supports, we need your help to come up with some good drafts! Your game might have a dedicated \`-quests\` channel. Check it out and discuss with others!`, }; // Server specific FAQ stuff. diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx index 43fd7d9a3..6417ed2bc 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx @@ -42,7 +42,7 @@ export default function RivalsMainPage({
- Goals & Milestones + Goals & Quests diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx index c7f4b3ded..0c1f82864 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx @@ -35,9 +35,9 @@ export default function TargetsPage({ reqUser, game, playtype }: Props) { Overview - + - Milestones + Quests
@@ -52,7 +52,7 @@ export default function TargetsPage({ reqUser, game, playtype }: Props) { nal diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index f17e8d29d..00f961f40 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -104,7 +104,7 @@ export function UGPTBottomNav({ Rivals , - Goals & Milestones + Goals & Quests , ]; diff --git a/client/src/types/seeds.ts b/client/src/types/seeds.ts index db34bff57..dc8c70a05 100644 --- a/client/src/types/seeds.ts +++ b/client/src/types/seeds.ts @@ -12,8 +12,8 @@ import { GoalDocumentSingle, IDStrings, IDStringToGame, - MilestoneDocument, - MilestoneSetDocument, + QuestDocument, + QuestlineDocument, SongDocument, TableDocument, } from "tachi-common"; @@ -64,13 +64,13 @@ export type GoalWithRelated = }) | GoalDocumentAny; -export type MilestoneSetWithRelated = MilestoneSetDocument & { +export type QuestlineWithRelated = QuestlineDocument & { __related: { - milestones: Array; + quests: Array; }; }; -export type MilestoneWithRelated = MilestoneDocument & { +export type QuestWithRelated = QuestDocument & { __related: { goals: Array; }; @@ -103,8 +103,8 @@ export type DatabaseSeedsWithRelated = { "tables.json": Array; "goals.json": Array; - "milestone-sets.json": Array; - "milestones.json": Array; + "questlines.json": Array; + "quests.json": Array; } & SongSeedsWithRelated & ChartSeedsWithRelated; diff --git a/client/src/util/seeds.ts b/client/src/util/seeds.ts index 6e60bdb89..f3ff09064 100644 --- a/client/src/util/seeds.ts +++ b/client/src/util/seeds.ts @@ -10,8 +10,8 @@ import { FolderDocument, Game, GoalDocument, - MilestoneDocument, - MilestoneSetDocument, + QuestDocument, + QuestlineDocument, SongDocument, TableDocument, } from "tachi-common"; @@ -178,8 +178,8 @@ export function MakeDataset( return data["folders.json"] ?? []; case "goals.json": - case "milestone-sets.json": - case "milestones.json": + case "questlines.json": + case "quests.json": throw new Error("GOALS NOT SUPPORTED ZZZZ"); case "tables.json": return RelateTables(data); @@ -490,13 +490,13 @@ function GetUniqID(collection: K, value: AllDa const v = value as GoalDocument; return v.goalID; } - case "milestones.json": { - const v = value as MilestoneDocument; - return v.milestoneID; + case "quests.json": { + const v = value as QuestDocument; + return v.questID; } - case "milestone-sets.json": { - const v = value as MilestoneSetDocument; - return v.setID; + case "questlines.json": { + const v = value as QuestlineDocument; + return v.questlineID; } } } diff --git a/common/src/lib/schemas.ts b/common/src/lib/schemas.ts index 3b681d8a8..8253b1692 100644 --- a/common/src/lib/schemas.ts +++ b/common/src/lib/schemas.ts @@ -583,21 +583,21 @@ const PRE_SCHEMAS = { inactive: "boolean", }), ...GAME_SCHEMAS, - "milestone-sets": prSchemaFnWrap({ - setID: "string", + questlines: prSchemaFnWrap({ + questlineID: "string", game: p.isIn(games), playtype: isValidPlaytype, - milestones: ["string"], + quests: ["string"], }), - milestones: prSchemaFnWrap({ + quests: prSchemaFnWrap({ game: p.isIn(games), playtype: isValidPlaytype, name: "string", desc: "string", - milestoneID: "string", + questID: "string", group: "?string", groupINdex: p.nullable(p.isInteger), - milestoneData: [ + questData: [ { title: "string", desc: "string", @@ -628,8 +628,8 @@ const PRE_SCHEMAS = { outOf: "number", outOfHuman: "string", }), - "milestone-subs": prSchemaFnWrap({ - milestoneID: "string", + "quest-subs": prSchemaFnWrap({ + questID: "string", userID: p.isPositiveNonZeroInteger, game: p.isIn(games), playtype: isValidPlaytype, @@ -876,9 +876,9 @@ const PRE_SCHEMAS = { new: PR_GOAL_INFO, }, ], - milestoneInfo: [ + questInfo: [ { - milestoneID: "string", + questID: "string", old: { progress: p.isInteger, achieved: "boolean" }, new: { progress: p.isInteger, achieved: "boolean" }, }, @@ -1089,9 +1089,10 @@ const PRE_SCHEMAS = { ["import", "importParse", "session", "pb"].map((k) => [k, p.isPositive]) ), abs: Object.fromEntries( - ["parse", "import", "importParse", "session", "pb", "ugs", "goal", "milestone"].map( - (k) => [k, p.isPositive] - ) + ["parse", "import", "importParse", "session", "pb", "ugs", "goal", "quest"].map((k) => [ + k, + p.isPositive, + ]) ), }), "arc-saved-profiles": prSchemaFnWrap({ @@ -1135,7 +1136,7 @@ const PRE_SCHEMAS = { sentAt: p.isPositive, read: "boolean", body: { - type: p.isIn("RIVALED_BY", "MILESTONE_CHANGED"), + type: p.isIn("RIVALED_BY", "QUEST_CHANGED"), content: (self, parent) => { const type = parent.type as NotificationBody["type"]; @@ -1151,9 +1152,9 @@ const PRE_SCHEMAS = { break; } - case "MILESTONE_CHANGED": { + case "QUEST_CHANGED": { subSchema = { - milestoneID: "string", + questID: "string", }; break; } diff --git a/common/src/seeds/types.ts b/common/src/seeds/types.ts index d8982d069..825d61cd9 100644 --- a/common/src/seeds/types.ts +++ b/common/src/seeds/types.ts @@ -6,8 +6,8 @@ import type { BMSCourseDocument, FolderDocument, GoalDocument, - MilestoneSetDocument, - MilestoneDocument, + QuestlineDocument, + QuestDocument, TableDocument, } from "../types"; @@ -26,8 +26,8 @@ interface OtherDBSeeds { "bms-course-lookup.json": Array; "folders.json": Array; "goals.json": Array; - "milestone-sets.json": Array; - "milestones.json": Array; + "questlines.json": Array; + "quests.json": Array; "tables.json": Array; } @@ -52,8 +52,8 @@ const CURRENT_DATABASE_SEEDS: Record = { "charts-wacca.json": true, "folders.json": true, "goals.json": true, - "milestone-sets.json": true, - "milestones.json": true, + "questlines.json": true, + "quests.json": true, "songs-bms.json": true, "songs-chunithm.json": true, "songs-ddr.json": true, diff --git a/common/src/types.ts b/common/src/types.ts index 4910736c8..881d4546e 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -537,7 +537,7 @@ export interface ImportDocument extends MongoDBDocument { importType: ImportTypes; classDeltas: Array; goalInfo: Array; - milestoneInfo: Array; + questInfo: Array; /** * Whether the user deliberately imported this through an action (i.e. uploaded a file personally) [true] @@ -555,7 +555,7 @@ export interface ImportTimingsDocument { * Relative times - these are the times for each section * divided by how much data they had to process. */ - rel: Omit; + rel: Omit; /** * Absolute times - these are the times for each section. @@ -571,7 +571,7 @@ interface ImportTimingSections { pb: number; ugs: number; goal: number; - milestone: number; + quest: number; } export type GoalImportStat = Pick< @@ -585,12 +585,12 @@ export interface GoalImportInfo { new: GoalImportStat; } -export type MilestoneImportStat = Pick; +export type QuestImportStat = Pick; -export interface MilestoneImportInfo { - milestoneID: string; - old: MilestoneImportStat; - new: MilestoneImportStat; +export interface QuestImportInfo { + questID: string; + old: QuestImportStat; + new: QuestImportStat; } export type GoalSubscriptionDocument = MongoDBDocument & { @@ -616,49 +616,49 @@ export type GoalSubscriptionDocument = MongoDBDocument & { } ); -interface MilestoneGoalReference { +interface QuestGoalReference { goalID: string; note?: string; } -interface MilestoneSection { +interface QuestSection { title: string; desc: string; - goals: Array; + goals: Array; } -export interface MilestoneDocument extends MongoDBDocument { +export interface QuestDocument extends MongoDBDocument { game: Game; playtype: Playtype; /** - * all: All goals must be achieved in order for the milestone to be complete + * all: All goals must be achieved in order for the quest to be complete * abs: Goals achieved must be greater than or equal to criteria.value. * proportion: Goals achieved must be greater than or equal to criteria.value * total_goals. */ - criteria: MilestoneAbsPropCriteria | MilestoneAllCriteria; + criteria: QuestAbsPropCriteria | QuestAllCriteria; name: string; desc: string; - milestoneData: Array; - milestoneID: string; + questData: Array; + questID: string; } -interface MilestoneAllCriteria { +interface QuestAllCriteria { type: "all"; } -interface MilestoneAbsPropCriteria { +interface QuestAbsPropCriteria { type: "total"; value: number; } -export interface MilestoneSetDocument extends MongoDBDocument { - setID: string; +export interface QuestlineDocument extends MongoDBDocument { + questlineID: string; name: string; desc: string; game: Game; playtype: Playtype; - milestones: Array; + quests: Array; } export type UserBadges = "alpha" | "beta" | "contributor" | "dev-team" | "significant-contributor"; @@ -1001,8 +1001,8 @@ export interface FolderChartLookup extends MongoDBDocument { folderID: string; } -export type MilestoneSubscriptionDocument = MongoDBDocument & { - milestoneID: string; +export type QuestSubscriptionDocument = MongoDBDocument & { + questID: string; userID: integer; game: Game; playtype: Playtype; @@ -1602,9 +1602,9 @@ export type NotificationBody = }; } | { - type: "MILESTONE_CHANGED"; // Emitted when a milestone the user is subscribed to changed. + type: "QUEST_CHANGED"; // Emitted when a quest the user is subscribed to changed. content: { - milestoneID: string; + questID: string; }; } | { diff --git a/common/src/webhooks.ts b/common/src/webhooks.ts index 0ec6b58a0..256c41f85 100644 --- a/common/src/webhooks.ts +++ b/common/src/webhooks.ts @@ -1,6 +1,6 @@ import type { Game, Playtypes } from "."; import type { GameClassSets } from "./game-classes"; -import type { GoalImportStat, IDStrings, integer, MilestoneImportStat } from "./types"; +import type { GoalImportStat, IDStrings, integer, QuestImportStat } from "./types"; /** * An event fired when a users class improves. @@ -35,15 +35,15 @@ export interface WebhookEventGoalAchievedV1 { } /** - * An event fired when a milestone is achieved. + * An event fired when a quest is achieved. */ -export interface WebhookEventMilestoneAchievedV1 { - type: "milestone-achieved/v1"; +export interface WebhookEventQuestAchievedV1 { + type: "quest-achieved/v1"; content: { userID: integer; - milestoneID: string; - old: MilestoneImportStat; - new: MilestoneImportStat; + questID: string; + old: QuestImportStat; + new: QuestImportStat; game: Game; playtype: Playtypes[Game]; }; @@ -65,4 +65,4 @@ export interface WebhookEventStatusV1 { export type WebhookEvents = | WebhookEventClassUpdateV1 | WebhookEventGoalAchievedV1 - | WebhookEventMilestoneAchievedV1; + | WebhookEventQuestAchievedV1; diff --git a/database-seeds/collections/milestone-sets.json b/database-seeds/collections/questlines.json similarity index 100% rename from database-seeds/collections/milestone-sets.json rename to database-seeds/collections/questlines.json diff --git a/database-seeds/collections/milestones.json b/database-seeds/collections/quests.json similarity index 100% rename from database-seeds/collections/milestones.json rename to database-seeds/collections/quests.json diff --git a/docs/docs/api/routes/gpt-targets.md b/docs/docs/api/routes/gpt-targets.md index 4a7a22422..70270de5d 100644 --- a/docs/docs/api/routes/gpt-targets.md +++ b/docs/docs/api/routes/gpt-targets.md @@ -1,6 +1,6 @@ # GPT-Target Endpoints -These endpoints deal with [targets](../../api/terminology.md) for a Game + Playtype. These are things like searching goals or milestones, or retrieving information about a specific ID. +These endpoints deal with [targets](../../api/terminology.md) for a Game + Playtype. These are things like searching goals or quests, or retrieving information about a specific ID. For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endpoints](./ugpt-targets.md). @@ -11,9 +11,9 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp `GET /api/v1/games/:game/:playtype/targets/recently-achieved` !!! info - This endpoint returns the 100 most recently achieved goal subscriptions, and 50 most recently achieved milestone subscriptions. + This endpoint returns the 100 most recently achieved goal subscriptions, and 50 most recently achieved quest subscriptions. - A target is not considered recently achieved if it was [instantly achieved](../../tachi-server/implementation-details/goals-milestones.md#instant-indirect-achievements). + A target is not considered recently achieved if it was [instantly achieved](../../tachi-server/implementation-details/goals-quests.md#instant-indirect-achievements). ### Parameters @@ -24,9 +24,9 @@ None. | Property | Type | Description | | :: | :: | :: | | `goals` | Array<GoalDocument> | The goal documents that were recently achieved. | -| `milestones` | Array<MilestoneDocument> | The milestone documents that were recently achieved. | +| `quests` | Array<QuestDocument> | The quest documents that were recently achieved. | | `goalSubs` | Array<GoalSubDocument> | User subscriptions to goals that were recently achieved. | -| `milestoneSubs` | Array<MilestoneSubDocument> | User subscriptions to milestones that were recently achieved. | +| `questSubs` | Array<QuestSubDocument> | User subscriptions to quests that were recently achieved. | ### Example @@ -44,10 +44,10 @@ GET /api/v1/games/iidx/SP/targets/recently-achieved goalID: "foo" // ... other goal props }], - milestones: [{ + quests: [{ name: "Go Beyond Diamond 1", - milestoneID: "bar", - // ... other milestone props + questID: "bar", + // ... other quest props }], goalSubs: [{ userID: 1, @@ -55,11 +55,11 @@ GET /api/v1/games/iidx/SP/targets/recently-achieved achieved: true, // ... other goalsub props }], - milestoneSubs: [{ + questSubs: [{ userID: 3, - milestoneID: "bar", + questID: "bar", achieved: true, - // ... other milestone sub props + // ... other quest sub props }] } ``` @@ -71,7 +71,7 @@ GET /api/v1/games/iidx/SP/targets/recently-achieved `GET /api/v1/games/:game/:playtype/targets/recently-raised` !!! info - This endpoint returns the 100 most recently interacted-with goal subscriptions, and 50 most recently interacted-with milestone subscriptions. + This endpoint returns the 100 most recently interacted-with goal subscriptions, and 50 most recently interacted-with quest subscriptions. A recently interacted with target subscription is one where `progress` or `outOf` has changed recently. @@ -87,9 +87,9 @@ None. | Property | Type | Description | | :: | :: | :: | | `goals` | Array<GoalDocument> | The goal documents that were recently achieved. | -| `milestones` | Array<MilestoneDocument> | The milestone documents that were recently achieved. | +| `quests` | Array<QuestDocument> | The quest documents that were recently achieved. | | `goalSubs` | Array<GoalSubDocument> | User subscriptions to goals that were recently interacted with. | -| `milestoneSubs` | Array<MilestoneSubDocument> | User subscriptions to milestones that were recently interacted with. | +| `questSubs` | Array<QuestSubDocument> | User subscriptions to quests that were recently interacted with. | ### Example @@ -107,10 +107,10 @@ GET /api/v1/games/iidx/SP/targets/recently-raised goalID: "foo" // ... other goal props }], - milestones: [{ + quests: [{ name: "Go Beyond Diamond 1", - milestoneID: "bar", - // ... other milestone props + questID: "bar", + // ... other quest props }], goalSubs: [{ userID: 1, @@ -119,12 +119,12 @@ GET /api/v1/games/iidx/SP/targets/recently-raised lastInteraction: 1649438990417, // ... other goalsub props }], - milestoneSubs: [{ + questSubs: [{ userID: 3, - milestoneID: "bar", + questID: "bar", achieved: false, lastInteraction: 1649438990415, - // ... other milestone sub props + // ... other quest sub props }] } ``` @@ -181,7 +181,7 @@ None. | `goal` | GoalDocument | The goal document at this ID. | | `goalSubs` | Array<GoalSubDocument> | All of the subscriptions to this goal. | | `users` | Array<UserDocument> | All of the users subscribed to this goal. | -| `parentMilestones` | Array<MilestoneDocument> | All of the milestones that include this goal. | +| `parentQuests` | Array<QuestDocument> | All of the quests that include this goal. | ***** @@ -215,7 +215,7 @@ None. | `results.outOfHuman` | String | A user friendly format for what this goal was out of. | !!! info - For more info on `progress`/`outOf`, see [Goals](../../tachi-server/implementation-details/goals-milestones.md#evaluating-a-users-progress). + For more info on `progress`/`outOf`, see [Goals](../../tachi-server/implementation-details/goals-quests.md#evaluating-a-users-progress). ### Example @@ -250,14 +250,14 @@ GET /api/v1/games/iidx/SP/targets/goals/some_goal_id/evaluate-for?userID=zkldi ***** -## Search milestones for this GPT. +## Search quests for this GPT. -`GET /api/v1/games/:game/:playtype/targets/milestones` +`GET /api/v1/games/:game/:playtype/targets/quests` !!! note You might notice that there's no equivalent endpoint for goals. - Searching goals for a GPT isn't very interesting, since they can be created by anyone at any time. The only reason goals are stored separately to subscriptions are for deduplication purposes and milestones. + Searching goals for a GPT isn't very interesting, since they can be created by anyone at any time. The only reason goals are stored separately to subscriptions are for deduplication purposes and quests. As such, searching goals for a GPT is pointless, since technically it should search the set of all possible goals. @@ -271,13 +271,13 @@ GET /api/v1/games/iidx/SP/targets/goals/some_goal_id/evaluate-for?userID=zkldi | Property | Type | Description | | :: | :: | :: | -| `` | Array<MilestoneDocument> | All of the milestones that matched this search criteria. | +| `` | Array<QuestDocument> | All of the quests that matched this search criteria. | ***** -## Retrieve information about a specific milestone, and who is subscribed to it. +## Retrieve information about a specific quest, and who is subscribed to it. -`GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID` +`GET /api/v1/games/:game/:playtype/targets/quests/:questID` ### Parameters @@ -287,33 +287,33 @@ N/A | Property | Type | Description | | :: | :: | :: | -| `milestone` | MilestoneDocument | The milestone with this milestoneID. | -| `milestoneSubs` | Array<MilestoneSubDocument> | All of the subscriptions to this milestone. | -| `users` | Array<UserDocument> | All of the user's with subscriptions to this milestone. | -| `goals` | Array<GoalDocument> | All of the goals in this milestone. | -| `parentMilestoneSets` | Array<MilestoneSetDocument> | Any milestone sets that contain this milestone. | +| `quest` | QuestDocument | The quest with this questID. | +| `questSubs` | Array<QuestSubDocument> | All of the subscriptions to this quest. | +| `users` | Array<UserDocument> | All of the user's with subscriptions to this quest. | +| `goals` | Array<GoalDocument> | All of the goals in this quest. | +| `parentQuestlines` | Array<QuestlineDocument> | Any questlines that contain this quest. | ***** -## Evaluate a milestone for a user, even if they aren't subscribed to it. +## Evaluate a quest for a user, even if they aren't subscribed to it. -`GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID/evaluate-for` +`GET /api/v1/games/:game/:playtype/targets/quests/:questID/evaluate-for` ### Parameters | Property | Type | Description | | :: | :: | :: | -| `userID` | String | The user you wish to evaluate this milestone upon. | +| `userID` | String | The user you wish to evaluate this quest upon. | ### Response | Property | Type | Description | | :: | :: | :: | -| `goals` | Array<GoalDocument> | All of the goals in this milestone. | -| `goalResults` | Array<EvaluatedGoalResult> | This user's progress on each individual goal in this milestone. | -| `achieved` | Boolean | Whether this user has this milestone achieved or not. | -| `progress` | Integer | How many goals this user has achieved in this milestone. | -| `outOf` | Integer | How many goals need to be achieved in this milestone for it to be marked as achieved. | +| `goals` | Array<GoalDocument> | All of the goals in this quest. | +| `goalResults` | Array<EvaluatedGoalResult> | This user's progress on each individual goal in this quest. | +| `achieved` | Boolean | Whether this user has this quest achieved or not. | +| `progress` | Integer | How many goals this user has achieved in this quest. | +| `outOf` | Integer | How many goals need to be achieved in this quest for it to be marked as achieved. | #### EvaluatedGoalResult @@ -328,27 +328,27 @@ N/A ***** -## Search Milestone Sets +## Search Questlines -`GET /api/v1/games/:game/:playtype/targets/milestone-sets` +`GET /api/v1/games/:game/:playtype/targets/questlines` ### Parameters | Property | Type | Description | | :: | :: | :: | -| `search` | String | A name of a milestone set to search for. | +| `search` | String | A name of a questline to search for. | ### Response | Property | Type | Description | | :: | :: | :: | -| `` | Array<MilestoneSetDocument> | An array of MilestoneSetDocuments, based on the search parameter. | +| `` | Array<QuestlineDocument> | An array of QuestlineDocuments, based on the search parameter. | ***** -## Retrieve a milestone set with a specific ID. +## Retrieve a questline with a specific ID. -`GET /api/v1/games/:game/:playtype/targets/milestone-sets/:setID` +`GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID` ### Parameters @@ -358,6 +358,6 @@ N/A | Property | Type | Description | | :: | :: | :: | -| `milestoneSet` | MilestoneSetDocument | The milestone set document at this ID. | -| `milestones` | Array<MilestoneDocument> | All of the milestone documents that belong to this set. | +| `questline` | QuestlineDocument | The questline document at this ID. | +| `quests` | Array<QuestDocument> | All of the quest documents that belong to this set. | diff --git a/docs/docs/api/routes/import.md b/docs/docs/api/routes/import.md index 326cf0a35..e519fceaa 100644 --- a/docs/docs/api/routes/import.md +++ b/docs/docs/api/routes/import.md @@ -93,7 +93,7 @@ scoreData= "userID": 1, "classDeltas": [], "goalInfo": [], - "milestoneInfo": [], + "questInfo": [], "userIntent": false, // if X-User-Intent was set, this would be true. } ``` diff --git a/docs/docs/api/routes/ugpt-targets.md b/docs/docs/api/routes/ugpt-targets.md index 96194f7e7..3d6cb911a 100644 --- a/docs/docs/api/routes/ugpt-targets.md +++ b/docs/docs/api/routes/ugpt-targets.md @@ -1,6 +1,6 @@ # UGPT-Target Endpoints -These endpoints deal with [targets](../../api/terminology.md) for a User, Game and Playtype. These are things like subscribing to a new milestone, or reading info about progress on a specific goal. +These endpoints deal with [targets](../../api/terminology.md) for a User, Game and Playtype. These are things like subscribing to a new quest, or reading info about progress on a specific goal. For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endpoints](./ugpt-targets.md). @@ -22,8 +22,8 @@ None. | :: | :: | :: | | `goalSubs` | Array<GoalSubDocument> | The goal subscriptions that were recently achieved by this user. | | `goals` | Array<GoalDocument> | The goal documents that were recently achieved (if any). | -| `milestoneSubs` | Array<MilestoneSubDocument> | The milestone subscriptions that were recently achieved by this user. | -| `milestone` | Array<MilestoneDocument> | The milestone documents that were recently achieved (if any). | +| `questSubs` | Array<QuestSubDocument> | The quest subscriptions that were recently achieved by this user. | +| `quest` | Array<QuestDocument> | The quest documents that were recently achieved (if any). | | `user` | UserDocument | The user you requested this information about. | ***** @@ -45,15 +45,15 @@ None. | :: | :: | :: | | `goalSubs` | Array<GoalSubDocument> | The goal subscriptions that were recently achieved by this user. | | `goals` | Array<GoalDocument> | The goal documents that were recently achieved (if any). | -| `milestoneSubs` | Array<MilestoneSubDocument> | The milestone subscriptions that were recently achieved by this user. | -| `milestone` | Array<MilestoneDocument> | The milestone documents that were recently achieved (if any). | +| `questSubs` | Array<QuestSubDocument> | The quest subscriptions that were recently achieved by this user. | +| `quest` | Array<QuestDocument> | The quest documents that were recently achieved (if any). | | `user` | UserDocument | The user you requested this information about. | ***** -## Retrieve this user's milestone subscriptions. +## Retrieve this user's quest subscriptions. -`GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones` +`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests` ### Parameters @@ -63,20 +63,20 @@ None. | Property | Type | Description | | :: | :: | :: | -| `milestones` | Array<MilestoneDocument> | All of the milestones this user is subscribed to. | -| `milestoneSubs` | Array<MilestoneSubDocument> | All of this user's milestone subscriptions. | +| `quests` | Array<QuestDocument> | All of the quests this user is subscribed to. | +| `questSubs` | Array<QuestSubDocument> | All of this user's quest subscriptions. | ***** -## Get a user's progress on a specific milestone they are subscribed to. +## Get a user's progress on a specific quest they are subscribed to. -`GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID` +`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID` !!! info - If you're looking to evaluate a milestone against a user, without knowing whether they're - subscribed to it or not, you want [GPT/Targets/Milestones/Evaluate-For](gpt-targets.md#evaluate-a-milestone-for-a-user-even-if-they-arent-subscribed-to-it). + If you're looking to evaluate a quest against a user, without knowing whether they're + subscribed to it or not, you want [GPT/Targets/Quests/Evaluate-For](gpt-targets.md#evaluate-a-quest-for-a-user-even-if-they-arent-subscribed-to-it). - This endpoint is admittedly in a very strange position, where it overlaps with that `evaluate-for` endpoint quite significantly, but this endpoint is also intended to return the user's `milestoneSub`, so I thought it best to split this into two endpoints. + This endpoint is admittedly in a very strange position, where it overlaps with that `evaluate-for` endpoint quite significantly, but this endpoint is also intended to return the user's `questSub`, so I thought it best to split this into two endpoints. ### Parameters @@ -86,10 +86,10 @@ None. | Property | Type | Description | | :: | :: | :: | -| `milestoneSub` | MilestoneSubDocument | The user's subscription to this milestone. | -| `milestone` | MilestoneDocument | The milestone document in question. | -| `goals` | Array<GoalDocument> | The goals involved in this milestone. | -| `results` | Array<EvaluatedGoalResult> | The user's progress on each goal in this milestone. | +| `questSub` | QuestSubDocument | The user's subscription to this quest. | +| `quest` | QuestDocument | The quest document in question. | +| `goals` | Array<GoalDocument> | The goals involved in this quest. | +| `results` | Array<EvaluatedGoalResult> | The user's progress on each goal in this quest. | #### EvaluatedGoalResult @@ -104,9 +104,9 @@ None. ***** -## Subscribe to a milestone. +## Subscribe to a quest. -`PUT /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID` +`PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID` ### Permissions @@ -121,16 +121,16 @@ None. (All relevant info is in the URL.) | Property | Type | Description | | :: | :: | :: | -| `goals` | Array<GpalDocument> | The goals involved in this milestone. | -| `milestone` | MilestoneDocument | The milestone the user just subscribed to. | -| `goalResults` | Array<EvaluatedGoalResults> | The user's progress on each individual goal in this milestone. | -| `milestoneSub` | MilestoneSubscriptionDocument | The milestone subscription this user just created | +| `goals` | Array<GpalDocument> | The goals involved in this quest. | +| `quest` | QuestDocument | The quest the user just subscribed to. | +| `goalResults` | Array<EvaluatedGoalResults> | The user's progress on each individual goal in this quest. | +| `questSub` | QuestSubscriptionDocument | The quest subscription this user just created | ***** -## Unsubscribe from a milestone. +## Unsubscribe from a quest. -`DELETE /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID` +`DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID` ### Permissions @@ -145,4 +145,4 @@ None. | Property | Type | Description | | :: | :: | :: | -| `milestone` | MilestoneDocument | The milestone you just unsubscribed from. | +| `quest` | QuestDocument | The quest you just unsubscribed from. | diff --git a/docs/docs/api/terminology.md b/docs/docs/api/terminology.md index 71bb63014..cc7a1c2ea 100644 --- a/docs/docs/api/terminology.md +++ b/docs/docs/api/terminology.md @@ -16,5 +16,5 @@ Refers to "User on Game + Playtype" - A user's "something" on a game and that pl Targets are a bucket term for things a user might set as targets to aim for. This term subsumes the following features: - Goals -- Milestones -- Sets +- Quests +- Questlines diff --git a/docs/docs/api/webhooks/main.md b/docs/docs/api/webhooks/main.md index 49495ceaf..48dbd6750 100644 --- a/docs/docs/api/webhooks/main.md +++ b/docs/docs/api/webhooks/main.md @@ -30,4 +30,4 @@ The current Events are: | :: | :: | | `class-update/v1` | Fires whenever a user has had a class update positively, such as going from 9th Dan to 10th Dan. | +| `quest-achieved/v1` | Fires whenever a user has achieved a quest. | --> diff --git a/docs/docs/schemas/goal-sub.md b/docs/docs/schemas/goal-sub.md index fd03858d7..eb1def1cc 100644 --- a/docs/docs/schemas/goal-sub.md +++ b/docs/docs/schemas/goal-sub.md @@ -50,4 +50,4 @@ type GoalSubscriptionDocument = MongoDBDocument & { | `progress` | The user's raw progress towards this goal. This is a number, and should not be displayed to the user. | | `outOf` | The value this goal is out of - this is a number, and should not be displayed to the user. | | `progressHuman`, `outOfHuman` | These are humanised, stringified versions of the above two fields. These convert things like the enum value of lamps to their string equivalents. | -| `wasInstantlyAchieved` | Whether this goal was instantly achieved or not. Instantly achieved goals are excluded from some parts of the UI, and from being emitted as webhook events. [Read more here](../tachi-server/implementation-details/goals-milestones.md). | +| `wasInstantlyAchieved` | Whether this goal was instantly achieved or not. Instantly achieved goals are excluded from some parts of the UI, and from being emitted as webhook events. [Read more here](../tachi-server/implementation-details/goals-quests.md). | diff --git a/docs/docs/tachi-server/implementation-details/goals-milestones.md b/docs/docs/tachi-server/implementation-details/goals-quests.md similarity index 65% rename from docs/docs/tachi-server/implementation-details/goals-milestones.md rename to docs/docs/tachi-server/implementation-details/goals-quests.md index 819a3b3ad..0eeb81b0a 100644 --- a/docs/docs/tachi-server/implementation-details/goals-milestones.md +++ b/docs/docs/tachi-server/implementation-details/goals-quests.md @@ -1,7 +1,7 @@ -# Goals, Milestones, Sets +# Goals, Quests, Questlines This page covers pretty much everything you need to know about interacting with goals -and milestones. +and quests. Although these features are fairly straightforward on the surface, their implementation is a bit complex at points. We'll get to it. @@ -56,54 +56,54 @@ For human representation, these values are formatted and stored in the Goal Subs Goal Subscriptions are capped by the MAX_GOAL_SUBSCRIPTIONS [conf.json5](../setup/config.md) property, and defaults to 1000. !!! danger - Unsubscribing from goals directly is only possible if they do not have any parent milestones. + Unsubscribing from goals directly is only possible if they do not have any parent quests. More on that below. -## What is a Milestone? +## What is a Quest? -Milestones are groups of goals, defined in a structured form, with support for assigning +Quests are groups of goals, defined in a structured form, with support for assigning notes next to goalIDs, and other generally useful stuff. Their purpose for users is to give them a quick way to assign a bunch of goals they might care about. For example, a dedicated user may make their own "Kaiden Checklist", and create a set of goals they think are useful for kaidens to aim for. Another user interested in this can just -subscribe to that milestone, and all the goals will be managed for them. +subscribe to that quest, and all the goals will be managed for them. ### Identification -Milestones are identified by their `milestoneID`, which is a completely arbitrary string. +Quests are identified by their `questID`, which is a completely arbitrary string. -At the moment, users cannot create milestones directly, they are hard-defined by the [Database Seeds](../infrastructure/database-seeds.md). +At the moment, users cannot create quests directly, they are hard-defined by the [Database Seeds](../infrastructure/database-seeds.md). ### Evaluating a User's Progress -Similarly to goals, user's can subscribe to milestones. +Similarly to goals, user's can subscribe to quests. -Milestone subscriptions are interesting in that the `progress` factor is always an integer -- how many goals in the milestone the user has achieved, and the `outOf` factor is always an integer -- how many goals need to be achieved in the milestone for the milestone to be marked as achieved. +Quest subscriptions are interesting in that the `progress` factor is always an integer -- how many goals in the quest the user has achieved, and the `outOf` factor is always an integer -- how many goals need to be achieved in the quest for the quest to be marked as achieved. -Similarly, the `achieved` status on milestones is identical to `progress` being greater than or equal to `outOf`. +Similarly, the `achieved` status on quests is identical to `progress` being greater than or equal to `outOf`. ### Parenting Goal Subscriptions -When a milestone is subscribed to, all the goals in the milestone are also subscribed to. +When a quest is subscribed to, all the goals in the quest are also subscribed to. -When a user is subscribed to a milestone, they *must* also be subscribed to all the goals in -that milestone. If they aren't, they've desynced with the milestone, and that's an awful +When a user is subscribed to a quest, they *must* also be subscribed to all the goals in +that quest. If they aren't, they've desynced with the quest, and that's an awful user experience. -As such, Tachi keeps track of all the milestones that care about this goal subscription, and users are *prevented from unsubscribing from this goal* while any of their milestone subscriptions parent the goal. +As such, Tachi keeps track of all the quests that care about this goal subscription, and users are *prevented from unsubscribing from this goal* while any of their quest subscriptions parent the goal. ### Instant Indirect Achievements -Subscribing to a milestone *mandates* that all of the goals are assigned. It is not rare for this to mean that you subscribe to goals that are instantly achieved. +Subscribing to a quest *mandates* that all of the goals are assigned. It is not rare for this to mean that you subscribe to goals that are instantly achieved. This is fine. If this does happen though, `wasInstantlyAchieved` is set on the goal, and no webhook event is emitted. ### Unsubscribing -Unsubscribing from a milestone is a two-pass process. Firstly, for all `goalID`s in the milestone, the subscription document has the relevant `milestoneID` pulled from the array. +Unsubscribing from a quest is a two-pass process. Firstly, for all `goalID`s in the quest, the subscription document has the relevant `questID` pulled from the array. The second part of the process involves unsubscribing the user from any goals that now have an empty array as a result of this process. @@ -112,9 +112,9 @@ an empty array as a result of this process. If this process sounds like it has race conditions, it's because it probably does. Ah well. -## Milestone Sets +## Questlines -Milestone sets are ordered groups of milestones. Their purpose is to group milestones together +Questline are ordered lists of quests. Their purpose is to group quests together visually. -Users *can not* "subscribe" to milestone sets, but they can use sets as a utility for subscribing to all the related milestones. +Users *can not* "subscribe" to quest sets, but they can use sets as a utility for subscribing to all the related quests. diff --git a/docs/docs/tachi-server/import/main.md b/docs/docs/tachi-server/import/main.md index 961ff9d64..e17dce474 100644 --- a/docs/docs/tachi-server/import/main.md +++ b/docs/docs/tachi-server/import/main.md @@ -129,13 +129,13 @@ changed or not, and updates them accordingly. - Full Page: [Goals](./goals.md) -### Milestones +### Quests Checks the set of goals modified in the previous step, -and gets the relevant milestones. This step then re-evaluates all of those milestones and +and gets the relevant quests. This step then re-evaluates all of those quests and updates accordingly. -- Full Page: [Milestones](./milestones.md) +- Full Page: [Quests](./quests.md) ### Import Document diff --git a/docs/docs/tachi-server/import/milestones.md b/docs/docs/tachi-server/import/milestones.md deleted file mode 100644 index 29fe508e1..000000000 --- a/docs/docs/tachi-server/import/milestones.md +++ /dev/null @@ -1,20 +0,0 @@ -# Updating Milestones - -We update milestones by looking at the set of modified -goalIDs, and check the user's assigned milestones to -get those that are affected. - -***** - -## Returns - -We evaluate every milestone, and for each one, -create a bulkwrite operation to update the user's milestone -progress. - -If the user has newly achieved a milestone, a Redis Event -is emitted, which could be hooked into by our Discord Bot. - -If the progress has changed at all, the data is -pushed to an array which is then returned. This is -attached onto the Import Document. \ No newline at end of file diff --git a/docs/docs/tachi-server/import/quests.md b/docs/docs/tachi-server/import/quests.md new file mode 100644 index 000000000..1290bcada --- /dev/null +++ b/docs/docs/tachi-server/import/quests.md @@ -0,0 +1,20 @@ +# Updating Quests + +We update quests by looking at the set of modified +goalIDs, and check the user's assigned quests to +get those that are affected. + +***** + +## Returns + +We evaluate every quest, and for each one, +create a bulkwrite operation to update the user's quest +progress. + +If the user has newly achieved a quest, a Redis Event +is emitted, which could be hooked into by our Discord Bot. + +If the progress has changed at all, the data is +pushed to an array which is then returned. This is +attached onto the Import Document. \ No newline at end of file diff --git a/docs/docs/wiki/features.md b/docs/docs/wiki/features.md index 26dcf280a..4467b9831 100644 --- a/docs/docs/wiki/features.md +++ b/docs/docs/wiki/features.md @@ -89,7 +89,7 @@ That's it for sessions! As a quick summary: ## Goals !!! note - This feature goes hand-in-hand with [Milestones](#milestones). + This feature goes hand-in-hand with [Quests](#quests). Goals are a built-in way of setting targets for yourself. @@ -187,7 +187,7 @@ That's it for goals! As a quick summary: This is nice, but there's something missing... -## Milestones +## Quests As mentioned above, Goals were designed to go hand-in-hand with this feature. @@ -203,11 +203,11 @@ aiming for, or is just lazy and doesn't like setting goals? In general, we're bad at setting our own goals. How can we fix that? -### What are Milestones? +### What are Quests? -Milestones are **pre-made groups of goals**. As an example, +Quests are **pre-made groups of goals**. As an example, we might bundle together some goals aimed at SOUND VOLTEX -11 dan players. You can then subscribe to that milestone, +11 dan players. You can then subscribe to that quest, and all those goals will be merged with your list of goals! This solves the problem of having to set and manage *loads* @@ -215,29 +215,29 @@ of goals on your own - since other people can come up with and debate good goals for your skill level, and you can just seamlessly integrate them with your play! -### Who makes the Milestones? +### Who makes the Quests? The community! People are free to create their own -milestones and share them. +quests and share them. -### Why are they called Milestones? +### Why are they called Quests? -Milestones work as - well - milestones! +Quests work as - well - quests! They're achievable, just like goals, which means the -milestone creator can set criteria. +quest creator can set criteria. -A basic example would be counting the milestone as +A basic example would be counting the quest as achieved when you've achieved *all* of the goals in that -milestone. +quest. More advanced options include achieving X goals inside a -milestone, or X% of the goals inside a milestone. +quest, or X% of the goals inside a quest. ### Summary -That's all for milestones! As a quick summary: +That's all for quests! As a quick summary: -- Since milestones are achievables, you'll be automatically notified when you tick another milestone off! -- You can also see other user's milestones and their progress. -- Milestones reduce the pain of having to set your goals yourself! +- Since quests are achievables, you'll be automatically notified when you tick another quest off! +- You can also see other user's quests and their progress. +- Quests reduce the pain of having to set your goals yourself! diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8f195bf4a..41fe17737 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -132,7 +132,7 @@ nav: - "tachi-server/import/pbs.md" - "tachi-server/import/ugs.md" - "tachi-server/import/goals.md" - - "tachi-server/import/milestones.md" + - "tachi-server/import/quests.md" - "tachi-server/import/import-doc-time.md" - Implementation Details: @@ -144,7 +144,7 @@ nav: - "tachi-server/implementation-details/esd.md" - "tachi-server/implementation-details/score-id.md" - "tachi-server/implementation-details/goal-id.md" - - "tachi-server/implementation-details/goals-milestones.md" + - "tachi-server/implementation-details/goals-quests.md" - Tachi Bot Reference: - "tachi-bot" diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index af1f11eda..7aabac361 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -28,9 +28,9 @@ import type { integer, InviteCodeDocument, KaiAuthDocument, - MilestoneDocument, - MilestoneSetDocument, - MilestoneSubscriptionDocument, + QuestDocument, + QuestlineDocument, + QuestSubscriptionDocument, NotificationDocument, OrphanChart, PBScoreDocument, @@ -141,8 +141,8 @@ const db = { "folder-chart-lookup": monkDB.get("folder-chart-lookup"), goals: monkDB.get("goals"), "goal-subs": monkDB.get("goal-subs"), - milestones: monkDB.get("milestones"), - "milestone-subs": monkDB.get("milestone-subs"), + quests: monkDB.get("quests"), + "quest-subs": monkDB.get("quest-subs"), users: monkDB.get("users"), imports: monkDB.get("imports"), "import-timings": monkDB.get("import-timings"), @@ -185,7 +185,7 @@ const db = { "verify-email-codes" ), "recent-folder-views": monkDB.get("recent-folder-views"), - "milestone-sets": monkDB.get("milestone-sets"), + questlines: monkDB.get("questlines"), migrations: monkDB.get("migrations"), notifications: monkDB.get("notifications"), }; diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index ecd2df8e3..0dfb9c6f1 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -46,19 +46,19 @@ const staticIndexes: Partial>> = { "folder-chart-lookup": [index({ chartID: 1, folderID: 1 }, UNIQUE)], goals: [index({ goalID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), index({ name: "text" })], "goal-subs": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })], - milestones: [ - index({ milestoneID: 1 }, UNIQUE), + quests: [ + index({ questID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), index({ name: "text" }), ], - "milestone-subs": [ - index({ milestoneID: 1, userID: 1 }, UNIQUE), + "quest-subs": [ + index({ questID: 1, userID: 1 }, UNIQUE), index({ userID: 1, game: 1, playtype: 1 }), ], - "milestone-sets": [ - index({ setID: 1 }, UNIQUE), + questlines: [ + index({ questlineID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), - index({ milestones: 1 }), + index({ quests: 1 }), index({ name: "text" }), ], imports: [index({ importID: 1 }, UNIQUE)], @@ -180,7 +180,7 @@ export async function SetIndexesWithDB(db: IMonkManager, reset: boolean) { } for (const index of values) { - // @ts-expect-error Type-mismatch here. our index.fileds are just boring records. I know + // @ts-expect-error Type-mismatch here. our index.fields are just boring records. I know // that this sucks... const r = await db.get(collection).createIndex(index.fields, index.options); diff --git a/server/src/lib/notifications/notifications.test.ts b/server/src/lib/notifications/notifications.test.ts index eb67f2db9..27215c7f6 100644 --- a/server/src/lib/notifications/notifications.test.ts +++ b/server/src/lib/notifications/notifications.test.ts @@ -8,8 +8,8 @@ t.test("#SendNotification", (t) => { t.test("Should send a notification.", async (t) => { await SendNotification("title", 1, { - type: "MILESTONE_CHANGED", - content: { milestoneID: "foo" }, + type: "QUEST_CHANGED", + content: { questID: "foo" }, }); const dbRes = await db.notifications.findOne({ @@ -21,8 +21,8 @@ t.test("#SendNotification", (t) => { title: "title", read: false, body: { - type: "MILESTONE_CHANGED", - content: { milestoneID: "foo" }, + type: "QUEST_CHANGED", + content: { questID: "foo" }, }, }); @@ -37,8 +37,8 @@ t.test("#BulkSendNotification", (t) => { t.test("Should send multiple notifications.", async (t) => { await BulkSendNotification("title", [1, 2], { - type: "MILESTONE_CHANGED", - content: { milestoneID: "foo" }, + type: "QUEST_CHANGED", + content: { questID: "foo" }, }); const dbRes = await db.notifications.findOne({ @@ -50,8 +50,8 @@ t.test("#BulkSendNotification", (t) => { title: "title", read: false, body: { - type: "MILESTONE_CHANGED", - content: { milestoneID: "foo" }, + type: "QUEST_CHANGED", + content: { questID: "foo" }, }, }); @@ -64,8 +64,8 @@ t.test("#BulkSendNotification", (t) => { title: "title", read: false, body: { - type: "MILESTONE_CHANGED", - content: { milestoneID: "foo" }, + type: "QUEST_CHANGED", + content: { questID: "foo" }, }, }); diff --git a/server/src/lib/score-import/framework/milestones/milestones.ts b/server/src/lib/score-import/framework/milestones/milestones.ts deleted file mode 100644 index 1982f64f8..000000000 --- a/server/src/lib/score-import/framework/milestones/milestones.ts +++ /dev/null @@ -1,184 +0,0 @@ -import db from "external/mongo/db"; -import { CalculateMilestoneOutOf, GetGoalIDsFromMilestone } from "lib/targets/milestones"; -import { EmitWebhookEvent } from "lib/webhooks/webhooks"; -import type { KtLogger } from "lib/logger/logger"; -import type { BulkWriteUpdateOneOperation } from "mongodb"; -import type { - Game, - GoalImportInfo, - integer, - MilestoneDocument, - MilestoneImportInfo, - Playtype, - MilestoneSubscriptionDocument, - GoalImportStat, -} from "tachi-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 -) { - const goalIDs = GetGoalIDsFromMilestone(milestone); - - let progress = 0; - - for (const goalID of goalIDs) { - const userInfo = gii.get(goalID); - - if (!userInfo) { - continue; - } - - if (!userInfo.achieved) { - continue; - } - - progress++; - } - - const outOf = CalculateMilestoneOutOf(milestone); - - // milestone achieved! - if (progress >= outOf) { - return { achieved: true, progress }; - } - - return { achieved: false, progress }; -} - -export async function UpdateUsersMilestones( - importGoalInfo: Array, - game: Game, - playtypes: Array, - userID: integer, - logger: KtLogger -) { - const goalSubInfoMap: Map = new Map(); - - const goalIDs = []; - - for (const e of importGoalInfo) { - goalSubInfoMap.set(e.goalID, e.new); - goalIDs.push(e.goalID); - } - - const { milestones, milestoneSubs } = await GetRelevantMilestones( - goalIDs, - game, - playtypes, - userID, - logger - ); - - // create a map here to avoid linear searching when - // co-iterating - const milestoneSubMap = new Map(); - - for (const um of milestoneSubs) { - milestoneSubMap.set(um.milestoneID, um); - } - - const importGoalMap = new Map(); - - for (const ig of importGoalInfo) { - importGoalMap.set(ig.goalID, ig.new); - } - - const bwrite: Array> = []; - - const importMilestoneInfo: Array = []; - - for (const milestone of milestones) { - const { achieved, progress } = ProcessMilestoneFromGII(milestone, importGoalMap); - - const milestoneSub = milestoneSubMap.get(milestone.milestoneID); - - if (!milestoneSub) { - 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."); - } - - const bwriteOp: BulkWriteUpdateOneOperation = { - updateOne: { - filter: { milestoneID: milestone.milestoneID }, - update: { - $set: { - achieved, - progress, - }, - }, - }, - }; - - const milestoneInfo = { - milestoneID: milestoneSub.milestoneID, - old: { - progress: milestoneSub.progress, - achieved: milestoneSub.achieved, - }, - new: { - progress, - achieved, - }, - }; - - if (progress !== milestoneSub.progress) { - importMilestoneInfo.push(milestoneInfo); - - // @ts-expect-error This property isn't read only, because I said so. - bwriteOp.updateOne.update.$set!.lastInteraction = Date.now(); - } - - bwrite.push(bwriteOp); - - if (achieved && !milestoneSub.achieved) { - void EmitWebhookEvent({ - type: "milestone-achieved/v1", - content: { - userID, - ...milestoneInfo, - game, - playtype: milestone.playtype, - }, - }); - } - } - - if (bwrite.length !== 0) { - await db["milestone-subs"].bulkWrite(bwrite, { ordered: false }); - } - - return importMilestoneInfo; -} - -async function GetRelevantMilestones( - goalIDs: Array, - game: Game, - playtypes: Array, - userID: integer, - logger: KtLogger -) { - const milestoneSubs = await db["milestone-subs"].find({ - game, - playtype: { $in: playtypes }, - userID, - }); - - logger.debug(`Found ${milestoneSubs.length} milestone-subs.`); - - const milestones = await db.milestones.find({ - milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, - "milestoneData.goals.goalID": { $in: goalIDs }, - }); - - logger.debug(`Found ${milestones.length} relevant milestones.`); - - return { milestoneSubs, milestones }; -} diff --git a/server/src/lib/score-import/framework/milestones/milestones.test.ts b/server/src/lib/score-import/framework/quests/quests.test.ts similarity index 64% rename from server/src/lib/score-import/framework/milestones/milestones.test.ts rename to server/src/lib/score-import/framework/quests/quests.test.ts index 317d9815d..201e81aec 100644 --- a/server/src/lib/score-import/framework/milestones/milestones.test.ts +++ b/server/src/lib/score-import/framework/quests/quests.test.ts @@ -1,10 +1,10 @@ -import { UpdateUsersMilestones } from "./milestones"; +import { UpdateUsersQuests } from "./quests"; import deepmerge from "deepmerge"; import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { TestingIIDXSPMilestone } from "test-utils/test-data"; +import { TestingIIDXSPQuest } from "test-utils/test-data"; import type { GoalImportInfo } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -17,15 +17,15 @@ function CreateMockGII(...garr: Array<[string, boolean]>) { })) as unknown as Array; } -t.test("#UpdateUsersMilestones", (t) => { +t.test("#UpdateUsersQuests", (t) => { t.beforeEach(ResetDBState); - t.beforeEach(() => db.milestones.insert(TestingIIDXSPMilestone)); + t.beforeEach(() => db.quests.insert(TestingIIDXSPQuest)); t.beforeEach(() => - db["milestone-subs"].insert({ + db["quest-subs"].insert({ achieved: false, wasInstantlyAchieved: false, game: "iidx", - milestoneID: TestingIIDXSPMilestone.milestoneID, + questID: TestingIIDXSPQuest.questID, playtype: "SP", progress: 0, timeAchieved: null, @@ -35,8 +35,8 @@ t.test("#UpdateUsersMilestones", (t) => { }) ); - t.test("Test with clean achieved milestone.", async (t) => { - const res = await UpdateUsersMilestones( + t.test("Test with clean achieved quest.", async (t) => { + const res = await UpdateUsersQuests( CreateMockGII( ["eg_goal_1", true], ["eg_goal_2", true], @@ -53,7 +53,7 @@ t.test("#UpdateUsersMilestones", (t) => { res, [ { - milestoneID: "example_milestone_id", + questID: "example_quest_id", old: { progress: 0, achieved: false, @@ -64,14 +64,14 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, ], - "Should correctly assert the milestone is achieved." + "Should correctly assert the quest is achieved." ); t.end(); }); - t.test("Test with unclean achieved milestone.", async (t) => { - const res = await UpdateUsersMilestones( + t.test("Test with unclean achieved quest.", async (t) => { + const res = await UpdateUsersQuests( CreateMockGII( ["eg_goal_1", true], ["eg_goal_2", true], @@ -91,7 +91,7 @@ t.test("#UpdateUsersMilestones", (t) => { res, [ { - milestoneID: "example_milestone_id", + questID: "example_quest_id", old: { progress: 0, achieved: false, @@ -102,14 +102,14 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, ], - "Should correctly assert the milestone is achieved." + "Should correctly assert the quest is achieved." ); t.end(); }); - t.test("Test with increased progress on milestone.", async (t) => { - const res = await UpdateUsersMilestones( + t.test("Test with increased progress on quest.", async (t) => { + const res = await UpdateUsersQuests( CreateMockGII(["eg_goal_1", true], ["eg_goal_2", true]), "iidx", ["SP"], @@ -121,7 +121,7 @@ t.test("#UpdateUsersMilestones", (t) => { res, [ { - milestoneID: "example_milestone_id", + questID: "example_quest_id", old: { progress: 0, achieved: false, @@ -132,34 +132,34 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, ], - "Should correctly assert the milestone progress has increased." + "Should correctly assert the quest progress has increased." ); t.end(); }); - t.test("Test with no new goals on milestone.", async (t) => { - const res = await UpdateUsersMilestones([], "iidx", ["SP"], 1, logger); + t.test("Test with no new goals on quest.", async (t) => { + const res = await UpdateUsersQuests([], "iidx", ["SP"], 1, logger); t.strictSame(res, [], "Should correctly return no changes."); t.end(); }); - t.test("Test with new goals on multiple milestones.", async (t) => { - delete TestingIIDXSPMilestone._id; - await db.milestones.insert( + t.test("Test with new goals on multiple quests.", async (t) => { + delete TestingIIDXSPQuest._id; + await db.quests.insert( // eslint-disable-next-line lines-around-comment // @ts-expect-error lol - deepmerge(TestingIIDXSPMilestone, { - milestoneID: "some_other_milestone_with_mutual_goals", + deepmerge(TestingIIDXSPQuest, { + questID: "some_other_quest_with_mutual_goals", }) ); - await db["milestone-subs"].insert({ + await db["quest-subs"].insert({ achieved: false, wasInstantlyAchieved: false, game: "iidx", - milestoneID: "some_other_milestone_with_mutual_goals", + questID: "some_other_quest_with_mutual_goals", playtype: "SP", progress: 0, timeAchieved: null, @@ -168,7 +168,7 @@ t.test("#UpdateUsersMilestones", (t) => { userID: 1, }); - const res = await UpdateUsersMilestones( + const res = await UpdateUsersQuests( CreateMockGII( ["eg_goal_1", true], ["eg_goal_2", true], @@ -188,7 +188,7 @@ t.test("#UpdateUsersMilestones", (t) => { res, [ { - milestoneID: "example_milestone_id", + questID: "example_quest_id", old: { progress: 0, achieved: false, @@ -199,7 +199,7 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, { - milestoneID: "some_other_milestone_with_mutual_goals", + questID: "some_other_quest_with_mutual_goals", old: { progress: 0, achieved: false, @@ -210,24 +210,24 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, ], - "Should correctly assert the milestones are achieved." + "Should correctly assert the quests are achieved." ); t.end(); }); - t.test("Test with multiple milestones that only some match", async (t) => { - delete TestingIIDXSPMilestone._id; - await db.milestones.insert([ + t.test("Test with multiple quests that only some match", async (t) => { + delete TestingIIDXSPQuest._id; + await db.quests.insert([ // @ts-expect-error lol - deepmerge(TestingIIDXSPMilestone, { - milestoneID: "some_other_milestone_with_mutual_goals", + deepmerge(TestingIIDXSPQuest, { + questID: "some_other_quest_with_mutual_goals", }), deepmerge( - TestingIIDXSPMilestone, + TestingIIDXSPQuest, { - milestoneID: "dp_milestone", + questID: "dp_quest", playtype: "DP", - milestoneData: [ + questData: [ { goals: [{ goalID: "foobar" }], }, @@ -236,12 +236,12 @@ t.test("#UpdateUsersMilestones", (t) => { { arrayMerge: (d, s) => s } ), deepmerge( - TestingIIDXSPMilestone, + TestingIIDXSPQuest, { - milestoneID: "other_game_milestone", + questID: "other_game_quest", game: "museca", playtype: "Single", - milestoneData: [ + questData: [ { goals: [{ goalID: "foo" }], }, @@ -250,12 +250,12 @@ t.test("#UpdateUsersMilestones", (t) => { { arrayMerge: (d, s) => s } ), deepmerge( - TestingIIDXSPMilestone, + TestingIIDXSPQuest, { - milestoneID: "iidx_with_not_goal", + questID: "iidx_with_not_goal", game: "iidx", playtype: "SP", - milestoneData: [ + questData: [ { goals: [{ goalID: "not_real_goal_id" }], }, @@ -265,12 +265,12 @@ t.test("#UpdateUsersMilestones", (t) => { ), ]); - await db["milestone-subs"].insert([ + await db["quest-subs"].insert([ { achieved: false, wasInstantlyAchieved: false, game: "iidx", - milestoneID: "some_other_milestone_with_mutual_goals", + questID: "some_other_quest_with_mutual_goals", playtype: "SP", progress: 0, timeAchieved: null, @@ -283,7 +283,7 @@ t.test("#UpdateUsersMilestones", (t) => { wasInstantlyAchieved: false, game: "iidx", - milestoneID: "dp_milestone", + questID: "dp_quest", playtype: "DP", progress: 0, timeAchieved: null, @@ -296,7 +296,7 @@ t.test("#UpdateUsersMilestones", (t) => { wasInstantlyAchieved: false, game: "museca", - milestoneID: "other_game_milestone", + questID: "other_game_quest", playtype: "Single", progress: 0, timeAchieved: null, @@ -308,7 +308,7 @@ t.test("#UpdateUsersMilestones", (t) => { achieved: false, wasInstantlyAchieved: false, game: "iidx", - milestoneID: "iidx_with_not_goal", + questID: "iidx_with_not_goal", playtype: "SP", progress: 0, timeAchieved: null, @@ -318,7 +318,7 @@ t.test("#UpdateUsersMilestones", (t) => { }, ]); - const res = await UpdateUsersMilestones( + const res = await UpdateUsersQuests( CreateMockGII( ["eg_goal_1", true], ["eg_goal_2", true], @@ -338,7 +338,7 @@ t.test("#UpdateUsersMilestones", (t) => { res, [ { - milestoneID: "example_milestone_id", + questID: "example_quest_id", old: { progress: 0, achieved: false, @@ -349,7 +349,7 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, { - milestoneID: "some_other_milestone_with_mutual_goals", + questID: "some_other_quest_with_mutual_goals", old: { progress: 0, achieved: false, @@ -360,7 +360,7 @@ t.test("#UpdateUsersMilestones", (t) => { }, }, ], - "Should correctly assert the milestones are achieved." + "Should correctly assert the quests are achieved." ); t.end(); }); diff --git a/server/src/lib/score-import/framework/quests/quests.ts b/server/src/lib/score-import/framework/quests/quests.ts new file mode 100644 index 000000000..7ab692157 --- /dev/null +++ b/server/src/lib/score-import/framework/quests/quests.ts @@ -0,0 +1,175 @@ +import db from "external/mongo/db"; +import { CalculateQuestOutOf, GetGoalIDsFromQuest } from "lib/targets/quests"; +import { EmitWebhookEvent } from "lib/webhooks/webhooks"; +import type { KtLogger } from "lib/logger/logger"; +import type { BulkWriteUpdateOneOperation } from "mongodb"; +import type { + Game, + GoalImportInfo, + integer, + QuestDocument, + QuestImportInfo, + Playtype, + QuestSubscriptionDocument, + GoalImportStat, +} from "tachi-common"; + +/** + * Processes and updates a user's quests from their Goal Import Info (i.e. what is returned + * about goals from imports) + */ +export function ProcessQuestFromGII(quest: QuestDocument, gii: Map) { + const goalIDs = GetGoalIDsFromQuest(quest); + + let progress = 0; + + for (const goalID of goalIDs) { + const userInfo = gii.get(goalID); + + if (!userInfo) { + continue; + } + + if (!userInfo.achieved) { + continue; + } + + progress++; + } + + const outOf = CalculateQuestOutOf(quest); + + // quest achieved! + if (progress >= outOf) { + return { achieved: true, progress }; + } + + return { achieved: false, progress }; +} + +export async function UpdateUsersQuests( + importGoalInfo: Array, + game: Game, + playtypes: Array, + userID: integer, + logger: KtLogger +) { + const goalSubInfoMap: Map = new Map(); + + const goalIDs = []; + + for (const e of importGoalInfo) { + goalSubInfoMap.set(e.goalID, e.new); + goalIDs.push(e.goalID); + } + + const { quests, questSubs } = await GetRelevantQuests(goalIDs, game, playtypes, userID, logger); + + // create a map here to avoid linear searching when + // co-iterating + const questSubMap = new Map(); + + for (const um of questSubs) { + questSubMap.set(um.questID, um); + } + + const importGoalMap = new Map(); + + for (const ig of importGoalInfo) { + importGoalMap.set(ig.goalID, ig.new); + } + + const bwrite: Array> = []; + + const importQuestInfo: Array = []; + + for (const quest of quests) { + const { achieved, progress } = ProcessQuestFromGII(quest, importGoalMap); + + const questSub = questSubMap.get(quest.questID); + + if (!questSub) { + logger.severe( + `Invalid state achieved in quest processing - processed quest that user did not have? ${quest.questID}` + ); + + throw new Error("Invalid state achieved in quest processing."); + } + + const bwriteOp: BulkWriteUpdateOneOperation = { + updateOne: { + filter: { questID: quest.questID }, + update: { + $set: { + achieved, + progress, + }, + }, + }, + }; + + const questInfo = { + questID: questSub.questID, + old: { + progress: questSub.progress, + achieved: questSub.achieved, + }, + new: { + progress, + achieved, + }, + }; + + if (progress !== questSub.progress) { + importQuestInfo.push(questInfo); + + // @ts-expect-error This property isn't read only, because I said so. + bwriteOp.updateOne.update.$set!.lastInteraction = Date.now(); + } + + bwrite.push(bwriteOp); + + if (achieved && !questSub.achieved) { + void EmitWebhookEvent({ + type: "quest-achieved/v1", + content: { + userID, + ...questInfo, + game, + playtype: quest.playtype, + }, + }); + } + } + + if (bwrite.length !== 0) { + await db["quest-subs"].bulkWrite(bwrite, { ordered: false }); + } + + return importQuestInfo; +} + +async function GetRelevantQuests( + goalIDs: Array, + game: Game, + playtypes: Array, + userID: integer, + logger: KtLogger +) { + const questSubs = await db["quest-subs"].find({ + game, + playtype: { $in: playtypes }, + userID, + }); + + logger.debug(`Found ${questSubs.length} quest-subs.`); + + const quests = await db.quests.find({ + questID: { $in: questSubs.map((e) => e.questID) }, + "questData.goals.goalID": { $in: goalIDs }, + }); + + logger.debug(`Found ${quests.length} relevant quests.`); + + return { questSubs, quests }; +} diff --git a/server/src/lib/score-import/framework/score-importing/score-import-main.ts b/server/src/lib/score-import/framework/score-importing/score-import-main.ts index 4ce9d0711..9275e8e96 100644 --- a/server/src/lib/score-import/framework/score-importing/score-import-main.ts +++ b/server/src/lib/score-import/framework/score-importing/score-import-main.ts @@ -5,8 +5,8 @@ import { InternalFailure } from "../common/converter-failures"; import { CreateScoreLogger } from "../common/import-logger"; import { GetAndUpdateUsersGoals } from "../goals/goals"; import { CheckAndSetOngoingImportLock, UnsetOngoingImportLock } from "../import-locks/lock"; -import { UpdateUsersMilestones } from "../milestones/milestones"; import { ProcessPBs } from "../pb/process-pbs"; +import { UpdateUsersQuests } from "../quests/quests"; import { CreateSessions } from "../sessions/sessions"; import { UpdateUsersGamePlaytypeStats } from "../user-game-stats/update-ugs"; import db from "external/mongo/db"; @@ -164,7 +164,7 @@ export default async function ScoreImportMain( sessionInfo, classDeltas, goalInfo, - milestoneInfo, + questInfo, relativeTimes, absoluteTimes, } = await HandlePostImportSteps( @@ -178,7 +178,7 @@ export default async function ScoreImportMain( ); const { importParseTimeRel, pbTimeRel, sessionTimeRel } = relativeTimes; - const { importParseTime, sessionTime, pbTime, ugsTime, goalTime, milestoneTime } = + const { importParseTime, sessionTime, pbTime, ugsTime, goalTime, questTime } = absoluteTimes; void SetJobProgress(job, "Finalising Import."); @@ -199,7 +199,7 @@ export default async function ScoreImportMain( userID: user.id, classDeltas, goalInfo, - milestoneInfo, + questInfo, userIntent, }; @@ -240,7 +240,7 @@ export default async function ScoreImportMain( pb: pbTime, ugs: ugsTime, goal: goalTime, - milestone: milestoneTime, + quest: questTime, }, }); @@ -341,20 +341,20 @@ export async function HandlePostImportSteps( logger.debug(`Goal Processing took ${goalTime} milliseconds.`); - void SetJobProgress(job, "Updating Milestones."); + void SetJobProgress(job, "Updating Quests."); - // --- 8. Milestones --- - // Evaluate and update the users milestones. This returns... - const milestoneTimeStart = process.hrtime.bigint(); - const milestoneInfo = await UpdateUsersMilestones(goalInfo, game, playtypes, user.id, logger); + // --- 8. Quests --- + // Evaluate and update the users quests. This returns... + const questTimeStart = process.hrtime.bigint(); + const questInfo = await UpdateUsersQuests(goalInfo, game, playtypes, user.id, logger); - const milestoneTime = GetMillisecondsSince(milestoneTimeStart); + const questTime = GetMillisecondsSince(questTimeStart); - logger.debug(`Milestone Processing took ${milestoneTime} milliseconds.`); + logger.debug(`Quest Processing took ${questTime} milliseconds.`); return { classDeltas, - milestoneInfo, + questInfo, goalInfo, playtypes, scoreIDs, @@ -371,7 +371,7 @@ export async function HandlePostImportSteps( pbTime, ugsTime, goalTime, - milestoneTime, + questTime, }, }; } diff --git a/server/src/lib/score-mutation/delete-scores.test.ts b/server/src/lib/score-mutation/delete-scores.test.ts index 33efe0e84..9ac2a35e9 100644 --- a/server/src/lib/score-mutation/delete-scores.test.ts +++ b/server/src/lib/score-mutation/delete-scores.test.ts @@ -16,7 +16,7 @@ const mockImportDocument: ImportDocument = { idStrings: [], importID: "mockImportID", importType: "file/batch-manual", - milestoneInfo: [], + questInfo: [], scoreIDs: ["scoreid_1", "scoreid_2"], timeFinished: 1000, timeStarted: 0, diff --git a/server/src/lib/score-mutation/update-score.test.ts b/server/src/lib/score-mutation/update-score.test.ts index d20cde497..6917b03fc 100644 --- a/server/src/lib/score-mutation/update-score.test.ts +++ b/server/src/lib/score-mutation/update-score.test.ts @@ -17,7 +17,7 @@ const mockImportDocument: ImportDocument = { idStrings: [], importID: "mockImportID", importType: "file/batch-manual", - milestoneInfo: [], + questInfo: [], scoreIDs: ["TESTING_SCORE_ID", "scoreid_2"], timeFinished: 1000, timeStarted: 0, diff --git a/server/src/lib/targets/goals.ts b/server/src/lib/targets/goals.ts index d300f7f91..c9401743f 100644 --- a/server/src/lib/targets/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -15,8 +15,8 @@ import type { PBScoreDocument, Playtype, GoalSubscriptionDocument, - MilestoneSubscriptionDocument, - MilestoneDocument, + QuestSubscriptionDocument, + QuestDocument, } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -318,7 +318,7 @@ export async function ConstructGoal( * 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 milestone subscriptions, + * the user to immediately achieve the goal. This is disabled for quest subscriptions, * but enabled for manual assignment. * * Returns null if the user is already subscribed to this goal. @@ -377,56 +377,57 @@ export async function SubscribeToGoal( return goalSub; } -export function GetMilestonesThatContainGoal(goalID: string) { - return db.milestones.find({ - "milestoneData.goals.goalID": goalID, +export function GetQuestsThatContainGoal(goalID: string) { + return db.quests.find({ + "questData.goals.goalID": goalID, }); } /** * Unsubscribing from a goal may not be legal, because the goal might be part of - * a milestone the user is subscribed to. This function returns all milestones - * and milestoneSubs that a goal is attached to. + * a quest the user is subscribed to. This function returns all quests + * and questSubs that a goal is attached to. * * If this query matches none, an empty array is returned. */ -export async function GetBlockingParentMilestoneSubs( +export async function GetBlockingParentQuestSubs( goalSub: GoalSubscriptionDocument -): Promise> { - const blockers: Array = - await db["milestone-subs"].aggregate([ - { - // find all milestones that this user is subscribed to - $match: { - userID: goalSub.userID, - game: goalSub.game, - playtype: goalSub.playtype, - }, +): Promise> { + const blockers: Array = await db[ + "quest-subs" + ].aggregate([ + { + // find all quests that this user is subscribed to + $match: { + userID: goalSub.userID, + game: goalSub.game, + playtype: goalSub.playtype, }, - { - // look up the parent milestones - $lookup: { - from: "milestones", - localField: "milestoneID", - foreignField: "milestoneID", - as: "parentMilestoneSubs", - }, + }, + { + // look up the parent quests + $lookup: { + from: "quests", + localField: "questID", + foreignField: "questID", + as: "parentQuestSubs", }, - { - // then project it onto the $milestone field. This will be null - // if the milestone has no parent, which we hopefully won't have - // to consider (illegal) - $set: { - milestone: { $arrayElemAt: ["$parentMilestoneSubs", 0] }, - }, + }, + { + // then project it onto the $quest field. This will be null + // if the quest has no parent, which we hopefully won't have + // to consider (illegal) + $set: { + quest: { $arrayElemAt: ["$parentQuestSubs", 0] }, }, - { - // then finally, filter to only milestones that pertain to this goal. - $match: { - "milestone.milestoneData.goals.goalID": goalSub.goalID, - }, + }, + { + // then finally, filter to only quests that pertain to this goal. + $match: { + "quest.questData.goals.goalID": goalSub.goalID, }, - ]); + }, + ]); return blockers; } diff --git a/server/src/lib/targets/milestones.ts b/server/src/lib/targets/milestones.ts deleted file mode 100644 index 11ce359bf..000000000 --- a/server/src/lib/targets/milestones.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { EvaluateGoalForUser, SubscribeToGoal } from "./goals"; -import db from "external/mongo/db"; -import { SubscribeFailReasons } from "lib/constants/err-codes"; -import CreateLogCtx from "lib/logger/logger"; -import { BulkSendNotification } from "lib/notifications/notifications"; -import type { EvaluatedGoalReturn } from "./goals"; -import type { - GoalDocument, - GoalSubscriptionDocument, - integer, - MilestoneDocument, - MilestoneSubscriptionDocument, -} from "tachi-common"; - -const logger = CreateLogCtx(__filename); - -/** - * Retrieves the goalID documents in a single array from the - * nested structure of milestones. - */ -export 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); -} - -/** - * Return all the goals inside this milestone. - */ -export async function GetGoalsInMilestone(milestone: MilestoneDocument) { - const goalIDs = GetGoalIDsFromMilestone(milestone); - - const goals = await db.goals.find({ - goalID: { $in: goalIDs }, - }); - - if (goals.length !== goalIDs.length) { - logger.error( - `Milestone ${milestone.name} has ${goalIDs.length} goals registered, but we could only find ${goals.length} in the database?`, - { goals: goals.length, milestone, goalIDs: goalIDs.length } - ); - throw new Error(`Milestone is corrupt. Not the right amount of goals in db?`); - } - - if (goalIDs.length < 2) { - logger.error( - `Milestone ${milestone.name} resolves to less than 2 goals. Isn't a valid milestone?`, - { milestone } - ); - throw new Error(`Milestone is corrupt. Doesn't have enough goals.`); - } - - return goals; -} - -/** - * Work out how many goals need to be achieved for this - * milestone to be considered completed. - */ -export function CalculateMilestoneOutOf(milestone: MilestoneDocument) { - const goalIDs = GetGoalIDsFromMilestone(milestone); - - switch (milestone.criteria.type) { - case "all": - return goalIDs.length; - case "total": { - // It's not possible according to the types, but I want to check it anyway. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (milestone.criteria.value === null) { - throw new Error( - `Invalid milestone ${milestone.milestoneID} - total and null are not compatible.` - ); - } - - return milestone.criteria.value; - } - - default: - // Typescript annoyingly complains that milestone.criteria is of type never now. - // it's right! but if we get here, I want to log the error somehow. - throw new Error( - `Invalid milestone.criteria.type of ${ - (milestone.criteria as MilestoneDocument["criteria"]).type - } -- milestoneID ${milestone.milestoneID}` - ); - } -} - -type EvaluatedGoalResult = EvaluatedGoalReturn & { goalID: string }; - -/** - * Evaluate a user's progress on a milestone, regardless of whether they have it - * assigned or not. - * - * @returns All of the goals in the milestone. The users progress on each individual goal, - * their overall progress, what the milestone was outOf, and whether they achieved it or - * not. - */ -export async function EvaluateMilestoneProgress(userID: integer, milestone: MilestoneDocument) { - const goals = await GetGoalsInMilestone(milestone); - - const isSubscribedToMilestone = await db["milestone-subs"].findOne({ - milestoneID: milestone.milestoneID, - }); - - // If the user is subscribed the milestone, we don't need to calculate - // their progress on each goal. - const goalSubMap = new Map(); - - if (isSubscribedToMilestone) { - const goalSubs = await db["goal-subs"].find({ - goalID: { $in: goals.map((e) => e.goalID) }, - userID, - }); - - for (const sub of goalSubs) { - goalSubMap.set(sub.goalID, sub); - } - } - - const goalResults: Array = await Promise.all( - goals.map(async (goal) => { - if (isSubscribedToMilestone) { - const goalSub = goalSubMap.get(goal.goalID); - - if (!goalSub) { - logger.error( - `User ${userID} has a corrupt subscription to milestone '${milestone.name}', They do not have all the goals in this milestone assigned.` - ); - - throw new Error( - `User has corrupt subscription to milestone. Cannot calculate.` - ); - } - - return { - achieved: goalSub.achieved, - progress: goalSub.progress, - outOf: goalSub.outOf, - progressHuman: goalSub.progressHuman, - outOfHuman: goalSub.outOfHuman, - goalID: goal.goalID, - }; - } - - const result = await EvaluateGoalForUser(goal, userID, logger); - - if (!result) { - logger.error( - `Failed to calculate ${userID} result for goal '${goal.name}'. Is the goal valid?`, - { goal, milestone } - ); - - throw new Error(`Goal inside milestone is corrupt.`); - } - - return { - achieved: result.achieved, - progress: result.progress, - outOf: result.outOf, - progressHuman: result.progressHuman, - outOfHuman: result.outOfHuman, - goalID: goal.goalID, - }; - }) - ); - - const progress = goalResults.filter((e) => e.achieved).length; - const outOf = CalculateMilestoneOutOf(milestone); - - const achieved = progress >= outOf; - - return { - goals, - goalResults, - achieved, - progress, - outOf, - }; -} - -interface MilestoneSubscriptionReturns { - milestoneSub: MilestoneSubscriptionDocument; - goals: Array; - goalResults: Array; -} - -/** - * Subscribes the given user to a provided milestone. If the user is already subscribed, - * null is returned. - * - * @param cancelIfAchieved - Don't subscribe to the milestone if subscribing would cause - * the user to immediately achieve it. - */ -export async function SubscribeToMilestone( - userID: integer, - milestone: MilestoneDocument, - cancelIfAchieved: false -): Promise; -export async function SubscribeToMilestone( - userID: integer, - milestone: MilestoneDocument, - cancelIfAchieved = true -): Promise< - | MilestoneSubscriptionReturns - | SubscribeFailReasons.ALREADY_ACHIEVED - | SubscribeFailReasons.ALREADY_SUBSCRIBED -> { - const isSubscribedToMilestone = await db["milestone-subs"].findOne({ - userID, - milestoneID: milestone.milestoneID, - }); - - if (isSubscribedToMilestone) { - return SubscribeFailReasons.ALREADY_SUBSCRIBED; - } - - const result = await EvaluateMilestoneProgress(userID, milestone); - - if (result.achieved && cancelIfAchieved) { - return SubscribeFailReasons.ALREADY_ACHIEVED; - } - - // @ts-expect-error TS can't resolve this. - // because it can't explode out the types. - const milestoneSub: MilestoneSubscriptionDocument = { - progress: result.progress, - userID, - milestoneID: milestone.milestoneID, - wasInstantlyAchieved: result.achieved, - timeSet: Date.now(), - game: milestone.game, - playtype: milestone.playtype, - achieved: result.achieved, - timeAchieved: result.achieved ? Date.now() : null, - lastInteraction: null, - }; - - // @optimisable, EvaluateMilestoneProgress calculates the users progress - // on each goal. We could probably shorten this by directly inserting the records - // from result.goalResults ourselves. - // evaluating goals is fairly cheap though. - await Promise.all(result.goals.map((goal) => SubscribeToGoal(userID, goal, false))); - - await db["milestone-subs"].insert(milestoneSub); - - logger.info(`User ${userID} subscribed to '${milestone.name}'.`); - - return { milestoneSub, goals: result.goals, goalResults: result.goalResults }; -} - -export async function UnsubscribeFromMilestone(userID: integer, milestoneID: string) { - await db["milestone-subs"].remove({ - userID, - milestoneID, - }); -} - -/** - * Given a milestoneID, update all of its subscriptions to potentially subscribe to any - * new goals added to it. - * - * @note Updating milestone subscriptions just means ensuring that any subscribing - * users are also subscribed to all goals in that milestone. Nothing more. - * - * A milestone that removes goals will not result in those users having goal subs removed. - */ -export async function UpdateMilestoneSubscriptions(milestoneID: string) { - logger.info(`Received update-subscribe call to milestone ${milestoneID}.`); - - const subscriptions = await db["milestone-subs"].find({ milestoneID }); - - const maybeMilestone = await db.milestones.findOne({ milestoneID }); - - if (!maybeMilestone) { - logger.info( - `Milestone ${milestoneID} has been deleted. Unsubscribing ${subscriptions.length} users.` - ); - - return Promise.all( - subscriptions.map((e) => UnsubscribeFromMilestone(e.userID, e.milestoneID)) - ); - } - - const goals = await GetGoalsInMilestone(maybeMilestone); - - const goalSubscriptionPromises = []; - - for (const sub of subscriptions) { - for (const goal of goals) { - // attempt to subscribe to all goals in this milestone. - // 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); - } - } - - const subscriptionResults = await Promise.all(goalSubscriptionPromises); - - const newStuff = subscriptionResults.filter( - (e) => e !== SubscribeFailReasons.ALREADY_SUBSCRIBED - ).length; - - if (newStuff !== 0) { - logger.info( - `Updating subscriptions for '${maybeMilestone.name}' resulted in ${newStuff} updates.` - ); - - await BulkSendNotification( - `The milestone '${maybeMilestone.name}' has changed, You have been automatically subscribed to some new goals.`, - subscriptions.map((e) => e.userID), - { - type: "MILESTONE_CHANGED", - content: { - milestoneID, - }, - } - ); - } - - return subscriptionResults; -} diff --git a/server/src/lib/targets/quests.ts b/server/src/lib/targets/quests.ts new file mode 100644 index 000000000..79b1b06a2 --- /dev/null +++ b/server/src/lib/targets/quests.ts @@ -0,0 +1,318 @@ +import { EvaluateGoalForUser, SubscribeToGoal } from "./goals"; +import db from "external/mongo/db"; +import { SubscribeFailReasons } from "lib/constants/err-codes"; +import CreateLogCtx from "lib/logger/logger"; +import { BulkSendNotification } from "lib/notifications/notifications"; +import type { EvaluatedGoalReturn } from "./goals"; +import type { + GoalDocument, + GoalSubscriptionDocument, + integer, + QuestDocument, + QuestSubscriptionDocument, +} from "tachi-common"; + +const logger = CreateLogCtx(__filename); + +/** + * Retrieves the goalID documents in a single array from the + * nested structure of quests. + */ +export function GetGoalIDsFromQuest(quest: QuestDocument) { + // this sucks - maybe a nicer way to do this, because nested + // maps are just ugly + return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1); +} + +/** + * Return all the goals inside this quest. + */ +export async function GetGoalsInQuest(quest: QuestDocument) { + const goalIDs = GetGoalIDsFromQuest(quest); + + const goals = await db.goals.find({ + goalID: { $in: goalIDs }, + }); + + if (goals.length !== goalIDs.length) { + logger.error( + `Quest ${quest.name} has ${goalIDs.length} goals registered, but we could only find ${goals.length} in the database?`, + { goals: goals.length, quest, goalIDs: goalIDs.length } + ); + throw new Error(`Quest is corrupt. Not the right amount of goals in db?`); + } + + if (goalIDs.length < 2) { + logger.error(`Quest ${quest.name} resolves to less than 2 goals. Isn't a valid quest?`, { + quest, + }); + throw new Error(`Quest is corrupt. Doesn't have enough goals.`); + } + + return goals; +} + +/** + * Work out how many goals need to be achieved for this + * quest to be considered completed. + */ +export function CalculateQuestOutOf(quest: QuestDocument) { + const goalIDs = GetGoalIDsFromQuest(quest); + + switch (quest.criteria.type) { + case "all": + return goalIDs.length; + case "total": { + // It's not possible according to the types, but I want to check it anyway. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quest.criteria.value === null) { + throw new Error( + `Invalid quest ${quest.questID} - total and null are not compatible.` + ); + } + + return quest.criteria.value; + } + + default: + // Typescript annoyingly complains that quest.criteria is of type never now. + // it's right! but if we get here, I want to log the error somehow. + throw new Error( + `Invalid quest.criteria.type of ${ + (quest.criteria as QuestDocument["criteria"]).type + } -- questID ${quest.questID}` + ); + } +} + +type EvaluatedGoalResult = EvaluatedGoalReturn & { goalID: string }; + +/** + * Evaluate a user's progress on a quest, regardless of whether they have it + * assigned or not. + * + * @returns All of the goals in the quest. The users progress on each individual goal, + * their overall progress, what the quest was outOf, and whether they achieved it or + * not. + */ +export async function EvaluateQuestProgress(userID: integer, quest: QuestDocument) { + const goals = await GetGoalsInQuest(quest); + + const isSubscribedToQuest = await db["quest-subs"].findOne({ + questID: quest.questID, + }); + + // If the user is subscribed the quest, we don't need to calculate + // their progress on each goal. + const goalSubMap = new Map(); + + if (isSubscribedToQuest) { + const goalSubs = await db["goal-subs"].find({ + goalID: { $in: goals.map((e) => e.goalID) }, + userID, + }); + + for (const sub of goalSubs) { + goalSubMap.set(sub.goalID, sub); + } + } + + const goalResults: Array = await Promise.all( + goals.map(async (goal) => { + if (isSubscribedToQuest) { + const 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.` + ); + + throw new Error(`User has corrupt subscription to quest. Cannot calculate.`); + } + + return { + achieved: goalSub.achieved, + progress: goalSub.progress, + outOf: goalSub.outOf, + progressHuman: goalSub.progressHuman, + outOfHuman: goalSub.outOfHuman, + goalID: goal.goalID, + }; + } + + const result = await EvaluateGoalForUser(goal, userID, logger); + + if (!result) { + logger.error( + `Failed to calculate ${userID} result for goal '${goal.name}'. Is the goal valid?`, + { goal, quest } + ); + + throw new Error(`Goal inside quest is corrupt.`); + } + + return { + achieved: result.achieved, + progress: result.progress, + outOf: result.outOf, + progressHuman: result.progressHuman, + outOfHuman: result.outOfHuman, + goalID: goal.goalID, + }; + }) + ); + + const progress = goalResults.filter((e) => e.achieved).length; + const outOf = CalculateQuestOutOf(quest); + + const achieved = progress >= outOf; + + return { + goals, + goalResults, + achieved, + progress, + outOf, + }; +} + +interface QuestSubscriptionReturns { + questSub: QuestSubscriptionDocument; + goals: Array; + goalResults: Array; +} + +/** + * 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 + * the user to immediately achieve it. + */ +export async function SubscribeToQuest( + userID: integer, + quest: QuestDocument, + cancelIfAchieved: false +): Promise; +export async function SubscribeToQuest( + userID: integer, + quest: QuestDocument, + cancelIfAchieved = true +): Promise< + | QuestSubscriptionReturns + | SubscribeFailReasons.ALREADY_ACHIEVED + | SubscribeFailReasons.ALREADY_SUBSCRIBED +> { + const isSubscribedToQuest = await db["quest-subs"].findOne({ + userID, + questID: quest.questID, + }); + + if (isSubscribedToQuest) { + return SubscribeFailReasons.ALREADY_SUBSCRIBED; + } + + const result = await EvaluateQuestProgress(userID, quest); + + if (result.achieved && cancelIfAchieved) { + return SubscribeFailReasons.ALREADY_ACHIEVED; + } + + // @ts-expect-error TS can't resolve this. + // because it can't explode out the types. + const questSub: QuestSubscriptionDocument = { + progress: result.progress, + userID, + questID: quest.questID, + wasInstantlyAchieved: result.achieved, + timeSet: Date.now(), + game: quest.game, + playtype: quest.playtype, + achieved: result.achieved, + timeAchieved: result.achieved ? Date.now() : null, + lastInteraction: null, + }; + + // @optimisable, EvaluateQuestProgress calculates the users progress + // on each goal. We could probably shorten this by directly inserting the records + // from result.goalResults ourselves. + // evaluating goals is fairly cheap though. + await Promise.all(result.goals.map((goal) => SubscribeToGoal(userID, goal, false))); + + await db["quest-subs"].insert(questSub); + + logger.info(`User ${userID} subscribed to '${quest.name}'.`); + + 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. + * + * @note Updating quest subscriptions just means ensuring that any subscribing + * users are also subscribed to all goals in that quest. Nothing more. + * + * A quest that removes goals will not result in those users having goal subs removed. + */ +export async function UpdateQuestSubscriptions(questID: string) { + logger.info(`Received update-subscribe call to quest ${questID}.`); + + const subscriptions = await db["quest-subs"].find({ questID }); + + const maybeQuest = await db.quests.findOne({ questID }); + + if (!maybeQuest) { + logger.info( + `Quest ${questID} has been deleted. Unsubscribing ${subscriptions.length} users.` + ); + + return Promise.all(subscriptions.map((e) => UnsubscribeFromQuest(e.userID, e.questID))); + } + + const goals = await GetGoalsInQuest(maybeQuest); + + const goalSubscriptionPromises = []; + + 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); + } + } + + 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; +} diff --git a/server/src/scripts/sync-database.ts b/server/src/scripts/sync-database.ts index 7b626eb23..8d6a70f4f 100644 --- a/server/src/scripts/sync-database.ts +++ b/server/src/scripts/sync-database.ts @@ -9,12 +9,11 @@ import CreateLogCtx from "lib/logger/logger"; import UpdateIsPrimaryStatus from "lib/score-mutation/update-isprimary"; import { TachiConfig } from "lib/setup/config"; import { RemoveStaleFolderShowcaseStats } from "lib/showcase/showcase"; -import { UpdateMilestoneSubscriptions } from "lib/targets/milestones"; +import { UpdateQuestSubscriptions } from "lib/targets/quests"; import { RecalcAllScores } from "utils/calculations/recalc-scores"; import { UpdateGameSongIDCounter } from "utils/db"; import { InitaliseFolderChartLookup } from "utils/folder"; import { ArrayDiff, IsSupported, WrapScriptPromise } from "utils/misc"; -import path from "path"; import type { KtLogger } from "lib/logger/logger"; import type { BulkWriteOperation, DeleteWriteOpResultObject } from "mongodb"; import type { ICollection } from "monk"; @@ -24,8 +23,8 @@ import type { FolderDocument, Game, GoalDocument, - MilestoneDocument, - MilestoneSetDocument, + QuestDocument, + QuestlineDocument, SongDocument, TableDocument, } from "tachi-common"; @@ -297,40 +296,40 @@ const syncInstructions: Array = [ }, }, { - pattern: /^milestone-sets/u, + pattern: /^questlines/u, handler: async ( - milestoneSets: Array, - collection: ICollection, + questlines: Array, + collection: ICollection, logger ) => { // removing and updating these is fine. Users cannot subscibe to sets. await GenericUpsert( - milestoneSets.filter((e) => IsSupported(e.game)), + questlines.filter((e) => IsSupported(e.game)), collection, - "setID", + "questlineID", logger ); }, }, { - pattern: /^milestones/u, + pattern: /^quests/u, handler: async ( - milestones: Array, - collection: ICollection, + quests: Array, + collection: ICollection, logger ) => { const r = await GenericUpsert( - milestones.filter((e) => IsSupported(e.game)), + quests.filter((e) => IsSupported(e.game)), collection, - "milestoneID", + "questID", logger, true ); if (r.thingsChanged) { - const affectedMilestoneIDs = r.changedFields as Array; + const affectedQuestIDs = r.changedFields as Array; - await Promise.all(affectedMilestoneIDs.map((e) => UpdateMilestoneSubscriptions(e))); + await Promise.all(affectedQuestIDs.map((e) => UpdateQuestSubscriptions(e))); } }, }, diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts index 92efdd40e..eba969926 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts @@ -5,19 +5,19 @@ import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; import { FakeOtherUser, - IIDXSPMilestoneGoals, - IIDXSPMilestoneGoalSubs, - TestingIIDXSPMilestone, + IIDXSPQuestGoals, + IIDXSPQuestGoalSubs, + TestingIIDXSPQuest, } from "test-utils/test-data"; import type { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; // this is my lazy sample data for these tests. const LoadLazySampleData = async () => { await db.users.insert(FakeOtherUser); - await db.goals.insert(IIDXSPMilestoneGoals); + await db.goals.insert(IIDXSPQuestGoals); await db["goal-subs"].insert([ - ...IIDXSPMilestoneGoalSubs, - dm(IIDXSPMilestoneGoalSubs[0]!, { + ...IIDXSPQuestGoalSubs, + dm(IIDXSPQuestGoalSubs[0]!, { userID: 2, }), ] as Array); @@ -32,7 +32,7 @@ t.test("GET /api/v1/games/:game/:playtype/targets/goals/popular", (t) => { t.equal(res.statusCode, 200, "Should return 200."); - for (const goalSub of IIDXSPMilestoneGoals) { + for (const goalSub of IIDXSPQuestGoals) { // i hate this monk 'feature'!!! delete goalSub._id; } @@ -43,13 +43,13 @@ t.test("GET /api/v1/games/:game/:playtype/targets/goals/popular", (t) => { ( [ // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPMilestoneGoals[0] as any, { __subscriptions: 2 }), + dm(IIDXSPQuestGoals[0] as any, { __subscriptions: 2 }), // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPMilestoneGoals[1] as any, { __subscriptions: 1 }), + dm(IIDXSPQuestGoals[1] as any, { __subscriptions: 1 }), // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPMilestoneGoals[2] as any, { __subscriptions: 1 }), + dm(IIDXSPQuestGoals[2] as any, { __subscriptions: 1 }), // eslint-disable-next-line @typescript-eslint/no-explicit-any - dm(IIDXSPMilestoneGoals[3] as any, { __subscriptions: 1 }), + dm(IIDXSPQuestGoals[3] as any, { __subscriptions: 1 }), ] as unknown as Array ).sort((a, b) => a.goalID.localeCompare(b.goalID)), "Should return the most subscribed goals." @@ -75,7 +75,7 @@ t.test("GET /api/v1/games/:game/:playtype/targets/goals/:goalID", (t) => { t.beforeEach(LoadLazySampleData); t.test("Should return information about the specified goal.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); + await db.quests.insert(TestingIIDXSPQuest); const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/eg_goal_1"); @@ -88,7 +88,7 @@ t.test("GET /api/v1/games/:game/:playtype/targets/goals/:goalID", (t) => { { userID: 2, goalID: "eg_goal_1" }, ], users: [{ id: 1 }, { id: 2 }], - parentMilestones: [{ milestoneID: TestingIIDXSPMilestone.milestoneID }], + parentQuests: [{ questID: TestingIIDXSPQuest.questID }], }); t.end(); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts index df1226a0f..534d63950 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; -import { EvaluateGoalForUser, GetMilestonesThatContainGoal } from "lib/targets/goals"; +import { EvaluateGoalForUser, GetQuestsThatContainGoal } from "lib/targets/goals"; import prValidate from "server/middleware/prudence-validate"; import { FormatGame } from "tachi-common"; import { GetMostSubscribedGoals } from "utils/db"; @@ -67,7 +67,7 @@ router.get("/:goalID", ResolveGoalID, async (req, res) => { const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID)); - const parentMilestones = await GetMilestonesThatContainGoal(goal.goalID); + const parentQuests = await GetQuestsThatContainGoal(goal.goalID); return res.status(200).json({ success: true, @@ -76,7 +76,7 @@ router.get("/:goalID", ResolveGoalID, async (req, res) => { goal, goalSubs, users, - parentMilestones, + parentQuests, }, }); }); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts deleted file mode 100644 index 5998f28d7..000000000 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import dm from "deepmerge"; -import db from "external/mongo/db"; -import t from "tap"; -import mockApi from "test-utils/mock-api"; -import ResetDBState from "test-utils/resets"; -import { TestingIIDXSPMilestone } from "test-utils/test-data"; -import type { MilestoneDocument, MilestoneSetDocument } from "tachi-common"; - -const TestingIIDXSPMilestoneSet: MilestoneSetDocument = { - name: "Testing Milestone Set", - desc: "foo", - game: "iidx", - milestones: [TestingIIDXSPMilestone.milestoneID, "other_milestone"], - playtype: "SP", - setID: "milestone_set", -}; - -t.test("GET /api/v1/games/:game/:playtype/targets/milestone-sets", (t) => { - t.beforeEach(ResetDBState); - - function mkSet(merge: any) { - return dm(TestingIIDXSPMilestoneSet, merge) as MilestoneSetDocument; - } - - t.test("Should search the loaded milestone sets for this game.", async (t) => { - await db["milestone-sets"].insert([ - mkSet({ name: "Testing Set", setID: "name" }), - mkSet({ name: "Testing Other Set", setID: "similar_name" }), - mkSet({ name: "Different Name", setID: "radically_different_name" }), - mkSet({ - game: "chunithm", - playtype: "Single", - setID: "matching name but different gpt", - }), - mkSet({ playtype: "DP", setID: "matching name but different playtype" }), - ]); - - const res = await mockApi.get( - "/api/v1/games/iidx/SP/targets/milestone-sets?search=Testing" - ); - - t.hasStrict( - (res.body.body as Array).sort((a, b) => - a.name.localeCompare(b.name) - ), - [{ setID: "similar_name" }, { setID: "name" }] - ); - - t.end(); - }); - - t.test("Should mandate a search field.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestone-sets"); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/targets/milestone-sets/:setID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await db["milestone-sets"].insert(TestingIIDXSPMilestoneSet); - await db.milestones.insert([ - TestingIIDXSPMilestone, - dm(TestingIIDXSPMilestone, { milestoneID: "other_milestone" }) as MilestoneDocument, - ]); - }); - - t.test("Should return the milestone set and its milestones.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestone-sets/milestone_set"); - - t.equal(res.statusCode, 200, "Should return 200."); - - t.hasStrict(res.body.body.milestoneSet, { - setID: TestingIIDXSPMilestoneSet.setID, - }); - - t.hasStrict( - (res.body.body.milestones as Array).sort((a, b) => - a.milestoneID.localeCompare(b.milestoneID) - ), - [ - { milestoneID: TestingIIDXSPMilestone.milestoneID }, - { milestoneID: "other_milestone" }, - ] - ); - - t.end(); - }); - - t.test("Should return 404 if the milestone set doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestone-sets/foobar"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts deleted file mode 100644 index cc7803459..000000000 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Router } from "express"; -import db from "external/mongo/db"; -import { SearchCollection } from "lib/search/search"; -import { GetChildMilestones } from "utils/db"; -import { IsString } from "utils/misc"; -import { AssignToReqTachiData, GetGPT, GetTachiData } from "utils/req-tachi-data"; -import type { RequestHandler } from "express"; - -const router: Router = Router({ mergeParams: true }); - -const ResolveMilestoneSetID: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - const setID = req.params.setID; - - const set = await db["milestone-sets"].findOne({ - setID, - game, - playtype, - }); - - if (!set) { - return res.status(404).json({ - success: false, - description: `A milestone set with ID ${setID} doesn't exist.`, - }); - } - - AssignToReqTachiData(req, { milestoneSetDoc: set }); - - next(); -}; - -/** - * Search milestone sets. - * - * @param search - The milestone set to search for. - * - * @name GET /api/v1/games/:game/:playtype/targets/milestone-sets - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: `Invalid value for search.`, - }); - } - - const milestoneSets = await SearchCollection( - db["milestone-sets"], - req.query.search, - { game, playtype }, - 50 - ); - - return res.status(200).json({ - success: true, - description: `Returned ${milestoneSets.length} milestone sets.`, - body: milestoneSets, - }); -}); - -/** - * Retrieve a specific milestone set. - * - * @name GET /api/v1/games/:game/:playtype/targets/milestone-sets/:setID - */ -router.get("/:setID", ResolveMilestoneSetID, async (req, res) => { - const milestoneSet = GetTachiData(req, "milestoneSetDoc"); - - const milestones = await GetChildMilestones(milestoneSet); - - return res.status(200).json({ - success: true, - description: `Retrieved milestone set '${milestoneSet.name}'.`, - body: { - milestones, - milestoneSet, - }, - }); -}); - -export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts deleted file mode 100644 index 151afab72..000000000 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import dm from "deepmerge"; -import db from "external/mongo/db"; -import t from "tap"; -import mockApi from "test-utils/mock-api"; -import ResetDBState from "test-utils/resets"; -import { - FakeOtherUser, - IIDXSPMilestoneGoals, - IIDXSPMilestoneGoalSubs, - TestingIIDXSPMilestone, - TestingIIDXSPMilestoneSub, - TestingIIDXSPScorePB, -} from "test-utils/test-data"; -import type { - MilestoneDocument, - MilestoneSetDocument, - MilestoneSubscriptionDocument, -} from "tachi-common"; - -t.test("GET /api/v1/games/:game/:playtype/targets/milestones", (t) => { - t.beforeEach(ResetDBState); - - function mkMilestone(merge: any) { - return dm(TestingIIDXSPMilestone, merge) as MilestoneDocument; - } - - t.test("Should search milestones.", async (t) => { - await db.milestones.insert([ - mkMilestone({ name: "Testing Set", milestoneID: "name" }), - mkMilestone({ name: "Testing Other Set", milestoneID: "similar_name" }), - mkMilestone({ name: "Different Name", milestoneID: "radically_different_name" }), - mkMilestone({ - game: "chunithm", - playtype: "Single", - milestoneID: "matching name but different gpt", - }), - mkMilestone({ playtype: "DP", milestoneID: "matching name but different playtype" }), - ]); - - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestones?search=Testing"); - - t.equal(res.statusCode, 200); - - t.hasStrict( - (res.body.body as Array).sort((a, b) => - a.name.localeCompare(b.name) - ), - [{ milestoneID: "similar_name" }, { milestoneID: "name" }] - ); - - t.end(); - }); - - t.end(); -}); - -// this is my lazy sample data for these tests. -const LoadLazySampleData = async () => { - await db.users.insert(FakeOtherUser); - await db.goals.insert(IIDXSPMilestoneGoals); - await db.milestones.insert([ - TestingIIDXSPMilestone, - dm(TestingIIDXSPMilestone, { milestoneID: "other_milestone" }) as MilestoneDocument, - ]); - await db["milestone-subs"].insert([ - TestingIIDXSPMilestoneSub, - dm(TestingIIDXSPMilestoneSub, { milestoneID: "other_milestone" }), - dm(TestingIIDXSPMilestoneSub, { - userID: 2, - }), - ] as Array); -}; - -t.test("GET /api/v1/games/:game/:playtype/targets/milestones/popular", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadLazySampleData); - - t.test("Should return the most subscribed milestones for this GPT.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestones/popular"); - - t.equal(res.statusCode, 200); - - t.hasStrict( - (res.body.body as Array).sort((a, b) => - a.name.localeCompare(b.name) - ), - [ - { milestoneID: TestingIIDXSPMilestone.milestoneID, __subscriptions: 2 }, - { milestoneID: "other_milestone", __subscriptions: 1 }, - ] - ); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(async () => { - await Promise.all([ - db["goal-subs"].insert(IIDXSPMilestoneGoalSubs), - db["milestone-sets"].insert({ - setID: "set_id", - milestones: [TestingIIDXSPMilestone.milestoneID], - } as MilestoneSetDocument), - ]); - }); - - t.beforeEach(LoadLazySampleData); - - t.test("Should return the milestone and its goals.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` - ); - - t.hasStrict(res.body.body, { - milestone: { milestoneID: TestingIIDXSPMilestone.milestoneID }, - milestoneSubs: [{ userID: 1, milestoneID: TestingIIDXSPMilestone.milestoneID }], - users: [{ id: 1 }, { id: 2 }], - goals: [ - { goalID: "eg_goal_1" }, - { goalID: "eg_goal_2" }, - { goalID: "eg_goal_3" }, - { goalID: "eg_goal_4" }, - ], - parentMilestoneSets: [{ setID: "set_id" }], - }); - - t.end(); - }); - - t.test("Should return 404 if the requested milestone doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestones/fake_milestone"); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 404 if the milestone exists but for a different GPT.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/DP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` - ); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID/evaluate-for", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadLazySampleData); - - t.test("Should evaluate a milestone for a user.", async (t) => { - // remove all milestone subs -- the user isn't properly - // subscribed to this milestone due to test mocking. - // this will force a live calc. - await db["milestone-subs"].remove({}); - - await db["personal-bests"].insert(TestingIIDXSPScorePB); - - const res = await mockApi.get( - `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=1` - ); - - t.equal(res.statusCode, 200); - - t.hasStrict(res.body.body, { - goals: [ - { - goalID: "eg_goal_1", - }, - { - goalID: "eg_goal_2", - }, - { - goalID: "eg_goal_3", - }, - { - goalID: "eg_goal_4", - }, - ], - goalResults: [ - { - achieved: true, - progress: 6, - outOf: 5, - progressHuman: "EX HARD CLEAR (BP: 2)", - outOfHuman: "HARD CLEAR", - goalID: "eg_goal_1", - }, - { - achieved: true, - progress: 6, - outOf: 2, - progressHuman: "EX HARD CLEAR (BP: 2)", - outOfHuman: "ASSIST CLEAR", - goalID: "eg_goal_2", - }, - { - achieved: true, - progress: 1479, - outOf: 300, - outOfHuman: "300", - progressHuman: "1479", - goalID: "eg_goal_3", - }, - { - achieved: true, - progress: 1479, - outOf: 1100, - outOfHuman: "1100", - progressHuman: "1479", - goalID: "eg_goal_4", - }, - ], - achieved: true, - progress: 4, - outOf: 4, - }); - - t.end(); - }); - - t.test("Should return 404 if the requested milestone doesn't exist.", async (t) => { - const res = await mockApi.get( - "/api/v1/games/iidx/SP/targets/milestones/fake_milestone/evaluate-for?userID=1" - ); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 404 if the milestone exists but for a different GPT.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/DP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=1` - ); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 404 if the requested user doesn't exist.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=3` - ); - - t.equal(res.statusCode, 404); - - t.end(); - }); - - t.test("Should return 400 if the requested user hasn't played this gpt.", async (t) => { - const res = await mockApi.get( - `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=2` - ); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.end(); -}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts deleted file mode 100644 index 2c722f0d2..000000000 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Router } from "express"; -import db from "external/mongo/db"; -import { SearchCollection } from "lib/search/search"; -import { EvaluateMilestoneProgress, GetGoalsInMilestone } from "lib/targets/milestones"; -import prValidate from "server/middleware/prudence-validate"; -import { FormatGame } from "tachi-common"; -import { GetMostSubscribedMilestones } from "utils/db"; -import { IsString } from "utils/misc"; -import { AssignToReqTachiData, GetGPT, GetTachiData } from "utils/req-tachi-data"; -import { GetUsersWithIDs, ResolveUser } from "utils/user"; -import type { RequestHandler } from "express"; - -const router: Router = Router({ mergeParams: true }); - -const ResolveMilestoneID: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - const milestoneID = req.params.milestoneID; - - const milestone = await db.milestones.findOne({ - milestoneID, - game, - playtype, - }); - - if (!milestone) { - return res.status(404).json({ - success: false, - description: `A milestone with ID ${milestoneID} doesn't exist.`, - }); - } - - AssignToReqTachiData(req, { milestoneDoc: milestone }); - - next(); -}; - -/** - * Search milestones for this GPT. - * - * @param search - The query to search for. - * - * @name GET /api/v1/games/:game/:playtype/targets/milestones - */ -router.get("/", async (req, res) => { - const { game, playtype } = GetGPT(req); - - if (!IsString(req.query.search)) { - return res.status(400).json({ - success: false, - description: `Invalid value for search.`, - }); - } - - const milestones = await SearchCollection( - db.milestones, - req.query.search, - { game, playtype }, - 50 - ); - - return res.status(200).json({ - success: true, - description: `Returned ${milestones.length} milestones.`, - body: milestones, - }); -}); - -/** - * Find the most subscribed-to milestones for this GPT. - * - * @name GET /api/v1/games/:game/:playtype/targets/milestones/popular - */ -router.get("/popular", async (req, res) => { - const { game, playtype } = GetGPT(req); - - const milestones = await GetMostSubscribedMilestones({ game, playtype }); - - return res.status(200).json({ - success: true, - description: `Returned ${milestones.length} popular milestones.`, - body: milestones, - }); -}); - -/** - * Retrieve information about this milestone and who is subscribed to it. - * - * @name GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID - */ -router.get("/:milestoneID", ResolveMilestoneID, async (req, res) => { - const milestone = GetTachiData(req, "milestoneDoc"); - - const milestoneSubs = await db["milestone-subs"].find({ - milestoneID: milestone.milestoneID, - }); - - const users = await GetUsersWithIDs(milestoneSubs.map((e) => e.userID)); - - const goals = await GetGoalsInMilestone(milestone); - - const parentMilestoneSets = await db["milestone-sets"].find({ - milestones: milestone.milestoneID, - }); - - return res.status(200).json({ - success: true, - description: `Retrieved information about ${milestone.name}.`, - body: { - milestone, - milestoneSubs, - users, - goals, - parentMilestoneSets, - }, - }); -}); - -/** - * Evaluates a milestone upon a user, even if they aren't subscribed to it. - * - * @param userID - The userID to evaluate this goal against. Must be a player of this GPT. - * - * @name GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID/evaluate-for - */ -router.get( - "/:milestoneID/evaluate-for", - ResolveMilestoneID, - prValidate({ userID: "string" }), - async (req, res) => { - const { game, playtype } = GetGPT(req); - - const userID = req.query.userID as string; - - const user = await ResolveUser(userID); - - if (!user) { - return res.status(404).json({ - success: false, - description: `The user ${userID} does not exist.`, - }); - } - - const hasPlayed = await db["game-stats"].findOne({ - game, - playtype, - userID: user.id, - }); - - if (!hasPlayed) { - return res.status(400).json({ - success: false, - description: `The user ${user.username} hasn't played ${FormatGame( - game, - playtype - )}.`, - }); - } - - const milestone = GetTachiData(req, "milestoneDoc"); - - const milestoneProgress = await EvaluateMilestoneProgress(user.id, milestone); - - return res.status(200).json({ - success: true, - description: `Evaluated ${milestone.name} for ${user.username}.`, - body: milestoneProgress, - }); - } -); - -export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts new file mode 100644 index 000000000..a2a1410be --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts @@ -0,0 +1,99 @@ +import dm from "deepmerge"; +import db from "external/mongo/db"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { TestingIIDXSPQuest } from "test-utils/test-data"; +import type { QuestDocument, QuestlineDocument } from "tachi-common"; + +const TestingIIDXSPQuestline: QuestlineDocument = { + name: "Testing Quest Set", + desc: "foo", + game: "iidx", + quests: [TestingIIDXSPQuest.questID, "other_quest"], + playtype: "SP", + questlineID: "quest_set", +}; + +t.test("GET /api/v1/games/:game/:playtype/targets/questlines", (t) => { + t.beforeEach(ResetDBState); + + function mkSet(merge: any) { + return dm(TestingIIDXSPQuestline, merge) as QuestlineDocument; + } + + t.test("Should search the loaded quest sets for this game.", async (t) => { + await db.questlines.insert([ + mkSet({ name: "Testing Set", questlineID: "name" }), + mkSet({ name: "Testing Other Set", questlineID: "similar_name" }), + mkSet({ name: "Different Name", questlineID: "radically_different_name" }), + mkSet({ + game: "chunithm", + playtype: "Single", + questlineID: "matching name but different gpt", + }), + mkSet({ playtype: "DP", questlineID: "matching name but different playtype" }), + ]); + + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines?search=Testing"); + + t.hasStrict( + (res.body.body as Array).sort((a, b) => + a.name.localeCompare(b.name) + ), + [{ questlineID: "similar_name" }, { questlineID: "name" }] + ); + + t.end(); + }); + + t.test("Should mandate a search field.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines"); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db.questlines.insert(TestingIIDXSPQuestline); + await db.quests.insert([ + TestingIIDXSPQuest, + dm(TestingIIDXSPQuest, { questID: "other_quest" }) as QuestDocument, + ]); + }); + + t.test("Should return the quest set and its quests.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines/quest_set"); + + t.equal(res.statusCode, 200, "Should return 200."); + + t.hasStrict(res.body.body.questline, { + questlineID: TestingIIDXSPQuestline.questlineID, + }); + + t.hasStrict( + (res.body.body.quests as Array).sort((a, b) => + a.questID.localeCompare(b.questID) + ), + [{ questID: TestingIIDXSPQuest.questID }, { questID: "other_quest" }] + ); + + t.end(); + }); + + t.test("Should return 404 if the quest set doesn't exist.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines/foobar"); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts new file mode 100644 index 000000000..eaf20f12e --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts @@ -0,0 +1,84 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import { SearchCollection } from "lib/search/search"; +import { GetChildQuests } from "utils/db"; +import { IsString } from "utils/misc"; +import { AssignToReqTachiData, GetGPT, GetTachiData } from "utils/req-tachi-data"; +import type { RequestHandler } from "express"; + +const router: Router = Router({ mergeParams: true }); + +const ResolveQuestlineID: RequestHandler = async (req, res, next) => { + const { game, playtype } = GetGPT(req); + const questlineID = req.params.questlineID; + + const set = await db.questlines.findOne({ + questlineID, + game, + playtype, + }); + + if (!set) { + return res.status(404).json({ + success: false, + description: `A quest set with ID ${questlineID} doesn't exist.`, + }); + } + + AssignToReqTachiData(req, { questlineDoc: set }); + + next(); +}; + +/** + * Search quest sets. + * + * @param search - The quest set to search for. + * + * @name GET /api/v1/games/:game/:playtype/targets/questlines + */ +router.get("/", async (req, res) => { + const { game, playtype } = GetGPT(req); + + if (!IsString(req.query.search)) { + return res.status(400).json({ + success: false, + description: `Invalid value for search.`, + }); + } + + const questlines = await SearchCollection( + db.questlines, + req.query.search, + { game, playtype }, + 50 + ); + + return res.status(200).json({ + success: true, + description: `Returned ${questlines.length} quest sets.`, + body: questlines, + }); +}); + +/** + * Retrieve a specific quest set. + * + * @name GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID + */ +router.get("/:questlineID", ResolveQuestlineID, async (req, res) => { + const questline = GetTachiData(req, "questlineDoc"); + + const quests = await GetChildQuests(questline); + + return res.status(200).json({ + success: true, + description: `Retrieved quest set '${questline.name}'.`, + body: { + quests, + questline, + }, + }); +}); + +export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.test.ts new file mode 100644 index 000000000..5e0624774 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.test.ts @@ -0,0 +1,263 @@ +import dm from "deepmerge"; +import db from "external/mongo/db"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { + FakeOtherUser, + IIDXSPQuestGoals, + IIDXSPQuestGoalSubs, + TestingIIDXSPQuest, + TestingIIDXSPQuestSub, + TestingIIDXSPScorePB, +} from "test-utils/test-data"; +import type { QuestDocument, QuestlineDocument, QuestSubscriptionDocument } from "tachi-common"; + +t.test("GET /api/v1/games/:game/:playtype/targets/quests", (t) => { + t.beforeEach(ResetDBState); + + function mkQuest(merge: any) { + return dm(TestingIIDXSPQuest, merge) as QuestDocument; + } + + t.test("Should search quests.", async (t) => { + await db.quests.insert([ + mkQuest({ name: "Testing Set", questID: "name" }), + mkQuest({ name: "Testing Other Set", questID: "similar_name" }), + mkQuest({ name: "Different Name", questID: "radically_different_name" }), + mkQuest({ + game: "chunithm", + playtype: "Single", + questID: "matching name but different gpt", + }), + mkQuest({ playtype: "DP", questID: "matching name but different playtype" }), + ]); + + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests?search=Testing"); + + t.equal(res.statusCode, 200); + + t.hasStrict( + (res.body.body as Array).sort((a, b) => a.name.localeCompare(b.name)), + [{ questID: "similar_name" }, { questID: "name" }] + ); + + t.end(); + }); + + t.end(); +}); + +// this is my lazy sample data for these tests. +const LoadLazySampleData = async () => { + await db.users.insert(FakeOtherUser); + await db.goals.insert(IIDXSPQuestGoals); + await db.quests.insert([ + TestingIIDXSPQuest, + dm(TestingIIDXSPQuest, { questID: "other_quest" }) as QuestDocument, + ]); + await db["quest-subs"].insert([ + TestingIIDXSPQuestSub, + dm(TestingIIDXSPQuestSub, { questID: "other_quest" }), + dm(TestingIIDXSPQuestSub, { + userID: 2, + }), + ] as Array); +}; + +t.test("GET /api/v1/games/:game/:playtype/targets/quests/popular", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should return the most subscribed quests for this GPT.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests/popular"); + + t.equal(res.statusCode, 200); + + t.hasStrict( + (res.body.body as Array).sort((a, b) => a.name.localeCompare(b.name)), + [ + { questID: TestingIIDXSPQuest.questID, __subscriptions: 2 }, + { questID: "other_quest", __subscriptions: 1 }, + ] + ); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/quests/:questID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await Promise.all([ + db["goal-subs"].insert(IIDXSPQuestGoalSubs), + db.questlines.insert({ + questlineID: "set_id", + quests: [TestingIIDXSPQuest.questID], + } as QuestlineDocument), + ]); + }); + + t.beforeEach(LoadLazySampleData); + + t.test("Should return the quest and its goals.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}` + ); + + t.hasStrict(res.body.body, { + quest: { questID: TestingIIDXSPQuest.questID }, + questSubs: [{ userID: 1, questID: TestingIIDXSPQuest.questID }], + users: [{ id: 1 }, { id: 2 }], + goals: [ + { goalID: "eg_goal_1" }, + { goalID: "eg_goal_2" }, + { goalID: "eg_goal_3" }, + { goalID: "eg_goal_4" }, + ], + parentQuestlines: [{ questlineID: "set_id" }], + }); + + t.end(); + }); + + t.test("Should return 404 if the requested quest doesn't exist.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests/fake_quest"); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 404 if the quest exists but for a different GPT.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/DP/targets/quests/${TestingIIDXSPQuest.questID}` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/quests/:questID/evaluate-for", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should evaluate a quest for a user.", async (t) => { + // remove all quest subs -- the user isn't properly + // subscribed to this quest due to test mocking. + // this will force a live calc. + await db["quest-subs"].remove({}); + + await db["personal-bests"].insert(TestingIIDXSPScorePB); + + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}/evaluate-for?userID=1` + ); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + goals: [ + { + goalID: "eg_goal_1", + }, + { + goalID: "eg_goal_2", + }, + { + goalID: "eg_goal_3", + }, + { + goalID: "eg_goal_4", + }, + ], + goalResults: [ + { + achieved: true, + progress: 6, + outOf: 5, + progressHuman: "EX HARD CLEAR (BP: 2)", + outOfHuman: "HARD CLEAR", + goalID: "eg_goal_1", + }, + { + achieved: true, + progress: 6, + outOf: 2, + progressHuman: "EX HARD CLEAR (BP: 2)", + outOfHuman: "ASSIST CLEAR", + goalID: "eg_goal_2", + }, + { + achieved: true, + progress: 1479, + outOf: 300, + outOfHuman: "300", + progressHuman: "1479", + goalID: "eg_goal_3", + }, + { + achieved: true, + progress: 1479, + outOf: 1100, + outOfHuman: "1100", + progressHuman: "1479", + goalID: "eg_goal_4", + }, + ], + achieved: true, + progress: 4, + outOf: 4, + }); + + t.end(); + }); + + t.test("Should return 404 if the requested quest doesn't exist.", async (t) => { + const res = await mockApi.get( + "/api/v1/games/iidx/SP/targets/quests/fake_quest/evaluate-for?userID=1" + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 404 if the quest exists but for a different GPT.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/DP/targets/quests/${TestingIIDXSPQuest.questID}/evaluate-for?userID=1` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 404 if the requested user doesn't exist.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}/evaluate-for?userID=3` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 400 if the requested user hasn't played this gpt.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}/evaluate-for?userID=2` + ); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts new file mode 100644 index 000000000..3655043eb --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts @@ -0,0 +1,166 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import { SearchCollection } from "lib/search/search"; +import { EvaluateQuestProgress, GetGoalsInQuest } from "lib/targets/quests"; +import prValidate from "server/middleware/prudence-validate"; +import { FormatGame } from "tachi-common"; +import { GetMostSubscribedQuests } from "utils/db"; +import { IsString } from "utils/misc"; +import { AssignToReqTachiData, GetGPT, GetTachiData } from "utils/req-tachi-data"; +import { GetUsersWithIDs, ResolveUser } from "utils/user"; +import type { RequestHandler } from "express"; + +const router: Router = Router({ mergeParams: true }); + +const ResolveQuestID: RequestHandler = async (req, res, next) => { + const { game, playtype } = GetGPT(req); + const questID = req.params.questID; + + const quest = await db.quests.findOne({ + questID, + game, + playtype, + }); + + if (!quest) { + return res.status(404).json({ + success: false, + description: `A quest with ID ${questID} doesn't exist.`, + }); + } + + AssignToReqTachiData(req, { questDoc: quest }); + + next(); +}; + +/** + * Search quests for this GPT. + * + * @param search - The query to search for. + * + * @name GET /api/v1/games/:game/:playtype/targets/quests + */ +router.get("/", async (req, res) => { + const { game, playtype } = GetGPT(req); + + if (!IsString(req.query.search)) { + return res.status(400).json({ + success: false, + description: `Invalid value for search.`, + }); + } + + const quests = await SearchCollection(db.quests, req.query.search, { game, playtype }, 50); + + return res.status(200).json({ + success: true, + description: `Returned ${quests.length} quests.`, + body: quests, + }); +}); + +/** + * Find the most subscribed-to quests for this GPT. + * + * @name GET /api/v1/games/:game/:playtype/targets/quests/popular + */ +router.get("/popular", async (req, res) => { + const { game, playtype } = GetGPT(req); + + const quests = await GetMostSubscribedQuests({ game, playtype }); + + return res.status(200).json({ + success: true, + description: `Returned ${quests.length} popular quests.`, + body: quests, + }); +}); + +/** + * Retrieve information about this quest and who is subscribed to it. + * + * @name GET /api/v1/games/:game/:playtype/targets/quests/:questID + */ +router.get("/:questID", ResolveQuestID, async (req, res) => { + const quest = GetTachiData(req, "questDoc"); + + const questSubs = await db["quest-subs"].find({ + questID: quest.questID, + }); + + const users = await GetUsersWithIDs(questSubs.map((e) => e.userID)); + + const goals = await GetGoalsInQuest(quest); + + const parentQuestlines = await db.questlines.find({ + quests: quest.questID, + }); + + return res.status(200).json({ + success: true, + description: `Retrieved information about ${quest.name}.`, + body: { + quest, + questSubs, + users, + goals, + parentQuestlines, + }, + }); +}); + +/** + * Evaluates a quest upon a user, even if they aren't subscribed to it. + * + * @param userID - The userID to evaluate this goal against. Must be a player of this GPT. + * + * @name GET /api/v1/games/:game/:playtype/targets/quests/:questID/evaluate-for + */ +router.get( + "/:questID/evaluate-for", + ResolveQuestID, + prValidate({ userID: "string" }), + async (req, res) => { + const { game, playtype } = GetGPT(req); + + const userID = req.query.userID as string; + + const user = await ResolveUser(userID); + + if (!user) { + return res.status(404).json({ + success: false, + description: `The user ${userID} does not exist.`, + }); + } + + const hasPlayed = await db["game-stats"].findOne({ + game, + playtype, + userID: user.id, + }); + + if (!hasPlayed) { + return res.status(400).json({ + success: false, + description: `The user ${user.username} hasn't played ${FormatGame( + game, + playtype + )}.`, + }); + } + + const quest = GetTachiData(req, "questDoc"); + + const questProgress = await EvaluateQuestProgress(user.id, quest); + + return res.status(200).json({ + success: true, + description: `Evaluated ${quest.name} for ${user.username}.`, + body: questProgress, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts index b79b2490f..4fb0d684b 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts @@ -1,29 +1,29 @@ import goalsRouter from "./goals/router"; -import milestoneSetsRouter from "./milestone-sets/router"; -import milestonesRouter from "./milestones/router"; +import questlineRouter from "./questlines/router"; +import questsRouter from "./quests/router"; import { Router } from "express"; import { FormatGame } from "tachi-common"; import { GetRecentlyAchievedGoals, - GetRecentlyAchievedMilestones, + GetRecentlyAchievedQuests, GetRecentlyInteractedGoals, - GetRecentlyInteractedMilestones, + GetRecentlyInteractedQuests, } from "utils/db"; import { GetGPT } from "utils/req-tachi-data"; const router: Router = Router({ mergeParams: true }); /** - * Retrieve all of this game's recently achieved goals and milestones. + * Retrieve all of this game's recently achieved goals and quests. * * @name GET /api/v1/games/:game/:playtype/targets/recently-achieved */ router.get("/recently-achieved", async (req, res) => { const { game, playtype } = GetGPT(req); - const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ GetRecentlyAchievedGoals({ game, playtype }), - GetRecentlyAchievedMilestones({ game, playtype }), + GetRecentlyAchievedQuests({ game, playtype }), ]); return res.status(200).json({ @@ -32,23 +32,23 @@ router.get("/recently-achieved", async (req, res) => { body: { goals, goalSubs, - milestones, - milestoneSubs, + quests, + questSubs, }, }); }); /** - * Retrieve all of this game's recently interacted-with goals and milestones. + * Retrieve all of this game's recently interacted-with goals and quests. * * @name GET /api/v1/games/:game/:playtype/targets/recently-raised */ router.get("/recently-raised", async (req, res) => { const { game, playtype } = GetGPT(req); - const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ GetRecentlyInteractedGoals({ game, playtype }), - GetRecentlyInteractedMilestones({ game, playtype }), + GetRecentlyInteractedQuests({ game, playtype }), ]); return res.status(200).json({ @@ -60,14 +60,14 @@ router.get("/recently-raised", async (req, res) => { body: { goals, goalSubs, - milestones, - milestoneSubs, + quests, + questSubs, }, }); }); router.use("/goals", goalsRouter); -router.use("/milestones", milestonesRouter); -router.use("/milestone-sets", milestoneSetsRouter); +router.use("/quests", questsRouter); +router.use("/questlines", questlineRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts index 58492208b..59124ef84 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts @@ -10,12 +10,12 @@ import { TestSnapshot } from "test-utils/single-process-snapshot"; import { HC511Goal, HC511UserGoal, - IIDXSPMilestoneGoals, - IIDXSPMilestoneGoalSubs, + IIDXSPQuestGoals, + IIDXSPQuestGoalSubs, Testing511SPA, TestingIIDXFolderSP10, - TestingIIDXSPMilestone, - TestingIIDXSPMilestoneSub, + TestingIIDXSPQuest, + TestingIIDXSPQuestSub, TestingIIDXSPScore, TestingIIDXSPScorePB, } from "test-utils/test-data"; @@ -653,7 +653,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", t.hasStrict(res.body.body, { goal: HC511Goal, goalSub: HC511UserGoal, - milestones: [], + quests: [], user: { id: 1, }, @@ -662,29 +662,29 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", t.end(); }); - t.test("Should return parent milestones if goal has any.", async (t) => { - await db.goals.insert(IIDXSPMilestoneGoals); - await db["goal-subs"].insert(IIDXSPMilestoneGoalSubs); - await db.milestones.insert(TestingIIDXSPMilestone); + t.test("Should return parent quests if goal has any.", async (t) => { + await db.goals.insert(IIDXSPQuestGoals); + await db["goal-subs"].insert(IIDXSPQuestGoalSubs); + await db.quests.insert(TestingIIDXSPQuest); - if (!IIDXSPMilestoneGoalSubs[0] || !IIDXSPMilestoneGoals[0]) { - throw new Error(`Expected atleast one milestone goal or sub to work with?`); + if (!IIDXSPQuestGoalSubs[0] || !IIDXSPQuestGoals[0]) { + throw new Error(`Expected atleast one quest goal or sub to work with?`); } const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/goals/${IIDXSPMilestoneGoalSubs[0].goalID}` + `/api/v1/users/1/games/iidx/SP/targets/goals/${IIDXSPQuestGoalSubs[0].goalID}` ); t.equal(res.statusCode, 200); - delete IIDXSPMilestoneGoals[0]._id; - delete IIDXSPMilestoneGoalSubs[0]._id; - delete TestingIIDXSPMilestone._id; + delete IIDXSPQuestGoals[0]._id; + delete IIDXSPQuestGoalSubs[0]._id; + delete TestingIIDXSPQuest._id; t.hasStrict(res.body.body, { - goal: IIDXSPMilestoneGoals[0], - goalSub: IIDXSPMilestoneGoalSubs[0], - milestones: [TestingIIDXSPMilestone], + goal: IIDXSPQuestGoals[0], + goalSub: IIDXSPQuestGoalSubs[0], + quests: [TestingIIDXSPQuest], user: { id: 1, }, @@ -729,10 +729,10 @@ t.test("DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID t.end(); }); - t.test("Should reject a goal deletion if goal has parent milestones.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); - await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); - await db.goals.insert(IIDXSPMilestoneGoals); + t.test("Should reject a goal deletion if goal has parent quests.", async (t) => { + await db.quests.insert(TestingIIDXSPQuest); + await db["quest-subs"].insert(TestingIIDXSPQuestSub); + await db.goals.insert(IIDXSPQuestGoals); await db["goal-subs"].insert( dm(dupedGoalSub, { goalID: "eg_goal_1" }) as GoalSubscriptionDocument ); @@ -744,7 +744,7 @@ t.test("DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID t.equal(res.statusCode, 400); t.equal( res.body.description, - `This goal is part of a milestone you are subscribed to. It can only be removed by unsubscribing from the relevant milestones: '${TestingIIDXSPMilestone.name}'.` + `This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: '${TestingIIDXSPQuest.name}'.` ); const dbRes = await db["goal-subs"].findOne({ 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 1885b56dc..89ea75b7f 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,8 +6,8 @@ import CreateLogCtx from "lib/logger/logger"; import { ServerConfig } from "lib/setup/config"; import { ConstructGoal, - GetBlockingParentMilestoneSubs, - GetMilestonesThatContainGoal, + GetBlockingParentQuestSubs, + GetQuestsThatContainGoal, SubscribeToGoal, } from "lib/targets/goals"; import p from "prudence"; @@ -16,7 +16,7 @@ import prValidate from "server/middleware/prudence-validate"; import { GetGoalForIDGuaranteed } from "utils/db"; import { AssignToReqTachiData, GetTachiData, GetUGPT } from "utils/req-tachi-data"; import type { RequestHandler } from "express"; -import type { GoalDocument, MilestoneDocument } from "tachi-common"; +import type { GoalDocument, QuestDocument } from "tachi-common"; const router: Router = Router({ mergeParams: true }); @@ -225,7 +225,7 @@ router.get("/:goalID", GetGoalSubscription, async (req, res) => { const goalSub = GetTachiData(req, "goalSubDoc"); - const milestones: Array = await GetMilestonesThatContainGoal(goalSub.goalID); + const quests: Array = await GetQuestsThatContainGoal(goalSub.goalID); const goal = await GetGoalForIDGuaranteed(goalSub.goalID); @@ -235,7 +235,7 @@ router.get("/:goalID", GetGoalSubscription, async (req, res) => { body: { goal, goalSub, - milestones, + quests, user, }, }); @@ -257,13 +257,13 @@ router.delete( const goalSub = GetTachiData(req, "goalSubDoc"); - const parentMilestones = await GetBlockingParentMilestoneSubs(goalSub); + const parentQuests = await GetBlockingParentQuestSubs(goalSub); - if (parentMilestones.length) { + if (parentQuests.length) { return res.status(400).json({ success: false, - description: `This goal is part of a milestone you are subscribed to. It can only be removed by unsubscribing from the relevant milestones: ${parentMilestones - .map((e) => `'${e.milestone.name}'`) + 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(", ")}.`, }); } diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts deleted file mode 100644 index 1183d9ade..000000000 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { RequireAuthedAsUser } from "../../../../../middleware"; -import { Router } from "express"; -import db from "external/mongo/db"; -import { SubscribeFailReasons } from "lib/constants/err-codes"; -import CreateLogCtx from "lib/logger/logger"; -import { ServerConfig } from "lib/setup/config"; -import { - EvaluateMilestoneProgress, - SubscribeToMilestone, - UnsubscribeFromMilestone, -} from "lib/targets/milestones"; -import { RequirePermissions } from "server/middleware/auth"; -import { AssignToReqTachiData, GetGPT, GetTachiData, GetUGPT } from "utils/req-tachi-data"; -import { FormatUserDoc } from "utils/user"; -import type { RequestHandler } from "express"; - -const logger = CreateLogCtx(__filename); - -const router: Router = Router({ mergeParams: true }); - -/** - * Retrieves this user's subscribed milestones. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones - */ -router.get("/", async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const milestoneSubs = await db["milestone-subs"].find({ - userID: user.id, - game, - playtype, - }); - - const milestones = await db.milestones.find({ - milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, - }); - - if (milestones.length !== milestoneSubs.length) { - logger.error( - `Found ${milestoneSubs.length} subscriptions, but got ${milestones.length} parents. This is a state desync.` - ); - throw new Error("Failed to fetch milestones"); - } - - return res.status(200).json({ - success: true, - description: `Retrieved ${milestoneSubs.length} milestone(s).`, - body: { - milestones, - milestoneSubs, - }, - }); -}); - -const GetMilestoneSubscription: RequestHandler = async (req, res, next) => { - const { user, game, playtype } = GetUGPT(req); - - const milestoneSub = await db["milestone-subs"].findOne({ - userID: user.id, - game, - playtype, - milestoneID: req.params.milestoneID, - }); - - if (!milestoneSub) { - return res.status(404).json({ - success: false, - description: `${user.username} is not subscribed to this milestone.`, - }); - } - - AssignToReqTachiData(req, { milestoneSubDoc: milestoneSub }); - - next(); -}; - -const GetMilestone: RequestHandler = async (req, res, next) => { - const { game, playtype } = GetGPT(req); - - const milestone = await db.milestones.findOne({ - game, - playtype, - milestoneID: req.params.milestoneID, - }); - - if (!milestone) { - return res.status(404).json({ - success: false, - description: `Can't find a milestone with id '${req.params.milestoneID}'.`, - }); - } - - AssignToReqTachiData(req, { milestoneDoc: milestone }); - - next(); -}; - -/** - * Returns this user's progress on this milestone. - * This also evaluates individual progress on all of the milestones goals. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID - */ -router.get("/:milestoneID", GetMilestone, GetMilestoneSubscription, async (req, res) => { - const { user } = GetUGPT(req); - - const milestoneSub = GetTachiData(req, "milestoneSubDoc"); - const milestone = GetTachiData(req, "milestoneDoc"); - - // Evaluate each goal for the user. This operation is much faster if the user is - // subscribed to the milestone (they are), as we can just read their goalSub - // for each goal. - const { goalResults: results, goals } = await EvaluateMilestoneProgress(user.id, milestone); - - return res.status(200).json({ - success: true, - description: `Returned information about ${FormatUserDoc(user)}'s progress on ${ - milestone.name - }.`, - body: { - milestoneSub, - milestone, - results, - goals, - }, - }); -}); - -/** - * Subscribe to a milestone. - * - * @name PUT /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID - */ -router.put( - "/:milestoneID", - RequireAuthedAsUser, - GetMilestone, - RequirePermissions("manage_targets"), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); - - const existingMilestonesCount = await db["milestone-subs"].count({ - userID: user.id, - game, - playtype, - }); - - if (existingMilestonesCount > ServerConfig.MAX_MILESTONE_SUBSCRIPTIONS) { - return res.status(400).json({ - success: false, - description: `You already have ${ServerConfig.MAX_MILESTONE_SUBSCRIPTIONS} milestones. You cannot have anymore for this game.`, - }); - } - - const milestone = GetTachiData(req, "milestoneDoc"); - - const alreadySubscibed = await db["milestone-subs"].findOne({ - userID: user.id, - milestoneID: milestone.milestoneID, - }); - - if (alreadySubscibed) { - return res.status(409).json({ - success: false, - description: `You are already subscribed to this goal.`, - }); - } - - const subResult = await SubscribeToMilestone(user.id, milestone, false); - - // Users should be able to subscribe to milestones EVEN IF they would instantly - // achieve them. - - // if (subResult === SubscribeFailReasons.ALREADY_ACHIEVED) { - // return res.status(400).json({ - // success: false, - // description: `You cannot assign a milestone that would be immediately achieved.`, - // }); - // } - - if (subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED) { - return res.status(409).json({ - success: false, - description: `You're already subscribed to this milestone.`, - }); - } - - return res.status(200).json({ - success: true, - description: `Subscribed to milestone '${milestone.name}'.`, - body: { ...subResult, milestone }, - }); - } -); - -/** - * Unsubscribe from a milestone. - * - * @name DELETE /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID - */ -router.delete( - "/:milestoneID", - RequireAuthedAsUser, - GetMilestone, - RequirePermissions("manage_targets"), - async (req, res) => { - const { user } = GetUGPT(req); - const milestone = GetTachiData(req, "milestoneDoc"); - - logger.info( - `User ${FormatUserDoc(user)} is unsubscribing from milestone '${milestone.name}'.`, - { - milestone, - user, - } - ); - - await UnsubscribeFromMilestone(user.id, milestone.milestoneID); - - return res.status(200).json({ - success: true, - description: `Unsubscribed from milestone.`, - body: { - milestone, - }, - }); - } -); - -export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts similarity index 51% rename from server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.test.ts rename to server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts index 60f4e495f..cac32e723 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts @@ -4,38 +4,38 @@ import { CreateFakeAuthCookie } from "test-utils/fake-auth"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; import { - IIDXSPMilestoneGoals, - IIDXSPMilestoneGoalSubs, - TestingIIDXSPMilestone, - TestingIIDXSPMilestoneSub, + IIDXSPQuestGoals, + IIDXSPQuestGoalSubs, + TestingIIDXSPQuest, + TestingIIDXSPQuestSub, } from "test-utils/test-data"; import type { PublicUserDocument, UserGameStats } from "tachi-common"; -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones", (t) => { +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/quests", (t) => { t.beforeEach(ResetDBState); - t.test("Should return all milestone subscriptions.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); - await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + t.test("Should return all quest subscriptions.", async (t) => { + await db.quests.insert(TestingIIDXSPQuest); + await db["quest-subs"].insert(TestingIIDXSPQuestSub); - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/milestones"); + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/quests"); - delete TestingIIDXSPMilestone._id; - delete TestingIIDXSPMilestoneSub._id; + delete TestingIIDXSPQuest._id; + delete TestingIIDXSPQuestSub._id; t.strictSame(res.body.body, { - milestones: [TestingIIDXSPMilestone], - milestoneSubs: [TestingIIDXSPMilestoneSub], + quests: [TestingIIDXSPQuest], + questSubs: [TestingIIDXSPQuestSub], }); t.end(); }); - t.test("Should panic if milestone subs are parentless.", async (t) => { - // await db.milestones.insert(TestingIIDXSPMilestone); - await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + t.test("Should panic if quest subs are parentless.", async (t) => { + // await db.quests.insert(TestingIIDXSPQuest); + await db["quest-subs"].insert(TestingIIDXSPQuestSub); - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/milestones"); + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/quests"); t.equal(res.statusCode, 500); @@ -45,32 +45,32 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones", (t) t.end(); }); -t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID", (t) => { +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID", (t) => { t.beforeEach(ResetDBState); - t.test("Should return the user's specific milestone sub.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); - await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + t.test("Should return the user's specific quest sub.", async (t) => { + await db.quests.insert(TestingIIDXSPQuest); + await db["quest-subs"].insert(TestingIIDXSPQuestSub); - await db.goals.insert(IIDXSPMilestoneGoals); - await db["goal-subs"].insert(IIDXSPMilestoneGoalSubs); + await db.goals.insert(IIDXSPQuestGoals); + await db["goal-subs"].insert(IIDXSPQuestGoalSubs); const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}` ); // i hate the fact that monk just adds this to objects! terrible. - delete TestingIIDXSPMilestone._id; - delete TestingIIDXSPMilestoneSub._id; + delete TestingIIDXSPQuest._id; + delete TestingIIDXSPQuestSub._id; - for (const goal of IIDXSPMilestoneGoals) { + for (const goal of IIDXSPQuestGoals) { delete goal._id; } t.strictSame(res.body.body, { - milestone: TestingIIDXSPMilestone, - milestoneSub: TestingIIDXSPMilestoneSub, - results: IIDXSPMilestoneGoalSubs.map((e) => ({ + quest: TestingIIDXSPQuest, + questSub: TestingIIDXSPQuestSub, + results: IIDXSPQuestGoalSubs.map((e) => ({ achieved: e.achieved, progress: e.progress, progressHuman: e.progressHuman, @@ -78,15 +78,15 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:mile outOfHuman: e.outOfHuman, goalID: e.goalID, })), - goals: IIDXSPMilestoneGoals, + goals: IIDXSPQuestGoals, }); t.end(); }); - t.test("Should return 404 if the user is not subscribed to this milestone.", async (t) => { + t.test("Should return 404 if the user is not subscribed to this quest.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone` + `/api/v1/users/1/games/iidx/SP/targets/quests/fake_quest` ); t.equal(res.statusCode, 404); @@ -98,15 +98,15 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:mile }); t.test( - "PUT /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID", + "PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID", async (t) => { t.beforeEach(ResetDBState); const cookie = await CreateFakeAuthCookie(mockApi); - t.test("Should return 404 if this milestone doesn't exist.", async (t) => { + t.test("Should return 404 if this quest doesn't exist.", async (t) => { const res = await mockApi - .put(`/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone`) + .put(`/api/v1/users/1/games/iidx/SP/targets/quests/fake_quest`) .set("Cookie", cookie); t.equal(res.statusCode, 404); @@ -114,13 +114,13 @@ t.test( t.end(); }); - t.test("Should return 409 if user is already subscribed to this milestone.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); - await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + t.test("Should return 409 if user is already subscribed to this quest.", async (t) => { + await db.quests.insert(TestingIIDXSPQuest); + await db["quest-subs"].insert(TestingIIDXSPQuestSub); const res = await mockApi .put( - `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}` ) .set("Cookie", cookie); @@ -129,31 +129,31 @@ t.test( t.end(); }); - t.test("Should subscribe to a milestone.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); - await db.goals.insert(IIDXSPMilestoneGoals); + t.test("Should subscribe to a quest.", async (t) => { + await db.quests.insert(TestingIIDXSPQuest); + await db.goals.insert(IIDXSPQuestGoals); const res = await mockApi .put( - `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}` ) .set("Cookie", cookie); t.equal(res.statusCode, 200); - const dbRes = await db["milestone-subs"].findOne({ + const dbRes = await db["quest-subs"].findOne({ userID: 1, - milestoneID: TestingIIDXSPMilestone.milestoneID, + questID: TestingIIDXSPQuest.questID, }); - t.not(dbRes, null, "Should have subscribed the user to the milestone."); + t.not(dbRes, null, "Should have subscribed the user to the quest."); t.end(); }); t.test("Should return 401 if the user is not authed.", async (t) => { const res = await mockApi.put( - `/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone` + `/api/v1/users/1/games/iidx/SP/targets/quests/fake_quest` ); t.equal(res.statusCode, 401); @@ -177,7 +177,7 @@ t.test( } as UserGameStats); const res = await mockApi - .put(`/api/v1/users/2/games/iidx/SP/targets/milestones/fake_milestone`) + .put(`/api/v1/users/2/games/iidx/SP/targets/quests/fake_quest`) .set("Cookie", cookie); t.equal(res.statusCode, 403); @@ -190,13 +190,13 @@ t.test( ); t.test( - "DELETE /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID", + "DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID", async (t) => { const cookie = await CreateFakeAuthCookie(mockApi); - t.test("Should return 404 if the user is not subscribed to this milestone.", async (t) => { + t.test("Should return 404 if the user is not subscribed to this quest.", async (t) => { const res = await mockApi - .delete(`/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone`) + .delete(`/api/v1/users/1/games/iidx/SP/targets/quests/fake_quest`) .set("Cookie", cookie); t.equal(res.statusCode, 404); @@ -206,7 +206,7 @@ t.test( t.test("Should return 401 if the user is not authed.", async (t) => { const res = await mockApi.delete( - `/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone` + `/api/v1/users/1/games/iidx/SP/targets/quests/fake_quest` ); t.equal(res.statusCode, 401); @@ -230,7 +230,7 @@ t.test( } as UserGameStats); const res = await mockApi - .delete(`/api/v1/users/2/games/iidx/SP/targets/milestones/fake_milestone`) + .delete(`/api/v1/users/2/games/iidx/SP/targets/quests/fake_quest`) .set("Cookie", cookie); t.equal(res.statusCode, 403); @@ -238,27 +238,27 @@ t.test( t.end(); }); - t.test("Should unsubscribe from a milestone.", async (t) => { - await db.milestones.insert(TestingIIDXSPMilestone); - await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + t.test("Should unsubscribe from a quest.", async (t) => { + await db.quests.insert(TestingIIDXSPQuest); + await db["quest-subs"].insert(TestingIIDXSPQuestSub); const res = await mockApi .delete( - `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + `/api/v1/users/1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}` ) .set("Cookie", cookie); t.equal(res.statusCode, 200); - delete TestingIIDXSPMilestone._id; - t.strictSame(res.body.body.milestone, TestingIIDXSPMilestone); + delete TestingIIDXSPQuest._id; + t.strictSame(res.body.body.quest, TestingIIDXSPQuest); - const dbRes = await db["milestone-subs"].findOne({ + const dbRes = await db["quest-subs"].findOne({ userID: 1, - milestoneID: TestingIIDXSPMilestone.milestoneID, + questID: TestingIIDXSPQuest.questID, }); - t.equal(dbRes, null, "Should have removed the milestone from the database."); + t.equal(dbRes, null, "Should have removed the quest from the database."); t.end(); }); 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 new file mode 100644 index 000000000..1cd06f22a --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -0,0 +1,231 @@ +import { RequireAuthedAsUser } from "../../../../../middleware"; +import { Router } from "express"; +import db from "external/mongo/db"; +import { SubscribeFailReasons } from "lib/constants/err-codes"; +import CreateLogCtx from "lib/logger/logger"; +import { ServerConfig } from "lib/setup/config"; +import { + EvaluateQuestProgress, + SubscribeToQuest, + UnsubscribeFromQuest, +} from "lib/targets/quests"; +import { RequirePermissions } from "server/middleware/auth"; +import { AssignToReqTachiData, GetGPT, GetTachiData, GetUGPT } from "utils/req-tachi-data"; +import { FormatUserDoc } from "utils/user"; +import type { RequestHandler } from "express"; + +const logger = CreateLogCtx(__filename); + +const router: Router = Router({ mergeParams: true }); + +/** + * Retrieves this user's subscribed quests. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/quests + */ +router.get("/", async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const questSubs = await db["quest-subs"].find({ + userID: user.id, + game, + playtype, + }); + + const quests = await db.quests.find({ + questID: { $in: questSubs.map((e) => e.questID) }, + }); + + if (quests.length !== questSubs.length) { + logger.error( + `Found ${questSubs.length} subscriptions, but got ${quests.length} parents. This is a state desync.` + ); + throw new Error("Failed to fetch quests"); + } + + return res.status(200).json({ + success: true, + description: `Retrieved ${questSubs.length} quest(s).`, + body: { + quests, + questSubs, + }, + }); +}); + +const GetQuestSubscription: RequestHandler = async (req, res, next) => { + const { user, game, playtype } = GetUGPT(req); + + const questSub = await db["quest-subs"].findOne({ + userID: user.id, + game, + playtype, + questID: req.params.questID, + }); + + if (!questSub) { + return res.status(404).json({ + success: false, + description: `${user.username} is not subscribed to this quest.`, + }); + } + + AssignToReqTachiData(req, { questSubDoc: questSub }); + + next(); +}; + +const GetQuest: RequestHandler = async (req, res, next) => { + const { game, playtype } = GetGPT(req); + + const quest = await db.quests.findOne({ + game, + playtype, + questID: req.params.questID, + }); + + if (!quest) { + return res.status(404).json({ + success: false, + description: `Can't find a quest with id '${req.params.questID}'.`, + }); + } + + AssignToReqTachiData(req, { questDoc: quest }); + + next(); +}; + +/** + * Returns this user's progress on this quest. + * This also evaluates individual progress on all of the quests goals. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID + */ +router.get("/:questID", GetQuest, GetQuestSubscription, async (req, res) => { + const { user } = GetUGPT(req); + + const questSub = GetTachiData(req, "questSubDoc"); + const quest = GetTachiData(req, "questDoc"); + + // Evaluate each goal for the user. This operation is much faster if the user is + // subscribed to the quest (they are), as we can just read their goalSub + // for each goal. + const { goalResults: results, goals } = await EvaluateQuestProgress(user.id, quest); + + return res.status(200).json({ + success: true, + description: `Returned information about ${FormatUserDoc(user)}'s progress on ${ + quest.name + }.`, + body: { + questSub, + quest, + results, + goals, + }, + }); +}); + +/** + * Subscribe to a quest. + * + * @name PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID + */ +router.put( + "/:questID", + RequireAuthedAsUser, + GetQuest, + RequirePermissions("manage_targets"), + async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const existingQuestsCount = await db["quest-subs"].count({ + userID: user.id, + game, + playtype, + }); + + if (existingQuestsCount > ServerConfig.MAX_MILESTONE_SUBSCRIPTIONS) { + return res.status(400).json({ + success: false, + description: `You already have ${ServerConfig.MAX_MILESTONE_SUBSCRIPTIONS} quests. You cannot have anymore for this game.`, + }); + } + + const quest = GetTachiData(req, "questDoc"); + + const alreadySubscibed = await db["quest-subs"].findOne({ + userID: user.id, + questID: quest.questID, + }); + + if (alreadySubscibed) { + return res.status(409).json({ + success: false, + description: `You are already subscribed to this goal.`, + }); + } + + const subResult = await SubscribeToQuest(user.id, quest, false); + + // Users should be able to subscribe to quests EVEN IF they would instantly + // achieve them. + + // if (subResult === SubscribeFailReasons.ALREADY_ACHIEVED) { + // return res.status(400).json({ + // success: false, + // description: `You cannot assign a quest that would be immediately achieved.`, + // }); + // } + + if (subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED) { + return res.status(409).json({ + success: false, + description: `You're already subscribed to this quest.`, + }); + } + + return res.status(200).json({ + success: true, + description: `Subscribed to quest '${quest.name}'.`, + body: { ...subResult, quest }, + }); + } +); + +/** + * Unsubscribe from a quest. + * + * @name DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID + */ +router.delete( + "/:questID", + RequireAuthedAsUser, + GetQuest, + RequirePermissions("manage_targets"), + async (req, res) => { + const { user } = GetUGPT(req); + const quest = GetTachiData(req, "questDoc"); + + logger.info( + `User ${FormatUserDoc(user)} is unsubscribing from quest '${quest.name}'.`, + { + quest, + user, + } + ); + + await UnsubscribeFromQuest(user.id, quest.questID); + + return res.status(200).json({ + success: true, + description: `Unsubscribed from quest.`, + body: { + quest, + }, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts index 8608e2943..79fa94ea8 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts @@ -6,14 +6,14 @@ import ResetDBState from "test-utils/resets"; import { HC511Goal, HC511UserGoal, - TestingIIDXSPMilestone, - TestingIIDXSPMilestoneSub, + TestingIIDXSPQuest, + TestingIIDXSPQuestSub, } from "test-utils/test-data"; import type { GoalDocument, GoalSubscriptionDocument, - MilestoneDocument, - MilestoneSubscriptionDocument, + QuestDocument, + QuestSubscriptionDocument, } from "tachi-common"; function mkGoalSub(merge: Partial) { @@ -24,12 +24,12 @@ function mkGoal(merge: Partial) { return dm(HC511Goal, merge); } -function mkMilestone(merge: Partial) { - return dm(TestingIIDXSPMilestone, merge); +function mkQuest(merge: Partial) { + return dm(TestingIIDXSPQuest, merge); } -function mkMilestoneSub(merge: Partial) { - return dm(TestingIIDXSPMilestoneSub, merge); +function mkQuestSub(merge: Partial) { + return dm(TestingIIDXSPQuestSub, merge); } t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved", (t) => { @@ -57,21 +57,21 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieve }), ]); - await db.milestones.insert([ - mkMilestone({}), - mkMilestone({ - milestoneID: "not_achieved_milestone", + await db.quests.insert([ + mkQuest({}), + mkQuest({ + questID: "not_achieved_quest", }), ]); - await db["milestone-subs"].insert([ - mkMilestoneSub({ + await db["quest-subs"].insert([ + mkQuestSub({ achieved: true, timeAchieved: 2000, }), - mkMilestoneSub({ + mkQuestSub({ achieved: false, - milestoneID: "not_achieved_milestone", + questID: "not_achieved_quest", }), ]); @@ -80,17 +80,17 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieve t.equal(res.statusCode, 200); t.hasStrict(res.body.body, { - milestones: [{ milestoneID: "example_milestone_id" }], + quests: [{ questID: "example_quest_id" }], goals: [{ goalID: "achieved_goal" }], goalSubs: [{ goalID: "achieved_goal" }], - milestoneSubs: [{ milestoneID: "example_milestone_id" }], + questSubs: [{ questID: "example_quest_id" }], user: { id: 1 }, }); t.equal(res.body.body.goals.length, 1); - t.equal(res.body.body.milestones.length, 1); + t.equal(res.body.body.quests.length, 1); t.equal(res.body.body.goalSubs.length, 1); - t.equal(res.body.body.milestoneSubs.length, 1); + t.equal(res.body.body.questSubs.length, 1); t.end(); }); @@ -124,22 +124,22 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised" }), ]); - await db.milestones.insert([ - mkMilestone({}), - mkMilestone({ - milestoneID: "not_achieved_milestone", + await db.quests.insert([ + mkQuest({}), + mkQuest({ + questID: "not_achieved_quest", }), ]); - await db["milestone-subs"].insert([ - mkMilestoneSub({ + await db["quest-subs"].insert([ + mkQuestSub({ achieved: true, timeAchieved: 2000, }), - mkMilestoneSub({ + mkQuestSub({ achieved: false, lastInteraction: 1000, - milestoneID: "not_achieved_milestone", + questID: "not_achieved_quest", }), ]); @@ -148,10 +148,10 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised" t.equal(res.statusCode, 200); t.hasStrict(res.body.body, { - milestones: [{ milestoneID: "not_achieved_milestone" }], + quests: [{ questID: "not_achieved_quest" }], goals: [{ goalID: "not_achieved_goal" }], goalSubs: [{ goalID: "not_achieved_goal" }], - milestoneSubs: [{ milestoneID: "not_achieved_milestone" }], + questSubs: [{ questID: "not_achieved_quest" }], user: { id: 1 }, }); 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 5fa1cd4ae..5017d616c 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 @@ -1,18 +1,18 @@ import goalsRouter from "./goals/router"; -import milestonesRouter from "./milestones/router"; +import questsRouter from "./quests/router"; import { Router } from "express"; import { GetRecentlyAchievedGoals, - GetRecentlyAchievedMilestones, + GetRecentlyAchievedQuests, GetRecentlyInteractedGoals, - GetRecentlyInteractedMilestones, + GetRecentlyInteractedQuests, } from "utils/db"; import { GetUGPT } from "utils/req-tachi-data"; const router: Router = Router({ mergeParams: true }); /** - * Return a user's recently achieved goals and milestones. + * Return a user's recently achieved goals and quests. * * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved */ @@ -21,9 +21,9 @@ router.get("/recently-achieved", async (req, res) => { const userID = user.id; - const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ GetRecentlyAchievedGoals({ userID, game, playtype }), - GetRecentlyAchievedMilestones({ userID, game, playtype }), + GetRecentlyAchievedQuests({ userID, game, playtype }), ]); return res.status(200).json({ @@ -31,16 +31,16 @@ router.get("/recently-achieved", async (req, res) => { description: `Returned ${user.username}'s recently achieved targets.`, body: { goals, - milestones, + quests, goalSubs, - milestoneSubs, + questSubs, user, }, }); }); /** - * Returns a user's recently interacted with (raised, etc.) goals and milestones. + * Returns a user's recently interacted with (raised, etc.) goals and quests. * Note that this does not include recently achieved. * * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised @@ -50,9 +50,9 @@ router.get("/recently-raised", async (req, res) => { const userID = user.id; - const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([ GetRecentlyInteractedGoals({ userID, game, playtype }), - GetRecentlyInteractedMilestones({ userID, game, playtype }), + GetRecentlyInteractedQuests({ userID, game, playtype }), ]); return res.status(200).json({ @@ -60,15 +60,15 @@ router.get("/recently-raised", async (req, res) => { description: `Returned ${user.username}'s recently achieved targets.`, body: { goals, - milestones, + quests, goalSubs, - milestoneSubs, + questSubs, user, }, }); }); router.use("/goals", goalsRouter); -router.use("/milestones", milestonesRouter); +router.use("/quests", questsRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/router.test.ts b/server/src/server/router/api/v1/users/_userID/router.test.ts index 367a7e72f..29f90fc66 100644 --- a/server/src/server/router/api/v1/users/_userID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/router.test.ts @@ -403,7 +403,7 @@ t.test("GET /api/v1/users/:userID/recent-imports", (t) => { importID: Random20Hex(), goalInfo: [], idStrings: [], - milestoneInfo: [], + questInfo: [], playtypes: [], scoreIDs: [], timeFinished, diff --git a/server/src/test-utils/mock-db/milestone-sets.json b/server/src/test-utils/mock-db/quest-subs.json similarity index 100% rename from server/src/test-utils/mock-db/milestone-sets.json rename to server/src/test-utils/mock-db/quest-subs.json diff --git a/server/src/test-utils/mock-db/milestone-subs.json b/server/src/test-utils/mock-db/questlines.json similarity index 100% rename from server/src/test-utils/mock-db/milestone-subs.json rename to server/src/test-utils/mock-db/questlines.json diff --git a/server/src/test-utils/mock-db/milestones.json b/server/src/test-utils/mock-db/quests.json similarity index 100% rename from server/src/test-utils/mock-db/milestones.json rename to server/src/test-utils/mock-db/quests.json diff --git a/server/src/test-utils/setup.ts b/server/src/test-utils/setup.ts index 75ec9495d..191865eef 100644 --- a/server/src/test-utils/setup.ts +++ b/server/src/test-utils/setup.ts @@ -4,4 +4,9 @@ import { monkDB } from "external/mongo/db"; SetIndexesForDB() .then(monkDB.close) .then(() => process.exit(0)) - .catch(() => process.exit(1)); + .catch((err) => { + // we might not *have* a working logger here + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 62ffa593d..4e53527ed 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -26,8 +26,8 @@ import type { GoalDocument, GoalSubscriptionDocument, ImportDocument, - MilestoneDocument, - MilestoneSubscriptionDocument, + QuestDocument, + QuestSubscriptionDocument, NotificationDocument, PBScoreDocument, PublicUserDocument, @@ -528,16 +528,16 @@ export const TestingIIDXFolderSP10: FolderDocument = { inactive: false, }; -export const TestingIIDXSPMilestone: MilestoneDocument = { +export const TestingIIDXSPQuest: QuestDocument = { criteria: { type: "all", }, - desc: "testing milestone", + desc: "testing quest", game: "iidx", playtype: "SP", - milestoneID: "example_milestone_id", - name: "Example Milestone", - milestoneData: [ + questID: "example_quest_id", + name: "Example Quest", + questData: [ { title: "Group1", desc: "Foo", @@ -565,27 +565,27 @@ export const TestingIIDXSPMilestone: MilestoneDocument = { ], }; -export const IIDXSPMilestoneGoals: Array = [ +export const IIDXSPQuestGoals: Array = [ dm(HC511Goal, { goalID: "eg_goal_1" }) as GoalDocument, dm(HC511Goal, { goalID: "eg_goal_2", criteria: { value: 2 } }), dm(HC511Goal, { goalID: "eg_goal_3", criteria: { key: "scoreData.score", value: 300 } }), dm(HC511Goal, { goalID: "eg_goal_4", criteria: { key: "scoreData.score", value: 1100 } }), ]; -export const IIDXSPMilestoneGoalSubs: Array = [ +export const IIDXSPQuestGoalSubs: Array = [ dm(HC511UserGoal, { goalID: "eg_goal_1" }) as GoalSubscriptionDocument, dm(HC511UserGoal, { goalID: "eg_goal_2" }) as GoalSubscriptionDocument, dm(HC511UserGoal, { goalID: "eg_goal_3" }) as GoalSubscriptionDocument, dm(HC511UserGoal, { goalID: "eg_goal_4" }) as GoalSubscriptionDocument, ]; -export const TestingIIDXSPMilestoneSub: MilestoneSubscriptionDocument = { +export const TestingIIDXSPQuestSub: QuestSubscriptionDocument = { userID: 1, achieved: false, game: "iidx", playtype: "SP", lastInteraction: null, - milestoneID: "example_milestone_id", + questID: "example_quest_id", progress: 4, timeAchieved: null, timeSet: 1900, @@ -762,7 +762,7 @@ export const FakeImport: ImportDocument = { idStrings: ["iidx:SP"], importID: "fake_import", importType: "ir/direct-manual", - milestoneInfo: [], + questInfo: [], playtypes: ["SP"], scoreIDs: [TestingIIDXSPScore.scoreID], timeFinished: 1000, @@ -778,9 +778,9 @@ export const FakeNotification: NotificationDocument = { sentAt: 1000, sentTo: 1, body: { - type: "MILESTONE_CHANGED", + type: "QUEST_CHANGED", content: { - milestoneID: "a", + questID: "a", }, }, }; diff --git a/server/src/utils/calculations/recalc-scores.ts b/server/src/utils/calculations/recalc-scores.ts index c284d0caf..162228853 100644 --- a/server/src/utils/calculations/recalc-scores.ts +++ b/server/src/utils/calculations/recalc-scores.ts @@ -4,7 +4,7 @@ import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; import { CreateCalculatedData } from "lib/score-import/framework/calculated-data/calculated-data"; import { GetAndUpdateUsersGoals } from "lib/score-import/framework/goals/goals"; -import { UpdateUsersMilestones } from "lib/score-import/framework/milestones/milestones"; +import { UpdateUsersQuests } from "lib/score-import/framework/quests/quests"; import { ProcessPBs } from "lib/score-import/framework/pb/process-pbs"; import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/user-game-stats/update-ugs"; import { TachiConfig } from "lib/setup/config"; @@ -77,7 +77,7 @@ export async function RecalcAllScores(filter = {}) { const goalInfo = await GetAndUpdateUsersGoals(game, userID, chartIDs, logger); - await UpdateUsersMilestones(goalInfo, game, [playtype], userID, logger); + await UpdateUsersQuests(goalInfo, game, [playtype], userID, logger); } logger.info(`Done!`); diff --git a/server/src/utils/db.ts b/server/src/utils/db.ts index d05806fc4..63f4483c8 100644 --- a/server/src/utils/db.ts +++ b/server/src/utils/db.ts @@ -7,9 +7,9 @@ import type { GoalDocument, GoalSubscriptionDocument, integer, - MilestoneDocument, - MilestoneSetDocument, - MilestoneSubscriptionDocument, + QuestDocument, + QuestlineDocument, + QuestSubscriptionDocument, PBScoreDocument, ScoreDocument, } from "tachi-common"; @@ -151,14 +151,14 @@ export async function GetGoalForIDGuaranteed(goalID: string) { return goal; } -export async function GetMilestoneForIDGuaranteed(milestoneID: string) { - const milestone = await db.milestones.findOne({ milestoneID }); +export async function GetQuestForIDGuaranteed(questID: string) { + const quest = await db.quests.findOne({ questID }); - if (!milestone) { - throw new Error(`Couldn't find milestone with ID ${milestoneID}`); + if (!quest) { + throw new Error(`Couldn't find quest with ID ${questID}`); } - return milestone; + return quest; } export async function HumaniseChartID(game: Game, chartID: string) { @@ -248,14 +248,14 @@ export async function GetRecentlyInteractedGoals( } /** - * Get recently achieved goals for this query. + * Get recently achieved quests for this query. * * @param baseQuery - A base query, used to limit results on GPTs or UGPTs. * @param limit - How many recently achieved goals to search for. - * @returns - The goals and their subs. + * @returns - The quests and their subs. */ -export async function GetRecentlyAchievedMilestones( - baseQuery: Omit, "achieved">, +export async function GetRecentlyAchievedQuests( + baseQuery: Omit, "achieved">, limit = 100 ) { const query = { @@ -264,37 +264,37 @@ export async function GetRecentlyAchievedMilestones( achieved: true, }; - const milestoneSubs = await db["milestone-subs"].find(query, { + const questSubs = await db["quest-subs"].find(query, { sort: { timeAchieved: -1, }, limit, }); - const milestones = await db.milestones.find({ - milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, + const quests = await db.quests.find({ + questID: { $in: questSubs.map((e) => e.questID) }, }); - if (milestones.length !== milestoneSubs.length) { + if (quests.length !== questSubs.length) { logger.error( - `Found ${milestones.length} milestones when looking for parents of ${milestoneSubs.length} subscriptions. This mismatch implies a state desync.` + `Found ${quests.length} quests when looking for parents of ${questSubs.length} subscriptions. This mismatch implies a state desync.` ); - throw new Error("Failed to fetch milestones."); + throw new Error("Failed to fetch quests."); } - return { milestones, milestoneSubs }; + return { quests, questSubs }; } /** - * Get recently interacted-with milestones for this query. + * Get recently interacted-with quests for this query. * * @param baseQuery - A base query, used to limit results on GPTs or UGPTs. - * @param limit - How many recently achieved milestones to search for. - * @returns - The milestones and their subs. + * @param limit - How many recently achieved quests to search for. + * @returns - The quests and their subs. */ -export async function GetRecentlyInteractedMilestones( - baseQuery: Omit, "achieved">, +export async function GetRecentlyInteractedQuests( + baseQuery: Omit, "achieved">, limit = 100 ) { const query = { @@ -304,26 +304,26 @@ export async function GetRecentlyInteractedMilestones( wasInstantlyAchieved: false, }; - const milestoneSubs = await db["milestone-subs"].find(query, { + const questSubs = await db["quest-subs"].find(query, { sort: { lastInteraction: -1, }, limit, }); - const milestones = await db.milestones.find({ - milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, + const quests = await db.quests.find({ + questID: { $in: questSubs.map((e) => e.questID) }, }); - if (milestones.length !== milestoneSubs.length) { + if (quests.length !== questSubs.length) { logger.error( - `Found ${milestones.length} milestones when looking for parents of ${milestoneSubs.length} subscriptions. This mismatch implies a state desync.` + `Found ${quests.length} quests when looking for parents of ${questSubs.length} subscriptions. This mismatch implies a state desync.` ); - throw new Error("Failed to fetch milestones."); + throw new Error("Failed to fetch quests."); } - return { milestones, milestoneSubs }; + return { quests, questSubs }; } export async function GetMostSubscribedGoals( @@ -374,18 +374,18 @@ export async function GetMostSubscribedGoals( })); } -export async function GetMostSubscribedMilestones( - query: FilterQuery, +export async function GetMostSubscribedQuests( + query: FilterQuery, limit = 100 -): Promise> { - const mostSubscribedMilesones: Array<{ milestone: MilestoneDocument; subscriptions: integer }> = - await db["milestone-subs"].aggregate([ +): Promise> { + const mostSubscribedMilesones: Array<{ quest: QuestDocument; subscriptions: integer }> = + await db["quest-subs"].aggregate([ { $match: query, }, { $group: { - _id: "$milestoneID", + _id: "$questID", subscriptions: { $sum: 1 }, }, }, @@ -399,40 +399,40 @@ export async function GetMostSubscribedMilestones( }, { $lookup: { - from: "milestones", + from: "quests", localField: "_id", - foreignField: "milestoneID", - as: "milestone", + foreignField: "questID", + as: "quest", }, }, { $set: { - milestone: { $arrayElemAt: ["$milestone", 0] }, + quest: { $arrayElemAt: ["$quest", 0] }, }, }, { - $unset: "milestone._id", + $unset: "quest._id", }, ]); return mostSubscribedMilesones.map((e) => ({ __subscriptions: e.subscriptions, - ...e.milestone, + ...e.quest, })); } -export async function GetChildMilestones(milestoneSet: MilestoneSetDocument) { - const milestones = await db.milestones.find({ - milestoneID: { $in: milestoneSet.milestones }, +export async function GetChildQuests(questline: QuestlineDocument) { + const quests = await db.quests.find({ + questID: { $in: questline.quests }, }); - if (milestones.length !== milestoneSet.milestones.length) { + if (quests.length !== questline.quests.length) { logger.error( - `Expected to find ${milestoneSet.milestones.length} milestones in the database, but only found ${milestones.length}.`, - { milestoneSet } + `Expected to find ${questline.quests.length} quests in the database, but only found ${quests.length}.`, + { questline } ); - throw new Error(`Failed to retrieve milestone sets' children.`); + throw new Error(`Failed to retrieve quest sets' children.`); } - return milestones; + return quests; } diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index 883eae922..727632216 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -12,10 +12,10 @@ import type { UserSettings, TachiAPIClientDocument, GoalDocument, - MilestoneSubscriptionDocument, + QuestSubscriptionDocument, GoalSubscriptionDocument, - MilestoneDocument, - MilestoneSetDocument, + QuestDocument, + QuestlineDocument, integer, ImportDocument, } from "tachi-common"; @@ -83,10 +83,10 @@ export interface TachiRequestData { tableDoc?: TableDocument; folderDoc?: FolderDocument; goalDoc?: GoalDocument; - milestoneDoc?: MilestoneDocument; + questDoc?: QuestDocument; goalSubDoc?: GoalSubscriptionDocument; - milestoneSubDoc?: MilestoneSubscriptionDocument; - milestoneSetDoc?: MilestoneSetDocument; + questSubDoc?: QuestSubscriptionDocument; + questlineDoc?: QuestlineDocument; importDoc?: ImportDocument; apiClientDoc: Omit;