WebHost, Core: Developer-defined game option presets. (#2143)

This commit is contained in:
Zach Parks 2023-11-16 04:37:06 -06:00 committed by GitHub
parent 3619abc7ca
commit 79ad54623b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 407 additions and 26 deletions

View File

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

View File

@ -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}:`);

View File

@ -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;

View File

@ -28,10 +28,24 @@
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
</p>
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
items if you are playing in a MultiWorld.</label><br />
<input id="player-name" placeholder="Player Name" data-key="name" maxlength="16" />
</p>
<div id="meta-options">
<div>
<label for="player-name">
Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
</label>
<input id="player-name" placeholder="Player" data-key="name" maxlength="16" />
</div>
<div>
<label for="game-options-preset">
Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
</label>
<select id="game-options-preset">
<option value="__default">Defaults</option>
<option value="__custom" hidden>Custom</option>
</select>
</div>
</div>
<h2>Game Options</h2>
<div id="game-options">

View File

@ -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_<name>` 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

View File

@ -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)."
)

View File

@ -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.

View File

@ -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,
}
}

View File

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