UNPKG

@kitten-science/kitten-scientists

Version:

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

543 lines 23 kB
import { isNil } from "@oliversalzburg/js-utils/data/nil.js"; import { redirectErrorsToConsole } from "@oliversalzburg/js-utils/errors/console.js"; import { InvalidArgumentError } from "@oliversalzburg/js-utils/errors/InvalidArgumentError.js"; import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; import { de, enUS, he, zhCN } from "date-fns/locale"; import { Engine } from "../Engine.js"; import { Icons } from "../images/Icons.js"; import { KittenScientists } from "../KittenScientists.js"; import { Unique } from "../tools/Entries.js"; import { cl } from "../tools/Log.js"; import { SavegameLoader } from "../tools/SavegameLoader.js"; import { UserScriptLoader } from "../UserScriptLoader.js"; import { Button } from "./components/Button.js"; import { ButtonListItem } from "./components/ButtonListItem.js"; import { Container } from "./components/Container.js"; import { Delimiter } from "./components/Delimiter.js"; import { HeaderListItem } from "./components/HeaderListItem.js"; import { IconButton } from "./components/IconButton.js"; import { LabelListItem } from "./components/LabelListItem.js"; import stylesLabelListItem from "./components/LabelListItem.module.css"; import { ListItem } from "./components/ListItem.js"; import { SettingListItem } from "./components/SettingListItem.js"; import stylesSettingListItem from "./components/SettingListItem.module.css"; import { SettingsList } from "./components/SettingsList.js"; import { SettingsPanel } from "./components/SettingsPanel.js"; import { TextButton } from "./components/TextButton.js"; import { ToolbarListItem } from "./components/ToolbarListItem.js"; export class StateManagementUi extends SettingsPanel { games = new Array(); /** * The states persisted to local storage. They use Unique<T> so that when we * provide a state to the engine to load or get a state from the engine to * save, we are not accidentally sharing a reference to a live object. */ states = new Array(); gameList; stateList; locale; constructor(parent, settings, locale) { console.debug(...cl(`Constructing ${StateManagementUi.name}`)); const label = parent.host.engine.i18n("state.title"); super(parent, settings, new LabelListItem(parent, label, { classes: [stylesSettingListItem.checked, stylesSettingListItem.setting], icon: Icons.State, }).addChildrenHead([ new Container(parent, { classes: [stylesLabelListItem.fillSpace], }), ]), { onRefresh: () => { this._refreshGameList(); this._refreshStateList(); }, }); this.gameList = new SettingsList(this, { hasDisableAll: false, hasEnableAll: false, }); this.stateList = new SettingsList(this, { hasDisableAll: false, hasEnableAll: false, }); this.locale = locale.selected === "zh-CN" ? zhCN : locale.selected === "he-IL" ? he : locale.selected === "de-DE" ? de : enUS; this.addChildContent(new SettingsList(this, { hasDisableAll: false, hasEnableAll: false, }).addChildren([ new SettingListItem(this, this.setting.noConfirm, this.host.engine.i18n("state.noConfirm")), new ListItem(this).addChild(new Delimiter(this)), new HeaderListItem(this, this.host.engine.i18n("state.local")), new ToolbarListItem(this).addChildren([ new Button(this, this.host.engine.i18n("state.import"), Icons.Import, { onClick: () => { this.import(); }, title: this.host.engine.i18n("state.importTitle"), }), ]), new ListItem(this).addChild(new Delimiter(this)), new HeaderListItem(this, this.host.engine.i18n("state.localStates")), new ToolbarListItem(this).addChildren([ new Button(this, this.host.engine.i18n("state.store"), Icons.SaveAs, { onClick: () => { this.storeState(); }, title: this.host.engine.i18n("state.storeState"), }), new Button(this, this.host.engine.i18n("copy"), Icons.Copy, { onClick: () => { this.copyState().catch(redirectErrorsToConsole(console)); this.host.engine.imessage("state.copied.stateCurrent"); }, title: this.host.engine.i18n("state.copy.stateCurrent"), }), new Button(this, this.host.engine.i18n("state.new"), Icons.Draft, { onClick: () => { this.storeStateFactoryDefaults(); this.host.engine.imessage("state.stored.state"); }, title: this.host.engine.i18n("state.storeFactory"), }), new Button(this, this.host.engine.i18n("state.exportAll"), Icons.Sync, { onClick: () => { this.exportStateAll(); }, title: this.host.engine.i18n("state.exportAllTitle"), }), ]), new ListItem(this).addChild(this.stateList), new ListItem(this).addChild(new Delimiter(this)), new HeaderListItem(this, this.host.engine.i18n("state.localGames")), new ToolbarListItem(this).addChildren([ new Button(this, this.host.engine.i18n("state.store"), Icons.SaveAs, { onClick: () => { this.storeGame(); this.host.engine.imessage("state.stored.game"); }, title: this.host.engine.i18n("state.storeGame"), }), new Button(this, this.host.engine.i18n("copy"), Icons.Copy, { onClick: () => { this.copyGame().catch(redirectErrorsToConsole(console)); this.host.engine.imessage("state.copied.gameCurrent"); }, title: this.host.engine.i18n("state.copy.gameCurrent"), }), ]), new ListItem(this).addChild(this.gameList), new SettingListItem(this, this.setting.compress, this.host.engine.i18n("state.compress")), ])); this._loadGames(); this._loadStates(); } _loadGames() { let index = 0; let game = localStorage.getItem(`ks.game.${index}`); this.games.splice(0); try { while (!isNil(game)) { const gameObject = JSON.parse(game); this.games.push(new Unique(gameObject)); game = localStorage.getItem(`ks.game.${++index}`); } } catch (error) { console.error(...cl(error)); } } _storeGames() { let index = 0; let game = localStorage.getItem(`ks.game.${index}`); while (!isNil(game)) { localStorage.removeItem(`ks.game.${index}`); game = localStorage.getItem(`ks.game.${++index}`); } index = 0; for (const game of this.games) { localStorage.setItem(`ks.game.${index++}`, JSON.stringify(game)); } } _loadStates() { let stateIndex = 0; let state = localStorage.getItem(`ks.state.${stateIndex}`); this.states.splice(0); try { while (!isNil(state)) { const stateObject = JSON.parse(state); KittenScientists.unknownAsEngineStateOrThrow(stateObject.state); this.states.push(new Unique(stateObject)); state = localStorage.getItem(`ks.state.${++stateIndex}`); } } catch (error) { console.error(...cl(error)); } } _storeStates() { let stateIndex = 0; let state = localStorage.getItem(`ks.state.${stateIndex}`); while (!isNil(state)) { localStorage.removeItem(`ks.state.${stateIndex}`); state = localStorage.getItem(`ks.state.${++stateIndex}`); } stateIndex = 0; for (const state of this.states) { localStorage.setItem(`ks.state.${stateIndex++}`, JSON.stringify(state)); } } _refreshGameList() { this.gameList.removeChildren(this.gameList.children); this.gameList.addChildren(this.games .sort((a, b) => new Date(a.unwrap().timestamp).getTime() - new Date(b.unwrap().timestamp).getTime()) .map(game => [game.unwrap(), game]) .map(([game, gameSlot]) => new ButtonListItem(this, new TextButton(this, `${game.label} (${formatDistanceToNow(new Date(game.timestamp), { addSuffix: true, locale: this.locale, })})`, { onClick: () => { this.loadGame(game.game).catch(redirectErrorsToConsole(console)); this.host.engine.imessage("state.loaded.game", [game.label]); }, title: new Date(game.timestamp).toLocaleString(), })).addChildren([ new Container(this, { classes: [stylesLabelListItem.fillSpace] }), new IconButton(this, Icons.Save, this.host.engine.i18n("state.update.game"), { onClick: () => { this.updateGame(gameSlot, this.host.game.save()); this.host.engine.imessage("state.updated.game", [game.label]); }, }), new IconButton(this, Icons.Edit, this.host.engine.i18n("state.edit.game"), { onClick: () => { this.storeGame(game.game); this.deleteGame(gameSlot, true); this.host.engine.imessage("state.updated.game", [game.label]); }, }), new IconButton(this, Icons.Copy, this.host.engine.i18n("state.copy.game"), { onClick: () => { this.copyGame(game.game).catch(redirectErrorsToConsole(console)); this.host.engine.imessage("state.copied.game", [game.label]); }, }), new IconButton(this, Icons.Delete, this.host.engine.i18n("state.delete.game"), { onClick: () => { this.deleteGame(gameSlot); this.host.engine.imessage("state.deleted.game", [game.label]); }, }), ]))); } _refreshStateList() { this.stateList.removeChildren(this.stateList.children); this.stateList.addChildren(this.states .sort((a, b) => new Date(a.unwrap().timestamp).getTime() - new Date(b.unwrap().timestamp).getTime()) .map(stateSlot => [stateSlot.unwrap(), stateSlot]) .map(([state, stateSlot]) => new ButtonListItem(this, new TextButton(this, `${state.label} (${formatDistanceToNow(new Date(state.timestamp), { addSuffix: true, locale: this.locale, })})`, { onClick: () => { this.loadState(state.state); this.host.engine.imessage("state.loaded.state", [state.label]); }, title: new Date(state.timestamp).toLocaleString(), })).addChildren([ new Container(this, { classes: [stylesLabelListItem.fillSpace] }), new IconButton(this, Icons.Save, this.host.engine.i18n("state.update.state"), { onClick: () => { this.updateState(stateSlot, this.host.engine.stateSerialize()); this.host.engine.imessage("state.updated.state", [state.label]); }, }), new IconButton(this, Icons.Edit, this.host.engine.i18n("state.edit.state"), { onClick: () => { this.storeState(state.state); this.deleteState(stateSlot, true); this.host.engine.imessage("state.updated.state", [state.label]); }, }), new IconButton(this, Icons.Copy, this.host.engine.i18n("state.copy.state"), { onClick: () => { this.copyState(state.state).catch(redirectErrorsToConsole(console)); this.host.engine.imessage("state.copied.state", [state.label]); }, }), new IconButton(this, Icons.Delete, this.host.engine.i18n("state.delete.state"), { onClick: () => { this.deleteState(stateSlot); this.host.engine.imessage("state.deleted.state", [state.label]); }, }), ]))); } async copyState(state) { await this.host.copySettings(state, false); } async copyGame(game) { const saveData = game ?? this.host.game.save(); const saveDataString = JSON.stringify(saveData); const encodedData = this.setting.compress.enabled ? this.host.game.compressLZData(saveDataString) : saveDataString; await UserScriptLoader.window.navigator.clipboard.writeText(encodedData); } /** * Request text input from the user, and try to import it. * * Things users might paste, in no specific order: * 1. Kittens Game Save, uncompressed line-agnostic JSON * 2. Kittens Game Save, lz-string compressed single-line UTF-8 string * 3. Kittens Game Save, lz-string compressed single-line UTF-16 string * 4. Kitten Scientists Settings, uncompressed line-agnostic JSON * 5. Kitten Scientists Settings, lz-string compressed single-line Base64 string * 6. Kitten Scientists Settings Export, multi-line string where all lines are either: * - #4, but must be single-line * - #5 * 7. Kitten Scientists Settings Export, multi-line string where each line is * an uncompressed JSON string serialization of: * { * label: "The label the user previously assigned to these settings.", * state: "The same options as #6", * timestamp: "Last time the settings were modified. As new Date().toISOString().", * } */ import() { const userInput = UserScriptLoader.window.prompt(this.host.engine.i18n("state.loadPrompt")); if (isNil(userInput)) { return; } const importId = new Date().toDateString(); let importSequence = 1; const makeImportLabel = () => this.host.engine.i18n("state.importedState", [`${importId} #${importSequence++}`]); const internalImport = (input) => { // Handles #4 and #5 try { // decodeSettings throws if the input is not a valid engine state. const state = KittenScientists.decodeSettings(input); this.storeState(state, makeImportLabel()); this.host.engine.imessage("state.imported.state"); return; } catch (_error) { // Not a valid Kitten Scientists state. } // Handles #7 try { const subjectData = JSON.parse(input); const state = KittenScientists.decodeSettings(subjectData.state); this.storeState(state, subjectData.label); this.host.engine.imessage("state.imported.state"); return; } catch (_error) { // Not a valid Kitten Scientists state. } // Attempt to parse as KG save. let subjectData; try { subjectData = JSON.parse(input); } catch (_error) { // Expected, as we assume compressed input. const uncompressed = this.host.game.decompressLZData(input); try { subjectData = JSON.parse(uncompressed); } catch (_error) { // Continued failure to parse as JSON might indicate newline-delimited JSON. if (input.match(/\r?\n/)) { return input.split(/\r?\n/).forEach(line => void internalImport(line)); } throw new InvalidArgumentError("The provided input can not be parsed as anything we understand."); } } // If game contains KS settings, import those separately. let stateLabel; if (!isNil(subjectData) && "ks" in subjectData && !isNil(subjectData.ks)) { const state = subjectData.ks.state[0]; stateLabel = this.storeState(state, makeImportLabel()) ?? undefined; this.host.engine.imessage("state.imported.state"); subjectData.ks = undefined; } this.storeGame(subjectData, stateLabel); this.host.engine.imessage("state.imported.game"); }; internalImport(userInput); } storeGame(game, label) { let gameLabel = label; if (isNil(gameLabel)) { gameLabel = UserScriptLoader.window.prompt(this.host.engine.i18n("state.storeGame.prompt")) ?? undefined; } if (isNil(gameLabel)) { return null; } // Normalize empty string to "no label". gameLabel = (gameLabel === "" ? undefined : gameLabel) ?? this.host.engine.i18n("state.unlabeledGame"); // Ensure labels aren't excessively long. gameLabel = gameLabel.substring(0, 127); this.games.push(new Unique({ game: game ?? this.host.game.save(), label: gameLabel, timestamp: new Date().toISOString(), })); this._storeGames(); this.requestRefresh(); return gameLabel; } storeState(state, label) { let stateLabel = label; if (isNil(stateLabel)) { stateLabel = UserScriptLoader.window.prompt(this.host.engine.i18n("state.storeState.prompt")) ?? undefined; } if (isNil(stateLabel)) { return null; } // Normalize empty string to "no label". stateLabel = (stateLabel === "" ? undefined : stateLabel) ?? this.host.engine.i18n("state.unlabeledState"); // Ensure labels aren't excessively long. stateLabel = stateLabel.substring(0, 127); this.states.push(new Unique({ label: stateLabel, state: state ?? this.host.engine.stateSerialize(), timestamp: new Date().toISOString(), })); this._storeStates(); this.requestRefresh(); return stateLabel; } storeStateFactoryDefaults() { this.storeState(Engine.DEFAULT_STATE); } storeAutoSave(state) { const existing = this.states.find(state => state.unwrap().label === "Auto-Save"); if (!isNil(existing)) { console.info(...cl("Updating existing Auto-Save...")); existing.replace({ ...existing.unwrap(), state, timestamp: new Date().toISOString(), }); this._storeStates(); this.requestRefresh(); return; } console.info(...cl("Storing new Auto-Save...")); this.storeState(state, "Auto-Save"); } exportStateAll() { const statesJson = this.states .map(state => state.unwrap()) .map(state => JSON.stringify({ label: state.label, state: KittenScientists.encodeSettings(state.state, false), timestamp: state.timestamp, })) .join("\n"); const a = document.createElement("a"); const blob = new Blob([statesJson], { type: "application/x-ndjson" }); const url = URL.createObjectURL(blob); a.setAttribute("href", url); a.setAttribute("download", `ks-local-states-${Date.now()}.ndjson`); a.click(); } async loadGame(game) { if (this._destructiveActionPrevented()) { return; } await new SavegameLoader(this.host.game).loadRaw(game); } loadState(state) { if (this._destructiveActionPrevented()) { return; } this.host.engine.stateLoad(state, true); this.host.refreshEntireUserInterface(); } loadAutoSave() { if (this.host.engine.isLoaded) { console.info(...cl("Not attempting to load Auto-Save, because a state is already loaded.")); return; } const existing = this.states.find(state => state.unwrap().label === "Auto-Save"); if (isNil(existing)) { console.info(...cl("No Auto-Save settings found.")); return; } console.info(...cl("Loading Auto-Save...")); this.host.engine.stateLoad(existing.unwrap().state, false); } updateGame(game, newGame) { if (this._destructiveActionPrevented()) { return; } const label = game.unwrap().label; game.replace({ game: newGame, label, timestamp: new Date().toISOString(), }); this._storeGames(); this.requestRefresh(); } updateState(state, newState) { if (this._destructiveActionPrevented()) { return; } const label = state.unwrap().label; state.replace({ label, state: newState, timestamp: new Date().toISOString(), }); this._storeStates(); this.requestRefresh(); } deleteGame(game, force = false) { if (!force && this._destructiveActionPrevented()) { return; } const index = this.games.indexOf(game); if (index < 0) { return; } this.games.splice(index, 1); this._storeGames(); this.requestRefresh(); } deleteState(state, force = false) { if (!force && this._destructiveActionPrevented()) { return; } const index = this.states.indexOf(state); if (index < 0) { return; } this.states.splice(index, 1); this._storeStates(); this.requestRefresh(); } _destructiveActionPrevented() { if (!this.setting.noConfirm.enabled && !UserScriptLoader.window.confirm(this.host.engine.i18n("state.confirmDestruction"))) { return true; } return false; } } //# sourceMappingURL=StateManagementUi.js.map