feat: start nemesis #1227

Merged
Sainan merged 7 commits from nemesis into main 2025-03-20 05:36:10 -07:00
11 changed files with 286 additions and 25 deletions

7
package-lock.json generated
View File

@ -14,6 +14,7 @@
"copyfiles": "^2.4.1",
"crc-32": "^1.2.2",
"express": "^5",
"json-with-bigint": "^3.2.1",
"mongoose": "^8.11.0",
"morgan": "^1.10.0",
"typescript": ">=5.5 <5.6.0",
@ -2346,6 +2347,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-with-bigint": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.2.1.tgz",
"integrity": "sha512-0f8RHpU1AwBFwIPmtm71W+cFxzlXdiBmzc3JqydsNDSKSAsr0Lso6KXRbz0h2LRwTIRiHAk/UaD+xaAN5f577w==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",

View File

@ -19,6 +19,7 @@
"copyfiles": "^2.4.1",
"crc-32": "^1.2.2",
"express": "^5",
"json-with-bigint": "^3.2.1",
"mongoose": "^8.11.0",
"morgan": "^1.10.0",
"typescript": ">=5.5 <5.6.0",

View File

@ -23,6 +23,7 @@ import { config } from "@/src/services/configService";
import { GuildPermission, ITechProjectClient, ITechProjectDatabase } from "@/src/types/guildTypes";
import { TGuildDatabaseDocument } from "@/src/models/guildModel";
import { toMongoDate } from "@/src/helpers/inventoryHelpers";
import { logger } from "@/src/utils/logger";
export const guildTechController: RequestHandler = async (req, res) => {
const accountId = await getAccountIdForRequest(req);
@ -219,6 +220,7 @@ export const guildTechController: RequestHandler = async (req, res) => {
await guild.save();
res.end();
} else {
logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
throw new Error(`unknown guildTech action: ${data.Action}`);
}
};

View File

@ -357,6 +357,7 @@ export const infestedFoundryController: RequestHandler = async (req, res) => {
}
default:
logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
throw new Error(`unhandled infestedFoundry mode: ${String(req.query.mode)}`);
}
};

View File

@ -0,0 +1,152 @@
import { getJSONfromString } from "@/src/helpers/stringHelpers";
import { getInventory } from "@/src/services/inventoryService";
import { getAccountIdForRequest } from "@/src/services/loginService";
import { SRng } from "@/src/services/rngService";
import { IMongoDate } from "@/src/types/commonTypes";
import { IInfNode } from "@/src/types/inventoryTypes/inventoryTypes";
import { logger } from "@/src/utils/logger";
import { RequestHandler } from "express";
import { ExportRegions } from "warframe-public-export-plus";
export const nemesisController: RequestHandler = async (req, res) => {
if ((req.query.mode as string) == "s") {
const accountId = await getAccountIdForRequest(req);
const inventory = await getInventory(accountId, "Nemesis NemesisAbandonedRewards");
const body = getJSONfromString<INemesisStartRequest>(String(req.body));
const infNodes: IInfNode[] = [];
for (const [key, value] of Object.entries(ExportRegions)) {
if (
value.systemIndex == 2 && // earth
value.nodeType != 3 && // not hub
value.nodeType != 7 && // not junction
value.missionIndex && // must have a mission type and not assassination
value.missionIndex != 28 && // not open world
value.missionIndex != 32 && // not railjack
value.missionIndex != 41 && // not saya's visions
value.name.indexOf("Archwing") == -1
) {
//console.log(dict_en[value.name]);
infNodes.push({ Node: key, Influence: 1 });
}
}
let weapons: readonly string[];
if (body.target.manifest == "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionSix") {
weapons = kuvaLichVersionSixWeapons;
} else if (
body.target.manifest == "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifestVersionFour" ||
body.target.manifest == "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifestVersionThree"
) {
weapons = corpusVersionThreeWeapons;
} else {
throw new Error(`unknown nemesis manifest: ${body.target.manifest}`);
}
body.target.fp = BigInt(body.target.fp);
const initialWeaponIdx = new SRng(body.target.fp).randomInt(0, weapons.length - 1);
let weaponIdx = initialWeaponIdx;
do {
const weapon = weapons[weaponIdx];
if (!body.target.DisallowedWeapons.find(x => x == weapon)) {
break;
}
weaponIdx = (weaponIdx + 1) % weapons.length;
} while (weaponIdx != initialWeaponIdx);
inventory.Nemesis = {
fp: body.target.fp,
manifest: body.target.manifest,
KillingSuit: body.target.KillingSuit,
killingDamageType: body.target.killingDamageType,
ShoulderHelmet: body.target.ShoulderHelmet,
WeaponIdx: weaponIdx,
AgentIdx: body.target.AgentIdx,
BirthNode: body.target.BirthNode,
Faction: body.target.Faction,
Rank: 0,
k: false,
Traded: false,
d: new Date(),
InfNodes: infNodes,
GuessHistory: [],
Hints: [],
HintProgress: 0,
Weakened: body.target.Weakened,
PrevOwners: 0,
HenchmenKilled: 0,
SecondInCommand: body.target.SecondInCommand
};
inventory.NemesisAbandonedRewards = []; // unclear if we need to do this since the client also submits this with missionInventoryUpdate
await inventory.save();
res.json({
target: inventory.toJSON().Nemesis
});
} else {
logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
throw new Error(`unknown nemesis mode: ${String(req.query.mode)}`);
}
};
export interface INemesisStartRequest {
target: {
fp: number | bigint;
manifest: string;
KillingSuit: string;
killingDamageType: number;
ShoulderHelmet: string;
DisallowedWeapons: string[];
WeaponIdx: number;
AgentIdx: number;
BirthNode: string;
Faction: string;
Rank: number;
k: boolean;
Traded: boolean;
d: IMongoDate;
InfNodes: [];
GuessHistory: [];
Hints: [];
HintProgress: number;
Weakened: boolean;
PrevOwners: number;
HenchmenKilled: number;
SecondInCommand: boolean;
};
}
const kuvaLichVersionSixWeapons = [
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Drakgoon/KuvaDrakgoon",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Karak/KuvaKarak",
"/Lotus/Weapons/Grineer/Melee/GrnKuvaLichScythe/GrnKuvaLichScytheWeapon",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Kohm/KuvaKohm",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Ogris/KuvaOgris",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Quartakk/KuvaQuartakk",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Tonkor/KuvaTonkor",
"/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Brakk/KuvaBrakk",
"/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Kraken/KuvaKraken",
"/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Seer/KuvaSeer",
"/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Stubba/KuvaStubba",
"/Lotus/Weapons/Grineer/HeavyWeapons/GrnHeavyGrenadeLauncher",
"/Lotus/Weapons/Grineer/LongGuns/GrnKuvaLichRifle/GrnKuvaLichRifleWeapon",
"/Lotus/Weapons/Grineer/Bows/GrnBow/GrnBowWeapon",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Hind/KuvaHind",
"/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Nukor/KuvaNukor",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Hek/KuvaHekWeapon",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Zarr/KuvaZarr",
"/Lotus/Weapons/Grineer/KuvaLich/HeavyWeapons/Grattler/KuvaGrattler",
"/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Sobek/KuvaSobek"
];
const corpusVersionThreeWeapons = [
"/Lotus/Weapons/Corpus/LongGuns/CrpBriefcaseLauncher/CrpBriefcaseLauncher",
"/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBEArcaPlasmor/CrpBEArcaPlasmor",
"/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBEFluxRifle/CrpBEFluxRifle",
"/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBETetra/CrpBETetra",
"/Lotus/Weapons/Corpus/BoardExec/Secondary/CrpBECycron/CrpBECycron",
"/Lotus/Weapons/Corpus/BoardExec/Secondary/CrpBEDetron/CrpBEDetron",
"/Lotus/Weapons/Corpus/Pistols/CrpIgniterPistol/CrpIgniterPistol",
"/Lotus/Weapons/Corpus/Pistols/CrpBriefcaseAkimbo/CrpBriefcaseAkimboPistol",
"/Lotus/Weapons/Corpus/BoardExec/Secondary/CrpBEPlinx/CrpBEPlinxWeapon",
"/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBEGlaxion/CrpBEGlaxion"
];

View File

@ -1,6 +1,8 @@
import { JSONParse } from "json-with-bigint";
export const getJSONfromString = <T>(str: string): T => {
const jsonSubstring = str.substring(0, str.lastIndexOf("}") + 1);
return JSON.parse(jsonSubstring) as T;
return JSONParse<T>(jsonSubstring);
};
export const getSubstringFromKeyword = (str: string, keyword: string): string => {

View File

@ -10,6 +10,21 @@ import { config, validateConfig } from "./services/configService";
import { registerLogFileCreationListener } from "@/src/utils/logger";
import mongoose from "mongoose";
// Patch JSON.stringify to work flawlessly with Bigints. Yeah, it's not pretty.
// TODO: Might wanna use json-with-bigint if/when possible.
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(BigInt.prototype as any).toJSON = function (): string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
return "<BIGINT>" + this.toString() + "</BIGINT>";
Sainan marked this conversation as resolved Outdated

lets keep this out tho, the lack of support there is uncomfortable, but lets keep our code.. noice

lets keep this out tho, the lack of support there is uncomfortable, but lets keep our code.. noice
};
const og_stringify = JSON.stringify;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(JSON as any).stringify = (obj: any): string => {
return og_stringify(obj).split(`"<BIGINT>`).join(``).split(`</BIGINT>"`).join(``);
};
}
registerLogFileCreationListener();
validateConfig();

View File

@ -79,7 +79,10 @@ import {
ICrewShipWeaponDatabase,
IRecentVendorPurchaseDatabase,
IVendorPurchaseHistoryEntryDatabase,
IVendorPurchaseHistoryEntryClient
IVendorPurchaseHistoryEntryClient,
INemesisDatabase,
INemesisClient,
IInfNode
} from "../../types/inventoryTypes/inventoryTypes";
import { IOid } from "../../types/commonTypes";
import {
@ -1058,6 +1061,54 @@ const libraryDailyTaskInfoSchema = new Schema<ILibraryDailyTaskInfo>(
{ _id: false }
);
const infNodeSchema = new Schema<IInfNode>(
{
Node: String,
Influence: Number
},
{ _id: false }
);
const nemesisSchema = new Schema<INemesisDatabase>(
{
fp: BigInt,
manifest: String,
KillingSuit: String,
killingDamageType: Number,
ShoulderHelmet: String,
WeaponIdx: Number,
AgentIdx: Number,
BirthNode: String,
Faction: String,
Rank: Number,
k: Boolean,
Traded: Boolean,
d: Date,
PrevOwners: Number,
SecondInCommand: Boolean,
Weakened: Boolean,
InfNodes: [infNodeSchema],
HenchmenKilled: Number,
HintProgress: Number,
Hints: [Number],
GuessHistory: [Number]
},
{ _id: false }
);
nemesisSchema.set("toJSON", {
virtuals: true,
transform(_doc, obj) {
const db = obj as INemesisDatabase;
const client = obj as INemesisClient;
client.d = toMongoDate(db.d);
delete obj._id;
delete obj.__v;
}
});
const alignmentSchema = new Schema<IAlignment>(
{
Alignment: Number,
@ -1341,7 +1392,7 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
//CorpusLich or GrineerLich
NemesisAbandonedRewards: { type: [String], default: [] },
//CorpusLich\KuvaLich
Nemesis: nemesisSchema,
NemesisHistory: [Schema.Types.Mixed],
LastNemesisAllySpawnTime: Schema.Types.Mixed,

View File

@ -69,6 +69,7 @@ import { missionInventoryUpdateController } from "@/src/controllers/api/missionI
import { modularWeaponCraftingController } from "@/src/controllers/api/modularWeaponCraftingController";
import { modularWeaponSaleController } from "@/src/controllers/api/modularWeaponSaleController";
import { nameWeaponController } from "@/src/controllers/api/nameWeaponController";
import { nemesisController } from "@/src/controllers/api/nemesisController";
import { placeDecoInComponentController } from "@/src/controllers/api/placeDecoInComponentController";
import { playerSkillsController } from "@/src/controllers/api/playerSkillsController";
import { projectionManagerController } from "@/src/controllers/api/projectionManagerController";
@ -203,6 +204,7 @@ apiRouter.post("/missionInventoryUpdate.php", missionInventoryUpdateController);
apiRouter.post("/modularWeaponCrafting.php", modularWeaponCraftingController);
apiRouter.post("/modularWeaponSale.php", modularWeaponSaleController);
apiRouter.post("/nameWeapon.php", nameWeaponController);
apiRouter.post("/nemesis.php", nemesisController);
apiRouter.post("/placeDecoInComponent.php", placeDecoInComponentController);
apiRouter.post("/playerSkills.php", playerSkillsController);
apiRouter.post("/projectionManager.php", projectionManagerController);

View File

@ -70,6 +70,7 @@ export const getRandomWeightedRewardUc = <T extends { Rarity: TRarity }>(
return getRandomReward(resultPool);
};
// Seeded RNG for internal usage. Based on recommendations in the ISO C standards.
export class CRng {
state: number;
@ -92,3 +93,21 @@ export class CRng {
return arr[Math.floor(this.random() * arr.length)];
}
}
// Seeded RNG for cases where we need identical results to the game client. Based on work by Donald Knuth.
export class SRng {
state: bigint;
constructor(seed: bigint) {
this.state = seed;
}
randomInt(min: number, max: number): number {
const diff = max - min;
if (diff != 0) {
this.state = (0x5851f42d4c957f2dn * this.state + 0x14057b7ef767814fn) & 0xffffffffffffffffn;
min += (Number(this.state >> 32n) & 0x3fffffff) % (diff + 1);
}
return min;
}
}

View File

@ -43,6 +43,7 @@ export interface IInventoryDatabase
| "Drones"
| "RecentVendorPurchases"
| "NextRefill"
| "Nemesis"
| TEquipmentKey
>,
InventoryDatabaseEquipment {
@ -71,6 +72,7 @@ export interface IInventoryDatabase
Drones: IDroneDatabase[];
RecentVendorPurchases?: IRecentVendorPurchaseDatabase[];
NextRefill?: Date;
Nemesis?: INemesisDatabase;
}
export interface IQuestKeyDatabase {
@ -288,7 +290,8 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
SeasonChallengeHistory: ISeasonChallenge[];
EquippedInstrument?: string;
InvasionChainProgress: IInvasionChainProgress[];
NemesisHistory: INemesisHistory[];
Nemesis?: INemesisClient;
NemesisHistory: INemesisBaseClient[];
LastNemesisAllySpawnTime?: IMongoDate;
Settings: ISettings;
PersonalTechProjects: IPersonalTechProject[];
@ -782,38 +785,44 @@ export interface IMission extends IMissionDatabase {
RewardsCooldownTime?: IMongoDate;
}
export interface INemesisHistory {
fp: number;
manifest: Manifest;
export interface INemesisBaseClient {
fp: bigint;
manifest: string;
KillingSuit: string;
killingDamageType: number;
ShoulderHelmet: string;
WeaponIdx: number;
AgentIdx: number;
BirthNode: BirthNode;
BirthNode: string;
Faction: string;
Rank: number;
k: boolean;
Traded: boolean;
d: IMongoDate;
GuessHistory?: number[];
currentGuess?: number;
Traded?: boolean;
PrevOwners?: number;
SecondInCommand?: boolean;
Faction?: string;
Weakened?: boolean;
PrevOwners: number;
SecondInCommand: boolean;
Weakened: boolean;
}
export enum BirthNode {
SolNode181 = "SolNode181",
SolNode4 = "SolNode4",
SolNode70 = "SolNode70",
SolNode76 = "SolNode76"
export interface INemesisBaseDatabase extends Omit<INemesisBaseClient, "d"> {
d: Date;
}
export enum Manifest {
LotusTypesEnemiesCorpusLawyersLawyerManifest = "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifest",
LotusTypesGameNemesisKuvaLichKuvaLichManifest = "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifest",
LotusTypesGameNemesisKuvaLichKuvaLichManifestVersionThree = "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionThree",
LotusTypesGameNemesisKuvaLichKuvaLichManifestVersionTwo = "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionTwo"
export interface INemesisClient extends INemesisBaseClient {
InfNodes: IInfNode[];
HenchmenKilled: number;
HintProgress: number;
Hints: number[];
GuessHistory: number[];
}
export interface INemesisDatabase extends Omit<INemesisClient, "d"> {
d: Date;
}
export interface IInfNode {
Node: string;
Influence: number;
}
export interface IPendingCouponDatabase {