import { Request } from "express"; import { getAccountIdForRequest } from "@/src/services/loginService"; import { addRecipes, getInventory } from "@/src/services/inventoryService"; import { Guild, GuildAd, GuildMember, TGuildDatabaseDocument } from "@/src/models/guildModel"; import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel"; import { GuildPermission, IDojoClient, IDojoComponentClient, IDojoComponentDatabase, IDojoContributable, IDojoDecoClient, IGuildClient, IGuildMemberClient, IGuildMemberDatabase, IGuildVault, ITechProjectDatabase } from "@/src/types/guildTypes"; import { toMongoDate, toOid } from "@/src/helpers/inventoryHelpers"; import { Types } from "mongoose"; import { ExportDojoRecipes, IDojoBuild, IDojoResearch } from "warframe-public-export-plus"; import { logger } from "../utils/logger"; import { config } from "./configService"; import { Account } from "../models/loginModel"; import { getRandomInt } from "./rngService"; import { Inbox } from "../models/inboxModel"; import { ITypeCount } from "../types/inventoryTypes/inventoryTypes"; export const getGuildForRequest = async (req: Request): Promise => { const accountId = await getAccountIdForRequest(req); const inventory = await getInventory(accountId); return await getGuildForRequestEx(req, inventory); }; export const getGuildForRequestEx = async ( req: Request, inventory: TInventoryDatabaseDocument ): Promise => { const guildId = req.query.guildId as string; if (!inventory.GuildId || inventory.GuildId.toString() != guildId) { throw new Error("Account is not in the guild that it has sent a request for"); } const guild = await Guild.findById(guildId); if (!guild) { throw new Error("Account thinks it is in a guild that doesn't exist"); } return guild; }; export const getGuildClient = async (guild: TGuildDatabaseDocument, accountId: string): Promise => { const guildMembers = await GuildMember.find({ guildId: guild._id }); const members: IGuildMemberClient[] = []; let missingEntry = true; for (const guildMember of guildMembers) { const member: IGuildMemberClient = { _id: toOid(guildMember.accountId), Rank: guildMember.rank, Status: guildMember.status }; if (guildMember.accountId.equals(accountId)) { missingEntry = false; } else { member.DisplayName = (await Account.findById(guildMember.accountId, "DisplayName"))!.DisplayName; await fillInInventoryDataForGuildMember(member); } members.push(member); } if (missingEntry) { // Handle clans created prior to creation of the GuildMember model. await GuildMember.insertOne({ accountId: accountId, guildId: guild._id, status: 0, rank: 0 }); members.push({ _id: { $oid: accountId }, Status: 0, Rank: 0 }); } return { _id: toOid(guild._id), Name: guild.Name, MOTD: guild.MOTD, LongMOTD: guild.LongMOTD, Members: members, Ranks: guild.Ranks, TradeTax: guild.TradeTax, Tier: guild.Tier, Vault: getGuildVault(guild), ActiveDojoColorResearch: guild.ActiveDojoColorResearch, Class: guild.Class, XP: guild.XP, IsContributor: !!guild.CeremonyContributors?.find(x => x.equals(accountId)), NumContributors: guild.CeremonyContributors?.length ?? 0, CeremonyResetDate: guild.CeremonyResetDate ? toMongoDate(guild.CeremonyResetDate) : undefined }; }; export const getGuildVault = (guild: TGuildDatabaseDocument): IGuildVault => { return { DojoRefundRegularCredits: guild.VaultRegularCredits, DojoRefundMiscItems: guild.VaultMiscItems, DojoRefundPremiumCredits: guild.VaultPremiumCredits, ShipDecorations: guild.VaultShipDecorations, FusionTreasures: guild.VaultFusionTreasures }; }; export const getDojoClient = async ( guild: TGuildDatabaseDocument, status: number, componentId: Types.ObjectId | string | undefined = undefined ): Promise => { const dojo: IDojoClient = { _id: { $oid: guild._id.toString() }, Name: guild.Name, Tier: 1, FixedContributions: true, DojoRevision: 1, Vault: getGuildVault(guild), RevisionTime: Math.round(Date.now() / 1000), Energy: guild.DojoEnergy, Capacity: guild.DojoCapacity, DojoRequestStatus: status, DojoComponents: [] }; const roomsToRemove: Types.ObjectId[] = []; let needSave = false; for (const dojoComponent of guild.DojoComponents) { if (!componentId || dojoComponent._id.equals(componentId)) { const clientComponent: IDojoComponentClient = { id: toOid(dojoComponent._id), pf: dojoComponent.pf, ppf: dojoComponent.ppf, Name: dojoComponent.Name, Message: dojoComponent.Message, DecoCapacity: dojoComponent.DecoCapacity ?? 600 }; if (dojoComponent.pi) { clientComponent.pi = toOid(dojoComponent.pi); clientComponent.op = dojoComponent.op!; clientComponent.pp = dojoComponent.pp!; } if (dojoComponent.CompletionTime) { clientComponent.CompletionTime = toMongoDate(dojoComponent.CompletionTime); if (dojoComponent.CompletionLogPending && Date.now() >= dojoComponent.CompletionTime.getTime()) { const entry = guild.RoomChanges?.find(x => x.componentId.equals(dojoComponent._id)); if (entry) { dojoComponent.CompletionLogPending = undefined; entry.entryType = 1; needSave = true; } let newTier: number | undefined; switch (dojoComponent.pf) { case "/Lotus/Levels/ClanDojo/ClanDojoBarracksShadow.level": newTier = 2; break; case "/Lotus/Levels/ClanDojo/ClanDojoBarracksStorm.level": newTier = 3; break; case "/Lotus/Levels/ClanDojo/ClanDojoBarracksMountain.level": newTier = 4; break; case "/Lotus/Levels/ClanDojo/ClanDojoBarracksMoon.level": newTier = 5; break; } if (newTier) { logger.debug(`clan finished building barracks, updating to tier ${newTier}`); await setGuildTier(guild, newTier); needSave = true; } } if (dojoComponent.DestructionTime) { if (Date.now() >= dojoComponent.DestructionTime.getTime()) { roomsToRemove.push(dojoComponent._id); continue; } clientComponent.DestructionTime = toMongoDate(dojoComponent.DestructionTime); } } else { clientComponent.RegularCredits = dojoComponent.RegularCredits; clientComponent.MiscItems = dojoComponent.MiscItems; } if (dojoComponent.Decos) { clientComponent.Decos = []; for (const deco of dojoComponent.Decos) { const clientDeco: IDojoDecoClient = { id: toOid(deco._id), Type: deco.Type, Pos: deco.Pos, Rot: deco.Rot, Name: deco.Name }; if (deco.CompletionTime) { clientDeco.CompletionTime = toMongoDate(deco.CompletionTime); } else { clientDeco.RegularCredits = deco.RegularCredits; clientDeco.MiscItems = deco.MiscItems; } clientComponent.Decos.push(clientDeco); } } dojo.DojoComponents.push(clientComponent); } } if (roomsToRemove.length) { logger.debug(`removing now-destroyed rooms`, roomsToRemove); for (const id of roomsToRemove) { await removeDojoRoom(guild, id); } needSave = true; } if (needSave) { await guild.save(); } dojo.Tier = guild.Tier; return dojo; }; const guildTierScalingFactors = [0.01, 0.03, 0.1, 0.3, 1]; export const scaleRequiredCount = (tier: number, count: number): number => { return Math.max(1, Math.trunc(count * guildTierScalingFactors[tier - 1])); }; export const removeDojoRoom = async ( guild: TGuildDatabaseDocument, componentId: Types.ObjectId | string ): Promise => { const component = guild.DojoComponents.splice( guild.DojoComponents.findIndex(x => x._id.equals(componentId)), 1 )[0]; const meta = Object.values(ExportDojoRecipes.rooms).find(x => x.resultType == component.pf); if (meta) { guild.DojoCapacity -= meta.capacity; guild.DojoEnergy -= meta.energy; } moveResourcesToVault(guild, component); component.Decos?.forEach(deco => moveResourcesToVault(guild, deco)); if (guild.RoomChanges) { const index = guild.RoomChanges.findIndex(x => x.componentId.equals(component._id)); if (index != -1) { guild.RoomChanges.splice(index, 1); } } switch (component.pf) { case "/Lotus/Levels/ClanDojo/ClanDojoBarracksShadow.level": await setGuildTier(guild, 1); break; case "/Lotus/Levels/ClanDojo/ClanDojoBarracksStorm.level": await setGuildTier(guild, 2); break; case "/Lotus/Levels/ClanDojo/ClanDojoBarracksMountain.level": await setGuildTier(guild, 3); break; case "/Lotus/Levels/ClanDojo/ClanDojoBarracksMoon.level": await setGuildTier(guild, 4); break; } }; export const removeDojoDeco = ( guild: TGuildDatabaseDocument, componentId: Types.ObjectId | string, decoId: Types.ObjectId | string ): void => { const component = guild.DojoComponents.id(componentId)!; const deco = component.Decos!.splice( component.Decos!.findIndex(x => x._id.equals(decoId)), 1 )[0]; const meta = Object.values(ExportDojoRecipes.decos).find(x => x.resultType == deco.Type); if (meta && meta.capacityCost) { component.DecoCapacity! += meta.capacityCost; } moveResourcesToVault(guild, deco); }; const moveResourcesToVault = (guild: TGuildDatabaseDocument, component: IDojoContributable): void => { if (component.RegularCredits) { guild.VaultRegularCredits ??= 0; guild.VaultRegularCredits += component.RegularCredits; } if (component.MiscItems) { addVaultMiscItems(guild, component.MiscItems); } if (component.RushPlatinum) { guild.VaultPremiumCredits ??= 0; guild.VaultPremiumCredits += component.RushPlatinum; } }; export const getVaultMiscItemCount = (guild: TGuildDatabaseDocument, itemType: string): number => { return guild.VaultMiscItems?.find(x => x.ItemType == itemType)?.ItemCount ?? 0; }; export const addVaultMiscItems = (guild: TGuildDatabaseDocument, miscItems: ITypeCount[]): void => { guild.VaultMiscItems ??= []; for (const miscItem of miscItems) { const vaultMiscItem = guild.VaultMiscItems.find(x => x.ItemType == miscItem.ItemType); if (vaultMiscItem) { vaultMiscItem.ItemCount += miscItem.ItemCount; } else { guild.VaultMiscItems.push(miscItem); } } }; export const addGuildMemberMiscItemContribution = (guildMember: IGuildMemberDatabase, item: ITypeCount): void => { guildMember.MiscItemsContributed ??= []; const miscItemContribution = guildMember.MiscItemsContributed.find(x => x.ItemType == item.ItemType); if (miscItemContribution) { miscItemContribution.ItemCount += item.ItemCount; } else { guildMember.MiscItemsContributed.push(item); } }; export const processDojoBuildMaterialsGathered = (guild: TGuildDatabaseDocument, build: IDojoBuild): void => { if (build.guildXpValue) { guild.ClaimedXP ??= []; if (!guild.ClaimedXP.find(x => x == build.resultType)) { guild.ClaimedXP.push(build.resultType); guild.XP += build.guildXpValue; } } }; // guild.save(); is expected some time after this function is called export const setDojoRoomLogFunded = (guild: TGuildDatabaseDocument, component: IDojoComponentDatabase): void => { const entry = guild.RoomChanges?.find(x => x.componentId.equals(component._id)); if (entry && entry.entryType == 2) { entry.entryType = 0; entry.dateTime = component.CompletionTime!; component.CompletionLogPending = true; } }; export const fillInInventoryDataForGuildMember = async (member: IGuildMemberClient): Promise => { const inventory = await getInventory(member._id.$oid, "PlayerLevel ActiveAvatarImageType"); member.PlayerLevel = config.spoofMasteryRank == -1 ? inventory.PlayerLevel : config.spoofMasteryRank; member.ActiveAvatarImageType = inventory.ActiveAvatarImageType; }; export const updateInventoryForConfirmedGuildJoin = async ( accountId: string, guildId: Types.ObjectId ): Promise => { const inventory = await getInventory(accountId, "GuildId Recipes"); // Set GuildId inventory.GuildId = guildId; // Give clan key blueprint addRecipes(inventory, [ { ItemType: "/Lotus/Types/Keys/DojoKeyBlueprint", ItemCount: 1 } ]); await inventory.save(); }; export const createUniqueClanName = async (name: string): Promise => { const initialDiscriminator = getRandomInt(0, 999); let discriminator = initialDiscriminator; do { const fullName = name + "#" + discriminator.toString().padStart(3, "0"); if (!(await Guild.exists({ Name: fullName }))) { return fullName; } discriminator = (discriminator + 1) % 1000; } while (discriminator != initialDiscriminator); throw new Error(`clan name is so unoriginal it's already been done 1000 times: ${name}`); }; export const hasAccessToDojo = (inventory: TInventoryDatabaseDocument): boolean => { return inventory.LevelKeys.find(x => x.ItemType == "/Lotus/Types/Keys/DojoKey") !== undefined; }; export const hasGuildPermission = async ( guild: TGuildDatabaseDocument, accountId: string | Types.ObjectId, perm: GuildPermission ): Promise => { const member = await GuildMember.findOne({ accountId: accountId, guildId: guild._id }); if (member) { return hasGuildPermissionEx(guild, member, perm); } return false; }; export const hasGuildPermissionEx = ( guild: TGuildDatabaseDocument, member: IGuildMemberDatabase, perm: GuildPermission ): boolean => { const rank = guild.Ranks[member.rank]; return (rank.Permissions & perm) != 0; }; export const removePigmentsFromGuildMembers = async (guildId: string | Types.ObjectId): Promise => { const members = await GuildMember.find({ guildId, status: 0 }, "accountId"); for (const member of members) { const inventory = await getInventory(member.accountId.toString(), "MiscItems"); const index = inventory.MiscItems.findIndex( x => x.ItemType == "/Lotus/Types/Items/Research/DojoColors/GenericDojoColorPigment" ); if (index != -1) { inventory.MiscItems.splice(index, 1); await inventory.save(); } } }; export const processGuildTechProjectContributionsUpdate = async ( guild: TGuildDatabaseDocument, techProject: ITechProjectDatabase ): Promise => { if (techProject.ReqCredits == 0 && !techProject.ReqItems.find(x => x.ItemCount > 0)) { // This research is now fully funded. if ( techProject.State == 0 && techProject.ItemType.substring(0, 39) == "/Lotus/Types/Items/Research/DojoColors/" ) { guild.ActiveDojoColorResearch = ""; await removePigmentsFromGuildMembers(guild._id); } const recipe = ExportDojoRecipes.research[techProject.ItemType]; processFundedGuildTechProject(guild, techProject, recipe); } }; export const processFundedGuildTechProject = ( guild: TGuildDatabaseDocument, techProject: ITechProjectDatabase, recipe: IDojoResearch ): void => { techProject.State = 1; techProject.CompletionDate = new Date(Date.now() + (config.noDojoResearchTime ? 0 : recipe.time) * 1000); if (recipe.guildXpValue) { guild.XP += recipe.guildXpValue; } setGuildTechLogState(guild, techProject.ItemType, config.noDojoResearchTime ? 4 : 3, techProject.CompletionDate); }; export const setGuildTechLogState = ( guild: TGuildDatabaseDocument, type: string, state: number, dateTime: Date | undefined = undefined ): boolean => { guild.TechChanges ??= []; const entry = guild.TechChanges.find(x => x.details == type); if (entry) { if (entry.entryType == state) { return false; } entry.dateTime = dateTime; entry.entryType = state; } else { guild.TechChanges.push({ dateTime: dateTime, entryType: state, details: type }); } return true; }; const setGuildTier = async (guild: TGuildDatabaseDocument, newTier: number): Promise => { const oldTier = guild.Tier; guild.Tier = newTier; if (guild.TechProjects) { for (const project of guild.TechProjects) { if (project.State == 1) { continue; } const meta = ExportDojoRecipes.research[project.ItemType]; { const numContributed = scaleRequiredCount(oldTier, meta.price) - project.ReqCredits; project.ReqCredits = scaleRequiredCount(newTier, meta.price) - numContributed; if (project.ReqCredits < 0) { guild.VaultRegularCredits ??= 0; guild.VaultRegularCredits += project.ReqCredits * -1; project.ReqCredits = 0; } } for (let i = 0; i != project.ReqItems.length; ++i) { const numContributed = scaleRequiredCount(oldTier, meta.ingredients[i].ItemCount) - project.ReqItems[i].ItemCount; project.ReqItems[i].ItemCount = scaleRequiredCount(newTier, meta.ingredients[i].ItemCount) - numContributed; if (project.ReqItems[i].ItemCount < 0) { project.ReqItems[i].ItemCount *= -1; addVaultMiscItems(guild, [project.ReqItems[i]]); project.ReqItems[i].ItemCount = 0; } } // Check if research is fully funded now due to lowered requirements. await processGuildTechProjectContributionsUpdate(guild, project); } } }; export const deleteGuild = async (guildId: Types.ObjectId): Promise => { await Guild.deleteOne({ _id: guildId }); await GuildMember.deleteMany({ guildId }); // If guild sent any invites, delete those inbox messages as well. await Inbox.deleteMany({ contextInfo: guildId.toString(), acceptAction: "GUILD_INVITE" }); await GuildAd.deleteOne({ GuildId: guildId }); };