import type { RequestHandler } from "express";
import {
addGuildMemberMiscItemContribution,
getGuildForRequestEx,
getGuildVault,
hasAccessToDojo,
hasGuildPermission,
processCompletedGuildTechProject,
processFundedGuildTechProject,
processGuildTechProjectContributionsUpdate,
removePigmentsFromGuildMembers,
scaleRequiredCount,
setGuildTechLogState
} from "../../services/guildService.ts";
import { ExportDojoRecipes, ExportRailjackWeapons } from "warframe-public-export-plus";
import { getAccountForRequest, getBuildLabel } from "../../services/loginService.ts";
import {
addCrewShipWeaponSkin,
addEquipment,
addItem,
addMiscItem,
addMiscItems,
addRecipes,
combineInventoryChanges,
getInventory,
getInventory2,
occupySlot,
updateCredits,
updatePlatinum
} from "../../services/inventoryService.ts";
import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
import { eInventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
import type { ITechProjectClient } from "../../types/guildTypes.ts";
import { eGuildPermission } from "../../types/guildTypes.ts";
import { GuildMember } from "../../models/guildModel.ts";
import { toMongoDate, toMongoDate2, toOid } from "../../helpers/inventoryHelpers.ts";
import { logger } from "../../utils/logger.ts";
import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
import { broadcastGuildUpdate } from "../../services/wsService.ts";
export const guildTechController: RequestHandler = async (req, res) => {
const account = await getAccountForRequest(req);
const accountId = account._id.toString();
const data = JSON.parse(String(req.body)) as TGuildTechRequest;
logger.trace("guildTech request:", data);
if (data.Action == "Sync") {
let needSave = false;
const techProjects: ITechProjectClient[] = [];
const buildLabel = getBuildLabel(req, account);
const inventory = await getInventory2(accountId, "GuildId");
const guild = await getGuildForRequestEx(req, inventory);
if (guild.TechProjects) {
for (const project of guild.TechProjects) {
const techProject: ITechProjectClient = {
ItemType: project.ItemType,
ReqCredits: project.ReqCredits,
ReqItems: project.ReqItems,
State: project.State
};
if (project.CompletionDate) {
techProject.CompletionDate = toMongoDate2(project.CompletionDate, buildLabel);
if (
Date.now() >= project.CompletionDate.getTime() &&
setGuildTechLogState(guild, project.ItemType, 4, project.CompletionDate)
) {
processCompletedGuildTechProject(guild, project.ItemType);
needSave = true;
}
}
techProjects.push(techProject);
}
}
if (needSave) {
await guild.save();
}
res.json({ TechProjects: techProjects });
} else if (data.Action == "Start") {
if (data.Mode != "Personal") {
const inventory = await getInventory2(accountId, "GuildId", "LevelKeys");
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Tech))) {
res.status(400).send("-1").end();
return;
}
const recipeType: string = (data.RecipeType ??
data.ResearchId ??
(req.query.researchId as string | undefined))!;
const recipe = ExportDojoRecipes.research[recipeType];
guild.TechProjects ??= [];
if (!guild.TechProjects.find(x => x.ItemType == recipeType)) {
const techProject =
guild.TechProjects[
guild.TechProjects.push({
ItemType: recipeType,
ReqCredits: guild.noDojoResearchCosts ? 0 : scaleRequiredCount(guild.Tier, recipe.price),
ReqItems: recipe.ingredients.map(x => ({
ItemType: x.ItemType,
ItemCount: guild.noDojoResearchCosts ? 0 : scaleRequiredCount(guild.Tier, x.ItemCount)
})),
State: 0
}) - 1
];
setGuildTechLogState(guild, techProject.ItemType, 5);
if (guild.noDojoResearchCosts) {
processFundedGuildTechProject(guild, techProject, recipe);
} else {
if (recipeType.substring(0, 39) == "/Lotus/Types/Items/Research/DojoColors/") {
guild.ActiveDojoColorResearch = recipeType;
}
}
}
await guild.save();
res.end();
broadcastGuildUpdate(req, guild._id.toString());
} else {
const inventory = await getInventory2(
accountId,
"GuildId",
"LevelKeys",
"CrewShipSalvagedWeapons",
"CrewShipSalvagedWeaponSkins",
"PersonalTechProjects"
);
const recipe = ExportDojoRecipes.research[data.RecipeType!];
if (data.TechProductCategory) {
if (
data.TechProductCategory != "CrewShipWeapons" &&
data.TechProductCategory != "CrewShipWeaponSkins"
) {
throw new Error(`unexpected TechProductCategory: ${data.TechProductCategory}`);
}
if (!inventory[getSalvageCategory(data.TechProductCategory)].id(data.CategoryItemId!)) {
throw new Error(
`no item with id ${data.CategoryItemId} in ${getSalvageCategory(data.TechProductCategory)} array`
);
}
}
const techProject =
inventory.PersonalTechProjects[
inventory.PersonalTechProjects.push({
State: 0,
ReqCredits: recipe.price,
ItemType: data.RecipeType,
ProductCategory: data.TechProductCategory,
CategoryItemId: data.CategoryItemId,
ReqItems: recipe.ingredients
}) - 1
];
await inventory.save();
res.json({
isPersonal: true,
action: "Start",
personalTech: techProject.toJSON()
});
}
} else if (data.Action == "Contribute") {
if (
("guildId" in req.query && (req.query.guildId as string) == "000000000000000000000000") ||
(data.RecipeType && isPersonalResearch(data.RecipeType)) // For U28: https://onlyg.it/OpenWF/SpaceNinjaServer/issues/4312
) {
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"infiniteCredits",
"RegularCredits",
"MiscItems"
);
const techProject = inventory.PersonalTechProjects.id(data.ResearchId!)!;
techProject.ReqCredits -= data.RegularCredits;
const inventoryChanges: IInventoryChanges = updateCredits(inventory, data.RegularCredits);
const miscItemChanges = [];
for (const miscItem of data.MiscItems) {
const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
if (reqItem) {
if (miscItem.ItemCount > reqItem.ItemCount) {
miscItem.ItemCount = reqItem.ItemCount;
}
reqItem.ItemCount -= miscItem.ItemCount;
miscItemChanges.push({
ItemType: miscItem.ItemType,
ItemCount: miscItem.ItemCount * -1
});
}
}
addMiscItems(inventory, miscItemChanges);
inventoryChanges.MiscItems = miscItemChanges;
techProject.HasContributions = true;
if (techProject.ReqCredits == 0 && !techProject.ReqItems.find(x => x.ItemCount > 0)) {
techProject.State = 1;
const recipe = ExportDojoRecipes.research[techProject.ItemType];
techProject.CompletionDate = new Date(Date.now() + recipe.time * 1000);
}
await inventory.save();
res.json({
InventoryChanges: inventoryChanges,
PersonalResearch: { $oid: data.ResearchId },
PersonalResearchDate: techProject.CompletionDate ? toMongoDate(techProject.CompletionDate) : undefined
});
} else {
const inventory = await getInventory2(
accountId,
"LevelKeys",
"GuildId",
"MiscItems",
"infiniteCredits",
"RegularCredits"
);
if (!hasAccessToDojo(inventory)) {
res.status(400).send("-1").end();
return;
}
const guild = await getGuildForRequestEx(req, inventory);
const guildMember = (await GuildMember.findOne(
{ accountId, guildId: guild._id },
"RegularCreditsContributed MiscItemsContributed"
))!;
const recipeType: string = (data.RecipeType ??
data.ResearchId ??
(req.query.researchId as string | undefined))!;
const techProject = guild.TechProjects!.find(x => x.ItemType == recipeType)!;
if (data.VaultCredits) {
if (data.VaultCredits > techProject.ReqCredits) {
data.VaultCredits = techProject.ReqCredits;
}
techProject.ReqCredits -= data.VaultCredits;
guild.VaultRegularCredits! -= data.VaultCredits;
}
if (data.RegularCredits > techProject.ReqCredits) {
data.RegularCredits = techProject.ReqCredits;
}
techProject.ReqCredits -= data.RegularCredits;
guildMember.RegularCreditsContributed ??= 0;
guildMember.RegularCreditsContributed += data.RegularCredits;
if (data.VaultMiscItems?.length) {
for (const miscItem of data.VaultMiscItems) {
const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
if (reqItem) {
if (miscItem.ItemCount > reqItem.ItemCount) {
miscItem.ItemCount = reqItem.ItemCount;
}
reqItem.ItemCount -= miscItem.ItemCount;
const vaultMiscItem = guild.VaultMiscItems!.find(x => x.ItemType == miscItem.ItemType)!;
vaultMiscItem.ItemCount -= miscItem.ItemCount;
}
}
}
const miscItemChanges = [];
for (const miscItem of data.MiscItems) {
const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
if (reqItem) {
if (miscItem.ItemCount > reqItem.ItemCount) {
miscItem.ItemCount = reqItem.ItemCount;
}
reqItem.ItemCount -= miscItem.ItemCount;
miscItemChanges.push({
ItemType: miscItem.ItemType,
ItemCount: miscItem.ItemCount * -1
});
addGuildMemberMiscItemContribution(guildMember, miscItem);
}
}
addMiscItems(inventory, miscItemChanges);
const inventoryChanges: IInventoryChanges = updateCredits(inventory, data.RegularCredits);
inventoryChanges.MiscItems = miscItemChanges;
// Check if research is fully funded now.
await processGuildTechProjectContributionsUpdate(guild, techProject);
await Promise.all([guild.save(), inventory.save(), guildMember.save()]);
const buildLabel = getBuildLabel(req, account);
res.json({
InventoryChanges: inventoryChanges,
Vault: await getGuildVault(guild, buildLabel)
});
broadcastGuildUpdate(req, guild._id.toString());
}
} else if (data.Action.split(",")[0] == "Buy") {
const purchase = data as IGuildTechBuyRequest;
if (purchase.Mode != "Personal") {
const recipeType: string = (purchase.RecipeType ??
purchase.ResearchId ??
(req.query.researchId as string | undefined))!;
const inventory = await getInventory2(
accountId,
"LevelKeys",
"GuildId",
"Recipes",
"infiniteCredits",
"RegularCredits"
);
const guild = await getGuildForRequestEx(req, inventory);
if (
!hasAccessToDojo(inventory) ||
!(await hasGuildPermission(guild, accountId, eGuildPermission.Fabricator))
) {
res.status(400).send("-1").end();
return;
}
const quantity = parseInt(data.Action.split(",")[1]) || 1;
const recipeChanges = [
{
ItemType: recipeType,
ItemCount: quantity
}
];
addRecipes(inventory, recipeChanges);
const currencyChanges = updateCredits(inventory, ExportDojoRecipes.research[recipeType].replicatePrice);
await inventory.save();
// Not a mistake: This response uses `inventoryChanges` instead of `InventoryChanges`.
res.json({
inventoryChanges: {
...currencyChanges,
Recipes: recipeChanges
}
});
} else {
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"CrewShipWeapons",
"CrewShipWeaponSkins",
"CrewShipSalvagedWeapons",
"CrewShipSalvagedWeaponSkins",
"CrewShipSalvageBin"
);
const inventoryChanges = claimSalvagedComponent(inventory, purchase.CategoryItemId!);
await inventory.save();
res.json({
inventoryChanges: inventoryChanges
});
}
} else if (data.Action == "Fabricate") {
const inventory = await getInventory(accountId, undefined);
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Fabricator))) {
res.status(400).send("-1").end();
return;
}
const recipe = ExportDojoRecipes.fabrications[data.RecipeType!];
const inventoryChanges: IInventoryChanges = updateCredits(inventory, recipe.price);
inventoryChanges.MiscItems = recipe.ingredients.map(x => ({
ItemType: x.ItemType,
ItemCount: x.ItemCount * -1
}));
addMiscItems(inventory, inventoryChanges.MiscItems);
combineInventoryChanges(inventoryChanges, await addItem(inventory, recipe.resultType));
await inventory.save();
// Not a mistake: This response uses `inventoryChanges` instead of `InventoryChanges`.
res.json({ inventoryChanges: inventoryChanges });
} else if (data.Action == "Pause") {
const inventory = await getInventory2(accountId, "GuildId", "LevelKeys");
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Tech))) {
res.status(400).send("-1").end();
return;
}
const project = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
project.State = -2;
guild.ActiveDojoColorResearch = "";
await guild.save();
await removePigmentsFromGuildMembers(guild._id);
res.end();
} else if (data.Action == "Unpause") {
const inventory = await getInventory2(accountId, "GuildId", "LevelKeys");
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Tech))) {
res.status(400).send("-1").end();
return;
}
const project = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
project.State = 0;
guild.ActiveDojoColorResearch = data.RecipeType!;
await guild.save();
res.end();
} else if (data.Action == "Cancel" && data.CategoryItemId) {
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"infiniteCredits",
"RegularCredits",
"MiscItems"
);
const personalTechProjectIndex = inventory.PersonalTechProjects.findIndex(x =>
x.CategoryItemId?.equals(data.CategoryItemId)
);
const personalTechProject = inventory.PersonalTechProjects[personalTechProjectIndex];
inventory.PersonalTechProjects.splice(personalTechProjectIndex, 1);
const meta = ExportDojoRecipes.research[personalTechProject.ItemType];
const contributedCredits = meta.price - personalTechProject.ReqCredits;
const inventoryChanges = updateCredits(inventory, contributedCredits * -1);
inventoryChanges.MiscItems = [];
for (const ingredient of meta.ingredients) {
const reqItem = personalTechProject.ReqItems.find(x => x.ItemType == ingredient.ItemType);
if (reqItem) {
const contributedItems = ingredient.ItemCount - reqItem.ItemCount;
inventoryChanges.MiscItems.push({
ItemType: ingredient.ItemType,
ItemCount: contributedItems
});
}
}
addMiscItems(inventory, inventoryChanges.MiscItems);
await inventory.save();
res.json({
action: "Cancel",
isPersonal: true,
inventoryChanges: inventoryChanges,
personalTech: {
ItemId: toOid(personalTechProject._id)
}
});
} else if (data.Action == "Rush" && data.CategoryItemId) {
const inventory = await getInventory2(
accountId,
"infinitePlatinum",
"PremiumCredits",
"PremiumCreditsFree",
"PersonalTechProjects",
"CrewShipSalvageBin",
"CrewShipWeapons",
"CrewShipSalvagedWeapons",
"CrewShipWeaponSkins",
"CrewShipSalvagedWeaponSkins"
);
const inventoryChanges: IInventoryChanges = {
...updatePlatinum(inventory, 20),
...claimSalvagedComponent(inventory, data.CategoryItemId)
};
await inventory.save();
res.json({
inventoryChanges: inventoryChanges
});
} else if (data.Action == "InstantFinish") {
if (data.TechProductCategory != "CrewShipWeapons" && data.TechProductCategory != "CrewShipWeaponSkins") {
throw new Error(`unexpected TechProductCategory: ${data.TechProductCategory}`);
}
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"CrewShipSalvageBin",
"CrewShipWeapons",
"CrewShipSalvagedWeapons",
"CrewShipWeaponSkins",
"CrewShipSalvagedWeaponSkins",
"MiscItems"
);
const inventoryChanges = finishComponentRepair(inventory, data.TechProductCategory, data.CategoryItemId!);
addMiscItem(inventory, "/Lotus/Types/Items/MiscItems/InstantSalvageRepairItem", -1, inventoryChanges);
await inventory.save();
res.json({
inventoryChanges: inventoryChanges
});
} else {
logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
throw new Error(`unhandled guildTech request`);
}
};
type TGuildTechRequest =
| { Action: "Sync" | "SomethingElseThatWeMightNotKnowAbout" }
| IGuildTechBasicRequest
| IGuildTechContributeRequest;
interface IGuildTechBasicRequest {
Action: "Start" | "Fabricate" | "Pause" | "Unpause" | "Cancel" | "Rush" | "InstantFinish";
Mode?: "Guild" | "Personal";
ResearchId?: string; // U18.0.6 uses that instead RecipeType
RecipeType?: string;
TechProductCategory?: string;
CategoryItemId?: string;
}
interface IGuildTechBuyRequest extends Omit<IGuildTechBasicRequest, "Action"> {
Action: string;
}
interface IGuildTechContributeRequest {
Action: "Contribute";
ResearchId?: string;
RecipeType?: string;
RegularCredits: number;
MiscItems: IMiscItem[];
VaultCredits?: number;
VaultMiscItems?: IMiscItem[];
}
const isPersonalResearch = (uniqueName: string): boolean | null => {
if (!(uniqueName in ExportDojoRecipes.research)) {
return null;
}
return !!ExportDojoRecipes.research[uniqueName].resultType;
};
const getSalvageCategory = (
category: "CrewShipWeapons" | "CrewShipWeaponSkins"
): "CrewShipSalvagedWeapons" | "CrewShipSalvagedWeaponSkins" => {
return category == "CrewShipWeapons" ? "CrewShipSalvagedWeapons" : "CrewShipSalvagedWeaponSkins";
};
const claimSalvagedComponent = (
inventory: Pick<
TInventoryDatabaseDocument,
| "PersonalTechProjects"
| "CrewShipWeapons"
| "CrewShipWeaponSkins"
| "CrewShipSalvagedWeapons"
| "CrewShipSalvagedWeaponSkins"
| "CrewShipSalvageBin"
>,
itemId: string
): IInventoryChanges => {
// delete personal tech project
const personalTechProjectIndex = inventory.PersonalTechProjects.findIndex(x => x.CategoryItemId?.equals(itemId));
const personalTechProject = inventory.PersonalTechProjects[personalTechProjectIndex];
inventory.PersonalTechProjects.splice(personalTechProjectIndex, 1);
const category = personalTechProject.ProductCategory! as "CrewShipWeapons" | "CrewShipWeaponSkins";
return finishComponentRepair(inventory, category, itemId);
};
const finishComponentRepair = (
inventory: Pick<
TInventoryDatabaseDocument,
| "CrewShipWeapons"
| "CrewShipWeaponSkins"
| "CrewShipSalvagedWeapons"
| "CrewShipSalvagedWeaponSkins"
| "CrewShipSalvageBin"
>,
category: "CrewShipWeapons" | "CrewShipWeaponSkins",
itemId: string
): IInventoryChanges => {
const salvageCategory = getSalvageCategory(category);
// find salved part & delete it
const salvageIndex = inventory[salvageCategory].findIndex(x => x._id.equals(itemId));
const salvageItem = inventory[salvageCategory][salvageIndex];
inventory[salvageCategory].splice(salvageIndex, 1);
// add final item
const inventoryChanges = {
...(category == "CrewShipWeaponSkins"
? addCrewShipWeaponSkin(inventory, salvageItem.ItemType, salvageItem.UpgradeFingerprint)
: addEquipment(inventory, category, salvageItem.ItemType, {
UpgradeType: ExportRailjackWeapons[salvageItem.ItemType].defaultUpgrades?.[0].ItemType,
UpgradeFingerprint: salvageItem.UpgradeFingerprint
})),
...occupySlot(inventory, eInventorySlot.RJ_COMPONENT_AND_ARMAMENTS, false)
};
inventoryChanges.RemovedIdItems = [
{
ItemId: { $oid: itemId }
}
];
return inventoryChanges;
};
export const selfTestGuildTech = (): boolean => {
let allGood = true;
if (isPersonalResearch("/Lotus/Types/Items/ShipFeatureItems/Railjack/RailjackHullFeatureItemBlueprint") !== true) {
logger.warn(`isPersonalResearch self-test failed for RailjackHullFeatureItemBlueprint`);
allGood = false;
}
if (isPersonalResearch("/Lotus/Types/Recipes/Railjack/Weapons/MissileLauncherEMPTierABlueprint") !== false) {
logger.warn(`isPersonalResearch self-test failed for MissileLauncherEMPTierABlueprint`);
allGood = false;
}
if (isPersonalResearch("I am not a recipe") !== null) {
logger.warn(`isPersonalResearch self-test failed for non-existent recipe`);
allGood = false;
}
return allGood;
};
import type { RequestHandler } from "express";
import {
addGuildMemberMiscItemContribution,
getGuildForRequestEx,
getGuildVault,
hasAccessToDojo,
hasGuildPermission,
processCompletedGuildTechProject,
processFundedGuildTechProject,
processGuildTechProjectContributionsUpdate,
removePigmentsFromGuildMembers,
scaleRequiredCount,
setGuildTechLogState
} from "../../services/guildService.ts";
import { ExportDojoRecipes, ExportRailjackWeapons } from "warframe-public-export-plus";
import { getAccountForRequest, getBuildLabel } from "../../services/loginService.ts";
import {
addCrewShipWeaponSkin,
addEquipment,
addItem,
addMiscItem,
addMiscItems,
addRecipes,
combineInventoryChanges,
getInventory,
getInventory2,
occupySlot,
updateCredits,
updatePlatinum
} from "../../services/inventoryService.ts";
import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
import { eInventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
import type { ITechProjectClient } from "../../types/guildTypes.ts";
import { eGuildPermission } from "../../types/guildTypes.ts";
import { GuildMember } from "../../models/guildModel.ts";
import { toMongoDate, toMongoDate2, toOid } from "../../helpers/inventoryHelpers.ts";
import { logger } from "../../utils/logger.ts";
import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
import { broadcastGuildUpdate } from "../../services/wsService.ts";
export const guildTechController: RequestHandler = async (req, res) => {
const account = await getAccountForRequest(req);
const accountId = account._id.toString();
const data = JSON.parse(String(req.body)) as TGuildTechRequest;
logger.trace("guildTech request:", data);
if (data.Action == "Sync") {
let needSave = false;
const techProjects: ITechProjectClient[] = [];
const buildLabel = getBuildLabel(req, account);
const inventory = await getInventory2(accountId, "GuildId");
const guild = await getGuildForRequestEx(req, inventory);
if (guild.TechProjects) {
for (const project of guild.TechProjects) {
const techProject: ITechProjectClient = {
ItemType: project.ItemType,
ReqCredits: project.ReqCredits,
ReqItems: project.ReqItems,
State: project.State
};
if (project.CompletionDate) {
techProject.CompletionDate = toMongoDate2(project.CompletionDate, buildLabel);
if (
Date.now() >= project.CompletionDate.getTime() &&
setGuildTechLogState(guild, project.ItemType, 4, project.CompletionDate)
) {
processCompletedGuildTechProject(guild, project.ItemType);
needSave = true;
}
}
techProjects.push(techProject);
}
}
if (needSave) {
await guild.save();
}
res.json({ TechProjects: techProjects });
} else if (data.Action == "Start") {
if (data.Mode != "Personal") {
const inventory = await getInventory2(accountId, "GuildId", "LevelKeys");
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Tech))) {
res.status(400).send("-1").end();
return;
}
const recipeType: string = (data.RecipeType ??
data.ResearchId ??
(req.query.researchId as string | undefined))!;
const recipe = ExportDojoRecipes.research[recipeType];
guild.TechProjects ??= [];
if (!guild.TechProjects.find(x => x.ItemType == recipeType)) {
const techProject =
guild.TechProjects[
guild.TechProjects.push({
ItemType: recipeType,
ReqCredits: guild.noDojoResearchCosts ? 0 : scaleRequiredCount(guild.Tier, recipe.price),
ReqItems: recipe.ingredients.map(x => ({
ItemType: x.ItemType,
ItemCount: guild.noDojoResearchCosts ? 0 : scaleRequiredCount(guild.Tier, x.ItemCount)
})),
State: 0
}) - 1
];
setGuildTechLogState(guild, techProject.ItemType, 5);
if (guild.noDojoResearchCosts) {
processFundedGuildTechProject(guild, techProject, recipe);
} else {
if (recipeType.substring(0, 39) == "/Lotus/Types/Items/Research/DojoColors/") {
guild.ActiveDojoColorResearch = recipeType;
}
}
}
await guild.save();
res.end();
broadcastGuildUpdate(req, guild._id.toString());
} else {
const inventory = await getInventory2(
accountId,
"GuildId",
"LevelKeys",
"CrewShipSalvagedWeapons",
"CrewShipSalvagedWeaponSkins",
"PersonalTechProjects"
);
const recipe = ExportDojoRecipes.research[data.RecipeType!];
if (data.TechProductCategory) {
if (
data.TechProductCategory != "CrewShipWeapons" &&
data.TechProductCategory != "CrewShipWeaponSkins"
) {
throw new Error(`unexpected TechProductCategory: ${data.TechProductCategory}`);
}
if (!inventory[getSalvageCategory(data.TechProductCategory)].id(data.CategoryItemId!)) {
throw new Error(
`no item with id ${data.CategoryItemId} in ${getSalvageCategory(data.TechProductCategory)} array`
);
}
}
const techProject =
inventory.PersonalTechProjects[
inventory.PersonalTechProjects.push({
State: 0,
ReqCredits: recipe.price,
ItemType: data.RecipeType,
ProductCategory: data.TechProductCategory,
CategoryItemId: data.CategoryItemId,
ReqItems: recipe.ingredients
}) - 1
];
await inventory.save();
res.json({
isPersonal: true,
action: "Start",
personalTech: techProject.toJSON()
});
}
} else if (data.Action == "Contribute") {
if (
("guildId" in req.query && (req.query.guildId as string) == "000000000000000000000000") ||
(data.RecipeType && isPersonalResearch(data.RecipeType)) // For U28: https://onlyg.it/OpenWF/SpaceNinjaServer/issues/4312
) {
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"infiniteCredits",
"RegularCredits",
"MiscItems"
);
const techProject = inventory.PersonalTechProjects.id(data.ResearchId!)!;
techProject.ReqCredits -= data.RegularCredits;
const inventoryChanges: IInventoryChanges = updateCredits(inventory, data.RegularCredits);
const miscItemChanges = [];
for (const miscItem of data.MiscItems) {
const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
if (reqItem) {
if (miscItem.ItemCount > reqItem.ItemCount) {
miscItem.ItemCount = reqItem.ItemCount;
}
reqItem.ItemCount -= miscItem.ItemCount;
miscItemChanges.push({
ItemType: miscItem.ItemType,
ItemCount: miscItem.ItemCount * -1
});
}
}
addMiscItems(inventory, miscItemChanges);
inventoryChanges.MiscItems = miscItemChanges;
techProject.HasContributions = true;
if (techProject.ReqCredits == 0 && !techProject.ReqItems.find(x => x.ItemCount > 0)) {
techProject.State = 1;
const recipe = ExportDojoRecipes.research[techProject.ItemType];
techProject.CompletionDate = new Date(Date.now() + recipe.time * 1000);
}
await inventory.save();
res.json({
InventoryChanges: inventoryChanges,
PersonalResearch: { $oid: data.ResearchId },
PersonalResearchDate: techProject.CompletionDate ? toMongoDate(techProject.CompletionDate) : undefined
});
} else {
const inventory = await getInventory2(
accountId,
"LevelKeys",
"GuildId",
"MiscItems",
"infiniteCredits",
"RegularCredits"
);
if (!hasAccessToDojo(inventory)) {
res.status(400).send("-1").end();
return;
}
const guild = await getGuildForRequestEx(req, inventory);
const guildMember = (await GuildMember.findOne(
{ accountId, guildId: guild._id },
"RegularCreditsContributed MiscItemsContributed"
))!;
const recipeType: string = (data.RecipeType ??
data.ResearchId ??
(req.query.researchId as string | undefined))!;
const techProject = guild.TechProjects!.find(x => x.ItemType == recipeType)!;
if (data.VaultCredits) {
if (data.VaultCredits > techProject.ReqCredits) {
data.VaultCredits = techProject.ReqCredits;
}
techProject.ReqCredits -= data.VaultCredits;
guild.VaultRegularCredits! -= data.VaultCredits;
}
if (data.RegularCredits > techProject.ReqCredits) {
data.RegularCredits = techProject.ReqCredits;
}
techProject.ReqCredits -= data.RegularCredits;
guildMember.RegularCreditsContributed ??= 0;
guildMember.RegularCreditsContributed += data.RegularCredits;
if (data.VaultMiscItems?.length) {
for (const miscItem of data.VaultMiscItems) {
const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
if (reqItem) {
if (miscItem.ItemCount > reqItem.ItemCount) {
miscItem.ItemCount = reqItem.ItemCount;
}
reqItem.ItemCount -= miscItem.ItemCount;
const vaultMiscItem = guild.VaultMiscItems!.find(x => x.ItemType == miscItem.ItemType)!;
vaultMiscItem.ItemCount -= miscItem.ItemCount;
}
}
}
const miscItemChanges = [];
for (const miscItem of data.MiscItems) {
const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
if (reqItem) {
if (miscItem.ItemCount > reqItem.ItemCount) {
miscItem.ItemCount = reqItem.ItemCount;
}
reqItem.ItemCount -= miscItem.ItemCount;
miscItemChanges.push({
ItemType: miscItem.ItemType,
ItemCount: miscItem.ItemCount * -1
});
addGuildMemberMiscItemContribution(guildMember, miscItem);
}
}
addMiscItems(inventory, miscItemChanges);
const inventoryChanges: IInventoryChanges = updateCredits(inventory, data.RegularCredits);
inventoryChanges.MiscItems = miscItemChanges;
// Check if research is fully funded now.
await processGuildTechProjectContributionsUpdate(guild, techProject);
await Promise.all([guild.save(), inventory.save(), guildMember.save()]);
const buildLabel = getBuildLabel(req, account);
res.json({
InventoryChanges: inventoryChanges,
Vault: await getGuildVault(guild, buildLabel)
});
broadcastGuildUpdate(req, guild._id.toString());
}
} else if (data.Action.split(",")[0] == "Buy") {
const purchase = data as IGuildTechBuyRequest;
if (purchase.Mode != "Personal") {
const recipeType: string = (purchase.RecipeType ??
purchase.ResearchId ??
(req.query.researchId as string | undefined))!;
const inventory = await getInventory2(
accountId,
"LevelKeys",
"GuildId",
"Recipes",
"infiniteCredits",
"RegularCredits"
);
const guild = await getGuildForRequestEx(req, inventory);
if (
!hasAccessToDojo(inventory) ||
!(await hasGuildPermission(guild, accountId, eGuildPermission.Fabricator))
) {
res.status(400).send("-1").end();
return;
}
const quantity = parseInt(data.Action.split(",")[1]) || 1;
const recipeChanges = [
{
ItemType: recipeType,
ItemCount: quantity
}
];
addRecipes(inventory, recipeChanges);
const currencyChanges = updateCredits(inventory, ExportDojoRecipes.research[recipeType].replicatePrice);
await inventory.save();
// Not a mistake: This response uses `inventoryChanges` instead of `InventoryChanges`.
res.json({
inventoryChanges: {
...currencyChanges,
Recipes: recipeChanges
}
});
} else {
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"CrewShipWeapons",
"CrewShipWeaponSkins",
"CrewShipSalvagedWeapons",
"CrewShipSalvagedWeaponSkins",
"CrewShipSalvageBin"
);
const inventoryChanges = claimSalvagedComponent(inventory, purchase.CategoryItemId!);
await inventory.save();
res.json({
inventoryChanges: inventoryChanges
});
}
} else if (data.Action == "Fabricate") {
const inventory = await getInventory(accountId, undefined);
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Fabricator))) {
res.status(400).send("-1").end();
return;
}
const recipe = ExportDojoRecipes.fabrications[data.RecipeType!];
const inventoryChanges: IInventoryChanges = updateCredits(inventory, recipe.price);
inventoryChanges.MiscItems = recipe.ingredients.map(x => ({
ItemType: x.ItemType,
ItemCount: x.ItemCount * -1
}));
addMiscItems(inventory, inventoryChanges.MiscItems);
combineInventoryChanges(inventoryChanges, await addItem(inventory, recipe.resultType));
await inventory.save();
// Not a mistake: This response uses `inventoryChanges` instead of `InventoryChanges`.
res.json({ inventoryChanges: inventoryChanges });
} else if (data.Action == "Pause") {
const inventory = await getInventory2(accountId, "GuildId", "LevelKeys");
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Tech))) {
res.status(400).send("-1").end();
return;
}
const project = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
project.State = -2;
guild.ActiveDojoColorResearch = "";
await guild.save();
await removePigmentsFromGuildMembers(guild._id);
res.end();
} else if (data.Action == "Unpause") {
const inventory = await getInventory2(accountId, "GuildId", "LevelKeys");
const guild = await getGuildForRequestEx(req, inventory);
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, eGuildPermission.Tech))) {
res.status(400).send("-1").end();
return;
}
const project = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
project.State = 0;
guild.ActiveDojoColorResearch = data.RecipeType!;
await guild.save();
res.end();
} else if (data.Action == "Cancel" && data.CategoryItemId) {
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"infiniteCredits",
"RegularCredits",
"MiscItems"
);
const personalTechProjectIndex = inventory.PersonalTechProjects.findIndex(x =>
x.CategoryItemId?.equals(data.CategoryItemId)
);
const personalTechProject = inventory.PersonalTechProjects[personalTechProjectIndex];
inventory.PersonalTechProjects.splice(personalTechProjectIndex, 1);
const meta = ExportDojoRecipes.research[personalTechProject.ItemType];
const contributedCredits = meta.price - personalTechProject.ReqCredits;
const inventoryChanges = updateCredits(inventory, contributedCredits * -1);
inventoryChanges.MiscItems = [];
for (const ingredient of meta.ingredients) {
const reqItem = personalTechProject.ReqItems.find(x => x.ItemType == ingredient.ItemType);
if (reqItem) {
const contributedItems = ingredient.ItemCount - reqItem.ItemCount;
inventoryChanges.MiscItems.push({
ItemType: ingredient.ItemType,
ItemCount: contributedItems
});
}
}
addMiscItems(inventory, inventoryChanges.MiscItems);
await inventory.save();
res.json({
action: "Cancel",
isPersonal: true,
inventoryChanges: inventoryChanges,
personalTech: {
ItemId: toOid(personalTechProject._id)
}
});
} else if (data.Action == "Rush" && data.CategoryItemId) {
const inventory = await getInventory2(
accountId,
"infinitePlatinum",
"PremiumCredits",
"PremiumCreditsFree",
"PersonalTechProjects",
"CrewShipSalvageBin",
"CrewShipWeapons",
"CrewShipSalvagedWeapons",
"CrewShipWeaponSkins",
"CrewShipSalvagedWeaponSkins"
);
const inventoryChanges: IInventoryChanges = {
...updatePlatinum(inventory, 20),
...claimSalvagedComponent(inventory, data.CategoryItemId)
};
await inventory.save();
res.json({
inventoryChanges: inventoryChanges
});
} else if (data.Action == "InstantFinish") {
if (data.TechProductCategory != "CrewShipWeapons" && data.TechProductCategory != "CrewShipWeaponSkins") {
throw new Error(`unexpected TechProductCategory: ${data.TechProductCategory}`);
}
const inventory = await getInventory2(
accountId,
"PersonalTechProjects",
"CrewShipSalvageBin",
"CrewShipWeapons",
"CrewShipSalvagedWeapons",
"CrewShipWeaponSkins",
"CrewShipSalvagedWeaponSkins",
"MiscItems"
);
const inventoryChanges = finishComponentRepair(inventory, data.TechProductCategory, data.CategoryItemId!);
addMiscItem(inventory, "/Lotus/Types/Items/MiscItems/InstantSalvageRepairItem", -1, inventoryChanges);
await inventory.save();
res.json({
inventoryChanges: inventoryChanges
});
} else {
logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
throw new Error(`unhandled guildTech request`);
}
};
type TGuildTechRequest =
| { Action: "Sync" | "SomethingElseThatWeMightNotKnowAbout" }
| IGuildTechBasicRequest
| IGuildTechContributeRequest;
interface IGuildTechBasicRequest {
Action: "Start" | "Fabricate" | "Pause" | "Unpause" | "Cancel" | "Rush" | "InstantFinish";
Mode?: "Guild" | "Personal";
ResearchId?: string; // U18.0.6 uses that instead RecipeType
RecipeType?: string;
TechProductCategory?: string;
CategoryItemId?: string;
}
interface IGuildTechBuyRequest extends Omit<IGuildTechBasicRequest, "Action"> {
Action: string;
}
interface IGuildTechContributeRequest {
Action: "Contribute";
ResearchId?: string;
RecipeType?: string;
RegularCredits: number;
MiscItems: IMiscItem[];
VaultCredits?: number;
VaultMiscItems?: IMiscItem[];
}
const isPersonalResearch = (uniqueName: string): boolean | null => {
if (!(uniqueName in ExportDojoRecipes.research)) {
return null;
}
return !!ExportDojoRecipes.research[uniqueName].resultType;
};
const getSalvageCategory = (
category: "CrewShipWeapons" | "CrewShipWeaponSkins"
): "CrewShipSalvagedWeapons" | "CrewShipSalvagedWeaponSkins" => {
return category == "CrewShipWeapons" ? "CrewShipSalvagedWeapons" : "CrewShipSalvagedWeaponSkins";
};
const claimSalvagedComponent = (
inventory: Pick<
TInventoryDatabaseDocument,
| "PersonalTechProjects"
| "CrewShipWeapons"
| "CrewShipWeaponSkins"
| "CrewShipSalvagedWeapons"
| "CrewShipSalvagedWeaponSkins"
| "CrewShipSalvageBin"
>,
itemId: string
): IInventoryChanges => {
// delete personal tech project
const personalTechProjectIndex = inventory.PersonalTechProjects.findIndex(x => x.CategoryItemId?.equals(itemId));
const personalTechProject = inventory.PersonalTechProjects[personalTechProjectIndex];
inventory.PersonalTechProjects.splice(personalTechProjectIndex, 1);
const category = personalTechProject.ProductCategory! as "CrewShipWeapons" | "CrewShipWeaponSkins";
return finishComponentRepair(inventory, category, itemId);
};
const finishComponentRepair = (
inventory: Pick<
TInventoryDatabaseDocument,
| "CrewShipWeapons"
| "CrewShipWeaponSkins"
| "CrewShipSalvagedWeapons"
| "CrewShipSalvagedWeaponSkins"
| "CrewShipSalvageBin"
>,
category: "CrewShipWeapons" | "CrewShipWeaponSkins",
itemId: string
): IInventoryChanges => {
const salvageCategory = getSalvageCategory(category);
// find salved part & delete it
const salvageIndex = inventory[salvageCategory].findIndex(x => x._id.equals(itemId));
const salvageItem = inventory[salvageCategory][salvageIndex];
inventory[salvageCategory].splice(salvageIndex, 1);
// add final item
const inventoryChanges = {
...(category == "CrewShipWeaponSkins"
? addCrewShipWeaponSkin(inventory, salvageItem.ItemType, salvageItem.UpgradeFingerprint)
: addEquipment(inventory, category, salvageItem.ItemType, {
UpgradeType: ExportRailjackWeapons[salvageItem.ItemType].defaultUpgrades?.[0].ItemType,
UpgradeFingerprint: salvageItem.UpgradeFingerprint
})),
...occupySlot(inventory, eInventorySlot.RJ_COMPONENT_AND_ARMAMENTS, false)
};
inventoryChanges.RemovedIdItems = [
{
ItemId: { $oid: itemId }
}
];
return inventoryChanges;
};
export const selfTestGuildTech = (): boolean => {
let allGood = true;
if (isPersonalResearch("/Lotus/Types/Items/ShipFeatureItems/Railjack/RailjackHullFeatureItemBlueprint") !== true) {
logger.warn(`isPersonalResearch self-test failed for RailjackHullFeatureItemBlueprint`);
allGood = false;
}
if (isPersonalResearch("/Lotus/Types/Recipes/Railjack/Weapons/MissileLauncherEMPTierABlueprint") !== false) {
logger.warn(`isPersonalResearch self-test failed for MissileLauncherEMPTierABlueprint`);
allGood = false;
}
if (isPersonalResearch("I am not a recipe") !== null) {
logger.warn(`isPersonalResearch self-test failed for non-existent recipe`);
allGood = false;
}
return allGood;
};