From 0e1973e246cdb049c4b8e6b2ff20f57db33ba388 Mon Sep 17 00:00:00 2001 From: Sainan Date: Thu, 20 Mar 2025 05:36:09 -0700 Subject: [PATCH] feat: start nemesis (#1227) Closes #446 As discussed there, some support for 64-bit integers without precision loss had to be hacked in. Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1227 --- package-lock.json | 7 + package.json | 1 + src/controllers/api/guildTechController.ts | 2 + .../api/infestedFoundryController.ts | 1 + src/controllers/api/nemesisController.ts | 152 ++++++++++++++++++ src/helpers/stringHelpers.ts | 4 +- src/index.ts | 15 ++ src/models/inventoryModels/inventoryModel.ts | 55 ++++++- src/routes/api.ts | 2 + src/services/rngService.ts | 19 +++ src/types/inventoryTypes/inventoryTypes.ts | 53 +++--- 11 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 src/controllers/api/nemesisController.ts diff --git a/package-lock.json b/package-lock.json index 5461fff07..2fdbe6b69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2ae66715f..3d0ade470 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/controllers/api/guildTechController.ts b/src/controllers/api/guildTechController.ts index fab5cf0b5..e1e79e8f4 100644 --- a/src/controllers/api/guildTechController.ts +++ b/src/controllers/api/guildTechController.ts @@ -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}`); } }; diff --git a/src/controllers/api/infestedFoundryController.ts b/src/controllers/api/infestedFoundryController.ts index f32a445ea..0e3b4ed70 100644 --- a/src/controllers/api/infestedFoundryController.ts +++ b/src/controllers/api/infestedFoundryController.ts @@ -355,6 +355,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)}`); } }; diff --git a/src/controllers/api/nemesisController.ts b/src/controllers/api/nemesisController.ts new file mode 100644 index 000000000..550b2771a --- /dev/null +++ b/src/controllers/api/nemesisController.ts @@ -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(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" +]; diff --git a/src/helpers/stringHelpers.ts b/src/helpers/stringHelpers.ts index 6ab138517..aee4355c9 100644 --- a/src/helpers/stringHelpers.ts +++ b/src/helpers/stringHelpers.ts @@ -1,6 +1,8 @@ +import { JSONParse } from "json-with-bigint"; + export const getJSONfromString = (str: string): T => { const jsonSubstring = str.substring(0, str.lastIndexOf("}") + 1); - return JSON.parse(jsonSubstring) as T; + return JSONParse(jsonSubstring); }; export const getSubstringFromKeyword = (str: string, keyword: string): string => { diff --git a/src/index.ts b/src/index.ts index 8bf614ef6..4f7fc9391 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 "" + this.toString() + ""; + }; + 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(`"`).join(``).split(`"`).join(``); + }; +} + registerLogFileCreationListener(); validateConfig(); diff --git a/src/models/inventoryModels/inventoryModel.ts b/src/models/inventoryModels/inventoryModel.ts index d7588a489..0f087d7e0 100644 --- a/src/models/inventoryModels/inventoryModel.ts +++ b/src/models/inventoryModels/inventoryModel.ts @@ -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( { _id: false } ); +const infNodeSchema = new Schema( + { + Node: String, + Influence: Number + }, + { _id: false } +); + +const nemesisSchema = new Schema( + { + 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( { Alignment: Number, @@ -1341,7 +1392,7 @@ const inventorySchema = new Schema( //CorpusLich or GrineerLich NemesisAbandonedRewards: { type: [String], default: [] }, - //CorpusLich\KuvaLich + Nemesis: nemesisSchema, NemesisHistory: [Schema.Types.Mixed], LastNemesisAllySpawnTime: Schema.Types.Mixed, diff --git a/src/routes/api.ts b/src/routes/api.ts index 530297661..b2ef011d4 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -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"; @@ -204,6 +205,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); diff --git a/src/services/rngService.ts b/src/services/rngService.ts index 3a119e6fc..9791b5c21 100644 --- a/src/services/rngService.ts +++ b/src/services/rngService.ts @@ -70,6 +70,7 @@ export const getRandomWeightedRewardUc = ( 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; + } +} diff --git a/src/types/inventoryTypes/inventoryTypes.ts b/src/types/inventoryTypes/inventoryTypes.ts index 869cd4e7b..397ee1cc5 100644 --- a/src/types/inventoryTypes/inventoryTypes.ts +++ b/src/types/inventoryTypes/inventoryTypes.ts @@ -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 { + 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 { + d: Date; +} + +export interface IInfNode { + Node: string; + Influence: number; } export interface IPendingCouponDatabase {