UNPKG

@kitten-science/kitten-scientists

Version:

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

363 lines 15.1 kB
import { isNil } from "@oliversalzburg/js-utils/data/nil.js"; import { redirectErrorsToConsole } from "@oliversalzburg/js-utils/errors/console.js"; import { InvalidOperationError } from "@oliversalzburg/js-utils/errors/InvalidOperationError.js"; import gt from "semver/functions/gt.js"; import { Engine } from "./Engine.js"; import { ScienceSettings } from "./settings/ScienceSettings.js"; import { SpaceSettings } from "./settings/SpaceSettings.js"; import { WorkshopSettings } from "./settings/WorkshopSettings.js"; import { cl } from "./tools/Log.js"; import { UserScriptLoader } from "./UserScriptLoader.js"; import { UserInterface } from "./ui/UserInterface.js"; export const ksVersion = (prefix = "") => { if (isNil(RELEASE_VERSION)) { throw Error("Build error: RELEASE_VERSION is not defined."); } return `${prefix}${RELEASE_VERSION}`; }; export class KittenScientists { game; /** * A function in the game that allows to retrieve translated messages. * * Ideally, you should never access this directly and instead use the * i18n interface provided by `Engine`. */ i18nEngine; _userInterface; engine; _gameBeforeSaveHandle; _serverLoadHandle; constructor(game, i18nEngine, gameLanguage = "en", engineState) { console.info(...cl(`Kitten Scientists ${ksVersion("v")} constructed. Checking for previous instances...`)); if ("kittenScientists" in UserScriptLoader.window) { console.warn(...cl("Detected existing KS instance. Trying to unload it...")); UserScriptLoader.window.kittenScientists?.unload(); } console.info(...cl(`You are on the '${String(RELEASE_CHANNEL)}' release channel.`)); this.game = game; this.i18nEngine = i18nEngine; try { this.engine = new Engine(this, gameLanguage); this._userInterface = new UserInterface(this); } catch (error) { console.error(...cl("Failed to construct core components.", error)); throw error; } if (!isNil(engineState)) { this.setSettings(engineState); } else { this._userInterface.stateManagementUi.loadAutoSave(); } } rebuildUi() { this._userInterface.destroy(); this._userInterface = new UserInterface(this); this._userInterface.forceFullRefresh(); } /** * Runs some validations against the game to determine if internal control * structures still match up with expectations. * Issues should be logged to the console. */ validateGame() { ScienceSettings.validateGame(this.game, this.engine.scienceManager.settings); SpaceSettings.validateGame(this.game, this.engine.spaceManager.settings); WorkshopSettings.validateGame(this.game, this.engine.workshopManager.settings); } /** * Removes Kitten Scientists from the browser. */ unload() { console.warn(...cl("Unloading Kitten Scientists...")); this.engine.stop(); this._userInterface.destroy(); $("#ks-styles").remove(); if (this._gameBeforeSaveHandle !== undefined) { UserScriptLoader.window.dojo.unsubscribe(this._gameBeforeSaveHandle); this._gameBeforeSaveHandle = undefined; } if (this._serverLoadHandle !== undefined) { UserScriptLoader.window.dojo.unsubscribe(this._serverLoadHandle); this._gameBeforeSaveHandle = undefined; } const managerIndex = this.game.managers.indexOf(this._saveManager); if (-1 < managerIndex) { this.game.managers.splice(managerIndex, 1); } UserScriptLoader.window.kittenScientists = undefined; console.warn(...cl("Kitten Scientists have been unloaded!")); } /** * Start the user script after loading and configuring it. */ run() { this.refreshEntireUserInterface(); if (this.engine.settings.enabled) { this.engine.start(true); } this.engine.imessage("status.ks.init"); this.runUpdateCheck().catch(redirectErrorsToConsole(console)); if (this._gameBeforeSaveHandle !== undefined) { UserScriptLoader.window.dojo.unsubscribe(this._gameBeforeSaveHandle); this._gameBeforeSaveHandle = undefined; } this._gameBeforeSaveHandle = UserScriptLoader.window.dojo.subscribe("game/beforesave", (saveData) => { console.info(...cl("Injecting Kitten Scientists engine state into save data...")); const state = this.getSettings(); saveData.ks = { state: [state] }; this._userInterface.stateManagementUi.storeAutoSave(state); document.dispatchEvent(new CustomEvent("ks.reportSavegame", { detail: saveData })); }); if (this._serverLoadHandle !== undefined) { UserScriptLoader.window.dojo.unsubscribe(this._serverLoadHandle); this._gameBeforeSaveHandle = undefined; } this._serverLoadHandle = UserScriptLoader.window.dojo.subscribe("server/load", (saveData) => { const state = UserScriptLoader.tryEngineStateFromSaveData("ks", saveData); if (!state) { console.info(...cl("The Kittens Game save data did not contain a script state. Trying to load Auto-Save settings...")); return; } console.info(...cl("Found! Loading settings...")); this.engine.stateLoad(state); this._userInterface.forceFullRefresh(); }); } /** * Check which versions of KS are currently published. */ async runUpdateCheck() { if (RELEASE_CHANNEL === "fixed") { console.debug(...cl("No update check on 'fixed' release channel.")); return; } try { const response = await fetch("https://kitten-science.com/release-info.json"); const releaseInfo = (await response.json()); console.debug(...cl(releaseInfo)); if (isNil(releaseInfo[RELEASE_CHANNEL].version) || releaseInfo[RELEASE_CHANNEL].version === "") { console.debug("Could not read current version for our release channel from provided metadata!"); return; } if (!isNil(RELEASE_VERSION) && gt(releaseInfo[RELEASE_CHANNEL].version, RELEASE_VERSION)) { this.engine.imessage("status.ks.upgrade", [ releaseInfo[RELEASE_CHANNEL].version, RELEASE_VERSION, releaseInfo[RELEASE_CHANNEL].url.release, ]); } } catch (error) { console.warn(...cl("Update check failed.")); console.warn(...cl(error)); } } /** * Requests the user interface to refresh. */ refreshEntireUserInterface() { console.info(...cl("Requesting entire user interface to be refreshed.")); this._userInterface.forceFullRefresh(); } /** * Turns a string like 52.7 into the number 52.7 * @param value - String representation of an absolute value. * @returns A number between 0 and Infinity, where Infinity is represented as -1. */ parseFloat(value) { if (value === null || value === "") { return null; } const hasSuffix = /[KMGTP]$/i.test(value); const baseValue = value.substring(0, value.length - (hasSuffix ? 1 : 0)); let numericValue = value.includes("e") || hasSuffix ? Number.parseFloat(baseValue) : Number.parseInt(baseValue, 10); if (hasSuffix) { const suffix = value.substring(value.length - 1).toUpperCase(); numericValue = numericValue * 1000 ** ["", "K", "M", "G", "T", "P"].indexOf(suffix); } if (numericValue === Number.POSITIVE_INFINITY || numericValue < 0) { numericValue = -1; } return numericValue; } parseAbsolute(value) { const floatValue = this.parseFloat(value); return floatValue !== null ? Math.round(floatValue) : null; } /** * Turns a string like 52.7 into the number 0.527 * @param value - String representation of a percentage. * @returns A number between 0 and 1 representing the described percentage. */ parsePercentage(value) { const cleanedValue = value.trim().replace(/%$/, ""); return Math.max(0, Math.min(1, Number.parseFloat(cleanedValue) / 100)); } /** * Turns a number into a game-native string representation. * Infinity, either by actual value or by -1 representation, is rendered as a symbol. * @param value - The number to render as a string. * @param host - The host instance which we can use to let the game render values for us. * @returns A string representing the given number. */ renderAbsolute(value, locale = "invariant") { if (value < 0 || value === Number.POSITIVE_INFINITY) { return "∞"; } return locale !== "invariant" && Math.floor(Math.log10(value)) < 9 ? new Intl.NumberFormat(locale, { maximumFractionDigits: 0, style: "decimal" }).format(value) : this.game.getDisplayValueExt(value, false, false); } /** * Turns a number like 0.527 into a string like 52.7 * @param value - The number to render as a string. * @param locale - The locale in which to render the percentage. * @param withUnit - Should the percentage sign be included in the output? * @returns A string representing the given percentage. */ renderPercentage(value, locale = "invariant", withUnit) { if (value < 0 || value === Number.POSITIVE_INFINITY) { return "∞"; } return locale !== "invariant" ? new Intl.NumberFormat(locale, { style: "percent" }).format(value) : `${this.game.getDisplayValueExt(value * 100, false, false)}${withUnit ? "%" : ""}`; } renderFloat(value, locale = "invariant") { if (value < 0 || value === Number.POSITIVE_INFINITY) { return "∞"; } return locale !== "invariant" ? new Intl.NumberFormat(locale, { style: "decimal" }).format(value) : this.game.getDisplayValueExt(value, false, false); } //#region Settings /** * Encodes an engine states into a string. * * @param settings The engine state to encode. * @param compress Should we use LZString compression? * @returns The settings encoded into a string. */ static encodeSettings(settings, compress = true) { const settingsString = JSON.stringify(settings); return compress ? UserScriptLoader.window.LZString.compressToBase64(settingsString) : settingsString; } /** * Given a serialized engine state, attempts to deserialize that engine state. * Assumes the input has been compressed with LZString, will accept uncompressed. * * @param compressedSettings An engine state that has previously been serialized to a string. * @returns The engine state, if valid. */ static decodeSettings(compressedSettings) { try { const naiveParse = JSON.parse(compressedSettings); return KittenScientists.unknownAsEngineStateOrThrow(naiveParse); } catch (_error) { /* expected, as we assume the input to be compressed. */ } if (compressedSettings.match(/\r?\n/)) { throw new InvalidOperationError("Multi-line non-JSON input can't be decoded."); } const settingsString = UserScriptLoader.window.LZString.decompressFromBase64(compressedSettings); const parsed = JSON.parse(settingsString); return KittenScientists.unknownAsEngineStateOrThrow(parsed); } /** * Retrieves the state from the engine. * * @returns The engine state. */ getSettings() { return this.engine.stateSerialize(); } getSettingsAsJson() { return JSON.stringify(this.getSettings()); } /** * Updates the engine with a new state. * * @param settings The engine state to apply. */ setSettings(settings) { console.info(...cl("Loading engine state...")); const requiresUiRebuild = this.engine.settings.ksColumn.enabled !== settings.engine.ksColumn.enabled; this.engine.stateLoad(settings); if (requiresUiRebuild) { this.rebuildUi(); } this._userInterface.refresh(true); } /** * Loads an encoded state into the engine. * * @param encodedSettings The encoded settings. */ importSettingsFromString(encodedSettings) { const settings = KittenScientists.decodeSettings(encodedSettings); this.setSettings(settings); } /** * Copies an engine state to the clipboard. * * @param settings The engine state to copy to the clipboard. * The default is this engine's current state. * @param compress Should the state be compressed? */ async copySettings(settings = this.getSettings(), compress = true) { const encodedSettings = KittenScientists.encodeSettings(settings, compress); await UserScriptLoader.window.navigator.clipboard.writeText(encodedSettings); } /** * Determines if an object is an engine state, and throws an * exception otherwise. * * @param subject The object that is hopefully an engine state. * @param subject.v The version in the engine state. * @returns An engine state. */ static unknownAsEngineStateOrThrow(subject) { const v = subject.v; if (!isNil(v) && typeof v === "string") { if (v.startsWith("2")) { return subject; } } throw new Error("Not a valid engine state."); } //#endregion //#region SaveManager installSaveManager() { console.info(...cl("Installing save game manager...")); this.game.managers.push(this._saveManager); } _saveManager = { load: (saveData) => { console.info(...cl("Looking for Kitten Scientists engine state in save data...")); const state = UserScriptLoader.tryEngineStateFromSaveData("ks", saveData); if (!state) { console.info(...cl("The Kittens Game save data did not contain a script state.")); return; } console.info(...cl("Found Kitten Scientists engine state in save data.")); this.engine.stateLoad(state); this.refreshEntireUserInterface(); }, resetState: () => null, save: (_saveData) => { // We ignore the manager invocation, because we already handle the // `game/beforesave` event, which is intended for external consumers. }, }; } //# sourceMappingURL=KittenScientists.js.map