UNPKG

@kitten-science/kitten-scientists

Version:

Add-on for the wonderful incremental browser game: https://kittensgame.com/web/

424 lines 18.2 kB
import { isNil, mustExist } from "@oliversalzburg/js-utils/data/nil.js"; import { Engine } from "../Engine.js"; import { objectEntries } from "../tools/Entries.js"; import { negativeOneToInfinity } from "../tools/Format.js"; import { cl } from "../tools/Log.js"; import { Buildings, ChronoForgeUpgrades, ReligionUpgrades, SpaceBuildings, StagedBuildings, TranscendenceUpgrades, VoidSpaceUpgrades, ZigguratUpgrades, } from "../types/index.js"; export class BulkPurchaseHelper { _host; _workshopManager; _priceCache; cacheHits = 0; cacheMisses = 0; constructor(host, workshopManager) { this._host = host; this._workshopManager = workshopManager; this._priceCache = new Map(); } resetPriceCache() { this._priceCache = new Map(); this.cacheHits = 0; this.cacheMisses = 0; } _getPriceForBuild(build, atValue = 0) { const cachedPrices = this._priceCache.get(build) ?? []; const cachedPrice = cachedPrices[atValue]; if (cachedPrice !== undefined) { ++this.cacheHits; return cachedPrice; } ++this.cacheMisses; if (StagedBuildings.includes(build)) { const baseStage = { broadcasttower: "amphitheatre", dataCenter: "library", hydroplant: "aqueduct", solarfarm: "pasture", spaceport: "warehouse", }; const prices = this._getPriceForBuild(mustExist(baseStage[build]), atValue); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (Buildings.includes(build)) { const buildingMeta = this._host.game.bld.getBuildingExt(build); const buildingMetaGet = buildingMeta.get; buildingMeta.get = ((attr) => { if (attr === "val") { return atValue; } return buildingMetaGet.apply(buildingMeta, [attr]); }); const prices = this._host.game.bld.getPricesWithAccessor(buildingMeta); buildingMeta.get = buildingMetaGet; cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (ChronoForgeUpgrades.includes(build)) { const button = this._host.game.time.queue.getQueueElementControllerAndModel({ name: build, type: "chronoforge", }); const prices = button.controller.getPrices({ ...button.model, metadata: { ...button.model.metadata, val: atValue }, }); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (ReligionUpgrades.includes(build)) { const button = this._host.game.time.queue.getQueueElementControllerAndModel({ name: build, type: "religion", }); const prices = button.controller.getPrices({ ...button.model, metadata: { ...button.model.metadata, val: atValue }, }); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (SpaceBuildings.includes(build)) { const button = this._host.game.time.queue.getQueueElementControllerAndModel({ name: build, type: "spaceBuilding", }); const prices = button.controller.getPrices({ ...button.model, metadata: { ...button.model.metadata, val: atValue }, }); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (TranscendenceUpgrades.includes(build)) { const button = this._host.game.time.queue.getQueueElementControllerAndModel({ name: build, type: "transcendenceUpgrades", }); const prices = button.controller.getPrices({ ...button.model, metadata: { ...button.model.metadata, val: atValue }, }); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (VoidSpaceUpgrades.includes(build)) { const button = this._host.game.time.queue.getQueueElementControllerAndModel({ name: build, type: "voidSpace", }); const prices = button.controller.getPrices({ ...button.model, metadata: { ...button.model.metadata, val: atValue }, }); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } if (ZigguratUpgrades.includes(build)) { const button = this._host.game.time.queue.getQueueElementControllerAndModel({ name: build, type: "zigguratUpgrades", }); const prices = button.controller.getPrices({ ...button.model, metadata: { ...button.model.metadata, val: atValue }, }); cachedPrices[atValue] = prices; this._priceCache.set(build, cachedPrices); return prices; } throw Error(`unable to get meta for '${build}'`); } /** * Take a hash of potential builds and determine how many of them can be built. * * @param builds - All potential builds. * @param metaData - The metadata for the potential builds. * @param sectionTrigger - The configured trigger threshold for the section of these builds. * @param sourceTab - The tab these builds originate from. * @returns All the possible builds. */ bulk(builds, metaData) { const buildDrafts = []; const buildsSorted = objectEntries(builds).sort((a, b) => { const aMeta = mustExist(metaData[a[0]]); const bMeta = mustExist(metaData[b[0]]); if (aMeta.val !== bMeta.val) { return aMeta.val - bMeta.val; } return a[0].localeCompare(b[0], "en"); }); for (const [name, build] of buildsSorted) { const trigger = Engine.evaluateSubSectionTrigger(build.sectionTrigger, build.trigger); const buildMetaData = mustExist(metaData[name]); // If the build is disabled, skip it. if (!build.enabled || trigger < 0) { continue; } // tHidden is a flag that is manually set to exclude time buildings from the process. if ("tHidden" in buildMetaData && buildMetaData.tHidden === true) { continue; } // rHidden is a flag that is manually set to exclude religion buildings from the process. if ("rHidden" in buildMetaData && buildMetaData.rHidden === true) { continue; } // If the building isn't unlocked, skip it. if (buildMetaData.unlocked === false) { continue; } // If the max allowed buildings have already been built, there is no need to check them. if (!isNil(build.max) && -1 < build.max && build.max <= buildMetaData.val) { continue; } // For cryochambers, if there are used cryochambers, don't build new ones. // Also, don't build more cryochambers than you have chronospheres, as they'll stay unused. if (name === "cryochambers" && (mustExist(this._host.game.time.getVSU("usedCryochambers")).val > 0 || this._host.game.bld.getBuildingExt("chronosphere").meta.val <= buildMetaData.val)) { continue; } // TODO: This should be generalized to match all builds that have a `limitBuild` property. if (name === "ressourceRetrieval" && buildMetaData.val >= 100) { continue; } // If the build is for a stage that the building isn't currently at, skip it. if (this._isStagedBuild(buildMetaData) && typeof build.stage !== "undefined" && build.stage !== buildMetaData.stage) { continue; } const itemPrices = this._getPriceForBuild(name); // Check the requirements for this build. // We want a list of all resources that are required for this build, which have a capacity. const requiredMaterials = itemPrices .map(price => this._workshopManager.getResource(price.name)) .filter(material => 0 < material.maxValue); const allMaterialsAboveTrigger = requiredMaterials.filter(material => material.value / material.maxValue < trigger) .length === 0; if (allMaterialsAboveTrigger) { // Create an entry in the cache list for the bulk processing. buildDrafts.push({ builder: build.builder, count: 1, id: name, limit: build.max, name: (build.baseBuilding ?? build.building), stage: build.stage ?? null, val: buildMetaData.val, variant: build.variant ?? null, }); } } if (buildDrafts.length === 0) { return []; } // Create a copy of the currently available resources. // We need a copy, because `_precalculateBuilds` modifies this data. const currentResourcePool = {}; for (const res of this._host.game.resPool.resources) { currentResourcePool[res.name] = this._workshopManager.getValueAvailable(res.name); } let iterations = 0; const buildsCommitted = new Array(); while (iterations < 1e6 && 0 < buildDrafts.length) { let increasedThisIteration = 0; const canBeBuilt = []; let tempPool = { ...currentResourcePool }; for (const buildDraft of buildDrafts) { const possibleInstances = this._precalculateBuilds({ ...buildDraft, limit: Math.min(negativeOneToInfinity(buildDraft.limit), buildDraft.val + buildDraft.count), }, metaData, tempPool); if (possibleInstances.count === 0) { continue; } if (possibleInstances.count !== buildDraft.count) { tempPool = possibleInstances.remainingResources; canBeBuilt.push({ ...buildDraft, count: possibleInstances.count }); continue; } tempPool = possibleInstances.remainingResources; canBeBuilt.push({ ...buildDraft }); ++buildDraft.count; ++increasedThisIteration; } if (increasedThisIteration === 0) { // Return last full, or partial result. break; } buildsCommitted.push(canBeBuilt); ++iterations; } if (buildsCommitted.length === 0) { return []; } let validBuild; for (const builds of buildsCommitted) { const tempPool = { ...currentResourcePool }; let buildIsValid = true; for (const build of builds) { const possibleInstances = this._precalculateBuilds({ ...build, limit: Math.min(negativeOneToInfinity(build.limit), build.val + build.count), }, metaData, tempPool); if (possibleInstances.count < build.count) { buildIsValid = false; break; } } if (buildIsValid) { validBuild = builds; } else { break; } } if (validBuild !== undefined) { return validBuild; } console.warn(...cl(`Took '${iterations}' iterations to evaluate bulk build request without result.`)); return []; } /** * Calculate how many of a given build item build be built with the given resources. * * @param buildCacheItem The item to build. * @param metaData The metadata for the potential builds. * @param resources The currently available resources. * @returns The number of items that could be built, and the amount of resources that would be left, after buying them. */ _precalculateBuilds(buildCacheItem, metaData, resources = {}) { let buildsPossible = 0; const tempPool = { ...resources }; // The KG metadata associated with the build. const buildMetaData = mustExist(metaData[buildCacheItem.id]); let maxItemsBuilt = false; // There is actually no strong guarantee that `maxItemsBuilt` changes in the loops below. // This could end up being an infinite loop under unexpected conditions. while (!maxItemsBuilt && buildsPossible < 1e5) { if (buildCacheItem.limit <= buildMetaData.val + buildsPossible) { break; } const prices = this._getPriceForBuild(buildCacheItem.id, buildMetaData.val + buildsPossible); for (let priceIndex = 0; priceIndex < prices.length; priceIndex++) { if (tempPool[prices[priceIndex].name] < prices[priceIndex].val) { maxItemsBuilt = true; break; } } if (!maxItemsBuilt) { ++buildsPossible; for (let priceIndex = 0; priceIndex < prices.length; priceIndex++) { tempPool[prices[priceIndex].name] -= prices[priceIndex].val; } } } return { count: buildsPossible, remainingResources: tempPool }; } /** * Try to trigger the build for a given button. * * @param model The model associated with the button. * @param button The build button. * @param amount How many items to build. * @returns How many items were built. */ construct(model, controller, amount) { if ("name" in model === false) { return 0; } if ("controller" in model.options === false) { return 0; } if ("getMetadata" in model.options.controller === false) { return 0; } const meta = model.metadata; let counter = 0; let amountCalculated = amount; // For limited builds, only construct up to the max. const vsMeta = meta; if (!isNil(vsMeta.limitBuild) && vsMeta.limitBuild - vsMeta.val < amountCalculated) { amountCalculated = vsMeta.limitBuild - vsMeta.val; } if ((model.enabled && controller.hasResources(model)) || this._host.game.devMode) { while (controller.hasResources(model) && amountCalculated > 0) { model.prices = controller.getPrices(model); controller.payPrice(model); controller.incrementValue(model); counter++; amountCalculated--; } if (vsMeta.breakIronWill) { this._host.game.ironWill = false; } if (!isNil(meta)) { if ("unlocks" in meta && !isNil(meta.unlocks)) { this._host.game.unlock(meta.unlocks); } if ("upgrades" in meta && !isNil(meta.upgrades)) { this._host.game.upgrade(meta.upgrades); } } } return counter; } _isStagedBuild( // biome-ignore lint/suspicious/noExplicitAny: This is currently too hard to work around. data) { return "stage" in data && "stages" in data && !isNil(data.stage) && !isNil(data.stages); } /** * Determine the price modifier for the given building. * * @param data The building metadata. * @param source The tab the building belongs to. * @returns The price modifier for this building. * @see `getPriceRatioWithAccessor`@`buildings.js` */ getPriceRatio(data, source) { // If the building has stages, use the ratio for the current stage. const ratio = // TODO: This seems weird. Why not take the price ratio of the stage as the default? this._isStagedBuild(data) ? data.priceRatio || data.stages[data.stage].priceRatio : (data.priceRatio ?? 0); let ratioDiff = 0; if (source && source === "Bonfire") { ratioDiff = this._host.game.getEffect(`${data.name}PriceRatio`) + this._host.game.getEffect("priceRatio") + this._host.game.getEffect("mapPriceReduction"); ratioDiff = this._host.game.getLimitedDR(ratioDiff, ratio - 1); } return ratio + ratioDiff; } /** * Check if a given build could be performed. * * @param build The build that should be checked. * @returns `true` if the build is possible; `false` otherwise. */ singleBuildPossible(build) { const prices = this._getPriceForBuild(build); // Check if we can't afford any of the prices for this build. // Return `false` if we can't afford something, otherwise `true` is // returned by default. for (const price of prices) { if (this._workshopManager.getValueAvailable(price.name) < price.val) { return false; } } // We can afford this build. return true; } } //# sourceMappingURL=BulkPurchaseHelper.js.map