UNPKG

@kitten-science/kitten-scientists

Version:

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

475 lines 20 kB
import { isNil, mustExist } from "@oliversalzburg/js-utils/data/nil.js"; import { unknownToError } from "@oliversalzburg/js-utils/errors/error-serializer.js"; import { measure, measureAsync } from "@oliversalzburg/js-utils/measurement/performance.js"; import { BonfireManager } from "./BonfireManager.js"; import { ActivitySummary, } from "./helper/ActivitySummary.js"; import { BulkPurchaseHelper } from "./helper/BulkPurchaseHelper.js"; import enUS from "./i18n/en-US.json" with { type: "json" }; import deDE from "./i18n/translations/de-DE.json" with { type: "json" }; import heIL from "./i18n/translations/he-IL.json" with { type: "json" }; import zhCN from "./i18n/translations/zh-CN.json" with { type: "json" }; import { ksVersion } from "./KittenScientists.js"; import { ReligionManager } from "./ReligionManager.js"; import { ScienceManager } from "./ScienceManager.js"; import { SpaceManager } from "./SpaceManager.js"; import { BonfireSettings } from "./settings/BonfireSettings.js"; import { EngineSettings } from "./settings/EngineSettings.js"; import { ReligionSettings } from "./settings/ReligionSettings.js"; import { ScienceSettings } from "./settings/ScienceSettings.js"; import { SpaceSettings } from "./settings/SpaceSettings.js"; import { TimeControlSettings } from "./settings/TimeControlSettings.js"; import { TimeSettings } from "./settings/TimeSettings.js"; import { TradeSettings } from "./settings/TradeSettings.js"; import { VillageSettings } from "./settings/VillageSettings.js"; import { WorkshopSettings } from "./settings/WorkshopSettings.js"; import { TimeControlManager } from "./TimeControlManager.js"; import { TimeManager } from "./TimeManager.js"; import { TradeManager } from "./TradeManager.js"; import { objectEntries } from "./tools/Entries.js"; import { cl } from "./tools/Log.js"; import { Cycles, } from "./types/index.js"; import { FallbackLocale, UserScriptLoader } from "./UserScriptLoader.js"; import { VillageManager } from "./VillageManager.js"; import { WorkshopManager } from "./WorkshopManager.js"; const i18nData = { "de-DE": deDE, "en-US": enUS, "he-IL": heIL, "zh-CN": zhCN }; export class Engine { /** * All i18n literals of the userscript. */ _i18nData; /** * Was any state loaded into this engine at any point in time? */ _isLoaded = false; get isLoaded() { return this._isLoaded; } _host; settings; bonfireManager; religionManager; scienceManager; spaceManager; timeControlManager; timeManager; tradeManager; villageManager; workshopManager; _activitySummary; _bulkManager; _timeoutMainLoop = undefined; constructor(host, gameLanguage) { this.settings = new EngineSettings(); this._i18nData = i18nData; this.setLanguage(gameLanguage, false); this._host = host; this._activitySummary = new ActivitySummary(this._host); this.workshopManager = new WorkshopManager(this._host); this._bulkManager = new BulkPurchaseHelper(this._host, this.workshopManager); this.bonfireManager = new BonfireManager(this._host, this.workshopManager); this.religionManager = new ReligionManager(this._host, this.bonfireManager, this.workshopManager); this.scienceManager = new ScienceManager(this._host, this.workshopManager); this.spaceManager = new SpaceManager(this._host, this.workshopManager); this.timeControlManager = new TimeControlManager(this._host, this.bonfireManager, this.religionManager, this.workshopManager); this.timeManager = new TimeManager(this._host, this.workshopManager); this.tradeManager = new TradeManager(this._host, this.workshopManager); this.villageManager = new VillageManager(this._host, this.workshopManager); } isLanguageSupported(language) { return Object.keys(this._i18nData).some(locale => locale.startsWith(`${language}-`)); } isLocaleSupported(locale) { return locale in this._i18nData; } localeSupportsFirstLetterSplits(locale = this.settings.locale.selected) { return locale !== "zh-CN"; } localeForLanguage(language) { return Object.keys(this._i18nData).find(locale => locale.startsWith(`${language}-`)); } setLanguage(language, rebuildUI = true) { const previousLocale = this.settings.locale.selected; if (!this.isLanguageSupported(language)) { console.warn(...cl(`Requested language '${language}' is not available. Falling back to '${FallbackLocale}'.`)); this.settings.locale.selected = FallbackLocale; } else { const locale = mustExist(this.localeForLanguage(language)); console.info(...cl(`Selecting language '${locale}'.`)); this.settings.locale.selected = locale; } if (previousLocale !== this.settings.locale.selected && rebuildUI) { this._host.rebuildUi(); } } setLocale(locale, rebuildUI = true) { const previousLocale = this.settings.locale.selected; if (!this.isLocaleSupported(locale)) { console.warn(...cl(`Requested language '${locale}' is not available. Falling back to '${FallbackLocale}'.`)); this.settings.locale.selected = FallbackLocale; } else { console.info(...cl(`Selecting language '${locale}'.`)); this.settings.locale.selected = locale; } if (previousLocale !== this.settings.locale.selected && rebuildUI) { this._host.rebuildUi(); } } /** * Loads a new state into the engine. * * @param settings The engine state to load. * @param retainMetaBehavior When set to `true`, the engine will not be stopped or started, if the engine * state would require that. The settings for state management are also not loaded from the engine state. * This is intended to make loading of previous settings snapshots more intuitive. */ stateLoad(settings, retainMetaBehavior = false) { this._isLoaded = true; this.stop(false); // For now, we only log a warning on mismatching tags. // Ideally, we would perform semver comparison, but that is // excessive at this point in time. The goal should be a stable // state import of most versions anyway. const version = ksVersion(); if (settings.v !== version) { console.warn(...cl(`Attempting to load engine state with version tag '${settings.v}' when engine is at version '${version}'!`)); } // Perform the load of each sub settings section in a try-catch to // allow us to still load the other sections if there were schema // changes. const attemptLoad = (loader, errorMessage) => { try { loader(); } catch (error) { console.error(...cl(`Failed load of ${errorMessage} settings.`, error)); } }; attemptLoad(() => { this.settings.load(settings.engine, retainMetaBehavior); }, "engine"); attemptLoad(() => { this.bonfireManager.settings.load(settings.bonfire); }, "bonfire"); attemptLoad(() => { this.religionManager.settings.load(settings.religion); }, "religion"); attemptLoad(() => { this.scienceManager.settings.load(settings.science); }, "science"); attemptLoad(() => { this.spaceManager.settings.load(settings.space); }, "space"); attemptLoad(() => { this.timeControlManager.settings.load(settings.timeControl); }, "time control"); attemptLoad(() => { this.timeManager.settings.load(settings.time); }, "time"); attemptLoad(() => { this.tradeManager.settings.load(settings.trade); }, "trade"); attemptLoad(() => { this.villageManager.settings.load(settings.village); }, "village"); attemptLoad(() => { this.workshopManager.settings.load(settings.workshop); }, "workshop"); this.setLocale(this.settings.locale.selected); // Ensure the main engine setting is respected. if (this.settings.enabled) { this.start(false); } else { this.stop(false); } } static get DEFAULT_STATE() { return { bonfire: new BonfireSettings(), engine: new EngineSettings(), religion: new ReligionSettings(), science: new ScienceSettings(), space: new SpaceSettings(), time: new TimeSettings(), timeControl: new TimeControlSettings(), trade: new TradeSettings(), v: ksVersion(), village: new VillageSettings(), workshop: new WorkshopSettings(), }; } stateReset() { this.stateLoad(Engine.DEFAULT_STATE); } /** * Serializes all settings in the engine. * * @returns A snapshot of the current engine settings state. */ stateSerialize() { return { bonfire: this.bonfireManager.settings, engine: this.settings, religion: this.religionManager.settings, science: this.scienceManager.settings, space: this.spaceManager.settings, time: this.timeManager.settings, timeControl: this.timeControlManager.settings, trade: this.tradeManager.settings, v: ksVersion(), village: this.villageManager.settings, workshop: this.workshopManager.settings, }; } /** * Start the Kitten Scientists engine. * * @param msg Should we print to the log that the engine was started? */ start(msg = true) { if (this._timeoutMainLoop) { return; } const loop = () => { const context = { entry: Date.now(), exit: 0, measurements: {}, priceCacheHits: 0, priceCacheMisses: 0, purchaseOrders: [], requestGameUiRefresh: false, }; this._iterate(context) .then(() => { context.exit = Date.now(); const timeTaken = context.exit - context.entry; document.dispatchEvent(new CustomEvent("ks.reportFrame", { detail: context })); // Check if the main loop was terminated during // the last iteration. if (this._timeoutMainLoop === undefined) { return; } this._timeoutMainLoop = UserScriptLoader.window.setTimeout(loop, Math.max(10, this._host.engine.settings.interval - timeTaken)); }) .catch((error) => { console.warn(...cl(unknownToError(error))); }); }; this._timeoutMainLoop = UserScriptLoader.window.setTimeout(loop, this._host.engine.settings.interval); if (msg) { this._host.engine.imessage("status.ks.enable"); } } /** * Stop the Kitten Scientists engine. * * @param msg Should we print to the log that the engine was stopped? */ stop(msg = true) { if (!this._timeoutMainLoop) { return; } clearTimeout(this._timeoutMainLoop); this._timeoutMainLoop = undefined; if (msg) { this._host.engine.imessage("status.ks.disable"); } } /** * The main loop of the automation script. */ async _iterate(context) { if (this.settings.filters.enabled) { this._maintainKGLogFilters(); } // The order in which these actions are performed is probably // semi-intentional and should be preserved or improved. let [, duration] = await measureAsync(() => this.scienceManager.tick(context)); context.measurements.scienceManager = duration; [, duration] = measure(() => { this.bonfireManager.tick(context); }); context.measurements.bonfireManager = duration; [, duration] = measure(() => { this.spaceManager.tick(context); }); context.measurements.spaceManager = duration; [, duration] = await measureAsync(() => this.workshopManager.tick(context)); context.measurements.workshopManager = duration; [, duration] = measure(() => { this.tradeManager.tick(context); }); context.measurements.tradeManager = duration; [, duration] = await measureAsync(() => this.religionManager.tick(context)); context.measurements.religionManager = duration; [, duration] = measure(() => { this.timeManager.tick(context); }); context.measurements.timeManager = duration; [, duration] = measure(() => { this.villageManager.tick(context); }); context.measurements.villageManager = duration; [, duration] = await measureAsync(() => this.timeControlManager.tick(context)); context.measurements.timeControlManager = duration; [, duration] = measure(() => { if (0 < context.purchaseOrders.length) { const [{ builds, metaData }, durationInitialize] = measure(() => { const builds = { ...context.purchaseOrders[0].builds }; let metaData = { ...context.purchaseOrders[0].metaData }; for (const order of context.purchaseOrders) { for (const [name, entry] of objectEntries(order.builds)) { builds[name] = { ...entry, baseBuilding: entry.baseBuilding, builder: order.builder, building: entry.building, sectionTrigger: order.sectionTrigger, stage: entry.stage, variant: entry.variant, }; } metaData = { ...metaData, ...order.metaData }; } this._bulkManager.resetPriceCache(); return { builds, metaData }; }); const [buildList, durationCalculate] = measure(() => { const buildList = this._bulkManager.bulk(builds, metaData); return buildList; }); const [, durationExecute] = measure(() => { for (const build of buildList.filter(item => 0 < item.count)) { build.builder(build); context.requestGameUiRefresh = true; } }); context.priceCacheHits = this._bulkManager.cacheHits; context.priceCacheMisses = this._bulkManager.cacheMisses; context.measurements.bulkPurchaseInit = durationInitialize; context.measurements.bulkPurchaseCalc = durationCalculate; context.measurements.bulkPurchaseExec = durationExecute; } }); context.measurements.bulkPurchaseTotal = duration; [, duration] = measure(() => { if (context.requestGameUiRefresh && !document.hidden) { this._host.game.ui.render(); } }); context.measurements.gameUiRefresh = duration; } /** * Set the log filters in the game to the selected configuration. */ _maintainKGLogFilters() { for (const [id, filter] of objectEntries(this.settings.filters.filtersGame)) { if (this._host.game.console.filters[id].enabled !== filter.enabled) { this._host.game.console.filters[id].unlocked = true; this._host.game.console.filters[id].enabled = filter.enabled; const filterCheckbox = UserScriptLoader.window.document.querySelector(`#filter-${id}`); if (filterCheckbox === null) { continue; } filterCheckbox.checked = filter.enabled; } } } symbolForCycle(cycle) { return this._host.game.calendar.cycles.find(entry => entry.name === cycle)?.uglyph ?? ""; } labelForCycle(cycle) { const symbol = this.symbolForCycle(cycle); const label = this._host.engine.i18n(`$space.planet.${cycle === "redmoon" ? "moon" : cycle}.label`); return `${symbol} ${label}`; } labelForPlanet(planet) { const cycleCandidate = planet === "moon" ? "redmoon" : planet; const cycle = Cycles.includes(cycleCandidate) ? cycleCandidate : undefined; const label = this._host.engine.i18n(`$space.planet.${planet}.label`); return cycle === undefined ? label : `${this.symbolForCycle(cycle)} ${label}`; } /** * Retrieve an internationalized string literal. * * @param key The key to retrieve from the translation table. * @param args Variable arguments to render into the string. * @returns The translated string. */ i18n(key, args = []) { let value; // Key is to be translated through KG engine. if (key.startsWith("$")) { value = this._host.i18nEngine(key.slice(1)); } value = value ?? this._i18nData[this.settings.locale.selected][key]; const check = value; if (isNil(check)) { value = i18nData[FallbackLocale][key]; if (!value) { console.warn(...cl(`i18n key '${key}' not found in default language.`)); return `$${key}`; } console.warn(...cl(`i18n key '${key}' not found in selected language.`)); } for (let argIndex = 0; argIndex < args.length; ++argIndex) { value = value.replace(`{${argIndex}}`, `${args[argIndex]}`); } return value; } iactivity(i18nLiteral, i18nArgs = [], logStyle) { const text = this.i18n(i18nLiteral, i18nArgs); if (logStyle) { const activityClass = `type_${logStyle}`; this.printOutput(`ks-activity ${activityClass}`, text); } else { this.printOutput("ks-activity", text); } } imessage(i18nLiteral, i18nArgs = []) { this.printOutput("ks-default", this.i18n(i18nLiteral, i18nArgs)); } storeForSummary(name, amount = 1, section = "other") { this._activitySummary.storeActivity(name, amount, section); } getSummary() { return this._activitySummary.renderSummary(); } displayActivitySummary() { const summary = this.getSummary(); for (const summaryLine of summary) { this.printOutput("ks-summary", summaryLine); } // Clear out the old activity this.resetActivitySummary(); } resetActivitySummary() { this._activitySummary.resetActivity(); } printOutput(cssClasses, message) { if (this.settings.filters.enabled) { for (const filterItem of Object.values(this.settings.filters.filters)) { if (filterItem.variant === cssClasses && !filterItem.enabled) { return; } } } this._host.game.msg(message, cssClasses); } static evaluateSubSectionTrigger(sectionTrigger, subSectionTrigger) { return sectionTrigger < 0 ? subSectionTrigger : subSectionTrigger < 0 ? sectionTrigger : subSectionTrigger; } } //# sourceMappingURL=Engine.js.map