From 3fc2dccf81da82c1e44c07c469c798518afcca31 Mon Sep 17 00:00:00 2001 From: Sainan <63328889+Sainan@users.noreply.github.com> Date: Fri, 9 May 2025 21:36:22 -0700 Subject: [PATCH] chore: use 64-bit RNG everywhere (#2030) Closes #2026 Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/2030 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com> --- .../api/modularWeaponSaleController.ts | 4 +- src/services/loginRewardService.ts | 12 ++--- src/services/rngService.ts | 48 ++----------------- src/services/serversideVendorsService.ts | 11 ++--- src/services/worldStateService.ts | 32 ++++++------- 5 files changed, 32 insertions(+), 75 deletions(-) diff --git a/src/controllers/api/modularWeaponSaleController.ts b/src/controllers/api/modularWeaponSaleController.ts index 3e37547b2..767d1a947 100644 --- a/src/controllers/api/modularWeaponSaleController.ts +++ b/src/controllers/api/modularWeaponSaleController.ts @@ -2,7 +2,7 @@ import { RequestHandler } from "express"; import { ExportWeapons } from "warframe-public-export-plus"; import { IMongoDate } from "@/src/types/commonTypes"; import { toMongoDate } from "@/src/helpers/inventoryHelpers"; -import { CRng } from "@/src/services/rngService"; +import { SRng } from "@/src/services/rngService"; import { ArtifactPolarity, EquipmentFeatures } from "@/src/types/inventoryTypes/commonInventoryTypes"; import { getJSONfromString } from "@/src/helpers/stringHelpers"; import { @@ -140,7 +140,7 @@ const getModularWeaponSale = ( partTypes: string[], getItemType: (parts: string[]) => string ): IModularWeaponSaleInfo => { - const rng = new CRng(day); + const rng = new SRng(day); const parts = partTypes.map(partType => rng.randomElement(partTypeToParts[partType])!); let partsCost = 0; for (const part of parts) { diff --git a/src/services/loginRewardService.ts b/src/services/loginRewardService.ts index 1734247af..1f94807b1 100644 --- a/src/services/loginRewardService.ts +++ b/src/services/loginRewardService.ts @@ -1,7 +1,7 @@ import randomRewards from "@/static/fixed_responses/loginRewards/randomRewards.json"; import { IInventoryChanges } from "../types/purchaseTypes"; import { TAccountDocument } from "./loginService"; -import { CRng, mixSeeds } from "./rngService"; +import { mixSeeds, SRng } from "./rngService"; import { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel"; import { addBooster, updateCurrency } from "./inventoryService"; import { handleStoreItemAcquisition } from "./purchaseService"; @@ -49,8 +49,8 @@ const scaleAmount = (day: number, amount: number, scalingMultiplier: number): nu // Always produces the same result for the same account _id & LoginDays pair. export const isLoginRewardAChoice = (account: TAccountDocument): boolean => { const accountSeed = parseInt(account._id.toString().substring(16), 16); - const rng = new CRng(mixSeeds(accountSeed, account.LoginDays)); - return rng.random() < 0.25; // Using 25% as an approximate chance for pick-a-doors. More conclusive data analysis is needed. + const rng = new SRng(mixSeeds(accountSeed, account.LoginDays)); + return rng.randomFloat() < 0.25; }; // Always produces the same result for the same account _id & LoginDays pair. @@ -59,8 +59,8 @@ export const getRandomLoginRewards = ( inventory: TInventoryDatabaseDocument ): ILoginReward[] => { const accountSeed = parseInt(account._id.toString().substring(16), 16); - const rng = new CRng(mixSeeds(accountSeed, account.LoginDays)); - const pick_a_door = rng.random() < 0.25; // Using 25% as an approximate chance for pick-a-doors. More conclusive data analysis is needed. + const rng = new SRng(mixSeeds(accountSeed, account.LoginDays)); + const pick_a_door = rng.randomFloat() < 0.25; const rewards = [getRandomLoginReward(rng, account.LoginDays, inventory)]; if (pick_a_door) { do { @@ -73,7 +73,7 @@ export const getRandomLoginRewards = ( return rewards; }; -const getRandomLoginReward = (rng: CRng, day: number, inventory: TInventoryDatabaseDocument): ILoginReward => { +const getRandomLoginReward = (rng: SRng, day: number, inventory: TInventoryDatabaseDocument): ILoginReward => { const reward = rng.randomReward(randomRewards)!; //const reward = randomRewards.find(x => x.RewardType == "RT_BOOSTER")!; if (reward.RewardType == "RT_RANDOM_RECIPE") { diff --git a/src/services/rngService.ts b/src/services/rngService.ts index 0e8364668..597ae01bf 100644 --- a/src/services/rngService.ts +++ b/src/services/rngService.ts @@ -86,54 +86,12 @@ export const mixSeeds = (seed1: number, seed2: number): number => { return seed >>> 0; }; -// Seeded RNG for internal usage. Based on recommendations in the ISO C standards. -export class CRng { - state: number; - - constructor(seed: number = 1) { - this.state = seed; - } - - random(): number { - this.state = (this.state * 1103515245 + 12345) & 0x7fffffff; - return (this.state & 0x3fffffff) / 0x3fffffff; - } - - randomInt(min: number, max: number): number { - const diff = max - min; - if (diff != 0) { - if (diff < 0) { - throw new Error(`max must be greater than min`); - } - if (diff > 0x3fffffff) { - throw new Error(`insufficient entropy`); - } - min += Math.floor(this.random() * (diff + 1)); - } - return min; - } - - randomElement(arr: readonly T[]): T | undefined { - return arr[Math.floor(this.random() * arr.length)]; - } - - randomReward(pool: T[]): T | undefined { - return getRewardAtPercentage(pool, this.random()); - } - - churnSeed(its: number): void { - while (its--) { - this.state = (this.state * 1103515245 + 12345) & 0x7fffffff; - } - } -} - -// Seeded RNG for cases where we need identical results to the game client. Based on work by Donald Knuth. +// Seeded RNG with identical results to the game client. Based on work by Donald Knuth. export class SRng { state: bigint; - constructor(seed: bigint) { - this.state = seed; + constructor(seed: bigint | number) { + this.state = BigInt(seed); } randomInt(min: number, max: number): number { diff --git a/src/services/serversideVendorsService.ts b/src/services/serversideVendorsService.ts index 171c60fc2..af7d0b916 100644 --- a/src/services/serversideVendorsService.ts +++ b/src/services/serversideVendorsService.ts @@ -1,6 +1,6 @@ import { unixTimesInMs } from "@/src/constants/timeConstants"; import { catBreadHash } from "@/src/helpers/stringHelpers"; -import { CRng, mixSeeds } from "@/src/services/rngService"; +import { mixSeeds, SRng } from "@/src/services/rngService"; import { IMongoDate } from "@/src/types/commonTypes"; import { IItemManifest, IVendorInfo, IVendorManifest } from "@/src/types/vendorTypes"; import { ExportVendors, IRange } from "warframe-public-export-plus"; @@ -204,7 +204,7 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani const cycleOffset = vendorInfo.cycleOffset ?? 1734307200_000; const cycleDuration = vendorInfo.cycleDuration; const cycleIndex = Math.trunc((Date.now() - cycleOffset) / cycleDuration); - const rng = new CRng(mixSeeds(vendorSeed, cycleIndex)); + const rng = new SRng(mixSeeds(vendorSeed, cycleIndex)); const manifest = ExportVendors[vendorInfo.TypeName]; const offersToAdd = []; if (manifest.numItems && !manifest.isOneBinPerCycle) { @@ -247,8 +247,7 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani $oid: ((cycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + vendorInfo._id.$oid.substring(8, 16) + - rng.randomInt(0, 0xffff).toString(16).padStart(4, "0") + - rng.randomInt(0, 0xffff).toString(16).padStart(4, "0") + rng.randomInt(0, 0xffff_ffff).toString(16).padStart(8, "0") } }; if (rawItem.numRandomItemPrices) { @@ -283,9 +282,9 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani item.PremiumPrice = [value, value]; } if (vendorInfo.RandomSeedType) { - item.LocTagRandSeed = (rng.randomInt(0, 0xffff) << 16) | rng.randomInt(0, 0xffff); + item.LocTagRandSeed = rng.randomInt(0, 0xffff_ffff); if (vendorInfo.RandomSeedType == "VRST_WEAPON") { - const highDword = (rng.randomInt(0, 0xffff) << 16) | rng.randomInt(0, 0xffff); + const highDword = rng.randomInt(0, 0xffff_ffff); item.LocTagRandSeed = (BigInt(highDword) << 32n) | (BigInt(item.LocTagRandSeed) & 0xffffffffn); } } diff --git a/src/services/worldStateService.ts b/src/services/worldStateService.ts index 6b953518f..3d4821a1d 100644 --- a/src/services/worldStateService.ts +++ b/src/services/worldStateService.ts @@ -5,7 +5,7 @@ import syndicateMissions from "@/static/fixed_responses/worldState/syndicateMiss import { buildConfig } from "@/src/services/buildConfigService"; import { unixTimesInMs } from "@/src/constants/timeConstants"; import { config } from "@/src/services/configService"; -import { CRng, SRng } from "@/src/services/rngService"; +import { SRng } from "@/src/services/rngService"; import { ExportNightwave, ExportRegions, IRegion } from "warframe-public-export-plus"; import { ICalendarDay, @@ -193,7 +193,7 @@ const pushSyndicateMissions = ( ): void => { const nodeOptions: string[] = [...syndicateMissions]; - const rng = new CRng(seed); + const rng = new SRng(seed); const nodes: string[] = []; for (let i = 0; i != 6; ++i) { const index = rng.randomInt(0, nodeOptions.length - 1); @@ -235,8 +235,8 @@ const pushTilesetModifiers = (modifiers: string[], tileset: TSortieTileset): voi }; export const getSortie = (day: number): ISortie => { - const seed = new CRng(day).randomInt(0, 100_000); - const rng = new CRng(seed); + const seed = new SRng(day).randomInt(0, 100_000); + const rng = new SRng(seed); const boss = rng.randomElement(sortieBosses)!; @@ -351,7 +351,7 @@ const dailyChallenges = Object.keys(ExportNightwave.challenges).filter(x => const getSeasonDailyChallenge = (day: number): ISeasonChallenge => { const dayStart = EPOCH + day * 86400000; const dayEnd = EPOCH + (day + 3) * 86400000; - const rng = new CRng(new CRng(day).randomInt(0, 100_000)); + const rng = new SRng(new SRng(day).randomInt(0, 100_000)); return { _id: { $oid: "67e1b5ca9d00cb47" + day.toString().padStart(8, "0") }, Daily: true, @@ -371,7 +371,7 @@ const getSeasonWeeklyChallenge = (week: number, id: number): ISeasonChallenge => const weekStart = EPOCH + week * 604800000; const weekEnd = weekStart + 604800000; const challengeId = week * 7 + id; - const rng = new CRng(new CRng(challengeId).randomInt(0, 100_000)); + const rng = new SRng(new SRng(challengeId).randomInt(0, 100_000)); return { _id: { $oid: "67e1bb2d9d00cb47" + challengeId.toString().padStart(8, "0") }, Activation: { $date: { $numberLong: weekStart.toString() } }, @@ -388,7 +388,7 @@ const getSeasonWeeklyHardChallenge = (week: number, id: number): ISeasonChalleng const weekStart = EPOCH + week * 604800000; const weekEnd = weekStart + 604800000; const challengeId = week * 7 + id; - const rng = new CRng(new CRng(challengeId).randomInt(0, 100_000)); + const rng = new SRng(new SRng(challengeId).randomInt(0, 100_000)); return { _id: { $oid: "67e1bb2d9d00cb47" + challengeId.toString().padStart(8, "0") }, Activation: { $date: { $numberLong: weekStart.toString() } }, @@ -432,12 +432,12 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[], // TODO: xpAmounts need to be calculated based on the jobType somehow? - const seed = new CRng(bountyCycle).randomInt(0, 100_000); + const seed = new SRng(bountyCycle).randomInt(0, 100_000); const bountyCycleStart = bountyCycle * 9000000; const bountyCycleEnd = bountyCycleStart + 9000000; { - const rng = new CRng(seed); + const rng = new SRng(seed); syndicateMissions.push({ _id: { $oid: ((bountyCycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "0000000000000008" @@ -509,7 +509,7 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[], } { - const rng = new CRng(seed); + const rng = new SRng(seed); syndicateMissions.push({ _id: { $oid: ((bountyCycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "0000000000000025" @@ -581,7 +581,7 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[], } { - const rng = new CRng(seed); + const rng = new SRng(seed); syndicateMissions.push({ _id: { $oid: ((bountyCycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "0000000000000002" @@ -701,7 +701,7 @@ const getCalendarSeason = (week: number): ICalendarSeason => { //logger.debug(`birthday on day ${day}`); eventDays.push({ day, events: [] }); // This is how CET_PLOT looks in worldState as of around 38.5.0 } - const rng = new CRng(new CRng(week).randomInt(0, 100_000)); + const rng = new SRng(new SRng(week).randomInt(0, 100_000)); const challenges = [ "/Lotus/Types/Challenges/Calendar1999/CalendarKillEnemiesEasy", "/Lotus/Types/Challenges/Calendar1999/CalendarKillEnemiesMedium", @@ -982,7 +982,7 @@ export const getWorldState = (buildLabel?: string): IWorldState => { } // Elite Sanctuary Onslaught cycling every week - worldState.NodeOverrides.find(x => x.Node == "SolNode802")!.Seed = new SRng(BigInt(week)).randomInt(0, 0xff_ffff); + worldState.NodeOverrides.find(x => x.Node == "SolNode802")!.Seed = new SRng(week).randomInt(0, 0xff_ffff); // Holdfast, Cavia, & Hex bounties cycling every 2.5 hours; unfaithful implementation let bountyCycle = Math.trunc(Date.now() / 9000000); @@ -1068,7 +1068,7 @@ export const getWorldState = (buildLabel?: string): IWorldState => { // The client does not seem to respect activation for classic syndicate missions, so only pushing current ones. const sdy = Date.now() >= rollover ? day : day - 1; - const rng = new CRng(sdy); + const rng = new SRng(sdy); pushSyndicateMissions(worldState, sdy, rng.randomInt(0, 100_000), "ba6f84724fa48049", "ArbitersSyndicate"); pushSyndicateMissions(worldState, sdy, rng.randomInt(0, 100_000), "ba6f84724fa4804a", "CephalonSudaSyndicate"); pushSyndicateMissions(worldState, sdy, rng.randomInt(0, 100_000), "ba6f84724fa4804e", "NewLokaSyndicate"); @@ -1184,8 +1184,8 @@ export const getLiteSortie = (week: number): ILiteSortie => { } } - const seed = new CRng(week).randomInt(0, 100_000); - const rng = new CRng(seed); + const seed = new SRng(week).randomInt(0, 100_000); + const rng = new SRng(seed); const firstNodeIndex = rng.randomInt(0, nodes.length - 1); const firstNode = nodes[firstNodeIndex]; nodes.splice(firstNodeIndex, 1);