diff --git a/inventoryService.ts b/inventoryService.ts new file mode 100644 index 00000000..a89ba244 --- /dev/null +++ b/inventoryService.ts @@ -0,0 +1,1449 @@ +import { + Inventory, + InventoryDocumentProps, + TInventoryDatabaseDocument +} from "@/src/models/inventoryModels/inventoryModel"; +import { config } from "@/src/services/configService"; +import { HydratedDocument, Types } from "mongoose"; +import { IMissionDatabase, IInventoryDatabase } from "@/src/types/inventoryTypes/inventoryTypes"; +import { SlotNames, IInventoryChanges, IBinChanges, slotNames } from "@/src/types/purchaseTypes"; +import { + IChallengeProgress, + IConsumable, + IFlavourItem, + IMiscItem, + IMission, + IRawUpgrade, + ISeasonChallenge, + ITypeCount, + InventorySlot, + IWeaponSkinClient, + TEquipmentKey, + IFusionTreasure, + IDailyAffiliations, + IKubrowPetEggDatabase, + IKubrowPetEggClient, + ILibraryDailyTaskInfo, + ICalendarProgress, + IDroneClient, + IUpgradeClient, + ICrewShipWeaponClient +} from "@/src/types/inventoryTypes/inventoryTypes"; +import { IGenericUpdate } from "../types/genericUpdate"; +import { + IMissionInventoryUpdateRequest, + IThemeUpdateRequest, + IUpdateChallengeProgressRequest +} from "../types/requestTypes"; +import { logger } from "@/src/utils/logger"; +import { convertInboxMessage, fromStoreItem, getExalted, getKeyChainItems } from "@/src/services/itemDataService"; +import { + EquipmentFeatures, + IEquipmentClient, + IEquipmentDatabase, + IItemConfig +} from "../types/inventoryTypes/commonInventoryTypes"; +import { + ExportArcanes, + ExportBundles, + ExportCustoms, + ExportDrones, + ExportEmailItems, + ExportEnemies, + ExportFlavour, + ExportFusionBundles, + ExportGear, + ExportKeys, + ExportMisc, + ExportRailjackWeapons, + ExportRecipes, + ExportResources, + ExportSentinels, + ExportSyndicates, + ExportUpgrades, + ExportWeapons, + ExportRegions, + IDefaultUpgrade, + TStandingLimitBin +} from "warframe-public-export-plus"; +import { createShip } from "./shipService"; +import { IKeyChainRequest } from "@/src/controllers/api/giveKeyChainTriggeredItemsController"; +import { toOid } from "../helpers/inventoryHelpers"; +import { generateRewardSeed } from "@/src/controllers/api/getNewRewardSeedController"; +import { addStartingGear } from "@/src/controllers/api/giveStartingGearController"; +import { addQuestKey, completeQuest } from "@/src/services/questService"; +import { handleBundleAcqusition } from "./purchaseService"; +import libraryDailyTasks from "@/static/fixed_responses/libraryDailyTasks.json"; +import { getRandomElement, getRandomInt } from "./rngService"; +import { createMessage } from "./inboxService"; + +export const createInventory = async ( + accountOwnerId: Types.ObjectId, + defaultItemReferences: { loadOutPresetId: Types.ObjectId; ship: Types.ObjectId } +): Promise => { + try { + const inventory = new Inventory({ + accountOwnerId: accountOwnerId, + LoadOutPresets: defaultItemReferences.loadOutPresetId, + Ships: [defaultItemReferences.ship], + PlayedParkourTutorial: config.skipTutorial, + ReceivedStartingGear: config.skipTutorial + }); + + inventory.LibraryAvailableDailyTaskInfo = createLibraryDailyTask(); + inventory.CalendarProgress = createCalendar(); + inventory.RewardSeed = generateRewardSeed(); + inventory.DuviriInfo = { + Seed: generateRewardSeed(), + NumCompletions: 0 + }; + await addItem(inventory, "/Lotus/Types/Friendly/PlayerControllable/Weapons/DuviriDualSwords"); + + if (config.skipTutorial) { + await addStartingGear(inventory); + await completeQuest(inventory, "/Lotus/Types/Keys/VorsPrize/VorsPrizeQuestKeyChain"); + + const completedMissions = ["SolNode27", "SolNode89", "SolNode63", "SolNode85", "SolNode15", "SolNode79"]; + + inventory.Missions.push( + ...completedMissions.map(tag => ({ + Completes: 1, + Tag: tag + })) + ); + } + + await inventory.save(); + } catch (error) { + throw new Error(`Error creating inventory: ${error instanceof Error ? error.message : "Unknown error type"}`); + } +}; + +/** + * Combines two inventory changes objects into one. + * + * @param InventoryChanges - will hold the combined changes + * @param delta - inventory changes to be added + */ +//TODO: this fails silently when providing an incorrect object to delta +export const combineInventoryChanges = (InventoryChanges: IInventoryChanges, delta: IInventoryChanges): void => { + for (const key in delta) { + if (!(key in InventoryChanges)) { + InventoryChanges[key] = delta[key]; + } else if (Array.isArray(delta[key])) { + const left = InventoryChanges[key] as object[]; + const right: object[] = delta[key]; + for (const item of right) { + left.push(item); + } + } else if (slotNames.indexOf(key as SlotNames) != -1) { + const left = InventoryChanges[key as SlotNames]!; + const right = delta[key as SlotNames]!; + if (right.count) { + left.count ??= 0; + left.count += right.count; + } + if (right.platinum) { + left.platinum ??= 0; + left.platinum += right.platinum; + } + left.Slots += right.Slots; + if (right.Extra) { + left.Extra ??= 0; + left.Extra += right.Extra; + } + } else if (typeof delta[key] === "number") { + (InventoryChanges[key] as number) += delta[key]; + } else { + throw new Error(`inventory change not merged: unhandled type for inventory key ${key}`); + } + } +}; + +export const getInventory = async ( + accountOwnerId: string, + projection: string | undefined = undefined +): Promise => { + const inventory = await Inventory.findOne({ accountOwnerId: accountOwnerId }, projection); + + if (!inventory) { + throw new Error(`Didn't find an inventory for ${accountOwnerId}`); + } + + return inventory; +}; + +export const productCategoryToInventoryBin = (productCategory: string): InventorySlot | undefined => { + switch (productCategory) { + case "Suits": + return InventorySlot.SUITS; + case "Pistols": + case "LongGuns": + case "Melee": + return InventorySlot.WEAPONS; + case "Sentinels": + case "SentinelWeapons": + case "KubrowPets": + case "MoaPets": + return InventorySlot.SENTINELS; + case "SpaceSuits": + case "Hoverboards": + return InventorySlot.SPACESUITS; + case "SpaceGuns": + case "SpaceMelee": + return InventorySlot.SPACEWEAPONS; + case "OperatorAmps": + return InventorySlot.AMPS; + case "CrewShipWeapons": + case "CrewShipWeaponSkins": + return InventorySlot.RJ_COMPONENT_AND_ARMAMENTS; + case "MechSuits": + return InventorySlot.MECHSUITS; + case "CrewMembers": + return InventorySlot.CREWMEMBERS; + } + return undefined; +}; + +export const occupySlot = ( + inventory: TInventoryDatabaseDocument, + bin: InventorySlot, + premiumPurchase: boolean +): IInventoryChanges => { + const slotChanges = { + Slots: 0, + Extra: 0 + }; + if (premiumPurchase) { + slotChanges.Extra += 1; + } else { + // { count: 1, platinum: 0, Slots: -1 } + slotChanges.Slots -= 1; + } + updateSlots(inventory, bin, slotChanges.Slots, slotChanges.Extra); + const inventoryChanges: IInventoryChanges = {}; + inventoryChanges[bin] = slotChanges satisfies IBinChanges; + return inventoryChanges; +}; + +export const freeUpSlot = (inventory: TInventoryDatabaseDocument, bin: InventorySlot): void => { + // { count: -1, platinum: 0, Slots: 1 } + updateSlots(inventory, bin, 1, 0); +}; + +export const addItem = async ( + inventory: TInventoryDatabaseDocument, + typeName: string, + quantity: number = 1, + premiumPurchase: boolean = false +): Promise<{ InventoryChanges: IInventoryChanges }> => { + // Bundles are technically StoreItems but a) they don't have a normal counterpart, and b) they are used in non-StoreItem contexts, e.g. email attachments. + if (typeName in ExportBundles) { + return { InventoryChanges: await handleBundleAcqusition(typeName, inventory, quantity) }; + } + + // Strict typing + if (typeName in ExportRecipes) { + const recipeChanges = [ + { + ItemType: typeName, + ItemCount: quantity + } satisfies ITypeCount + ]; + addRecipes(inventory, recipeChanges); + return { + InventoryChanges: { + Recipes: recipeChanges + } + }; + } + if (typeName in ExportResources) { + if (ExportResources[typeName].productCategory == "MiscItems") { + const miscItemChanges = [ + { + ItemType: typeName, + ItemCount: quantity + } satisfies IMiscItem + ]; + addMiscItems(inventory, miscItemChanges); + return { + InventoryChanges: { + MiscItems: miscItemChanges + } + }; + } else if (ExportResources[typeName].productCategory == "FusionTreasures") { + const fusionTreasureChanges = [ + { + ItemType: typeName, + ItemCount: quantity, + Sockets: 0 + } satisfies IFusionTreasure + ]; + addFusionTreasures(inventory, fusionTreasureChanges); + return { + InventoryChanges: { + FusionTreasures: fusionTreasureChanges + } + }; + } else if (ExportResources[typeName].productCategory == "Ships") { + const oid = await createShip(inventory.accountOwnerId, typeName); + inventory.Ships.push(oid); + return { + InventoryChanges: { + Ships: [ + { + ItemId: { $oid: oid.toString() }, + ItemType: typeName + } + ] + } + }; + } else if (ExportResources[typeName].productCategory == "CrewShips") { + const inventoryChanges = { + ...addCrewShip(inventory, typeName), + // fix to unlock railjack modding, item bellow supposed to be obtained from archwing quest + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...(!inventory.CrewShipHarnesses?.length + ? addCrewShipHarness(inventory, "/Lotus/Types/Game/CrewShip/RailJack/DefaultHarness") + : {}) + }; + + return { InventoryChanges: inventoryChanges }; + } else if (ExportResources[typeName].productCategory == "ShipDecorations") { + const changes = [ + { + ItemType: typeName, + ItemCount: quantity + } satisfies IMiscItem + ]; + addShipDecorations(inventory, changes); + return { + InventoryChanges: { + ShipDecorations: changes + } + }; + } else if (ExportResources[typeName].productCategory == "KubrowPetEggs") { + const changes: IKubrowPetEggClient[] = []; + if (quantity < 0) { + throw new Error(`removal of KubrowPetEggs not handled`); + } + for (let i = 0; i != quantity; ++i) { + const egg: IKubrowPetEggDatabase = { + ItemType: "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg", + _id: new Types.ObjectId() + }; + inventory.KubrowPetEggs ??= []; + inventory.KubrowPetEggs.push(egg); + changes.push({ + ItemType: egg.ItemType, + ExpirationDate: { $date: { $numberLong: "2000000000000" } }, + ItemId: toOid(egg._id) + }); + } + return { + InventoryChanges: { + KubrowPetEggs: changes + } + }; + } else { + throw new Error(`unknown product category: ${ExportResources[typeName].productCategory}`); + } + } + if (typeName in ExportCustoms) { + if (ExportCustoms[typeName].productCategory == "CrewShipWeaponSkins") { + return { InventoryChanges: addCrewShipWeaponSkin(inventory, typeName) }; + } else { + return { InventoryChanges: addSkin(inventory, typeName) }; + } + } + if (typeName in ExportFlavour) { + const inventoryChanges = addCustomization(inventory, typeName); + return { InventoryChanges: inventoryChanges }; + } + if (typeName in ExportUpgrades || typeName in ExportArcanes) { + const changes = [ + { + ItemType: typeName, + ItemCount: quantity + } + ]; + addMods(inventory, changes); + return { + InventoryChanges: { + RawUpgrades: changes + } + }; + } + if (typeName in ExportGear) { + const consumablesChanges = [ + { + ItemType: typeName, + ItemCount: quantity + } satisfies IConsumable + ]; + addConsumables(inventory, consumablesChanges); + return { + InventoryChanges: { + Consumables: consumablesChanges + } + }; + } + if (typeName in ExportWeapons) { + const weapon = ExportWeapons[typeName]; + if (weapon.totalDamage != 0) { + const inventoryChanges = addEquipment( + inventory, + weapon.productCategory, + typeName, + [], + {}, + premiumPurchase ? { Features: EquipmentFeatures.DOUBLE_CAPACITY } : {} + ); + if (weapon.additionalItems) { + for (const item of weapon.additionalItems) { + combineInventoryChanges(inventoryChanges, (await addItem(inventory, item, 1)).InventoryChanges); + } + } + return { + InventoryChanges: { + ...inventoryChanges, + ...occupySlot(inventory, InventorySlot.WEAPONS, premiumPurchase) + } + }; + } else { + // Modular weapon parts + const miscItemChanges = [ + { + ItemType: typeName, + ItemCount: quantity + } satisfies IMiscItem + ]; + addMiscItems(inventory, miscItemChanges); + return { + InventoryChanges: { + MiscItems: miscItemChanges + } + }; + } + } + if (typeName in ExportRailjackWeapons) { + return { + InventoryChanges: { + ...addCrewShipWeapon(inventory, typeName), + ...occupySlot(inventory, InventorySlot.RJ_COMPONENT_AND_ARMAMENTS, premiumPurchase) + } + }; + } + if (typeName in ExportMisc.creditBundles) { + const creditsTotal = ExportMisc.creditBundles[typeName] * quantity; + inventory.RegularCredits += creditsTotal; + return { + InventoryChanges: { + RegularCredits: creditsTotal + } + }; + } + if (typeName in ExportFusionBundles) { + const fusionPointsTotal = ExportFusionBundles[typeName].fusionPoints * quantity; + inventory.FusionPoints += fusionPointsTotal; + return { + InventoryChanges: { + FusionPoints: fusionPointsTotal + } + }; + } + if (typeName in ExportKeys) { + // Note: "/Lotus/Types/Keys/" contains some EmailItems + const key = ExportKeys[typeName]; + + if (key.chainStages) { + const key = addQuestKey(inventory, { ItemType: typeName }); + if (!key) return { InventoryChanges: {} }; + return { InventoryChanges: { QuestKeys: [key] } }; + } else { + const key = { ItemType: typeName, ItemCount: quantity }; + + const index = inventory.LevelKeys.findIndex(levelKey => levelKey.ItemType == typeName); + if (index != -1) { + inventory.LevelKeys[index].ItemCount += quantity; + } else { + inventory.LevelKeys.push(key); + } + return { InventoryChanges: { LevelKeys: [key] } }; + } + } + if (typeName in ExportDrones) { + const inventoryChanges = addDrone(inventory, typeName); + return { + InventoryChanges: inventoryChanges + }; + } + if (typeName in ExportEmailItems) { + return { + InventoryChanges: await addEmailItem(inventory, typeName) + }; + } + + // Path-based duck typing + switch (typeName.substr(1).split("/")[1]) { + case "Powersuits": + switch (typeName.substr(1).split("/")[2]) { + default: { + return { + InventoryChanges: { + ...addPowerSuit( + inventory, + typeName, + {}, + premiumPurchase ? EquipmentFeatures.DOUBLE_CAPACITY : undefined + ), + ...occupySlot(inventory, InventorySlot.SUITS, premiumPurchase) + } + }; + } + case "Archwing": { + inventory.ArchwingEnabled = true; + return { + InventoryChanges: { + ...addSpaceSuit( + inventory, + typeName, + {}, + premiumPurchase ? EquipmentFeatures.DOUBLE_CAPACITY : undefined + ), + ...occupySlot(inventory, InventorySlot.SPACESUITS, premiumPurchase) + } + }; + } + case "EntratiMech": { + return { + InventoryChanges: { + ...addMechSuit( + inventory, + typeName, + {}, + premiumPurchase ? EquipmentFeatures.DOUBLE_CAPACITY : undefined + ), + ...occupySlot(inventory, InventorySlot.MECHSUITS, premiumPurchase) + } + }; + } + } + break; + case "Upgrades": { + switch (typeName.substr(1).split("/")[2]) { + case "Mods": // Legendary Core + case "CosmeticEnhancers": // Traumatic Peculiar + { + const changes = [ + { + ItemType: typeName, + ItemCount: quantity + } + ]; + addMods(inventory, changes); + return { + InventoryChanges: { + RawUpgrades: changes + } + }; + } + break; + } + break; + } + case "Types": + switch (typeName.substr(1).split("/")[2]) { + case "Sentinels": { + return { + InventoryChanges: addSentinel(inventory, typeName, premiumPurchase) + }; + } + case "Game": { + if (typeName.substr(1).split("/")[3] == "Projections") { + // Void Relics, e.g. /Lotus/Types/Game/Projections/T2VoidProjectionGaussPrimeDBronze + const miscItemChanges = [ + { + ItemType: typeName, + ItemCount: quantity + } satisfies IMiscItem + ]; + addMiscItems(inventory, miscItemChanges); + inventory.HasOwnedVoidProjectionsPreviously = true; + return { + InventoryChanges: { + MiscItems: miscItemChanges + } + }; + } + break; + } + case "NeutralCreatures": { + const horseIndex = inventory.Horses.push({ ItemType: typeName }); + return { + InventoryChanges: { + Horses: [inventory.Horses[horseIndex - 1].toJSON()] + } + }; + } + case "Recipes": { + inventory.MiscItems.push({ ItemType: typeName, ItemCount: quantity }); + return { + InventoryChanges: { + MiscItems: [ + { + ItemType: typeName, + ItemCount: quantity + } + ] + } + }; + } + case "Vehicles": + if (typeName == "/Lotus/Types/Vehicles/Motorcycle/MotorcyclePowerSuit") { + return { InventoryChanges: addMotorcycle(inventory, typeName) }; + } + break; + } + break; + } + throw new Error(`unable to add item: ${typeName}`); +}; + +export const addItems = async ( + inventory: TInventoryDatabaseDocument, + items: ITypeCount[] | string[], + inventoryChanges: IInventoryChanges = {} +): Promise => { + let inventoryDelta; + for (const item of items) { + if (typeof item === "string") { + inventoryDelta = await addItem(inventory, item, 1, true); + } else { + inventoryDelta = await addItem(inventory, item.ItemType, item.ItemCount, true); + } + combineInventoryChanges(inventoryChanges, inventoryDelta.InventoryChanges); + } + return inventoryChanges; +}; + +export const applyDefaultUpgrades = ( + inventory: TInventoryDatabaseDocument, + defaultUpgrades: IDefaultUpgrade[] | undefined +): IItemConfig[] => { + const modsToGive: IRawUpgrade[] = []; + const configs: IItemConfig[] = []; + if (defaultUpgrades) { + const upgrades = []; + for (const defaultUpgrade of defaultUpgrades) { + modsToGive.push({ ItemType: defaultUpgrade.ItemType, ItemCount: 1 }); + if (defaultUpgrade.Slot != -1) { + while (upgrades.length < defaultUpgrade.Slot) { + upgrades.push(""); + } + upgrades[defaultUpgrade.Slot] = defaultUpgrade.ItemType; + } + } + if (upgrades.length != 0) { + configs.push({ Upgrades: upgrades }); + } + } + addMods(inventory, modsToGive); + return configs; +}; + +//TODO: maybe genericMethod for all the add methods, they share a lot of logic +const addSentinel = ( + inventory: TInventoryDatabaseDocument, + sentinelName: string, + premiumPurchase: boolean, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + // Sentinel itself occupies a slot in the sentinels bin + combineInventoryChanges(inventoryChanges, occupySlot(inventory, InventorySlot.SENTINELS, premiumPurchase)); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (ExportSentinels[sentinelName]?.defaultWeapon) { + addSentinelWeapon(inventory, ExportSentinels[sentinelName].defaultWeapon, premiumPurchase, inventoryChanges); + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const configs: IItemConfig[] = applyDefaultUpgrades(inventory, ExportSentinels[sentinelName]?.defaultUpgrades); + + const features = premiumPurchase ? EquipmentFeatures.DOUBLE_CAPACITY : undefined; + const sentinelIndex = + inventory.Sentinels.push({ ItemType: sentinelName, Configs: configs, XP: 0, Features: features }) - 1; + inventoryChanges.Sentinels ??= []; + inventoryChanges.Sentinels.push(inventory.Sentinels[sentinelIndex].toJSON()); + + return inventoryChanges; +}; + +const addSentinelWeapon = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + premiumPurchase: boolean, + inventoryChanges: IInventoryChanges +): void => { + // Sentinel weapons also occupy a slot in the sentinels bin + combineInventoryChanges(inventoryChanges, occupySlot(inventory, InventorySlot.SENTINELS, premiumPurchase)); + + const index = inventory.SentinelWeapons.push({ ItemType: typeName, XP: 0 }) - 1; + inventoryChanges.SentinelWeapons ??= []; + inventoryChanges.SentinelWeapons.push(inventory.SentinelWeapons[index].toJSON()); +}; + +export const addPowerSuit = ( + inventory: TInventoryDatabaseDocument, + powersuitName: string, + inventoryChanges: IInventoryChanges = {}, + features: number | undefined = undefined +): IInventoryChanges => { + const specialItems = getExalted(powersuitName); + if (specialItems) { + for (const specialItem of specialItems) { + addSpecialItem(inventory, specialItem, inventoryChanges); + } + } + const suitIndex = + inventory.Suits.push({ ItemType: powersuitName, Configs: [], UpgradeVer: 101, XP: 0, Features: features }) - 1; + inventoryChanges.Suits ??= []; + inventoryChanges.Suits.push(inventory.Suits[suitIndex].toJSON()); + return inventoryChanges; +}; + +export const addMechSuit = ( + inventory: TInventoryDatabaseDocument, + mechsuitName: string, + inventoryChanges: IInventoryChanges = {}, + features: number | undefined = undefined +): IInventoryChanges => { + const specialItems = getExalted(mechsuitName); + if (specialItems) { + for (const specialItem of specialItems) { + addSpecialItem(inventory, specialItem, inventoryChanges); + } + } + const suitIndex = + inventory.MechSuits.push({ ItemType: mechsuitName, Configs: [], UpgradeVer: 101, XP: 0, Features: features }) - + 1; + inventoryChanges.MechSuits ??= []; + inventoryChanges.MechSuits.push(inventory.MechSuits[suitIndex].toJSON()); + return inventoryChanges; +}; + +export const addSpecialItem = ( + inventory: TInventoryDatabaseDocument, + itemName: string, + inventoryChanges: IInventoryChanges +): void => { + if (inventory.SpecialItems.find(x => x.ItemType == itemName)) { + return; + } + const specialItemIndex = + inventory.SpecialItems.push({ + ItemType: itemName, + Configs: [], + Features: 1, + UpgradeVer: 101, + XP: 0 + }) - 1; + inventoryChanges.SpecialItems ??= []; + inventoryChanges.SpecialItems.push(inventory.SpecialItems[specialItemIndex].toJSON()); +}; + +export const addSpaceSuit = ( + inventory: TInventoryDatabaseDocument, + spacesuitName: string, + inventoryChanges: IInventoryChanges = {}, + features: number | undefined = undefined +): IInventoryChanges => { + const suitIndex = + inventory.SpaceSuits.push({ + ItemType: spacesuitName, + Configs: [], + UpgradeVer: 101, + XP: 0, + Features: features + }) - 1; + inventoryChanges.SpaceSuits ??= []; + inventoryChanges.SpaceSuits.push(inventory.SpaceSuits[suitIndex].toJSON()); + return inventoryChanges; +}; + +export const updateSlots = ( + inventory: TInventoryDatabaseDocument, + slotName: SlotNames, + slotAmount: number, + extraAmount: number +): void => { + inventory[slotName].Slots += slotAmount; + if (extraAmount != 0) { + inventory[slotName].Extra ??= 0; + inventory[slotName].Extra += extraAmount; + } +}; + +const isCurrencyTracked = (usePremium: boolean): boolean => { + return usePremium ? !config.infinitePlatinum : !config.infiniteCredits; +}; + +export const updateCurrency = ( + inventory: TInventoryDatabaseDocument, + price: number, + usePremium: boolean +): IInventoryChanges => { + const currencyChanges: IInventoryChanges = {}; + if (price != 0 && isCurrencyTracked(usePremium)) { + if (usePremium) { + if (inventory.PremiumCreditsFree > 0) { + currencyChanges.PremiumCreditsFree = Math.min(price, inventory.PremiumCreditsFree) * -1; + inventory.PremiumCreditsFree += currencyChanges.PremiumCreditsFree; + } + currencyChanges.PremiumCredits = -price; + inventory.PremiumCredits += currencyChanges.PremiumCredits; + } else { + currencyChanges.RegularCredits = -price; + inventory.RegularCredits += currencyChanges.RegularCredits; + } + logger.debug(`currency changes `, currencyChanges); + } + return currencyChanges; +}; + +const standingLimitBinToInventoryKey: Record< + Exclude, + keyof IDailyAffiliations +> = { + STANDING_LIMIT_BIN_NORMAL: "DailyAffiliation", + STANDING_LIMIT_BIN_PVP: "DailyAffiliationPvp", + STANDING_LIMIT_BIN_LIBRARY: "DailyAffiliationLibrary", + STANDING_LIMIT_BIN_CETUS: "DailyAffiliationCetus", + STANDING_LIMIT_BIN_QUILLS: "DailyAffiliationQuills", + STANDING_LIMIT_BIN_SOLARIS: "DailyAffiliationSolaris", + STANDING_LIMIT_BIN_VENTKIDS: "DailyAffiliationVentkids", + STANDING_LIMIT_BIN_VOX: "DailyAffiliationVox", + STANDING_LIMIT_BIN_ENTRATI: "DailyAffiliationEntrati", + STANDING_LIMIT_BIN_NECRALOID: "DailyAffiliationNecraloid", + STANDING_LIMIT_BIN_ZARIMAN: "DailyAffiliationZariman", + STANDING_LIMIT_BIN_KAHL: "DailyAffiliationKahl", + STANDING_LIMIT_BIN_CAVIA: "DailyAffiliationCavia", + STANDING_LIMIT_BIN_HEX: "DailyAffiliationHex" +}; + +export const allDailyAffiliationKeys: (keyof IDailyAffiliations)[] = Object.values(standingLimitBinToInventoryKey); + +export const getStandingLimit = (inventory: IDailyAffiliations, bin: TStandingLimitBin): number => { + if (bin == "STANDING_LIMIT_BIN_NONE" || config.noDailyStandingLimits) { + return Number.MAX_SAFE_INTEGER; + } + return inventory[standingLimitBinToInventoryKey[bin]]; +}; + +export const updateStandingLimit = ( + inventory: IDailyAffiliations, + bin: TStandingLimitBin, + subtrahend: number +): void => { + if (bin != "STANDING_LIMIT_BIN_NONE" && !config.noDailyStandingLimits) { + inventory[standingLimitBinToInventoryKey[bin]] -= subtrahend; + } +}; + +// TODO: AffiliationMods support (Nightwave). +export const updateGeneric = async (data: IGenericUpdate, accountId: string): Promise => { + const inventory = await getInventory(accountId); + + // Make it an array for easier parsing. + if (typeof data.NodeIntrosCompleted === "string") { + data.NodeIntrosCompleted = [data.NodeIntrosCompleted]; + } + + // Combine the two arrays into one. + data.NodeIntrosCompleted = inventory.NodeIntrosCompleted.concat(data.NodeIntrosCompleted); + + // Remove duplicate entries. + const nodes = [...new Set(data.NodeIntrosCompleted)]; + + inventory.NodeIntrosCompleted = nodes; + await inventory.save(); +}; + +export const updateTheme = async (data: IThemeUpdateRequest, accountId: string): Promise => { + const inventory = await getInventory(accountId); + if (data.Style) inventory.ThemeStyle = data.Style; + if (data.Background) inventory.ThemeBackground = data.Background; + if (data.Sounds) inventory.ThemeSounds = data.Sounds; + + await inventory.save(); +}; + +export const addEquipment = ( + inventory: TInventoryDatabaseDocument, + category: TEquipmentKey, + type: string, + modularParts: string[] | undefined = undefined, + inventoryChanges: IInventoryChanges = {}, + defaultOverwrites: Partial | undefined = undefined +): IInventoryChanges => { + const equipment = Object.assign( + { + ItemType: type, + Configs: [], + XP: 0, + ModularParts: modularParts + }, + defaultOverwrites + ); + const index = inventory[category].push(equipment) - 1; + + inventoryChanges[category] ??= []; + inventoryChanges[category].push(inventory[category][index].toJSON()); + return inventoryChanges; +}; + +export const addCustomization = ( + inventory: TInventoryDatabaseDocument, + customizationName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + const flavourItemIndex = inventory.FlavourItems.push({ ItemType: customizationName }) - 1; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + inventoryChanges.FlavourItems ??= []; + (inventoryChanges.FlavourItems as IFlavourItem[]).push( + inventory.FlavourItems[flavourItemIndex].toJSON() + ); + return inventoryChanges; +}; + +export const addSkin = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + const index = inventory.WeaponSkins.push({ ItemType: typeName }) - 1; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + inventoryChanges.WeaponSkins ??= []; + (inventoryChanges.WeaponSkins as IWeaponSkinClient[]).push( + inventory.WeaponSkins[index].toJSON() + ); + return inventoryChanges; +}; + +const addCrewShipWeapon = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + const index = inventory.CrewShipWeapons.push({ ItemType: typeName, _id: new Types.ObjectId() }) - 1; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + inventoryChanges.CrewShipWeapons ??= []; + (inventoryChanges.CrewShipWeapons as ICrewShipWeaponClient[]).push( + inventory.CrewShipWeapons[index].toJSON() + ); + return inventoryChanges; +}; + +const addCrewShipWeaponSkin = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + const index = inventory.CrewShipWeaponSkins.push({ ItemType: typeName }) - 1; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + inventoryChanges.CrewShipWeaponSkins ??= []; + (inventoryChanges.CrewShipWeaponSkins as IUpgradeClient[]).push( + inventory.CrewShipWeaponSkins[index].toJSON() + ); + return inventoryChanges; +}; + +const addCrewShip = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + if (inventory.CrewShips.length != 0) { + throw new Error("refusing to add CrewShip because account already has one"); + } + const index = inventory.CrewShips.push({ ItemType: typeName }) - 1; + inventoryChanges.CrewShips ??= []; + inventoryChanges.CrewShips.push(inventory.CrewShips[index].toJSON()); + return inventoryChanges; +}; + +const addCrewShipHarness = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + if (inventory.CrewShipHarnesses.length != 0) { + throw new Error("refusing to add CrewShipHarness because account already has one"); + } + const index = inventory.CrewShipHarnesses.push({ ItemType: typeName }) - 1; + inventoryChanges.CrewShipHarnesses ??= []; + inventoryChanges.CrewShipHarnesses.push(inventory.CrewShipHarnesses[index].toJSON()); + return inventoryChanges; +}; + +const addMotorcycle = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + if (inventory.Motorcycles.length != 0) { + throw new Error("refusing to add Motorcycle because account already has one"); + } + const index = inventory.Motorcycles.push({ ItemType: typeName }) - 1; + inventoryChanges.Motorcycles ??= []; + inventoryChanges.Motorcycles.push(inventory.Motorcycles[index].toJSON()); + return inventoryChanges; +}; + +const addDrone = ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): IInventoryChanges => { + const index = inventory.Drones.push({ ItemType: typeName, CurrentHP: ExportDrones[typeName].durability }) - 1; + inventoryChanges.Drones ??= []; + inventoryChanges.Drones.push(inventory.Drones[index].toJSON()); + return inventoryChanges; +}; + +export const addEmailItem = async ( + inventory: TInventoryDatabaseDocument, + typeName: string, + inventoryChanges: IInventoryChanges = {} +): Promise => { + const meta = ExportEmailItems[typeName]; + const emailItem = inventory.EmailItems.find(x => x.ItemType == typeName); + if (!emailItem || !meta.sendOnlyOnce) { + await createMessage(inventory.accountOwnerId.toString(), [convertInboxMessage(meta.message)]); + + if (emailItem) { + emailItem.ItemCount += 1; + } else { + inventory.EmailItems.push({ ItemType: typeName, ItemCount: 1 }); + } + + inventoryChanges.EmailItems ??= []; + inventoryChanges.EmailItems.push({ ItemType: typeName, ItemCount: 1 }); + } + return inventoryChanges; +}; + +//TODO: wrong id is not erroring +export const addGearExpByCategory = ( + inventory: TInventoryDatabaseDocument, + gearArray: IEquipmentClient[], + categoryName: TEquipmentKey +): void => { + const category = inventory[categoryName]; + + gearArray.forEach(({ ItemId, XP }) => { + if (!XP) { + return; + } + + const item = category.id(ItemId.$oid); + if (item) { + item.XP ??= 0; + item.XP += XP; + + const xpinfoIndex = inventory.XPInfo.findIndex(x => x.ItemType == item.ItemType); + if (xpinfoIndex !== -1) { + const xpinfo = inventory.XPInfo[xpinfoIndex]; + xpinfo.XP += XP; + } else { + inventory.XPInfo.push({ + ItemType: item.ItemType, + XP: XP + }); + } + } + }); +}; + +export const addMiscItems = (inventory: TInventoryDatabaseDocument, itemsArray: IMiscItem[]): void => { + const { MiscItems } = inventory; + + itemsArray.forEach(({ ItemCount, ItemType }) => { + if (ItemCount == 0) { + return; + } + + let itemIndex = MiscItems.findIndex(x => x.ItemType === ItemType); + if (itemIndex == -1) { + itemIndex = MiscItems.push({ ItemType, ItemCount: 0 }) - 1; + } + + MiscItems[itemIndex].ItemCount += ItemCount; + + if (ItemType == "/Lotus/Types/Items/MiscItems/ArgonCrystal") { + inventory.FoundToday ??= []; + let foundTodayIndex = inventory.FoundToday.findIndex(x => x.ItemType == ItemType); + if (foundTodayIndex == -1) { + foundTodayIndex = inventory.FoundToday.push({ ItemType, ItemCount: 0 }) - 1; + } + inventory.FoundToday[foundTodayIndex].ItemCount += ItemCount; + if (inventory.FoundToday[foundTodayIndex].ItemCount <= 0) { + inventory.FoundToday.splice(foundTodayIndex, 1); + } + if (inventory.FoundToday.length == 0) { + inventory.FoundToday = undefined; + } + } + + if (MiscItems[itemIndex].ItemCount == 0) { + MiscItems.splice(itemIndex, 1); + } else if (MiscItems[itemIndex].ItemCount <= 0) { + logger.warn(`account now owns a negative amount of ${ItemType}`); + } + }); +}; + +export const addShipDecorations = (inventory: TInventoryDatabaseDocument, itemsArray: IConsumable[]): void => { + const { ShipDecorations } = inventory; + + itemsArray.forEach(({ ItemCount, ItemType }) => { + const itemIndex = ShipDecorations.findIndex(miscItem => miscItem.ItemType === ItemType); + + if (itemIndex !== -1) { + ShipDecorations[itemIndex].ItemCount += ItemCount; + } else { + ShipDecorations.push({ ItemCount, ItemType }); + } + }); +}; + +export const addConsumables = (inventory: TInventoryDatabaseDocument, itemsArray: IConsumable[]): void => { + const { Consumables } = inventory; + + itemsArray.forEach(({ ItemCount, ItemType }) => { + const itemIndex = Consumables.findIndex(i => i.ItemType === ItemType); + + if (itemIndex !== -1) { + Consumables[itemIndex].ItemCount += ItemCount; + } else { + Consumables.push({ ItemCount, ItemType }); + } + }); +}; + +export const addCrewShipRawSalvage = (inventory: TInventoryDatabaseDocument, itemsArray: ITypeCount[]): void => { + const { CrewShipRawSalvage } = inventory; + + itemsArray.forEach(({ ItemCount, ItemType }) => { + const itemIndex = CrewShipRawSalvage.findIndex(i => i.ItemType === ItemType); + + if (itemIndex !== -1) { + CrewShipRawSalvage[itemIndex].ItemCount += ItemCount; + } else { + CrewShipRawSalvage.push({ ItemCount, ItemType }); + } + }); +}; + +export const addCrewShipAmmo = (inventory: TInventoryDatabaseDocument, itemsArray: ITypeCount[]): void => { + const { CrewShipAmmo } = inventory; + + itemsArray.forEach(({ ItemCount, ItemType }) => { + const itemIndex = CrewShipAmmo.findIndex(i => i.ItemType === ItemType); + + if (itemIndex !== -1) { + CrewShipAmmo[itemIndex].ItemCount += ItemCount; + } else { + CrewShipAmmo.push({ ItemCount, ItemType }); + } + }); +}; + +export const addRecipes = (inventory: TInventoryDatabaseDocument, itemsArray: ITypeCount[]): void => { + const { Recipes } = inventory; + + itemsArray.forEach(({ ItemCount, ItemType }) => { + const itemIndex = Recipes.findIndex(i => i.ItemType === ItemType); + + if (itemIndex !== -1) { + Recipes[itemIndex].ItemCount += ItemCount; + } else { + Recipes.push({ ItemCount, ItemType }); + } + }); +}; + +export const addMods = (inventory: TInventoryDatabaseDocument, itemsArray: IRawUpgrade[]): void => { + const { RawUpgrades } = inventory; + + itemsArray.forEach(({ ItemType, ItemCount }) => { + if (ItemCount == 0) { + return; + } + + let itemIndex = RawUpgrades.findIndex(x => x.ItemType === ItemType); + if (itemIndex == -1) { + itemIndex = RawUpgrades.push({ ItemType, ItemCount: 0 }) - 1; + } + + RawUpgrades[itemIndex].ItemCount += ItemCount; + if (RawUpgrades[itemIndex].ItemCount == 0) { + RawUpgrades.splice(itemIndex, 1); + } else if (RawUpgrades[itemIndex].ItemCount <= 0) { + logger.warn(`account now owns a negative amount of ${ItemType}`); + } + }); +}; + +export const addFusionTreasures = (inventory: TInventoryDatabaseDocument, itemsArray: IFusionTreasure[]): void => { + const { FusionTreasures } = inventory; + itemsArray.forEach(({ ItemType, ItemCount, Sockets }) => { + const itemIndex = FusionTreasures.findIndex(i => i.ItemType == ItemType && (i.Sockets || 0) == (Sockets || 0)); + + if (itemIndex !== -1) { + FusionTreasures[itemIndex].ItemCount += ItemCount; + if (FusionTreasures[itemIndex].ItemCount == 0) { + FusionTreasures.splice(itemIndex, 1); + } else if (FusionTreasures[itemIndex].ItemCount <= 0) { + logger.warn(`account now owns a negative amount of ${ItemType}`); + } + } else { + FusionTreasures.push({ ItemCount, ItemType, Sockets }); + } + }); +}; + +export const addFocusXpIncreases = (inventory: TInventoryDatabaseDocument, focusXpPlus: number[]): void => { + enum FocusType { + AP_UNIVERSAL, + AP_ATTACK, + AP_DEFENSE, + AP_TACTIC, + AP_POWER, + AP_PRECEPT, + AP_FUSION, + AP_WARD, + AP_UMBRA, + AP_ANY + } + + inventory.FocusXP ??= { AP_ATTACK: 0, AP_DEFENSE: 0, AP_TACTIC: 0, AP_POWER: 0, AP_WARD: 0 }; + inventory.FocusXP.AP_ATTACK += focusXpPlus[FocusType.AP_ATTACK]; + inventory.FocusXP.AP_DEFENSE += focusXpPlus[FocusType.AP_DEFENSE]; + inventory.FocusXP.AP_TACTIC += focusXpPlus[FocusType.AP_TACTIC]; + inventory.FocusXP.AP_POWER += focusXpPlus[FocusType.AP_POWER]; + inventory.FocusXP.AP_WARD += focusXpPlus[FocusType.AP_WARD]; +}; + +export const updateChallengeProgress = async ( + challenges: IUpdateChallengeProgressRequest, + accountId: string +): Promise => { + const inventory = await getInventory(accountId); + + addChallenges(inventory, challenges.ChallengeProgress); + addSeasonalChallengeHistory(inventory, challenges.SeasonChallengeHistory); + + await inventory.save(); +}; + +export const addSeasonalChallengeHistory = ( + inventory: TInventoryDatabaseDocument, + itemsArray: ISeasonChallenge[] | undefined +): void => { + const category = inventory.SeasonChallengeHistory; + + itemsArray?.forEach(({ challenge, id }) => { + const itemIndex = category.findIndex(i => i.challenge === challenge); + + if (itemIndex !== -1) { + category[itemIndex].id = id; + } else { + category.push({ challenge, id }); + } + }); +}; + +export const addChallenges = ( + inventory: TInventoryDatabaseDocument, + itemsArray: IChallengeProgress[] | undefined +): void => { + const category = inventory.ChallengeProgress; + + itemsArray?.forEach(({ Name, Progress }) => { + const itemIndex = category.findIndex(i => i.Name === Name); + + if (itemIndex !== -1) { + category[itemIndex].Progress += Progress; + } else { + category.push({ Name, Progress }); + } + }); +}; + + +export const addMissionComplete = ( + inventory: TInventoryDatabaseDocument, + { Tag, Completes, Tier }: IMissionDatabase +): void => { + const { Missions, NodeIntrosCompleted } = inventory; + const itemIndex = Missions.findIndex(item => item.Tag === Tag); + + // 检查是否存在 TeshinHardModeUnlocked + const hasTeshinHardModeUnlocked = NodeIntrosCompleted.includes("TeshinHardModeUnlocked"); + + if (itemIndex !== -1) { + Missions[itemIndex].Completes += Completes; + // 如果存在 TeshinHardModeUnlocked,增加 Tier + if (hasTeshinHardModeUnlocked) { + // 确保 Tier 是一个有效的数字 + if (typeof Missions[itemIndex].Tier !== "number" || isNaN(Missions[itemIndex].Tier)) { + Missions[itemIndex].Tier = 0; // 如果 Tier 是 NaN 或未定义,重置为 0 + } + Missions[itemIndex].Tier += Tier || 0; // 如果 Tier 未传入,默认为 0 + } + } else { + const newMission: IMissionDatabase = { Tag, Completes, Tier: Tier || 0 }; // 如果 Tier 未传入,默认为 0 + Missions.push(newMission); + } + + // 当完成 SolNode89 时,自动添加 SolNode855 完成次数 + if (Tag === "SolNode857") { + const solNode855Index = Missions.findIndex(item => item.Tag === "SolNode855"); + if (solNode855Index !== -1) { + Missions[solNode855Index].Completes += 1; + } else { + Missions.push({ Tag: "SolNode855", Completes: 1, Tier: 0 }); // 默认 Tier 为 0 + } + } + + // 如果存在 TeshinHardModeUnlocked,添加一项 Tier 默认为 0 的任务 + if (hasTeshinHardModeUnlocked) { + const teshinMissionTag = "TeshinHardModeMission"; // 新任务的 Tag + const teshinMissionIndex = Missions.findIndex(item => item.Tag === teshinMissionTag); + + if (teshinMissionIndex !== -1) { + // 如果任务已存在,增加 Completes + Missions[teshinMissionIndex].Completes += 1; + } else { + // 如果任务不存在,添加新任务 + Missions.push({ Tag: teshinMissionTag, Completes: 1, Tier: 0 }); // 默认 Tier 为 0 + } + } +}; + +export const addBooster = (ItemType: string, time: number, inventory: TInventoryDatabaseDocument): void => { + const currentTime = Math.floor(Date.now() / 1000) - 129600; // Value is wrong without 129600. Figure out why, please. :) + + const { Boosters } = inventory; + + const itemIndex = Boosters.findIndex(booster => booster.ItemType === ItemType); + + if (itemIndex !== -1) { + const existingBooster = Boosters[itemIndex]; + existingBooster.ExpiryDate = Math.max(existingBooster.ExpiryDate, currentTime) + time; + } else { + Boosters.push({ ItemType, ExpiryDate: currentTime + time }); + } +}; + +export const updateSyndicate = ( + inventory: HydratedDocument, + syndicateUpdate: IMissionInventoryUpdateRequest["AffiliationChanges"] +): void => { + syndicateUpdate?.forEach(affiliation => { + const syndicate = inventory.Affiliations.find(x => x.Tag === affiliation.Tag); + if (syndicate !== undefined) { + // 更新声望 + syndicate.Standing += Number(affiliation.Standing); + + // 如果 Title 是 number 类型,则更新 Title + if (typeof affiliation.Title === 'number') { + syndicate.Title = (syndicate.Title || 0) + affiliation.Title; + } + } else { + // 创建新的派系记录 + inventory.Affiliations.push({ + Standing: Number(affiliation.Standing), // 确保 Standing 是 number 类型 + Title: typeof affiliation.Title === 'number' ? affiliation.Title : 0, // 确保 Title 是 number 类型 + Tag: affiliation.Tag, + FreeFavorsEarned: [], + FreeFavorsUsed: [] + }); + } + + // 额外增加 1000 点 RadioLegionIntermission12Syndicate 声望 + if (affiliation.Tag === "RadioLegionIntermission12Syndicate") { + const radioLegionSyndicate = inventory.Affiliations.find(x => x.Tag === "RadioLegionIntermission12Syndicate"); + if (radioLegionSyndicate !== undefined) { + radioLegionSyndicate.Standing += 1000; // 增加 1000 点声望 + } else { + // 如果不存在,创建新的记录并设置初始声望 + inventory.Affiliations.push({ + Standing: Number(affiliation.Standing) + 1000, // 初始声望 + 1000 + Title: typeof affiliation.Title === 'number' ? affiliation.Title : 0, // 确保 Title 是 number 类型 + Tag: "RadioLegionIntermission12Syndicate", + FreeFavorsEarned: [], + FreeFavorsUsed: [] + }); + } + } + + updateStandingLimit(inventory, ExportSyndicates[affiliation.Tag].dailyLimitBin, affiliation.Standing); + }); +}; + +/** + * @returns object with inventory keys of changes or empty object when no items were added + */ +export const addKeyChainItems = async ( + inventory: TInventoryDatabaseDocument, + keyChainData: IKeyChainRequest +): Promise => { + const keyChainItems = getKeyChainItems(keyChainData); + + logger.debug( + `adding key chain items ${keyChainItems.join()} for ${keyChainData.KeyChain} at stage ${keyChainData.ChainStage}` + ); + + const nonStoreItems = keyChainItems.map(item => fromStoreItem(item)); + + //TODO: inventoryChanges is not typed correctly + const inventoryChanges = {}; + + for (const item of nonStoreItems) { + const inventoryChangesDelta = await addItem(inventory, item); + combineInventoryChanges(inventoryChanges, inventoryChangesDelta.InventoryChanges); + } + + return inventoryChanges; +}; + +export const createLibraryDailyTask = (): ILibraryDailyTaskInfo => { + const enemyTypes = getRandomElement(libraryDailyTasks); + const enemyAvatar = ExportEnemies.avatars[enemyTypes[0]]; + const scansRequired = getRandomInt(2, 4); + return { + EnemyTypes: enemyTypes, + EnemyLocTag: enemyAvatar.name, + EnemyIcon: enemyAvatar.icon!, + ScansRequired: scansRequired, + RewardStoreItem: "/Lotus/StoreItems/Upgrades/Mods/FusionBundles/RareFusionBundle", + RewardQuantity: Math.trunc(scansRequired * 2.5), + RewardStanding: 2500 * scansRequired + }; +}; + +const createCalendar = (): ICalendarProgress => { + return { + Version: 19, + Iteration: 2, + YearProgress: { Upgrades: [] }, + SeasonProgress: { + SeasonType: "CST_SPRING", + LastCompletedDayIdx: -1, + LastCompletedChallengeDayIdx: -1, + ActivatedChallenges: [] + } + }; +}; diff --git a/missionInventoryUpdateService.ts b/missionInventoryUpdateService.ts new file mode 100644 index 00000000..0150f6d6 --- /dev/null +++ b/missionInventoryUpdateService.ts @@ -0,0 +1,906 @@ +import { + ExportEnemies, + ExportFusionBundles, + ExportRegions, + ExportRewards, + IMissionReward as IMissionRewardExternal, + IReward +} from "warframe-public-export-plus"; +import { IMissionInventoryUpdateRequest, IRewardInfo } from "../types/requestTypes"; +import { logger } from "@/src/utils/logger"; +import { IRngResult, getRandomElement, getRandomReward } from "@/src/services/rngService"; +import { equipmentKeys, IInventoryDatabase, TEquipmentKey } from "@/src/types/inventoryTypes/inventoryTypes"; +import { + addChallenges, + addConsumables, + addCrewShipAmmo, + addCrewShipRawSalvage, + addEmailItem, + addFocusXpIncreases, + addFusionTreasures, + addGearExpByCategory, + addItem, + addMiscItems, + addMissionComplete, + addMods, + addRecipes, + combineInventoryChanges, + updateSyndicate +} from "@/src/services/inventoryService"; +import { updateQuestKey } from "@/src/services/questService"; +import { HydratedDocument } from "mongoose"; +import { IInventoryChanges } from "@/src/types/purchaseTypes"; +import { getLevelKeyRewards, getNode, toStoreItem } from "@/src/services/itemDataService"; +import { InventoryDocumentProps, TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel"; +import { getEntriesUnsafe } from "@/src/utils/ts-utils"; +import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes"; +import { handleStoreItemAcquisition } from "./purchaseService"; +import { IMissionReward } from "../types/missionTypes"; +import { crackRelic } from "@/src/helpers/relicHelper"; +import { createMessage } from "./inboxService"; +import kuriaMessage50 from "@/static/fixed_responses/kuriaMessages/fiftyPercent.json"; +import kuriaMessage75 from "@/static/fixed_responses/kuriaMessages/seventyFivePercent.json"; +import kuriaMessage100 from "@/static/fixed_responses/kuriaMessages/oneHundredPercent.json"; + +const getRotations = (rotationCount: number): number[] => { + if (rotationCount === 0) return [0]; + + const rotationPattern = [0, 0, 1, 2]; // A, A, B, C + const rotatedValues = []; + + for (let i = 0; i < rotationCount; i++) { + rotatedValues.push(rotationPattern[i % rotationPattern.length]); + } + + return rotatedValues; +}; + +const getRandomRewardByChance = (pool: IReward[]): IRngResult | undefined => { + return getRandomReward(pool as IRngResult[]); +}; + +//type TMissionInventoryUpdateKeys = keyof IMissionInventoryUpdateRequest; +//const ignoredInventoryUpdateKeys = ["FpsAvg", "FpsMax", "FpsMin", "FpsSamples"] satisfies TMissionInventoryUpdateKeys[]; // for keys with no meaning for this server +//type TignoredInventoryUpdateKeys = (typeof ignoredInventoryUpdateKeys)[number]; +//const knownUnhandledKeys: readonly string[] = ["test"] as const; // for unimplemented but important keys + +export const addMissionInventoryUpdates = async ( + inventory: HydratedDocument, + inventoryUpdates: IMissionInventoryUpdateRequest +): Promise => { + const inventoryChanges: IInventoryChanges = {}; + if (inventoryUpdates.RewardInfo && inventoryUpdates.RewardInfo.periodicMissionTag) { + const tag = inventoryUpdates.RewardInfo.periodicMissionTag; + const existingCompletion = inventory.PeriodicMissionCompletions.find(completion => completion.tag === tag); + + if (existingCompletion) { + existingCompletion.date = new Date(); + } else { + inventory.PeriodicMissionCompletions.push({ + tag: tag, + date: new Date() + }); + } + } + if (inventoryUpdates.RewardInfo && inventoryUpdates.RewardInfo.NemesisAbandonedRewards) { + inventory.NemesisAbandonedRewards = inventoryUpdates.RewardInfo.NemesisAbandonedRewards; + } + for (const [key, value] of getEntriesUnsafe(inventoryUpdates)) { + if (value === undefined) { + logger.error(`Inventory update key ${key} has no value `); + continue; + } + switch (key) { + case "RegularCredits": + inventory.RegularCredits += value; + break; + case "QuestKeys": + await updateQuestKey(inventory, value); + break; + case "AffiliationChanges": + updateSyndicate(inventory, value); + break; + // Incarnon Challenges + case "EvolutionProgress": { + for (const evoProgress of value) { + const entry = inventory.EvolutionProgress + ? inventory.EvolutionProgress.find(entry => entry.ItemType == evoProgress.ItemType) + : undefined; + if (entry) { + entry.Progress = evoProgress.Progress; + entry.Rank = evoProgress.Rank; + } else { + inventory.EvolutionProgress ??= []; + inventory.EvolutionProgress.push(evoProgress); + } + } + break; + } + case "Missions": + addMissionComplete(inventory, value); + break; + case "LastRegionPlayed": + inventory.LastRegionPlayed = value; + break; + case "RawUpgrades": + addMods(inventory, value); + break; + case "MiscItems": + case "BonusMiscItems": + addMiscItems(inventory, value); + break; + case "Consumables": + addConsumables(inventory, value); + break; + case "Recipes": + addRecipes(inventory, value); + break; + case "ChallengeProgress": + addChallenges(inventory, value); + break; + case "FusionTreasures": + addFusionTreasures(inventory, value); + break; + case "CrewShipRawSalvage": + addCrewShipRawSalvage(inventory, value); + break; + case "CrewShipAmmo": + addCrewShipAmmo(inventory, value); + break; + case "FusionBundles": { + let fusionPoints = 0; + for (const fusionBundle of value) { + const fusionPointsTotal = + ExportFusionBundles[fusionBundle.ItemType].fusionPoints * fusionBundle.ItemCount; + inventory.FusionPoints += fusionPointsTotal; + fusionPoints += fusionPointsTotal; + } + inventoryChanges.FusionPoints = fusionPoints; + break; + } + case "EmailItems": { + for (const tc of value) { + await addEmailItem(inventory, tc.ItemType); + } + break; + } + case "FocusXpIncreases": { + addFocusXpIncreases(inventory, value); + break; + } + case "PlayerSkillGains": { + inventory.PlayerSkills.LPP_SPACE += value.LPP_SPACE; + inventory.PlayerSkills.LPP_DRIFTER += value.LPP_DRIFTER; + break; + } + case "CustomMarkers": { + value.forEach(markers => { + const map = inventory.CustomMarkers + ? inventory.CustomMarkers.find(entry => entry.tag == markers.tag) + : undefined; + if (map) { + map.markerInfos = markers.markerInfos; + } else { + inventory.CustomMarkers ??= []; + inventory.CustomMarkers.push(markers); + } + }); + break; + } + case "LoreFragmentScans": + value.forEach(clientFragment => { + const fragment = inventory.LoreFragmentScans.find(x => x.ItemType == clientFragment.ItemType); + if (fragment) { + fragment.Progress += clientFragment.Progress; + } else { + inventory.LoreFragmentScans.push(clientFragment); + } + }); + break; + case "LibraryScans": + value.forEach(scan => { + let synthesisIgnored = true; + if ( + inventory.LibraryPersonalTarget && + libraryPersonalTargetToAvatar[inventory.LibraryPersonalTarget] == scan.EnemyType + ) { + let progress = inventory.LibraryPersonalProgress.find( + x => x.TargetType == inventory.LibraryPersonalTarget + ); + if (!progress) { + progress = + inventory.LibraryPersonalProgress[ + inventory.LibraryPersonalProgress.push({ + TargetType: inventory.LibraryPersonalTarget, + Scans: 0, + Completed: false + }) - 1 + ]; + } + progress.Scans += scan.Count; + if ( + progress.Scans >= + (inventory.LibraryPersonalTarget == + "/Lotus/Types/Game/Library/Targets/DragonframeQuestTarget" + ? 3 + : 10) + ) { + progress.Completed = true; + } + logger.debug(`synthesis of ${scan.EnemyType} added to personal target progress`); + synthesisIgnored = false; + } + if ( + inventory.LibraryActiveDailyTaskInfo && + inventory.LibraryActiveDailyTaskInfo.EnemyTypes.find(x => x == scan.EnemyType) + ) { + inventory.LibraryActiveDailyTaskInfo.Scans ??= 0; + inventory.LibraryActiveDailyTaskInfo.Scans += scan.Count; + logger.debug(`synthesis of ${scan.EnemyType} added to daily task progress`); + synthesisIgnored = false; + } + if (synthesisIgnored) { + logger.warn(`ignoring synthesis of ${scan.EnemyType} due to not knowing why you did that`); + } + }); + break; + case "CollectibleScans": + for (const scan of value) { + const entry = inventory.CollectibleSeries?.find(x => x.CollectibleType == scan.CollectibleType); + if (entry) { + entry.Count = scan.Count; + entry.Tracking = scan.Tracking; + if (entry.CollectibleType == "/Lotus/Objects/Orokin/Props/CollectibleSeriesOne") { + const progress = entry.Count / entry.ReqScans; + for (const gate of entry.IncentiveStates) { + gate.complete = progress >= gate.threshold; + if (gate.complete && !gate.sent) { + gate.sent = true; + if (gate.threshold == 0.5) { + await createMessage(inventory.accountOwnerId.toString(), [kuriaMessage50]); + } else { + await createMessage(inventory.accountOwnerId.toString(), [kuriaMessage75]); + } + } + } + if (progress >= 1.0) { + await createMessage(inventory.accountOwnerId.toString(), [kuriaMessage100]); + } + } + } else { + logger.warn(`${scan.CollectibleType} was not found in inventory, ignoring scans`); + } + } + break; + case "Upgrades": + value.forEach(clientUpgrade => { + const upgrade = inventory.Upgrades.id(clientUpgrade.ItemId.$oid)!; + upgrade.UpgradeFingerprint = clientUpgrade.UpgradeFingerprint; // primitive way to copy over the riven challenge progress + }); + break; + case "SyndicateId": { + inventory.CompletedSyndicates.push(value); + break; + } + case "SortieId": { + inventory.CompletedSorties.push(value); + break; + } + case "SeasonChallengeCompletions": { + const processedCompletions = value.map(({ challenge, id }) => ({ + challenge: challenge.substring(challenge.lastIndexOf("/") + 1), + id + })); + inventory.SeasonChallengeHistory.push(...processedCompletions); + break; + } + case "DeathMarks": { + for (const deathMark of value) { + if (!inventory.DeathMarks.find(x => x == deathMark)) { + // It's a new death mark; we have to say the line. + await createMessage(inventory.accountOwnerId.toString(), [ + { + sub: "/Lotus/Language/G1Quests/DeathMarkTitle", + sndr: "/Lotus/Language/G1Quests/DeathMarkSender", + msg: "/Lotus/Language/G1Quests/DeathMarkMessage", + icon: "/Lotus/Interface/Icons/Npcs/Stalker_d.png", + highPriority: true + } + ]); + // TODO: This type of inbox message seems to automatically delete itself. Figure out under which conditions. + } + } + inventory.DeathMarks = value; + break; + } +default: + // Equipment XP updates + if (equipmentKeys.includes(key as TEquipmentKey)) { + const multipliedValue = (value as IEquipmentClient[]).map(equipment => { + // 生成一个 0 到 1 的随机数 + const randomChance = Math.random(); + // 基础倍率为 5 倍 + let multiplier = 5; + // 10% 的概率触发 10 倍经验 + if (randomChance < 0.1) { + multiplier = 10; // ✅ 10% 概率 10 倍经验 + } + // 计算最终经验值 + const finalXP = (equipment.XP ?? 0) * multiplier; + + // 日志输出(中文) + logger.debug(`[经验倍率] 随机数: ${randomChance.toFixed(2)}, 倍率: ${multiplier}, 最终经验值: ${finalXP}`); + + return { + ...equipment, + XP: finalXP // ✅ 处理 undefined + }; + }); + addGearExpByCategory(inventory, multipliedValue, key as TEquipmentKey); + } + break; + // if ( + // (ignoredInventoryUpdateKeys as readonly string[]).includes(key) || + // knownUnhandledKeys.includes(key) + // ) { + // continue; + // } + // logger.error(`Unhandled inventory update key: ${key}`); + } + } + + return inventoryChanges; +}; + +interface AddMissionRewardsReturnType { + MissionRewards: IMissionReward[]; + inventoryChanges?: IInventoryChanges; + credits?: IMissionCredits; +} + +//TODO: return type of partial missioninventoryupdate response +export const addMissionRewards = async ( + inventory: TInventoryDatabaseDocument, + { + RewardInfo: rewardInfo, + LevelKeyName: levelKeyName, + Missions: missions, + RegularCredits: creditDrops, + VoidTearParticipantsCurrWave: voidTearWave, + StrippedItems: strippedItems + }: IMissionInventoryUpdateRequest +): Promise => { + if (!rewardInfo) { + //TODO: if there is a case where you can have credits collected during a mission but no rewardInfo, add credits needs to be handled earlier + logger.debug(`Mission ${missions!.Tag} did not have Reward Info `); + return { MissionRewards: [] }; + } + + //TODO: check double reward merging + const MissionRewards: IMissionReward[] = getRandomMissionDrops(rewardInfo); + logger.debug("random mission drops:", MissionRewards); + const inventoryChanges: IInventoryChanges = {}; + + 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 (levelKeyName) { + const fixedLevelRewards = getLevelKeyRewards(levelKeyName); + //logger.debug(`fixedLevelRewards ${fixedLevelRewards}`); + if (fixedLevelRewards.levelKeyRewards) { + addFixedLevelRewards(fixedLevelRewards.levelKeyRewards, inventory, MissionRewards); + } + if (fixedLevelRewards.levelKeyRewards2) { + for (const reward of fixedLevelRewards.levelKeyRewards2) { + //quest stage completion credit rewards + if (reward.rewardType == "RT_CREDITS") { + inventory.RegularCredits += reward.amount; + missionCompletionCredits += reward.amount; + continue; + } + MissionRewards.push({ + StoreItem: reward.itemType, + ItemCount: reward.rewardType === "RT_RESOURCE" ? reward.amount : 1 + }); + } + } + } + + if ( + missions && + missions.Tag != "" // https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1013 + ) { + const node = getNode(missions.Tag); + + //node based credit rewards for mission completion + if (node.missionIndex !== 28) { + const levelCreditReward = getLevelCreditRewards(missions.Tag); + missionCompletionCredits += levelCreditReward; + inventory.RegularCredits += levelCreditReward; + logger.debug(`levelCreditReward ${levelCreditReward}`); + } + + if (node.missionReward) { + missionCompletionCredits += addFixedLevelRewards(node.missionReward, inventory, MissionRewards); + } + } + + if (rewardInfo.useVaultManifest) { + MissionRewards.push({ + StoreItem: getRandomElement(corruptedMods), + ItemCount: 1 + }); + } + + for (const reward of MissionRewards) { + const inventoryChange = await handleStoreItemAcquisition(reward.StoreItem, inventory, reward.ItemCount); + //TODO: combineInventoryChanges improve type safety, merging 2 of the same item? + //TODO: check for the case when two of the same item are added, combineInventoryChanges should merge them, but the client also merges them + //TODO: some conditional types to rule out binchanges? + combineInventoryChanges(inventoryChanges, inventoryChange.InventoryChanges); + } + + const credits = addCredits(inventory, { + missionCompletionCredits, + missionDropCredits: creditDrops ?? 0, + rngRewardCredits: inventoryChanges.RegularCredits ?? 0 + }); + + if ( + voidTearWave && + voidTearWave.Participants[0].QualifiesForReward && + !voidTearWave.Participants[0].HaveRewardResponse + ) { + const reward = await crackRelic(inventory, voidTearWave.Participants[0], inventoryChanges); + MissionRewards.push({ StoreItem: reward.type, ItemCount: reward.itemCount }); + } + + if (strippedItems) { + for (const si of strippedItems) { + const droptable = ExportEnemies.droptables[si.DropTable]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!droptable) { + logger.error(`unknown droptable ${si.DropTable}`); + } else { + for (let i = 0; i != (si.DROP_MOD || []).length; ++i) { + for (const pool of droptable) { + const reward = getRandomReward(pool.items)!; + logger.debug(`stripped droptable rolled`, reward); + await addItem(inventory, reward.type); + MissionRewards.push({ + StoreItem: toStoreItem(reward.type), + ItemCount: 1, + FromEnemyCache: true // to show "identified" + }); + } + } + } + } + } + + return { inventoryChanges, MissionRewards, credits }; +}; + +interface IMissionCredits { + MissionCredits: number[]; + CreditBonus: number[]; + TotalCredits: number[]; + DailyMissionBonus?: boolean; +} + +//creditBonus is not entirely accurate. +//TODO: consider ActiveBoosters +export const addCredits = ( + inventory: HydratedDocument, + { + missionDropCredits, + missionCompletionCredits, + rngRewardCredits + }: { missionDropCredits: number; missionCompletionCredits: number; rngRewardCredits: number } +): IMissionCredits => { + const hasDailyCreditBonus = true; + const totalCredits = missionDropCredits + missionCompletionCredits + rngRewardCredits; + + const finalCredits: IMissionCredits = { + MissionCredits: [missionDropCredits, missionDropCredits], + CreditBonus: [missionCompletionCredits, missionCompletionCredits], + TotalCredits: [totalCredits, totalCredits] + }; + + if (hasDailyCreditBonus) { + inventory.RegularCredits += missionCompletionCredits; + finalCredits.CreditBonus[1] *= 2; + finalCredits.MissionCredits[1] *= 2; + finalCredits.TotalCredits[1] *= 2; + } + + if (!hasDailyCreditBonus) { + return finalCredits; + } + return { ...finalCredits, DailyMissionBonus: true }; +}; + +export const addFixedLevelRewards = ( + rewards: IMissionRewardExternal, + inventory: TInventoryDatabaseDocument, + MissionRewards: IMissionReward[] +): number => { + let missionBonusCredits = 0; + if (rewards.credits) { + missionBonusCredits += rewards.credits; + inventory.RegularCredits += rewards.credits; + } + if (rewards.items) { + for (const item of rewards.items) { + MissionRewards.push({ + StoreItem: item, + ItemCount: 1 + }); + } + } + if (rewards.countedItems) { + for (const item of rewards.countedItems) { + MissionRewards.push({ + StoreItem: `/Lotus/StoreItems${item.ItemType.substring("Lotus/".length)}`, + ItemCount: item.ItemCount + }); + } + } + if (rewards.countedStoreItems) { + for (const item of rewards.countedStoreItems) { + MissionRewards.push(item); + } + } + return missionBonusCredits; +}; + +function getLevelCreditRewards(nodeName: string): number { + const minEnemyLevel = getNode(nodeName).minEnemyLevel; + + return 1000 + (minEnemyLevel - 1) * 100; + + //TODO: get dark sektor fixed credit rewards and railjack bonus +} + +function getRandomMissionDrops(RewardInfo: IRewardInfo): IMissionReward[] { + const drops: IMissionReward[] = []; + + // 模糊匹配 jobId 并处理奖励 + if (RewardInfo.jobId) { + // 定义任务类型和对应的奖励表、声望阵营及声望值 + const jobRewardsMap: Record = { + "AssassinateBountyCap": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierATableCRewards", + tag: "CetusSyndicate", + standingValue: 430 + }, + "DeimosGrnSurvivorBounty": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierATableBRewards", + tag: "EntratiSyndicate", + standingValue: 450 + }, + "DeimosAreaDefenseBounty": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierCTableBRewards", + tag: "EntratiSyndicate", + standingValue: 500 + }, + "DeimosEndlessExcavateBounty": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierBTableARewards", + tag: "EntratiSyndicate", + standingValue: 550 + }, + "DeimosAssassinateBounty": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierDTableBRewards", + tag: "EntratiSyndicate", + standingValue: 600 + }, + "DeimosKeyPiecesBounty": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierETableARewards", + tag: "EntratiSyndicate", + standingValue: 650 + }, + "DeimosExcavateBounty": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierETableARewards", + tag: "EntratiSyndicate", + standingValue: 700 + }, + "VenusIntelJobSpy": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierATableBRewards", + tag: "SolarisSyndicate", + standingValue: 450 + }, + "VenusCullJobResource": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierBTableBRewards", + tag: "SolarisSyndicate", + standingValue: 500 + }, + "VenusIntelJobRecovery": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierCTableBRewards", + tag: "SolarisSyndicate", + standingValue: 550 + }, + "VenusHelpingJobCaches": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierDTableBRewards", + tag: "SolarisSyndicate", + standingValue: 600 + }, + "VenusArtifactJobAmbush": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierETableBRewards", + tag: "SolarisSyndicate", + standingValue: 650 + }, + "VenusChaosJobExcavation": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierETableBRewards", + tag: "SolarisSyndicate", + standingValue: 700 + }, + "NarmerVenusCullJobExterminate": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/NarmerTableBRewards", + tag: "SolarisSyndicate", + standingValue: 800 + }, + "AttritionBountyLib": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierBTableBRewards", + tag: "CetusSyndicate", + standingValue: 500 + }, + "RescueBountyResc": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierCTableBRewards", + tag: "CetusSyndicate", + standingValue: 550 + }, + "CaptureBountyCapTwo": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierDTableBRewards", + tag: "CetusSyndicate", + standingValue: 600 + }, + "ReclamationBountyCache": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierETableBRewards", + tag: "CetusSyndicate", + standingValue: 650 + }, + "AttritionBountyCap": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierETableBRewards", + tag: "CetusSyndicate", + standingValue: 700 + }, + "ChamberB": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/VaultBountyTierATableCRewards", + tag: "EntratiSyndicate", + standingValue: 500 + }, + "ChamberA": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/VaultBountyTierBTableCRewards", + tag: "EntratiSyndicate", + standingValue: 800 + }, + "Chamberc": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/VaultBountyTierCTableCRewards", + tag: "EntratiSyndicate", + standingValue: 1000 + }, + "HeistProfitTakerBountyOne": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/VaultBountyTierCTableCRewards111", + tag: "EntratiSyndicate", + standingValue: 1000 + }, + "AssassinateBountyAss": { + rewardManifest: "/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/NarmerTableBRewards", + tag: "CetusSyndicate", + standingValue: 800 + } + }; + + // 遍历任务类型,模糊匹配 jobId + for (const [jobType, { rewardManifest, tag, standingValue }] of Object.entries(jobRewardsMap)) { + if (RewardInfo.jobId.includes(jobType)) { + logger.debug(`Job ID contains ${jobType}, using reward manifest: ${rewardManifest}`); + + const rewardTable = ExportRewards[rewardManifest]; + if (rewardTable) { + // 使用 JobStage 作为轮次索引 + let rotation = RewardInfo.JobStage || 0; // 默认值为 0 + logger.debug("Using JobStage as rotation index:", rotation); + + // 如果 JobStage 超过 3,则按最高档(第 3 档)处理 + if (rotation > 3) { + rotation = 3; + logger.debug("JobStage exceeds 3, using highest rotation (3)"); + } + + // 检查轮次索引是否在奖励表范围内 + if (rotation >= rewardTable.length || rotation < 0) { + logger.error(`Rotation index ${rotation} is out of bounds for reward table ${rewardManifest}`); + } else { + // 获取当前轮次的奖励池 + const rotationRewards = rewardTable[rotation]; + logger.debug("Rotation rewards:", rotationRewards); + + // 从奖励池中随机选择一个奖励 + const drop = getRandomRewardByChance(rotationRewards); + if (drop) { + logger.debug("Random drop selected:", drop); + drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); + } else { + logger.debug("No drop selected from reward table"); + } + } + } else { + logger.error(`Reward table ${rewardManifest} not found in ExportRewards`); + } + + // 新增一个固定的物品奖励 + const additionalReward = { + StoreItem: "/Lotus/StoreItems/Types/Items/SyndicateDogTags/UniversalSyndicateDogTag", // 新物品的路径 + ItemCount: 1 // 物品数量 + }; + drops.push(additionalReward); + logger.debug("Added additional reward:", additionalReward); + + // 直接返回,不再执行后续的区域奖励逻辑 + return drops; + } + } + } + + + + + if (RewardInfo.node in ExportRegions) { + const region = ExportRegions[RewardInfo.node]; + const rewardManifests: string[] = + RewardInfo.periodicMissionTag == "EliteAlert" || RewardInfo.periodicMissionTag == "EliteAlertB" + ? ["/Lotus/Types/Game/MissionDecks/EliteAlertMissionRewards/EliteAlertMissionRewards"] + : region.rewardManifests; + + let rotations: number[] = []; + if (RewardInfo.VaultsCracked) { + // For Spy missions, e.g. 3 vaults cracked = A, B, C + for (let i = 0; i != RewardInfo.VaultsCracked; ++i) { + rotations.push(i); + } + } else { + const rotationCount = RewardInfo.rewardQualifications?.length || 0; + rotations = getRotations(rotationCount); + } + rewardManifests + .map(name => ExportRewards[name]) + .forEach(table => { + for (const rotation of rotations) { + const rotationRewards = table[rotation]; + const drop = getRandomRewardByChance(rotationRewards); + + // 原始掉落逻辑 + if (drop) { + drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); + } + + // EliteAlert奖励逻辑 + if (RewardInfo.periodicMissionTag === "EliteAlert" || RewardInfo.periodicMissionTag === "EliteAlertB") { + const randomCount = Math.floor(Math.random() * 5) + 1; + drops.push({ StoreItem: "/Lotus/StoreItems/Types/Items/MiscItems/Elitium", ItemCount: randomCount }); + } + // 添加 HardDaily 任务的 钢铁精华 掉落 + if (RewardInfo.periodicMissionTag?.startsWith("HardDaily")) { + let randomCount = Math.floor(Math.random() * 5) + 1; // 生成 1 到 5 的随机数 + + // 20% 的几率翻 1 到 10 倍 + if (Math.random() < 0.2) { + const multiplier = Math.floor(Math.random() * 10) + 1; // 生成 1 到 10 的随机倍数 + randomCount *= multiplier; + } + + drops.push({ StoreItem: "/Lotus/StoreItems/Types/Items/MiscItems/SteelEssence", ItemCount: randomCount }); + } + // 新增10%概率独立掉落 ▼▼▼ + if (Math.random() < 0.01) { // 每个rotation独立判定 + drops.push({ + StoreItem: "/Lotus/StoreItems/Upgrades/Skins/Volt/SWTechnoshockHelmet", + ItemCount: 1 + }); + } + } + }); + + if (region.cacheRewardManifest && RewardInfo.EnemyCachesFound) { + const deck = ExportRewards[region.cacheRewardManifest]; + for (let rotation = 0; rotation != RewardInfo.EnemyCachesFound; ++rotation) { + const drop = getRandomRewardByChance(deck[rotation]); + if (drop) { + drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount, FromEnemyCache: true }); + } + } + } + +if (RewardInfo.nightmareMode) { + const deck = ExportRewards["/Lotus/Types/Game/MissionDecks/NightmareModeRewards"]; + let rotation = 0; + + // 确保 region 已正确初始化 + if (region) { + if (region.missionIndex === 3 && RewardInfo.rewardTier) { + // 如果 missionIndex 为 3 且 rewardTier 存在,则使用 rewardTier + rotation = RewardInfo.rewardTier; + } else if ([6, 7, 8, 10, 11].includes(region.systemIndex)) { + // 如果 systemIndex 在 [6, 7, 8, 10, 11] 中,则 rotation 为 2 + rotation = 2; + } else if ([4, 9, 12, 14, 15, 16, 17, 18].includes(region.systemIndex)) { + // 如果 systemIndex 在 [4, 9, 12, 14, 15, 16, 17, 18] 中,则 rotation 为 1 + rotation = 1; + } + } + + // 确保 rotation 在 deck 的范围内 + if (rotation >= deck.length || rotation < 0) { + logger.error(`Rotation index ${rotation} is out of bounds for NightmareModeRewards`); + rotation = 0; // 如果超出范围,则使用默认值 0 + } + + // 获取当前轮次的奖励池 + const rotationRewards = deck[rotation]; + if (rotationRewards) { + // 从奖励池中随机选择一个奖励 + const drop = getRandomRewardByChance(rotationRewards); + if (drop) { + logger.debug("Nightmare mode drop selected:", drop); + drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); + } else { + logger.debug("No drop selected from NightmareModeRewards"); + } + } else { + logger.error("No rewards found for NightmareModeRewards"); + } +} + } + + // 确保函数有返回值 + return drops; +} + +const corruptedMods = [ + "/Lotus/StoreItems/Upgrades/Mods/Melee/DualStat/CorruptedHeavyDamageChargeSpeedMod", // Corrupt Charge + "/Lotus/StoreItems/Upgrades/Mods/Pistol/DualStat/CorruptedCritDamagePistol", // Hollow Point + "/Lotus/StoreItems/Upgrades/Mods/Melee/DualStat/CorruptedDamageSpeedMod", // Spoiled Strike + "/Lotus/StoreItems/Upgrades/Mods/Pistol/DualStat/CorruptedDamageRecoilPistol", // Magnum Force + "/Lotus/StoreItems/Upgrades/Mods/Pistol/DualStat/CorruptedMaxClipReloadSpeedPistol", // Tainted Clip + "/Lotus/StoreItems/Upgrades/Mods/Rifle/DualStat/CorruptedCritRateFireRateRifle", // Critical Delay + "/Lotus/StoreItems/Upgrades/Mods/Rifle/DualStat/CorruptedDamageRecoilRifle", // Heavy Caliber + "/Lotus/StoreItems/Upgrades/Mods/Rifle/DualStat/CorruptedMaxClipReloadSpeedRifle", // Tainted Mag + "/Lotus/StoreItems/Upgrades/Mods/Rifle/DualStat/CorruptedRecoilFireRateRifle", // Vile Precision + "/Lotus/StoreItems/Upgrades/Mods/Warframe/DualStat/CorruptedDurationRangeWarframe", // Narrow Minded + "/Lotus/StoreItems/Upgrades/Mods/Warframe/DualStat/CorruptedEfficiencyDurationWarframe", // Fleeting Expertise + "/Lotus/StoreItems/Upgrades/Mods/Warframe/DualStat/CorruptedPowerEfficiencyWarframe", // Blind Rage + "/Lotus/StoreItems/Upgrades/Mods/Warframe/DualStat/CorruptedRangePowerWarframe", // Overextended + "/Lotus/StoreItems/Upgrades/Mods/Shotgun/DualStat/CorruptedAccuracyFireRateShotgun", // Tainted Shell + "/Lotus/StoreItems/Upgrades/Mods/Shotgun/DualStat/CorruptedDamageAccuracyShotgun", // Vicious Spread + "/Lotus/StoreItems/Upgrades/Mods/Shotgun/DualStat/CorruptedMaxClipReloadSpeedShotgun", // Burdened Magazine + "/Lotus/StoreItems/Upgrades/Mods/Pistol/DualStat/CorruptedFireRateDamagePistol", // Anemic Agility + "/Lotus/StoreItems/Upgrades/Mods/Rifle/DualStat/CorruptedFireRateDamageRifle", // Vile Acceleration + "/Lotus/StoreItems/Upgrades/Mods/Shotgun/DualStat/CorruptedFireRateDamageShotgun", // Frail Momentum + "/Lotus/StoreItems/Upgrades/Mods/Shotgun/DualStat/CorruptedCritChanceFireRateShotgun", // Critical Deceleration + "/Lotus/StoreItems/Upgrades/Mods/Pistol/DualStat/CorruptedCritChanceFireRatePistol", // Creeping Bullseye + "/Lotus/StoreItems/Upgrades/Mods/Warframe/DualStat/CorruptedPowerStrengthPowerDurationWarframe", // Transient Fortitude + "/Lotus/StoreItems/Upgrades/Mods/Rifle/DualStat/CorruptedReloadSpeedMaxClipRifle", // Depleted Reload + "/Lotus/StoreItems/Upgrades/Mods/Warframe/DualStat/FixedShieldAndShieldGatingDuration" // Catalyzing Shields +]; + +const libraryPersonalTargetToAvatar: Record = { + "/Lotus/Types/Game/Library/Targets/DragonframeQuestTarget": + "/Lotus/Types/Enemies/Grineer/Desert/Avatars/RifleLancerAvatar", + "/Lotus/Types/Game/Library/Targets/Research1Target": + "/Lotus/Types/Enemies/Grineer/Desert/Avatars/RifleLancerAvatar", + "/Lotus/Types/Game/Library/Targets/Research2Target": + "/Lotus/Types/Enemies/Corpus/BipedRobot/AIWeek/LaserDiscBipedAvatar", + "/Lotus/Types/Game/Library/Targets/Research3Target": + "/Lotus/Types/Enemies/Grineer/Desert/Avatars/EvisceratorLancerAvatar", + "/Lotus/Types/Game/Library/Targets/Research4Target": "/Lotus/Types/Enemies/Orokin/OrokinHealingAncientAvatar", + "/Lotus/Types/Game/Library/Targets/Research5Target": + "/Lotus/Types/Enemies/Corpus/Spaceman/AIWeek/ShotgunSpacemanAvatar", + "/Lotus/Types/Game/Library/Targets/Research6Target": "/Lotus/Types/Enemies/Infested/AiWeek/Runners/RunnerAvatar", + "/Lotus/Types/Game/Library/Targets/Research7Target": + "/Lotus/Types/Enemies/Grineer/AIWeek/Avatars/GrineerMeleeStaffAvatar", + "/Lotus/Types/Game/Library/Targets/Research8Target": "/Lotus/Types/Enemies/Orokin/OrokinHeavyFemaleAvatar", + "/Lotus/Types/Game/Library/Targets/Research9Target": + "/Lotus/Types/Enemies/Infested/AiWeek/Quadrupeds/QuadrupedAvatar", + "/Lotus/Types/Game/Library/Targets/Research10Target": + "/Lotus/Types/Enemies/Corpus/Spaceman/AIWeek/NullifySpacemanAvatar" +}; diff --git a/serversideVendorsService.ts b/serversideVendorsService.ts new file mode 100644 index 00000000..cae48e6e --- /dev/null +++ b/serversideVendorsService.ts @@ -0,0 +1,100 @@ +import { IMongoDate, IOid } from "@/src/types/commonTypes"; + +import ArchimedeanVendorManifest from "@/static/fixed_responses/getVendorInfo/ArchimedeanVendorManifest.json"; +import DeimosEntratiFragmentVendorProductsManifest from "@/static/fixed_responses/getVendorInfo/DeimosEntratiFragmentVendorProductsManifest.json"; +import DeimosFishmongerVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosFishmongerVendorManifest.json"; +import DeimosHivemindCommisionsManifestFishmonger from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestFishmonger.json"; +import DeimosHivemindCommisionsManifestPetVendor from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestPetVendor.json"; +import DeimosHivemindCommisionsManifestProspector from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestProspector.json"; +import DeimosHivemindCommisionsManifestTokenVendor from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestTokenVendor.json"; +import DeimosHivemindCommisionsManifestWeaponsmith from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestWeaponsmith.json"; +import DeimosHivemindTokenVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosHivemindTokenVendorManifest.json"; +import DeimosPetVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosPetVendorManifest.json"; +import DeimosProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosProspectorVendorManifest.json"; +import DuviriAcrithisVendorManifest from "@/static/fixed_responses/getVendorInfo/DuviriAcrithisVendorManifest.json"; +import EntratiLabsEntratiLabsCommisionsManifest from "@/static/fixed_responses/getVendorInfo/EntratiLabsEntratiLabsCommisionsManifest.json"; +import EntratiLabsEntratiLabVendorManifest from "@/static/fixed_responses/getVendorInfo/EntratiLabsEntratiLabVendorManifest.json"; +import GuildAdvertisementVendorManifest from "@/static/fixed_responses/getVendorInfo/GuildAdvertisementVendorManifest.json"; +import HubsIronwakeDondaVendorManifest from "@/static/fixed_responses/getVendorInfo/HubsIronwakeDondaVendorManifest.json"; +import HubsPerrinSequenceWeaponVendorManifest from "@/static/fixed_responses/getVendorInfo/HubsPerrinSequenceWeaponVendorManifest.json"; +import HubsRailjackCrewMemberVendorManifest from "@/static/fixed_responses/getVendorInfo/HubsRailjackCrewMemberVendorManifest.json"; +import MaskSalesmanManifest from "@/static/fixed_responses/getVendorInfo/MaskSalesmanManifest.json"; +import OstronFishmongerVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronFishmongerVendorManifest.json"; +import OstronPetVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronPetVendorManifest.json"; +import OstronProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronProspectorVendorManifest.json"; +import RadioLegionIntermission12VendorManifest from "@/static/fixed_responses/getVendorInfo/RadioLegionIntermission12VendorManifest.json"; +import SolarisDebtTokenVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorManifest.json"; +import SolarisDebtTokenVendorRepossessionsManifest from "@/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorRepossessionsManifest.json"; +import SolarisFishmongerVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisFishmongerVendorManifest.json"; +import SolarisProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisProspectorVendorManifest.json"; +import TeshinHardModeVendorManifest from "@/static/fixed_responses/getVendorInfo/TeshinHardModeVendorManifest.json"; +import ZarimanCommisionsManifestArchimedean from "@/static/fixed_responses/getVendorInfo/ZarimanCommisionsManifestArchimedean.json"; + +interface IVendorManifest { + VendorInfo: { + _id: IOid; + TypeName: string; + ItemManifest: { + StoreItem: string; + ItemPrices?: { ItemType: string; ItemCount: number; ProductCategory: string }[]; + Bin: string; + QuantityMultiplier: number; + Expiry: IMongoDate; + PurchaseQuantityLimit?: number; + RotatedWeekly?: boolean; + AllowMultipurchase: boolean; + Id: IOid; + }[]; + Expiry: IMongoDate; + }; +} + +const vendorManifests: IVendorManifest[] = [ + ArchimedeanVendorManifest, + DeimosEntratiFragmentVendorProductsManifest, + DeimosFishmongerVendorManifest, + DeimosHivemindCommisionsManifestFishmonger, + DeimosHivemindCommisionsManifestPetVendor, + DeimosHivemindCommisionsManifestProspector, + DeimosHivemindCommisionsManifestTokenVendor, + DeimosHivemindCommisionsManifestWeaponsmith, + DeimosHivemindTokenVendorManifest, + DeimosPetVendorManifest, + DeimosProspectorVendorManifest, + DuviriAcrithisVendorManifest, + EntratiLabsEntratiLabsCommisionsManifest, + EntratiLabsEntratiLabVendorManifest, + GuildAdvertisementVendorManifest, + HubsIronwakeDondaVendorManifest, + HubsPerrinSequenceWeaponVendorManifest, + HubsRailjackCrewMemberVendorManifest, + MaskSalesmanManifest, + OstronFishmongerVendorManifest, + OstronPetVendorManifest, + OstronProspectorVendorManifest, + RadioLegionIntermission12VendorManifest, + SolarisDebtTokenVendorManifest, + SolarisDebtTokenVendorRepossessionsManifest, + SolarisFishmongerVendorManifest, + SolarisProspectorVendorManifest, + TeshinHardModeVendorManifest, + ZarimanCommisionsManifestArchimedean +]; + +export const getVendorManifestByTypeName = (typeName: string): IVendorManifest | undefined => { + for (const vendorManifest of vendorManifests) { + if (vendorManifest.VendorInfo.TypeName == typeName) { + return vendorManifest; + } + } + return undefined; +}; + +export const getVendorManifestByOid = (oid: string): IVendorManifest | undefined => { + for (const vendorManifest of vendorManifests) { + if (vendorManifest.VendorInfo._id.$oid == oid) { + return vendorManifest; + } + } + return undefined; +};