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):