From b81a05b3f4fe610c67558cf69cc7454e99cf3d6a Mon Sep 17 00:00:00 2001 From: Sainan <63328889+Sainan@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:20:41 +0200 Subject: [PATCH] feat: initial invasions A rough generation of 3 invasions that change at daily reset, so missing the planet-based invasion 'chains'. Battle pay is fully working tho, just a few points of uncertainty there due to missing research and logs. Death marks are also roughly working. --- src/controllers/api/inventoryController.ts | 70 ++++++- src/services/worldStateService.ts | 92 ++++++++- src/types/worldStateTypes.ts | 23 +++ .../worldState/invasionNodes.json | 114 +++++++++++ .../worldState/invasionRewards.json | 190 ++++++++++++++++++ .../worldState/worldState.json | 40 ---- 6 files changed, 486 insertions(+), 43 deletions(-) create mode 100644 static/fixed_responses/worldState/invasionNodes.json create mode 100644 static/fixed_responses/worldState/invasionRewards.json diff --git a/src/controllers/api/inventoryController.ts b/src/controllers/api/inventoryController.ts index 0d2e8ac7..357c34cb 100644 --- a/src/controllers/api/inventoryController.ts +++ b/src/controllers/api/inventoryController.ts @@ -6,10 +6,18 @@ import allDialogue from "@/static/fixed_responses/allDialogue.json"; import { ILoadoutDatabase } from "@/src/types/saveLoadoutTypes"; import { IInventoryClient, IShipInventory, equipmentKeys } from "@/src/types/inventoryTypes/inventoryTypes"; import { IPolarity, ArtifactPolarity } from "@/src/types/inventoryTypes/commonInventoryTypes"; -import { ExportCustoms, ExportFlavour, ExportResources, ExportVirtuals } from "warframe-public-export-plus"; +import { + eFaction, + ExportCustoms, + ExportFlavour, + ExportResources, + ExportVirtuals, + ICountedItem +} from "warframe-public-export-plus"; import { applyCheatsToInfestedFoundry, handleSubsumeCompletion } from "@/src/services/infestedFoundryService"; import { addEmailItem, + addItem, addMiscItems, allDailyAffiliationKeys, checkCalendarAutoAdvance, @@ -30,7 +38,8 @@ import { unixTimesInMs } from "@/src/constants/timeConstants"; import { DailyDeal } from "@/src/models/worldStateModel"; import { EquipmentFeatures } from "@/src/types/equipmentTypes"; import { generateRewardSeed } from "@/src/services/rngService"; -import { getWorldState } from "@/src/services/worldStateService"; +import { getInvasionByOid, getWorldState } from "@/src/services/worldStateService"; +import { createMessage } from "@/src/services/inboxService"; export const inventoryController: RequestHandler = async (request, response) => { const account = await getAccountForRequest(request); @@ -186,6 +195,63 @@ export const inventoryController: RequestHandler = async (request, response) => //await inventory.save(); } + for (let i = 0; i != inventory.QualifyingInvasions.length; ) { + const qi = inventory.QualifyingInvasions[i]; + const invasion = getInvasionByOid(qi.invasionId.toString()); + if (!invasion) { + logger.debug(`removing QualifyingInvasions entry for unknown invasion: ${qi.invasionId.toString()}`); + inventory.QualifyingInvasions.splice(i, 1); + continue; + } + if (invasion.Completed) { + let factionSidedWith: string | undefined; + let battlePay: ICountedItem[] | undefined; + if (qi.AttackerScore >= 3) { + factionSidedWith = invasion.Faction; + battlePay = invasion.AttackerReward.countedItems; + logger.debug(`invasion pay from ${factionSidedWith}`, { battlePay }); + } else if (qi.DefenderScore >= 3) { + factionSidedWith = invasion.DefenderFaction; + battlePay = invasion.DefenderReward.countedItems; + logger.debug(`invasion pay from ${factionSidedWith}`, { battlePay }); + } + if (factionSidedWith) { + if (battlePay) { + // Decoupling rewards from the inbox message because it may delete itself without being read + for (const item of battlePay) { + await addItem(inventory, item.ItemType, item.ItemCount); + } + await createMessage(account._id, [ + { + sndr: eFaction.find(x => x.tag == factionSidedWith)?.name ?? factionSidedWith, // TOVERIFY + msg: `/Lotus/Language/G1Quests/${factionSidedWith}_InvasionThankyouMessageBody`, + sub: `/Lotus/Language/G1Quests/${factionSidedWith}_InvasionThankyouMessageSubject`, + countedAtt: battlePay, + attVisualOnly: true, + icon: + factionSidedWith == "FC_GRINEER" + ? "/Lotus/Interface/Icons/Npcs/EliteRifleLancerAvatar.png" // Source: https://www.reddit.com/r/Warframe/comments/1aj4usx/battle_pay_worth_10_plat/, https://www.youtube.com/watch?v=XhNZ6ai6BOY + : "/Lotus/Interface/Icons/Npcs/CrewmanNormal.png", // My best source for this is https://www.youtube.com/watch?v=rxrCCFm73XE around 1:37 + // TOVERIFY: highPriority? + endDate: new Date(Date.now() + 86400_000) // TOVERIFY: This type of inbox message seems to automatically delete itself. We'll just delete it after 24 hours, but it's not clear if this is correct. + } + ]); + } + if (invasion.Faction != "FC_INFESTATION") { + // Sided with grineer -> opposed corpus -> send zanuka (harvester) + // Sided with corpus -> opposed grineer -> send g3 (death squad) + inventory[factionSidedWith != "FC_GRINEER" ? "DeathSquadable" : "Harvestable"] = true; + // TOVERIFY: Should this happen earlier? + // TOVERIFY: Should this send an (ephemeral) email? + } + } + logger.debug(`removing QualifyingInvasions entry for completed invasion: ${qi.invasionId.toString()}`); + inventory.QualifyingInvasions.splice(i, 1); + continue; + } + ++i; + } + if (inventory.LastInventorySync) { const lastSyncDuviriMood = Math.trunc(inventory.LastInventorySync.getTimestamp().getTime() / 7200000); const currentDuviriMood = Math.trunc(Date.now() / 7200000); diff --git a/src/services/worldStateService.ts b/src/services/worldStateService.ts index 0b2dc7a5..60db22b1 100644 --- a/src/services/worldStateService.ts +++ b/src/services/worldStateService.ts @@ -6,15 +6,18 @@ import sortieTilesets from "@/static/fixed_responses/worldState/sortieTilesets.j import sortieTilesetMissions from "@/static/fixed_responses/worldState/sortieTilesetMissions.json"; import syndicateMissions from "@/static/fixed_responses/worldState/syndicateMissions.json"; import darvoDeals from "@/static/fixed_responses/worldState/darvoDeals.json"; +import invasionNodes from "@/static/fixed_responses/worldState/invasionNodes.json"; +import invasionRewards from "@/static/fixed_responses/worldState/invasionRewards.json"; import { buildConfig } from "@/src/services/buildConfigService"; import { unixTimesInMs } from "@/src/constants/timeConstants"; import { config } from "@/src/services/configService"; import { getRandomElement, getRandomInt, sequentiallyUniqueRandomElement, SRng } from "@/src/services/rngService"; -import { eMissionType, ExportRegions, ExportSyndicates, IRegion } from "warframe-public-export-plus"; +import { eMissionType, ExportRegions, ExportSyndicates, IMissionReward, IRegion } from "warframe-public-export-plus"; import { ICalendarDay, ICalendarEvent, ICalendarSeason, + IInvasion, ILiteSortie, IPrimeVaultTrader, IPrimeVaultTraderOffer, @@ -1227,6 +1230,78 @@ const getAllVarziaManifests = (): IPrimeVaultTraderOffer[] => { return [...dualPacks, ...singlePacks, ...items, ...bobbleHeads, ...relics]; }; +const createInvasion = (day: number, idx: number): IInvasion => { + const id = day * 3 + idx; + const defender = (["FC_GRINEER", "FC_CORPUS", day % 2 ? "FC_GRINEER" : "FC_CORPUS"] as const)[idx]; + const rng = new SRng(new SRng(id).randomInt(0, 1_000_000)); + const isInfestationOutbreak = rng.randomInt(0, 1) == 0; + const attacker = isInfestationOutbreak ? "FC_INFESTATION" : defender == "FC_GRINEER" ? "FC_CORPUS" : "FC_GRINEER"; + const startMs = EPOCH + day * 86400_000; + const oid = + ((startMs / 1000) & 0xffffffff).toString(16).padStart(8, "0") + + "fd148cb8" + + (idx & 0xffffffff).toString(16).padStart(8, "0"); + const node = sequentiallyUniqueRandomElement(invasionNodes[defender], id, 5, 690175)!; // Can't repeat the other 2 on this day nor the last 3 + const progress = (Date.now() - startMs) / 86400_000; + const countMultiplier = isInfestationOutbreak || rng.randomInt(0, 1) ? -1 : 1; // if defender is winning, count is negative + const fiftyPercent = rng.randomInt(1000, 29000); // introduce some 'yitter' for the percentages + const rewardFloat = rng.randomFloat(); + const rewardTier = rewardFloat < 0.201 ? "RARE" : rewardFloat < 0.7788 ? "COMMON" : "UNCOMMON"; + const attackerReward: IMissionReward = {}; + const defenderReward: IMissionReward = {}; + if (isInfestationOutbreak) { + defenderReward.countedItems = [ + rng.randomElement(invasionRewards[rng.randomInt(0, 1) ? "FC_INFESTATION" : defender][rewardTier])! + ]; + } else { + attackerReward.countedItems = [rng.randomElement(invasionRewards[attacker][rewardTier])!]; + defenderReward.countedItems = [rng.randomElement(invasionRewards[defender][rewardTier])!]; + } + return { + _id: { $oid: oid }, + Faction: attacker, + DefenderFaction: defender, + Node: node, + Count: Math.round( + (progress < 0.5 ? progress * 2 * fiftyPercent : fiftyPercent + (30_000 - fiftyPercent) * (progress - 0.5)) * + countMultiplier + ), + Goal: 30000, // Value seems to range from 30000 to 98000 in intervals of 1000. Higher values are increasingly rare. I don't think this is relevant for the frontend besides dividing count by it. + LocTag: isInfestationOutbreak + ? ExportRegions[node].missionIndex == 0 + ? "/Lotus/Language/Menu/InfestedInvasionBoss" + : "/Lotus/Language/Menu/InfestedInvasionGeneric" + : attacker == "FC_CORPUS" + ? "/Lotus/Language/Menu/CorpusInvasionGeneric" + : "/Lotus/Language/Menu/GrineerInvasionGeneric", + Completed: startMs + 86400_000 < Date.now(), // Sorta unfaithful. Invasions on live are (at least in part) in fluenced by people completing them. And otherwise also probably not hardcoded to last 24 hours. + ChainID: { $oid: oid }, + AttackerReward: attackerReward, + AttackerMissionInfo: { + seed: rng.randomInt(0, 1_000_000), + faction: defender + }, + DefenderReward: defenderReward, + DefenderMissionInfo: { + seed: rng.randomInt(0, 1_000_000), + faction: attacker + }, + Activation: { + $date: { + $numberLong: startMs.toString() + } + } + }; +}; + +export const getInvasionByOid = (oid: string): IInvasion | undefined => { + const arr = oid.split("fd148cb8"); + if (arr.length == 2 && arr[0].length == 8 && arr[1].length == 8) { + return createInvasion(idToDay(oid), parseInt(arr[1], 16)); + } + return undefined; +}; + export const getWorldState = (buildLabel?: string): IWorldState => { const constraints: ITimeConstraint[] = []; if (config.worldState?.eidolonOverride) { @@ -1275,6 +1350,7 @@ export const getWorldState = (buildLabel?: string): IWorldState => { LiteSorties: [], ActiveMissions: [], GlobalUpgrades: [], + Invasions: [], VoidTraders: [], PrimeVaultTraders: [], VoidStorms: [], @@ -1477,6 +1553,20 @@ export const getWorldState = (buildLabel?: string): IWorldState => { }); } + // Rough outline of dynamic invasions. + // TODO: Invasions chains, e.g. an infestation mission would soon lead to other nodes on that planet also having an infestation invasion. + // TODO: Grineer/Corpus to fund their death stars with each invasion win. + { + worldState.Invasions.push(createInvasion(day, 0)); + worldState.Invasions.push(createInvasion(day, 1)); + worldState.Invasions.push(createInvasion(day, 2)); + + // Completed invasions stay for up to 24 hours as the winner 'occupies' that node + worldState.Invasions.push(createInvasion(day - 1, 0)); + worldState.Invasions.push(createInvasion(day - 1, 1)); + worldState.Invasions.push(createInvasion(day - 1, 2)); + } + // Baro { const baroIndex = Math.trunc((Date.now() - 910800000) / (unixTimesInMs.day * 14)); diff --git a/src/types/worldStateTypes.ts b/src/types/worldStateTypes.ts index b83b787c..e52e613f 100644 --- a/src/types/worldStateTypes.ts +++ b/src/types/worldStateTypes.ts @@ -12,6 +12,7 @@ export interface IWorldState { SyndicateMissions: ISyndicateMissionInfo[]; ActiveMissions: IFissure[]; GlobalUpgrades: IGlobalUpgrade[]; + Invasions: IInvasion[]; NodeOverrides: INodeOverride[]; VoidTraders: IVoidTrader[]; PrimeVaultTraders: IPrimeVaultTrader[]; @@ -82,6 +83,28 @@ export interface IGlobalUpgrade { LocalizeDescTag: string; } +export interface IInvasion { + _id: IOid; + Faction: string; + DefenderFaction: string; + Node: string; + Count: number; + Goal: number; + LocTag: string; + Completed: boolean; + ChainID: IOid; + AttackerReward: IMissionReward; + AttackerMissionInfo: IInvasionMissionInfo; + DefenderReward: IMissionReward; + DefenderMissionInfo: IInvasionMissionInfo; + Activation: IMongoDate; +} + +export interface IInvasionMissionInfo { + seed: number; + faction: string; +} + export interface IFissure { _id: IOid; Region: number; diff --git a/static/fixed_responses/worldState/invasionNodes.json b/static/fixed_responses/worldState/invasionNodes.json new file mode 100644 index 00000000..47429eac --- /dev/null +++ b/static/fixed_responses/worldState/invasionNodes.json @@ -0,0 +1,114 @@ +{ + "FC_CORPUS": [ + "SettlementNode1", + "SettlementNode2", + "SettlementNode3", + "SettlementNode11", + "SettlementNode12", + "SettlementNode14", + "SettlementNode15", + "SettlementNode20", + "SolNode1", + "SolNode2", + "SolNode4", + "SolNode6", + "SolNode10", + "SolNode17", + "SolNode21", + "SolNode22", + "SolNode23", + "SolNode25", + "SolNode38", + "SolNode43", + "SolNode48", + "SolNode49", + "SolNode51", + "SolNode53", + "SolNode56", + "SolNode57", + "SolNode61", + "SolNode62", + "SolNode65", + "SolNode66", + "SolNode72", + "SolNode73", + "SolNode74", + "SolNode76", + "SolNode78", + "SolNode81", + "SolNode84", + "SolNode88", + "SolNode97", + "SolNode100", + "SolNode101", + "SolNode102", + "SolNode104", + "SolNode107", + "SolNode109", + "SolNode118", + "SolNode121", + "SolNode123", + "SolNode125", + "SolNode126", + "SolNode127", + "SolNode128", + "SolNode203", + "SolNode205", + "SolNode209", + "SolNode210", + "SolNode211", + "SolNode212", + "SolNode214", + "SolNode216", + "SolNode217", + "SolNode220" + ], + "FC_GRINEER": [ + "SolNode11", + "SolNode16", + "SolNode18", + "SolNode19", + "SolNode20", + "SolNode30", + "SolNode31", + "SolNode32", + "SolNode36", + "SolNode41", + "SolNode42", + "SolNode45", + "SolNode46", + "SolNode50", + "SolNode58", + "SolNode67", + "SolNode68", + "SolNode70", + "SolNode82", + "SolNode93", + "SolNode96", + "SolNode99", + "SolNode106", + "SolNode113", + "SolNode131", + "SolNode132", + "SolNode135", + "SolNode137", + "SolNode138", + "SolNode139", + "SolNode140", + "SolNode141", + "SolNode144", + "SolNode146", + "SolNode147", + "SolNode149", + "SolNode177", + "SolNode181", + "SolNode184", + "SolNode185", + "SolNode187", + "SolNode188", + "SolNode189", + "SolNode191", + "SolNode195", + "SolNode196" + ] +} diff --git a/static/fixed_responses/worldState/invasionRewards.json b/static/fixed_responses/worldState/invasionRewards.json new file mode 100644 index 00000000..bfbd03d3 --- /dev/null +++ b/static/fixed_responses/worldState/invasionRewards.json @@ -0,0 +1,190 @@ +{ + "FC_GRINEER": { + "COMMON": [ + { + "ItemType": "/Lotus/Types/Items/Research/ChemComponent", + "ItemCount": 3 + } + ], + "UNCOMMON": [ + { + "ItemType": "/Lotus/Types/Recipes/Weapons/KarakWraithBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/KarakWraithBarrel", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/KarakWraithReceiver", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/KarakWraithStock", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/StrunWraithBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/StrunWraithBarrel", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/StrunWraithReceiver", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/StrunWraithStock", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/LatronWraithBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/LatronWraithBarrel", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/LatronWraithReceiver", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/LatronWraithStock", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/TwinVipersWraithBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/TwinVipersWraithBarrel", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/TwinVipersWraithLink", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/TwinVipersWraithReceiver", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/GrineerCombatKnifeSortieBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/GrineerCombatKnifeHilt", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/GrineerCombatKnifeBlade", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/GrineerCombatKnifeHeatsink", + "ItemCount": 1 + } + ], + "RARE": [ + { + "ItemType": "/Lotus/Types/Recipes/Components/OrokinCatalystBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Components/OrokinReactorBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Components/FormaBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Components/UtilityUnlockerBlueprint", + "ItemCount": 1 + } + ] + }, + "FC_CORPUS": { + "COMMON": [ + { + "ItemType": "/Lotus/Types/Items/Research/EnergyComponent", + "ItemCount": 3 + } + ], + "UNCOMMON": [ + { + "ItemType": "/Lotus/Types/Recipes/Weapons/DeraVandalBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/DeraVandalBarrel", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/DeraVandalReceiver", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/DeraVandalStock", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/SnipetronVandalBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/SnipetronVandalStock", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/SnipetronVandalReceiver", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Weapons/WeaponParts/SnipetronVandalBarrel", + "ItemCount": 1 + } + ], + "RARE": [ + { + "ItemType": "/Lotus/Types/Recipes/Components/OrokinCatalystBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Components/OrokinReactorBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Components/FormaBlueprint", + "ItemCount": 1 + }, + { + "ItemType": "/Lotus/Types/Recipes/Components/UtilityUnlockerBlueprint", + "ItemCount": 1 + } + ] + }, + "FC_INFESTATION": { + "COMMON": [ + { + "ItemType": "/Lotus/Types/Items/Research/BioComponent", + "ItemCount": 1 + } + ], + "UNCOMMON": [ + { + "ItemType": "/Lotus/Types/Items/Research/BioComponent", + "ItemCount": 2 + } + ], + "RARE": [ + { + "ItemType": "/Lotus/Types/Items/MiscItems/InfestedAladCoordinate", + "ItemCount": 1 + } + ] + } +} \ No newline at end of file diff --git a/static/fixed_responses/worldState/worldState.json b/static/fixed_responses/worldState/worldState.json index 1b849391..8c04c4a2 100644 --- a/static/fixed_responses/worldState/worldState.json +++ b/static/fixed_responses/worldState/worldState.json @@ -117,46 +117,6 @@ ] } }, - "Invasions": [ - { - "_id": { - "$oid": "67c8ec8b3d0d86b236c1c18f" - }, - "Faction": "FC_INFESTATION", - "DefenderFaction": "FC_CORPUS", - "Node": "SolNode53", - "Count": -28558, - "Goal": 30000, - "LocTag": "/Lotus/Language/Menu/InfestedInvasionBoss", - "Completed": false, - "ChainID": { - "$oid": "67c8b6a2bde0dfd0f7c1c18d" - }, - "AttackerReward": [], - "AttackerMissionInfo": { - "seed": 488863, - "faction": "FC_CORPUS" - }, - "DefenderReward": { - "countedItems": [ - { - "ItemType": "/Lotus/Types/Items/Research/EnergyComponent", - "ItemCount": 3 - } - ] - }, - "DefenderMissionInfo": { - "seed": 127653, - "faction": "FC_INFESTATION", - "missionReward": [] - }, - "Activation": { - "$date": { - "$numberLong": "1741221003031" - } - } - } - ], "SyndicateMissions": [ { "_id": { "$oid": "663a4fc5ba6f84724fa4804c" },