diff --git a/src/services/conquestService.ts b/src/services/conquestService.ts new file mode 100644 index 00000000..b45975e7 --- /dev/null +++ b/src/services/conquestService.ts @@ -0,0 +1,430 @@ +import { ExportFactions, type TFaction, type TMissionType } from "warframe-public-export-plus"; +import type { CalendarSeasonType, IConquest, IConquestMission, TConquestType } from "../types/worldStateTypes.ts"; +import { mixSeeds, SRng } from "./rngService.ts"; +import { EPOCH } from "../constants/timeConstants.ts"; + +/*const missionTypes: Record = { + CT_LAB: ["MT_EXTERMINATION", "MT_SURVIVAL", "MT_ALCHEMY", "MT_DEFENSE", "MT_ARTIFACT"], + CT_HEX: ["MT_EXTERMINATION", "MT_SURVIVAL", "MT_DEFENSE", "MT_ENDLESS_CAPTURE"] +};*/ + +const missionAndFactionTypes: Record>> = { + CT_LAB: { + MT_EXTERMINATION: ["FC_MITW"], + MT_SURVIVAL: ["FC_MITW"], + MT_ALCHEMY: ["FC_MITW"], + MT_DEFENSE: ["FC_MITW"], + MT_ARTIFACT: ["FC_MITW"] + }, + CT_HEX: { + MT_EXTERMINATION: ["FC_SCALDRA", "FC_TECHROT"], + MT_SURVIVAL: ["FC_SCALDRA", "FC_TECHROT"], + MT_DEFENSE: ["FC_SCALDRA"], + MT_ENDLESS_CAPTURE: ["FC_TECHROT"] + } +}; + +const assassinationFactionOptions: Record = { + CT_LAB: ["FC_MITW"], + CT_HEX: ["FC_SCALDRA"] +}; + +type TConquestDifficulty = "CD_NORMAL" | "CD_HARD"; + +interface IConquestConditional { + tag: string; + missionType?: TMissionType; + conquest?: TConquestType; + difficulty?: TConquestDifficulty; + season?: CalendarSeasonType; +} + +const deviations: readonly IConquestConditional[] = [ + { + tag: "AlchemicalShields", + missionType: "MT_ALCHEMY" + }, + { + tag: "ContaminationZone", + missionType: "MT_SURVIVAL", + conquest: "CT_HEX" + }, + { + tag: "DoubleTrouble", + missionType: "MT_ARTIFACT" + }, + { + tag: "EscalateImmediately", + missionType: "MT_EXTERMINATION", + conquest: "CT_HEX" + }, + { + tag: "EximusGrenadiers", + missionType: "MT_ALCHEMY" + }, + { + tag: "FortifiedFoes", + missionType: "MT_EXTERMINATION" + }, + { + tag: "FragileNodes", + missionType: "MT_ARTIFACT" + }, + { + tag: "GrowingIncursion", + missionType: "MT_EXTERMINATION", + conquest: "CT_LAB" + }, + { + tag: "HarshWords", + missionType: "MT_DEFENSE", + conquest: "CT_LAB" + }, + { + tag: "HighScalingLegacyte", + missionType: "MT_ENDLESS_CAPTURE", + conquest: "CT_HEX" + }, + { + tag: "DoubleTroubleLegacyte", + missionType: "MT_ENDLESS_CAPTURE", + conquest: "CT_HEX" + }, + { + tag: "HostileSecurity", + missionType: "MT_DEFENSE", + conquest: "CT_LAB" + }, + { + tag: "InfiniteTide", + missionType: "MT_ASSASSINATION", + conquest: "CT_LAB" + }, + { + tag: "LostInTranslation", + missionType: "MT_DEFENSE", + conquest: "CT_LAB" + }, + { + tag: "MutatedEnemies", + missionType: "MT_ENDLESS_CAPTURE", + conquest: "CT_HEX" + }, + { + tag: "NecramechActivation", + missionType: "MT_SURVIVAL", + conquest: "CT_LAB" + }, + { + tag: "Reinforcements", + missionType: "MT_ASSASSINATION", + conquest: "CT_LAB" + }, + { + tag: "StickyFingers", + missionType: "MT_ARTIFACT", + conquest: "CT_LAB" + }, + { + tag: "TankStrongArmor", + missionType: "MT_ASSASSINATION", + conquest: "CT_HEX" + }, + { + tag: "TankReinforcements", + missionType: "MT_ASSASSINATION", + conquest: "CT_HEX" + }, + { + tag: "TankSuperToxic", + missionType: "MT_ASSASSINATION", + conquest: "CT_HEX" + }, + { + tag: "TechrotConjunction", + missionType: "MT_SURVIVAL", + conquest: "CT_HEX" + }, + { + tag: "UnpoweredCapsules", + missionType: "MT_SURVIVAL", + conquest: "CT_LAB" + }, + { + tag: "VolatileGrenades", + missionType: "MT_ALCHEMY" + }, + { + tag: "GestatingTumors", + missionType: "MT_SURVIVAL", + conquest: "CT_HEX" + }, + { + tag: "ChemicalNoise", + missionType: "MT_DEFENSE", + conquest: "CT_HEX" + }, + { + tag: "ExplosiveEnergy", + missionType: "MT_DEFENSE", + conquest: "CT_HEX" + }, + { + tag: "DisruptiveSounds", + missionType: "MT_DEFENSE", + conquest: "CT_HEX" + } +]; + +const risks: readonly IConquestConditional[] = [ + { + tag: "Voidburst" + }, + { + tag: "RegeneratingEnemies" + }, + { + tag: "VoidAberration" + }, + { + tag: "ShieldedFoes" + }, + { + tag: "PointBlank" + }, + { + tag: "Deflectors", + conquest: "CT_LAB" + }, + { + tag: "AcceleratedEnemies" + }, + { + tag: "DrainingResiduals" + }, + { + tag: "Quicksand" + }, + { + tag: "AntiMaterialWeapons", + conquest: "CT_LAB" + }, + { + tag: "ExplosiveCrawlers", + conquest: "CT_LAB" + }, + { + tag: "EMPBlackHole", + conquest: "CT_LAB" + }, + { + tag: "ArtilleryBeacons", + conquest: "CT_HEX" + }, + { + tag: "InfectedTechrot", + conquest: "CT_HEX" + }, + { + tag: "BalloonFest", + conquest: "CT_HEX" + }, + { + tag: "MiasmiteHive", + conquest: "CT_HEX" + }, + { + tag: "CompetitionSpillover", + conquest: "CT_HEX" + }, + { + tag: "HostileOvergrowth", + conquest: "CT_HEX" + }, + { + tag: "MurmurIncursion", + conquest: "CT_HEX" + }, + { + tag: "FactionSwarm_Techrot", + conquest: "CT_HEX", + difficulty: "CD_NORMAL" + }, + { + tag: "FactionSwarm_Scaldra", + conquest: "CT_HEX", + difficulty: "CD_NORMAL" + }, + { + tag: "HeavyWarfare", + conquest: "CT_HEX" + }, + { + tag: "ArcadeAutomata", + conquest: "CT_HEX", + difficulty: "CD_NORMAL" + }, + { + tag: "EfervonFog", + conquest: "CT_HEX" + }, + { + tag: "WinterFrost", + conquest: "CT_HEX", + season: "CST_WINTER" + }, + { + tag: "JadeSpring", + conquest: "CT_HEX", + season: "CST_SPRING" + }, + { + tag: "ExplosiveSummer", + conquest: "CT_HEX", + season: "CST_SUMMER" + }, + { + tag: "FallFog", + conquest: "CT_HEX", + season: "CST_FALL" + } +]; + +const filterConditionals = ( + arr: readonly IConquestConditional[], + missionType: TMissionType | null, + conquest: TConquestType | null, + difficulty: TConquestDifficulty | null, + season: CalendarSeasonType | null +): string[] => { + const applicable = []; + for (const cond of arr) { + if ( + (!cond.missionType || cond.missionType == missionType) && + (!cond.conquest || cond.conquest == conquest) && + (!cond.difficulty || cond.difficulty == difficulty) && + (!cond.season || cond.season == season) + ) { + applicable.push(cond.tag); + } + } + return applicable; +}; + +const buildMission = ( + rng: SRng, + conquest: TConquestType, + missionType: TMissionType, + faction: TFaction, + season: CalendarSeasonType | null +): IConquestMission => { + const deviation = rng.randomElement(filterConditionals(deviations, missionType, conquest, null, season))!; + const easyRisk = rng.randomElement(filterConditionals(risks, missionType, conquest, "CD_NORMAL", season))!; + const hardRiskOptions = filterConditionals(risks, missionType, conquest, "CD_HARD", season); + { + const i = hardRiskOptions.indexOf(easyRisk); + if (i != -1) { + hardRiskOptions.splice(i, 1); + } + } + const hardRisk = rng.randomElement(hardRiskOptions)!; + return { + faction, + missionType, + difficulties: [ + { + type: "CD_NORMAL", + deviation, + risks: [easyRisk] + }, + { + type: "CD_HARD", + deviation, + risks: [easyRisk, hardRisk] + } + ] + }; +}; + +const conquestStartingDay: Record = { + CT_LAB: 3703, + CT_HEX: 4053 +}; + +// This function produces identical results to clients pre-40.0.0. +const getFrameVariables = (conquestType: TConquestType, time: number): [string, string, string, string] => { + const day = Math.floor((time - 1391990400_000) / 86400_000) - conquestStartingDay[conquestType]; + const week = Math.floor(day / 7) + 1; + const frameVariables = [ + "Framecurse", + "Knifestep", + "Exhaustion", + "Gearless", + "TimeDilation", + "Armorless", + "Starvation", + "ShieldDelay", + "Withering", + "ContactDamage", + "AbilityLockout", + "OperatorLockout", + "EnergyStarved", + "OverSensitive", + "AntiGuard", + "DecayingFlesh", + "VoidEnergyOverload", + "DullBlades", + "Undersupplied" + ]; + const mag = Math.floor(frameVariables.length / 4); + const rng = new SRng(conquestStartingDay[conquestType] + Math.floor(week / mag)); + rng.shuffleArray(frameVariables); + const i = week % mag; + return [frameVariables[i], frameVariables[i + 1], frameVariables[i + 2], frameVariables[i + 3]]; +}; + +export const getConquest = ( + conquestType: TConquestType, + week: number, + season: CalendarSeasonType | null +): IConquest => { + const rng = new SRng(mixSeeds(conquestStartingDay[conquestType], week)); + + const missions: IConquestMission[] = []; + { + const missionOptions = Object.entries(missionAndFactionTypes[conquestType]); + { + const i = rng.randomInt(0, missionOptions.length - 1); + const [missionType, factionOptions] = missionOptions.splice(i, 1)[0]; + missions.push( + buildMission(rng, conquestType, missionType as TMissionType, rng.randomElement(factionOptions)!, season) + ); + } + { + const i = rng.randomInt(0, missionOptions.length - 1); + const [missionType, factionOptions] = missionOptions.splice(i, 1)[0]; + missions.push( + buildMission(rng, conquestType, missionType as TMissionType, rng.randomElement(factionOptions)!, season) + ); + } + missionOptions.push(["MT_ASSASSINATION", assassinationFactionOptions[conquestType]]); + { + const i = rng.randomInt(0, missionOptions.length - 1); + const [missionType, factionOptions] = missionOptions.splice(i, 1)[0]; + missions.push( + buildMission(rng, conquestType, missionType as TMissionType, rng.randomElement(factionOptions)!, season) + ); + } + } + + const weekStart = EPOCH + week * 604800000; + const weekEnd = weekStart + 604800000; + return { + Activation: { $date: { $numberLong: weekStart.toString() } }, + Expiry: { $date: { $numberLong: weekEnd.toString() } }, + Type: conquestType, + Missions: missions, + Variables: getFrameVariables(conquestType, weekStart), + RandomSeed: rng.randomInt(0, 1_000_000) + }; +}; diff --git a/src/services/worldStateService.ts b/src/services/worldStateService.ts index dc93acd5..8fc9cb8a 100644 --- a/src/services/worldStateService.ts +++ b/src/services/worldStateService.ts @@ -41,6 +41,7 @@ import type { import { toMongoDate, toOid, version_compare } from "../helpers/inventoryHelpers.ts"; import { logger } from "../utils/logger.ts"; import { DailyDeal, Fissure } from "../models/worldStateModel.ts"; +import { getConquest } from "./conquestService.ts"; const sortieBosses = [ "SORTIE_BOSS_HYENA", @@ -3467,6 +3468,18 @@ export const getWorldState = (buildLabel?: string): IWorldState => { } } + // Void Storms + const hour = Math.trunc(timeMs / unixTimesInMs.hour); + const overLastHourStormExpiry = hour * unixTimesInMs.hour + 10 * unixTimesInMs.minute; + const thisHourStormActivation = hour * unixTimesInMs.hour + 40 * unixTimesInMs.minute; + if (overLastHourStormExpiry > timeMs) { + pushVoidStorms(worldState.VoidStorms, hour - 2); + } + pushVoidStorms(worldState.VoidStorms, hour - 1); + if (isBeforeNextExpectedWorldStateRefresh(timeMs, thisHourStormActivation)) { + pushVoidStorms(worldState.VoidStorms, hour); + } + // Sortie & syndicate missions cycling every day (at 16:00 or 17:00 UTC depending on if London, OT is observing DST) { const rollover = getSortieTime(day); @@ -3549,16 +3562,18 @@ export const getWorldState = (buildLabel?: string): IWorldState => { worldState.KnownCalendarSeasons.push(getCalendarSeason(week + 1)); } - // Void Storms - const hour = Math.trunc(timeMs / unixTimesInMs.hour); - const overLastHourStormExpiry = hour * unixTimesInMs.hour + 10 * unixTimesInMs.minute; - const thisHourStormActivation = hour * unixTimesInMs.hour + 40 * unixTimesInMs.minute; - if (overLastHourStormExpiry > timeMs) { - pushVoidStorms(worldState.VoidStorms, hour - 2); - } - pushVoidStorms(worldState.VoidStorms, hour - 1); - if (isBeforeNextExpectedWorldStateRefresh(timeMs, thisHourStormActivation)) { - pushVoidStorms(worldState.VoidStorms, hour); + if (!buildLabel || version_compare(buildLabel, "2025.10.14.16.10") >= 0) { + worldState.Conquests = []; + { + const season = (["CST_WINTER", "CST_SPRING", "CST_SUMMER", "CST_FALL"] as const)[week % 4]; + worldState.Conquests.push(getConquest("CT_LAB", week, null)); + worldState.Conquests.push(getConquest("CT_HEX", week, season)); + } + if (isBeforeNextExpectedWorldStateRefresh(timeMs, weekEnd)) { + const season = (["CST_WINTER", "CST_SPRING", "CST_SUMMER", "CST_FALL"] as const)[(week + 1) % 4]; + worldState.Conquests.push(getConquest("CT_LAB", week, null)); + worldState.Conquests.push(getConquest("CT_HEX", week, season)); + } } // Sentient Anomaly + Xtra Cheese cycles diff --git a/src/types/inventoryTypes/inventoryTypes.ts b/src/types/inventoryTypes/inventoryTypes.ts index cc44c061..6f2ed342 100644 --- a/src/types/inventoryTypes/inventoryTypes.ts +++ b/src/types/inventoryTypes/inventoryTypes.ts @@ -14,6 +14,7 @@ import type { IOrbiterClient } from "../personalRoomsTypes.ts"; import type { ICountedStoreItem } from "warframe-public-export-plus"; import type { IEquipmentClient, IEquipmentDatabase, ITraits } from "../equipmentTypes.ts"; import type { ILoadOutPresets } from "../saveLoadoutTypes.ts"; +import type { CalendarSeasonType } from "../worldStateTypes.ts"; export type InventoryDatabaseEquipment = { [_ in TEquipmentKey]: IEquipmentDatabase[]; @@ -1180,7 +1181,7 @@ export interface IMarker { } export interface ISeasonProgress { - SeasonType: "CST_WINTER" | "CST_SPRING" | "CST_SUMMER" | "CST_FALL"; + SeasonType: CalendarSeasonType; LastCompletedDayIdx: number; LastCompletedChallengeDayIdx: number; ActivatedChallenges: string[]; diff --git a/src/types/worldStateTypes.ts b/src/types/worldStateTypes.ts index 358e4e80..799e261d 100644 --- a/src/types/worldStateTypes.ts +++ b/src/types/worldStateTypes.ts @@ -32,6 +32,7 @@ export interface IWorldState { ActiveChallenges: ISeasonChallenge[]; }; KnownCalendarSeasons: ICalendarSeason[]; + Conquests?: IConquest[]; Tmp?: string; } @@ -352,10 +353,11 @@ export interface ISeasonChallenge { Challenge: string; } +export type CalendarSeasonType = "CST_WINTER" | "CST_SPRING" | "CST_SUMMER" | "CST_FALL"; export interface ICalendarSeason { Activation: IMongoDate; Expiry: IMongoDate; - Season: "CST_WINTER" | "CST_SPRING" | "CST_SUMMER" | "CST_FALL"; + Season: CalendarSeasonType; Days: ICalendarDay[]; YearIteration: number; Version: number; @@ -416,6 +418,33 @@ export interface IGameMarketCategory { Items?: string[]; } +// >= 40.0.0 +export type TConquestType = "CT_LAB" | "CT_HEX"; +export interface IConquest { + Activation: IMongoDate; + Expiry: IMongoDate; + Type: TConquestType; + Missions: IConquestMission[]; + Variables: [string, string, string, string]; + RandomSeed: number; +} +export interface IConquestMission { + faction: TFaction; + missionType: TMissionType; + difficulties: [ + { + type: "CD_NORMAL"; + deviation: string; + risks: [string]; + }, + { + type: "CD_HARD"; + deviation: string; + risks: [string, string]; + } + ]; +} + export interface ITmp { cavabegin: string; PurchasePlatformLockEnabled: boolean; // Seems unused @@ -423,6 +452,8 @@ export interface ITmp { ennnd?: boolean; // True if 1999 demo is available (no effect for >=38.6.0) mbrt?: boolean; // Related to mobile app rating request fbst: IFbst; + lqo?: IConquestOverride; + hqo?: IConquestOverride; sfn: number; edg?: TCircuitGameMode[]; // The Circuit game modes overwrite } @@ -451,3 +482,12 @@ interface IFbst { e: number; n: number; } + +// < 40.0.0 +interface IConquestOverride { + mt?: string[]; // mission types but "Exterminate" instead of "MT_EXTERMINATION", etc. and "DualDefense" instead of "Defense" for hex conquest + mv?: string[]; + mf?: number[]; // hex conquest only + c?: [string, string][]; + fv?: string[]; +}