diff --git a/src/services/rngService.ts b/src/services/rngService.ts index f88b23c0..bb9028b2 100644 --- a/src/services/rngService.ts +++ b/src/services/rngService.ts @@ -97,9 +97,17 @@ export class CRng { } randomInt(min: number, max: number): number { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(this.random() * (max - min + 1)) + min; + 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: T[]): T { diff --git a/src/services/serversideVendorsService.ts b/src/services/serversideVendorsService.ts index 3abe56fa..f13ca7e5 100644 --- a/src/services/serversideVendorsService.ts +++ b/src/services/serversideVendorsService.ts @@ -5,9 +5,10 @@ import { IItemManifestPreprocessed, IRawVendorManifest, IVendorInfo, + IVendorInfoPreprocessed, IVendorManifestPreprocessed } from "@/src/types/vendorTypes"; -import { ExportVendors } from "warframe-public-export-plus"; +import { ExportVendors, IRange } from "warframe-public-export-plus"; import ArchimedeanVendorManifest from "@/static/fixed_responses/getVendorInfo/ArchimedeanVendorManifest.json"; import DeimosEntratiFragmentVendorProductsManifest from "@/static/fixed_responses/getVendorInfo/DeimosEntratiFragmentVendorProductsManifest.json"; @@ -32,7 +33,6 @@ import OstronFishmongerVendorManifest from "@/static/fixed_responses/getVendorIn import OstronPetVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronPetVendorManifest.json"; import OstronProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronProspectorVendorManifest.json"; import RadioLegionIntermission12VendorManifest from "@/static/fixed_responses/getVendorInfo/RadioLegionIntermission12VendorManifest.json"; -import SolarisDebtTokenVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorManifest.json"; import SolarisDebtTokenVendorRepossessionsManifest from "@/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorRepossessionsManifest.json"; import SolarisFishmongerVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisFishmongerVendorManifest.json"; import SolarisProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisProspectorVendorManifest.json"; @@ -63,7 +63,6 @@ const rawVendorManifests: IRawVendorManifest[] = [ OstronPetVendorManifest, OstronProspectorVendorManifest, RadioLegionIntermission12VendorManifest, - SolarisDebtTokenVendorManifest, SolarisDebtTokenVendorRepossessionsManifest, SolarisFishmongerVendorManifest, SolarisProspectorVendorManifest, @@ -72,7 +71,7 @@ const rawVendorManifests: IRawVendorManifest[] = [ ]; interface IGeneratableVendorInfo extends Omit { - cycleStart: number; + cycleOffset: number; cycleDuration: number; } @@ -84,7 +83,7 @@ const generatableVendors: IGeneratableVendorInfo[] = [ RandomSeedType: "VRST_WEAPON", RequiredGoalTag: "", WeaponUpgradeValueAttenuationExponent: 2.25, - cycleStart: 1740960000_000, + cycleOffset: 1740960000_000, cycleDuration: 4 * unixTimesInMs.day }, { @@ -93,8 +92,16 @@ const generatableVendors: IGeneratableVendorInfo[] = [ PropertyTextHash: "34F8CF1DFF745F0D67433A5EF0A03E70", RandomSeedType: "VRST_WEAPON", WeaponUpgradeValueAttenuationExponent: 2.25, - cycleStart: 1744934400_000, + cycleOffset: 1744934400_000, cycleDuration: 4 * unixTimesInMs.day + }, + { + _id: { $oid: "5be4a159b144f3cdf1c22efa" }, + TypeName: "/Lotus/Types/Game/VendorManifests/Solaris/DebtTokenVendorManifest", + PropertyTextHash: "A39621049CA3CA13761028CD21C239EF", + RandomSeedType: "VRST_FLAVOUR_TEXT", + cycleOffset: 1734307200_000, + cycleDuration: unixTimesInMs.hour } // { // _id: { $oid: "5dbb4c41e966f7886c3ce939" }, @@ -166,60 +173,128 @@ const refreshExpiry = (expiry: IMongoDate): number => { return 0; }; -const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorManifestPreprocessed => { - const EPOCH = vendorInfo.cycleStart; - const manifest = ExportVendors[vendorInfo.TypeName]; - let binThisCycle; - if (manifest.isOneBinPerCycle) { - const cycleDuration = vendorInfo.cycleDuration; // manifest.items[0].durationHours! * 3600_000; - const cycleIndex = Math.trunc((Date.now() - EPOCH) / cycleDuration); - binThisCycle = cycleIndex % 2; // Note: May want to auto-compute the bin size, but this is only used for coda weapons right now. +const toRange = (value: IRange | number): IRange => { + if (typeof value == "number") { + return { minValue: value, maxValue: value }; } - const items: IItemManifestPreprocessed[] = []; - let soonestOfferExpiry: number = Number.MAX_SAFE_INTEGER; - for (let i = 0; i != manifest.items.length; ++i) { - const rawItem = manifest.items[i]; - if (manifest.isOneBinPerCycle && rawItem.bin != binThisCycle) { - continue; + return value; +}; + +const vendorInfoCache: Record = {}; + +const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorManifestPreprocessed => { + if (!(vendorInfo.TypeName in vendorInfoCache)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { cycleOffset, cycleDuration, ...clientVendorInfo } = vendorInfo; + vendorInfoCache[vendorInfo.TypeName] = { + ...clientVendorInfo, + ItemManifest: [], + Expiry: { $date: { $numberLong: "0" } } + }; + } + const processed = vendorInfoCache[vendorInfo.TypeName]; + if (Date.now() >= parseInt(processed.Expiry.$date.$numberLong)) { + // Remove expired offers + for (let i = 0; i != processed.ItemManifest.length; ) { + if (Date.now() >= parseInt(processed.ItemManifest[i].Expiry.$date.$numberLong)) { + processed.ItemManifest.splice(i, 1); + } else { + ++i; + } } - const cycleDuration = vendorInfo.cycleDuration; // rawItem.durationHours! * 3600_000; - const cycleIndex = Math.trunc((Date.now() - EPOCH) / cycleDuration); - const cycleStart = EPOCH + cycleIndex * cycleDuration; - const cycleEnd = cycleStart + cycleDuration; - if (soonestOfferExpiry > cycleEnd) { - soonestOfferExpiry = cycleEnd; + + // Add new offers + const vendorSeed = parseInt(vendorInfo._id.$oid.substring(16), 16); + const cycleIndex = Math.trunc((Date.now() - vendorInfo.cycleOffset) / vendorInfo.cycleDuration); + const rng = new CRng(mixSeeds(vendorSeed, cycleIndex)); + const manifest = ExportVendors[vendorInfo.TypeName]; + const offersToAdd = []; + if (manifest.numItems && manifest.numItems.minValue != manifest.numItems.maxValue) { + const numItemsTarget = rng.randomInt(manifest.numItems.minValue, manifest.numItems.maxValue); + while (processed.ItemManifest.length + offersToAdd.length < numItemsTarget) { + // TODO: Consider per-bin item limits + // TODO: Consider item probability weightings + offersToAdd.push(rng.randomElement(manifest.items)); + } + } else { + let binThisCycle; + if (manifest.isOneBinPerCycle) { + binThisCycle = cycleIndex % 2; // Note: May want to auto-compute the bin size, but this is only used for coda weapons right now. + } + for (const rawItem of manifest.items) { + if (!manifest.isOneBinPerCycle || rawItem.bin == binThisCycle) { + offersToAdd.push(rawItem); + } + } } - const rng = new CRng(cycleIndex); - rng.churnSeed(i); - /*for (let j = -1; j != rawItem.duplicates; ++j)*/ { + const cycleStart = vendorInfo.cycleOffset + cycleIndex * vendorInfo.cycleDuration; + for (const rawItem of offersToAdd) { + const durationHoursRange = toRange(rawItem.durationHours); + const expiry = + cycleStart + + rng.randomInt(durationHoursRange.minValue, durationHoursRange.maxValue) * unixTimesInMs.hour; const item: IItemManifestPreprocessed = { StoreItem: rawItem.storeItem, - ItemPrices: rawItem.itemPrices!.map(itemPrice => ({ ...itemPrice, ProductCategory: "MiscItems" })), + ItemPrices: rawItem.itemPrices?.map(itemPrice => ({ ...itemPrice, ProductCategory: "MiscItems" })), Bin: "BIN_" + rawItem.bin, QuantityMultiplier: 1, - Expiry: { $date: { $numberLong: cycleEnd.toString() } }, + Expiry: { $date: { $numberLong: expiry.toString() } }, AllowMultipurchase: false, Id: { $oid: - i.toString(16).padStart(8, "0") + + Math.trunc(cycleStart / 1000) + .toString(16) + .padStart(8, "0") + vendorInfo._id.$oid.substring(8, 16) + - rng.randomInt(0, 0xffffffff).toString(16).padStart(8, "0") + rng.randomInt(0, 0xffff).toString(16).padStart(4, "0") + + rng.randomInt(0, 0xffff).toString(16).padStart(4, "0") } }; - if (vendorInfo.RandomSeedType) { - item.LocTagRandSeed = - (BigInt(rng.randomInt(0, 0xffffffff)) << 32n) | BigInt(rng.randomInt(0, 0xffffffff)); + if (rawItem.numRandomItemPrices) { + item.ItemPrices = []; + for (let i = 0; i != rawItem.numRandomItemPrices; ++i) { + let itemPrice: { type: string; count: IRange }; + do { + itemPrice = rng.randomElement(manifest.randomItemPricesPerBin![rawItem.bin]); + } while (item.ItemPrices.find(x => x.ItemType == itemPrice.type)); + item.ItemPrices.push({ + ItemType: itemPrice.type, + ItemCount: rng.randomInt(itemPrice.count.minValue, itemPrice.count.maxValue), + ProductCategory: "MiscItems" + }); + } } - items.push(item); + if (rawItem.credits) { + const value = + typeof rawItem.credits == "number" + ? rawItem.credits + : rng.randomInt( + rawItem.credits.minValue / rawItem.credits.step, + rawItem.credits.maxValue / rawItem.credits.step + ) * rawItem.credits.step; + item.RegularPrice = [value, value]; + } + if (vendorInfo.RandomSeedType) { + item.LocTagRandSeed = (rng.randomInt(0, 0xffff) << 16) | rng.randomInt(0, 0xffff); + if (vendorInfo.RandomSeedType == "VRST_WEAPON") { + const highDword = (rng.randomInt(0, 0xffff) << 16) | rng.randomInt(0, 0xffff); + item.LocTagRandSeed = (BigInt(highDword) << 32n) | BigInt(item.LocTagRandSeed); + } + } + processed.ItemManifest.push(item); } + + // Update vendor expiry + let soonestOfferExpiry: number = Number.MAX_SAFE_INTEGER; + for (const offer of processed.ItemManifest) { + const offerExpiry = parseInt(offer.Expiry.$date.$numberLong); + if (soonestOfferExpiry > offerExpiry) { + soonestOfferExpiry = offerExpiry; + } + } + processed.Expiry.$date.$numberLong = soonestOfferExpiry.toString(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { cycleStart, cycleDuration, ...clientVendorInfo } = vendorInfo; return { - VendorInfo: { - ...clientVendorInfo, - ItemManifest: items, - Expiry: { $date: { $numberLong: soonestOfferExpiry.toString() } } - } + VendorInfo: processed }; }; diff --git a/src/types/vendorTypes.ts b/src/types/vendorTypes.ts index f14d3f55..a6e01835 100644 --- a/src/types/vendorTypes.ts +++ b/src/types/vendorTypes.ts @@ -13,6 +13,7 @@ export interface IItemPricePreprocessed extends Omit { export interface IItemManifest { StoreItem: string; ItemPrices?: IItemPrice[]; + RegularPrice?: number[]; Bin: string; QuantityMultiplier: number; Expiry: IMongoDate; // Either a date in the distant future or a period in milliseconds for preprocessing. diff --git a/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorManifest.json b/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorManifest.json deleted file mode 100644 index 3a4fa0ac..00000000 --- a/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorManifest.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "VendorInfo": { - "_id": { - "$oid": "5be4a159b144f3cdf1c22efa" - }, - "TypeName": "/Lotus/Types/Game/VendorManifests/Solaris/DebtTokenVendorManifest", - "ItemManifest": [ - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleUncommonD", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Gameplay/Venus/Resources/VenusCoconutItem", - "ItemCount": 5, - "ProductCategory": "MiscItems" - }, - { - "ItemType": "/Lotus/Types/Items/MiscItems/Circuits", - "ItemCount": 3664, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [87300, 87300], - "Bin": "BIN_1", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 1881404827, - "Id": { - "$oid": "670daf92d21f34757a5e73b4" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleRareC", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/MiscItems/NeuralSensor", - "ItemCount": 1, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [53300, 53300], - "Bin": "BIN_2", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 1943984533, - "Id": { - "$oid": "6710b5029e1a3080a65e73a7" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleCommonG", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/MiscItems/Salvage", - "ItemCount": 11540, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [27300, 27300], - "Bin": "BIN_0", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 744199559, - "Id": { - "$oid": "67112582cc115756985e73a4" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleUncommonB", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/Fish/Solaris/FishParts/CorpusFishThermalLaserItem", - "ItemCount": 9, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [75800, 75800], - "Bin": "BIN_1", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 3744711432, - "Id": { - "$oid": "670de7d28a6ec82cd25e73a2" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleUncommonB", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/MiscItems/Rubedo", - "ItemCount": 3343, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [52200, 52200], - "Bin": "BIN_1", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 1579000687, - "Id": { - "$oid": "670e58526171148e125e73ad" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleCommonA", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Gameplay/Venus/Resources/CoolantItem", - "ItemCount": 9, - "ProductCategory": "MiscItems" - }, - { - "ItemType": "/Lotus/Types/Items/Fish/Solaris/FishParts/CorpusFishAnoscopicSensorItem", - "ItemCount": 5, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [12400, 12400], - "Bin": "BIN_0", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 3589081466, - "Id": { - "$oid": "67112582cc115756985e73a5" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleUncommonC", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/Gems/Solaris/SolarisCommonOreBAlloyItem", - "ItemCount": 13, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [77500, 77500], - "Bin": "BIN_1", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 1510234814, - "Id": { - "$oid": "670f0f21250ad046c35e73ee" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleUncommonD", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/Fish/Solaris/FishParts/CorpusFishParralelBiodeItem", - "ItemCount": 7, - "ProductCategory": "MiscItems" - }, - { - "ItemType": "/Lotus/Types/Items/Gems/Solaris/SolarisCommonGemBCutItem", - "ItemCount": 12, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [94600, 94600], - "Bin": "BIN_1", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 4222095721, - "Id": { - "$oid": "670f63827be40254f95e739d" - } - }, - { - "StoreItem": "/Lotus/Types/StoreItems/Packages/DebtTokenBundles/DebtTokenBundleCommonJ", - "ItemPrices": [ - { - "ItemType": "/Lotus/Types/Items/MiscItems/Nanospores", - "ItemCount": 14830, - "ProductCategory": "MiscItems" - } - ], - "RegularPrice": [25600, 25600], - "Bin": "BIN_0", - "QuantityMultiplier": 1, - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - }, - "PurchaseQuantityLimit": 1, - "AllowMultipurchase": true, - "LocTagRandSeed": 2694388669, - "Id": { - "$oid": "67112582cc115756985e73a6" - } - } - ], - "PropertyTextHash": "A39621049CA3CA13761028CD21C239EF", - "RandomSeedType": "VRST_FLAVOUR_TEXT", - "Expiry": { - "$date": { - "$numberLong": "9999999000000" - } - } - } -}