XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESpaceNinjaServer

A simple server for a small space ninja game

公开
关注 0 Fork 0 Star 0
UTF-8
import type { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel.ts";
import { Inventory } from "../models/inventoryModels/inventoryModel.ts";
import { config, shouldDoServerQol } from "./configService.ts";
import { Types } from "mongoose";
import type { IInventoryChanges, IBinChanges, IAffiliationMods } from "../types/purchaseTypes.ts";
import type {
    IChallengeProgress,
    IMiscItem,
    IMission,
    IRawUpgrade,
    ISeasonChallenge,
    IWeaponSkinClient,
    TEquipmentKey,
    IFusionTreasure,
    IDailyAffiliations,
    ILibraryDailyTaskInfo,
    IDroneClient,
    IUpgradeClient,
    TPartialStartingGear,
    ILoreFragmentScan,
    ICrewMemberClient,
    ICalendarProgress,
    INemesisWeaponTargetFingerprint,
    INemesisPetTargetFingerprint,
    IDialogueDatabase,
    IKubrowPetPrintClient,
    IWeeklyMissionChallengeInfo,
    ITauPrequelQuestCustomData,
    ICollectibleEntry,
    TInventorySlot
} from "../types/inventoryTypes/inventoryTypes.ts";
import { eInventorySlot, equipmentKeys, slotNames } from "../types/inventoryTypes/inventoryTypes.ts";
import type { IGenericUpdate, IUpdateNodeIntrosResponse } from "../types/genericUpdateTypes.ts";
import type { IAffiliationChange, IGoalsProgress, IKeyChainRequest } from "../types/requestTypes.ts";
import { logger } from "../utils/logger.ts";
import {
    convertInboxMessage,
    isStoreItem,
    fromStoreItem,
    getKey,
    getKeyChainItems,
    getPowerSuit,
    getSyndicate,
    supplementalKeys,
    supplementalRecipes,
    supplementalUpgrades,
    U5ModsWeights,
    U5Modules,
    getDefaultUpgrades
} from "./itemDataService.ts";
import type { IFlavourItem, IItemConfig, IItemConfigDatabase } from "../types/inventoryTypes/commonInventoryTypes.ts";
import type { IDefaultUpgrade, IRegion, ISentinel, TStandingLimitBin } from "warframe-public-export-plus";
import {
    ExportArcanes,
    ExportBoosters,
    ExportBundles,
    ExportChallenges,
    ExportCreditBundles,
    ExportCustoms,
    ExportDrones,
    ExportEmailItems,
    ExportEnemies,
    ExportFlavour,
    ExportFusionBundles,
    ExportGear,
    ExportKeys,
    ExportRailjackWeapons,
    ExportRecipes,
    ExportRegions,
    ExportResources,
    ExportSentinels,
    ExportSystems,
    ExportUpgrades,
    ExportWeapons
} from "warframe-public-export-plus";
import { createShip } from "./shipService.ts";
import type { TTraitsPool } from "../helpers/inventoryHelpers.ts";
import {
    catbrowDetails,
    convertFromLegacyFingerprint,
    convertToLegacyFingerprint,
    fromMongoDate,
    fromOid,
    kubrowDetails,
    kubrowFurPatternsWeights,
    kubrowWeights,
    toMongoDate2,
    toOid,
    toOid2,
    version_compare
} from "../helpers/inventoryHelpers.ts";
import { addQuestKey, completeQuest } from "./questService.ts";
import { handleBundleAcquisition } from "./purchaseService.ts";
import libraryDailyTasks from "../../static/fixed_responses/libraryDailyTasks.json" with { type: "json" };
import {
    generateRewardSeed,
    getRandomElement,
    getRandomFloat,
    getRandomInt,
    getRandomWeightedReward,
    SRng
} from "./rngService.ts";
import type { IMessageCreationTemplate } from "./inboxService.ts";
import { createMessage } from "./inboxService.ts";
import { getMaxStanding, getMinStanding } from "../helpers/syndicateStandingHelper.ts";
import { getNightwaveSyndicateTag, getWorldState } from "./worldStateService.ts";
import type { ICalendarSeason } from "../types/worldStateTypes.ts";
import type { INemesisProfile } from "../helpers/nemesisHelpers.ts";
import { generateNemesisProfile } from "../helpers/nemesisHelpers.ts";
import { type TAccountDocument } from "./loginService.ts";
import { KAHL_EPOCH, unixTimesInMs } from "../constants/timeConstants.ts";
import { addString } from "../helpers/stringHelpers.ts";
import type {
    IEquipmentClient,
    IEquipmentDatabase,
    IKubrowGenetics,
    IKubrowPetDetailsDatabase,
    ITraits
} from "../types/equipmentTypes.ts";
import { eEquipmentFeatures, eStatus } from "../types/equipmentTypes.ts";
import type { ITypeCount } from "../types/commonTypes.ts";
import type { TLoadoutDatabaseDocument } from "../models/inventoryModels/loadoutModel.ts";
import gameToBuildVersion from "../constants/gameToBuildVersion.ts";
import suitDefaultUpgrades from "../constants/suitDefaultUpgrades.ts";
import kuriaMessage50 from "../../static/fixed_responses/kuriaMessages/fiftyPercent.json" with { type: "json" };
import kuriaMessage75 from "../../static/fixed_responses/kuriaMessages/seventyFivePercent.json" with { type: "json" };
import kuriaMessage100 from "../../static/fixed_responses/kuriaMessages/oneHundredPercent.json" with { type: "json" };
import { BL_LATEST } from "../constants/gameVersions.ts";
import { Guild } from "../models/guildModel.ts";
import { handleGuildGoalProgress } from "./guildService.ts";
import { buildVersionToInt } from "../helpers/versionHelper.ts";

type OperatorAntiqueMeta = {
    focusAbility: string;
    ultimateUpgrades: string[];
};

const operatorAntiqueMeta: Partial<Record<string, OperatorAntiqueMeta>> = {
    "/Lotus/Weapons/Operator/Antiques/ZenurikGrimoire/ZanurikGrimoireAntique": {
        focusAbility: "/Lotus/Upgrades/Focus/Power/PowerFocusAbility",
        ultimateUpgrades: [
            "/Lotus/Upgrades/Focus/Power/Ultimate/PowerUltimateUpgrade",
            "/Lotus/Upgrades/Focus/Power/Ultimate/PowerUltimateBlindUpgrade",
            "/Lotus/Upgrades/Focus/Power/Ultimate/PowerUltimateBuffUpgrade",
            "/Lotus/Upgrades/Focus/Power/Ultimate/PowerUltimateChainUpgrade",
            "/Lotus/Upgrades/Focus/Power/Ultimate/PowerUltimateNumberUpgrade"
        ]
    },
    "/Lotus/Weapons/Operator/Antiques/NaramonBlade/NaramonBladeAntique": {
        focusAbility: "/Lotus/Upgrades/Focus/Tactic/TacticFocusAbility",
        ultimateUpgrades: [
            "/Lotus/Upgrades/Focus/Tactic/Active/TacticUltimateUpgrade",
            "/Lotus/Upgrades/Focus/Tactic/Active/TacticUltimateDoubleUpgrade",
            "/Lotus/Upgrades/Focus/Tactic/Active/TacticUltimateNumberUpgrade",
            "/Lotus/Upgrades/Focus/Tactic/Active/TacticUltimateRangeUpgrade",
            "/Lotus/Upgrades/Focus/Tactic/Active/TacticUltimateSizeUpgrade"
        ]
    },
    "/Lotus/Weapons/Operator/Antiques/MaduraiBow/MaduraiBowAntique": {
        focusAbility: "/Lotus/Upgrades/Focus/Attack/AttackFocusAbility",
        ultimateUpgrades: [
            "/Lotus/Upgrades/Focus/Attack/Ultimate/AttackUltimateUpgrade",
            "/Lotus/Upgrades/Focus/Attack/Ultimate/AttackUltimateCooldownUpgrade",
            "/Lotus/Upgrades/Focus/Attack/Ultimate/AttackUltimateDoubleUpgrade",
            "/Lotus/Upgrades/Focus/Attack/Ultimate/AttackUltimateRadiusUpgrade",
            "/Lotus/Upgrades/Focus/Attack/Ultimate/AttackUltimateStrengthUpgrade"
        ]
    },
    "/Lotus/Weapons/Operator/Antiques/UnairuHammer/UniaruHammerAntique": {
        focusAbility: "/Lotus/Upgrades/Focus/Ward/WardFocusAbility",
        ultimateUpgrades: [
            "/Lotus/Upgrades/Focus/Ward/Ultimate/WardUltimateUpgrade",
            "/Lotus/Upgrades/Focus/Ward/Ultimate/WardUltimateArmorUpgrade",
            "/Lotus/Upgrades/Focus/Ward/Ultimate/WardUltimateChanceUpgrade",
            "/Lotus/Upgrades/Focus/Ward/Ultimate/WardUltimateRadiusUpgrade",
            "/Lotus/Upgrades/Focus/Ward/Ultimate/WardUltimateSlamUpgrade"
        ]
    },
    "/Lotus/Weapons/Operator/Antiques/VazarinStaff/VazarinStaffAntique": {
        focusAbility: "/Lotus/Upgrades/Focus/Defense/DefenseFocusAbility",
        ultimateUpgrades: [
            "/Lotus/Upgrades/Focus/Defense/Ultimate/DefenseUltimateUpgrade",
            "/Lotus/Upgrades/Focus/Defense/Ultimate/DefenseUltimateDurationUpgrade",
            "/Lotus/Upgrades/Focus/Defense/Ultimate/DefenseUltimateElementalUpgrade",
            "/Lotus/Upgrades/Focus/Defense/Ultimate/DefenseUltimateHealUpgrade",
            "/Lotus/Upgrades/Focus/Defense/Ultimate/DefenseUltimateProcVulnUpgrade"
        ]
    }
};

const ensureOperatorSuitsRemasterData = (inventory: TInventoryDatabaseDocument): boolean => {
    if (inventory.OperatorSuits.length != 0) {
        return false;
    }
    inventory.OperatorSuits.push(
        { ItemType: "/Lotus/Powersuits/Operator/ChildOperatorSuitRemaster", Configs: [], UpgradeVer: 101 },
        { ItemType: "/Lotus/Powersuits/Operator/AdultOperatorSuitRemaster", Configs: [], UpgradeVer: 101 }
    );
    return true;
};

const setupAntique = (
    inventory: TInventoryDatabaseDocument,
    weaponType: string,
    weaponId: Types.ObjectId,
    inventoryChanges: IInventoryChanges
): void => {
    const meta = operatorAntiqueMeta[weaponType];
    if (!meta) {
        throw new Error(`unknown antique: ${weaponType}`);
    }

    handleOneTimePurchasable(inventory, weaponType, inventoryChanges);

    if (!inventory.FocusUpgrades.some(x => x.ItemType === meta.focusAbility)) {
        inventory.FocusUpgrades.push({ ItemType: meta.focusAbility });
        inventoryChanges.FocusUpgrades ??= [];
        inventoryChanges.FocusUpgrades.push({ ItemType: meta.focusAbility });
    }
    for (const upgradeType of meta.ultimateUpgrades) {
        if (!inventory.FocusUpgrades.some(x => x.ItemType === upgradeType)) {
            inventory.FocusUpgrades.push({ ItemType: upgradeType, Level: 0 });
            inventoryChanges.FocusUpgrades ??= [];
            inventoryChanges.FocusUpgrades.push({ ItemType: upgradeType, Level: 0 });
        }
    }

    inventory.FocusLoadouts ??= [];
    inventoryChanges.FocusLoadouts ??= [];
    if (!inventory.FocusLoadouts.some(x => x.FocusAbility === meta.focusAbility)) {
        inventory.FocusLoadouts.push({
            FocusAbility: meta.focusAbility,
            Preset: { ItemId: weaponId, mod: 0, cus: 0 }
        });
        inventoryChanges.FocusLoadouts.push({
            FocusAbility: meta.focusAbility,
            Preset: { ItemId: toOid(weaponId), mod: 0, cus: 0 }
        });
    }
};

export const createInventory = async (
    accountOwnerId: Types.ObjectId,
    loadout: TLoadoutDatabaseDocument,
    defaultItemReferences: { loadOutPresetId: Types.ObjectId; ship: Types.ObjectId }
): Promise<void> => {
    try {
        const inventory = new Inventory({
            ...(config.inventoryDefaults ?? {}),
            accountOwnerId: accountOwnerId,
            LoadOutPresets: defaultItemReferences.loadOutPresetId,
            Ships: [defaultItemReferences.ship]
        });

        inventory.LibraryAvailableDailyTaskInfo = createLibraryDailyTask(BL_LATEST);
        inventory.RewardSeed = generateRewardSeed();
        inventory.DuviriInfo = {
            Seed: generateRewardSeed(),
            NumCompletions: 0
        };
        ensureOperatorSuitsRemasterData(inventory);
        await addItem(inventory, "/Lotus/Types/Friendly/PlayerControllable/Weapons/DuviriDualSwords");

        if (config.skipTutorial) {
            inventory.MadeStoryModeDecision = true;
            inventory.PlayedParkourTutorial = true;
            const startingGear = await addStartingGear(inventory);
            await completeQuest(inventory, "/Lotus/Types/Keys/VorsPrize/VorsPrizeQuestKeyChain", BL_LATEST);
            await completeQuest(inventory, "/Lotus/Types/Keys/ModQuest/ModQuestKeyChain", BL_LATEST);

            loadout.NORMAL.push({
                s: {
                    ItemId: new Types.ObjectId(fromOid(startingGear.Suits![0].ItemId))
                },
                l: {
                    ItemId: new Types.ObjectId(fromOid(startingGear.LongGuns![0].ItemId))
                },
                p: {
                    ItemId: new Types.ObjectId(fromOid(startingGear.Pistols![0].ItemId))
                },
                m: {
                    ItemId: new Types.ObjectId(fromOid(startingGear.Melee![0].ItemId))
                },
                a: {
                    ItemId: new Types.ObjectId(fromOid(startingGear.SpecialItems![0].ItemId))
                }
            });
            await loadout.save();

            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"}`);
    }
};

export const addStartingGear = async (
    inventory: TInventoryDatabaseDocument,
    buildLabel: string = BL_LATEST,
    startingGear?: Partial<TPartialStartingGear>
): Promise<IInventoryChanges> => {
    if (inventory.ReceivedStartingGear) {
        throw new Error(`account has already received starting gear`);
    }
    inventory.ReceivedStartingGear = true;

    const { LongGuns, Pistols, Suits, Melee } = {
        LongGuns: [{ ItemType: "/Lotus/Weapons/Tenno/Rifle/Rifle" }],
        Pistols: [{ ItemType: "/Lotus/Weapons/Tenno/Pistol/Pistol" }],
        Suits: [{ ItemType: "/Lotus/Powersuits/Excalibur/Excalibur" }],
        Melee: [{ ItemType: "/Lotus/Weapons/Tenno/Melee/LongSword/LongSword" }],
        ...startingGear
    };

    //TODO: properly merge weapon bin changes it is currently static here
    const inventoryChanges: IInventoryChanges = {};
    addEquipment(inventory, "LongGuns", LongGuns[0].ItemType, { IsNew: false }, inventoryChanges);
    addEquipment(inventory, "Pistols", Pistols[0].ItemType, { IsNew: false }, inventoryChanges);
    if (shouldDoServerQol("tutorialGivesStanceMods", buildLabel, gameToBuildVersion["38.5.0"])) {
        combineInventoryChanges(inventoryChanges, await addItem(inventory, Melee[0].ItemType)); // This respects defaultUpgrades
    } else {
        addEquipment(inventory, "Melee", Melee[0].ItemType, { IsNew: false }, inventoryChanges);
    }
    await addPowerSuit(inventory, Suits[0].ItemType, { IsNew: false }, buildLabel, inventoryChanges);
    addEquipment(
        inventory,
        "DataKnives",
        "/Lotus/Weapons/Tenno/HackingDevices/TnHackingDevice/TnHackingDeviceWeapon",
        { XP: 450_000, IsNew: false },
        inventoryChanges
    );
    addEquipment(
        inventory,
        "Scoops",
        "/Lotus/Weapons/Tenno/Speedball/SpeedballWeaponTest",
        { IsNew: false },
        inventoryChanges
    );

    updateSlots(inventory, eInventorySlot.SUITS, 0, 1);
    updateSlots(inventory, eInventorySlot.WEAPONS, 0, 3);
    inventoryChanges.SuitBin = { count: 1, platinum: 0, Slots: -1 };
    inventoryChanges.WeaponBin = { count: 3, platinum: 0, Slots: -3 };

    await addItem(inventory, "/Lotus/Types/Keys/VorsPrize/VorsPrizeQuestKeyChain");
    inventory.ActiveQuest = "/Lotus/Types/Keys/VorsPrize/VorsPrizeQuestKeyChain";

    inventory.PremiumCredits = 50;
    inventory.PremiumCreditsFree = 50;
    inventoryChanges.PremiumCredits = 50;
    inventoryChanges.PremiumCreditsFree = 50;
    inventory.RegularCredits = 3000;
    inventoryChanges.RegularCredits = 3000;

    const awakeningRewards = [
        "/Lotus/Types/StoreItems/AvatarImages/AvatarImageItem1",
        "/Lotus/Types/StoreItems/AvatarImages/AvatarImageItem2",
        "/Lotus/Types/StoreItems/AvatarImages/AvatarImageItem3",
        "/Lotus/Types/StoreItems/AvatarImages/AvatarImageItem4",
        "/Lotus/Types/Restoratives/LisetAutoHack"
    ];

    for (const item of awakeningRewards) {
        const inventoryDelta = await addItem(inventory, item);
        combineInventoryChanges(inventoryChanges, inventoryDelta);
    }

    return inventoryChanges;
};

/**
 * 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)) {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            (InventoryChanges as Record<string, any>)[key] = (delta as Record<string, any>)[key];
        } else if (key == "MiscItems") {
            for (const deltaItem of delta[key]!) {
                const existing = InventoryChanges[key]!.find(x => x.ItemType == deltaItem.ItemType);
                if (existing) {
                    existing.ItemCount += deltaItem.ItemCount;
                } else {
                    InventoryChanges[key]!.push(deltaItem);
                }
            }
        } else if (Array.isArray(delta[key as keyof IInventoryChanges])) {
            const left = InventoryChanges[key as keyof IInventoryChanges] as any[];
            const right = delta[key as keyof IInventoryChanges] as any[];
            for (const item of right) {
                left.push(item);
            }
        } else if (slotNames.indexOf(key as TInventorySlot) != -1) {
            const left = InventoryChanges[key as TInventorySlot]!;
            const right = delta[key as TInventorySlot]!;
            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 as keyof IInventoryChanges] === "number") {
            (InventoryChanges[key as keyof IInventoryChanges] as number) += delta[
                key as keyof IInventoryChanges
            ] as number;
        } else {
            throw new Error(`inventory change not merged: unhandled type for inventory key ${key}`);
        }
    }
};

export const getInventory = async (
    accountOwnerId: string | Types.ObjectId,
    projection: string | undefined
): Promise<TInventoryDatabaseDocument> => {
    const inventory = await Inventory.findOne({ accountOwnerId: accountOwnerId }, projection);

    if (!inventory) {
        throw new Error(`Didn't find an inventory for ${String(accountOwnerId)}`);
    }

    return inventory;
};

export const getInventory2 = async <K extends keyof TInventoryDatabaseDocument>(
    accountOwnerId: string | Types.ObjectId,
    ...projection: readonly K[]
): Promise<
    Pick<
        TInventoryDatabaseDocument,
        K | "save" | "toObject" | "toJSON" | "populate" | "isModified" | "get" | "set" | "validate" | "validateSync"
    >
> => {
    const inventory = await Inventory.findOne({ accountOwnerId: accountOwnerId }, projection);

    if (!inventory) {
        throw new Error(`Didn't find an inventory for ${String(accountOwnerId)}`);
    }

    return inventory;
};

export const productCategoryToInventoryBin = (productCategory: string): TInventorySlot | undefined => {
    switch (productCategory) {
        case "Suits":
            return eInventorySlot.SUITS;
        case "Pistols":
        case "LongGuns":
        case "Melee":
            return eInventorySlot.WEAPONS;
        case "Sentinels":
        case "SentinelWeapons":
        case "KubrowPets":
        case "MoaPets":
            return eInventorySlot.SENTINELS;
        case "SpaceSuits":
        case "Hoverboards":
            return eInventorySlot.SPACESUITS;
        case "SpaceGuns":
        case "SpaceMelee":
            return eInventorySlot.SPACEWEAPONS;
        case "OperatorAmps":
        case "Antiques":
            return eInventorySlot.AMPS;
        case "CrewShipWeapons":
        case "CrewShipWeaponSkins":
            return eInventorySlot.RJ_COMPONENT_AND_ARMAMENTS;
        case "MechSuits":
            return eInventorySlot.MECHSUITS;
        case "CrewMembers":
            return eInventorySlot.CREWMEMBERS;
    }
    return undefined;
};

export const occupySlot = <ST extends TInventorySlot>(
    inventory: Pick<TInventoryDatabaseDocument, ST>,
    bin: ST,
    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 = <ST extends TInventorySlot>(
    inventory: Pick<TInventoryDatabaseDocument, ST>,
    bin: ST
): void => {
    // { count: -1, platinum: 0, Slots: 1 }
    updateSlots(inventory, bin, 1, 0);
};

export const PRE_U40_MAX_KUBROW_EGGS = 100; // capped to avoid sending an overly large array

export const addItem = async (
    inventory: TInventoryDatabaseDocument,
    typeName: string,
    quantity: number = 1,
    premiumPurchase: boolean = false,
    seed?: bigint,
    targetFingerprint?: string,
    exactQuantity: boolean = false,
    buildLabel: string = BL_LATEST
): Promise<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 await handleBundleAcquisition(typeName, inventory, quantity, {}, buildLabel);
    }

    // Strict typing
    if (typeName in ExportRecipes || typeName in supplementalRecipes) {
        const inventoryChanges: IInventoryChanges = {};
        inventoryChanges.Recipes = [
            {
                ItemType: typeName,
                ItemCount: quantity
            } satisfies ITypeCount
        ];
        if (
            (typeName in ExportRecipes && ExportRecipes[typeName].oneTimePurchasable) ||
            (typeName in supplementalRecipes && supplementalRecipes[typeName].oneTimePurchasable)
        ) {
            handleOneTimePurchasable(inventory, typeName, inventoryChanges);
        }
        addRecipes(inventory, inventoryChanges.Recipes);
        return inventoryChanges;
    }
    if (typeName in ExportResources) {
        const inventoryChanges: IInventoryChanges = {};
        if (ExportResources[typeName].oneTimePurchasable) {
            handleOneTimePurchasable(inventory, typeName, inventoryChanges);
        }
        if (ExportResources[typeName].productCategory == "MiscItems") {
            inventoryChanges.MiscItems = [
                {
                    ItemType: typeName,
                    ItemCount: quantity
                } satisfies IMiscItem
            ];
            addMiscItemsComplex(inventory, inventoryChanges.MiscItems);

            if (
                typeName == "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg" &&
                version_compare(buildLabel, gameToBuildVersion["40.0.0"]) < 0
            ) {
                if (quantity < 0 || quantity > 100) {
                    throw new Error(
                        `unexpected acquisition quantity of KubrowPetEggs: got ${quantity}, expected 0..100`
                    );
                }
                const idBase = Math.min(
                    inventory.MiscItems.find(x => x.ItemType == "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg")
                        ?.ItemCount ?? 0,
                    PRE_U40_MAX_KUBROW_EGGS
                );
                inventoryChanges.KubrowPetEggs ??= [];
                delete inventoryChanges.MiscItems;
                for (let i = 0; i != quantity; ++i) {
                    inventoryChanges.KubrowPetEggs.push({
                        ItemType: "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg",
                        ExpirationDate: toMongoDate2(2000000000000, buildLabel),
                        ItemId: toOid2((idBase + i).toString().padStart(24, "0"), buildLabel)
                    });
                }
            }

            return inventoryChanges;
        } else if (ExportResources[typeName].productCategory == "FusionTreasures") {
            inventoryChanges.FusionTreasures = [
                {
                    ItemType: typeName,
                    ItemCount: quantity,
                    Sockets: 0
                } satisfies IFusionTreasure
            ];
            addFusionTreasures(inventory, inventoryChanges.FusionTreasures);
            return inventoryChanges;
        } else if (ExportResources[typeName].productCategory == "Ships") {
            if (quantity != 1) {
                throw new Error(`unexpected acquisition quantity of Ships: got ${quantity}, expected 1`);
            }
            const oid = await createShip(inventory.accountOwnerId, typeName);
            inventory.Ships.push(oid);
            inventoryChanges.Ships = [
                {
                    ItemId: { $oid: oid.toString() },
                    ItemType: typeName
                }
            ];
            return inventoryChanges;
        } else if (ExportResources[typeName].productCategory == "CrewShips") {
            if (quantity != 1) {
                throw new Error(`unexpected acquisition quantity of CrewShips: got ${quantity}, expected 1`);
            }
            await addCrewShip(inventory, typeName, inventoryChanges);
            // fix to unlock railjack modding, item bellow supposed to be obtained from archwing quest
            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
            if (!inventory.CrewShipHarnesses?.length) {
                addCrewShipHarness(inventory, "/Lotus/Types/Game/CrewShip/RailJack/DefaultHarness", inventoryChanges);
            }
            return inventoryChanges;
        } else if (ExportResources[typeName].productCategory == "ShipDecorations") {
            inventoryChanges.ShipDecorations = [
                {
                    ItemType: typeName,
                    ItemCount: quantity
                } satisfies IMiscItem
            ];
            addShipDecorations(inventory, inventoryChanges.ShipDecorations);
            return inventoryChanges;
        } else {
            throw new Error(`unknown product category: ${ExportResources[typeName].productCategory}`);
        }
    }
    if (typeName in ExportCustoms) {
        const meta = ExportCustoms[typeName];
        let inventoryChanges: IInventoryChanges;
        if (meta.productCategory == "CrewShipWeaponSkins") {
            if (meta.subroutines || meta.randomisedUpgrades) {
                // House versions need to be identified to get stats so put them into raw salvage first.
                const rawSalvageChanges = [
                    {
                        ItemType: typeName,
                        ItemCount: quantity
                    }
                ];
                addCrewShipRawSalvage(inventory, rawSalvageChanges);
                inventoryChanges = { CrewShipRawSalvage: rawSalvageChanges };
            } else {
                // Sigma versions can be added directly.
                if (quantity != 1) {
                    throw new Error(
                        `unexpected acquisition quantity of CrewShipWeaponSkin: got ${quantity}, expected 1`
                    );
                }
                inventoryChanges = {
                    ...addCrewShipWeaponSkin(inventory, typeName, undefined),
                    ...occupySlot(inventory, eInventorySlot.RJ_COMPONENT_AND_ARMAMENTS, premiumPurchase)
                };
            }
        } else {
            if (quantity != 1) {
                throw new Error(`unexpected acquisition quantity of WeaponSkins: got ${quantity}, expected 1`);
            }
            inventoryChanges = addSkin(inventory, typeName);
        }
        if (meta.additionalItems) {
            for (const item of meta.additionalItems) {
                combineInventoryChanges(inventoryChanges, await addItem(inventory, item));
            }
        }
        return inventoryChanges;
    }
    if (typeName in ExportFlavour) {
        return addCustomization(inventory, typeName);
    }
    if (
        typeName in ExportUpgrades ||
        typeName in ExportArcanes ||
        typeName in U5Modules ||
        typeName in supplementalUpgrades ||
        (typeName.startsWith("/Lotus/Upgrades/Mods/") && // Fusion Core, Legendary Core
            !typeName.startsWith("/Lotus/Upgrades/Mods/FusionBundles/")) ||
        typeName.startsWith("/Lotus/Upgrades/CosmeticEnhancers/") || // Traumatic Peculiar
        (typeName.endsWith("AbilityCard") && typeName.startsWith("/Lotus/Powersuits/"))
    ) {
        if (!targetFingerprint && typeName in U5Modules) {
            const meta = U5Modules[typeName];
            const equipment = getRandomWeightedReward(meta.fits, U5ModsWeights)!;
            const upgrades = [];
            {
                const rolledCount = getRandomWeightedReward(meta.numUpgrades, U5ModsWeights)!.number;
                for (let i = 0; i < rolledCount; i++) {
                    const rolledUpgrade = getRandomWeightedReward(meta.upgrades, U5ModsWeights)!;
                    const rolledRarity = getRandomWeightedReward(
                        (["COMMON", "UNCOMMON", "RARE"] as const).map(rarity => ({ rarity })),
                        U5ModsWeights
                    )!.rarity;
                    upgrades.push({
                        upgrade: rolledUpgrade.type,
                        valueRarity: rolledRarity,
                        value: getRandomFloat(0, 1)
                    });
                }
            }
            targetFingerprint = JSON.stringify({
                reqLevel: getRandomInt(0, 30),
                fits: equipment.type,
                upgrades: upgrades
            });
        }
        if (targetFingerprint && typeName.startsWith("/Lotus/Upgrades/Mods/Randomized/Raw")) {
            logger.debug(`ignoring fingerprint for raw riven mod`);
        } else if (targetFingerprint && targetFingerprint != `{"lvl":0}`) {
            if (quantity != 1) {
                logger.warn(`adding 1 of ${typeName} ${targetFingerprint} even tho quantity ${quantity} was requested`);
            }
            const upgrade =
                inventory.Upgrades[
                    inventory.Upgrades.push({
                        ItemType: typeName,
                        UpgradeFingerprint: targetFingerprint
                    }) - 1
                ];
            return { Upgrades: [upgrade.toJSON<IUpgradeClient>()] };
        }
        const changes = [
            {
                ItemType: typeName,
                ItemCount: quantity
            }
        ];
        addMods(inventory, changes);
        return {
            RawUpgrades: changes
        };
    }
    if (typeName in ExportGear) {
        // Multipling by purchase quantity for gear because:
        // - The Saya's Vigil scanner message has it as a non-counted attachment.
        // - Blueprints for Ancient Protector Specter, Shield Osprey Specter, etc. have num=1 despite giving their purchaseQuantity.
        if (!exactQuantity) {
            quantity *= ExportGear[typeName].purchaseQuantity ?? 1;
            logger.debug(`non-exact acquisition of ${typeName}; factored quantity is ${quantity}`);
        }
        const consumablesChanges = [
            {
                ItemType: typeName,
                ItemCount: quantity
            } satisfies ITypeCount
        ];
        addConsumables(inventory, consumablesChanges);
        return {
            Consumables: consumablesChanges
        };
    }
    if (typeName in ExportWeapons) {
        const weapon = ExportWeapons[typeName];
        const inventoryChanges: IInventoryChanges = {};
        if (weapon.oneTimePurchasable) {
            handleOneTimePurchasable(inventory, typeName, inventoryChanges);
        }
        if (weapon.totalDamage != 0) {
            if (quantity != 1) {
                throw new Error(
                    `unexpected acquisition quantity of ${weapon.productCategory}: got ${quantity}, expected 1`
                );
            }
            const defaultOverwrites: Partial<IEquipmentDatabase> = {};
            if (premiumPurchase) {
                defaultOverwrites.Features = eEquipmentFeatures.DOUBLE_CAPACITY;
            }
            if (weapon.maxLevelCap == 40 && typeName.indexOf("BallasSword") == -1) {
                if (!seed) {
                    seed = BigInt(Math.round(Math.random() * Number.MAX_SAFE_INTEGER));
                }
                const rng = new SRng(seed);
                const tag = rng.randomElement([
                    "InnateElectricityDamage",
                    "InnateFreezeDamage",
                    "InnateHeatDamage",
                    "InnateImpactDamage",
                    "InnateMagDamage",
                    "InnateRadDamage",
                    "InnateToxinDamage"
                ]);
                //const WeaponUpgradeValueAttenuationExponent = 2.25;
                //let value = Math.pow(rng.randomFloat(), WeaponUpgradeValueAttenuationExponent);
                let value = rng.randomFloat();
                if (value >= 0.941428) {
                    value = 1;
                }
                defaultOverwrites.UpgradeType = "/Lotus/Weapons/Grineer/KuvaLich/Upgrades/InnateDamageRandomMod";
                defaultOverwrites.UpgradeFingerprint = JSON.stringify({
                    compat: typeName,
                    buffs: [
                        {
                            Tag: tag,
                            Value: Math.trunc(value * 0x40000000)
                        }
                    ]
                });
            }
            if (targetFingerprint) {
                const targetFingerprintObj = JSON.parse(targetFingerprint) as INemesisWeaponTargetFingerprint;
                defaultOverwrites.UpgradeType = targetFingerprintObj.ItemType;
                defaultOverwrites.UpgradeFingerprint = JSON.stringify(targetFingerprintObj.UpgradeFingerprint);
                defaultOverwrites.ItemName = targetFingerprintObj.Name;
            }
            defaultOverwrites.Configs = applyDefaultUpgrades(inventory, weapon.defaultUpgrades, inventoryChanges);
            if (
                weapon.bayonetOtherWeaponType && // Is this a bayonet?
                !weapon.excludeFromMarket // Is this is the rifle half?
            ) {
                defaultOverwrites._id = new Types.ObjectId();
                defaultOverwrites.AltWeaponModeId = new Types.ObjectId();
                addEquipment(
                    inventory,
                    "Melee",
                    weapon.bayonetOtherWeaponType,
                    {
                        _id: defaultOverwrites.AltWeaponModeId,
                        AltWeaponModeId: defaultOverwrites._id,
                        Features: defaultOverwrites.Features, // premium purchase puts potato in both parts
                        IsNew: false // Not 100% sure if this is faithful, but the client seems to act a bit weird when it's set on the melee weapon.
                    },
                    inventoryChanges
                );
            }
            addEquipment(inventory, weapon.productCategory, typeName, defaultOverwrites, inventoryChanges);
            if (weapon.additionalItems) {
                for (const item of weapon.additionalItems) {
                    combineInventoryChanges(inventoryChanges, await addItem(inventory, item, 1));
                }
            }
            return {
                ...inventoryChanges,
                ...occupySlot(
                    inventory,
                    productCategoryToInventoryBin(weapon.productCategory) ?? eInventorySlot.WEAPONS,
                    premiumPurchase
                )
            };
        } else if (targetFingerprint) {
            // Sister's Hound
            if (quantity != 1) {
                throw new Error(`unexpected acquisition quantity of MoaPets: got ${quantity}, expected 1`);
            }
            const targetFingerprintObj = JSON.parse(targetFingerprint) as INemesisPetTargetFingerprint;
            const head = targetFingerprintObj.Parts[0];
            const defaultOverwrites: Partial<IEquipmentDatabase> = {
                ModularParts: targetFingerprintObj.Parts,
                ItemName: targetFingerprintObj.Name,
                Configs: applyDefaultUpgrades(
                    inventory,
                    getDefaultUpgrades(targetFingerprintObj.Parts),
                    inventoryChanges
                )
            };
            const itemType = {
                "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadA":
                    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetAPowerSuit",
                "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadB":
                    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetBPowerSuit",
                "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadC":
                    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetCPowerSuit"
            }[head] as string;
            giveMoaPetDefaultWeapon(inventory, itemType, premiumPurchase, inventoryChanges);
            return {
                ...inventoryChanges,
                ...addEquipment(inventory, "MoaPets", itemType, defaultOverwrites),
                ...occupySlot(inventory, eInventorySlot.SENTINELS, premiumPurchase)
            };
        } else {
            // Modular weapon parts
            inventoryChanges.MiscItems = [
                {
                    ItemType: typeName,
                    ItemCount: quantity
                } satisfies IMiscItem
            ];
            addMiscItems(inventory, inventoryChanges.MiscItems);
            return inventoryChanges;
        }
    }
    if (typeName in ExportRailjackWeapons) {
        const meta = ExportRailjackWeapons[typeName];
        if (meta.defaultUpgrades?.length) {
            // House versions need to be identified to get stats so put them into raw salvage first.
            const rawSalvageChanges = [
                {
                    ItemType: typeName,
                    ItemCount: quantity
                }
            ];
            addCrewShipRawSalvage(inventory, rawSalvageChanges);
            return { CrewShipRawSalvage: rawSalvageChanges };
        } else {
            // Sigma versions can be added directly.
            if (quantity != 1) {
                throw new Error(`unexpected acquisition quantity of CrewShipWeapon: got ${quantity}, expected 1`);
            }
            return {
                ...addEquipment(inventory, meta.productCategory, typeName),
                ...occupySlot(inventory, eInventorySlot.RJ_COMPONENT_AND_ARMAMENTS, premiumPurchase)
            };
        }
    }
    if (typeName in ExportCreditBundles) {
        const creditsTotal = ExportCreditBundles[typeName].credits * quantity;
        inventory.RegularCredits += creditsTotal;
        return {
            RegularCredits: creditsTotal
        };
    }
    if (typeName in ExportFusionBundles) {
        const fusionPointsTotal = ExportFusionBundles[typeName].fusionPoints * quantity;
        addFusionPoints(inventory, fusionPointsTotal);
        return {
            FusionPoints: fusionPointsTotal
        };
    }
    if (typeName in ExportKeys || typeName in supplementalKeys) {
        // Note: "/Lotus/Types/Keys/" contains some EmailItems
        const key = getKey(typeName, buildLabel)!;

        if (key.chainStages) {
            if (quantity != 1) {
                throw new Error(`unexpected acquisition quantity of QuestKeys: got ${quantity}, expected 1`);
            }
            const key = addQuestKey(inventory, { ItemType: typeName });
            if (!key) return {};
            return { QuestKeys: [key] };
        } else {
            const levelKeyChanges = [{ ItemType: typeName, ItemCount: quantity }];
            addLevelKeys(inventory, levelKeyChanges);
            return { LevelKeys: levelKeyChanges };
        }
    }
    if (typeName in ExportDrones) {
        // Can only get 1 at a time from crafting, but for convenience's sake, allow up 100 to via the WebUI.
        if (quantity < 0 || quantity > 100) {
            throw new Error(`unexpected acquisition quantity of Drones: got ${quantity}, expected 0..100`);
        }
        const inventoryChanges: IInventoryChanges = {};
        for (let i = 0; i != quantity; ++i) {
            addDrone(inventory, typeName, inventoryChanges);
        }
        return inventoryChanges;
    }
    if (typeName in ExportEmailItems) {
        if (quantity != 1) {
            throw new Error(`unexpected acquisition quantity of EmailItems: got ${quantity}, expected 1`);
        }
        return await addEmailItem(inventory, typeName);
    }

    // Boosters are an odd case. They're only added like this via Baro's Void Surplus afaik.
    {
        const boosterEntry = Object.entries(ExportBoosters).find(arr => arr[1].typeName == typeName);
        if (boosterEntry) {
            addBooster(typeName, quantity, inventory);
            return {
                Boosters: [{ ItemType: typeName, ExpiryDate: quantity }]
            };
        }
    }

    // Path-based duck typing
    switch (typeName.substring(1).split("/")[1]) {
        case "Powersuits":
            if (typeName.endsWith("AugmentCard")) break;
            switch (typeName.substring(1).split("/")[2]) {
                default: {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of Suits: got ${quantity}, expected 1`);
                    }
                    if (typeName == "/Lotus/Powersuits/Excalibur/DoomSwordCYUmbra") {
                        const inventoryChanges: IInventoryChanges = {};
                        addSpecialItem(inventory, typeName, inventoryChanges);
                        return inventoryChanges;
                    }
                    return {
                        ...(await addPowerSuit(
                            inventory,
                            typeName,
                            {
                                Features: premiumPurchase ? eEquipmentFeatures.DOUBLE_CAPACITY : undefined
                            },
                            buildLabel
                        )),
                        ...occupySlot(inventory, eInventorySlot.SUITS, premiumPurchase)
                    };
                }
                case "Archwing": {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of SpaceSuits: got ${quantity}, expected 1`);
                    }
                    inventory.ArchwingEnabled = true;
                    return {
                        ...addSpaceSuit(
                            inventory,
                            typeName,
                            {},
                            premiumPurchase ? eEquipmentFeatures.DOUBLE_CAPACITY : undefined
                        ),
                        ...occupySlot(inventory, eInventorySlot.SPACESUITS, premiumPurchase)
                    };
                }
                case "EntratiMech": {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of MechSuits: got ${quantity}, expected 1`);
                    }
                    return {
                        ...(await addMechSuit(
                            inventory,
                            typeName,
                            {},
                            premiumPurchase ? eEquipmentFeatures.DOUBLE_CAPACITY : undefined
                        )),
                        ...occupySlot(inventory, eInventorySlot.MECHSUITS, premiumPurchase)
                    };
                }
            }
            break;
        case "Upgrades": {
            switch (typeName.substring(1).split("/")[2]) {
                case "Boons":
                    // Can purchase /Lotus/Upgrades/Boons/DuviriVendorBoonItem from Acrithis, doesn't need to be added to inventory.
                    logger.debug(`acquisition of ${typeName} is not committed to inventory`);
                    return {};

                case "Stickers":
                    {
                        const entry = inventory.RawUpgrades.find(x => x.ItemType == typeName);
                        if (entry && entry.ItemCount >= 10) {
                            logger.debug(`adding ${quantity} pix chip(s) instead of ${typeName}`);
                            const miscItemChanges = [
                                {
                                    ItemType: "/Lotus/Types/Items/MiscItems/1999ConquestBucks",
                                    ItemCount: quantity
                                }
                            ];
                            addMiscItems(inventory, miscItemChanges);
                            return {
                                MiscItems: miscItemChanges
                            };
                        } else {
                            const changes = [
                                {
                                    ItemType: typeName,
                                    ItemCount: quantity
                                }
                            ];
                            addMods(inventory, changes);
                            return {
                                RawUpgrades: changes
                            };
                        }
                    }
                    break;

                case "Skins": {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of Skins: got ${quantity}, expected 1`);
                    }
                    return addSkin(inventory, typeName);
                }
            }
            break;
        }
        case "Types":
            switch (typeName.substring(1).split("/")[2]) {
                case "Sentinels": {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of Sentinels: got ${quantity}, expected 1`);
                    }
                    return addSentinel(inventory, typeName, premiumPurchase);
                }
                case "Game":
                case "JadeShadowsPart2Mission": {
                    if (typeName.substring(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 {
                            MiscItems: miscItemChanges
                        };
                    } else if (
                        typeName.substring(1).split("/")[3] == "CatbrowPet" ||
                        typeName.substring(1).split("/")[3] == "KubrowPet"
                    ) {
                        if (typeName == "/Lotus/Types/Game/CatbrowPet/VampireKavatTraitPrint") {
                            const inventoryChanges: IInventoryChanges = {};
                            for (let i = 0; i != quantity; ++i) {
                                addKubrowPetPrint(
                                    inventory,
                                    typeName,
                                    {
                                        Name: "",
                                        IsMale: false,
                                        Size: 1,
                                        DominantTraits: {
                                            BaseColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseVampire",
                                            SecondaryColor:
                                                "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorSecondaryVampire",
                                            TertiaryColor:
                                                "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorTertiaryVampire",
                                            AccentColor:
                                                "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorAccentsVampire",
                                            EyeColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesVamp",
                                            FurPattern:
                                                "/Lotus/Types/Game/CatbrowPet/Patterns/CatbrowPetPatternVampire",
                                            Personality: "/Lotus/Types/Game/CatbrowPet/VampireCatbrowPetPowerSuit",
                                            BodyType:
                                                "/Lotus/Types/Game/CatbrowPet/BodyTypes/CatbrowPetVampireBodyType",
                                            Head: "/Lotus/Types/Game/CatbrowPet/Heads/CatbrowHeadVampire",
                                            Tail: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailVampire"
                                        },
                                        RecessiveTraits: {}
                                    },
                                    inventoryChanges
                                );
                            }
                            return inventoryChanges;
                        } else if (
                            typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg" &&
                            typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem" &&
                            typeName != "/Lotus/Types/Game/KubrowPet/BlankTraitPrint" &&
                            typeName != "/Lotus/Types/Game/KubrowPet/ImprintedTraitPrint"
                        ) {
                            if (quantity != 1) {
                                throw new Error(
                                    `unexpected acquisition quantity of KubrowPet: got ${quantity}, expected 1`
                                );
                            }
                            return addKubrowPet(inventory, typeName, undefined, premiumPurchase, {}, buildLabel);
                        }
                    } else if (
                        typeName.startsWith("/Lotus/Types/Game/CrewShip/CrewMember/") ||
                        typeName.startsWith("/Lotus/Types/JadeShadowsPart2Mission/CrewMembers/")
                    ) {
                        if (quantity != 1) {
                            throw new Error(
                                `unexpected acquisition quantity of CrewMember: got ${quantity}, expected 1`
                            );
                        }
                        if (!seed) {
                            throw new Error(`Expected crew member to have a seed`);
                        }
                        seed |= BigInt(getAccountRandSeed(inventory)) << 32n;
                        return {
                            ...addCrewMember(inventory, typeName, seed),
                            ...occupySlot(inventory, eInventorySlot.CREWMEMBERS, premiumPurchase)
                        };
                    } else if (typeName == "/Lotus/Types/Game/CrewShip/RailJack/DefaultHarness") {
                        if (quantity != 1) {
                            throw new Error(
                                `unexpected acquisition quantity of CrewShipHarness: got ${quantity}, expected 1`
                            );
                        }
                        return addCrewShipHarness(inventory, typeName);
                    }
                    break;
                }
                case "Items": {
                    if (typeName.substring(1).split("/")[3] == "Emotes") {
                        if (quantity != 1) {
                            throw new Error(
                                `unexpected acquisition quantity of FlavourItems: got ${quantity}, expected 1`
                            );
                        }
                        return addCustomization(inventory, typeName);
                    } else if (typeName.substring(1).split("/")[3] == "MiscItems") {
                        const miscItemChanges = [
                            {
                                ItemType: typeName,
                                ItemCount: quantity
                            } satisfies IMiscItem
                        ];
                        addMiscItems(inventory, miscItemChanges);
                        return { MiscItems: miscItemChanges };
                    }
                    break;
                }
                case "NeutralCreatures": {
                    if (inventory.Horses.length != 0) {
                        logger.warn("refusing to add Horse because account already has one");
                        return {};
                    }
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of Horses: got ${quantity}, expected 1`);
                    }
                    const horseIndex = inventory.Horses.push({ ItemType: typeName });
                    return {
                        Horses: [inventory.Horses[horseIndex - 1].toJSON<IEquipmentClient>()]
                    };
                }
                case "Vehicles":
                    if (typeName == "/Lotus/Types/Vehicles/Motorcycle/MotorcyclePowerSuit") {
                        if (quantity != 1) {
                            throw new Error(`unexpected acquisition quantity of Vehicles: got ${quantity}, expected 1`);
                        }
                        return addMotorcycle(inventory, typeName);
                    }
                    break;
                case "Lore":
                    if (typeName == "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentRewards") {
                        if (quantity != 1) {
                            throw new Error(
                                `unexpected acquisition quantity of LoreFragmentScans: got ${quantity}, expected 1`
                            );
                        }
                        const fragmentType = getRandomElement([
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentA",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentB",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentC",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentD",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentE",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentF",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentG",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentH",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentI",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentJ",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentK",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentL",
                            "/Lotus/Types/Lore/Fragments/GrineerGhoulFragments/GhoulFragmentM"
                        ])!;
                        addLoreFragmentScans(inventory, [
                            {
                                Progress: 1,
                                Region: "",
                                ItemType: fragmentType
                            }
                        ]);
                        return {};
                    }
                    break;
                case "Keys":
                    if (typeName.endsWith("Key") || typeName.substring(1).split("/")[3] == "RaidKeys") {
                        const levelKeyChanges = [{ ItemType: typeName, ItemCount: quantity }];
                        addLevelKeys(inventory, levelKeyChanges);
                        return { LevelKeys: levelKeyChanges };
                    }
                    break;
                case "Recipes":
                    if (typeName == "/Lotus/Types/Recipes/CosmeticUnenhancerItem") {
                        const miscItemChanges = [
                            {
                                ItemType: typeName,
                                ItemCount: quantity
                            } satisfies IMiscItem
                        ];
                        addMiscItems(inventory, miscItemChanges);
                        return {
                            MiscItems: miscItemChanges
                        };
                    }
                    break;
            }
            break;
        case "Weapons": {
            if (typeName.substring(1).split("/")[4] == "MeleeTrees") break;
            if (typeName.substring(1).split("/")[2] == "CrewShip") {
                const itemTypeToCount: Record<string, number> = {
                    "/Lotus/Weapons/CrewShip/MultitoolAmmoItem": 50,
                    "/Lotus/Weapons/CrewShip/EnergyAmmoItem": 100,
                    "/Lotus/Weapons/CrewShip/Missiles/MissileAmmoItem": 5,
                    "/Lotus/Weapons/CrewShip/Laser/MegaLaserAmmoItem": 1
                };
                if (typeName in itemTypeToCount) {
                    const inventoryChanges = {
                        CrewShipAmmo: [
                            {
                                ItemType: typeName.replace("AmmoItem", "Ammo"),
                                ItemCount: itemTypeToCount[typeName] * quantity
                            }
                        ]
                    };

                    addCrewShipAmmo(inventory, inventoryChanges.CrewShipAmmo);
                    return inventoryChanges;
                }
            }
            const productCategory = typeName.substring(1).split("/")[3];
            switch (productCategory) {
                case "Pistols":
                case "LongGuns":
                case "Melee": {
                    if (quantity != 1) {
                        throw new Error(
                            `unexpected acquisition quantity of ${productCategory}: got ${quantity}, expected 1`
                        );
                    }
                    const inventoryChanges = addEquipment(inventory, productCategory, typeName);
                    return {
                        ...inventoryChanges,
                        ...occupySlot(
                            inventory,
                            productCategoryToInventoryBin(productCategory) ?? eInventorySlot.WEAPONS,
                            premiumPurchase
                        )
                    };
                }
                case "Antiques": {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of Antiques: got ${quantity}, expected 1`);
                    }
                    const priorLen = inventory.Antiques.length;
                    const inventoryChanges = addEquipment(inventory, "Antiques", typeName, {
                        UpgradeVer: 101,
                        Configs: [{}, {}, {}]
                    });
                    const added = inventory.Antiques[priorLen];
                    setupAntique(inventory, typeName, added._id, inventoryChanges);
                    return {
                        ...inventoryChanges,
                        ...occupySlot(
                            inventory,
                            productCategoryToInventoryBin(productCategory) ?? eInventorySlot.AMPS,
                            premiumPurchase
                        )
                    };
                }
                case "OperatorAmplifiers": {
                    if (quantity != 1) {
                        throw new Error(`unexpected acquisition quantity of OperatorAmps: got ${quantity}, expected 1`);
                    }
                    let defaultOverrides = {};
                    if (
                        typeName ==
                        "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/OperatorTrainingAmpWeapon"
                    ) {
                        defaultOverrides = {
                            ModularParts: [
                                "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingGrip",
                                "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingChassis",
                                "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingBarrel"
                            ]
                        };
                    }
                    const inventoryChanges = addEquipment(inventory, "OperatorAmps", typeName, defaultOverrides);
                    return {
                        ...inventoryChanges,
                        ...occupySlot(inventory, eInventorySlot.AMPS, premiumPurchase)
                    };
                }
            }
            break;
        }
    }
    throw new Error(`unable to add item: ${typeName}`);
};

export const addItems = async (
    inventory: TInventoryDatabaseDocument,
    items: readonly ITypeCount[] | readonly string[],
    inventoryChanges: IInventoryChanges = {}
): Promise<IInventoryChanges> => {
    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);
    }
    return inventoryChanges;
};

export const applyDefaultUpgrades = (
    inventory: TInventoryDatabaseDocument,
    defaultUpgrades: readonly IDefaultUpgrade[] | undefined,
    inventoryChanges: IInventoryChanges
): IItemConfigDatabase[] => {
    const modsToGive: IRawUpgrade[] = [];
    const configs: IItemConfigDatabase[] = [];
    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 });
        }
    }
    if (modsToGive.length != 0) {
        addMods(inventory, modsToGive);
        inventoryChanges.RawUpgrades ??= [];
        for (const mod of modsToGive) {
            inventoryChanges.RawUpgrades.push(mod);
        }
    }
    return configs;
};

export const giveMoaPetDefaultWeapon = (
    inventory: TInventoryDatabaseDocument,
    moaPetsItemType: string,
    premiumPurchase: boolean,
    inventoryChanges: IInventoryChanges
): void => {
    const weapon = ExportSentinels[moaPetsItemType].defaultWeapon;
    if (weapon) {
        const category = ExportWeapons[weapon].productCategory;
        addEquipment(inventory, category, weapon, undefined, inventoryChanges);
        combineInventoryChanges(
            inventoryChanges,
            occupySlot(inventory, productCategoryToInventoryBin(category)!, premiumPurchase)
        );
    }
};

//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, eInventorySlot.SENTINELS, premiumPurchase));

    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
    if (ExportSentinels[sentinelName]?.defaultWeapon) {
        addSentinelWeapon(inventory, ExportSentinels[sentinelName].defaultWeapon, premiumPurchase, inventoryChanges);
    }

    const configs: IItemConfig[] = applyDefaultUpgrades(
        inventory,
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        ExportSentinels[sentinelName]?.defaultUpgrades,
        inventoryChanges
    );

    const sentinelIndex =
        inventory.Sentinels.push({
            ItemType: sentinelName,
            Configs: configs,
            XP: 0,
            Features: premiumPurchase ? eEquipmentFeatures.DOUBLE_CAPACITY : undefined,
            IsNew: inventory.Sentinels.find(x => x.ItemType == sentinelName) ? undefined : true
        }) - 1;
    inventoryChanges.Sentinels ??= [];
    inventoryChanges.Sentinels.push(inventory.Sentinels[sentinelIndex].toJSON<IEquipmentClient>());

    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, eInventorySlot.SENTINELS, premiumPurchase));

    const index = inventory.SentinelWeapons.push({ ItemType: typeName, XP: 0 }) - 1;
    inventoryChanges.SentinelWeapons ??= [];
    inventoryChanges.SentinelWeapons.push(inventory.SentinelWeapons[index].toJSON<IEquipmentClient>());
};

export const addPowerSuit = async (
    inventory: TInventoryDatabaseDocument,
    powersuitName: string,
    defaultOverwrites?: Partial<IEquipmentDatabase>,
    buildLabel: string = BL_LATEST,
    inventoryChanges: IInventoryChanges = {}
): Promise<IInventoryChanges> => {
    const powersuit = getPowerSuit(powersuitName);
    const exalted = powersuit?.exalted ?? [];
    for (const specialItem of exalted) {
        addSpecialItem(inventory, specialItem, inventoryChanges);
    }
    if (powersuit?.additionalItems) {
        for (const item of powersuit.additionalItems) {
            if (exalted.indexOf(item) == -1) {
                combineInventoryChanges(inventoryChanges, await addItem(inventory, item, 1));
            }
        }
    }
    let configs: IItemConfig[] = [];
    if (version_compare(buildLabel, gameToBuildVersion["15.0.0"]) < 0) {
        if (powersuitName in suitDefaultUpgrades) {
            configs = applyDefaultUpgrades(inventory, suitDefaultUpgrades[powersuitName], inventoryChanges);
        }
    }
    const suit: Omit<IEquipmentDatabase, "_id"> = Object.assign(
        {
            ItemType: powersuitName,
            Configs: configs,
            UpgradeVer: 101,
            XP: 0,
            IsNew: true
        },
        defaultOverwrites
    );
    if (suit.IsNew) {
        suit.IsNew = !inventory.Suits.find(x => x.ItemType == powersuitName);
    }
    if (!suit.IsNew) {
        suit.IsNew = undefined;
    }
    const suitIndex = inventory.Suits.push(suit) - 1;
    inventoryChanges.Suits ??= [];
    inventoryChanges.Suits.push(inventory.Suits[suitIndex].toJSON<IEquipmentClient>());
    return inventoryChanges;
};

const addMechSuit = async (
    inventory: TInventoryDatabaseDocument,
    mechsuitName: string,
    inventoryChanges: IInventoryChanges = {},
    features?: number
): Promise<IInventoryChanges> => {
    const powersuit = getPowerSuit(mechsuitName);
    const exalted = powersuit?.exalted ?? [];
    for (const specialItem of exalted) {
        addSpecialItem(inventory, specialItem, inventoryChanges);
    }
    if (powersuit?.additionalItems) {
        for (const item of powersuit.additionalItems) {
            if (exalted.indexOf(item) == -1) {
                combineInventoryChanges(inventoryChanges, await addItem(inventory, item, 1));
            }
        }
    }
    const suitIndex =
        inventory.MechSuits.push({
            ItemType: mechsuitName,
            Configs: [],
            UpgradeVer: 101,
            XP: 0,
            Features: features,
            IsNew: inventory.MechSuits.find(x => x.ItemType == mechsuitName) ? undefined : true
        }) - 1;
    inventoryChanges.MechSuits ??= [];
    inventoryChanges.MechSuits.push(inventory.MechSuits[suitIndex].toJSON<IEquipmentClient>());
    return inventoryChanges;
};

export const addSpecialItem = (
    inventory: Pick<TInventoryDatabaseDocument, "SpecialItems">,
    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<IEquipmentClient>());
};

const addSpaceSuit = (
    inventory: TInventoryDatabaseDocument,
    spacesuitName: string,
    inventoryChanges: IInventoryChanges = {},
    features?: number
): IInventoryChanges => {
    const suitIndex =
        inventory.SpaceSuits.push({
            ItemType: spacesuitName,
            Configs: [],
            UpgradeVer: 101,
            XP: 0,
            Features: features,
            IsNew: inventory.SpaceSuits.find(x => x.ItemType == spacesuitName) ? undefined : true
        }) - 1;
    inventoryChanges.SpaceSuits ??= [];
    inventoryChanges.SpaceSuits.push(inventory.SpaceSuits[suitIndex].toJSON<IEquipmentClient>());
    return inventoryChanges;
};

const createRandomTraits = (kubrowPetName: string, traitsPool: TTraitsPool): ITraits => {
    return {
        BaseColor: getRandomWeightedReward(traitsPool.Colors, kubrowWeights)!.type,
        SecondaryColor: getRandomWeightedReward(traitsPool.Colors, kubrowWeights)!.type,
        TertiaryColor: getRandomWeightedReward(traitsPool.Colors, kubrowWeights)!.type,
        AccentColor: getRandomWeightedReward(traitsPool.Colors, kubrowWeights)!.type,
        EyeColor: getRandomWeightedReward(traitsPool.EyeColors, kubrowWeights)!.type,
        FurPattern: getRandomWeightedReward(traitsPool.FurPatterns, kubrowFurPatternsWeights)!.type,
        Personality: kubrowPetName,
        BodyType: getRandomWeightedReward(traitsPool.BodyTypes, kubrowWeights)!.type,
        Head: traitsPool.Heads.length ? getRandomWeightedReward(traitsPool.Heads, kubrowWeights)!.type : undefined,
        Tail: traitsPool.Tails.length ? getRandomWeightedReward(traitsPool.Tails, kubrowWeights)!.type : undefined
    };
};

export const addKubrowPet = (
    inventory: TInventoryDatabaseDocument,
    kubrowPetName: string,
    details: IKubrowPetDetailsDatabase | undefined,
    premiumPurchase: boolean,
    inventoryChanges: IInventoryChanges,
    buildLabel: string
): IInventoryChanges => {
    const isPreU28 = version_compare(buildLabel, gameToBuildVersion["28.0.0"]) < 0;
    const isPreU26 = version_compare(buildLabel, gameToBuildVersion["26.0.0"]) < 0;
    const questCompleted = inventory.QuestKeys.some(x => x.ItemType.endsWith("KubrowQuestKeyChain") && x.Completed);

    combineInventoryChanges(
        inventoryChanges,
        occupySlot(inventory, isPreU28 ? eInventorySlot.PETS : eInventorySlot.SENTINELS, premiumPurchase)
    );

    // TODO: When incubating, this should only be given when claiming the recipe.
    const kubrowPet = ExportSentinels[kubrowPetName] as ISentinel | undefined;
    const exalted = kubrowPet?.exalted ?? [];
    for (const specialItem of exalted) {
        addSpecialItem(inventory, specialItem, inventoryChanges);
    }

    const configs: IItemConfig[] = applyDefaultUpgrades(inventory, kubrowPet?.defaultUpgrades, inventoryChanges);

    if (!details) {
        const isCatbrow = [
            "/Lotus/Types/Game/CatbrowPet/CheshireCatbrowPetPowerSuit",
            "/Lotus/Types/Game/CatbrowPet/MirrorCatbrowPetPowerSuit",
            "/Lotus/Types/Game/CatbrowPet/VampireCatbrowPetPowerSuit"
        ].includes(kubrowPetName);

        const traitsPool = isCatbrow ? catbrowDetails : kubrowDetails;
        let dominantTraits: ITraits;
        if (kubrowPetName == "/Lotus/Types/Game/CatbrowPet/VampireCatbrowPetPowerSuit") {
            dominantTraits = {
                BaseColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseVampire",
                SecondaryColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorSecondaryVampire",
                TertiaryColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorTertiaryVampire",
                AccentColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorAccentsVampire",
                EyeColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseA",
                FurPattern: "/Lotus/Types/Game/CatbrowPet/Patterns/CatbrowPetPatternVampire",
                Personality: kubrowPetName,
                BodyType: "/Lotus/Types/Game/CatbrowPet/BodyTypes/CatbrowPetVampireBodyType",
                Head: "/Lotus/Types/Game/CatbrowPet/Heads/CatbrowHeadVampire",
                Tail: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailVampire"
            };
        } else {
            dominantTraits = createRandomTraits(kubrowPetName, traitsPool);
            if (kubrowPetName == "/Lotus/Types/Game/KubrowPet/ChargerKubrowPetPowerSuit") {
                dominantTraits.BodyType = "/Lotus/Types/Game/KubrowPet/BodyTypes/ChargerKubrowPetBodyType";
                dominantTraits.FurPattern = "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternInfested";
            }
        }

        const recessiveTraits: ITraits = createRandomTraits(
            getRandomElement(
                isCatbrow
                    ? [
                          "/Lotus/Types/Game/CatbrowPet/MirrorCatbrowPetPowerSuit",
                          "/Lotus/Types/Game/CatbrowPet/CheshireCatbrowPetPowerSuit"
                      ]
                    : [
                          "/Lotus/Types/Game/KubrowPet/AdventurerKubrowPetPowerSuit",
                          "/Lotus/Types/Game/KubrowPet/FurtiveKubrowPetPowerSuit",
                          "/Lotus/Types/Game/KubrowPet/GuardKubrowPetPowerSuit",
                          "/Lotus/Types/Game/KubrowPet/HunterKubrowPetPowerSuit",
                          "/Lotus/Types/Game/KubrowPet/RetrieverKubrowPetPowerSuit"
                      ]
            )!,
            traitsPool
        );
        for (const key of Object.keys(recessiveTraits) as (keyof ITraits)[]) {
            // My heurstic approximation is a 20% chance for a dominant trait to be copied into the recessive traits. TODO: A more scientific statistical analysis maybe?
            if (Math.random() < 0.2) {
                recessiveTraits[key] = dominantTraits[key]!;
            }
        }

        details = {
            Name: "",
            IsPuppy: !premiumPurchase,
            HasCollar: isCatbrow || questCompleted,
            PrintsRemaining: !isPreU26 && isCatbrow ? 3 : 2,
            Status: premiumPurchase ? eStatus.StatusStasis : eStatus.StatusIncubating,
            HatchDate: premiumPurchase ? new Date() : new Date(Date.now() + 10 * unixTimesInMs.hour), // On live, this seems to be somewhat randomised so that the pet hatches 9~11 hours after start. TOVERIFY: Although it might differ for the quest recipe?
            IsMale: !!getRandomInt(0, 1),
            Size: getRandomInt(70, 100) / 100,
            DominantTraits: dominantTraits,
            RecessiveTraits: recessiveTraits
        };
    }

    const kubrowPetIndex =
        inventory.KubrowPets.push({
            ItemType: kubrowPetName,
            Configs: configs,
            XP: 0,
            Details: details,
            IsNew: inventory.KubrowPets.find(x => x.ItemType == kubrowPetName) ? undefined : true
        }) - 1;
    inventoryChanges.KubrowPets ??= [];
    inventoryChanges.KubrowPets.push(inventory.KubrowPets[kubrowPetIndex].toJSON<IEquipmentClient>());

    return inventoryChanges;
};

export const addKubrowPetPrint = (
    inventory: TInventoryDatabaseDocument,
    typeName: string,
    details: IKubrowGenetics,
    inventoryChanges: IInventoryChanges
): void => {
    inventoryChanges.KubrowPetPrints ??= [];
    inventoryChanges.KubrowPetPrints.push(
        inventory.KubrowPetPrints[
            inventory.KubrowPetPrints.push({
                ItemType: typeName,
                Name: details.Name,
                IsMale: details.IsMale,
                Size: details.Size,
                DominantTraits: details.DominantTraits,
                RecessiveTraits: details.RecessiveTraits
            }) - 1
        ].toJSON<IKubrowPetPrintClient>()
    );
};

export const updateSlots = <ST extends TInventorySlot>(
    inventory: Pick<TInventoryDatabaseDocument, ST>,
    slotName: ST,
    slotAmount: number,
    extraAmount: number
): void => {
    inventory[slotName].Slots += slotAmount;
    if (extraAmount != 0) {
        inventory[slotName].Extra ??= 0;
        inventory[slotName].Extra += extraAmount;
    }
};

export const CurrencyType = {
    CREDITS: false,
    PLATINUM: true,
    PAID_PLATINUM: 2
} as const;

type TCurrencyType = (typeof CurrencyType)[keyof typeof CurrencyType];

export const updateCurrency = (
    inventory: TInventoryDatabaseDocument,
    price: number,
    currencyType: TCurrencyType,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    return currencyType == CurrencyType.CREDITS
        ? updateCredits(inventory, price, inventoryChanges)
        : updatePlatinum(inventory, price, currencyType == CurrencyType.PAID_PLATINUM, inventoryChanges);
};

export const updateCredits = (
    inventory: Pick<TInventoryDatabaseDocument, "infiniteCredits" | "RegularCredits">,
    price: number,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    if (price != 0 && !inventory.infiniteCredits) {
        if (price > inventory.RegularCredits) {
            throw new Error(`Cannot subtract ${price} credits, would be left with ${inventory.RegularCredits - price}`);
        }
        inventoryChanges.RegularCredits ??= 0;
        inventoryChanges.RegularCredits -= price;
        inventory.RegularCredits -= price;
        logger.debug(`currency changes`, { RegularCredits: -price });
    }
    return inventoryChanges;
};

export const updatePlatinum = (
    inventory: Pick<TInventoryDatabaseDocument, "infinitePlatinum" | "PremiumCreditsFree" | "PremiumCredits">,
    price: number,
    mustBePaidPlatinum?: boolean,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    if (price != 0 && !inventory.infinitePlatinum) {
        if (price > 0) {
            if (mustBePaidPlatinum) {
                const paidPlatinumBalance = inventory.PremiumCredits - inventory.PremiumCreditsFree;
                if (price > paidPlatinumBalance) {
                    throw new Error(
                        `Cannot subtract ${price} paid platinum, would be left with ${paidPlatinumBalance - price}`
                    );
                }
            } else if (inventory.PremiumCreditsFree > 0) {
                const premiumCreditsFreeDelta = Math.min(price, inventory.PremiumCreditsFree) * -1;
                inventoryChanges.PremiumCreditsFree ??= 0;
                inventoryChanges.PremiumCreditsFree += premiumCreditsFreeDelta;
                inventory.PremiumCreditsFree += premiumCreditsFreeDelta;
                logger.debug(`spending ${-premiumCreditsFreeDelta} starter plat`);
            }
            if (price > inventory.PremiumCredits) {
                throw new Error(
                    `Cannot subtract ${price} platinum, would be left with ${inventory.PremiumCredits - price}`
                );
            }
        }
        inventoryChanges.PremiumCredits ??= 0;
        inventoryChanges.PremiumCredits -= price;
        inventory.PremiumCredits -= price;
        logger.debug(`currency changes`, { PremiumCredits: -price });
    }
    return inventoryChanges;
};

export const addFusionPoints = (
    inventory: Pick<TInventoryDatabaseDocument, "infiniteEndo" | "FusionPoints">,
    add: number
): number => {
    if (inventory.infiniteEndo) {
        add = 0;
    } else {
        if (inventory.FusionPoints + add > 2147483647) {
            logger.warn(`capping FusionPoints balance at 2147483647`);
            add = 2147483647 - inventory.FusionPoints;
        }
        inventory.FusionPoints += add;
    }
    return add;
};

export const addCrewShipFusionPoints = (
    inventory: Pick<TInventoryDatabaseDocument, "infiniteDirac" | "CrewShipFusionPoints">,
    add: number
): number => {
    if (inventory.infiniteDirac) {
        add = 0;
    } else {
        if (inventory.CrewShipFusionPoints + add > 2147483647) {
            logger.warn(`capping CrewShipFusionPoints balance at 2147483647`);
            add = 2147483647 - inventory.CrewShipFusionPoints;
        }
        inventory.CrewShipFusionPoints += add;
    }
    return add;
};

const standingLimitBinToInventoryKey: Record<
    Exclude<TStandingLimitBin, "STANDING_LIMIT_BIN_NONE">,
    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);

const getStandingLimit = (inventory: TInventoryDatabaseDocument, bin: TStandingLimitBin): number => {
    if (bin == "STANDING_LIMIT_BIN_NONE" || inventory.noDailyStandingLimits) {
        return Number.MAX_SAFE_INTEGER;
    }
    return inventory[standingLimitBinToInventoryKey[bin]];
};

const updateStandingLimit = (
    inventory: TInventoryDatabaseDocument,
    bin: TStandingLimitBin,
    subtrahend: number
): void => {
    if (bin != "STANDING_LIMIT_BIN_NONE" && !inventory.noDailyStandingLimits) {
        inventory[standingLimitBinToInventoryKey[bin]] -= subtrahend;
    }
};

export const eStandingSource = {
    Medallion: 0b101, // Effect on daily cap depends on syndicate. Propagates to aligned syndicates.
    Alignment: 0b010, // Applies & updates daily cap. Does not propagate to aligned syndicates (to avoid infinite recursion).
    Misc: 0b011 // Applies & updates daily cap. Propagates to aligned syndicates.
};
type TStandingSource = (typeof eStandingSource)[keyof typeof eStandingSource];

export const addStanding = async (
    inventory: TInventoryDatabaseDocument,
    buildLabel: string,
    syndicateTag: string,
    gainedStanding: number,
    affiliationMods: IAffiliationMods[] = [],
    source: TStandingSource = eStandingSource.Misc
): Promise<void> => {
    let syndicate = inventory.Affiliations.find(x => x.Tag == syndicateTag);
    const syndicateMeta = (await getSyndicate(syndicateTag, buildLabel))!;

    if (!syndicate) {
        syndicate =
            inventory.Affiliations[inventory.Affiliations.push({ Tag: syndicateTag, Standing: 0, Title: 0 }) - 1];
    }

    const max = getMaxStanding(syndicateMeta, syndicate.Title ?? 0);
    if (syndicate.Standing + gainedStanding > max) gainedStanding = max - syndicate.Standing;

    if (syndicate.Standing + gainedStanding < -71000) {
        gainedStanding = -71000 - syndicate.Standing;
    }

    if ((source == eStandingSource.Medallion && syndicateMeta.medallionsCappedByDailyLimit) || source & 0b010) {
        if (gainedStanding > getStandingLimit(inventory, syndicateMeta.dailyLimitBin)) {
            gainedStanding = getStandingLimit(inventory, syndicateMeta.dailyLimitBin);
        }
        updateStandingLimit(inventory, syndicateMeta.dailyLimitBin, gainedStanding);
    }

    syndicate.Standing += gainedStanding;
    const affiliationMod: IAffiliationMods = {
        Tag: syndicateTag,
        Standing: gainedStanding
    };
    affiliationMods.push(affiliationMod);

    if (syndicateMeta.alignments) {
        if (source & 0b001) {
            for (const [tag, factor] of Object.entries(syndicateMeta.alignments)) {
                await addStanding(
                    inventory,
                    buildLabel,
                    tag,
                    gainedStanding * factor,
                    affiliationMods,
                    eStandingSource.Alignment
                );
            }
        } else {
            while (syndicate.Standing < getMinStanding(syndicateMeta, syndicate.Title ?? 0)) {
                syndicate.Title ??= 0;
                syndicate.Title -= 1;
                affiliationMod.Title ??= 0;
                affiliationMod.Title -= 1;
                logger.debug(`${syndicateTag} is decreasing to title ${syndicate.Title} after applying alignment`);
            }
        }
    }
};

// TODO: AffiliationMods support (Nightwave).
export const updateGeneric = async (
    accountId: string | Types.ObjectId,
    data: IGenericUpdate
): Promise<IUpdateNodeIntrosResponse> => {
    const inventory = await getInventory(
        accountId,
        "NodeIntrosCompleted MiscItems ShipDecorations FlavourItems WeaponSkins"
    );

    // Make it an array for easier parsing.
    if (typeof data.NodeIntrosCompleted === "string") {
        data.NodeIntrosCompleted = [data.NodeIntrosCompleted];
    }

    const inventoryChanges: IInventoryChanges = {};
    for (const node of data.NodeIntrosCompleted) {
        if (inventory.NodeIntrosCompleted.indexOf(node) != -1) {
            continue;
        }

        // [OLD]BabyName_A[NEW]BabyName_B_1782322823
        if (node.startsWith("[OLD]") && node.includes("[NEW]")) {
            const parts = node.slice(5).split("[NEW]");
            if (parts.length === 2) {
                const oldNode = parts[0];
                const newNode = parts[1];
                const index = inventory.NodeIntrosCompleted.indexOf(oldNode);
                if (index !== -1) {
                    inventory.NodeIntrosCompleted[index] = newNode;
                    logger.debug(`changed ${oldNode} to ${newNode}`);
                }
            }
        } else {
            inventory.NodeIntrosCompleted.push(node);
            logger.debug(`completed popup/dialogue/cutscene for ${node}`);
        }

        if (node == "TL21Start") {
            await createMessage(accountId, [
                {
                    sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
                    msg: "",
                    sub: "/Lotus/Language/Alerts/LotusGiftDesc",
                    icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
                    highPriority: true,
                    att: ["/Lotus/Upgrades/Skins/Weapons/Staff/TnRibbonStaffSkin"]
                }
            ]);
        } else if (node == "TL21End") {
            await createMessage(accountId, [
                {
                    sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
                    msg: "",
                    sub: "/Lotus/Language/Alerts/LotusGiftDesc",
                    icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
                    highPriority: true,
                    att: ["/Lotus/Types/StoreItems/Packages/TannukaiBundle"]
                }
            ]);
        } else if (node == "TC2025") {
            inventoryChanges.ShipDecorations = [
                {
                    ItemType: "/Lotus/Types/Items/ShipDecos/TauGrineerLancerBobbleHead",
                    ItemCount: 1
                }
            ];
            addShipDecorations(inventory, inventoryChanges.ShipDecorations);
        } else if (node == "TC2026") {
            inventoryChanges.ShipDecorations = [
                {
                    ItemType: "/Lotus/Types/Items/ShipDecos/LisetPropCleaningDroneGrineer",
                    ItemCount: 1
                }
            ];
            addShipDecorations(inventory, inventoryChanges.ShipDecorations);
        } else if (node == "KayaFirstVisitPack") {
            inventoryChanges.MiscItems = [
                {
                    ItemType: "/Lotus/Types/Items/MiscItems/1999FixedStickersPack",
                    ItemCount: 1
                }
            ];
            addMiscItems(inventory, inventoryChanges.MiscItems);
        } else if (node == "BeatCaliberChicks") {
            await addEmailItem(inventory, "/Lotus/Types/Items/EmailItems/BeatCaliberChicksEmailItem", inventoryChanges);
        } else if (node == "ClearedFiveLoops") {
            await addEmailItem(inventory, "/Lotus/Types/Items/EmailItems/ClearedFiveLoopsEmailItem", inventoryChanges);
        } else if (node == "NokkoVisions_AllCompleted") {
            addCustomization(
                inventory,
                "/Lotus/Types/StoreItems/AvatarImages/Warframes/NokkoBabySecretGlyph",
                inventoryChanges
            );
            addSkin(inventory, "/Lotus/Upgrades/Skins/Clan/NokkoBabySecretEmblemItem", inventoryChanges);
        }
    }
    await inventory.save();

    return {
        MissionRewards: [],
        InventoryChanges: inventoryChanges
    };
};

export const addEquipment = <K extends TEquipmentKey>(
    inventory: Pick<TInventoryDatabaseDocument, K>,
    category: K,
    type: string,
    defaultOverwrites?: Partial<IEquipmentDatabase>,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    const equipment: Omit<IEquipmentDatabase, "_id"> = Object.assign(
        {
            ItemType: type,
            Configs: [],
            XP: 0,
            IsNew: category != "CrewShipWeapons" && category != "CrewShipSalvagedWeapons"
        },
        defaultOverwrites
    );
    if (equipment.IsNew) {
        equipment.IsNew = !inventory[category].find(x => x.ItemType == type);
    }
    if (!equipment.IsNew) {
        equipment.IsNew = undefined;
    }
    const index = inventory[category].push(equipment) - 1;

    inventoryChanges[category] ??= [];
    inventoryChanges[category].push(inventory[category][index].toJSON<IEquipmentClient>());
    return inventoryChanges;
};

const addCustomization = (
    inventory: TInventoryDatabaseDocument,
    customizationName: string,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    if (!inventory.FlavourItems.some(x => x.ItemType == customizationName)) {
        const flavourItemIndex = inventory.FlavourItems.push({ ItemType: customizationName }) - 1;
        inventoryChanges.FlavourItems ??= [];
        inventoryChanges.FlavourItems.push(inventory.FlavourItems[flavourItemIndex].toJSON<IFlavourItem>());
    }
    return inventoryChanges;
};

export const addSkin = (
    inventory: Pick<TInventoryDatabaseDocument, "WeaponSkins" | "BountyScore">,
    typeName: string,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    if (typeName == "/Lotus/Upgrades/Skins/Clan/BountyHunterBadgeItem") {
        logger.debug(`stratos emblem, increasing bounty score`);
        inventory.BountyScore ??= 0;
        inventory.BountyScore += 1;
    }

    // https://onlyg.it/OpenWF/SpaceNinjaServer/issues/3941
    if (typeName.endsWith("LeftArmor")) {
        addSkin(
            inventory,
            typeName.substring(0, typeName.length - "LeftArmor".length) + "RightArmor",
            inventoryChanges
        );
    }

    if (inventory.WeaponSkins.some(x => x.ItemType == typeName)) {
        logger.debug(`refusing to add WeaponSkin ${typeName} because account already owns it`);
    } else {
        const index =
            inventory.WeaponSkins.push({
                ItemType: typeName,
                IsNew: typeName.startsWith("/Lotus/Upgrades/Skins/RailJack/") ? undefined : true // railjack skins are incompatible with this flag
            }) - 1;
        inventoryChanges.WeaponSkins ??= [];
        inventoryChanges.WeaponSkins.push(inventory.WeaponSkins[index].toJSON<IWeaponSkinClient>());
    }
    return inventoryChanges;
};

export const addCrewShipWeaponSkin = (
    inventory: Pick<TInventoryDatabaseDocument, "CrewShipWeaponSkins">,
    typeName: string,
    upgradeFingerprint: string | undefined,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    const index =
        inventory.CrewShipWeaponSkins.push({ ItemType: typeName, UpgradeFingerprint: upgradeFingerprint }) - 1;
    inventoryChanges.CrewShipWeaponSkins ??= [];
    inventoryChanges.CrewShipWeaponSkins.push(inventory.CrewShipWeaponSkins[index].toJSON<IUpgradeClient>());
    return inventoryChanges;
};

export const addCrewShipSalvagedWeaponSkin = (
    inventory: TInventoryDatabaseDocument,
    typeName: string,
    upgradeFingerprint: string | undefined,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    const index =
        inventory.CrewShipSalvagedWeaponSkins.push({ ItemType: typeName, UpgradeFingerprint: upgradeFingerprint }) - 1;
    inventoryChanges.CrewShipSalvagedWeaponSkins ??= [];
    inventoryChanges.CrewShipSalvagedWeaponSkins.push(
        inventory.CrewShipSalvagedWeaponSkins[index].toJSON<IUpgradeClient>()
    );
    return inventoryChanges;
};

const addCrewShip = async (
    inventory: TInventoryDatabaseDocument,
    typeName: string,
    inventoryChanges: IInventoryChanges = {}
): Promise<IInventoryChanges> => {
    if (inventory.CrewShips.length != 0) {
        logger.warn("refusing to add CrewShip because account already has one");
    } else {
        const index = inventory.CrewShips.push({ ItemType: typeName }) - 1;
        inventoryChanges.CrewShips ??= [];
        inventoryChanges.CrewShips.push(inventory.CrewShips[index].toJSON<IEquipmentClient>());
        const railjackQuest = inventory.QuestKeys.find(
            qk => qk.ItemType === "/Lotus/Types/Keys/RailJackBuildQuest/RailjackBuildQuestKeyChain"
        );
        if (!railjackQuest || !railjackQuest.Completed) {
            const questChanges = await completeQuest(
                inventory,
                "/Lotus/Types/Keys/RailJackBuildQuest/RailjackBuildQuestKeyChain",
                BL_LATEST,
                false
            );
            if (questChanges) {
                inventoryChanges.QuestKeys ??= [];
                inventoryChanges.QuestKeys.push(questChanges);
            }
        }
    }
    return inventoryChanges;
};

const addCrewShipHarness = (
    inventory: TInventoryDatabaseDocument,
    typeName: string,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    if (inventory.CrewShipHarnesses.length != 0) {
        logger.warn("refusing to add CrewShipHarness because account already has one");
    } else {
        const index = inventory.CrewShipHarnesses.push({ ItemType: typeName }) - 1;
        inventoryChanges.CrewShipHarnesses ??= [];
        inventoryChanges.CrewShipHarnesses.push(inventory.CrewShipHarnesses[index].toJSON<IEquipmentClient>());
    }
    return inventoryChanges;
};

const addMotorcycle = (
    inventory: TInventoryDatabaseDocument,
    typeName: string,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    if (inventory.Motorcycles.length != 0) {
        logger.warn("refusing to add Motorcycle because account already has one");
    } else {
        const index = inventory.Motorcycles.push({ ItemType: typeName }) - 1;
        inventoryChanges.Motorcycles ??= [];
        inventoryChanges.Motorcycles.push(inventory.Motorcycles[index].toJSON<IEquipmentClient>());
    }
    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<IDroneClient>());
    return inventoryChanges;
};

const getAccountRandSeed = (inventory: TInventoryDatabaseDocument): number => {
    return parseInt(inventory.accountOwnerId.toString().substring(2, 8), 16);
};

/*export const getCrewMemberSkills = (seed: bigint, skillPointsToAssign: number): Record<string, number> => {
    const rng = new SRng(seed);

    const skills = ["PILOTING", "GUNNERY", "ENGINEERING", "COMBAT", "SURVIVABILITY"];
    for (let i = 1; i != 5; ++i) {
        const swapIndex = rng.randomInt(0, i);
        if (swapIndex != i) {
            const tmp = skills[i];
            skills[i] = skills[swapIndex];
            skills[swapIndex] = tmp;
        }
    }

    rng.randomFloat(); // unused afaict

    const skillAssignments = [0, 0, 0, 0, 0];
    for (let skill = 0; skillPointsToAssign; skill = (skill + 1) % 5) {
        const maxIncrease = Math.min(5 - skillAssignments[skill], skillPointsToAssign);
        const increase = rng.randomInt(0, maxIncrease);
        skillAssignments[skill] += increase;
        skillPointsToAssign -= increase;
    }

    skillAssignments.sort((a, b) => b - a);

    const combined: Record<string, number> = {};
    for (let i = 0; i != 5; ++i) {
        combined[skills[i]] = skillAssignments[i];
    }
    return combined;
};*/

const addCrewMember = (
    inventory: TInventoryDatabaseDocument,
    itemType: string,
    seed: bigint,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    // SkillEfficiency is additional to the base stats, so we don't need to compute this
    //const skillPointsToAssign = itemType.endsWith("Strong") ? 12 : itemType.indexOf("Medium") != -1 ? 10 : 8;
    //const skills = getCrewMemberSkills(seed, skillPointsToAssign);

    // Arbiters = male
    // CephalonSuda = female
    // NewLoka = female
    // Perrin = male
    // RedVeil = male
    // SteelMeridian = female
    // JadeShadowsPart2Mission doesn't have powersuit type
    const powersuitType = !itemType.includes("JadeShadowsPart2Mission")
        ? itemType.includes("Arbiters") || itemType.includes("Perrin") || itemType.includes("RedVeil")
            ? "/Lotus/Powersuits/NpcPowersuits/CrewMemberMaleSuit"
            : "/Lotus/Powersuits/NpcPowersuits/CrewMemberFemaleSuit"
        : undefined;

    const index =
        inventory.CrewMembers.push({
            ItemType: itemType,
            NemesisFingerprint: 0n,
            Seed: seed,
            SkillEfficiency: {
                PILOTING: { Assigned: 0 },
                GUNNERY: { Assigned: 0 },
                ENGINEERING: { Assigned: 0 },
                COMBAT: { Assigned: 0 },
                SURVIVABILITY: { Assigned: 0 }
            },
            PowersuitType: powersuitType
        }) - 1;
    inventoryChanges.CrewMembers ??= [];
    inventoryChanges.CrewMembers.push(inventory.CrewMembers[index].toJSON<ICrewMemberClient>());
    return inventoryChanges;
};

export const addEmailItem = async (
    inventory: Pick<
        TInventoryDatabaseDocument,
        "accountOwnerId" | "EmailItems" | "AdultOperatorLoadOuts" | "OperatorSuits"
    >,
    typeName: string,
    inventoryChanges: IInventoryChanges = {},
    buildLabel: string = BL_LATEST
): Promise<IInventoryChanges> => {
    const meta = ExportEmailItems[typeName];
    const emailItem = inventory.EmailItems.find(x => x.ItemType == typeName);
    if (!emailItem || !meta.sendOnlyOnce) {
        const msg: IMessageCreationTemplate = convertInboxMessage(meta.message);
        if (
            msg.cinematic == "/Lotus/Levels/1999/PlayerHomeBalconyCinematics.level" ||
            msg.cinematic == "/Lotus/Levels/EntratiLab/TriadRomanceCinematics.level"
        ) {
            if (version_compare(buildLabel, gameToBuildVersion["41.0.0"]) < 0) {
                msg.customData = JSON.stringify({
                    Tag: msg.customData + "KissCin",
                    CinLoadout: {
                        Skins: inventory.AdultOperatorLoadOuts[0].Skins,
                        Upgrades: inventory.AdultOperatorLoadOuts[0].Upgrades,
                        attcol: inventory.AdultOperatorLoadOuts[0].attcol,
                        cloth: inventory.AdultOperatorLoadOuts[0].cloth,
                        eyecol: inventory.AdultOperatorLoadOuts[0].eyecol,
                        pricol: inventory.AdultOperatorLoadOuts[0].pricol,
                        syancol: inventory.AdultOperatorLoadOuts[0].syancol
                    }
                });
            } else {
                const postRemasterAdultOperator =
                    inventory.OperatorSuits.find(
                        x => x.ItemType == "/Lotus/Powersuits/Operator/AdultOperatorSuitRemaster"
                        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
                    )?.Configs?.[0] ?? {};
                msg.customData = JSON.stringify({
                    Tag: msg.customData + "KissCin",
                    CinLoadout: {
                        Skins: postRemasterAdultOperator.Skins,
                        Upgrades: postRemasterAdultOperator.Upgrades,
                        attcol: postRemasterAdultOperator.attcol,
                        cloth: postRemasterAdultOperator.cloth,
                        eyecol: postRemasterAdultOperator.eyecol,
                        pricol: postRemasterAdultOperator.pricol,
                        syancol: postRemasterAdultOperator.syancol
                    }
                });
            }
        }
        await createMessage(inventory.accountOwnerId, [msg]);

        if (emailItem) {
            emailItem.ItemCount += 1;
        } else {
            inventory.EmailItems.push({ ItemType: typeName, ItemCount: 1 });
        }

        inventoryChanges.EmailItems ??= [];
        inventoryChanges.EmailItems.push({ ItemType: typeName, ItemCount: 1 });
    }
    return inventoryChanges;
};

const xpEarningParts: readonly string[] = [
    "LWPT_BLADE",
    "LWPT_GUN_BARREL",
    "LWPT_AMP_OCULUS",
    "LWPT_MOA_HEAD",
    "LWPT_ZANUKA_HEAD",
    "LWPT_HB_DECK"
];

export const applyClientEquipmentUpdates = <K extends TEquipmentKey>(
    inventory: Pick<TInventoryDatabaseDocument, K | "XPInfo" | "infiniteRevives">,
    gearArray: IEquipmentClient[],
    categoryName: K,
    buildLabel: string
): void => {
    const category = inventory[categoryName];

    gearArray.forEach(({ ItemId, XP, InfestationDate, ExtraRemaining }) => {
        const item = category.id(fromOid(ItemId));
        if (!item) {
            logger.warn(`Skipping unknown ${categoryName} item: id ${fromOid(ItemId)} not found`);
            return;
        }

        if (XP) {
            item.XP ??= 0;
            item.XP += XP;

            if (
                categoryName != "SpecialItems" ||
                item.ItemType == "/Lotus/Powersuits/Khora/Kavat/KhoraKavatPowerSuit" ||
                item.ItemType == "/Lotus/Powersuits/Khora/Kavat/KhoraPrimeKavatPowerSuit"
            ) {
                let xpItemType = item.ItemType;
                if (item.ModularParts) {
                    for (const part of item.ModularParts) {
                        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
                        const partType = ExportWeapons[part]?.partType;
                        if (partType !== undefined && xpEarningParts.indexOf(partType) != -1) {
                            xpItemType = part;
                            break;
                        }
                    }
                    logger.debug(`adding xp to ${xpItemType} for modular item ${fromOid(ItemId)} (${item.ItemType})`);
                }

                const xpinfoIndex = inventory.XPInfo.findIndex(x => x.ItemType == xpItemType);
                if (xpinfoIndex !== -1) {
                    const xpinfo = inventory.XPInfo[xpinfoIndex];
                    xpinfo.XP += XP;
                } else {
                    inventory.XPInfo.push({
                        ItemType: xpItemType,
                        XP: XP
                    });
                }
            }
        }

        if (InfestationDate) {
            // 2147483647000 means cured, otherwise became infected
            item.InfestationDate =
                InfestationDate.$date.$numberLong == "2147483647000" ? new Date(0) : fromMongoDate(InfestationDate);
        }

        if (ExtraRemaining !== undefined) {
            if (inventory.infiniteRevives) {
                return;
            }
            if (version_compare(buildLabel, gameToBuildVersion["10.3.3"]) >= 0) {
                if (ExtraRemaining > 0) {
                    throw new Error(
                        `unexpected value for ExtraRemaining (${ExtraRemaining}); expected a delta from this client`
                    );
                }
                item.ExtraRemaining ??= 4;
                item.ExtraRemaining += ExtraRemaining;
            } else {
                if (ExtraRemaining < 0) {
                    throw new Error(
                        `unexpected value for ExtraRemaining (${ExtraRemaining}); expected an absolute value from this client`
                    );
                }
                item.ExtraRemaining = ExtraRemaining;
            }
        }
    });
};

// May not be used for adding argon or void traces (subtracting these is fine).
export const addMiscItem = (
    inventory: Pick<TInventoryDatabaseDocument, "MiscItems">,
    type: string,
    count: number,
    inventoryChanges: IInventoryChanges = {}
): void => {
    const miscItemChanges: IMiscItem[] = [
        {
            ItemType: type,
            ItemCount: count
        }
    ];
    addMiscItems(inventory, miscItemChanges);
    combineInventoryChanges(inventoryChanges, { MiscItems: miscItemChanges });
};

// May not be used for adding argon or void traces (subtracting these is fine).
export const addMiscItems = (
    inventory: Pick<TInventoryDatabaseDocument, "MiscItems">,
    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 (MiscItems[itemIndex].ItemCount == 0) {
            MiscItems.splice(itemIndex, 1);
        } else if (MiscItems[itemIndex].ItemCount < 0) {
            throw new Error(
                `Cannot remove ${ItemCount * -1}x ${ItemType} from MiscItems, would be left with ${MiscItems[itemIndex].ItemCount}`
            );
        }
    });
};

// Correctly handles acquisition of argon and void traces.
export const addMiscItemsComplex = (
    inventory: Pick<TInventoryDatabaseDocument, "MiscItems" | "FoundToday" | "PlayerLevel" | "spoofMasteryRank">,
    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 (ItemCount > 0) {
            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;
                }
            } else if (ItemType == "/Lotus/Types/Items/MiscItems/VoidTearDrop") {
                const masteryRank =
                    inventory.spoofMasteryRank && inventory.spoofMasteryRank >= 0
                        ? inventory.spoofMasteryRank
                        : inventory.PlayerLevel;
                const maxVoidTraces = 100 + masteryRank * 50;
                if (MiscItems[itemIndex].ItemCount > maxVoidTraces) {
                    MiscItems[itemIndex].ItemCount = maxVoidTraces;
                    logger.debug(`capped void traces to ${maxVoidTraces} for mastery rank ${masteryRank}`);
                }
            }
        }

        if (MiscItems[itemIndex].ItemCount == 0) {
            MiscItems.splice(itemIndex, 1);
        } else if (MiscItems[itemIndex].ItemCount < 0) {
            throw new Error(
                `Cannot remove ${ItemCount * -1}x ${ItemType} from MiscItems, would be left with ${MiscItems[itemIndex].ItemCount}`
            );
        }
    });
};

const applyArrayChanges = <
    K extends "ShipDecorations" | "Consumables" | "CrewShipRawSalvage" | "CrewShipAmmo" | "Recipes" | "LevelKeys"
>(
    inventory: Pick<TInventoryDatabaseDocument, K>,
    key: K,
    changes: ITypeCount[]
): void => {
    const arr: ITypeCount[] = inventory[key];
    for (const change of changes) {
        if (change.ItemCount != 0) {
            let itemIndex = arr.findIndex(x => x.ItemType === change.ItemType);
            if (itemIndex == -1) {
                itemIndex = arr.push({ ItemType: change.ItemType, ItemCount: 0 }) - 1;
            }

            arr[itemIndex].ItemCount += change.ItemCount;
            if (arr[itemIndex].ItemCount == 0) {
                arr.splice(itemIndex, 1);
            } else if (arr[itemIndex].ItemCount < 0) {
                throw new Error(
                    `Cannot remove ${change.ItemCount * -1}x ${change.ItemType} from ${key}, would be left with ${arr[itemIndex].ItemCount}`
                );
            }
        }
    }
};

export const addShipDecorations = (
    inventory: Pick<TInventoryDatabaseDocument, "ShipDecorations">,
    itemsArray: ITypeCount[]
): void => {
    applyArrayChanges(inventory, "ShipDecorations", itemsArray);
};

export const addConsumables = (
    inventory: Pick<TInventoryDatabaseDocument, "Consumables">,
    itemsArray: ITypeCount[]
): void => {
    applyArrayChanges(inventory, "Consumables", itemsArray);
};

export const addCrewShipRawSalvage = (
    inventory: Pick<TInventoryDatabaseDocument, "CrewShipRawSalvage">,
    itemsArray: ITypeCount[]
): void => {
    applyArrayChanges(inventory, "CrewShipRawSalvage", itemsArray);
};

export const addCrewShipAmmo = (
    inventory: Pick<TInventoryDatabaseDocument, "CrewShipAmmo">,
    itemsArray: ITypeCount[]
): void => {
    applyArrayChanges(inventory, "CrewShipAmmo", itemsArray);
};

export const addRecipes = (inventory: Pick<TInventoryDatabaseDocument, "Recipes">, itemsArray: ITypeCount[]): void => {
    applyArrayChanges(inventory, "Recipes", itemsArray);
};

export const addLevelKeys = (
    inventory: Pick<TInventoryDatabaseDocument, "LevelKeys">,
    itemsArray: ITypeCount[]
): void => {
    applyArrayChanges(inventory, "LevelKeys", itemsArray);
};

export const addMods = (
    inventory: Pick<TInventoryDatabaseDocument, "RawUpgrades">,
    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) {
            throw new Error(
                `Cannot remove ${ItemCount * -1}x ${ItemType} from RawUpgrades, would be left with ${RawUpgrades[itemIndex].ItemCount}`
            );
        }
    });
};

export const addFusionTreasures = (
    inventory: Pick<TInventoryDatabaseDocument, "HybridFusionTreasures">,
    changes: IFusionTreasure[]
): void => {
    for (const change of changes) {
        if (change.ItemCount != 0) {
            if (change.ItemCount > 0) {
                if (change.ItemCount > 100) {
                    throw new Error(
                        `unexpected acquisition quantity of FusionTreasures: got ${change.ItemCount}, expected 0..100`
                    );
                }
                for (let i = 0; i != change.ItemCount; ++i) {
                    inventory.HybridFusionTreasures.push({
                        ItemType: change.ItemType,
                        Sockets: change.Sockets || 0
                    });
                }
            } else {
                for (let i = 0; i != change.ItemCount; --i) {
                    const fusex = inventory.HybridFusionTreasures.find(
                        x => x.ItemType == change.ItemType && (x.Sockets || 0) == (change.Sockets || 0)
                    );
                    if (!fusex) {
                        throw new Error(`Not enough ${change.ItemType} with Sockets=${change.Sockets || 0} to remove`);
                    }
                    inventory.HybridFusionTreasures.pull({ _id: fusex._id });
                }
            }
        }
    }
};

export const addFocusXpIncreases = (inventory: TInventoryDatabaseDocument, focusXpPlus: number[]): void => {
    const FocusType = {
        AP_UNIVERSAL: 0,
        AP_ATTACK: 1,
        AP_DEFENSE: 2,
        AP_TACTIC: 3,
        AP_POWER: 4,
        AP_PRECEPT: 5,
        AP_FUSION: 6,
        AP_WARD: 7,
        AP_UMBRA: 8,
        AP_ANY: 9
    };

    inventory.FocusXP ??= {};
    if (focusXpPlus[FocusType.AP_ATTACK]) {
        inventory.FocusXP.AP_ATTACK ??= 0;
        inventory.FocusXP.AP_ATTACK += focusXpPlus[FocusType.AP_ATTACK];
    }
    if (focusXpPlus[FocusType.AP_DEFENSE]) {
        inventory.FocusXP.AP_DEFENSE ??= 0;
        inventory.FocusXP.AP_DEFENSE += focusXpPlus[FocusType.AP_DEFENSE];
    }
    if (focusXpPlus[FocusType.AP_TACTIC]) {
        inventory.FocusXP.AP_TACTIC ??= 0;
        inventory.FocusXP.AP_TACTIC += focusXpPlus[FocusType.AP_TACTIC];
    }
    if (focusXpPlus[FocusType.AP_POWER]) {
        inventory.FocusXP.AP_POWER ??= 0;
        inventory.FocusXP.AP_POWER += focusXpPlus[FocusType.AP_POWER];
    }
    if (focusXpPlus[FocusType.AP_WARD]) {
        inventory.FocusXP.AP_WARD ??= 0;
        inventory.FocusXP.AP_WARD += focusXpPlus[FocusType.AP_WARD];
    }

    if (!inventory.noDailyFocusLimit) {
        inventory.DailyFocus -= focusXpPlus.reduce((a, b) => a + b, 0);
    }
};

export const addLoreFragmentScans = (inventory: TInventoryDatabaseDocument, arr: ILoreFragmentScan[]): void => {
    arr.forEach(clientFragment => {
        const fragment = inventory.LoreFragmentScans.find(x => x.ItemType == clientFragment.ItemType);
        if (fragment) {
            fragment.Progress += clientFragment.Progress;
        } else {
            inventory.LoreFragmentScans.push(clientFragment);
        }
    });
};

const challengeNameToPath: Record<string, string> = {};
for (const path of Object.keys(ExportChallenges)) {
    challengeNameToPath[path.split("/").pop()!] = path;
}

export const addChallenges = async (
    buildVersion: number,
    inventory: TInventoryDatabaseDocument,
    ChallengeProgress: IChallengeProgress[],
    SeasonChallengeCompletions?: ISeasonChallenge[],
    inventoryChanges: IInventoryChanges = {}
): Promise<IAffiliationMods[]> => {
    for (const { Name, Progress, Completed } of ChallengeProgress) {
        let dbChallenge = inventory.ChallengeProgress.find(x => x.Name == Name);
        if (dbChallenge) {
            dbChallenge.Progress = Progress;
        } else {
            dbChallenge = { Name, Progress };
            inventory.ChallengeProgress.push(dbChallenge);
        }

        if (Name.startsWith("Calendar")) {
            addString(getCalendarProgress(inventory).SeasonProgress.ActivatedChallenges, Name);
        }

        if ((Completed?.length ?? 0) > (dbChallenge.Completed?.length ?? 0)) {
            dbChallenge.Completed ??= [];
            for (const completion of Completed!) {
                if (dbChallenge.Completed.indexOf(completion) == -1) {
                    dbChallenge.Completed.push(completion);
                    if (completion == "challengeRewards") {
                        const path = challengeNameToPath[Name];
                        const meta = ExportChallenges[path];
                        if (meta.message) {
                            logger.debug(`${Name} completed, sending inbox message`);
                            await createMessage(inventory.accountOwnerId, [convertInboxMessage(meta.message)]);
                            continue;
                        }
                        if (meta.countedRewards) {
                            logger.debug(`${Name} completed, giving rewards:`, meta.countedRewards);
                            for (const cr of meta.countedRewards) {
                                combineInventoryChanges(
                                    inventoryChanges,
                                    await addItem(inventory, fromStoreItem(cr.StoreItem), cr.ItemCount)
                                );
                            }
                            continue;
                        }
                        logger.warn(`ignoring unknown challenge completion`, { name: Name, path, completion, meta });
                        dbChallenge.Progress = 0;
                        dbChallenge.Completed = [];
                    }
                }
            }
        } else {
            dbChallenge.Completed = Completed;
        }
    }

    const affiliationMods: IAffiliationMods[] = [];
    if (SeasonChallengeCompletions) {
        for (const challenge of SeasonChallengeCompletions) {
            // Ignore challenges that weren't completed just now
            if (!ChallengeProgress.find(x => challenge.challenge.indexOf(x.Name) != -1)) {
                continue;
            }

            const meta = ExportChallenges[challenge.challenge];
            const nightwaveSyndicateTag = getNightwaveSyndicateTag(buildVersion);
            logger.debug("Completed season challenge", {
                uniqueName: challenge.challenge,
                syndicateTag: nightwaveSyndicateTag,
                ...meta
            });
            if (nightwaveSyndicateTag) {
                let affiliation = inventory.Affiliations.find(x => x.Tag == nightwaveSyndicateTag);
                if (!affiliation) {
                    affiliation =
                        inventory.Affiliations[
                            inventory.Affiliations.push({
                                Tag: nightwaveSyndicateTag,
                                Standing: 0
                            }) - 1
                        ];
                }

                const standingToAdd = meta.standing! * (inventory.nightwaveStandingMultiplier ?? 1);
                affiliation.Standing += standingToAdd;
                if (affiliationMods.length == 0) {
                    affiliationMods.push({ Tag: nightwaveSyndicateTag });
                }
                affiliationMods[0].Standing ??= 0;
                affiliationMods[0].Standing += standingToAdd;
            }
        }
    }
    return affiliationMods;
};

export const addCalendarProgress = (inventory: TInventoryDatabaseDocument, value: { challenge: string }[]): void => {
    const calendarProgress = getCalendarProgress(inventory);
    const currentSeason = getWorldState().KnownCalendarSeasons[0];
    calendarProgress.SeasonProgress.LastCompletedChallengeDayIdx = currentSeason.Days.findIndex(
        day => day.events.length != 0 && day.events[0].challenge == value[value.length - 1].challenge
    );
    checkCalendarAutoAdvance(inventory, currentSeason);
};

export const resetKahlWeeklyMission = (
    inventory: Pick<TInventoryDatabaseDocument, "Affiliations">,
    value: string
): void => {
    const currentWeek = Math.trunc((Date.now() - KAHL_EPOCH) / unixTimesInMs.week);
    const kahl = inventory.Affiliations.find(x => x.Tag == "KahlSyndicate");
    if (kahl && kahl.WeeklyMissions) {
        const index = kahl.WeeklyMissions.findIndex(i => i.WeekCount == Number(value.slice("KahlSyndicate_".length)));
        if (index !== -1) {
            logger.debug(`kahl weekly mission completed, handling weekly reset`);
            kahl.WeeklyMissions[index].CompletedMission = true;
            if (kahl.WeeklyMissions.findIndex(i => i.WeekCount == currentWeek + 1) == -1) {
                kahl.WeeklyMissions.splice(0, kahl.WeeklyMissions.length, kahl.WeeklyMissions[index]);
                kahl.WeeklyMissions.push({
                    MissionIndex: kahl.WeeklyMissions[kahl.WeeklyMissions.length - 1].MissionIndex + 1,
                    CompletedMission: false,
                    JobManifest: "/Lotus/Syndicates/Kahl/KahlJobManifestVersionThree",
                    Challenges: [],
                    WeekCount: currentWeek + 1
                });
            }
        }
    }
};

export const addKahlProgress = (
    inventory: Pick<TInventoryDatabaseDocument, "Affiliations" | "MiscItems">,
    value: IWeeklyMissionChallengeInfo[],
    inventoryChanges: IInventoryChanges
): void => {
    for (const info of value) {
        let stockEarned = 0;
        const kahl = inventory.Affiliations.find(x => x.Tag == info.Syndicate)!;
        const mission = kahl.WeeklyMissions!.find(i => i.WeekCount == info.WeekCount)!;
        if (info.ResetChallenges) {
            mission.ChallengesReset = true;
        }
        for (const challenge of info.CompletedChallenges) {
            if (mission.Challenges.indexOf(challenge) == -1) {
                stockEarned += challenge == "/Lotus/Types/Challenges/KahlMissions/NoDeathKahlChallenge" ? 30 : 15;
                mission.Challenges.push(challenge);
            }
        }
        logger.debug(`adding ${stockEarned} stock for kahl challenges`);
        addMiscItem(inventory, "/Lotus/Types/Items/MiscItems/KahlCreds", stockEarned, inventoryChanges);
    }
};

export const addMissionComplete = (
    inventory: Pick<TInventoryDatabaseDocument, "Missions">,
    { Tag, Completes, Tier }: IMission
): void => {
    const { Missions } = inventory;
    const itemIndex = Missions.findIndex(item => item.Tag === Tag);

    if (itemIndex !== -1) {
        Missions[itemIndex].Completes += Completes;
        if (Completes && Tier) {
            Missions[itemIndex].Tier = Tier;
        }
    } else if (Tag !== "") {
        //Prevent "Chains of Harrow" pushing "Tag":"" into inventory.Missions displaying incorrect steel path progress
        Missions.push({ Tag, Completes });
    }
};

export const addBooster = (ItemType: string, timeSecs: number, inventory: TInventoryDatabaseDocument): void => {
    const currentTime = Math.floor(Date.now() / 1000);

    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) + timeSecs;
    } else {
        Boosters.push({ ItemType, ExpiryDate: currentTime + timeSecs });
    }
};

export const setBooster = (ItemType: string, expiryTimeSecs: number, inventory: TInventoryDatabaseDocument): void => {
    const { Boosters } = inventory;

    const itemIndex = Boosters.findIndex(booster => booster.ItemType === ItemType);

    if (itemIndex !== -1) {
        const existingBooster = Boosters[itemIndex];
        existingBooster.ExpiryDate = Math.max(existingBooster.ExpiryDate, expiryTimeSecs);
    } else {
        Boosters.push({ ItemType, ExpiryDate: expiryTimeSecs });
    }
};

export const updateSyndicate = (
    inventory: Pick<TInventoryDatabaseDocument, "Affiliations">,
    syndicateUpdate: IAffiliationChange[]
): void => {
    for (const affiliation of syndicateUpdate) {
        const syndicate = inventory.Affiliations.find(x => x.Tag == affiliation.Tag);
        if (syndicate !== undefined) {
            syndicate.Standing += affiliation.Standing;
            syndicate.Title = syndicate.Title === undefined ? affiliation.Title : syndicate.Title + affiliation.Title;
        } else {
            inventory.Affiliations.push({
                Standing: affiliation.Standing,
                Title: affiliation.Title,
                Tag: affiliation.Tag,
                FreeFavorsEarned: [],
                FreeFavorsUsed: []
            });
        }
    }
};

/**
 * @returns object with inventory keys of changes or empty object when no items were added
 */
export const addKeyChainItems = async (
    inventory: TInventoryDatabaseDocument,
    keyChainData: IKeyChainRequest,
    buildLabel: string
): Promise<IInventoryChanges> => {
    const keyChainItems = getKeyChainItems(keyChainData, buildLabel);

    logger.debug(
        `adding key chain items ${keyChainItems.join()} for ${keyChainData.KeyChain} at stage ${keyChainData.ChainStage}`
    );

    const nonStoreItems = keyChainItems.map(item => fromStoreItem(item));

    const inventoryChanges: IInventoryChanges = {};

    for (const item of nonStoreItems) {
        const inventoryChangesDelta = await addItem(inventory, item);
        combineInventoryChanges(inventoryChanges, inventoryChangesDelta);
    }

    return inventoryChanges;
};

export const createLibraryDailyTask = (buildLabel: string): ILibraryDailyTaskInfo => {
    const enemyTypes = getRandomElement(libraryDailyTasks)!;
    const enemyAvatar = ExportEnemies.avatars[enemyTypes[0]];
    const scansRequired = getRandomInt(2, 4);
    const rewardQuantityMultiplier = shouldDoServerQol(
        "doubleDailySynthesisEndoReward",
        buildLabel,
        gameToBuildVersion["22.13.4"] // change actually made in 22.13.3
    )
        ? 2.5
        : 1.25;
    return {
        EnemyTypes: enemyTypes,
        EnemyLocTag: enemyAvatar.name,
        EnemyIcon: enemyAvatar.icon!,
        ScansRequired: scansRequired,
        RewardStoreItem:
            Math.random() < 0.25 // Unsure what the actual chance for a "rare reward" is
                ? "/Lotus/StoreItems/Upgrades/Mods/FusionBundles/RareFusionBundle"
                : "/Lotus/StoreItems/Upgrades/Mods/FusionBundles/UncommonFusionBundle",
        RewardQuantity: Math.trunc(scansRequired * rewardQuantityMultiplier),
        RewardStanding: 2500 * scansRequired
    };
};

export const setupKahlSyndicate = (inventory: TInventoryDatabaseDocument): void => {
    if (inventory.Affiliations.some(x => x.Tag == "KahlSyndicate")) {
        logger.debug(`refusing to setup KahlSyndicate. it's already set up.`);
    } else {
        inventory.Affiliations.push({
            Title: 1,
            Standing: 1,
            WeeklyMissions: [
                {
                    MissionIndex: 0,
                    CompletedMission: false,
                    JobManifest: "/Lotus/Syndicates/Kahl/KahlJobManifestVersionThree",
                    WeekCount: Math.trunc((Date.now() - KAHL_EPOCH) / unixTimesInMs.week),
                    Challenges: []
                }
            ],
            Tag: "KahlSyndicate"
        });
    }
};

export const cleanupInventory = (inventory: TInventoryDatabaseDocument): void => {
    if (ensureOperatorSuitsRemasterData(inventory)) {
        logger.debug(`added missing Operator suits for Operator remaster`);
    }

    let index = inventory.MiscItems.findIndex(x => x.ItemType == "");
    if (index != -1) {
        inventory.MiscItems.splice(index, 1);
    }

    index = inventory.Affiliations.findIndex(x => x.Tag == "KahlSyndicate");
    if (index != -1 && !inventory.Affiliations[index].WeeklyMissions) {
        logger.debug(`KahlSyndicate seems broken, removing it and setting up again`);
        inventory.Affiliations.splice(index, 1);
        setupKahlSyndicate(inventory);
    }

    index = inventory.Missions.findIndex(x => x.Tag == "");
    if (index != -1) {
        inventory.Missions.splice(index, 1);
        logger.debug(`removed mission completion of ""`);
    }

    const set = new Set<string>();
    for (let i = 0; i != inventory.Affiliations.length; ) {
        if (set.has(inventory.Affiliations[i].Tag)) {
            logger.debug(`removing duplicate affiliation for ${inventory.Affiliations[i].Tag}`);
            inventory.Affiliations.splice(i, 1);
        } else {
            set.add(inventory.Affiliations[i].Tag);
            ++i;
        }
    }

    const LibrarySyndicate = inventory.Affiliations.find(x => x.Tag == "LibrarySyndicate");
    if (LibrarySyndicate && LibrarySyndicate.FreeFavorsEarned) {
        logger.debug(`removing FreeFavorsEarned from LibrarySyndicate`);
        LibrarySyndicate.FreeFavorsEarned = undefined;
    }

    if (inventory.LotusCustomization) {
        if (
            Array.isArray(inventory.LotusCustomization.attcol) ||
            Array.isArray(inventory.LotusCustomization.sigcol) ||
            Array.isArray(inventory.LotusCustomization.eyecol) ||
            Array.isArray(inventory.LotusCustomization.facial) ||
            Array.isArray(inventory.LotusCustomization.cloth) ||
            Array.isArray(inventory.LotusCustomization.syancol)
        ) {
            logger.debug(`fixing empty objects represented as empty arrays in LotusCustomization`);
            inventory.LotusCustomization.attcol = {};
            inventory.LotusCustomization.sigcol = {};
            inventory.LotusCustomization.eyecol = {};
            inventory.LotusCustomization.facial = {};
            inventory.LotusCustomization.cloth = {};
            inventory.LotusCustomization.syancol = {};
        }
    }

    {
        let numFixed = 0;
        for (const equipmentKey of equipmentKeys) {
            for (const item of inventory[equipmentKey]) {
                if (item.ModularParts?.length === 0) {
                    item.ModularParts = undefined;
                    ++numFixed;
                }
            }
        }
        if (numFixed != 0) {
            logger.debug(`removed ModularParts from ${numFixed} non-modular items`);
        }
    }

    {
        let fixed = 0;
        for (const key of equipmentKeys) {
            for (const item of inventory[key]) {
                for (const config of item.Configs) {
                    if (config.Skins) {
                        for (let i = 0; i != config.Skins.length; ++i) {
                            if (config.Skins[i] == "[object Object]") {
                                config.Skins[i] = "";
                                ++fixed;
                            }
                        }
                    }
                }
            }
        }
        if (fixed) {
            logger.debug(`fixed ${fixed} invalid skin upgrade ids`);
        }
    }

    {
        let fixed = 0;
        for (const upgrade of inventory.Upgrades) {
            if (upgrade.UpgradeFingerprint) {
                const fingerprint = upgrade.UpgradeFingerprint;
                const json = JSON.parse(fingerprint) as {
                    lvl?: number | string;
                    lvlReq?: number | string;
                    Progress?: number | string;
                    reqLevel?: number | string;
                };

                if (
                    typeof json.lvl == "string" ||
                    typeof json.lvlReq == "string" ||
                    typeof json.Progress == "string" ||
                    typeof json.reqLevel == "string"
                ) {
                    upgrade.UpgradeFingerprint = convertFromLegacyFingerprint(convertToLegacyFingerprint(fingerprint));
                    ++fixed;
                }
            }
        }
        if (fixed) {
            logger.debug(`fixed ${fixed} invalid upgrade fingerprints`);
        }
    }

    if (inventory.KubrowPetEggs) {
        addMiscItem(inventory, "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg", inventory.KubrowPetEggs.length);
        logger.debug(`migrated ${inventory.KubrowPetEggs.length} KubrowPetEggs to MiscItems`);
        inventory.KubrowPetEggs = undefined;
    }

    // Migrate fusion treasures to hybrid format
    if (inventory.FusionTreasures) {
        migrateFusionTreasures(inventory, inventory.FusionTreasures);
        inventory.FusionTreasures = undefined;
    }

    // Fix WeeklyGuildVaultBonusInfo
    if (inventory.WeeklyGuildVaultBonusInfo && !Array.isArray(inventory.WeeklyGuildVaultBonusInfo)) {
        inventory.WeeklyGuildVaultBonusInfo = [inventory.WeeklyGuildVaultBonusInfo];
    }

    if (inventory.PeriodicMissionCompletions.some(x => x.tag.startsWith("TreasureHunt"))) {
        const challenge = inventory.ChallengeProgress.find(x => x.Name == "MCCompleteTreasureHunt");
        if (challenge) {
            if (challenge.Progress != 1) {
                challenge.Progress = 1;
                logger.debug("fixed broken MCCompleteTreasureHunt challenge");
            }
        } else {
            inventory.ChallengeProgress.push({
                Name: "MCCompleteTreasureHunt",
                Progress: 1
            });
            logger.debug("pushed MCCompleteTreasureHunt challenge because we alredy completed Treasure Hunt in past");
        }
    }

    for (const challenge of inventory.ChallengeProgress) {
        if (challenge.Completed?.length === 0) {
            challenge.Completed = undefined;
        }
    }
};

export const migrateFusionTreasures = (
    inventory: TInventoryDatabaseDocument,
    fusionTreasures: IFusionTreasure[]
): void => {
    const arr: IFusionTreasure[] = [];
    for (const treasure of fusionTreasures) {
        if (treasure.ItemCount > 100) {
            logger.debug(
                `reducing ${treasure.ItemCount} owned ${treasure.ItemType}_${treasure.Sockets} to 100 for migration to hybrid format`
            );
            arr.push({ ItemType: treasure.ItemType, ItemCount: 100, Sockets: treasure.Sockets });
        } else if (treasure.ItemCount > 0) {
            arr.push(treasure);
        }
    }
    addFusionTreasures(inventory, arr);
};

export const createDefaultDialogue = (dialogueName: string): IDialogueDatabase => {
    return {
        Rank: 1,
        Chemistry: 0,
        AvailableDate: new Date(0),
        AvailableGiftDate: new Date(0),
        RankUpExpiry: new Date(0),
        BountyChemExpiry: new Date(0),
        QueuedDialogues: [],
        Gifts: [],
        Booleans: [],
        Completed: [],
        Counters: [],
        DialogueName: dialogueName
    };
};

export const getDialogue = (
    inventory: Pick<TInventoryDatabaseDocument, "DialogueHistory">,
    dialogueName: string
): IDialogueDatabase => {
    inventory.DialogueHistory ??= {};
    inventory.DialogueHistory.Dialogues ??= [];
    let dialogue = inventory.DialogueHistory.Dialogues.find(x => x.DialogueName == dialogueName);
    if (!dialogue) {
        dialogue =
            inventory.DialogueHistory.Dialogues[
                inventory.DialogueHistory.Dialogues.push(createDefaultDialogue(dialogueName)) - 1
            ];
    }
    return dialogue;
};

export const getCalendarProgress = (
    inventory: Pick<TInventoryDatabaseDocument, "CalendarProgress">
): ICalendarProgress => {
    const currentSeason = getWorldState().KnownCalendarSeasons[0];

    if (!inventory.CalendarProgress) {
        inventory.CalendarProgress = {
            Version: 19,
            Iteration: currentSeason.YearIteration,
            YearProgress: {
                Upgrades: []
            },
            SeasonProgress: {
                SeasonType: currentSeason.Season,
                LastCompletedDayIdx: -1,
                LastCompletedChallengeDayIdx: -1,
                ActivatedChallenges: []
            }
        };
    }

    const yearRolledOver = inventory.CalendarProgress.Iteration != currentSeason.YearIteration;
    if (yearRolledOver) {
        inventory.CalendarProgress.Iteration = currentSeason.YearIteration;
        inventory.CalendarProgress.YearProgress.Upgrades = [];
    }
    if (yearRolledOver || inventory.CalendarProgress.SeasonProgress.SeasonType != currentSeason.Season) {
        inventory.CalendarProgress.SeasonProgress.SeasonType = currentSeason.Season;
        inventory.CalendarProgress.SeasonProgress.LastCompletedDayIdx = -1;
        inventory.CalendarProgress.SeasonProgress.LastCompletedChallengeDayIdx = -1;
        inventory.CalendarProgress.SeasonProgress.ActivatedChallenges = [];
    }

    return inventory.CalendarProgress;
};

export const checkCalendarAutoAdvance = (
    inventory: TInventoryDatabaseDocument,
    currentSeason: ICalendarSeason
): void => {
    const calendarProgress = inventory.CalendarProgress!;
    for (
        let dayIndex = calendarProgress.SeasonProgress.LastCompletedDayIdx + 1;
        dayIndex != currentSeason.Days.length;
        ++dayIndex
    ) {
        const day = currentSeason.Days[dayIndex];
        if (day.events.length == 0) {
            // birthday
            if (day.day == 1) {
                // kaya
                if ((inventory.Affiliations.find(x => x.Tag == "HexSyndicate")?.Title || 0) >= 4) {
                    break;
                }
                logger.debug(`cannot talk to kaya, skipping birthday`);
                calendarProgress.SeasonProgress.LastCompletedDayIdx++;
            } else if (day.day == 74 || day.day == 355) {
                // minerva, velimir
                if ((inventory.Affiliations.find(x => x.Tag == "HexSyndicate")?.Title || 0) >= 5) {
                    break;
                }
                logger.debug(`cannot talk to minerva/velimir, skipping birthday`);
                calendarProgress.SeasonProgress.LastCompletedDayIdx++;
            } else {
                break;
            }
        } else if (day.events[0].type == "CET_CHALLENGE") {
            if (calendarProgress.SeasonProgress.LastCompletedChallengeDayIdx < dayIndex) {
                break;
            }
            logger.trace(`already completed the challenge, skipping ahead`);
            calendarProgress.SeasonProgress.LastCompletedDayIdx++;
        } else {
            break;
        }
    }
};

export const giveNemesisWeaponRecipe = (
    inventory: TInventoryDatabaseDocument,
    weaponType: string,
    nemesisName: string = "AGOR ROK",
    weaponLoc?: string,
    profile: INemesisProfile = generateNemesisProfile()
): void => {
    if (!weaponLoc) {
        weaponLoc = ExportWeapons[weaponType].name;
    }
    const recipeType = Object.entries(ExportRecipes).find(arr => arr[1].resultType == weaponType)![0];
    addRecipes(inventory, [
        {
            ItemType: recipeType,
            ItemCount: 1
        }
    ]);
    inventory.PendingRecipes.push({
        CompletionDate: new Date(),
        ItemType: recipeType,
        TargetFingerprint: JSON.stringify({
            ItemType: "/Lotus/Weapons/Grineer/KuvaLich/Upgrades/InnateDamageRandomMod",
            UpgradeFingerprint: {
                compat: weaponType,
                buffs: [
                    {
                        Tag: profile.innateDamageTag,
                        Value: profile.innateDamageValue
                    }
                ]
            },
            Name: weaponLoc + "|" + nemesisName
        } satisfies INemesisWeaponTargetFingerprint)
    });
};

export const giveNemesisPetRecipe = (
    inventory: TInventoryDatabaseDocument,
    nemesisName: string = "AGOR ROK",
    profile: INemesisProfile = generateNemesisProfile()
): void => {
    const head = profile.petHead!;
    const body = profile.petBody!;
    const legs = profile.petLegs!;
    const tail = profile.petTail!;
    const recipeType = Object.entries(ExportRecipes).find(arr => arr[1].resultType == head)![0];
    addRecipes(inventory, [
        {
            ItemType: recipeType,
            ItemCount: 1
        }
    ]);
    inventory.PendingRecipes.push({
        CompletionDate: new Date(),
        ItemType: recipeType,
        TargetFingerprint: JSON.stringify({
            Parts: [head, body, legs, tail],
            Name: "/Lotus/Language/Pets/ZanukaPetName|" + nemesisName
        } satisfies INemesisPetTargetFingerprint)
    });
};

export const getEffectiveAvatarImageType = (inventory: TInventoryDatabaseDocument): string => {
    return inventory.ActiveAvatarImageType ?? "/Lotus/Types/StoreItems/AvatarImages/AvatarImageDefault";
};

export const updateEntratiVault = (inventory: TInventoryDatabaseDocument): void => {
    if (!inventory.EntratiVaultCountResetDate || Date.now() >= inventory.EntratiVaultCountResetDate.getTime()) {
        const EPOCH = 1734307200 * 1000; // Mondays, amirite?
        const day = Math.trunc((Date.now() - EPOCH) / 86400000);
        const week = Math.trunc(day / 7);
        const weekStart = EPOCH + week * 604800000;
        const weekEnd = weekStart + 604800000;
        inventory.EntratiVaultCountLastPeriod = 0;
        inventory.EntratiVaultCountResetDate = new Date(weekEnd);
        if (inventory.EntratiLabConquestUnlocked) {
            inventory.EntratiLabConquestUnlocked = 0;
            inventory.EntratiLabConquestCacheScoreMission = 0;
            inventory.EntratiLabConquestActiveFrameVariants = [];
        }
        if (inventory.EchoesHexConquestUnlocked) {
            inventory.EchoesHexConquestUnlocked = 0;
            inventory.EchoesHexConquestCacheScoreMission = 0;
            inventory.EchoesHexConquestActiveFrameVariants = [];
            inventory.EchoesHexConquestActiveStickers = [];
        }
    }
};

export const isEligibleForThousandYearFishDeco = (inventory: TInventoryDatabaseDocument): boolean => {
    const lfsTypes = new Set<string>(inventory.LoreFragmentScans.map(x => x.ItemType));
    return (
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentA") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentB") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentC") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentD") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentE") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentF") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentG") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentH") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentI") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentJ") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentK") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentL") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentM") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentN") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentO") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentP") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentQ") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentR") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentS") &&
        lfsTypes.has("/Lotus/Types/Lore/Fragments/EidolonFragments/EidolonFragmentT")
    );
};

export const giveThousandYearFishDeco = async (inventory: TInventoryDatabaseDocument): Promise<void> => {
    inventory.receivedThousandYearFishDeco = true;
    await createMessage(inventory.accountOwnerId, [
        {
            sndr: "/Lotus/Language/Npcs/Onkko",
            icon: "/Lotus/Interface/Icons/Npcs/Ostron/Onkko.png",
            sub: "/Lotus/Language/Messages/EidolonCollectibleRewardTitle",
            msg: "/Lotus/Language/Messages/EidolonCollectibleRewardBody",
            att: ["/Lotus/Types/Items/ShipDecos/LisetPropOstStoneFish"],
            highPriority: true
        }
    ]);
};

export const ensureUserHasFounderHonoria = async (inventory: TInventoryDatabaseDocument): Promise<void> => {
    if (!inventory.FlavourItems.some(x => x.ItemType == "/Lotus/Types/Items/Titles/FounderLvl1Title")) {
        const eligibleItems = [
            "/Lotus/Types/Items/Titles/FounderLvl1Title",
            "/Lotus/Types/Items/Titles/FounderLvl2Title",
            "/Lotus/Types/Items/Titles/FounderLvl3Title",
            "/Lotus/Types/Items/Titles/FounderLvl4Title"
        ].splice(0, inventory.Founder);
        for (const item of eligibleItems) {
            addCustomization(inventory, item);
        }
        await createMessage(inventory.accountOwnerId, [
            {
                sndr: "/Lotus/Language/CircleOfHell/RoatheName",
                icon: "/Lotus/Interface/Icons/Npcs/Roathe.png",
                sub: "/Lotus/Language/Inbox/FounderTitleReceivedTitle",
                msg: "/Lotus/Language/Inbox/FounderTitleReceivedBody",
                att: eligibleItems,
                highPriority: true, // TOVERIFY
                attVisualOnly: true
            }
        ]);
    }
};

export const handleTauMemories = async (inventory: TInventoryDatabaseDocument): Promise<void> => {
    const oldPeace = inventory.QuestKeys.find(
        x => x.ItemType == "/Lotus/Types/Keys/TauPrequel/TauPrequelQuestKeyChain"
    );
    if (oldPeace?.Completed) {
        oldPeace.CustomData ||= `{}`;
        const customData = JSON.parse(oldPeace.CustomData) as ITauPrequelQuestCustomData;
        // If the user didn't pick any memories during the quest, ChosenMemories would not exist. TOVERIFY: What is the expected behaviour in that case?
        customData.ChosenMemories ??= {
            TauPrequelStage11_PoolA: { Name: "cat0", Choice: 1 },
            TauPrequelStage13_PoolA: { Name: "cat1", Choice: 1 },
            TauPrequelStage14_PoolA: { Name: "cat2", Choice: 1 }
        };
        if (!customData.ChosenMemories.InboxMessageSent) {
            customData.ChosenMemories.InboxMessageSent = true;
            oldPeace.CustomData = JSON.stringify(customData);
            const playerNames = [];
            for (const memory of Object.values(customData.ChosenMemories)) {
                if (typeof memory == "object") {
                    playerNames.push(memory.Name);
                }
            }
            await createMessage(inventory.accountOwnerId, [
                {
                    sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender_PostWar",
                    msg: "/Lotus/Language/TauPrequel/TauPrequelFinal/PostQuestSomaticBearersInboxBody",
                    arg: [
                        {
                            Key: "PLAYER_NAMES",
                            Tag: playerNames.join(",")
                        }
                    ],
                    att: ["/Lotus/Types/Items/ShipDecos/Tau/LisetPropSomaticBearerFlowerPostQuestItem"],
                    sub: "/Lotus/Language/TauPrequel/TauPrequelFinal/PostQuestSomaticBearersInboxTitle",
                    icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
                    highPriority: true
                }
            ]);
        }
    }
};

export const updateIncentiveStates = async (
    accountId: string | Types.ObjectId,
    entry: ICollectibleEntry
): Promise<void> => {
    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(accountId, [kuriaMessage50]);
                } else {
                    await createMessage(accountId, [kuriaMessage75]);
                }
            }
        }
        if (progress >= 1.0) {
            await createMessage(accountId, [kuriaMessage100]);
        }
    } else if (entry.CollectibleType == "/Lotus/Types/Lore/Fragments/DuviriMITWFragments/DuviriMITWCollectibleDeco") {
        if (entry.Count == entry.ReqScans) {
            await createMessage(accountId, [
                {
                    sub: "/Lotus/Language/Isleweaver/IsleweaverFragmentInboxMessageSender",
                    sndr: "/Lotus/Language/Isleweaver/IsleweaverFragmentInboxMessageSubject",
                    msg: "/Lotus/Language/Isleweaver/IsleweaverFragmentInboxMessageBody",
                    icon: "/Lotus/Interface/Icons/Npcs/UnknownSource.png",
                    att: [
                        "/Lotus/Types/Items/ShipDecos/Duviri/Mitw/RusalkaHat",
                        "/Lotus/Types/Items/ShipDecos/Duviri/Mitw/RusalkaMedals"
                    ],
                    highPriority: true
                }
            ]);
        }
    }
};

const steelPathSystems: [number, string][] = [
    [0, "Mercury"],
    [1, "Venus"],
    [2, "Earth"],
    [3, "Mars"],
    [4, "Jupiter"],
    [5, "Saturn"],
    [6, "Uranus"],
    [7, "Neptune"],
    [8, "Pluto"],
    [9, "Ceres"],
    [10, "Eris"],
    [11, "Sedna"],
    [12, "Europa"],
    [14, "Void"],
    [15, "Phobos"],
    [16, "Deimos"],
    [17, "Lua"],
    [18, "KuvaFortress"],
    [20, "DeepSpace"],
    [21, "Zariman"],
    [22, "Duviri"],
    [23, "1999"],
    [24, "Perita"]
];

const isRequiredForSteelPathTrophy = (region: IRegion): boolean => {
    return region.missionType != "MT_PVP";
};

const isRequiredForRailjackSteelPathTrophy = (region: IRegion): boolean => {
    return (
        region.systemIndex != 6 && // ash & garuda nodes
        !region.hidden && // nemesis nodes
        region.maxEnemyLevel != 1 // freeroam node
    );
};

export const ensureUserHasSteelPathRewards = async (
    inventory: TInventoryDatabaseDocument,
    silent?: true
): Promise<void> => {
    const completedNodes = new Set<string>();
    for (const mission of inventory.Missions) {
        if (mission.Tier) {
            completedNodes.add(mission.Tag);
        }
    }

    const eligibleSystems = new Set<number>(steelPathSystems.map(x => x[0]));
    for (const [tag, region] of Object.entries(ExportRegions)) {
        if (!completedNodes.has(tag)) {
            const isRailjack = region.missionType == "MT_RAILJACK";
            if (isRailjack ? isRequiredForRailjackSteelPathTrophy(region) : isRequiredForSteelPathTrophy(region)) {
                eligibleSystems.delete(isRailjack ? 20 : region.systemIndex);
            }
        }
    }

    for (const systemIndex of eligibleSystems) {
        const systemShortName = steelPathSystems.find(x => x[0] == systemIndex)![1];
        const emoteItem = `/Lotus/Types/Items/Emotes/Hardmode${systemShortName}Emote`;
        if (!inventory.FlavourItems.some(x => x.ItemType == emoteItem)) {
            const countedAtt: ITypeCount[] = [];

            const trophyItem: ITypeCount = {
                ItemType: `/Lotus/Types/Items/ShipDecos/PlanetTrophies/PlanetTrophy${systemShortName}Bronze`,
                ItemCount: 1
            };
            addShipDecorations(inventory, [trophyItem]);
            countedAtt.push(trophyItem);

            if (systemIndex == 16) {
                const otherTropyItem: ITypeCount = {
                    ItemType: `/Lotus/Types/Items/ShipDecos/PlanetTrophies/PlanetTrophyDerelictBronze`,
                    ItemCount: 1
                };
                addShipDecorations(inventory, [otherTropyItem]);
                countedAtt.push(otherTropyItem);
            }

            inventory.FlavourItems.push({ ItemType: emoteItem });
            countedAtt.push({
                ItemType: emoteItem,
                ItemCount: 1
            });

            if (systemIndex == 16) {
                addCustomization(inventory, "/Lotus/Types/Items/Emotes/HardmodeDerelictEmote");
                countedAtt.push({
                    ItemType: "/Lotus/Types/Items/Emotes/HardmodeDerelictEmote",
                    ItemCount: 1
                });
            }

            addMiscItem(inventory, "/Lotus/Types/Items/MiscItems/SteelEssence", 25);
            countedAtt.push({
                ItemType: "/Lotus/Types/Items/MiscItems/SteelEssence",
                ItemCount: 25
            });

            if (!silent) {
                await createMessage(inventory.accountOwnerId, [
                    {
                        sndr: "/Lotus/Language/Bosses/Teshin",
                        msg: "/Lotus/Language/Inbox/HardModeRewardMsgBody",
                        arg: [
                            {
                                Key: "PLANET_NAME",
                                Tag: ExportSystems[systemIndex].name
                            }
                        ],
                        countedAtt,
                        attVisualOnly: true,
                        sub: "/Lotus/Language/Inbox/HardModeRewardMsgTitle",
                        icon: "/Lotus/Interface/Icons/Npcs/Teshin.png",
                        highPriority: true,
                        minBuildVersion: buildVersionToInt("2020.07.08.00.00") // U28.1 introduced Steel Path
                    }
                ]);
            }
        }
    }
};

export const dispatchPendingPremiumCredits = async (inventory: TInventoryDatabaseDocument): Promise<void> => {
    await createMessage(inventory.accountOwnerId, [
        {
            sndr: "/Lotus/Language/Bosses/Ordis",
            msg: "/Lotus/Language/Inbox/FoundItemsBody",
            sub: "/Lotus/Language/Inbox/FoundItemsTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Ordis.png",
            PremiumCredits: inventory.pendingPremiumCredits,
            highPriority: true // FoundItems messages on live have this flag, tho as we are reusing it here for platinum rewards, it's maybe questionable if it's needed, but whatever.
        }
    ]);
    inventory.pendingPremiumCredits = undefined;
};

export const processGoalProgressUpdates = async (
    inventory: Pick<TInventoryDatabaseDocument, "PersonalGoalProgress" | "GuildId">,
    account: TAccountDocument,
    buildLabel: string,
    goalProgressUpdates: IGoalsProgress[],
    currentNode?: string,
    isStealPath: boolean = false
): Promise<void> => {
    for (const uploadProgress of goalProgressUpdates) {
        const goal = getWorldState(buildLabel, false, false).Goals.find(
            x => fromOid(x._id) == fromOid(uploadProgress._id)
        );
        if (goal) {
            const goalId = fromOid(goal._id);

            if (goal.Tag == "ShadowgrapherEvent") {
                if (currentNode == goal.Node) {
                    const extraCount = isStealPath ? 4 : 3;
                    uploadProgress.Best += extraCount;
                    uploadProgress.Count += extraCount;
                }
            }
            if (goal.Personal) {
                inventory.PersonalGoalProgress ??= [];
                const goalProgress = inventory.PersonalGoalProgress.find(x => x.goalId.equals(goalId));
                if (!goalProgress) {
                    inventory.PersonalGoalProgress.push({
                        Best: uploadProgress.Best,
                        Count: uploadProgress.Count,
                        Tag: goal.Tag,
                        goalId: new Types.ObjectId(goalId)
                    });
                }

                let currentMissionKey: string | undefined;
                if (currentNode == goal.Node) {
                    currentMissionKey = goal.MissionKeyName;
                } else if (goal.ConcurrentNodes && goal.ConcurrentMissionKeyNames) {
                    for (let i = 0; i < goal.ConcurrentNodes.length; i++) {
                        if (currentNode == goal.ConcurrentNodes[i]) {
                            currentMissionKey = goal.ConcurrentMissionKeyNames[i];
                            break;
                        }
                    }
                }
                const rewards = [];
                let countBeforeUpload = goalProgress?.Count ?? 0;
                let totalCount = countBeforeUpload + uploadProgress.Count;
                if (goal.Best) {
                    countBeforeUpload = goalProgress?.Best ?? 0;
                    totalCount = uploadProgress.Best;
                }

                {
                    if (
                        "InterimGoals" in goal &&
                        goal.InterimGoals &&
                        "InterimRewards" in goal &&
                        goal.InterimRewards
                    ) {
                        for (let i = 0; i < goal.InterimGoals.length; i++) {
                            if (
                                goal.InterimGoals[i] &&
                                goal.InterimGoals[i] <= totalCount &&
                                (!goalProgress || countBeforeUpload < goal.InterimGoals[i]) &&
                                goal.InterimRewards[i]
                            ) {
                                rewards.push(goal.InterimRewards[i]);
                            }
                        }
                    }
                    if (
                        goal.Goal &&
                        goal.Goal <= totalCount &&
                        (!goalProgress || countBeforeUpload < goal.Goal) &&
                        goal.Reward
                    ) {
                        rewards.push(goal.Reward);
                    }
                    if (
                        goal.BonusGoal &&
                        goal.BonusGoal <= totalCount &&
                        (!goalProgress || countBeforeUpload < goal.BonusGoal) &&
                        goal.BonusReward
                    ) {
                        rewards.push(goal.BonusReward);
                    }
                }

                const messages: IMessageCreationTemplate[] = [];
                const infos: {
                    sndr: string;
                    msg: string;
                    sub: string;
                    icon: string;
                    arg?: string[];
                    minBuildLabel?: string;
                }[] = [];

                {
                    if (currentMissionKey && currentMissionKey in goalMessagesByKey) {
                        infos.push(goalMessagesByKey[currentMissionKey]);
                    } else if (goal.Tag in goalMessagesByTag) {
                        const combinedGoals = [
                            ...("InterimGoals" in goal && goal.InterimGoals ? goal.InterimGoals : []),
                            goal.Goal,
                            goal.BonusGoal
                        ];
                        combinedGoals.forEach((n, i) => {
                            if (n !== undefined && n > countBeforeUpload && n <= totalCount) {
                                infos.push(goalMessagesByTag[goal.Tag][i]);
                            }
                        });
                    }
                }

                for (let i = 0; i < rewards.length; i++) {
                    if (infos[i]) {
                        const info = infos[i];
                        const reward = rewards[i];
                        const message: IMessageCreationTemplate = {
                            sndr: info.sndr,
                            msg: info.msg,
                            sub: info.sub,
                            icon: info.icon,
                            minBuildVersion: info.minBuildLabel ? buildVersionToInt(info.minBuildLabel) : undefined,
                            highPriority: true
                        };
                        if (reward.items) {
                            message.att = reward.items.map(x => (isStoreItem(x) ? fromStoreItem(x) : x));
                        }
                        if (reward.countedItems) {
                            message.countedAtt = reward.countedItems;
                        }
                        if (reward.credits) {
                            message.RegularCredits = reward.credits;
                        }
                        if (info.arg) {
                            const args: Record<string, string | number> = {
                                PLAYER_NAME: account.DisplayName,
                                CREDIT_REWARD: reward.credits ?? 0
                            };

                            for (let j = 0; j < info.arg.length; j++) {
                                const key = info.arg[j];
                                const value = args[key];
                                if (value) {
                                    message.arg ??= [];
                                    message.arg.push({
                                        Key: key,
                                        Tag: value
                                    });
                                }
                            }
                        }
                        messages.push(message);
                    }
                }

                if (messages.length > 0) await createMessage(account._id, messages);

                if (goalProgress) {
                    goalProgress.Best = Math.max(goalProgress.Best!, uploadProgress.Best);
                    goalProgress.Count += uploadProgress.Count;
                }
            }
            if ("ClanGoal" in goal && goal.ClanGoal && inventory.GuildId) {
                const guild = await Guild.findById(inventory.GuildId, "GoalProgress Tier VaultDecoRecipes");
                if (guild) {
                    await handleGuildGoalProgress(guild, {
                        Count: uploadProgress.Count,
                        Tag: goal.Tag,
                        goalId: new Types.ObjectId(goalId)
                    });
                }
            }
        }
    }
};

export const handleOneTimePurchasable = (
    inventory: Pick<TInventoryDatabaseDocument, "OneTimePurchases">,
    typeName: string,
    inventoryChanges: IInventoryChanges = {}
): IInventoryChanges => {
    inventory.OneTimePurchases ??= [];
    if (!inventory.OneTimePurchases.includes(typeName)) {
        inventory.OneTimePurchases.push(typeName);
        inventoryChanges.OneTimePurchases ??= [];
        inventoryChanges.OneTimePurchases.push(typeName);
    } else {
        logger.warn(`${typeName} is already in OneTimePurchases`);
    }
    return inventoryChanges;
};

const goalMessagesByKey: Record<
    string,
    { sndr: string; msg: string; sub: string; icon: string; arg?: string[]; minBuildLabel?: string }
> = {
    "/Lotus/Types/Keys/GalleonRobberyAlert": {
        sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
        msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgA",
        sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleA",
        icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png"
    },
    "/Lotus/Types/Keys/GalleonRobberyAlertB": {
        sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
        msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgB",
        sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleB",
        icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png"
    },
    "/Lotus/Types/Keys/GalleonRobberyAlertC": {
        sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
        msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgC",
        sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleC",
        icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyWaterFightA": {
        sndr: "/Lotus/Language/Bosses/BossKelaDeThaym",
        msg: "/Lotus/Language/Inbox/WaterFightRewardMsgA",
        sub: "/Lotus/Language/Inbox/WaterFightRewardSubjectA",
        icon: "/Lotus/Interface/Icons/Npcs/Grineer/KelaDeThaym.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyWaterFightB": {
        sndr: "/Lotus/Language/Bosses/BossKelaDeThaym",
        msg: "/Lotus/Language/Inbox/WaterFightRewardMsgB",
        sub: "/Lotus/Language/Inbox/WaterFightRewardSubjectB",
        icon: "/Lotus/Interface/Icons/Npcs/Grineer/KelaDeThaym.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyWaterFightC": {
        sndr: "/Lotus/Language/Bosses/BossKelaDeThaym",
        msg: "/Lotus/Language/Inbox/WaterFightRewardMsgC",
        sub: "/Lotus/Language/Inbox/WaterFightRewardSubjectC",
        icon: "/Lotus/Interface/Icons/Npcs/Grineer/KelaDeThaym.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyWaterFightD": {
        sndr: "/Lotus/Language/Bosses/BossKelaDeThaym",
        msg: "/Lotus/Language/Inbox/WaterFightRewardMsgD",
        sub: "/Lotus/Language/Inbox/WaterFightRewardSubjectD",
        icon: "/Lotus/Interface/Icons/Npcs/Grineer/KelaDeThaym.png"
    },
    "/Lotus/Types/Keys/WolfTacAlertA": {
        sndr: "/Lotus/Language/Bosses/NoraNight",
        msg: "/Lotus/Language/Inbox/WolfTacAlertBody",
        sub: "/Lotus/Language/Inbox/WolfTacAlertTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Seasonal/NoraNight.png"
    },
    "/Lotus/Types/Keys/WolfTacAlertxB": {
        sndr: "/Lotus/Language/Bosses/NoraNight",
        msg: "/Lotus/Language/Inbox/WolfTacAlertHardBody",
        sub: "/Lotus/Language/Inbox/WolfTacAlertHardTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Seasonal/NoraNight.png"
    },
    "/Lotus/Types/Keys/WolfTacAlertReduxA": {
        sndr: "/Lotus/Language/Bosses/NoraNight",
        msg: "/Lotus/Language/Inbox/WolfTacAlertBody",
        sub: "/Lotus/Language/Inbox/WolfTacAlertTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Seasonal/NoraNight.png"
    },
    "/Lotus/Types/Keys/WolfTacAlertReduxB": {
        sndr: "/Lotus/Language/Bosses/NoraNight",
        msg: "/Lotus/Language/Inbox/WolfTacAlertBody",
        sub: "/Lotus/Language/Inbox/WolfTacAlertTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Seasonal/NoraNight.png"
    },
    "/Lotus/Types/Keys/WolfTacAlertReduxD": {
        sndr: "/Lotus/Language/Bosses/NoraNight",
        msg: "/Lotus/Language/Inbox/WolfTacAlertBody",
        sub: "/Lotus/Language/Inbox/WolfTacAlertTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Seasonal/NoraNight.png"
    },
    "/Lotus/Types/Keys/WolfTacAlertReduxC": {
        sndr: "/Lotus/Language/Bosses/NoraNight",
        msg: "/Lotus/Language/Inbox/WolfTacAlertBody",
        sub: "/Lotus/Language/Inbox/WolfTacAlertTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Seasonal/NoraNight.png"
    },
    "/Lotus/Types/Keys/LanternEndlessEventKeyA": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/GenericEventRewardMsgDesc",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/LanternEndlessEventKeyB": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/GenericEventRewardMsgDesc",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/LanternEndlessEventKeyD": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/GenericEventRewardMsgDesc",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/LanternEndlessEventKeyC": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/GenericEventRewardMsgDesc",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyHalloween": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/TacAlertHalloweenRewardsBonusBody",
        sub: "/Lotus/Language/G1Quests/TacAlertHalloweenRewardsBonusTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyHalloweenBonus": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/TacAlertHalloweenRewardsBody",
        sub: "/Lotus/Language/G1Quests/TacAlertHalloweenRewardsTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyHalloweenTimeAttack": {
        sndr: "/Lotus/Language/Bosses/Lotus",
        msg: "/Lotus/Language/G1Quests/TacAlertHalloweenRewardsBody",
        sub: "/Lotus/Language/G1Quests/TacAlertHalloweenRewardsTitle",
        icon: "/Lotus/Interface/Icons/Npcs/LotusVamp_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyProxyRebellionOne": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/RazorbackArmadaRewardBody",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertSmallRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["CREDIT_REWARD"]
    },
    "/Lotus/Types/Keys/TacAlertKeyProxyRebellionTwo": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/RazorbackArmadaRewardBody",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertSmallRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["CREDIT_REWARD"]
    },
    "/Lotus/Types/Keys/TacAlertKeyProxyRebellionThree": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/RazorbackArmadaRewardBody",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertSmallRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["CREDIT_REWARD"]
    },
    "/Lotus/Types/Keys/TacAlertKeyProxyRebellionFour": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/GenericTacAlertBadgeRewardMsgDesc",
        sub: "/Lotus/Language/G1Quests/GenericTacAlertBadgeRewardMsgTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyProjectNightwatchEasy": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/ProjectNightwatchRewardMsgA",
        sub: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionOneTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["CREDIT_REWARD"]
    },
    "/Lotus/Types/Keys/TacAlertKeyProjectNightwatch": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionRewardBody",
        sub: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionTwoTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyProjectNightwatchHard": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionRewardBody",
        sub: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionThreeTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyProjectNightwatchBonus": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionRewardBody",
        sub: "/Lotus/Language/G1Quests/ProjectNightwatchTacAlertMissionFourTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png"
    },
    "/Lotus/Types/Keys/MechSurvivalCorpusShip": {
        sndr: "/Lotus/Language/Bosses/DeimosFather",
        msg: "/Lotus/Language/Inbox/MechEvent2020Tier1CompleteDesc",
        sub: "/Lotus/Language/Inbox/MechEvent2020Tier1CompleteTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Entrati/Father.png"
    },
    "/Lotus/Types/Keys/MechSurvivalGrineerGalleon": {
        sndr: "/Lotus/Language/Bosses/DeimosFather",
        msg: "/Lotus/Language/Inbox/MechEvent2020Tier2CompleteDesc",
        sub: "/Lotus/Language/Inbox/MechEvent2020Tier2CompleteTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Entrati/Father.png"
    },
    "/Lotus/Types/Keys/MechSurvivalGasCity": {
        sndr: "/Lotus/Language/Bosses/DeimosFather",
        msg: "/Lotus/Language/Inbox/MechEvent2020Tier3CompleteDesc",
        sub: "/Lotus/Language/Inbox/MechEvent2020Tier3CompleteTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Entrati/Father.png"
    },
    "/Lotus/Types/Keys/MechSurvivalCorpusShipEndurance": {
        sndr: "/Lotus/Language/Bosses/DeimosFather",
        msg: "/Lotus/Language/Inbox/MechEvent2020Tier3CompleteDesc",
        sub: "/Lotus/Language/Inbox/MechEvent2020Tier3CompleteTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Entrati/Father.png"
    },
    "/Lotus/Types/Keys/MechSurvivalGrineerGalleonEndurance": {
        sndr: "/Lotus/Language/Bosses/DeimosFather",
        msg: "/Lotus/Language/Inbox/MechEvent2020Tier3CompleteDesc",
        sub: "/Lotus/Language/Inbox/MechEvent2020Tier3CompleteTitle",
        icon: "/Lotus/Interface/Icons/Npcs/Entrati/Father.png"
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2019E": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgB",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleB",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2020F": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgC",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleB",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2024ChallengeModeA": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgD",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleD",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2017C": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2019RewardMsgC",
        sub: "/Lotus/Language/Messages/Anniversary2019MissionTitleC",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2020H": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2020RewardMsgH",
        sub: "/Lotus/Language/Messages/Anniversary2020MissionTitleH",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2022J": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2022RewardMsgJ",
        sub: "/Lotus/Language/Messages/Anniversary2022MissionTitleJ",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2025D": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2025RewardMsgB",
        sub: "/Lotus/Language/Messages/Anniversary2025MissionTitleB",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2025ChallengeModeA": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2025RewardMsgC",
        sub: "/Lotus/Language/Messages/Anniversary2025MissionTitleC",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2020G": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2020RewardMsgG",
        sub: "/Lotus/Language/Messages/Anniversary2020MissionTitleG",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2017B": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2019RewardMsgB",
        sub: "/Lotus/Language/Messages/Anniversary2019MissionTitleB",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2017A": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2019RewardMsgA",
        sub: "/Lotus/Language/Messages/Anniversary2019MissionTitleA",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2023K": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2025RewardMsgG",
        sub: "/Lotus/Language/Messages/Anniversary2025MissionTitleG",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2025ChallengeModeB": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2025RewardMsgD",
        sub: "/Lotus/Language/Messages/Anniversary2025MissionTitleD",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2025A": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2025RewardMsgA",
        sub: "/Lotus/Language/Messages/Anniversary2025MissionTitleA",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2018D": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgG",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleG",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2025C": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgF",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleF",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2024L": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgA",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleA",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2024ChallengeModeB": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgE",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleE",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2021I": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2024RewardMsgH",
        sub: "/Lotus/Language/Messages/Anniversary2024MissionTitleH",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    },
    "/Lotus/Types/Keys/TacAlertKeyAnniversary2025B": {
        sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
        msg: "/Lotus/Language/Messages/Anniversary2025RewardMsgE",
        sub: "/Lotus/Language/Messages/Anniversary2025MissionTitleE",
        icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
        arg: ["PLAYER_NAME"]
    }
};

const goalMessagesByTag: Record<
    string,
    { sndr: string; msg: string; sub: string; icon: string; arg?: string[]; minBuildLabel?: string }[]
> = {
    HeatFissure: [
        {
            sndr: "/Lotus/Language/Npcs/Eudico",
            msg: "/Lotus/Language/Messages/OrbHeistEventRewardAInboxMessageBody",
            sub: "/Lotus/Language/Messages/OrbHeistEventRewardAInboxMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Eudico.png"
        },
        {
            sndr: "/Lotus/Language/Npcs/Eudico",
            msg: "/Lotus/Language/Messages/OrbHeistEventRewardBInboxMessageBody",
            sub: "/Lotus/Language/Messages/OrbHeistEventRewardBInboxMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Eudico.png"
        },
        {
            sndr: "/Lotus/Language/Npcs/Eudico",
            msg: "/Lotus/Language/Messages/OrbHeistEventRewardCInboxMessageBody",
            sub: "/Lotus/Language/Messages/OrbHeistEventRewardCInboxMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Eudico.png"
        },
        {
            sndr: "/Lotus/Language/Npcs/Eudico",
            msg: "/Lotus/Language/Messages/OrbHeistEventRewardDInboxMessageBody",
            sub: "/Lotus/Language/Messages/OrbHeistEventRewardDInboxMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Eudico.png"
        },
        {
            sndr: "/Lotus/Language/Npcs/Eudico",
            msg: "/Lotus/Language/Messages/OrbHeistEventRewardEInboxMessageBody",
            sub: "/Lotus/Language/Messages/OrbHeistEventRewardEInboxMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Eudico.png"
        }
    ],
    HiveEvent: [
        {
            sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender", // Maybe?
            msg: "/Lotus/Language/G1Quests/HiveEventRewardOneMessageDesc",
            sub: "/Lotus/Language/G1Quests/HiveEventRewardOneMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
            minBuildLabel: gameToBuildVersion["14.0.0"]
        },
        {
            sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender", // Maybe?
            msg: "/Lotus/Language/G1Quests/HiveEventRewardTwoMessageDesc",
            sub: "/Lotus/Language/G1Quests/HiveEventRewardTwoMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
            minBuildLabel: gameToBuildVersion["14.0.0"]
        },
        {
            sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender", // Maybe?
            msg: "/Lotus/Language/G1Quests/HiveEventRewardThreeMessageDesc",
            sub: "/Lotus/Language/G1Quests/HiveEventRewardThreeMessageTitle",
            icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
            arg: ["PLAYER_NAME"],
            minBuildLabel: gameToBuildVersion["14.0.0"]
        }
    ]
};