From 9def5c265e4ed1ddf356e512eb597b9a0c515381 Mon Sep 17 00:00:00 2001 From: Sainan <63328889+Sainan@users.noreply.github.com> Date: Sat, 7 Jun 2025 16:45:50 -0700 Subject: [PATCH] feat: kubrow & kavat incubation (#2131) Closes #377 Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/2131 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com> --- src/controllers/api/adoptPetController.ts | 27 ++++++++++++ .../api/claimCompletedRecipeController.ts | 23 ++++++++-- src/controllers/api/startRecipeController.ts | 43 ++++++++++++------- src/models/inventoryModels/inventoryModel.ts | 4 +- src/routes/api.ts | 2 + src/services/inventoryService.ts | 18 +++++--- src/types/inventoryTypes/inventoryTypes.ts | 9 +++- 7 files changed, 98 insertions(+), 28 deletions(-) create mode 100644 src/controllers/api/adoptPetController.ts diff --git a/src/controllers/api/adoptPetController.ts b/src/controllers/api/adoptPetController.ts new file mode 100644 index 00000000..3cd340da --- /dev/null +++ b/src/controllers/api/adoptPetController.ts @@ -0,0 +1,27 @@ +import { getJSONfromString } from "@/src/helpers/stringHelpers"; +import { getInventory } from "@/src/services/inventoryService"; +import { getAccountIdForRequest } from "@/src/services/loginService"; +import { RequestHandler } from "express"; + +export const adoptPetController: RequestHandler = async (req, res) => { + const accountId = await getAccountIdForRequest(req); + const inventory = await getInventory(accountId, "KubrowPets"); + const data = getJSONfromString(String(req.body)); + const details = inventory.KubrowPets.id(data.petId)!.Details!; + details.Name = data.name; + await inventory.save(); + res.json({ + petId: data.petId, + newName: data.name + } satisfies IAdoptPetResponse); +}; + +interface IAdoptPetRequest { + petId: string; + name: string; +} + +interface IAdoptPetResponse { + petId: string; + newName: string; +} diff --git a/src/controllers/api/claimCompletedRecipeController.ts b/src/controllers/api/claimCompletedRecipeController.ts index e519d170..6482c9a3 100644 --- a/src/controllers/api/claimCompletedRecipeController.ts +++ b/src/controllers/api/claimCompletedRecipeController.ts @@ -17,7 +17,7 @@ import { } from "@/src/services/inventoryService"; import { IInventoryChanges } from "@/src/types/purchaseTypes"; import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes"; -import { InventorySlot, IPendingRecipeDatabase } from "@/src/types/inventoryTypes/inventoryTypes"; +import { InventorySlot, IPendingRecipeDatabase, Status } from "@/src/types/inventoryTypes/inventoryTypes"; import { toOid2 } from "@/src/helpers/inventoryHelpers"; import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel"; import { IRecipe } from "warframe-public-export-plus"; @@ -105,7 +105,21 @@ export const claimCompletedRecipeController: RequestHandler = async (req, res) = ...updateCurrency(inventory, cost, true) }; } - if (recipe.secretIngredientAction != "SIA_UNBRAND") { + + 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.StatusIncubating; + } else if (recipe.secretIngredientAction != "SIA_UNBRAND") { InventoryChanges = { ...InventoryChanges, ...(await addItem( @@ -118,7 +132,10 @@ export const claimCompletedRecipeController: RequestHandler = async (req, res) = )) }; } - if (config.claimingBlueprintRefundsIngredients) { + if ( + config.claimingBlueprintRefundsIngredients && + recipe.secretIngredientAction != "SIA_CREATE_KUBROW" // Can't refund the egg + ) { await refundRecipeIngredients(inventory, InventoryChanges, recipe, pendingRecipe); } await inventory.save(); diff --git a/src/controllers/api/startRecipeController.ts b/src/controllers/api/startRecipeController.ts index 78adf1fc..8f68fc28 100644 --- a/src/controllers/api/startRecipeController.ts +++ b/src/controllers/api/startRecipeController.ts @@ -3,12 +3,14 @@ import { getJSONfromString } from "@/src/helpers/stringHelpers"; import { logger } from "@/src/utils/logger"; import { RequestHandler } from "express"; import { getRecipe } from "@/src/services/itemDataService"; -import { addItem, freeUpSlot, getInventory, updateCurrency } from "@/src/services/inventoryService"; +import { addItem, addKubrowPet, freeUpSlot, getInventory, updateCurrency } from "@/src/services/inventoryService"; import { unixTimesInMs } from "@/src/constants/timeConstants"; import { Types } from "mongoose"; import { InventorySlot, ISpectreLoadout } from "@/src/types/inventoryTypes/inventoryTypes"; -import { toOid } from "@/src/helpers/inventoryHelpers"; +import { fromOid, toOid } from "@/src/helpers/inventoryHelpers"; import { ExportWeapons } from "warframe-public-export-plus"; +import { getRandomElement } from "@/src/services/rngService"; +import { IInventoryChanges } from "@/src/types/purchaseTypes"; interface IStartRecipeRequest { RecipeName: string; @@ -42,24 +44,35 @@ export const startRecipeController: RequestHandler = async (req, res) => { for (let i = 0; i != recipe.ingredients.length; ++i) { if (startRecipeRequest.Ids[i] && startRecipeRequest.Ids[i][0] != "/") { - const category = ExportWeapons[recipe.ingredients[i].ItemType].productCategory; - if (category != "LongGuns" && category != "Pistols" && category != "Melee") { - throw new Error(`unexpected equipment ingredient type: ${category}`); + if (recipe.ingredients[i].ItemType == "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem") { + const index = inventory.KubrowPetEggs!.findIndex(x => x._id.equals(startRecipeRequest.Ids[i])); + if (index != -1) { + inventory.KubrowPetEggs!.splice(index, 1); + } + } else { + const category = ExportWeapons[recipe.ingredients[i].ItemType].productCategory; + if (category != "LongGuns" && category != "Pistols" && category != "Melee") { + throw new Error(`unexpected equipment ingredient type: ${category}`); + } + const equipmentIndex = inventory[category].findIndex(x => x._id.equals(startRecipeRequest.Ids[i])); + if (equipmentIndex == -1) { + throw new Error(`could not find equipment item to use for recipe`); + } + pr[category] ??= []; + pr[category].push(inventory[category][equipmentIndex]); + inventory[category].splice(equipmentIndex, 1); + freeUpSlot(inventory, InventorySlot.WEAPONS); } - const equipmentIndex = inventory[category].findIndex(x => x._id.equals(startRecipeRequest.Ids[i])); - if (equipmentIndex == -1) { - throw new Error(`could not find equipment item to use for recipe`); - } - pr[category] ??= []; - pr[category].push(inventory[category][equipmentIndex]); - inventory[category].splice(equipmentIndex, 1); - freeUpSlot(inventory, InventorySlot.WEAPONS); } else { await addItem(inventory, recipe.ingredients[i].ItemType, recipe.ingredients[i].ItemCount * -1); } } - if (recipe.secretIngredientAction == "SIA_SPECTRE_LOADOUT_COPY") { + let inventoryChanges: IInventoryChanges | undefined; + if (recipe.secretIngredientAction == "SIA_CREATE_KUBROW") { + inventoryChanges = addKubrowPet(inventory, getRandomElement(recipe.secretIngredients!)!.ItemType); + pr.KubrowPet = new Types.ObjectId(fromOid(inventoryChanges.KubrowPets![0].ItemId)); + } else if (recipe.secretIngredientAction == "SIA_SPECTRE_LOADOUT_COPY") { const spectreLoadout: ISpectreLoadout = { ItemType: recipe.resultType, Suits: "", @@ -116,5 +129,5 @@ export const startRecipeController: RequestHandler = async (req, res) => { await inventory.save(); - res.json({ RecipeId: toOid(pr._id) }); + res.json({ RecipeId: toOid(pr._id), InventoryChanges: inventoryChanges }); }; diff --git a/src/models/inventoryModels/inventoryModel.ts b/src/models/inventoryModels/inventoryModel.ts index 27caf691..2e4008dd 100644 --- a/src/models/inventoryModels/inventoryModel.ts +++ b/src/models/inventoryModels/inventoryModel.ts @@ -1097,7 +1097,8 @@ const pendingRecipeSchema = new Schema( LongGuns: { type: [EquipmentSchema], default: undefined }, Pistols: { type: [EquipmentSchema], default: undefined }, Melee: { type: [EquipmentSchema], default: undefined }, - SuitToUnbrand: { type: Schema.Types.ObjectId, default: undefined } + SuitToUnbrand: { type: Schema.Types.ObjectId, default: undefined }, + KubrowPet: { type: Schema.Types.ObjectId, default: undefined } }, { id: false } ); @@ -1115,6 +1116,7 @@ pendingRecipeSchema.set("toJSON", { delete returnedObject.Pistols; delete returnedObject.Melees; delete returnedObject.SuitToUnbrand; + delete returnedObject.KubrowPet; (returnedObject as IPendingRecipeClient).CompletionDate = { $date: { $numberLong: (returnedObject as IPendingRecipeDatabase).CompletionDate.getTime().toString() } }; diff --git a/src/routes/api.ts b/src/routes/api.ts index 7db66dfc..a12efbd0 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,6 +9,7 @@ import { addIgnoredUserController } from "@/src/controllers/api/addIgnoredUserCo import { addPendingFriendController } from "@/src/controllers/api/addPendingFriendController"; import { addToAllianceController } from "@/src/controllers/api/addToAllianceController"; import { addToGuildController } from "@/src/controllers/api/addToGuildController"; +import { adoptPetController } from "@/src/controllers/api/adoptPetController"; import { arcaneCommonController } from "@/src/controllers/api/arcaneCommonController"; import { archonFusionController } from "@/src/controllers/api/archonFusionController"; import { artifactsController } from "@/src/controllers/api/artifactsController"; @@ -226,6 +227,7 @@ apiRouter.post("/addIgnoredUser.php", addIgnoredUserController); apiRouter.post("/addPendingFriend.php", addPendingFriendController); apiRouter.post("/addToAlliance.php", addToAllianceController); apiRouter.post("/addToGuild.php", addToGuildController); +apiRouter.post("/adoptPet.php", adoptPetController); apiRouter.post("/arcaneCommon.php", arcaneCommonController); apiRouter.post("/archonFusion.php", archonFusionController); apiRouter.post("/artifacts.php", artifactsController); diff --git a/src/services/inventoryService.ts b/src/services/inventoryService.ts index 28832737..6060f1e9 100644 --- a/src/services/inventoryService.ts +++ b/src/services/inventoryService.ts @@ -86,6 +86,7 @@ import { getMaxStanding } from "@/src/helpers/syndicateStandingHelper"; import { getNightwaveSyndicateTag, getWorldState } from "./worldStateService"; import { generateNemesisProfile, INemesisProfile } from "../helpers/nemesisHelpers"; import { TAccountDocument } from "./loginService"; +import { unixTimesInMs } from "../constants/timeConstants"; export const createInventory = async ( accountOwnerId: Types.ObjectId, @@ -780,7 +781,9 @@ export const addItem = async ( typeName.substr(1).split("/")[3] == "CatbrowPet" || typeName.substr(1).split("/")[3] == "KubrowPet" ) { - return addKubrowPet(inventory, typeName, undefined, premiumPurchase); + if (typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem") { + return addKubrowPet(inventory, typeName, undefined, premiumPurchase); + } } else if (typeName.startsWith("/Lotus/Types/Game/CrewShip/CrewMember/")) { if (!seed) { throw new Error(`Expected crew member to have a seed`); @@ -1025,12 +1028,13 @@ export const addSpaceSuit = ( export const addKubrowPet = ( inventory: TInventoryDatabaseDocument, kubrowPetName: string, - details: IKubrowPetDetailsDatabase | undefined, - premiumPurchase: boolean, + details?: IKubrowPetDetailsDatabase, + premiumPurchase: boolean = false, inventoryChanges: IInventoryChanges = {} ): IInventoryChanges => { combineInventoryChanges(inventoryChanges, occupySlot(inventory, InventorySlot.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) { @@ -1079,11 +1083,11 @@ export const addKubrowPet = ( details = { Name: "", - IsPuppy: false, + IsPuppy: !premiumPurchase, HasCollar: true, - PrintsRemaining: 2, - Status: Status.StatusStasis, - HatchDate: new Date(Math.trunc(Date.now() / 86400000) * 86400000), + PrintsRemaining: 3, + Status: premiumPurchase ? Status.StatusStasis : Status.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. IsMale: !!getRandomInt(0, 1), Size: getRandomInt(70, 100) / 100, DominantTraits: traits, diff --git a/src/types/inventoryTypes/inventoryTypes.ts b/src/types/inventoryTypes/inventoryTypes.ts index 32cac658..997b50f4 100644 --- a/src/types/inventoryTypes/inventoryTypes.ts +++ b/src/types/inventoryTypes/inventoryTypes.ts @@ -765,7 +765,8 @@ export interface IKubrowPetDetailsClient extends Omit { + extends Omit< + IPendingRecipeDatabase, + "CompletionDate" | "LongGuns" | "Pistols" | "Melee" | "SuitToUnbrand" | "KubrowPet" + > { CompletionDate: IMongoDate; }