import { ExportEnemies, ExportFusionBundles, ExportRegions, ExportRewards, IMissionReward as IMissionRewardExternal, IRegion, IReward } from "warframe-public-export-plus"; import { IMissionInventoryUpdateRequest, IRewardInfo } from "../types/requestTypes"; import { logger } from "@/src/utils/logger"; import { IRngResult, SRng, getRandomElement, getRandomReward } from "@/src/services/rngService"; import { equipmentKeys, TEquipmentKey } from "@/src/types/inventoryTypes/inventoryTypes"; import { addBooster, addChallenges, addConsumables, addCrewShipAmmo, addCrewShipRawSalvage, addEmailItem, addFocusXpIncreases, addFusionPoints, addFusionTreasures, addGearExpByCategory, addItem, addLevelKeys, addLoreFragmentScans, addMiscItems, addMissionComplete, addMods, addRecipes, addShipDecorations, addSkin, addStanding, combineInventoryChanges, generateRewardSeed, getCalendarProgress, updateCurrency, updateSyndicate } from "@/src/services/inventoryService"; import { updateQuestKey } from "@/src/services/questService"; import { Types } from "mongoose"; import { IAffiliationMods, IInventoryChanges } from "@/src/types/purchaseTypes"; import { getLevelKeyRewards, 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"; import { handleStoreItemAcquisition } from "./purchaseService"; import { IMissionCredits, 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"; import conservationAnimals from "@/static/fixed_responses/conservationAnimals.json"; import { getInfNodes } from "@/src/helpers/nemesisHelpers"; import { Loadout } from "../models/inventoryModels/loadoutModel"; import { ILoadoutConfigDatabase } from "../types/saveLoadoutTypes"; import { getLiteSortie, getWorldState, idToWeek } from "./worldStateService"; import { config } from "./configService"; import libraryDailyTasks from "@/static/fixed_responses/libraryDailyTasks.json"; const getRotations = (rewardInfo: IRewardInfo, tierOverride?: number): number[] => { // For Spy missions, e.g. 3 vaults cracked = A, B, C if (rewardInfo.VaultsCracked) { const rotations: number[] = []; for (let i = 0; i != rewardInfo.VaultsCracked; ++i) { rotations.push(i); } return rotations; } // For Rescue missions if (rewardInfo.node in ExportRegions && ExportRegions[rewardInfo.node].missionIndex == 3 && rewardInfo.rewardTier) { return [rewardInfo.rewardTier]; } const rotationCount = rewardInfo.rewardQualifications?.length || 0; // Empty or absent rewardQualifications should not give rewards: // - Aborting a railjack mission (https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1741) // - Completing only 1 zone of (E)SO (https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1823) const rotationPattern = tierOverride === undefined ? [0, 0, 1, 2] // A, A, B, C : [tierOverride]; const rotatedValues = []; for (let i = 0; i < rotationCount; i++) { rotatedValues.push(rotationPattern[i % rotationPattern.length]); } return rotatedValues; }; const getRandomRewardByChance = (pool: IReward[], rng?: SRng): IRngResult | undefined => { if (rng) { const res = rng.randomReward(pool as IRngResult[]); rng.randomFloat(); // something related to rewards multiplier return res; } 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: TInventoryDatabaseDocument, inventoryUpdates: IMissionInventoryUpdateRequest ): Promise => { const inventoryChanges: IInventoryChanges = {}; if (inventoryUpdates.EndOfMatchUpload) { if (inventoryUpdates.Missions && inventoryUpdates.Missions.Tag in ExportRegions) { const node = ExportRegions[inventoryUpdates.Missions.Tag]; if (node.miscItemFee) { addMiscItems(inventory, [ { ItemType: node.miscItemFee.ItemType, ItemCount: node.miscItemFee.ItemCount * -1 } ]); } } if (inventoryUpdates.KeyToRemove) { if (!inventoryUpdates.KeyOwner || inventory.accountOwnerId.equals(inventoryUpdates.KeyOwner)) { addLevelKeys(inventory, [ { ItemType: inventoryUpdates.KeyToRemove, ItemCount: -1 } ]); } } // Somewhat heuristically detect G3 capture: // - https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1365 // - https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1694 // - https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1724 if ( inventoryUpdates.MissionFailed && inventoryUpdates.MissionStatus == "GS_FAILURE" && inventoryUpdates.ObjectiveReached && !inventoryUpdates.LockedWeaponGroup && !inventory.LockedWeaponGroup && !inventoryUpdates.LevelKeyName ) { const loadout = (await Loadout.findById(inventory.LoadOutPresets, "NORMAL"))!; const config = loadout.NORMAL.id(inventory.CurrentLoadOutIds[0].$oid)!; const SuitId = new Types.ObjectId(config.s!.ItemId.$oid); inventory.BrandedSuits ??= []; if (!inventory.BrandedSuits.find(x => x.equals(SuitId))) { inventory.BrandedSuits.push(SuitId); await createMessage(inventory.accountOwnerId, [ { sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender", msg: "/Lotus/Language/G1Quests/BrandedMessage", sub: "/Lotus/Language/G1Quests/BrandedTitle", att: ["/Lotus/Types/Recipes/Components/BrandRemovalBlueprint"], highPriority: true // TOVERIFY: I cannot find any content of this within the last 10 years so I can only assume that highPriority is set (it certainly would make sense), but I just don't know for sure that it is so on live. } ]); } } } if (inventoryUpdates.RewardInfo) { if (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.NemesisAbandonedRewards) { inventory.NemesisAbandonedRewards = inventoryUpdates.RewardInfo.NemesisAbandonedRewards; } if (inventoryUpdates.MissionStatus == "GS_SUCCESS" && inventoryUpdates.RewardInfo.jobId) { // e.g. for Profit-Taker Phase 1: // JobTier: -6, // jobId: '/Lotus/Types/Gameplay/Venus/Jobs/Heists/HeistProfitTakerBountyOne_-6_SolarisUnitedHub1_663a71c80000000000000025_EudicoHeists', // This is sent multiple times, with JobStage starting at 0 and incrementing each time, but only the final upload has GS_SUCCESS. // eslint-disable-next-line @typescript-eslint/no-unused-vars const [bounty, tier, hub, id, tag] = inventoryUpdates.RewardInfo.jobId.split("_"); if (tag == "EudicoHeists") { inventory.CompletedJobChains ??= []; let chain = inventory.CompletedJobChains.find(x => x.LocationTag == tag); if (!chain) { chain = inventory.CompletedJobChains[ inventory.CompletedJobChains.push({ LocationTag: tag, Jobs: [] }) - 1 ]; } if (!chain.Jobs.includes(bounty)) { chain.Jobs.push(bounty); if (bounty == "/Lotus/Types/Gameplay/Venus/Jobs/Heists/HeistProfitTakerBountyThree") { await createMessage(inventory.accountOwnerId, [ { sub: "/Lotus/Language/SolarisHeists/HeavyCatalystInboxTitle", sndr: "/Lotus/Language/Bosses/Ordis", msg: "/Lotus/Language/SolarisHeists/HeavyCatalystInboxMessage", icon: "/Lotus/Interface/Icons/Npcs/Ordis.png", att: ["/Lotus/Types/Restoratives/HeavyWeaponSummon"], highPriority: true } ]); await addItem(inventory, "/Lotus/Types/Items/MiscItems/HeavyWeaponCatalyst", 1); } } } } } 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, inventoryUpdates.SeasonChallengeCompletions); break; case "FusionTreasures": addFusionTreasures(inventory, value); break; case "CrewShipRawSalvage": addCrewShipRawSalvage(inventory, value); break; case "CrewShipAmmo": addCrewShipAmmo(inventory, value); break; case "ShipDecorations": // e.g. when getting a 50+ score in happy zephyr, this is how the poster is given. addShipDecorations(inventory, value); break; case "FusionBundles": { let fusionPointsDelta = 0; for (const fusionBundle of value) { fusionPointsDelta += addFusionPoints( inventory, ExportFusionBundles[fusionBundle.ItemType].fusionPoints * fusionBundle.ItemCount ); } inventoryChanges.FusionPoints = fusionPointsDelta; 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": addLoreFragmentScans(inventory, value); break; case "LibraryScans": value.forEach(scan => { let synthesisIgnored = true; if (inventory.LibraryPersonalTarget) { const taskAvatar = libraryPersonalTargetToAvatar[inventory.LibraryPersonalTarget]; const taskAvatars = libraryDailyTasks.find(x => x.indexOf(taskAvatar) != -1)!; if (taskAvatars.indexOf(scan.EnemyType) != -1) { 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, [kuriaMessage50]); } else { await createMessage(inventory.accountOwnerId, [kuriaMessage75]); } } } if (progress >= 1.0) { await createMessage(inventory.accountOwnerId, [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 "WeaponSkins": for (const item of value) { addSkin(inventory, item.ItemType); } break; case "Boosters": value.forEach(booster => { addBooster(booster.ItemType, booster.ExpiryDate, inventory); }); break; case "SyndicateId": { inventory.CompletedSyndicates.push(value); break; } case "SortieId": { if (inventory.CompletedSorties.indexOf(value) == -1) { 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": { if (!config.noDeathMarks) { for (const bossName of value) { if (inventory.DeathMarks.indexOf(bossName) == -1) { // It's a new death mark; we have to say the line. await createMessage(inventory.accountOwnerId, [ { sub: bossName, sndr: "/Lotus/Language/G1Quests/DeathMarkSender", msg: "/Lotus/Language/G1Quests/DeathMarkMessage", icon: "/Lotus/Interface/Icons/Npcs/Stalker_d.png", highPriority: true, expiry: new Date(Date.now() + 86400_000) // TOVERIFY: This type of inbox message seems to automatically delete itself. We'll just delete it after 24 hours, but it's clear if this is correct. } ]); } } inventory.DeathMarks = value; } break; } case "CapturedAnimals": { for (const capturedAnimal of value) { const meta = conservationAnimals[capturedAnimal.AnimalType as keyof typeof conservationAnimals]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (meta) { if (capturedAnimal.NumTags) { addMiscItems(inventory, [ { ItemType: meta.tag, ItemCount: capturedAnimal.NumTags } ]); } if (capturedAnimal.NumExtraRewards) { if ("extraReward" in meta) { addMiscItems(inventory, [ { ItemType: meta.extraReward, ItemCount: capturedAnimal.NumExtraRewards } ]); } else { logger.warn( `client attempted to claim unknown extra rewards for conservation of ${capturedAnimal.AnimalType}` ); } } } else { logger.warn(`ignoring conservation of unknown AnimalType: ${capturedAnimal.AnimalType}`); } } break; } case "DiscoveredMarkers": { for (const clientMarker of value) { const dbMarker = inventory.DiscoveredMarkers.find(x => x.tag == clientMarker.tag); if (dbMarker) { dbMarker.discoveryState = clientMarker.discoveryState; } else { inventory.DiscoveredMarkers.push(clientMarker); } } break; } case "LockedWeaponGroup": { inventory.LockedWeaponGroup = { s: new Types.ObjectId(value.s.$oid), l: value.l ? new Types.ObjectId(value.l.$oid) : undefined, p: value.p ? new Types.ObjectId(value.p.$oid) : undefined, m: value.m ? new Types.ObjectId(value.m.$oid) : undefined, sn: value.sn ? new Types.ObjectId(value.sn.$oid) : undefined }; break; } case "UnlockWeapons": { inventory.LockedWeaponGroup = undefined; break; } case "CurrentLoadOutIds": { if (value.LoadOuts) { const loadout = await Loadout.findOne({ loadoutOwnerId: inventory.accountOwnerId }); if (loadout) { for (const [loadoutId, loadoutConfig] of Object.entries(value.LoadOuts.NORMAL)) { const { ItemId, ...loadoutConfigItemIdRemoved } = loadoutConfig; const loadoutConfigDatabase: ILoadoutConfigDatabase = { _id: new Types.ObjectId(ItemId.$oid), ...loadoutConfigItemIdRemoved }; const dbConfig = loadout.NORMAL.id(loadoutId); if (dbConfig) { dbConfig.overwrite(loadoutConfigDatabase); } else { logger.warn(`couldn't update loadout because there's no config with id ${loadoutId}`); } } await loadout.save(); } } break; } case "creditsFee": { updateCurrency(inventory, value, false); inventoryChanges.RegularCredits ??= 0; inventoryChanges.RegularCredits -= value; break; } case "InvasionProgress": { for (const clientProgress of value) { const dbProgress = inventory.QualifyingInvasions.find(x => x.invasionId.equals(clientProgress._id.$oid) ); if (dbProgress) { dbProgress.Delta += clientProgress.Delta; dbProgress.AttackerScore += clientProgress.AttackerScore; dbProgress.DefenderScore += clientProgress.DefenderScore; } else { inventory.QualifyingInvasions.push({ invasionId: new Types.ObjectId(clientProgress._id.$oid), Delta: clientProgress.Delta, AttackerScore: clientProgress.AttackerScore, DefenderScore: clientProgress.DefenderScore }); } } break; } case "CalendarProgress": { const calendarProgress = getCalendarProgress(inventory); for (const progress of value) { const challengeName = progress.challenge.substring(progress.challenge.lastIndexOf("/") + 1); calendarProgress.SeasonProgress.LastCompletedChallengeDayIdx++; calendarProgress.SeasonProgress.ActivatedChallenges.push(challengeName); } break; } case "duviriCaveOffers": { // Duviri cave offers (generated with the duviri seed) change after completing one of its game modes (not when aborting). if (inventoryUpdates.MissionStatus != "GS_QUIT") { inventory.DuviriInfo.Seed = generateRewardSeed(); } break; } default: // Equipment XP updates if (equipmentKeys.includes(key as TEquipmentKey)) { addGearExpByCategory(inventory, value as IEquipmentClient[], 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; AffiliationMods?: IAffiliationMods[]; SyndicateXPItemReward?: number; ConquestCompletedMissionsCount?: number; } interface IConquestReward { at: number; pool: IRngResult[]; } const labConquestRewards: IConquestReward[] = [ { at: 5, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/EntratiLabConquestRewards/EntratiLabConquestSilverRewards" ][0] as IRngResult[] }, { at: 10, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/EntratiLabConquestRewards/EntratiLabConquestSilverRewards" ][0] as IRngResult[] }, { at: 15, pool: [ { type: "/Lotus/StoreItems/Types/Gameplay/EntratiLab/Resources/EntratiLanthornBundle", itemCount: 3, probability: 1 } ] }, { at: 20, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/EntratiLabConquestRewards/EntratiLabConquestGoldRewards" ][0] as IRngResult[] }, { at: 28, pool: [ { type: "/Lotus/StoreItems/Types/Items/MiscItems/DistillPoints", itemCount: 20, probability: 1 } ] }, { at: 31, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/EntratiLabConquestRewards/EntratiLabConquestGoldRewards" ][0] as IRngResult[] }, { at: 34, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/EntratiLabConquestRewards/EntratiLabConquestArcaneRewards" ][0] as IRngResult[] }, { at: 37, pool: [ { type: "/Lotus/StoreItems/Types/Items/MiscItems/DistillPoints", itemCount: 50, probability: 1 } ] } ]; const hexConquestRewards: IConquestReward[] = [ { at: 5, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/1999ConquestRewards/1999ConquestSilverRewards" ][0] as IRngResult[] }, { at: 10, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/1999ConquestRewards/1999ConquestSilverRewards" ][0] as IRngResult[] }, { at: 15, pool: [ { type: "/Lotus/StoreItems/Types/BoosterPacks/1999StickersPackEchoesArchimedea", itemCount: 1, probability: 1 } ] }, { at: 20, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/1999ConquestRewards/1999ConquestGoldRewards" ][0] as IRngResult[] }, { at: 28, pool: [ { type: "/Lotus/StoreItems/Types/Items/MiscItems/1999ConquestBucks", itemCount: 6, probability: 1 } ] }, { at: 31, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/1999ConquestRewards/1999ConquestGoldRewards" ][0] as IRngResult[] }, { at: 34, pool: ExportRewards[ "/Lotus/Types/Game/MissionDecks/1999ConquestRewards/1999ConquestArcaneRewards" ][0] as IRngResult[] }, { at: 37, pool: [ { type: "/Lotus/StoreItems/Types/Items/MiscItems/1999ConquestBucks", itemCount: 9, probability: 1 } ] } ]; //TODO: return type of partial missioninventoryupdate response export const addMissionRewards = async ( inventory: TInventoryDatabaseDocument, { wagerTier: wagerTier, Nemesis: nemesis, RewardInfo: rewardInfo, LevelKeyName: levelKeyName, Missions: missions, RegularCredits: creditDrops, VoidTearParticipantsCurrWave: voidTearWave, StrippedItems: strippedItems }: IMissionInventoryUpdateRequest, firstCompletion: boolean ): 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(inventory, rewardInfo, wagerTier, firstCompletion); logger.debug("random mission drops:", MissionRewards); const inventoryChanges: IInventoryChanges = {}; const AffiliationMods: IAffiliationMods[] = []; let SyndicateXPItemReward; let ConquestCompletedMissionsCount; 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, rewardInfo); } 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 }); } } } // ignoring tags not in ExportRegions, because it can just be garbage: // - https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1013 // - https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1365 if (missions && missions.Tag in ExportRegions) { const node = ExportRegions[missions.Tag]; //node based credit rewards for mission completion if ( node.missionIndex != 23 && // junction node.missionIndex != 28 && // open world missions.Tag != "SolNode761" && // the index missions.Tag != "SolNode762" && // the index missions.Tag != "SolNode763" && // the index missions.Tag != "CrewBattleNode556" && // free flight getRotations(rewardInfo).length > 0 // (E)SO should not give credits for only completing zone 1, in which case it has no rewardQualifications (https://onlyg.it/OpenWF/SpaceNinjaServer/issues/1823) ) { const levelCreditReward = getLevelCreditRewards(node); missionCompletionCredits += levelCreditReward; inventory.RegularCredits += levelCreditReward; logger.debug(`levelCreditReward ${levelCreditReward}`); } if (node.missionReward) { missionCompletionCredits += addFixedLevelRewards(node.missionReward, inventory, MissionRewards, rewardInfo); } if (rewardInfo.sortieTag == "Mission1") { missionCompletionCredits += 20_000; } else if (rewardInfo.sortieTag == "Mission2") { missionCompletionCredits += 30_000; } else if (rewardInfo.sortieTag == "Final") { missionCompletionCredits += 50_000; } if (missions.Tag == "PlutoToErisJunction") { await createMessage(inventory.accountOwnerId, [ { sndr: "/Lotus/Language/G1Quests/GolemQuestJordasName", msg: "/Lotus/Language/G1Quests/GolemQuestIntroBody", att: ["/Lotus/Types/Keys/GolemQuest/GolemQuestKeyChainItem"], sub: "/Lotus/Language/G1Quests/GolemQuestIntroTitle", icon: "/Lotus/Interface/Icons/Npcs/JordasPortrait.png", highPriority: true } ]); } } if (rewardInfo.useVaultManifest) { MissionRewards.push({ StoreItem: getRandomElement(corruptedMods), ItemCount: 1 }); } if (rewardInfo.ConquestCompleted !== undefined) { let score = 1; if (rewardInfo.ConquestHardModeActive === 1) score += 3; if (rewardInfo.ConquestPersonalModifiersActive !== undefined) score += rewardInfo.ConquestPersonalModifiersActive; if (rewardInfo.ConquestEquipmentSuggestionsFulfilled !== undefined) score += rewardInfo.ConquestEquipmentSuggestionsFulfilled; score *= rewardInfo.ConquestCompleted + 1; if (rewardInfo.ConquestCompleted == 2 && rewardInfo.ConquestHardModeActive === 1) score += 1; logger.debug(`completed conquest mission ${rewardInfo.ConquestCompleted + 1} for a score of ${score}`); const conquestType = rewardInfo.ConquestType; const conquestNode = conquestType == "HexConquest" ? "EchoesHexConquestHardModeUnlocked" : "EntratiLabConquestHardModeUnlocked"; if (score >= 25 && inventory.NodeIntrosCompleted.indexOf(conquestNode) == -1) inventory.NodeIntrosCompleted.push(conquestNode); if (conquestType == "HexConquest") { inventory.EchoesHexConquestCacheScoreMission ??= 0; if (score > inventory.EchoesHexConquestCacheScoreMission) { for (const reward of hexConquestRewards) { if (score >= reward.at && inventory.EchoesHexConquestCacheScoreMission < reward.at) { const rolled = getRandomReward(reward.pool)!; logger.debug(`rolled hex conquest reward for reaching ${reward.at} points`, rolled); MissionRewards.push({ StoreItem: rolled.type, ItemCount: rolled.itemCount }); } } inventory.EchoesHexConquestCacheScoreMission = score; } } else { inventory.EntratiLabConquestCacheScoreMission ??= 0; if (score > inventory.EntratiLabConquestCacheScoreMission) { for (const reward of labConquestRewards) { if (score >= reward.at && inventory.EntratiLabConquestCacheScoreMission < reward.at) { const rolled = getRandomReward(reward.pool)!; logger.debug(`rolled lab conquest reward for reaching ${reward.at} points`, rolled); MissionRewards.push({ StoreItem: rolled.type, ItemCount: rolled.itemCount }); } } inventory.EntratiLabConquestCacheScoreMission = score; } } ConquestCompletedMissionsCount = rewardInfo.ConquestCompleted == 2 ? 0 : rewardInfo.ConquestCompleted + 1; } for (const reward of MissionRewards) { const inventoryChange = await handleStoreItemAcquisition( reward.StoreItem, inventory, reward.ItemCount, undefined, true ); //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) { if (si.DropTable == "/Lotus/Types/DropTables/ManInTheWall/MITWGruzzlingArcanesDropTable") { logger.debug( `rewriting ${si.DropTable} to /Lotus/Types/DropTables/EntratiLabDropTables/DoppelgangerDropTable` ); si.DropTable = "/Lotus/Types/DropTables/EntratiLabDropTables/DoppelgangerDropTable"; } const droptables = ExportEnemies.droptables[si.DropTable] ?? []; if (si.DROP_MOD) { const modDroptable = droptables.find(x => x.type == "mod"); if (modDroptable) { for (let i = 0; i != si.DROP_MOD.length; ++i) { const reward = getRandomReward(modDroptable.items)!; logger.debug(`stripped droptable (mods pool) rolled`, reward); await addItem(inventory, reward.type); MissionRewards.push({ StoreItem: toStoreItem(reward.type), ItemCount: 1, FromEnemyCache: true // to show "identified" }); } } else { logger.error(`unknown droptable ${si.DropTable} for DROP_MOD`); } } if (si.DROP_BLUEPRINT) { const blueprintDroptable = droptables.find(x => x.type == "blueprint"); if (blueprintDroptable) { for (let i = 0; i != si.DROP_BLUEPRINT.length; ++i) { const reward = getRandomReward(blueprintDroptable.items)!; logger.debug(`stripped droptable (blueprints pool) rolled`, reward); await addItem(inventory, reward.type); MissionRewards.push({ StoreItem: toStoreItem(reward.type), ItemCount: 1, FromEnemyCache: true // to show "identified" }); } } else { logger.error(`unknown droptable ${si.DropTable} for DROP_BLUEPRINT`); } } } } if (inventory.Nemesis) { if ( nemesis || (inventory.Nemesis.Faction == "FC_INFESTATION" && inventory.Nemesis.InfNodes.find(obj => obj.Node == rewardInfo.node)) ) { inventoryChanges.Nemesis ??= {}; const nodeIndex = inventory.Nemesis.InfNodes.findIndex(obj => obj.Node === rewardInfo.node); if (nodeIndex !== -1) inventory.Nemesis.InfNodes.splice(nodeIndex, 1); if (inventory.Nemesis.InfNodes.length <= 0) { if (inventory.Nemesis.Faction != "FC_INFESTATION") { inventory.Nemesis.Rank = Math.min(inventory.Nemesis.Rank + 1, 4); inventoryChanges.Nemesis.Rank = inventory.Nemesis.Rank; } inventory.Nemesis.InfNodes = getInfNodes(inventory.Nemesis.Faction, inventory.Nemesis.Rank); } if (inventory.Nemesis.Faction == "FC_INFESTATION") { inventory.Nemesis.MissionCount += 1; inventoryChanges.Nemesis.MissionCount ??= 0; inventoryChanges.Nemesis.MissionCount += 1; } inventoryChanges.Nemesis.InfNodes = inventory.Nemesis.InfNodes; } } if (rewardInfo.JobStage != undefined && rewardInfo.jobId) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [jobType, unkIndex, hubNode, syndicateId, locationTag] = rewardInfo.jobId.split("_"); const worldState = getWorldState(); let syndicateEntry = worldState.SyndicateMissions.find(m => m._id.$oid === syndicateId); if (!syndicateEntry) syndicateEntry = worldState.SyndicateMissions.find(m => m.Tag === syndicateId); // Sometimes syndicateId can be tag if (syndicateEntry && syndicateEntry.Jobs) { let currentJob = syndicateEntry.Jobs[rewardInfo.JobTier!]; if (syndicateEntry.Tag === "EntratiSyndicate") { const vault = syndicateEntry.Jobs.find(j => j.locationTag === locationTag); if (vault) currentJob = vault; let medallionAmount = currentJob.xpAmounts[rewardInfo.JobStage]; if ( ["DeimosEndlessAreaDefenseBounty", "DeimosEndlessExcavateBounty", "DeimosEndlessPurifyBounty"].some( ending => jobType.endsWith(ending) ) ) { const endlessJob = syndicateEntry.Jobs.find(j => j.endless); if (endlessJob) { const index = rewardInfo.JobStage % endlessJob.xpAmounts.length; const excess = Math.floor(rewardInfo.JobStage / (endlessJob.xpAmounts.length - 1)); medallionAmount = Math.floor(endlessJob.xpAmounts[index] * (1 + 0.15000001 * excess)); } } await addItem(inventory, "/Lotus/Types/Items/Deimos/EntratiFragmentUncommonB", medallionAmount); MissionRewards.push({ StoreItem: "/Lotus/StoreItems/Types/Items/Deimos/EntratiFragmentUncommonB", ItemCount: medallionAmount }); SyndicateXPItemReward = medallionAmount; } else { if (rewardInfo.JobTier! >= 0) { AffiliationMods.push( addStanding(inventory, syndicateEntry.Tag, currentJob.xpAmounts[rewardInfo.JobStage]) ); } else { if (jobType.endsWith("Heists/HeistProfitTakerBountyOne") && rewardInfo.JobStage === 2) { AffiliationMods.push(addStanding(inventory, syndicateEntry.Tag, 1000)); } if (jobType.endsWith("Hunts/AllTeralystsHunt") && rewardInfo.JobStage === 2) { AffiliationMods.push(addStanding(inventory, syndicateEntry.Tag, 5000)); } if ( [ "Hunts/TeralystHunt", "Heists/HeistProfitTakerBountyTwo", "Heists/HeistProfitTakerBountyThree", "Heists/HeistProfitTakerBountyFour", "Heists/HeistExploiterBountyOne" ].some(ending => jobType.endsWith(ending)) ) { AffiliationMods.push(addStanding(inventory, syndicateEntry.Tag, 1000)); } } } } } if (rewardInfo.challengeMissionId) { const [syndicateTag, tierStr] = rewardInfo.challengeMissionId.split("_"); // TODO: third part in HexSyndicate jobs - Chemistry points const tier = Number(tierStr); const isSteelPath = missions?.Tier; if (syndicateTag === "ZarimanSyndicate") { let medallionAmount = tier + 1; if (isSteelPath) medallionAmount = Math.round(medallionAmount * 1.5); await addItem(inventory, "/Lotus/Types/Gameplay/Zariman/Resources/ZarimanDogTagBounty", medallionAmount); MissionRewards.push({ StoreItem: "/Lotus/StoreItems/Types/Gameplay/Zariman/Resources/ZarimanDogTagBounty", ItemCount: medallionAmount }); SyndicateXPItemReward = medallionAmount; } else { let standingAmount = (tier + 1) * 1000; if (tier > 5) standingAmount = 7500; // InfestedLichBounty if (isSteelPath) standingAmount *= 1.5; AffiliationMods.push(addStanding(inventory, syndicateTag, standingAmount)); } if (isSteelPath) { await addItem(inventory, "/Lotus/Types/Items/MiscItems/SteelEssence", 1); MissionRewards.push({ StoreItem: "/Lotus/StoreItems/Types/Items/MiscItems/SteelEssence", ItemCount: 1 }); } } return { inventoryChanges, MissionRewards, credits, AffiliationMods, SyndicateXPItemReward, ConquestCompletedMissionsCount }; }; //creditBonus is not entirely accurate. //TODO: consider ActiveBoosters export const addCredits = ( inventory: TInventoryDatabaseDocument, { 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[], rewardInfo?: IRewardInfo ): 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); } } if (rewards.droptable) { if (rewards.droptable in ExportRewards) { const rotations: number[] = rewardInfo ? getRotations(rewardInfo) : [0]; logger.debug(`rolling ${rewards.droptable} for level key rewards`, { rotations }); for (const tier of rotations) { const reward = getRandomRewardByChance(ExportRewards[rewards.droptable][tier]); if (reward) { MissionRewards.push({ StoreItem: reward.type, ItemCount: reward.itemCount }); } } } else { logger.error(`unknown droptable ${rewards.droptable}`); } } return missionBonusCredits; }; function getLevelCreditRewards(node: IRegion): number { const minEnemyLevel = node.minEnemyLevel; return 1000 + (minEnemyLevel - 1) * 100; //TODO: get dark sektor fixed credit rewards and railjack bonus } function getRandomMissionDrops( inventory: TInventoryDatabaseDocument, RewardInfo: IRewardInfo, tierOverride: number | undefined, firstCompletion: boolean ): IMissionReward[] { const drops: IMissionReward[] = []; if (RewardInfo.sortieTag == "Final" && firstCompletion) { const arr = RewardInfo.sortieId!.split("_"); let sortieId = arr[1]; if (sortieId == "Lite") { sortieId = arr[2]; const boss = getLiteSortie(idToWeek(sortieId)).Boss; let crystalType = { SORTIE_BOSS_AMAR: "/Lotus/StoreItems/Types/Gameplay/NarmerSorties/ArchonCrystalAmar", SORTIE_BOSS_NIRA: "/Lotus/StoreItems/Types/Gameplay/NarmerSorties/ArchonCrystalNira", SORTIE_BOSS_BOREAL: "/Lotus/StoreItems/Types/Gameplay/NarmerSorties/ArchonCrystalBoreal" }[boss]; const attenTag = { SORTIE_BOSS_AMAR: "NarmerSortieAmarCrystalRewards", SORTIE_BOSS_NIRA: "NarmerSortieNiraCrystalRewards", SORTIE_BOSS_BOREAL: "NarmerSortieBorealCrystalRewards" }[boss]; const attenIndex = inventory.SortieRewardAttenuation?.findIndex(x => x.Tag == attenTag) ?? -1; const mythicProbability = 0.2 + (inventory.SortieRewardAttenuation?.find(x => x.Tag == attenTag)?.Atten ?? 0); if (Math.random() < mythicProbability) { crystalType += "Mythic"; if (attenIndex != -1) { inventory.SortieRewardAttenuation!.splice(attenIndex, 1); } } else { if (attenIndex == -1) { inventory.SortieRewardAttenuation ??= []; inventory.SortieRewardAttenuation.push({ Tag: attenTag, Atten: 0.2 }); } else { inventory.SortieRewardAttenuation![attenIndex].Atten += 0.2; } } drops.push({ StoreItem: crystalType, ItemCount: 1 }); const drop = getRandomRewardByChance( ExportRewards["/Lotus/Types/Game/MissionDecks/ArchonSortieRewards"][0] )!; drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); inventory.LastLiteSortieReward = [ { SortieId: new Types.ObjectId(sortieId), StoreItem: drop.type, Manifest: "/Lotus/Types/Game/MissionDecks/ArchonSortieRewards" } ]; } else { const drop = getRandomRewardByChance(ExportRewards["/Lotus/Types/Game/MissionDecks/SortieRewards"][0])!; drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); inventory.LastSortieReward = [ { SortieId: new Types.ObjectId(sortieId), StoreItem: drop.type, Manifest: "/Lotus/Types/Game/MissionDecks/SortieRewards" } ]; } } if (RewardInfo.periodicMissionTag?.startsWith("HardDaily")) { drops.push({ StoreItem: "/Lotus/StoreItems/Types/Items/MiscItems/SteelEssence", ItemCount: 5 }); } if (RewardInfo.node in ExportRegions) { const region = ExportRegions[RewardInfo.node]; let rewardManifests: string[]; if (RewardInfo.periodicMissionTag == "EliteAlert" || RewardInfo.periodicMissionTag == "EliteAlertB") { rewardManifests = ["/Lotus/Types/Game/MissionDecks/EliteAlertMissionRewards/EliteAlertMissionRewards"]; } else if (RewardInfo.invasionId && region.missionIndex == 0) { // Invasion assassination has Phorid has the boss who should drop Nyx parts // TODO: Check that the invasion faction is indeed FC_INFESTATION once the Invasions in worldState are more dynamic rewardManifests = ["/Lotus/Types/Game/MissionDecks/BossMissionRewards/NyxRewards"]; } else { rewardManifests = region.rewardManifests; } let rotations: number[] = []; if (RewardInfo.jobId) { if (RewardInfo.JobStage! >= 0) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [jobType, unkIndex, hubNode, syndicateId, locationTag] = RewardInfo.jobId.split("_"); let isEndlessJob = false; if (syndicateId) { const worldState = getWorldState(); let syndicateEntry = worldState.SyndicateMissions.find(m => m._id.$oid === syndicateId); if (!syndicateEntry) syndicateEntry = worldState.SyndicateMissions.find(m => m.Tag === syndicateId); if (syndicateEntry && syndicateEntry.Jobs) { let job = syndicateEntry.Jobs[RewardInfo.JobTier!]; if (syndicateEntry.Tag === "EntratiSyndicate") { const vault = syndicateEntry.Jobs.find(j => j.locationTag === locationTag); if (vault && locationTag) job = vault; // if ( // [ // "DeimosRuinsExterminateBounty", // "DeimosRuinsEscortBounty", // "DeimosRuinsMistBounty", // "DeimosRuinsPurifyBounty", // "DeimosRuinsSacBounty" // ].some(ending => jobType.endsWith(ending)) // ) { // job.rewards = "TODO"; // Droptable for Arcana Isolation Vault // } if ( [ "DeimosEndlessAreaDefenseBounty", "DeimosEndlessExcavateBounty", "DeimosEndlessPurifyBounty" ].some(ending => jobType.endsWith(ending)) ) { const endlessJob = syndicateEntry.Jobs.find(j => j.endless); if (endlessJob) { isEndlessJob = true; job = endlessJob; const excess = Math.floor(RewardInfo.JobStage! / (job.xpAmounts.length - 1)); const rotationIndexes = [0, 0, 1, 2]; const rotationIndex = rotationIndexes[excess % rotationIndexes.length]; const dropTable = [ "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierBTableARewards", "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierBTableBRewards", "/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierBTableCRewards" ]; job.rewards = dropTable[rotationIndex]; } } } else if (syndicateEntry.Tag === "SolarisSyndicate") { if (jobType.endsWith("Heists/HeistProfitTakerBountyOne") && RewardInfo.JobStage == 2) { job = { rewards: "/Lotus/Types/Game/MissionDecks/HeistJobMissionRewards/HeistTierATableARewards", masteryReq: 0, minEnemyLevel: 40, maxEnemyLevel: 60, xpAmounts: [1000] }; RewardInfo.Q = false; // Just in case } else { const tierMap = { Two: "B", Three: "C", Four: "D" }; for (const [key, tier] of Object.entries(tierMap)) { if (jobType.endsWith(`Heists/HeistProfitTakerBounty${key}`)) { job = { rewards: `/Lotus/Types/Game/MissionDecks/HeistJobMissionRewards/HeistTier${tier}TableARewards`, masteryReq: 0, minEnemyLevel: 40, maxEnemyLevel: 60, xpAmounts: [1000] }; RewardInfo.Q = false; // Just in case break; } } } } rewardManifests = [job.rewards]; rotations = [RewardInfo.JobStage! % (job.xpAmounts.length - 1)]; if ( RewardInfo.Q && (RewardInfo.JobStage === job.xpAmounts.length - 1 || job.isVault) && !isEndlessJob ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ExportRewards[job.rewards]) { rewardManifests.push(job.rewards); rotations.push(ExportRewards[job.rewards].length - 1); } } } } } } else if (RewardInfo.challengeMissionId) { const rewardTables: Record = { EntratiLabSyndicate: [ "/Lotus/Types/Game/MissionDecks/EntratiLabJobMissionReward/TierATableRewards", "/Lotus/Types/Game/MissionDecks/EntratiLabJobMissionReward/TierBTableRewards", "/Lotus/Types/Game/MissionDecks/EntratiLabJobMissionReward/TierCTableRewards", "/Lotus/Types/Game/MissionDecks/EntratiLabJobMissionReward/TierDTableRewards", "/Lotus/Types/Game/MissionDecks/EntratiLabJobMissionReward/TierETableRewards" ], ZarimanSyndicate: [ "/Lotus/Types/Game/MissionDecks/ZarimanJobMissionRewards/TierATableRewards", "/Lotus/Types/Game/MissionDecks/ZarimanJobMissionRewards/TierBTableRewards", "/Lotus/Types/Game/MissionDecks/ZarimanJobMissionRewards/TierCTableRewards", "/Lotus/Types/Game/MissionDecks/ZarimanJobMissionRewards/TierDTableRewards", "/Lotus/Types/Game/MissionDecks/ZarimanJobMissionRewards/TierETableRewards" ], HexSyndicate: [ "/Lotus/Types/Game/MissionDecks/1999MissionRewards/TierABountyRewards", "/Lotus/Types/Game/MissionDecks/1999MissionRewards/TierBBountyRewards", "/Lotus/Types/Game/MissionDecks/1999MissionRewards/TierCBountyRewards", "/Lotus/Types/Game/MissionDecks/1999MissionRewards/TierDBountyRewards", "/Lotus/Types/Game/MissionDecks/1999MissionRewards/TierEBountyRewards", "/Lotus/Types/Game/MissionDecks/1999MissionRewards/TierFBountyRewards", "/Lotus/Types/Game/MissionDecks/1999MissionRewards/InfestedLichBountyRewards" ] }; const [syndicateTag, tierStr] = RewardInfo.challengeMissionId.split("_"); const tier = Number(tierStr); const rewardTable = rewardTables[syndicateTag][tier]; if (rewardTable) { rewardManifests = [rewardTable]; rotations = [0]; } else { logger.error(`Unknown syndicate or tier: ${RewardInfo.challengeMissionId}`); } } else { rotations = getRotations(RewardInfo, tierOverride); } if (rewardManifests.length != 0) { logger.debug(`generating random mission rewards`, { rewardManifests, rotations }); } if (RewardInfo.rewardSeed) { if (RewardInfo.rewardSeed != inventory.RewardSeed) { logger.warn(`RewardSeed mismatch:`, { client: RewardInfo.rewardSeed, database: inventory.RewardSeed }); } } const rng = new SRng(BigInt(RewardInfo.rewardSeed ?? generateRewardSeed()) ^ 0xffffffffffffffffn); rewardManifests.forEach(name => { const table = ExportRewards[name]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!table) { logger.error(`unknown droptable: ${name}`); return; } for (const rotation of rotations) { const rotationRewards = table[rotation]; const drop = getRandomRewardByChance(rotationRewards, rng); if (drop) { drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); } } }); 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; if (region.missionIndex === 3 && RewardInfo.rewardTier) { rotation = RewardInfo.rewardTier; } else if ([6, 7, 8, 10, 11].includes(region.systemIndex)) { rotation = 2; } else if ([4, 9, 12, 14, 15, 16, 17, 18].includes(region.systemIndex)) { rotation = 1; } const drop = getRandomRewardByChance(deck[rotation]); if (drop) { drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount }); } } if (RewardInfo.PurgatoryRewardQualifications) { for (const encodedQualification of RewardInfo.PurgatoryRewardQualifications) { const qualification = parseInt(encodedQualification) - 1; if (qualification < 0 || qualification > 8) { logger.error(`unexpected purgatory reward qualification: ${qualification}`); } else { const drop = getRandomRewardByChance( ExportRewards[ [ "/Lotus/Types/Game/MissionDecks/PurgatoryMissionRewards/PurgatoryBlackTokenRewards", "/Lotus/Types/Game/MissionDecks/PurgatoryMissionRewards/PurgatoryGoldTokenRewards", "/Lotus/Types/Game/MissionDecks/PurgatoryMissionRewards/PurgatoryBlueTokenRewards" ][Math.trunc(qualification / 3)] ][qualification % 3] ); if (drop) { drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount, FromEnemyCache: true // to show "identified" }); } } } } } 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" };