From 79ad54623b3d2fd23fba82e934d390d31697934f Mon Sep 17 00:00:00 2001 From: Zach Parks Date: Thu, 16 Nov 2023 04:37:06 -0600 Subject: [PATCH] WebHost, Core: Developer-defined game option presets. (#2143) --- WebHostLib/options.py | 47 +++++- WebHostLib/static/assets/player-options.js | 157 ++++++++++++++++++-- WebHostLib/static/styles/player-options.css | 31 ++++ WebHostLib/templates/player-options.html | 22 ++- docs/world api.md | 47 ++++++ test/webhost/test_option_presets.py | 63 ++++++++ worlds/AutoWorld.py | 3 + worlds/rogue_legacy/Presets.py | 61 ++++++++ worlds/rogue_legacy/__init__.py | 2 + 9 files changed, 407 insertions(+), 26 deletions(-) create mode 100644 test/webhost/test_option_presets.py create mode 100644 worlds/rogue_legacy/Presets.py diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 3c0f47f3..4d17c7fd 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -3,11 +3,8 @@ import logging import os import typing -import yaml -from jinja2 import Template - import Options -from Utils import __version__, local_path +from Utils import local_path from worlds.AutoWorld import AutoWorldRegister handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", @@ -28,7 +25,7 @@ def create(): weighted_options = { "baseOptions": { "description": "Generated by https://archipelago.gg/", - "name": "Player", + "name": "", "game": {}, }, "games": {}, @@ -43,7 +40,7 @@ def create(): "baseOptions": { "description": f"Generated by https://archipelago.gg/ for {game_name}", "game": game_name, - "name": "Player", + "name": "", }, } @@ -117,10 +114,46 @@ def create(): } else: - logging.debug(f"{option} not exported to Web options.") + logging.debug(f"{option} not exported to Web Options.") player_options["gameOptions"] = game_options + player_options["presetOptions"] = {} + for preset_name, preset in world.web.options_presets.items(): + player_options["presetOptions"][preset_name] = {} + for option_name, option_value in preset.items(): + # Random range type settings are not valid. + assert (not str(option_value).startswith("random-")), \ + f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \ + f"values are not supported for presets." + + # Normal random is supported, but needs to be handled explicitly. + if option_value == "random": + player_options["presetOptions"][preset_name][option_name] = option_value + continue + + option = world.options_dataclass.type_hints[option_name].from_any(option_value) + if isinstance(option, Options.SpecialRange) and isinstance(option_value, str): + assert option_value in option.special_range_names, \ + f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \ + f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." + + # Still use the true value for the option, not the name. + player_options["presetOptions"][preset_name][option_name] = option.value + elif isinstance(option, Options.Range): + player_options["presetOptions"][preset_name][option_name] = option.value + elif isinstance(option_value, str): + # For Choice and Toggle options, the value should be the name of the option. This is to prevent + # setting a preset for an option with an overridden from_text method that would normally be okay, + # but would not be okay for the webhost's current implementation of player options UI. + assert option.name_lookup[option.value] == option_value, \ + f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \ + f"Values must not be resolved to a different option via option.from_text (or an alias)." + player_options["presetOptions"][preset_name][option_name] = option.current_key + else: + # int and bool values are fine, just resolve them to the current key for webhost. + player_options["presetOptions"][preset_name][option_name] = option.current_key + os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True) with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f: diff --git a/WebHostLib/static/assets/player-options.js b/WebHostLib/static/assets/player-options.js index 727e0f63..54fae290 100644 --- a/WebHostLib/static/assets/player-options.js +++ b/WebHostLib/static/assets/player-options.js @@ -16,8 +16,9 @@ window.addEventListener('load', () => { } if (optionHash !== md5(JSON.stringify(results))) { - showUserMessage("Your options are out of date! Click here to update them! Be aware this will reset " + - "them all to default."); + showUserMessage( + 'Your options are out of date! Click here to update them! Be aware this will reset them all to default.' + ); document.getElementById('user-message').addEventListener('click', resetOptions); } @@ -36,6 +37,17 @@ window.addEventListener('load', () => { const nameInput = document.getElementById('player-name'); nameInput.addEventListener('keyup', (event) => updateBaseOption(event)); nameInput.value = playerOptions.name; + + // Presets + const presetSelect = document.getElementById('game-options-preset'); + presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value)); + for (const preset in results['presetOptions']) { + const presetOption = document.createElement('option'); + presetOption.innerText = preset; + presetSelect.appendChild(presetOption); + } + presetSelect.value = localStorage.getItem(`${gameName}-preset`); + results['presetOptions']['__default'] = {}; }).catch((e) => { console.error(e); const url = new URL(window.location.href); @@ -45,7 +57,8 @@ window.addEventListener('load', () => { const resetOptions = () => { localStorage.removeItem(gameName); - localStorage.removeItem(`${gameName}-hash`) + localStorage.removeItem(`${gameName}-hash`); + localStorage.removeItem(`${gameName}-preset`); window.location.reload(); }; @@ -77,6 +90,10 @@ const createDefaultOptions = (optionData) => { } localStorage.setItem(gameName, JSON.stringify(newOptions)); } + + if (!localStorage.getItem(`${gameName}-preset`)) { + localStorage.setItem(`${gameName}-preset`, '__default'); + } }; const buildUI = (optionData) => { @@ -84,8 +101,11 @@ const buildUI = (optionData) => { const leftGameOpts = {}; const rightGameOpts = {}; Object.keys(optionData.gameOptions).forEach((key, index) => { - if (index < Object.keys(optionData.gameOptions).length / 2) { leftGameOpts[key] = optionData.gameOptions[key]; } - else { rightGameOpts[key] = optionData.gameOptions[key]; } + if (index < Object.keys(optionData.gameOptions).length / 2) { + leftGameOpts[key] = optionData.gameOptions[key]; + } else { + rightGameOpts[key] = optionData.gameOptions[key]; + } }); document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts)); document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts)); @@ -120,7 +140,7 @@ const buildOptionsTable = (options, romOpts = false) => { const randomButton = document.createElement('button'); - switch(options[option].type){ + switch(options[option].type) { case 'select': element = document.createElement('div'); element.classList.add('select-container'); @@ -129,16 +149,17 @@ const buildOptionsTable = (options, romOpts = false) => { select.setAttribute('data-key', option); if (romOpts) { select.setAttribute('data-romOpt', '1'); } options[option].options.forEach((opt) => { - const option = document.createElement('option'); - option.setAttribute('value', opt.value); - option.innerText = opt.name; + const optionElement = document.createElement('option'); + optionElement.setAttribute('value', opt.value); + optionElement.innerText = opt.name; + if ((isNaN(currentOptions[gameName][option]) && (parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) || (opt.value === currentOptions[gameName][option])) { - option.selected = true; + optionElement.selected = true; } - select.appendChild(option); + select.appendChild(optionElement); }); select.addEventListener('change', (event) => updateGameOption(event.target)); element.appendChild(select); @@ -162,6 +183,7 @@ const buildOptionsTable = (options, romOpts = false) => { element.classList.add('range-container'); let range = document.createElement('input'); + range.setAttribute('id', option); range.setAttribute('type', 'range'); range.setAttribute('data-key', option); range.setAttribute('min', options[option].min); @@ -205,11 +227,11 @@ const buildOptionsTable = (options, romOpts = false) => { let presetOption = document.createElement('option'); presetOption.innerText = presetName; presetOption.value = options[option].value_names[presetName]; - const words = presetOption.innerText.split("_"); + const words = presetOption.innerText.split('_'); for (let i = 0; i < words.length; i++) { words[i] = words[i][0].toUpperCase() + words[i].substring(1); } - presetOption.innerText = words.join(" "); + presetOption.innerText = words.join(' '); specialRangeSelect.appendChild(presetOption); }); let customOption = document.createElement('option'); @@ -294,6 +316,90 @@ const buildOptionsTable = (options, romOpts = false) => { return table; }; +const setPresets = (optionsData, presetName) => { + const defaults = optionsData['gameOptions']; + const preset = optionsData['presetOptions'][presetName]; + + localStorage.setItem(`${gameName}-preset`, presetName); + + if (!preset) { + console.error(`No presets defined for preset name: '${presetName}'`); + return; + } + + const updateOptionElement = (option, presetValue) => { + const optionElement = document.querySelector(`#${option}[data-key='${option}']`); + const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`); + + if (presetValue === 'random') { + randomElement.classList.add('active'); + optionElement.disabled = true; + updateGameOption(randomElement, false); + } else { + optionElement.value = presetValue; + randomElement.classList.remove('active'); + optionElement.disabled = undefined; + updateGameOption(optionElement, false); + } + }; + + for (const option in defaults) { + let presetValue = preset[option]; + if (presetValue === undefined) { + // Using the default value if not set in presets. + presetValue = defaults[option]['defaultValue']; + } + + switch (defaults[option].type) { + case 'range': + const numberElement = document.querySelector(`#${option}-value`); + if (presetValue === 'random') { + numberElement.innerText = defaults[option]['defaultValue'] === 'random' + ? defaults[option]['min'] // A fallback so we don't print 'random' in the UI. + : defaults[option]['defaultValue']; + } else { + numberElement.innerText = presetValue; + } + + updateOptionElement(option, presetValue); + break; + + case 'select': { + updateOptionElement(option, presetValue); + break; + } + + case 'special_range': { + const selectElement = document.querySelector(`select[data-key='${option}']`); + const rangeElement = document.querySelector(`input[data-key='${option}']`); + const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`); + + if (presetValue === 'random') { + randomElement.classList.add('active'); + selectElement.disabled = true; + rangeElement.disabled = true; + updateGameOption(randomElement, false); + } else { + rangeElement.value = presetValue; + selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ? + parseInt(presetValue) : 'custom'; + document.getElementById(`${option}-value`).innerText = presetValue; + + randomElement.classList.remove('active'); + selectElement.disabled = undefined; + rangeElement.disabled = undefined; + updateGameOption(rangeElement, false); + } + break; + } + + default: + console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`); + break; + } + } +}; + const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { const active = event.target.classList.contains('active'); const randomButton = event.target; @@ -321,8 +427,15 @@ const updateBaseOption = (event) => { localStorage.setItem(gameName, JSON.stringify(options)); }; -const updateGameOption = (optionElement) => { +const updateGameOption = (optionElement, toggleCustomPreset = true) => { const options = JSON.parse(localStorage.getItem(gameName)); + + if (toggleCustomPreset) { + localStorage.setItem(`${gameName}-preset`, '__custom'); + const presetElement = document.getElementById('game-options-preset'); + presetElement.value = '__custom'; + } + if (optionElement.classList.contains('randomize-button')) { // If the event passed in is the randomize button, then we know what we must do. options[gameName][optionElement.getAttribute('data-key')] = 'random'; @@ -336,7 +449,21 @@ const updateGameOption = (optionElement) => { const exportOptions = () => { const options = JSON.parse(localStorage.getItem(gameName)); - if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) { + const preset = localStorage.getItem(`${gameName}-preset`); + switch (preset) { + case '__default': + options['description'] = `Generated by https://archipelago.gg with the default preset.`; + break; + + case '__custom': + options['description'] = `Generated by https://archipelago.gg.`; + break; + + default: + options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`; + } + + if (!options.name || options.name.trim().length === 0) { return showUserMessage('You must enter a player name!'); } const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); diff --git a/WebHostLib/static/styles/player-options.css b/WebHostLib/static/styles/player-options.css index 2f5481d2..7d6a1970 100644 --- a/WebHostLib/static/styles/player-options.css +++ b/WebHostLib/static/styles/player-options.css @@ -90,6 +90,31 @@ html{ flex-direction: row; } +#player-options #meta-options { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 3px; +} + +#player-options div { + display: flex; + flex-grow: 1; +} + +#player-options #meta-options label { + display: inline-block; + min-width: 180px; + flex-grow: 1; +} + +#player-options #meta-options input, +#player-options #meta-options select { + box-sizing: border-box; + min-width: 150px; + width: 50%; +} + #player-options .left, #player-options .right{ flex-grow: 1; } @@ -188,6 +213,12 @@ html{ border-radius: 0; } + #player-options #meta-options { + flex-direction: column; + justify-content: flex-start; + gap: 6px; + } + #player-options #game-options{ justify-content: flex-start; flex-wrap: wrap; diff --git a/WebHostLib/templates/player-options.html b/WebHostLib/templates/player-options.html index 701b4e58..4c749752 100644 --- a/WebHostLib/templates/player-options.html +++ b/WebHostLib/templates/player-options.html @@ -28,10 +28,24 @@ template file for this game.

-


- -

+
+
+ + +
+
+ + +
+ +

Game Options

diff --git a/docs/world api.md b/docs/world api.md index 67a44c06..4008c9c4 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -73,6 +73,53 @@ for your world specifically on the webhost: `game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be prefixed with the same string as defined here. Default already has 'en'. +`options_presets` (optional) A `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values +are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names of +the options and the values are the values to be set for that option. These presets will be available for users to select from on the game's options page. + +Note: The values must be a non-aliased value for the option type and can only include the following option types: + + - If you have a `Range`/`SpecialRange` option, the value should be an `int` between the `range_start` and `range_end` + values. + - If you have a `SpecialRange` option, the value can alternatively be a `str` that is one of the + `special_range_names` keys. + - If you have a `Choice` option, the value should be a `str` that is one of the `option_` values. + - If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`. + - `random` is also a valid value for any of these option types. + +`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on the webhost at this time. + +Here is an example of a defined preset: +```python +# presets.py +options_presets = { + "Limited Potential": { + "progression_balancing": 0, + "fairy_chests_per_zone": 2, + "starting_class": "random", + "chests_per_zone": 30, + "vendors": "normal", + "architect": "disabled", + "gold_gain_multiplier": "half", + "number_of_children": 2, + "free_diary_on_generation": False, + "health_pool": 10, + "mana_pool": 10, + "attack_pool": 10, + "magic_damage_pool": 10, + "armor_pool": 5, + "equip_pool": 10, + "crit_chance_pool": 5, + "crit_damage_pool": 5, + } +} + +# __init__.py +class RLWeb(WebWorld): + options_presets = options_presets + # ... +``` + ### MultiWorld Object The `MultiWorld` object references the whole multiworld (all items and locations diff --git a/test/webhost/test_option_presets.py b/test/webhost/test_option_presets.py new file mode 100644 index 00000000..8c6ebea2 --- /dev/null +++ b/test/webhost/test_option_presets.py @@ -0,0 +1,63 @@ +import unittest + +from worlds import AutoWorldRegister +from Options import Choice, SpecialRange, Toggle, Range + + +class TestOptionPresets(unittest.TestCase): + def test_option_presets_have_valid_options(self): + """Test that all predefined option presets are valid options.""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + presets = world_type.web.options_presets + for preset_name, preset in presets.items(): + for option_name, option_value in preset.items(): + with self.subTest(game=game_name, preset=preset_name, option=option_name): + try: + option = world_type.options_dataclass.type_hints[option_name].from_any(option_value) + supported_types = [Choice, Toggle, Range, SpecialRange] + if not any([issubclass(option.__class__, t) for t in supported_types]): + self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' " + f"is not a supported type for webhost. " + f"Supported types: {', '.join([t.__name__ for t in supported_types])}") + except AssertionError as ex: + self.fail(f"Option '{option_name}': '{option_value}' in preset '{preset_name}' for game " + f"'{game_name}' is not valid. Error: {ex}") + except KeyError as ex: + self.fail(f"Option '{option_name}' in preset '{preset_name}' for game '{game_name}' is " + f"not a defined option. Error: {ex}") + + def test_option_preset_values_are_explicitly_defined(self): + """Test that option preset values are not a special flavor of 'random' or use from_text to resolve another + value. + """ + for game_name, world_type in AutoWorldRegister.world_types.items(): + presets = world_type.web.options_presets + for preset_name, preset in presets.items(): + for option_name, option_value in preset.items(): + with self.subTest(game=game_name, preset=preset_name, option=option_name): + # Check for non-standard random values. + self.assertFalse( + str(option_value).startswith("random-"), + f"'{option_name}': '{option_value}' in preset '{preset_name}' for game '{game_name}' " + f"is not supported for webhost. Special random values are not supported for presets." + ) + + option = world_type.options_dataclass.type_hints[option_name].from_any(option_value) + + # Check for from_text resolving to a different value. ("random" is allowed though.) + if option_value != "random" and isinstance(option_value, str): + # Allow special named values for SpecialRange option presets. + if isinstance(option, SpecialRange): + self.assertTrue( + option_value in option.special_range_names, + f"Invalid preset '{option_name}': '{option_value}' in preset '{preset_name}' " + f"for game '{game_name}'. Expected {option.special_range_names.keys()} or " + f"{option.range_start}-{option.range_end}." + ) + else: + self.assertTrue( + option.name_lookup.get(option.value, None) == option_value, + f"'{option_name}': '{option_value}' in preset '{preset_name}' for game " + f"'{game_name}' is not supported for webhost. Values must not be resolved to a " + f"different option via option.from_text (or an alias)." + ) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 67403472..5d0533e0 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -186,6 +186,9 @@ class WebWorld: bug_report_page: Optional[str] """display a link to a bug report page, most likely a link to a GitHub issue page.""" + options_presets: Dict[str, Dict[str, Any]] = {} + """A dictionary containing a collection of developer-defined game option presets.""" + class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. diff --git a/worlds/rogue_legacy/Presets.py b/worlds/rogue_legacy/Presets.py new file mode 100644 index 00000000..a4284e9f --- /dev/null +++ b/worlds/rogue_legacy/Presets.py @@ -0,0 +1,61 @@ +from typing import Any, Dict + +from .Options import Architect, GoldGainMultiplier, Vendors + +rl_options_presets: Dict[str, Dict[str, Any]] = { + # Example preset using only literal values. + "Unknown Fate": { + "progression_balancing": "random", + "accessibility": "random", + "starting_gender": "random", + "starting_class": "random", + "new_game_plus": "random", + "fairy_chests_per_zone": "random", + "chests_per_zone": "random", + "universal_fairy_chests": "random", + "universal_chests": "random", + "vendors": "random", + "architect": "random", + "architect_fee": "random", + "disable_charon": "random", + "require_purchasing": "random", + "progressive_blueprints": "random", + "gold_gain_multiplier": "random", + "number_of_children": "random", + "free_diary_on_generation": "random", + "khidr": "random", + "alexander": "random", + "leon": "random", + "herodotus": "random", + "health_pool": "random", + "mana_pool": "random", + "attack_pool": "random", + "magic_damage_pool": "random", + "armor_pool": "random", + "equip_pool": "random", + "crit_chance_pool": "random", + "crit_damage_pool": "random", + "allow_default_names": False, + "death_link": "random", + }, + # A preset I actually use, using some literal values and some from the option itself. + "Limited Potential": { + "progression_balancing": "disabled", + "fairy_chests_per_zone": 2, + "starting_class": "random", + "chests_per_zone": 30, + "vendors": Vendors.option_normal, + "architect": Architect.option_disabled, + "gold_gain_multiplier": GoldGainMultiplier.option_half, + "number_of_children": 2, + "free_diary_on_generation": False, + "health_pool": 10, + "mana_pool": 10, + "attack_pool": 10, + "magic_damage_pool": 10, + "armor_pool": 5, + "equip_pool": 10, + "crit_chance_pool": 5, + "crit_damage_pool": 5, + } +} diff --git a/worlds/rogue_legacy/__init__.py b/worlds/rogue_legacy/__init__.py index 68a0c856..c5a8d71b 100644 --- a/worlds/rogue_legacy/__init__.py +++ b/worlds/rogue_legacy/__init__.py @@ -5,6 +5,7 @@ from worlds.AutoWorld import WebWorld, World from .Items import RLItem, RLItemData, event_item_table, get_items_by_category, item_table from .Locations import RLLocation, location_table from .Options import rl_options +from .Presets import rl_options_presets from .Regions import create_regions from .Rules import set_rules @@ -22,6 +23,7 @@ class RLWeb(WebWorld): )] bug_report_page = "https://github.com/ThePhar/RogueLegacyRandomizer/issues/new?assignees=&labels=bug&template=" \ "report-an-issue---.md&title=%5BIssue%5D" + options_presets = rl_options_presets class RLWorld(World):