From 30398021b3fcc1e3a13192a14819d6c9b259d071 Mon Sep 17 00:00:00 2001 From: Sainan <63328889+Sainan@users.noreply.github.com> Date: Wed, 27 Aug 2025 01:29:46 -0700 Subject: [PATCH] feat: claim all recipes (#2700) Closes #2699 tried to make it a cleaner diff, but this is the best I could do Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/2700 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com> --- .../api/claimCompletedRecipeController.ts | 449 +++++++++--------- 1 file changed, 232 insertions(+), 217 deletions(-) diff --git a/src/controllers/api/claimCompletedRecipeController.ts b/src/controllers/api/claimCompletedRecipeController.ts index 7deb7b09..39782101 100644 --- a/src/controllers/api/claimCompletedRecipeController.ts +++ b/src/controllers/api/claimCompletedRecipeController.ts @@ -4,8 +4,9 @@ import type { RequestHandler } from "express"; import { logger } from "../../utils/logger.ts"; import { getRecipe } from "../../services/itemDataService.ts"; -import type { IOid, IOidWithLegacySupport } from "../../types/commonTypes.ts"; +import type { IOidWithLegacySupport } from "../../types/commonTypes.ts"; import { getJSONfromString } from "../../helpers/stringHelpers.ts"; +import type { TAccountDocument } from "../../services/loginService.ts"; import { getAccountForRequest } from "../../services/loginService.ts"; import { getInventory, @@ -21,240 +22,254 @@ import { import type { IInventoryChanges } from "../../types/purchaseTypes.ts"; import type { IPendingRecipeDatabase } from "../../types/inventoryTypes/inventoryTypes.ts"; import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts"; -import { toOid2 } from "../../helpers/inventoryHelpers.ts"; +import { fromOid, toOid2 } from "../../helpers/inventoryHelpers.ts"; import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts"; import type { IRecipe } from "warframe-public-export-plus"; import type { IEquipmentClient } from "../../types/equipmentTypes.ts"; import { EquipmentFeatures, Status } from "../../types/equipmentTypes.ts"; interface IClaimCompletedRecipeRequest { - RecipeIds: IOid[]; + RecipeIds: IOidWithLegacySupport[]; +} + +interface IClaimCompletedRecipeResponse { + InventoryChanges: IInventoryChanges; + BrandedSuits?: IOidWithLegacySupport[]; } export const claimCompletedRecipeController: RequestHandler = async (req, res) => { const claimCompletedRecipeRequest = getJSONfromString(String(req.body)); const account = await getAccountForRequest(req); const inventory = await getInventory(account._id.toString()); - const pendingRecipe = inventory.PendingRecipes.id(claimCompletedRecipeRequest.RecipeIds[0].$oid); - if (!pendingRecipe) { - throw new Error(`no pending recipe found with id ${claimCompletedRecipeRequest.RecipeIds[0].$oid}`); - } - - //check recipe is indeed ready to be completed - // if (pendingRecipe.CompletionDate > new Date()) { - // throw new Error(`recipe ${pendingRecipe._id} is not ready to be completed`); - // } - - inventory.PendingRecipes.pull(pendingRecipe._id); - - const recipe = getRecipe(pendingRecipe.ItemType); - if (!recipe) { - throw new Error(`no completed item found for recipe ${pendingRecipe._id.toString()}`); - } - - if (req.query.cancel) { - const inventoryChanges: IInventoryChanges = {}; - await refundRecipeIngredients(inventory, inventoryChanges, recipe, pendingRecipe); - await inventory.save(); - res.json(inventoryChanges); // Not a bug: In the specific case of cancelling a recipe, InventoryChanges are expected to be the root. - } else { - logger.debug("Claiming Recipe", { recipe, pendingRecipe }); - - let BrandedSuits: undefined | IOidWithLegacySupport[]; - if (recipe.secretIngredientAction == "SIA_SPECTRE_LOADOUT_COPY") { - inventory.PendingSpectreLoadouts ??= []; - inventory.SpectreLoadouts ??= []; - - const pendingLoadoutIndex = inventory.PendingSpectreLoadouts.findIndex( - x => x.ItemType == recipe.resultType - ); - if (pendingLoadoutIndex != -1) { - const loadoutIndex = inventory.SpectreLoadouts.findIndex(x => x.ItemType == recipe.resultType); - if (loadoutIndex != -1) { - inventory.SpectreLoadouts.splice(loadoutIndex, 1); - } - logger.debug( - "moving spectre loadout from pending to active", - inventory.toJSON().PendingSpectreLoadouts![pendingLoadoutIndex] - ); - inventory.SpectreLoadouts.push(inventory.PendingSpectreLoadouts[pendingLoadoutIndex]); - inventory.PendingSpectreLoadouts.splice(pendingLoadoutIndex, 1); - } - } else if (recipe.secretIngredientAction == "SIA_UNBRAND") { - inventory.BrandedSuits!.splice( - inventory.BrandedSuits!.findIndex(x => x.equals(pendingRecipe.SuitToUnbrand)), - 1 - ); - BrandedSuits = [toOid2(pendingRecipe.SuitToUnbrand!, account.BuildLabel)]; + const resp: IClaimCompletedRecipeResponse = { + InventoryChanges: {} + }; + for (const recipeId of claimCompletedRecipeRequest.RecipeIds) { + const pendingRecipe = inventory.PendingRecipes.id(fromOid(recipeId)); + if (!pendingRecipe) { + throw new Error(`no pending recipe found with id ${fromOid(recipeId)}`); } - let InventoryChanges: IInventoryChanges = {}; - if (recipe.consumeOnUse) { - addRecipes(inventory, [ + //check recipe is indeed ready to be completed + // if (pendingRecipe.CompletionDate > new Date()) { + // throw new Error(`recipe ${pendingRecipe._id} is not ready to be completed`); + // } + + inventory.PendingRecipes.pull(pendingRecipe._id); + + const recipe = getRecipe(pendingRecipe.ItemType); + if (!recipe) { + throw new Error(`no completed item found for recipe ${pendingRecipe._id.toString()}`); + } + + if (req.query.cancel) { + const inventoryChanges: IInventoryChanges = {}; + await refundRecipeIngredients(inventory, inventoryChanges, recipe, pendingRecipe); + await inventory.save(); + res.json(inventoryChanges); // Not a bug: In the specific case of cancelling a recipe, InventoryChanges are expected to be the root. + return; + } + + await claimCompletedRecipe(account, inventory, recipe, pendingRecipe, resp, req.query.rush); + } + await inventory.save(); + res.json(resp); +}; + +const claimCompletedRecipe = async ( + account: TAccountDocument, + inventory: TInventoryDatabaseDocument, + recipe: IRecipe, + pendingRecipe: IPendingRecipeDatabase, + resp: IClaimCompletedRecipeResponse, + rush: any +): Promise => { + logger.debug("Claiming Recipe", { recipe, pendingRecipe }); + + if (recipe.secretIngredientAction == "SIA_SPECTRE_LOADOUT_COPY") { + inventory.PendingSpectreLoadouts ??= []; + inventory.SpectreLoadouts ??= []; + + const pendingLoadoutIndex = inventory.PendingSpectreLoadouts.findIndex(x => x.ItemType == recipe.resultType); + if (pendingLoadoutIndex != -1) { + const loadoutIndex = inventory.SpectreLoadouts.findIndex(x => x.ItemType == recipe.resultType); + if (loadoutIndex != -1) { + inventory.SpectreLoadouts.splice(loadoutIndex, 1); + } + logger.debug( + "moving spectre loadout from pending to active", + inventory.toJSON().PendingSpectreLoadouts![pendingLoadoutIndex] + ); + inventory.SpectreLoadouts.push(inventory.PendingSpectreLoadouts[pendingLoadoutIndex]); + inventory.PendingSpectreLoadouts.splice(pendingLoadoutIndex, 1); + } + } else if (recipe.secretIngredientAction == "SIA_UNBRAND") { + inventory.BrandedSuits!.splice( + inventory.BrandedSuits!.findIndex(x => x.equals(pendingRecipe.SuitToUnbrand)), + 1 + ); + resp.BrandedSuits = [toOid2(pendingRecipe.SuitToUnbrand!, account.BuildLabel)]; + } + + if (recipe.consumeOnUse) { + addRecipes(inventory, [ + { + ItemType: pendingRecipe.ItemType, + ItemCount: -1 + } + ]); + } + + if (rush) { + const end = Math.trunc(pendingRecipe.CompletionDate.getTime() / 1000); + const start = end - recipe.buildTime; + const secondsElapsed = Math.trunc(Date.now() / 1000) - start; + const progress = secondsElapsed / recipe.buildTime; + logger.debug(`rushing recipe at ${Math.trunc(progress * 100)}% completion`); + const cost = + progress > 0.5 ? Math.round(recipe.skipBuildTimePrice * (1 - (progress - 0.5))) : recipe.skipBuildTimePrice; + combineInventoryChanges(resp.InventoryChanges, updateCurrency(inventory, cost, true)); + } + + if (recipe.secretIngredientAction == "SIA_CREATE_KUBROW") { + const pet = inventory.KubrowPets.id(pendingRecipe.KubrowPet!)!; + if (pet.Details!.HatchDate!.getTime() > Date.now()) { + pet.Details!.HatchDate = new Date(); + } + let canSetActive = true; + for (const pet of inventory.KubrowPets) { + if (pet.Details!.Status == Status.StatusAvailable) { + canSetActive = false; + break; + } + } + pet.Details!.Status = canSetActive ? Status.StatusAvailable : Status.StatusStasis; + } else if (recipe.secretIngredientAction == "SIA_DISTILL_PRINT") { + const pet = inventory.KubrowPets.id(pendingRecipe.KubrowPet!)!; + addKubrowPetPrint(inventory, pet, resp.InventoryChanges); + } else if (recipe.secretIngredientAction != "SIA_UNBRAND") { + if (recipe.resultType == "/Lotus/Powersuits/Excalibur/ExcaliburUmbra") { + // Quite the special case here... + // We don't just get Umbra, but also Skiajati and Umbra Mods. Both items are max rank, potatoed, and with the mods are pre-installed. + // Source: https://wiki.warframe.com/w/The_Sacrifice, https://wiki.warframe.com/w/Excalibur/Umbra, https://wiki.warframe.com/w/Skiajati + + const umbraModA = ( + await addItem( + inventory, + "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModA", + 1, + false, + undefined, + `{"lvl":5}` + ) + ).Upgrades![0]; + const umbraModB = ( + await addItem( + inventory, + "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModB", + 1, + false, + undefined, + `{"lvl":5}` + ) + ).Upgrades![0]; + const umbraModC = ( + await addItem( + inventory, + "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModC", + 1, + false, + undefined, + `{"lvl":5}` + ) + ).Upgrades![0]; + const sacrificeModA = ( + await addItem( + inventory, + "/Lotus/Upgrades/Mods/Sets/Sacrifice/MeleeSacrificeModA", + 1, + false, + undefined, + `{"lvl":5}` + ) + ).Upgrades![0]; + const sacrificeModB = ( + await addItem( + inventory, + "/Lotus/Upgrades/Mods/Sets/Sacrifice/MeleeSacrificeModB", + 1, + false, + undefined, + `{"lvl":5}` + ) + ).Upgrades![0]; + resp.InventoryChanges.Upgrades ??= []; + resp.InventoryChanges.Upgrades.push(umbraModA, umbraModB, umbraModC, sacrificeModA, sacrificeModB); + + await addPowerSuit( + inventory, + "/Lotus/Powersuits/Excalibur/ExcaliburUmbra", { - ItemType: pendingRecipe.ItemType, - ItemCount: -1 - } - ]); - } - if (req.query.rush) { - const end = Math.trunc(pendingRecipe.CompletionDate.getTime() / 1000); - const start = end - recipe.buildTime; - const secondsElapsed = Math.trunc(Date.now() / 1000) - start; - const progress = secondsElapsed / recipe.buildTime; - logger.debug(`rushing recipe at ${Math.trunc(progress * 100)}% completion`); - const cost = - progress > 0.5 - ? Math.round(recipe.skipBuildTimePrice * (1 - (progress - 0.5))) - : recipe.skipBuildTimePrice; - InventoryChanges = { - ...InventoryChanges, - ...updateCurrency(inventory, cost, true) - }; - } + Configs: [ + { + Upgrades: [ + "", + "", + "", + "", + "", + umbraModA.ItemId.$oid, + umbraModB.ItemId.$oid, + umbraModC.ItemId.$oid + ] + } + ], + XP: 900_000, + Features: EquipmentFeatures.DOUBLE_CAPACITY + }, + resp.InventoryChanges + ); + inventory.XPInfo.push({ + ItemType: "/Lotus/Powersuits/Excalibur/ExcaliburUmbra", + XP: 900_000 + }); - if (recipe.secretIngredientAction == "SIA_CREATE_KUBROW") { - const pet = inventory.KubrowPets.id(pendingRecipe.KubrowPet!)!; - if (pet.Details!.HatchDate!.getTime() > Date.now()) { - pet.Details!.HatchDate = new Date(); - } - let canSetActive = true; - for (const pet of inventory.KubrowPets) { - if (pet.Details!.Status == Status.StatusAvailable) { - canSetActive = false; - break; - } - } - pet.Details!.Status = canSetActive ? Status.StatusAvailable : Status.StatusStasis; - } else if (recipe.secretIngredientAction == "SIA_DISTILL_PRINT") { - const pet = inventory.KubrowPets.id(pendingRecipe.KubrowPet!)!; - addKubrowPetPrint(inventory, pet, InventoryChanges); - } else if (recipe.secretIngredientAction != "SIA_UNBRAND") { - if (recipe.resultType == "/Lotus/Powersuits/Excalibur/ExcaliburUmbra") { - // Quite the special case here... - // We don't just get Umbra, but also Skiajati and Umbra Mods. Both items are max rank, potatoed, and with the mods are pre-installed. - // Source: https://wiki.warframe.com/w/The_Sacrifice, https://wiki.warframe.com/w/Excalibur/Umbra, https://wiki.warframe.com/w/Skiajati - - const umbraModA = ( - await addItem( - inventory, - "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModA", - 1, - false, - undefined, - `{"lvl":5}` - ) - ).Upgrades![0]; - const umbraModB = ( - await addItem( - inventory, - "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModB", - 1, - false, - undefined, - `{"lvl":5}` - ) - ).Upgrades![0]; - const umbraModC = ( - await addItem( - inventory, - "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModC", - 1, - false, - undefined, - `{"lvl":5}` - ) - ).Upgrades![0]; - const sacrificeModA = ( - await addItem( - inventory, - "/Lotus/Upgrades/Mods/Sets/Sacrifice/MeleeSacrificeModA", - 1, - false, - undefined, - `{"lvl":5}` - ) - ).Upgrades![0]; - const sacrificeModB = ( - await addItem( - inventory, - "/Lotus/Upgrades/Mods/Sets/Sacrifice/MeleeSacrificeModB", - 1, - false, - undefined, - `{"lvl":5}` - ) - ).Upgrades![0]; - InventoryChanges.Upgrades ??= []; - InventoryChanges.Upgrades.push(umbraModA, umbraModB, umbraModC, sacrificeModA, sacrificeModB); - - await addPowerSuit( + addEquipment( + inventory, + "Melee", + "/Lotus/Weapons/Tenno/Melee/Swords/UmbraKatana/UmbraKatana", + { + Configs: [ + { Upgrades: ["", "", "", "", "", "", sacrificeModA.ItemId.$oid, sacrificeModB.ItemId.$oid] } + ], + XP: 450_000, + Features: EquipmentFeatures.DOUBLE_CAPACITY + }, + resp.InventoryChanges + ); + inventory.XPInfo.push({ + ItemType: "/Lotus/Weapons/Tenno/Melee/Swords/UmbraKatana/UmbraKatana", + XP: 450_000 + }); + } else { + combineInventoryChanges( + resp.InventoryChanges, + await addItem( inventory, - "/Lotus/Powersuits/Excalibur/ExcaliburUmbra", - { - Configs: [ - { - Upgrades: [ - "", - "", - "", - "", - "", - umbraModA.ItemId.$oid, - umbraModB.ItemId.$oid, - umbraModC.ItemId.$oid - ] - } - ], - XP: 900_000, - Features: EquipmentFeatures.DOUBLE_CAPACITY - }, - InventoryChanges - ); - inventory.XPInfo.push({ - ItemType: "/Lotus/Powersuits/Excalibur/ExcaliburUmbra", - XP: 900_000 - }); - - addEquipment( - inventory, - "Melee", - "/Lotus/Weapons/Tenno/Melee/Swords/UmbraKatana/UmbraKatana", - { - Configs: [ - { Upgrades: ["", "", "", "", "", "", sacrificeModA.ItemId.$oid, sacrificeModB.ItemId.$oid] } - ], - XP: 450_000, - Features: EquipmentFeatures.DOUBLE_CAPACITY - }, - InventoryChanges - ); - inventory.XPInfo.push({ - ItemType: "/Lotus/Weapons/Tenno/Melee/Swords/UmbraKatana/UmbraKatana", - XP: 450_000 - }); - } else { - InventoryChanges = { - ...InventoryChanges, - ...(await addItem( - inventory, - recipe.resultType, - recipe.num, - false, - undefined, - pendingRecipe.TargetFingerprint - )) - }; - } + recipe.resultType, + recipe.num, + false, + undefined, + pendingRecipe.TargetFingerprint + ) + ); } - if ( - inventory.claimingBlueprintRefundsIngredients && - recipe.secretIngredientAction != "SIA_CREATE_KUBROW" // Can't refund the egg - ) { - await refundRecipeIngredients(inventory, InventoryChanges, recipe, pendingRecipe); - } - await inventory.save(); - res.json({ InventoryChanges, BrandedSuits }); + } + if ( + inventory.claimingBlueprintRefundsIngredients && + recipe.secretIngredientAction != "SIA_CREATE_KUBROW" // Can't refund the egg + ) { + await refundRecipeIngredients(inventory, resp.InventoryChanges, recipe, pendingRecipe); } };