From bba8e22023cb36d7842653c32050266f1c76ceaa Mon Sep 17 00:00:00 2001 From: Alain Bryden <2285037+alainbryden@users.noreply.github.com> Date: Fri, 25 Oct 2024 13:18:40 -0300 Subject: [PATCH] Refactor for better typing, and better output to support #403 - Big refactor of faction manager to give better type hints, which should result in fewer bugs. - Changed the output text to support #403 --- faction-manager.js | 420 ++++++++++++++++++++++++++++----------------- 1 file changed, 267 insertions(+), 153 deletions(-) diff --git a/faction-manager.js b/faction-manager.js index 69d573a3..60da6ff5 100644 --- a/faction-manager.js +++ b/faction-manager.js @@ -14,20 +14,23 @@ const default_desired_augs = ["CashRoot Starter Kit"] // By default, mark these // If not in a gang, and we are nearing unlocking gangs (54K Karma) we will attempt to join any/all of these factions const potentialGangFactions = ["Slum Snakes", "Tetrads", "The Black Hand", "The Syndicate", "The Dark Army", "Speakers for the Dead"]; const default_hidden_stats = ['bladeburner', 'hacknet']; // Hide from the summary table by default because they clearly all come from one faction. -const output_file = "/Temp/affordable-augs.txt"; +const output_file = "/Temp/affordable-augs.txt"; // Temp file produced for autopilot.js to relay information about current owned & affordable augs. const staneksGift = "Stanek's Gift - Genesis"; const factionsWithoutDonation = ["Bladeburners", "Church of the Machine God", "Shadows of Anarchy"]; // Not allowed to donate to these factions for rep // Factors used in calculations const nfCountMult = 1.14; // Factors that control how neuroflux prices scale let augCountMult = 1.9; // The multiplier for the cost increase of augmentations (changes based on SF11 level) -let favorToDonate; // Based on the current BitNode Multipliers, the favour required to donate to factions for reputation. -// Various globals because this script does not do modularity well -let playerData = null, bitNode = 0, gangFaction = null; -let augsAwaitingInstall, startingPlayerMoney, stockValue = 0; // If the player holds stocks, their liquidation value will be determined -let factionNames = [], joinedFactions = [], desiredStatsFilters = [], purchaseFactionDonations = []; -let ownedAugmentations = [], simulatedOwnedAugmentations = [], effectiveSourceFiles = [], allAugStats = [], priorityAugs = [], purchaseableAugs = []; -let factionData = {}, augmentationData = {}; +let favorToDonate = 0; // Based on the current BitNode Multipliers, the favour required to donate to factions for reputation. +// Various globals because this script does not do modularity well. Assigned values are all ignored, just used to get type hints +let playerData = (/**@returns{Player}*/() => null)(), bitNode = 0, gangFaction = ""; +let numAugsAwaitingInstall = 0, startingPlayerMoney = 0, stockValue = 0; // If the player holds stocks, their liquidation value will be determined +let factionNames = [""], joinedFactions = [""], desiredAugs = [""], desiredStatsFilters = [""], purchaseFactionDonations = []; +let ownedAugmentations = [""], simulatedOwnedAugmentations = [""], allAugStats = [""], priorityAugs = [""]; +let effectiveSourceFiles = (/**@returns {{[bitNode: number]: number}}*/() => ({}))(); +let factionData = (/**@returns {{[factionName: string]: FactionData}}*/() => ({}))(); +let augmentationData = (/**@returns {{[augmentationName: string]: AugmentationData}}*/() => ({}))(); +let purchaseableAugs = (/**@returns {AugmentationData[]}*/() => [])(); let bitNodeMults = (/**@returns{BitNodeMultipliers}*/() => undefined)(); let printToTerminal, ignorePlayerData; let _ns; // Used to avoid passing ns to functions that don't need it except for some logs. @@ -99,8 +102,8 @@ export async function main(ns) { // Ensure all globals are reset before we proceed with the script, in case we've done things out of order augCountMult = favorToDonate = playerData = gangFaction = startingPlayerMoney = stockValue = null; - factionNames = [], joinedFactions = [], desiredStatsFilters = [], purchaseFactionDonations = []; - ownedAugmentations = [], simulatedOwnedAugmentations = [], effectiveSourceFiles = [], allAugStats = [], priorityAugs = [], purchaseableAugs = []; + factionNames = [], joinedFactions = [], desiredAugs = [], desiredStatsFilters = [], purchaseFactionDonations = []; + ownedAugmentations = [], simulatedOwnedAugmentations = [], effectiveSourceFiles = {}, allAugStats = [], priorityAugs = [], purchaseableAugs = []; factionData = {}, augmentationData = {}, bitNodeMults = {}; printToTerminal = (options.v || options.verbose === true || options.verbose === null) && !options['join-only']; @@ -111,7 +114,7 @@ export async function main(ns) { priorityAugs = options['priority-aug']?.map(f => f.replaceAll("_", " ")); if (priorityAugs.length == 0) priorityAugs = default_priority_augs; // Set up "desired augs" to always include in our purhase order (but with standard priority). Should include priority-augs as well - let desiredAugs = options['aug-desired'].map(f => f.replaceAll("_", " ")); + desiredAugs = options['aug-desired'].map(f => f.replaceAll("_", " ")); if (desiredAugs.length == 0) desiredAugs = default_desired_augs; desiredAugs = priorityAugs.concat(desiredAugs); @@ -133,7 +136,7 @@ export async function main(ns) { // Collect information about the player const gangInfo = await getGangInfo(ns); - gangFaction = gangInfo ? gangInfo.faction : false; + gangFaction = gangInfo ? gangInfo.faction : null; favorToDonate = await getNsDataThroughFile(ns, 'ns.getFavorToDonate()'); startingPlayerMoney = playerData.money; stockValue = options['ignore-stocks'] ? 0 : await getStocksValue(ns); @@ -141,8 +144,9 @@ export async function main(ns) { log(ns, 'In factions: ' + joinedFactions); // Get owned augmentations (whether they've been installed or not). Ignore strNF because you can always buy more. ownedAugmentations = await getNsDataThroughFile(ns, 'ns.singularity.getOwnedAugmentations(true)', '/Temp/player-augs-purchased.txt'); - const installedAugmentations = await getNsDataThroughFile(ns, 'ns.singularity.getOwnedAugmentations()', '/Temp/player-augs-installed.txt'); - augsAwaitingInstall = ownedAugmentations.length - installedAugmentations.length; + const installedAugmentations = (/**@returns {string[]}*/() => null)() ?? + await getNsDataThroughFile(ns, 'ns.singularity.getOwnedAugmentations()', '/Temp/player-augs-installed.txt'); + numAugsAwaitingInstall = ownedAugmentations.length - installedAugmentations.length; if (options['neuroflux-disabled']) omitAugs.push(strNF); simulatedOwnedAugmentations = ignorePlayerData ? [] : ownedAugmentations.filter(a => a != strNF); // Clear "priority" / "desired" lists of any augs we already own @@ -160,7 +164,7 @@ export async function main(ns) { log(ns, 'Getting all faction data...'); await updateFactionData(ns, options['ignore-faction'].map(f => f.replaceAll("_", " "))); log(ns, 'Getting all augmentation data...'); - await updateAugmentationData(ns, desiredAugs); + await updateAugmentationData(ns); // Join available factions that would give access to additional desired augmentations if (ignorePlayerData) @@ -168,9 +172,16 @@ export async function main(ns) { else { log(ns, 'Joining available factions...'); let forceJoinFactions = options['force-join'] || []; - if (!forceJoinFactions && !gangFaction && 2 in ownedSourceFiles && ns.heart.break() <= -53000) { - forceJoinFactions.push(potentialGangFactions); // Try to join all gang factions as we near unlocking gangs, regardless of their augmentations - log(ns, `INFO: Will join any gang faction because Karma is at ${formatNumberShort(ns.heart.break())}`, printToTerminal, printToTerminal ? 'info' : undefined); + // If the user didn't set the 'force-join' option, there are some defaults we should apply + if (!forceJoinFactions) { + // If we're in BN 10, we can purchase special Sleeve-related things from the Covenant, so we should always join it + if (bitNode == 10) + forceJoinFactions.push("The Covenant"); + // If gangs are an available feature, we should by default want to join any available gang factions + if (!gangFaction && 2 in ownedSourceFiles && ns.heart.break() <= -53000) { + forceJoinFactions.push(potentialGangFactions); // Try to join all gang factions as we near unlocking gangs, regardless of their augmentations + log(ns, `INFO: Will join any gang faction because Karma is at ${formatNumberShort(ns.heart.break())}`, printToTerminal, printToTerminal ? 'info' : undefined); + } } let joined = await joinFactions(ns, forceJoinFactions); if (joined) log(ns, `SUCCESS: Joined ${joined} factions.`); @@ -194,16 +205,36 @@ export async function main(ns) { `prevent you from doing so for the rest of this BN. (Run with '--ignore-stanek' to bypass this warning.)`, true); else if (options.purchase && purchaseableAugs) { await purchaseDesiredAugs(ns); - await ns.write(output_file, "", "w"); // Clear the file so it isn't misinterpreted on next reset. - } else if (!ignorePlayerData) // Write a temp file that summarizes what augs we could afford if we could ascend right now. - await ns.write(output_file, JSON.stringify({ - affordable_nf_count: purchaseableAugs.filter(a => a.name == strNF).length, - affordable_augs: [...new Set(purchaseableAugs.map(a => a.name))], - owned_count: Object.values(augmentationData).filter(a => a.owned).length, - unowned_count: Object.values(augmentationData).filter(a => !a.owned).length, - total_rep_cost: Object.values(purchaseFactionDonations).reduce((t, r) => t + r, 0), - total_aug_cost: getTotalCost(purchaseableAugs), - }), "w"); + ns.write(output_file, "", "w"); // Clear the file so it isn't misinterpreted on next reset. + } else if (!ignorePlayerData) { // Don't do this next part if we were "mocking" the player for this run + // Write a file that summarizes what augs we could afford if we could ascend right now. (used by autopilot.js) + ns.write(output_file, JSON.stringify({ + // Augs we already have installed + installed_augs: installedAugmentations, // Names of augs we've installed (which may include duplicates of neuroflux) + installed_count: installedAugmentations.length, // Number of augs we've installed + installed_count_nf: installedAugmentations.filter(a => a.name == strNF).length, // Count of neuroflux levels we have installed + installed_count_ex_nf: installedAugmentations.filter(a => a.name != strNF).length, // Count of non-neuroflux augs installed + // Augs we have purchased, but perhaps not installed + owned_augs: ownedAugmentations, // Names of augs we've installed (which may include duplicates of neuroflux) + owned_count: ownedAugmentations.length, // Total number of augs we've purchased (some of which may not be installed) + owned_count_nf: ownedAugmentations.filter(a => a.name == strNF).length, // Count of neuroflux levels we have purchased (some of which may not be installed) + owned_count_ex_nf: ownedAugmentations.filter(a => a.name != strNF).length, // Count augmentations we have purchased (some of which may not be installed) + // Augs awaiting installation (to be explicit about what's installed vs purchased) + awaiting_install_augs: ownedAugmentations.slice(installedAugmentations.length), // Assumes augs are returned in purchased order + awaiting_install_count: numAugsAwaitingInstall, // Number of augmentations awaiting installation + awaiting_install_count_nf: ownedAugmentations.filter(a => a.name == strNF).length, // Count of neuroflux levels purchased + awaiting_install_count_ex_nf: ownedAugmentations.filter(a => a.name != strNF).length, // Count of non-neuroflux augs purchased + // Augs we want to purchase + affordable_augs: purchaseableAugs.map(a => a.name), // List of aug names we can currently afford to buy + affordable_count: purchaseableAugs.length, // Count of augmentations we can currently install + affordable_count_nf: purchaseableAugs.filter(a => a.name == strNF).length, // Count of neuroflux levels we can currently afford to buy + affordable_count_ex_nf: purchaseableAugs.filter(a => a.name != strNF).length, // Count of unique augs we can currently afford to buy, ignoring neuroflux + total_rep_cost: Object.values(purchaseFactionDonations).reduce((t, r) => t + r, 0), // Total money needed to buy the reputation needed for all affordable augs + total_aug_cost: getTotalCost(purchaseableAugs), // Total money needed to buy all affordable augs (excludes the rep cost above) + // Unpurchased augs + unowned_count: Object.values(augmentationData).filter(a => !a.owned).length, // Number of augs are we have not yet purchased (note: depending on config, may not include all augs in the game) + }, undefined, 2), "w"); + } } /** @param {NS} ns @@ -239,19 +270,25 @@ let factionSortValue = faction => { return preferredIndex == -1 ? 99 : preferredIndex; }; -// Ram-dodging helper, runs a command for all items in a list and returns a dictionary. +/** Ram-dodging helper, runs a command for all items in a list and returns a dictionary. + * @returns {string} */ const dictCommand = (command) => `Object.fromEntries(ns.args.map(o => [o, ${command}]))`; -// Get a dictionary from retrieving the same infromation for every server name +/** Get a dictionary from retrieving the same infromation for every server name + * @param {NS} ns + * @param {any[]} listItems + * @returns {Promise<{[k: string]: any}>} */ async function getSingularityDict(ns, command, listItems) { return await getNsDataThroughFile(ns, dictCommand(`ns.singularity.${command}(o)`), `/Temp/singularity-${command}-all.txt`, listItems); } -/** @param {NS} ns **/ +/** @param {NS} ns + * @param {string[]} factionsToOmit **/ async function updateFactionData(ns, factionsToOmit) { // Gather a list of all faction names to collect information about. Start with any player joined and invited factions - const invitations = await getNsDataThroughFile(ns, 'ns.singularity.checkFactionInvitations()'); + const invitations = (/**@returns {string[]}*/() => null)() ?? + await getNsDataThroughFile(ns, 'ns.singularity.checkFactionInvitations()'); factionNames = joinedFactions.concat(invitations); // Add in factions the user hasn't seen. All factions by default, or a small subset of easy-access factions if --hide-locked-factions is set factionNames.push(...(options['hide-locked-factions'] ? easyAccessFactions : allFactions).filter(f => !factionNames.includes(f))); @@ -263,105 +300,176 @@ async function updateFactionData(ns, factionsToOmit) { // Finally, remove all factions marked as omitted log(ns, `We "know" about ${factionNames.length} factions, and will omit ${factionsToOmit.length} of them.`); factionNames = factionNames.filter(f => !factionsToOmit.includes(f)); - - let dictFactionAugs = await getSingularityDict(ns, 'getAugmentationsFromFaction', factionNames); - let dictFactionReps = await getSingularityDict(ns, 'getFactionRep', factionNames); - let dictFactionFavors = await getSingularityDict(ns, 'getFactionFavor', factionNames); + // Force-feed typescript information about the type of these dictionaries retrieved via ram-dodging + const dictFactionAugs = (/**@returns {{[factionName: string]: string[]}}*/() => null)() ?? + await getSingularityDict(ns, 'getAugmentationsFromFaction', factionNames); + const dictFactionReps = (/**@returns {{[factionName: string]: number}}*/() => null)() ?? + await getSingularityDict(ns, 'getFactionRep', factionNames); + const dictFactionFavors = (/**@returns {{[factionName: string]: number}}*/() => null)() ?? + await getSingularityDict(ns, 'getFactionFavor', factionNames); // Need information about our gang to work around a TRP bug - gang faction appears to have it available, but it's not (outside of BN2) if (gangFaction && bitNode != 2) dictFactionAugs[gangFaction] = dictFactionAugs[gangFaction]?.filter(a => a != "The Red Pill"); - factionData = Object.fromEntries(factionNames.map(faction => [faction, { - name: faction, - invited: invitations.includes(faction), - joined: joinedFactions.includes(faction), - reputation: dictFactionReps[faction] || 0, - favor: dictFactionFavors[faction], - donationsUnlocked: dictFactionFavors[faction] >= favorToDonate && + factionData = Object.fromEntries(factionNames.map(faction => [faction, new FactionData( + faction, invitations.includes(faction), joinedFactions.includes(faction), dictFactionReps[faction], dictFactionFavors[faction], dictFactionAugs[faction] + )])); +} + +/** Custom class with all faction data we care to gather, plus some helper functions. */ +class FactionData { + /** @param {string} faction The faction name + * @param {boolean} invited Whether we have an invitation to this faction + * @param {boolean} joined Whether we have an already joined this faction + * @param {number} factionRep The amount of reputation we have with this faction + * @param {number} factionFavor The amount of faction favour we have with this faction + * @param {string[]} augmentationNames The names of all augmentations offered by this faction **/ + constructor(faction, invited, joined, factionRep, factionFavor, augmentationNames) { + this.name = faction; + this.invited = invited; + this.joined = joined; + this.reputation = factionRep || 0; + this.favor = factionFavor; + this.donationsUnlocked = factionFavor >= favorToDonate && // As a rule, cannot donate to gang factions or any of the below factions - need to use other mechanics to gain rep. - ![gangFaction, ...factionsWithoutDonation].includes(faction), - augmentations: dictFactionAugs[faction], - unownedAugmentations: function (includeNf = false) { return this.augmentations.filter(aug => !simulatedOwnedAugmentations.includes(aug) && (aug != strNF || includeNf)) }, - mostExpensiveAugCost: function () { return this.augmentations.map(augName => augmentationData[augName]).reduce((max, aug) => Math.max(max, aug.price), 0) }, - totalUnownedMults: function () { - return this.unownedAugmentations().map(augName => augmentationData[augName]) - .reduce((arr, aug) => Object.keys(aug.stats).forEach(stat => arr[stat] = ((arr[stat] || 1) * aug.stats[stat])) || arr, new Map); - } - }])); + ![gangFaction, ...factionsWithoutDonation].includes(faction); + this.augmentations = augmentationNames; + } + /** @param {boolean} includeNf Whether to include neuroflux (generally offered by all factions) in the list of augmentations offered. + * @returns {string[]} A list of augmentations we don't own that are offered by this faction */ + unownedAugmentations(includeNf = false) { + return this.augmentations.filter(aug => !simulatedOwnedAugmentations.includes(aug) && (aug != strNF || includeNf)) + } + /** @returns {number} The most cost (monetary) of the most expensive augmentation offered by this faction. */ + mostExpensiveAugCost() { + return this.augmentations.map(augName => augmentationData[augName]).reduce((max, aug) => Math.max(max, aug.price), 0) + } + /** @returns {Map} */ + totalUnownedMults() { + return this.unownedAugmentations().map(augName => augmentationData[augName]) + .reduce((arr, aug) => Object.keys(aug.stats).forEach(stat => arr[stat] = ((arr[stat] || 1) * aug.stats[stat])) || arr, new Map); + } } -/** @param {NS} ns **/ -async function updateAugmentationData(ns, desiredAugs) { +/** Updates the global "augmentationData" property with information about every augmentation. + * @param {NS} ns **/ +async function updateAugmentationData(ns) { const augmentationNames = [...new Set(Object.values(factionData).flatMap(f => f.augmentations))]; // augmentations.slice(); - const dictAugRepReqs = await getSingularityDict(ns, 'getAugmentationRepReq', augmentationNames); - const dictAugPrices = await getSingularityDict(ns, 'getAugmentationPrice', augmentationNames); - const dictAugStats = await getSingularityDict(ns, 'getAugmentationStats', augmentationNames); - const dictAugPrereqs = await getSingularityDict(ns, 'getAugmentationPrereq', augmentationNames); - augmentationData = Object.fromEntries(augmentationNames.map(aug => [aug, { - name: aug, - displayName: aug, - owned: simulatedOwnedAugmentations.includes(aug), - reputation: dictAugRepReqs[aug], - price: dictAugPrices[aug], - stats: Object.fromEntries(Object.entries(dictAugStats[aug]).filter(([k, v]) => v != 1)), - prereqs: dictAugPrereqs[aug] || [], - desired: desiredAugs.includes(aug) || // Mark as "desired" augs explicitly requested, or those with stats in the 'stat-desired' command line options + // Force-feed typescript information about the type of these dictionaries retrieved via ram-dodging + const dictAugRepReqs = (/**@returns {{[augmentationName: string]: number}}*/() => null)() ?? + await getSingularityDict(ns, 'getAugmentationRepReq', augmentationNames); + const dictAugPrices = (/**@returns {{[augmentationName: string]: number}}*/() => null)() ?? + await getSingularityDict(ns, 'getAugmentationPrice', augmentationNames); + const dictAugStats = (/**@returns {{[augmentationName: string]: Multipliers}}*/() => null)() ?? + await getSingularityDict(ns, 'getAugmentationStats', augmentationNames); + const dictAugPrereqs = (/**@returns {{[augmentationName: string]: string[]}}*/() => null)() ?? + await getSingularityDict(ns, 'getAugmentationPrereq', augmentationNames); + // Create a new dictionary of augmentation data by augmentation name + augmentationData = Object.fromEntries(augmentationNames.map(aug => [aug, new AugmentationData( + aug, dictAugRepReqs[aug], dictAugPrices[aug], dictAugStats[aug], dictAugPrereqs[aug] + )])); + /** Helper function which will propagate the "desired" (priority) status to any dependencies of desired augs. + * Note when --all-factions mode is not enabled, it's possible some prereqs will be missing from our list + * @param {AugmentationData} aug */ + function propagateDesired(aug) { + if (!aug.desired && !aug.prereqs) return; + aug.prereqs.forEach(prereqName => { + let pa = augmentationData[prereqName]; + if (!pa) return log(ns, `WARNING: Missing info about aug ${aug.name} prerequisite ${prereqName}. We likely don't have access.`); + if (pa.owned) return; + if (!pa.desired) { + log(ns, `INFO: Promoting aug "${prereqName}" to "desired" status, because desired aug "${aug.name}" depends on it.`); + pa.desired = true; + } // Also propagate the "priority" status to any dependencies of priority augs (dependency must be made a higher priority) + if (priorityAugs.includes(aug.name) && !priorityAugs.includes(prereqName)) { + log(ns, `INFO: Promoting aug "${prereqName}" to "priority" status, because priority aug "${aug.name}" depends on it.`, true); + priorityAugs.splice(priorityAugs.indexOf(aug.name), 0, prereqName); + } + propagateDesired(pa); // Recurse on any nested prerequisites of this prerequisite aug. + }) + } + const allAugmentations = Object.values(augmentationData); + allAugmentations.forEach(a => propagateDesired(a)); + // Prepare a collection of all augmentations' statistics + allAugStats = allAugmentations.flatMap(aug => Object.keys(aug.stats)).filter((v, i, a) => a.indexOf(v) === i).sort(); +} + +/** Custom class with all augmentation data we care to gather, plus some helper functions. */ +class AugmentationData { + /** @param {string} aug The augmentation name + * @param {number} reputationRequirement The required reputation to unlock this augmentation (it's the same for all factions that carry it) + * @param {number} price The cost (money) of this augmentation + * @param {Multipliers} augmentationStats The stats granted if this augmentation is installed. + * @param {string[]} augmentationPrereqs The names of all augmentations which must be installed before this one. **/ + constructor(aug, reputationRequirement, price, augmentationStats, augmentationPrereqs) { + this.name = aug; + this.displayName = aug; + this.owned = simulatedOwnedAugmentations.includes(aug); + this.reputation = reputationRequirement; + this.price = price; + /** The stats for this augmentation, except that all properties with a value of 1.0 have been stripped out. @type {Multipliers} */ + this.stats = Object.fromEntries(Object.entries(augmentationStats).filter(([k, v]) => v != 1)); + this.prereqs = augmentationPrereqs || []; + this.desired = desiredAugs.includes(aug) || // Mark as "desired" augs explicitly requested, or those with stats in the 'stat-desired' command line options desiredStatsFilters.includes('*') || desiredStatsFilters.includes('_') || // Wildcards - all stats are desired (_ is for backwards compatibility when all stat names ended with '_mult') - Object.entries(dictAugStats[aug]).some(([k, v]) => v != 1 && desiredStatsFilters.some(filter => k.includes(filter))), + Object.entries(augmentationStats).some(([k, v]) => v != 1 && desiredStatsFilters.some(filter => k.includes(filter))); // Get the name of the "most-early-game" faction from which we can buy this augmentation. Estimate this by cost of the most expensive aug the offer - getFromAny: factionNames.map(f => factionData[f]).sort((a, b) => a.mostExpensiveAugCost - b.mostExpensiveAugCost) - .filter(f => f.augmentations.includes(aug))[0]?.name ?? "(unknown)", - // Get a list of joined factions that have this augmentation - joinedFactionsWithAug: function () { - return factionNames.map(f => factionData[f]).filter(f => f.joined && f.augmentations.includes(this.name)); - }, - // Whether there is some joined faction which already has enough reputation to buy this augmentation - canAfford: function () { return this.joinedFactionsWithAug().some(f => f.reputation >= this.reputation); }, - canAffordWithDonation: function () { return this.joinedFactionsWithAug().some(f => f.donationsUnlocked); }, - // Get the name of the **joined** faction from which we can buy this augmentation (sorted by which is closest to being able to afford it, then by most preferred) - getFromJoined: function () { - return (this.joinedFactionsWithAug().filter(f => f.reputation >= this.reputation)[0] || - this.joinedFactionsWithAug().filter(f => f.donationsUnlocked).sort((a, b) => getReqDonationForAug(this, a) - getReqDonationForAug(this, b))[0] || - this.joinedFactionsWithAug()[0])?.name; - }, - toString: function () { - const factionColWidth = 16, augColWidth = 40, statsColWidth = 60; - const statKeys = Object.keys(this.stats); - const statsString = `Stats:${statKeys.length.toFixed(0).padStart(2)}` + (statKeys.length == 0 ? '' : - ` { ${statKeys.map(prop => shorten(prop) + ': ' + Math.round((this.stats[prop] + Number.EPSILON) * 100) / 100).join(', ')} }`); - const factionName = this.getFromJoined() || this.getFromAny; - const fCreep = Math.max(0, factionName.length - factionColWidth); - const budget = playerData.money + stockValue; - const augNameShort = this.displayName.length <= (augColWidth - fCreep) ? this.displayName : - `${this.displayName.slice(0, Math.ceil(augColWidth / 2 - 3 - fCreep))}...${this.displayName.slice(this.displayName.length - Math.floor(augColWidth / 2))}`; - return `${this.desired ? '*' : ' '} Price: ${formatMoney(this.price, 4).padEnd(7)} ${this.price <= budget ? '✓' : '✗'} ` + - `Rep: ${formatNumberShort(this.reputation, 4).padEnd(6)} ${this.canAfford() ? '✓' : this.canAffordWithDonation() ? '$' : '✗'} ` + - `Faction: ${factionName.padEnd(factionColWidth)} Aug: ${augNameShort.padEnd(augColWidth - fCreep)} ` + - `${statsString.length <= statsColWidth ? statsString : (statsString.substring(0, statsColWidth - 4) + '... }')}`; - } - }])); - // Propagate desired/priority status to any dependencies of desired augs. Note when --all-factions mode is not enabled, it's possible some prereqs are not in our list - let propagateDesired = (aug) => !aug.desired || !aug.prereqs || aug.prereqs.forEach(prereqName => { - let pa = augmentationData[prereqName]; - if (!pa) return log(ns, `WARNING: Missing info about aug ${aug.name} prerequisite ${prereqName}. We likely don't have access.`); - if (pa.owned) return; - if (!pa.desired) { - log(ns, `INFO: Promoting aug "${prereqName}" to "desired" status, because desired aug "${aug.name}" depends on it.`); - pa.desired = true; - } // Also propagate the "priority" status to any dependencies of priority augs (dependency must be made a higher priority) - if (priorityAugs.includes(aug.name) && !priorityAugs.includes(prereqName)) { - log(ns, `INFO: Promoting aug "${prereqName}" to "priority" status, because priority aug "${aug.name}" depends on it.`, true); - priorityAugs.splice(priorityAugs.indexOf(aug.name), 0, prereqName); - } - propagateDesired(pa); // Recurse on any nested prerequisites of this prerequisite aug. - }); - Object.values(augmentationData).forEach(a => propagateDesired(a)); - // Prepare a collection of all augmentations' statistics - allAugStats = Object.values(augmentationData).flatMap(aug => Object.keys(aug.stats)).filter((v, i, a) => a.indexOf(v) === i).sort(); + this.getFromAny = factionNames.map(f => factionData[f]).sort((a, b) => a.mostExpensiveAugCost - b.mostExpensiveAugCost) + .filter(f => f.augmentations.includes(aug))[0]?.name ?? "(unknown)"; + } + /** @returns {FactionData[]} A list of joined factions that have this augmentation */ + joinedFactionsWithAug() { + return factionNames.map(f => factionData[f]).filter(f => f.joined && f.augmentations.includes(this.name)); + } + /** @returns {boolean} Whether there is some joined faction which already has enough reputation to buy this augmentation */ + canAfford() { + return this.joinedFactionsWithAug().some(f => f.reputation >= this.reputation); + } + /** @returns {boolean} Whether there is some joined faction with the augmentation that we've unlocked reputation donations with */ + canAffordWithDonation() { + return this.joinedFactionsWithAug().some(f => f.donationsUnlocked); + } + /** @returns {string} Get the name of the joined faction from which we should purchase this augmentation. */ + getFromJoined() { + // For most augmentations, choose to get the augmentation from the faction requiring the lowest cost + const augFactions = this.joinedFactionsWithAug(); + if (this.name != strNF) + return (augFactions.filter(f => f.reputation >= this.reputation)[0] || // Any faction we can buy it from + augFactions.filter(f => f.donationsUnlocked).sort((a, b) => getReqDonationForAug(this, a) - getReqDonationForAug(this, b))[0] || // Cheapest donation for aug + augFactions.sort((a, b) => b.reputation - a.reputation)[0] || // Faction we are closest to being able to get it from (most rep) + augFactions[0])?.name; // First faction in our faction list order (which should be ordered by priority) + + // The "Neuroflux" augmentation uses a different approach. + // Prefer to purchase NF first from whatever joined factions have donations unlocked (allow us to continuously donate for more), next by faction with the most current reputation. + return augFactions.sort((a, b) => // This sort order prefers factions that support donations over ones that already have sufficient rep for one or more NF levels. + ((b.donationsUnlocked ? 1 : 0) - (a.donationsUnlocked ? 1 : 0)) || (b.reputation - a.reputation))[0]?.name; + // This (disabled) sort order prefers factions that already have enough reputation to buy at least one level of NF (whether they support donations or not) + // augFactions.sort((a, b) => ((b.reputation >= this.reputation ? 1 : 0) - (a.reputation >= this.reputation ? 1 : 0)) || + // ((b.donationsUnlocked ? 1 : 0) - (a.donationsUnlocked ? 1 : 0)) || (b.reputation - a.reputation))[0]?.name; + // TODO: #145 Is there a way to first buy NF from factions that already have enough rep, before switching to a different faction that supports donations? + } + /** @returns {string} A formatted row of information for this augmentation */ + toString() { + const factionColWidth = 16, augColWidth = 40, statsColWidth = 60; + const statKeys = Object.keys(this.stats); + const statsString = `Stats:${statKeys.length.toFixed(0).padStart(2)}` + (statKeys.length == 0 ? '' : + ` { ${statKeys.map(prop => shorten(prop) + ': ' + Math.round((this.stats[prop] + Number.EPSILON) * 100) / 100).join(', ')} }`); + const factionName = this.getFromJoined() || this.getFromAny; + const fCreep = Math.max(0, factionName.length - factionColWidth); + const budget = playerData.money + stockValue; + const augNameShort = this.displayName.length <= (augColWidth - fCreep) ? this.displayName : + `${this.displayName.slice(0, Math.ceil(augColWidth / 2 - 3 - fCreep))}...${this.displayName.slice(this.displayName.length - Math.floor(augColWidth / 2))}`; + return `${this.desired ? '*' : ' '} Price: ${formatMoney(this.price, 4).padEnd(7)} ${this.price <= budget ? '✓' : '✗'} ` + + `Rep: ${formatNumberShort(this.reputation, 4).padEnd(6)} ${this.canAfford() ? '✓' : this.canAffordWithDonation() ? '$' : '✗'} ` + + `Faction: ${factionName.padEnd(factionColWidth)} Aug: ${augNameShort.padEnd(augColWidth - fCreep)} ` + + `${statsString.length <= statsColWidth ? statsString : (statsString.substring(0, statsColWidth - 4) + '... }')}`; + } } -/** @param {NS} ns **/ +/** Helper function to join any factions we have an invite to, and which have augmentations we want. + * @param {NS} ns + * @param {string[]} forceJoinFactions A list of factions to join even if they have no remaining augmentations. **/ async function joinFactions(ns, forceJoinFactions) { let manualJoin = ["Sector-12", "Chongqing", "New Tokyo", "Ishima", "Aevum", "Volhaven"]; // If we have already joined one of the "precluding" factions, we are free to join the remainder @@ -401,22 +509,30 @@ async function joinFactions(ns, forceJoinFactions) { return joined; } -/** Compute how much money must be donated to recieve the specified reputation amount. */ +/** Compute how much money must be donated to recieve the specified reputation amount. + * @param {number} rep */ let getCostOfReputation = (rep) => Math.ceil(1e6 * rep / playerData.mults.faction_rep / bitNodeMults.FactionWorkRepGain); -/** Compute how much money must be donated to the faction to attain the specified reputation amount with this faction. Takes into account the current faction rep. */ -let getReqDonationForRep = (rep_needed, faction) => getCostOfReputation(Math.max(0, rep_needed - (faction.name ? faction : factionData[faction]).reputation)); -/** Compute how much money must be donated to the faction to afford an augmentation. Faction can be either a faction object, or faction name */ -let getReqDonationForAug = (aug, faction) => getReqDonationForRep(aug.reputation, faction || aug.getFromJoined()); - +/** Compute how much money must be donated to the faction to attain the specified reputation amount with this faction. Takes into account the current faction rep. + * @param {number} rep_needed @param {FactionData|string} factionOrFactionName */ +let getReqDonationForRep = (rep_needed, factionOrFactionName) => getCostOfReputation(Math.max(0, rep_needed - (factionOrFactionName.name ? factionOrFactionName : factionData[factionOrFactionName]).reputation)); +/** Compute how much money must be donated to the faction to afford an augmentation. Faction can be either a faction object, or faction name + * @param {AugmentationData} aug @param {FactionData|string} factionOrFactionName */ +let getReqDonationForAug = (aug, factionOrFactionName) => getReqDonationForRep(aug.reputation, factionOrFactionName || aug.getFromJoined()); +/** @param {AugmentationData[]} augPurchaseOrder The augmentations we wish to purchase in order of purchase. + * @returns The total cost of purchasing all these augmentations in the specified order */ let getTotalCost = (augPurchaseOrder) => augPurchaseOrder.reduce((total, aug, i) => total + aug.price * augCountMult ** i, 0); +/** @param {AugmentationData} a @param {AugmentationData} b */ let augSortOrder = (a, b) => - // Hack: Multiple NF have to be from most least expensive to most expensive + // Hack: Multiple NF have to be from least expensive to most expensive (a.name == strNF && b.name == strNF ? a.price - b.price : 0) || (b.price - a.price) || (b.reputation - a.reputation) || (b.desired != a.desired ? (a.desired ? -1 : 1) : a.name.localeCompare(b.name)); -// Sort augmentations such that they are in order of price, except when there are prerequisites to worry about +/** Sort augmentations such that they are in order of price, except when there are prerequisites to worry about + * @param {NS} ns + * @param {AugmentationData[]} augs augmentations to sort + * @returns {AugmentationData[]} The input array of augs, which were sorted in place */ function sortAugs(ns, augs = []) { augs.sort(augSortOrder); // Bubble up prerequisites to the top @@ -462,6 +578,7 @@ function sortAugs(ns, augs = []) { } /** @param {NS} ns + * @param {string[]} ignoredAugs a list of augmentation names to ignore * Display all information about all augmentations, including lists of available / desired / affordable augmentations in their optimal purchase order. */ async function manageUnownedAugmentations(ns, ignoredAugs) { const reqDaedalusAugs = bitNodeMults.DaedalusAugsRequirement; @@ -502,8 +619,9 @@ async function manageUnownedAugmentations(ns, ignoredAugs) { (options.purchase ? '' : ' Run with the --purchase flag to make the purchase.'), printToTerminal); } -/** @param {[]} sortedAugs - * Helper to compute the total rep cost for augmentations, including the cost of donating for access. */ +/** Helper to compute the total rep cost for augmentations, including the cost of donating for access. + * @param {AugmentationData[]} sortedAugs The augmentations we're purchasing, in the order we'll puchase them + * @returns {[{[factionName: string]: number},number,number]} */ function computeCosts(sortedAugs) { const repCostByFaction = computeAugsRepReqDonationByFaction(sortedAugs); const totalRepCost = Object.values(repCostByFaction).reduce((t, r) => t + r, 0); @@ -535,8 +653,11 @@ function filterMissingPrereqs(ns, subset) { /** Helper to generate outputs for different subsets of the augmentations, each in optimal sort order * @param {NS} ns - * @param {boolean|undefined} printList - if undefined => automatically print if sort order changed - * */ + * @param {string[]} outputRows An array of strings to which we should log the cost of these augmentations, and other details as specified. + * @param {AugmentationData[]} subset A list of augmentations to include in the output. + * @param {boolean|undefined} printList Whether to print the list to the outputRows. If undefined, we will only automatically print only if the sort order changed. + * @param {boolean} + * @returns {Promise} The list of augmentations, with the requested operations performed */ async function manageFilteredSubset(ns, outputRows, subsetName, subset, printList = undefined, removeMissingPrereqs = true, reorder = true) { subset = subset.slice(); // Take a copy so we don't mess up the original array sent in. // If enabled, filter out augs who are missing prerequisites @@ -590,7 +711,7 @@ async function managePurchaseableAugs(ns, outputRows, accessibleAugs) { } let costBefore = getCostString(totalAugCost, totalRepCost); purchaseableAugs = sortAugs(ns, purchaseableAugs.filter(aug => aug !== mostExpensiveAug)); - [purchaseFactionDonations, totalRepCost, totalAugCost] = computeCosts(purchaseableAugs);; + [purchaseFactionDonations, totalRepCost, totalAugCost] = computeCosts(purchaseableAugs); let costAfter = getCostString(totalAugCost, totalRepCost); dropped.unshift({ aug: mostExpensiveAug, costBefore, costAfter }); log(ns, `Dropping aug from the purchase order: \"${mostExpensiveAug.name}\". New total cost: ${costAfter}`); @@ -604,9 +725,9 @@ async function managePurchaseableAugs(ns, outputRows, accessibleAugs) { let nextUpAug = dropped.length == 0 ? null : `Next desired aug available at:`.padEnd(37) + ` ${dropped[0].costBefore} ` + `for \"${dropped[0].aug.name}\" from "${dropped[0].aug.getFromJoined()}" (cheapest of ${dropped.length} dropped augs)` if (nextUpAug && options['neuroflux-disabled']) outputRows.push(nextUpAug); // Output this now if we will be exiting early, otherwise save for after the last table. - if (augsAwaitingInstall > 0) - outputRows.push(`WARNING: Prices all have a x ${formatNumberShort(augCountMult ** augsAwaitingInstall)} cost penalty, because ` + - `${augsAwaitingInstall} Augmentations were previously purchased but are not yet installed.`); + if (numAugsAwaitingInstall > 0) + outputRows.push(`WARNING: Prices all have a x ${formatNumberShort(augCountMult ** numAugsAwaitingInstall)} cost penalty, because ` + + `${numAugsAwaitingInstall} Augmentations were previously purchased but are not yet installed.`); if (inaccessiblePriorityAugs.length > 0) outputRows.push(`INFO: ${inaccessiblePriorityAugs.length} 'priority' augs are not yet accessible: ${inaccessiblePriorityAugs.map(n => `"${n}"`).join(", ")}`); const additionalDroppedPri = droppedPriorityAugs.filter(n => !inaccessiblePriorityAugs.includes(n)); @@ -616,21 +737,12 @@ async function managePurchaseableAugs(ns, outputRows, accessibleAugs) { // NEXT STEP: Add as many NeuroFlux levels to our purchase as we can (unless disabled) if (options['neuroflux-disabled']) return; const augNf = augmentationData[strNF]; - let nfLevel = Math.round(Math.log(augNf.price / (augCountMult ** augsAwaitingInstall) / 750000) / Math.log(1.14)); - // Prefer to purchase NF first from whatever **joined** factions can currently afford the next NF level, next from factions with donations unlocked - // (allow us to continuously donate for more), finally by faction with the most current reputation. - augNf.getFromJoined = function () { // NOTE: Must be a function (not a lambda) so that `this` is bound to the augmentation object. - // This sort order prefers factions that already have enough reputation to buy at least one level of NF (whether they support donations or not) - //return this.joinedFactionsWithAug().sort((a, b) => ((b.reputation >= this.reputation ? 1 : 0) - (a.reputation >= this.reputation ? 1 : 0)) || - // ((b.donationsUnlocked ? 1 : 0) - (a.donationsUnlocked ? 1 : 0)) || (b.reputation - a.reputation))[0]?.name; - // This sort order prefers factions that support donations over ones that already have sufficient rep for one or more NF levels. - return this.joinedFactionsWithAug().sort((a, b) => - ((b.donationsUnlocked ? 1 : 0) - (a.donationsUnlocked ? 1 : 0)) || (b.reputation - a.reputation))[0]?.name; - }; + // We can reverse-engineer our current neuroflux level by looking at its current price, and knowing its cost scales at x1.14 per level. + let nfLevel = Math.round(Math.log(augNf.price / (augCountMult ** numAugsAwaitingInstall) / 750000) / Math.log(1.14)); let getFrom = augNf.getFromJoined(); // If No currently joined factions can provide us with the next level of Neuroflux, look for the best joined **or unjoined** faction to get NF from. if (!augNf.canAfford() && !augNf.canAffordWithDonation()) { - outputRows.push(`Cannot purchase any ${strNF} because the next level requires ${formatNumberShort(augNf.reputation)} reputation, but ` + + outputRows.push(`Cannot purchase any ${strNF}. The next level (${nfLevel + 1}) requires ${formatNumberShort(augNf.reputation)} reputation, but ` + (!getFrom ? `it isn't being offered by any of our factions` : `the best faction (${getFrom}) has insufficient rep (${formatNumberShort(factionData[getFrom].reputation)}).`)); // Prefer factions that support donating for reputation, otherwise grinding rep takes a long time. const factionSort = (a, b) => ((b.donationsUnlocked ? 1 : 0) - (a.donationsUnlocked ? 1 : 0)) || (b.favor - a.favor); @@ -702,10 +814,9 @@ async function managePurchaseableAugs(ns, outputRows, accessibleAugs) { } // Otherwise, add the next NF to our purchase order, and see if we can afford any more. // TODO: Clone nf first, and possible buy from different factions as we move from ones with enough rep to ones that support donation - const nfClone = { ...augNf }; + const nextNfPrice = augNf.price * (nfCountMult ** nfPurchased); // Note this should be the base price, before scaling for number of augs purchased + const nfClone = new AugmentationData(augNf.name, nextNfRep, nextNfPrice, augNf.stats, augNf.prereqs); // { ...augNf }; nfClone.displayName += ` Level ${++nfLevel}` - nfClone.price = augNf.price * (nfCountMult ** nfPurchased); // Note this should be the base price, before scaling for number of augs purchased - nfClone.reputation = nextNfRep; // Note, insert all NF purchases after the current NF purchase, in front of all augs cheaper than the first NF purchaseableAugs.splice(purchaseableAugs.length - augsCheaperThanNF, 0, nfClone); totalAugCost += nextNfCost; @@ -722,11 +833,14 @@ async function managePurchaseableAugs(ns, outputRows, accessibleAugs) { if (nextUpNf) outputRows.push(nextUpNf); }; -/** @param {NS} ns - * Find out the optimal set of factions and rep-donations required to access them */ +/** Find out the optimal set of factions and rep-donations required to access them + * @param {NS} ns + * @param {AugmentationData[]} augmentations + * @returns {{[factionName: string]: number}} The amount of reputation we need to donate to each faction to purchase the specified augmentations. */ function computeAugsRepReqDonationByFaction(augmentations) { - const repCostByFaction = {}; + const repCostByFaction = (/**@returns {{[factionName: string]: number}}*/() => ({}))(); for (const aug of augmentations) { + if (!(aug instanceof AugmentationData)) throw new Error("Wrong type: " + aug.constructor.name + JSON.stringify(aug)); let faction = factionData[aug.getFromJoined() || aug.getFromAny]; if (!faction.donationsUnlocked) continue; // getFromJoined() already ensures that we don't resort to faction requiring donations unless we must (and can) let reqDonation = getReqDonationForAug(aug, faction); @@ -738,7 +852,7 @@ function computeAugsRepReqDonationByFaction(augmentations) { ((getReqDonationForAug(aug, f) - repCostByFaction[f]) < (reqDonation - (repCostByFaction[faction.name] || 0))))); // The amount we've committed to donating the other faction is closer to this requirement if (alternativeFaction) { log(_ns, `INFO: Using alternative faction "${alternativeFaction}" for "${aug.name}" rather than earlier faction "${faction.name}"`) - aug.getFromJoined = () => alternativeFaction; + aug.getFromJoined = () => alternativeFaction; // Overwrite this function to always return the faction we've chosen to provide this augmentation reqDonation = getReqDonationForAug(aug, alternativeFaction); faction = factionData[alternativeFaction]; }