UNPKG

@kitten-science/kitten-scientists

Version:

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

557 lines 27 kB
import { isNil, mustExist } from "@oliversalzburg/js-utils/data/nil.js"; import { Engine } from "./Engine.js"; import { WorkshopSettings } from "./settings/WorkshopSettings.js"; import { objectEntries } from "./tools/Entries.js"; import { negativeOneToInfinity } from "./tools/Format.js"; import { cl } from "./tools/Log.js"; import { UpgradeManager } from "./UpgradeManager.js"; import { UserScriptLoader } from "./UserScriptLoader.js"; export class WorkshopManager extends UpgradeManager { settings; static DEFAULT_CONSUME_RATE = 1; constructor(host, settings = new WorkshopSettings()) { super(host); this.settings = settings; } tick(_context) { if (!this.settings.enabled) { return Promise.resolve(); } this.autoCraft(); if (this._host.engine.settings.highlighStock.enabled) { this.refreshStock(); } if (this.settings.unlockUpgrades.enabled) { return this.autoUnlock(); } return Promise.resolve(); } async autoUnlock() { if (!this._host.game.workshopTab.visible) { return; } const upgrades = this._host.game.workshop.upgrades; const toUnlock = new Array(); workLoop: for (const setting of Object.values(this.settings.unlockUpgrades.upgrades)) { if (!setting.enabled) { continue; } const upgrade = setting.$upgrade ?? upgrades.find(subject => subject.name === setting.upgrade); if (isNil(upgrade)) { console.error(...cl(`Upgrade '${setting.upgrade}' not found in game!`)); continue; } if (setting.$upgrade === undefined) { setting.$upgrade = upgrade; } if (upgrade.researched || !upgrade.unlocked) { continue; } // Create a copy of the prices for this upgrade, so that we can apply effects to it. let prices = UserScriptLoader.window.dojo.clone(upgrade.prices); prices = this._host.game.village.getEffectLeader("scientist", prices); for (const price of prices) { const available = this.getValueAvailable(price.name); const resource = this.getResource(price.name); const trigger = Engine.evaluateSubSectionTrigger(this.settings.unlockUpgrades.trigger, setting.trigger); if (trigger < 0 || (0 < trigger && available < resource.maxValue * trigger) || available < price.val) { continue workLoop; } } toUnlock.push(upgrade); } for (const item of toUnlock) { await this.upgrade(item, "workshop"); } } /** * Try to craft as many of the passed resources as possible. * Usually, this is called at each iteration of the automation engine to * handle the crafting of items on the Workshop tab. * * @param crafts The resources to build. */ autoCraft(crafts = this.settings.resources) { const craftRequests = new Map(); const sectionTrigger = this.settings.trigger; // Find all resources we would want to craft. // For crafts that require resources with a capacity, those resources must // be at or above the trigger for them to be considered to be crafted. for (const craft of Object.values(crafts)) { const trigger = Engine.evaluateSubSectionTrigger(sectionTrigger, craft.trigger); if (trigger < 0 || !craft.enabled) { continue; } const current = !craft.max ? false : this.getResource(craft.resource); const max = negativeOneToInfinity(craft.max); if (current && max < current.value) { continue; } // If we can't even craft a single item of the resource, skip it. if (!this.singleCraftPossible(craft.resource)) { continue; } const materials = Object.keys(this.getMaterials(craft.resource)); // The resource information for the requirement of this craft which have a capacity. const requiredMaterials = materials .map(material => this.getResource(material)) .filter(material => 0 < material.maxValue); const allMaterialsAboveTrigger = requiredMaterials.filter(material => material.value / material.maxValue < trigger) .length === 0; if (!allMaterialsAboveTrigger) { continue; } craftRequests.set(craft, { countRequested: 1, materials: materials.map(material => ({ consume: 0, resource: material, })), }); } if (craftRequests.size < 1) { return; } // For all crafts under consideration, find the crafts that share resources in their requirements. // We will use this to split crafts evenly among the available stock of that resource. const billOfMaterials = new Map(); for (const [craft, request] of craftRequests) { for (const material of request.materials) { if (!billOfMaterials.has(material.resource)) { billOfMaterials.set(material.resource, new Array()); } const consumers = mustExist(billOfMaterials.get(material.resource)); consumers.push(craft.resource); } } // Determine how much of each resource we want to spend on each craft. for (const [, request] of craftRequests) { for (const material of request.materials) { const available = this.getValueAvailable(material.resource); material.consume = available / mustExist(billOfMaterials.get(material.resource)).length; } } // Determine how much of each craft we want to perform, given our resource allocations. for (const [craft, request] of craftRequests) { const materials = this.getMaterials(craft.resource); let amount = Number.MAX_VALUE; for (const material of request.materials) { // How much of the material is needed to craft 1 new resource. const materialAmount = mustExist(materials[material.resource]); const materialResource = this.getResource(material.resource); if ( // For unlimited crafts, assign all resources. !craft.limited || // Handle the ship override. (craft.resource === "ship" && this.settings.shipOverride.enabled && this.getResource("ship").value < 243)) { amount = Math.min(amount, material.consume / materialAmount); continue; } const ratio = this._host.game.getResCraftRatio(craft.resource); // Quantity of source and target resource currently available. const availableSource = this.getValueAvailable(material.resource) / mustExist(billOfMaterials.get(material.resource)).length; const availableTarget = this.getValueAvailable(craft.resource); // How much source resource is consumed and target resource is crafted per craft operation. const recipeRequires = materialAmount; const recipeProduces = 1 + ratio; // How many crafts could we do given the amount of source resource available. const craftsPossible = availableSource / recipeRequires; // How many crafts were hypothetically done to produce the current amount of target resource. const craftsDone = availableTarget / recipeProduces; // The "order of magnitude" (how many powers of 10) of the existing target resource. const orderDone = Math.max(0, Math.floor(Math.log(craftsDone) / Math.LN10 + 0.000000001)); // Crafting gets progressively more expensive as the amount of the target increases. // This heuristic gives other, cheaper, targets a chance to get built from the same source resource. amount = Math.min( // Whatever was previously assumed as the best amount. amount, // We want to craft the lowest amount of these values, as we're in the "limited" mode. Math.min( // We take the possible crafts as the baseline craftsPossible - // If the source material is one that has a storage capacity, and the storage is full, // then we want to allow all possible crafts to be crafted. So we subtract 0. (0 < materialResource.maxValue && materialResource.maxValue <= materialResource.value ? 0 : // If the resource is not capped, we subtract the crafts we have already done. craftsDone), // The safe limit is to buy the next higher order of magnitude of items, to not // waste all resources if the target resource is very low, like after a reset with chronospheres. 10 ** (orderDone + 1)), // The amount of resources we could craft, based on our consume rate. material.consume / materialAmount); } const availableTarget = this.getValueAvailable(craft.resource); const recipeProduces = 1 + this._host.game.getResCraftRatio(craft.resource); // In any case, autoCraft amount should not exceed Number.MAX_VALUE const craftsMaxLimit = Number.MAX_VALUE / recipeProduces - availableTarget / recipeProduces; amount = Math.min(amount, craftsMaxLimit); request.countRequested = Math.max(0, craft.max === -1 ? amount : Math.min(amount, (craft.max - availableTarget) / recipeProduces)); } const orders = new Array(); for (const [craft, request] of craftRequests) { if (request.countRequested < 1) { continue; } orders.push({ amount: Math.floor(request.countRequested), name: craft.resource }); } if (0 < orders.length) { this.craftMultiple(orders); } } craftMultiple(orders) { const messages = new Array(); for (const order of orders) { const craft = this.getCraft(order.name); const ratio = this._host.game.getResCraftRatio(craft.name); const craftSucceeded = this._host.game.workshop.craft(craft.name, order.amount, true, false, false); if (!craftSucceeded) { console.error(...cl(`Failed trying to craft ${order.amount}x ${order.name}! This is a problem and should be reported.`)); continue; } const resourceName = mustExist(this._host.game.resPool.get(order.name)).title; // Determine actual amount after crafting upgrades const craftedAmount = Number.parseFloat((order.amount * (1 + ratio)).toFixed(2)); this._host.engine.storeForSummary(resourceName, craftedAmount, "craft"); messages.push(this._host.engine.i18n("act.craft", [ this._host.game.getDisplayValueExt(craftedAmount), resourceName, ])); } this._host.game.updateResources(); for (const message of messages) { this._host.engine.printOutput("ks-activity type_ks-craft", message); } } /** * Retrieve the resource information object from the game. * * @param name The name of the craftable resource. * @returns The information object for the resource. */ getCraft(name) { const craft = this._host.game.workshop.getCraft(name); if (!craft) { throw new Error(`Unable to find craft '${name}'`); } return craft; } /** * Check if we have enough resources to craft a single craftable resource. * * @param name The name of the resource. * @returns `true` if the build is possible; `false` otherwise. */ singleCraftPossible(name) { // Can't craft anything but wood until workshop is unlocked. if (!this._host.game.workshopTab.visible && name !== "wood") { return false; } const materials = this.getMaterials(name); for (const [material, amount] of objectEntries(materials)) { if (this.getValueAvailable(material) < amount) { return false; } } return true; } /** * Returns a hash of the required source resources and their * amount to craft the given resource. * * @param name The resource to craft. * @returns The source resources you need and how many. */ getMaterials(name) { const materials = {}; const craft = this.getCraft(name); const prices = this._host.game.workshop.getCraftPrice(craft); for (const price of prices) { materials[price.name] = price.val; } return materials; } /** * Determine how much of a resource is produced per tick. For craftable resources, * this also includes how many of them we *could* craft this tick. * * @param resource The resource to retrieve the production for. * @param cacheManager A `CacheManager` to use in the process. * @param preTrade ? * @returns The amount of resources produced per tick, adjusted arbitrarily. */ getTickVal(resource, cacheManager, preTrade = undefined) { let production = this._host.game.getResourcePerTick(resource.name, true); // For craftable resources, we also want to take into account how much of them // we *could* craft. if (resource.craftable) { let minProd = Number.MAX_VALUE; const materials = this.getMaterials(resource.name); for (const [mat, amount] of objectEntries(materials)) { const rat = (1 + this._host.game.getResCraftRatio(resource.name)) / amount; // Currently preTrade is only true for the festival stuff, so including furs from hunting is ideal. const addProd = this.getTickVal(this.getResource(mat)); if (addProd === "ignore") { continue; } minProd = Math.min(addProd * rat, minProd); } production += minProd !== Number.MAX_VALUE ? minProd : 0; } // If we have negative production (or none), and we're looking at either spice or // blueprints, return "ignore". // TODO: This special case seems to revolve around trading. As trading results in // spice and blueprints. if (production <= 0 && (resource.name === "spice" || resource.name === "blueprint")) { return "ignore"; } // If "preTrade" was set, increase the production. The "resValue" stored in the cache // makes no sense. // TODO: The only time this is used is for holding festivals. // It's unclear why this would be necessary. if (!preTrade && !isNil(cacheManager)) { production += cacheManager.getResValue(resource.name); } return production; } /** * Determine the resources and their amount that would usually result from a hunt. * * @returns The amounts of resources usually gained from hunting. */ getAverageHunt() { const output = {}; const hunterRatio = this._host.game.getEffect("hunterRatio") + this._host.game.village.getEffectLeader("manager", 0); output.furs = 40 + 32.5 * hunterRatio; output.ivory = 50 * Math.min(0.225 + 0.01 * hunterRatio, 0.5) + 40 * hunterRatio * Math.min(0.225 + 0.01 * hunterRatio, 0.5); output.unicorns = 0.05; if (this.getValue("zebras") >= 10) { output.bloodstone = this.getValue("bloodstone") === 0 ? 0.05 : 0.0005; } if (this._host.game.ironWill && this._host.game.workshop.get("goldOre").researched) { output.gold = 0.625 + 0.625 * hunterRatio; } return output; } /** * Retrieve the information object for a resource. * * @param name The resource to retrieve info for. * @returns The information object for the resource. */ getResource(name) { const res = this._host.game.resPool.get(name); if (isNil(res)) { throw new Error(`Unable to find resource ${name}`); } return res; } /** * Determine how many items of a resource are currently available. * * @param name The resource. * @returns How many items are currently available. */ getValue(name) { return this.getResource(name).value ?? 0; } /** * Determine how many items of the resource to always keep in stock. * * @param name The resource. * @returns How many items of the resource to always keep in stock. */ getStock(name) { const resource = this._host.engine.settings.resources.resources[name]; const stock = resource.enabled ? resource.stock : 0; return stock; } /** * Retrieve the consume rate for a resource. * * @param name - The resource. * @returns The consume rate for the resource. */ getConsume(name) { const resource = this._host.engine.settings.resources.resources[name]; const consume = resource.enabled ? resource.consume : 1; return consume; } /** * Determine how much of a resource is available for a certain operation * to use. * * @param name The resource to check. * @returns The available amount of the resource. */ getValueAvailable(name) { // How many items to keep in stock. let stock = this.getStock(name); // If the resource is catnip, ensure to not use so much that we can't satisfy // consumption by kittens. if ("catnip" === name) { const pastureMeta = this._host.game.bld.getBuildingExt("pasture").meta; const aqueductMeta = this._host.game.bld.getBuildingExt("aqueduct").meta; const pastures = pastureMeta.stage === 0 ? pastureMeta.val : 0; const aqueducts = aqueductMeta.stage === 0 ? aqueductMeta.val : 0; // How many catnip per tick do we have available? This can be negative. const resPerTick = this.getPotentialCatnip(true, pastures, aqueducts); // If our stock is currently decreasing. Ensure we work with the value // where it should be in 5 ticks. // TODO: I'm assuming 202 is the catnip consumption per tick and the 5 are a // magic value that just made sense, or the script assumes it runs every // 5 ticks. Which would mean it probably ignores the `interval` setting. if (resPerTick < 0) { stock -= resPerTick * 202 * 5; } } // How many items are currently available. let value = this.getValue(name); // Subtract the amount to keep in stock. value = Math.max(value - stock, 0); // Determine the consume rate. const consume = this.getConsume(name); return value * consume; } /** * Determine how much catnip we have available to "work with" per tick. * * @param worstWeather Should the worst weather be assumed for this calculation? * @param pastures How many pastures to take into account. * @param aqueducts How many aqueducts to take into account * @returns The potential catnip per tick. */ getPotentialCatnip(worstWeather, pastures, aqueducts) { // Start of by checking how much catnip we produce per tick at base level. let productionField = this._host.game.getEffect("catnipPerTickBase"); if (worstWeather) { // Assume fields run at -90% productionField *= 0.1; // Factor in cold harshness. productionField *= 1 + this._host.game.getLimitedDR(this._host.game.getEffect("coldHarshness"), 1); } else { productionField *= this._host.game.calendar.getWeatherMod({ name: "catnip" }) + this._host.game.calendar.getCurSeason().modifiers.catnip; } // When the communism policy is active, if (this._host.game.science.getPolicy("communism").researched) { productionField = 0; } // Get base production values for jobs. const resourceProduction = this._host.game.village.getResProduction(); // Check how much catnip we're producing through kitten jobs. const productionVillager = resourceProduction.catnip ? resourceProduction.catnip * (1 + this._host.game.getEffect("catnipJobRatio")) : 0; // Base production is catnip fields + farmers. let baseProd = productionField + productionVillager; // Determine the effect of other buildings on the production value. let hydroponics = this._host.game.space.getBuilding("hydroponics").val; // Index 21 is the "pawgan rituals" metaphysics upgrade. This makes no sense. // This likely wants index 22, which is "numeromancy", which has effects on // catnip production in cycles at index 2 and 7. // TODO: Fix this so the upgrade is properly taken into account. if (this._host.game.prestige.meta[0].meta[21].researched) { if (this._host.game.calendar.cycle === 2) { hydroponics *= 2; } if (this._host.game.calendar.cycle === 7) { hydroponics *= 0.5; } } // Our base production value is boosted by our aqueducts and hydroponics accordingly. baseProd *= 1 + 0.03 * aqueducts + 0.025 * hydroponics; // Apply paragon bonus, except during the "winter is coming" challenge. const isWinterComing = this._host.game.challenges.currentChallenge === "winterIsComing"; const paragonBonus = isWinterComing ? 0 : this._host.game.prestige.getParagonProductionRatio(); baseProd *= 1 + paragonBonus; // Apply faith bonus. baseProd *= 1 + this._host.game.religion.getSolarRevolutionRatio(); // Unless the user disabled the "global donate bonus", apply it. if (!this._host.game.opts.disableCMBR) { baseProd *= 1 + this._host.game.getCMBRBonus(); } // Apply the effects of possibly running festival. baseProd = mustExist(this._host.game.calendar.cycleEffectsFestival({ catnip: baseProd }).catnip); // Determine our demand for catnip. This is usually a negative value. let baseDemand = this._host.game.village.getResConsumption().catnip; // Pastures and unicron pastures reduce catnip demand. Factor that in. const unicornPastures = this._host.game.bld.getBuildingExt("unicornPasture").meta.val; baseDemand *= 1 + this._host.game.getLimitedDR(pastures * -0.005 + unicornPastures * -0.0015, 1.0); // If we have any kittens and happiness over 100%. if (this._host.game.village.sim.kittens.length > 0 && this._host.game.village.happiness > 1) { // How happy beyond 100% are we? const happyCon = this._host.game.village.happiness - 1; const catnipDemandWorkerRatioGlobal = this._host.game.getEffect("catnipDemandWorkerRatioGlobal"); // Determine the effect of kittens without jobs. if (this._host.game.challenges.currentChallenge === "anarchy") { // During anarchy, they have no effect. They eat all the catnip. baseDemand *= 1 + happyCon * (1 + catnipDemandWorkerRatioGlobal); } else { // During normal operation, reduce the demand proportionally. // TODO: This should probably be split up into 2 steps. baseDemand *= 1 + happyCon * (1 + catnipDemandWorkerRatioGlobal) * (1 - this._host.game.village.getFreeKittens() / this._host.game.village.sim.kittens.length); } } // Subtract the demand from the production. Demand is negative. baseProd += baseDemand; // Subtract possible catnip consumers, like breweries. baseProd += this._host.game.getResourcePerTickConvertion("catnip"); // Might need to eventually factor in time acceleration using this._host.gamePage.timeAccelerationRatio(). return baseProd; } /** * Maintains the CSS classes in the resource indicators in the game UI to * reflect if the amount of resource in stock is below or above the desired * total amount to keep in stock. * The user can configure this in the Workshop automation section. */ refreshStock() { const resourceCells = [ ...document.querySelectorAll("#game .res-row .res-cell.resAmount, #game .res-row .res-cell.resource-value"), ]; const cellMap = new Map(resourceCells.map(_ => [ mustExist([..._.parentNode.classList.entries()].find(([_i, __]) => __.startsWith("resource_")))[1].substring(9), _, ])); for (const [name, resource] of objectEntries(this._host.engine.settings.resources.resources)) { const cell = cellMap.get(name); // For some configured resources, there might be no UI cell. if (!cell) { continue; } if (!resource.enabled || resource.stock === 0) { if (cell.classList.contains("ks-stock-above") || cell.classList.contains("ks-stock-below")) { cell.classList.remove("ks-stock-above", "ks-stock-below"); } continue; } const isBelow = this._host.game.resPool.get(name).value < resource.stock; cell.classList.add(isBelow ? "ks-stock-below" : "ks-stock-above"); cell.classList.remove(isBelow ? "ks-stock-above" : "ks-stock-below"); } } } //# sourceMappingURL=WorkshopManager.js.map