diff --git a/config.json.example b/config.json.example index d75d619f..11da5729 100644 --- a/config.json.example +++ b/config.json.example @@ -69,6 +69,7 @@ "affinityBoost": false, "resourceBoost": false, "starDays": true, + "galleonOfGhouls": 0, "eidolonOverride": "", "vallisOverride": "", "duviriOverride": "", diff --git a/package-lock.json b/package-lock.json index d3d0207d..70184b5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "ncp": "^2.0.0", "typescript": "^5.5", "undici": "^7.10.0", - "warframe-public-export-plus": "^0.5.69", + "warframe-public-export-plus": "^0.5.70", "warframe-riven-info": "^0.1.2", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", @@ -3396,9 +3396,9 @@ } }, "node_modules/warframe-public-export-plus": { - "version": "0.5.69", - "resolved": "https://registry.npmjs.org/warframe-public-export-plus/-/warframe-public-export-plus-0.5.69.tgz", - "integrity": "sha512-vTU1tUzqpihzpseUSJMrM82pYbCDZCfW40jXIi+Ol9B3a3Acz0DccfP7i4eoXf7Abahu4H/sjRt/nSHLNBvLHA==" + "version": "0.5.70", + "resolved": "https://registry.npmjs.org/warframe-public-export-plus/-/warframe-public-export-plus-0.5.70.tgz", + "integrity": "sha512-d5dQ/a0rakQnW9tl1HitST8439jDvEgMhkkntQIw7HmdM7s7mvIxvaYSl5wjlYawpUVfGyvGBdZVoAJ7kkQRWw==" }, "node_modules/warframe-riven-info": { "version": "0.1.2", diff --git a/package.json b/package.json index ce1614ba..063cd966 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "ncp": "^2.0.0", "typescript": "^5.5", "undici": "^7.10.0", - "warframe-public-export-plus": "^0.5.69", + "warframe-public-export-plus": "^0.5.70", "warframe-riven-info": "^0.1.2", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", diff --git a/src/controllers/api/inventoryController.ts b/src/controllers/api/inventoryController.ts index ecc32ec3..3880186e 100644 --- a/src/controllers/api/inventoryController.ts +++ b/src/controllers/api/inventoryController.ts @@ -22,7 +22,8 @@ import { getNemesisManifest } from "@/src/helpers/nemesisHelpers"; import { getPersonalRooms } from "@/src/services/personalRoomsService"; import { IPersonalRoomsClient } from "@/src/types/personalRoomsTypes"; import { Ship } from "@/src/models/shipModel"; -import { toLegacyOid, version_compare } from "@/src/helpers/inventoryHelpers"; +import { toLegacyOid, toOid, version_compare } from "@/src/helpers/inventoryHelpers"; +import { Inbox } from "@/src/models/inboxModel"; export const inventoryController: RequestHandler = async (request, response) => { const account = await getAccountForRequest(request); @@ -128,13 +129,21 @@ export const getInventoryResponse = async ( xpBasedLevelCapDisabled: boolean, buildLabel: string | undefined ): Promise => { - const [inventoryWithLoadOutPresets, ships] = await Promise.all([ + const [inventoryWithLoadOutPresets, ships, latestMessage] = await Promise.all([ inventory.populate<{ LoadOutPresets: ILoadoutDatabase }>("LoadOutPresets"), - Ship.find({ ShipOwnerId: inventory.accountOwnerId }) + Ship.find({ ShipOwnerId: inventory.accountOwnerId }), + Inbox.findOne({ ownerId: inventory.accountOwnerId }, "_id").sort({ date: -1 }) ]); const inventoryResponse = inventoryWithLoadOutPresets.toJSON(); inventoryResponse.Ships = ships.map(x => x.toJSON()); + // In case mission inventory update added an inbox message, we need to send the Mailbox part so the client knows to refresh it. + if (latestMessage) { + inventoryResponse.Mailbox = { + LastInboxId: toOid(latestMessage._id) + }; + } + if (config.infiniteCredits) { inventoryResponse.RegularCredits = 999999999; } diff --git a/src/index.ts b/src/index.ts index 7afd9387..f9d671a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,13 +21,14 @@ import mongoose from "mongoose"; import { JSONStringify } from "json-with-bigint"; import { startWebServer } from "./services/webService"; -import { validateConfig } from "@/src/services/configWatcherService"; +import { syncConfigWithDatabase, validateConfig } from "@/src/services/configWatcherService"; import { updateWorldStateCollections } from "./services/worldStateService"; // Patch JSON.stringify to work flawlessly with Bigints. JSON.stringify = JSONStringify; validateConfig(); +syncConfigWithDatabase(); mongoose .connect(config.mongodbUrl) diff --git a/src/models/inboxModel.ts b/src/models/inboxModel.ts index d339707e..139e8b44 100644 --- a/src/models/inboxModel.ts +++ b/src/models/inboxModel.ts @@ -27,11 +27,12 @@ export interface IMessage { icon?: string; highPriority?: boolean; lowPrioNewPlayers?: boolean; - startDate?: Date; - endDate?: Date; + transmission?: string; att?: string[]; countedAtt?: ITypeCount[]; - transmission?: string; + startDate?: Date; + endDate?: Date; + goalTag?: string; CrossPlatform?: boolean; arg?: Arg[]; gifts?: IGift[]; @@ -107,6 +108,7 @@ const messageSchema = new Schema( lowPrioNewPlayers: Boolean, startDate: Date, endDate: Date, + goalTag: String, date: { type: Date, required: true }, r: Boolean, CrossPlatform: Boolean, diff --git a/src/models/inventoryModels/inventoryModel.ts b/src/models/inventoryModels/inventoryModel.ts index 0daeb2c1..9b91c7ca 100644 --- a/src/models/inventoryModels/inventoryModel.ts +++ b/src/models/inventoryModels/inventoryModel.ts @@ -1,4 +1,4 @@ -import { Document, HydratedDocument, Model, Schema, Types, model } from "mongoose"; +import { Document, Model, Schema, Types, model } from "mongoose"; import { IFlavourItem, IRawUpgrade, @@ -7,7 +7,6 @@ import { IBooster, IInventoryClient, ISlots, - IMailboxDatabase, IDuviriInfo, IPendingRecipeDatabase, IPendingRecipeClient, @@ -54,7 +53,6 @@ import { IUpgradeDatabase, ICrewShipMemberDatabase, ICrewShipMemberClient, - IMailboxClient, TEquipmentKey, equipmentKeys, IKubrowPetDetailsDatabase, @@ -99,7 +97,9 @@ import { IAccolades, IHubNpcCustomization, ILotusCustomization, - IEndlessXpReward + IEndlessXpReward, + IPersonalGoalProgressDatabase, + IPersonalGoalProgressClient } from "../../types/inventoryTypes/inventoryTypes"; import { IOid } from "../../types/commonTypes"; import { @@ -371,7 +371,7 @@ FlavourItemSchema.set("toJSON", { } }); -const MailboxSchema = new Schema( +/*const MailboxSchema = new Schema( { LastInboxId: Schema.Types.ObjectId }, @@ -384,7 +384,7 @@ MailboxSchema.set("toJSON", { delete mailboxDatabase.__v; (returnedObject as IMailboxClient).LastInboxId = toOid(mailboxDatabase.LastInboxId); } -}); +});*/ const DuviriInfoSchema = new Schema( { @@ -457,6 +457,29 @@ const discoveredMarkerSchema = new Schema( { _id: false } ); +const personalGoalProgressSchema = new Schema( + { + Best: Number, + Count: Number, + Tag: String, + goalId: Types.ObjectId + }, + { _id: false } +); + +personalGoalProgressSchema.set("toJSON", { + virtuals: true, + transform(_doc, obj) { + const db = obj as IPersonalGoalProgressDatabase; + const client = obj as IPersonalGoalProgressClient; + + client._id = toOid(db.goalId); + + delete obj.goalId; + delete obj.__v; + } +}); + const challengeProgressSchema = new Schema( { Progress: Number, @@ -1630,7 +1653,7 @@ const inventorySchema = new Schema( //CompletedJobs: [Schema.Types.Mixed], //Game mission\ivent score example "Tag": "WaterFight", "Best": 170, "Count": 1258, - //PersonalGoalProgress: [Schema.Types.Mixed], + PersonalGoalProgress: { type: [personalGoalProgressSchema], default: undefined }, //Setting interface Style ThemeStyle: String, @@ -1701,7 +1724,7 @@ const inventorySchema = new Schema( //Unknown and system DuviriInfo: DuviriInfoSchema, LastInventorySync: Schema.Types.ObjectId, - Mailbox: MailboxSchema, + //Mailbox: MailboxSchema, HandlerPoints: Number, ChallengesFixVersion: Number, PlayedParkourTutorial: Boolean, diff --git a/src/services/configService.ts b/src/services/configService.ts index 404e4b49..fe74a584 100644 --- a/src/services/configService.ts +++ b/src/services/configService.ts @@ -76,6 +76,7 @@ export interface IConfig { affinityBoost?: boolean; resourceBoost?: boolean; starDays?: boolean; + galleonOfGhouls?: number; eidolonOverride?: string; vallisOverride?: string; duviriOverride?: string; diff --git a/src/services/configWatcherService.ts b/src/services/configWatcherService.ts index bb64d5da..8df1acd9 100644 --- a/src/services/configWatcherService.ts +++ b/src/services/configWatcherService.ts @@ -3,6 +3,7 @@ import fsPromises from "fs/promises"; import { logger } from "../utils/logger"; import { config, configPath, loadConfig } from "./configService"; import { getWebPorts, sendWsBroadcast, startWebServer, stopWebServer } from "./webService"; +import { Inbox } from "../models/inboxModel"; let amnesia = false; fs.watchFile(configPath, (now, then) => { @@ -22,6 +23,7 @@ fs.watchFile(configPath, (now, then) => { process.exit(1); } validateConfig(); + syncConfigWithDatabase(); const webPorts = getWebPorts(); if (config.httpPort != webPorts.http || config.httpsPort != webPorts.https) { @@ -51,6 +53,15 @@ export const validateConfig = (): void => { } } } + if ( + config.worldState?.galleonOfGhouls && + config.worldState.galleonOfGhouls != 1 && + config.worldState.galleonOfGhouls != 2 && + config.worldState.galleonOfGhouls != 3 + ) { + config.worldState.galleonOfGhouls = 0; + modified = true; + } if (modified) { logger.info(`Updating config file to fix some issues with it.`); void saveConfig(); @@ -61,3 +72,10 @@ export const saveConfig = async (): Promise => { amnesia = true; await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); }; + +export const syncConfigWithDatabase = (): void => { + // Event messages are deleted after endDate. Since we don't use beginDate/endDate and instead have config toggles, we need to delete the messages once those bools are false. + if (!config.worldState?.galleonOfGhouls) { + void Inbox.deleteMany({ goalTag: "GalleonRobbery" }).then(() => {}); // For some reason, I can't just do `Inbox.deleteMany(...)`; it needs this whole circus. + } +}; diff --git a/src/services/inboxService.ts b/src/services/inboxService.ts index cc5afc29..d623030d 100644 --- a/src/services/inboxService.ts +++ b/src/services/inboxService.ts @@ -54,6 +54,22 @@ export const createNewEventMessages = async (req: Request): Promise => { }); } + // BUG: Deleting the inbox message manually means it'll just be automatically re-created. This is because we don't use startDate/endDate for these config-toggled events. + if (config.worldState?.galleonOfGhouls) { + if (!(await Inbox.exists({ ownerId: account._id, goalTag: "GalleonRobbery" }))) { + newEventMessages.push({ + sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek", + sub: "/Lotus/Language/Events/GalleonRobberyIntroMsgTitle", + msg: "/Lotus/Language/Events/GalleonRobberyIntroMsgDesc", + icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png", + transmission: "/Lotus/Sounds/Dialog/GalleonOfGhouls/DGhoulsWeekOneInbox0010VayHek", + att: ["/Lotus/Upgrades/Skins/Events/OgrisOldSchool"], + startDate: new Date(), + goalTag: "GalleonRobbery" + }); + } + } + if (newEventMessages.length === 0) { return; } diff --git a/src/services/missionInventoryUpdateService.ts b/src/services/missionInventoryUpdateService.ts index 6ab8b4e9..ec3ee941 100644 --- a/src/services/missionInventoryUpdateService.ts +++ b/src/services/missionInventoryUpdateService.ts @@ -46,7 +46,7 @@ import { import { updateQuestKey } from "@/src/services/questService"; import { Types } from "mongoose"; import { IAffiliationMods, IInventoryChanges } from "@/src/types/purchaseTypes"; -import { fromStoreItem, getLevelKeyRewards, toStoreItem } from "@/src/services/itemDataService"; +import { fromStoreItem, getLevelKeyRewards, isStoreItem, toStoreItem } from "@/src/services/itemDataService"; import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel"; import { getEntriesUnsafe } from "@/src/utils/ts-utils"; import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes"; @@ -609,6 +609,47 @@ export const addMissionInventoryUpdates = async ( inventoryChanges.RegularCredits -= value; break; } + case "GoalProgress": { + for (const uploadProgress of value) { + const goal = getWorldState().Goals.find(x => x._id.$oid == uploadProgress._id.$oid); + if (goal && goal.Personal) { + inventory.PersonalGoalProgress ??= []; + const goalProgress = inventory.PersonalGoalProgress.find(x => x.goalId.equals(goal._id.$oid)); + if (goalProgress) { + goalProgress.Best = Math.max(goalProgress.Best, uploadProgress.Best); + goalProgress.Count += uploadProgress.Count; + } else { + inventory.PersonalGoalProgress.push({ + Best: uploadProgress.Best, + Count: uploadProgress.Count, + Tag: goal.Tag, + goalId: new Types.ObjectId(goal._id.$oid) + }); + + if ( + goal.Reward && + goal.Reward.items && + goal.MissionKeyName && + goal.MissionKeyName in goalMessagesByKey + ) { + // Send reward via inbox + const info = goalMessagesByKey[goal.MissionKeyName]; + await createMessage(inventory.accountOwnerId, [ + { + sndr: info.sndr, + msg: info.msg, + att: goal.Reward.items.map(x => (isStoreItem(x) ? fromStoreItem(x) : x)), + sub: info.sub, + icon: info.icon, + highPriority: true + } + ]); + } + } + } + } + break; + } case "InvasionProgress": { for (const clientProgress of value) { const dbProgress = inventory.QualifyingInvasions.find(x => @@ -962,6 +1003,14 @@ export const addMissionRewards = async ( let missionCompletionCredits = 0; //inventory change is what the client has not rewarded itself, also the client needs to know the credit changes for display + + if (rewardInfo.goalId) { + const goal = getWorldState().Goals.find(x => x._id.$oid == rewardInfo.goalId); + if (goal?.MissionKeyName) { + levelKeyName = goal.MissionKeyName; + } + } + if (levelKeyName) { const fixedLevelRewards = getLevelKeyRewards(levelKeyName); //logger.debug(`fixedLevelRewards ${fixedLevelRewards}`); @@ -1978,3 +2027,24 @@ const getHexBounties = (seed: number): { nodes: string[]; buddies: string[] } => } return { nodes, buddies }; };*/ + +const goalMessagesByKey: Record = { + "/Lotus/Types/Keys/GalleonRobberyAlert": { + sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek", + msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgA", + sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleA", + icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png" + }, + "/Lotus/Types/Keys/GalleonRobberyAlertB": { + sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek", + msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgB", + sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleB", + icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png" + }, + "/Lotus/Types/Keys/GalleonRobberyAlertC": { + sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek", + msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgC", + sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleC", + icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png" + } +}; diff --git a/src/services/worldStateService.ts b/src/services/worldStateService.ts index f7099220..0a2bfd4f 100644 --- a/src/services/worldStateService.ts +++ b/src/services/worldStateService.ts @@ -1149,6 +1149,77 @@ export const getWorldState = (buildLabel?: string): IWorldState => { Node: "SolarisUnitedHub1" }); } + // The client gets kinda confused when multiple goals have the same tag, so considering these mutually exclusive. + if (config.worldState?.galleonOfGhouls == 1) { + worldState.Goals.push({ + _id: { $oid: "6814ddf00000000000000000" }, + Activation: { $date: { $numberLong: "1746198000000" } }, + Expiry: { $date: { $numberLong: "2000000000000" } }, + Count: 0, + Goal: 1, + Success: 0, + Personal: true, + Bounty: true, + ClampNodeScores: true, + Node: "EventNode19", + MissionKeyName: "/Lotus/Types/Keys/GalleonRobberyAlert", + Desc: "/Lotus/Language/Events/GalleonRobberyEventMissionTitle", + Icon: "/Lotus/Interface/Icons/Player/GalleonRobberiesEvent.png", + Tag: "GalleonRobbery", + Reward: { + items: [ + "/Lotus/StoreItems/Types/Recipes/Weapons/GrnChainSawTonfaBlueprint", + "/Lotus/StoreItems/Upgrades/Skins/Clan/BountyHunterBadgeItem" + ] + } + }); + } else if (config.worldState?.galleonOfGhouls == 2) { + worldState.Goals.push({ + _id: { $oid: "681e18700000000000000000" }, + Activation: { $date: { $numberLong: "1746802800000" } }, + Expiry: { $date: { $numberLong: "2000000000000" } }, + Count: 0, + Goal: 1, + Success: 0, + Personal: true, + Bounty: true, + ClampNodeScores: true, + Node: "EventNode28", + MissionKeyName: "/Lotus/Types/Keys/GalleonRobberyAlertB", + Desc: "/Lotus/Language/Events/GalleonRobberyEventMissionTitle", + Icon: "/Lotus/Interface/Icons/Player/GalleonRobberiesEvent.png", + Tag: "GalleonRobbery", + Reward: { + items: [ + "/Lotus/StoreItems/Types/Recipes/Weapons/MortiforShieldAndSwordBlueprint", + "/Lotus/StoreItems/Upgrades/Skins/Clan/BountyHunterBadgeItem" + ] + } + }); + } else if (config.worldState?.galleonOfGhouls == 3) { + worldState.Goals.push({ + _id: { $oid: "682752f00000000000000000" }, + Activation: { $date: { $numberLong: "1747407600000" } }, + Expiry: { $date: { $numberLong: "2000000000000" } }, + Count: 0, + Goal: 1, + Success: 0, + Personal: true, + Bounty: true, + ClampNodeScores: true, + Node: "EventNode19", + MissionKeyName: "/Lotus/Types/Keys/GalleonRobberyAlertC", + Desc: "/Lotus/Language/Events/GalleonRobberyEventMissionTitle", + Icon: "/Lotus/Interface/Icons/Player/GalleonRobberiesEvent.png", + Tag: "GalleonRobbery", + Reward: { + items: [ + "/Lotus/Types/StoreItems/Packages/EventCatalystReactorBundle", + "/Lotus/StoreItems/Upgrades/Skins/Clan/BountyHunterBadgeItem" + ] + } + }); + } // Nightwave Challenges const nightwaveSyndicateTag = getNightwaveSyndicateTag(buildLabel); diff --git a/src/types/inventoryTypes/inventoryTypes.ts b/src/types/inventoryTypes/inventoryTypes.ts index c3a8a7d1..30aced6b 100644 --- a/src/types/inventoryTypes/inventoryTypes.ts +++ b/src/types/inventoryTypes/inventoryTypes.ts @@ -56,6 +56,7 @@ export interface IInventoryDatabase | "QualifyingInvasions" | "LastInventorySync" | "EndlessXP" + | "PersonalGoalProgress" | TEquipmentKey >, InventoryDatabaseEquipment { @@ -63,7 +64,7 @@ export interface IInventoryDatabase Created: Date; TrainingDate: Date; LoadOutPresets: Types.ObjectId; // LoadOutPresets changed from ILoadOutPresets to Types.ObjectId for population - Mailbox?: IMailboxDatabase; + //Mailbox?: IMailboxDatabase; GuildId?: Types.ObjectId; PendingRecipes: IPendingRecipeDatabase[]; QuestKeys: IQuestKeyDatabase[]; @@ -95,6 +96,7 @@ export interface IInventoryDatabase QualifyingInvasions: IInvasionProgressDatabase[]; LastInventorySync?: Types.ObjectId; EndlessXP?: IEndlessXpProgressDatabase[]; + PersonalGoalProgress?: IPersonalGoalProgressDatabase[]; } export interface IQuestKeyDatabase { @@ -150,9 +152,9 @@ export interface IMailboxClient { LastInboxId: IOid; } -export interface IMailboxDatabase { +/*export interface IMailboxDatabase { LastInboxId: Types.ObjectId; -} +}*/ export type TSolarMapRegion = | "Earth" @@ -306,7 +308,7 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu HWIDProtectEnabled?: boolean; //KubrowPetPrints: IKubrowPetPrint[]; AlignmentReplay?: IAlignment; - //PersonalGoalProgress: IPersonalGoalProgress[]; + PersonalGoalProgress?: IPersonalGoalProgressClient[]; ThemeStyle: string; ThemeBackground: string; ThemeSounds: string; @@ -1015,13 +1017,17 @@ export interface IPeriodicMissionCompletionResponse extends Omit { + goalId: Types.ObjectId; } export interface IPersonalTechProjectDatabase { diff --git a/src/types/requestTypes.ts b/src/types/requestTypes.ts index a6b3f1c1..e2e5da92 100644 --- a/src/types/requestTypes.ts +++ b/src/types/requestTypes.ts @@ -139,6 +139,14 @@ export type IMissionInventoryUpdateRequest = { }; wagerTier?: number; // the index creditsFee?: number; // the index + GoalProgress?: { + _id: IOid; + Count: number; + Best: number; + Tag: string; + IsMultiProgress: boolean; + MultiProgress: unknown[]; + }[]; InvasionProgress?: IInvasionProgressClient[]; ConquestMissionsCompleted?: number; duviriSuitSelection?: string; @@ -156,6 +164,8 @@ export type IMissionInventoryUpdateRequest = { export interface IRewardInfo { node: string; + goalId?: string; + goalManifest?: string; invasionId?: string; invasionAllyFaction?: "FC_GRINEER" | "FC_CORPUS"; sortieId?: string; diff --git a/src/types/worldStateTypes.ts b/src/types/worldStateTypes.ts index 88544d6c..7301333f 100644 --- a/src/types/worldStateTypes.ts +++ b/src/types/worldStateTypes.ts @@ -1,3 +1,4 @@ +import { IMissionReward } from "warframe-public-export-plus"; import { IMongoDate, IOid } from "./commonTypes"; export interface IWorldState { @@ -37,11 +38,15 @@ export interface IGoal { Goal: number; Success: number; Personal: boolean; + Bounty?: boolean; + ClampNodeScores?: boolean; Desc: string; - ToolTip: string; + ToolTip?: string; Icon: string; Tag: string; Node: string; + MissionKeyName?: string; + Reward?: IMissionReward; } export interface ISyndicateMissionInfo {