WebHost, Core: Developer-defined game option presets. (#2143)
This commit is contained in:
parent
3619abc7ca
commit
79ad54623b
|
@ -3,11 +3,8 @@ import logging
|
||||||
import os
|
import os
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import yaml
|
|
||||||
from jinja2 import Template
|
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import __version__, local_path
|
from Utils import local_path
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
|
@ -28,7 +25,7 @@ def create():
|
||||||
weighted_options = {
|
weighted_options = {
|
||||||
"baseOptions": {
|
"baseOptions": {
|
||||||
"description": "Generated by https://archipelago.gg/",
|
"description": "Generated by https://archipelago.gg/",
|
||||||
"name": "Player",
|
"name": "",
|
||||||
"game": {},
|
"game": {},
|
||||||
},
|
},
|
||||||
"games": {},
|
"games": {},
|
||||||
|
@ -43,7 +40,7 @@ def create():
|
||||||
"baseOptions": {
|
"baseOptions": {
|
||||||
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||||
"game": game_name,
|
"game": game_name,
|
||||||
"name": "Player",
|
"name": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,10 +114,46 @@ def create():
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
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["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)
|
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:
|
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
|
||||||
|
|
|
@ -16,8 +16,9 @@ window.addEventListener('load', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (optionHash !== md5(JSON.stringify(results))) {
|
if (optionHash !== md5(JSON.stringify(results))) {
|
||||||
showUserMessage("Your options are out of date! Click here to update them! Be aware this will reset " +
|
showUserMessage(
|
||||||
"them all to default.");
|
'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);
|
document.getElementById('user-message').addEventListener('click', resetOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,6 +37,17 @@ window.addEventListener('load', () => {
|
||||||
const nameInput = document.getElementById('player-name');
|
const nameInput = document.getElementById('player-name');
|
||||||
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
|
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
|
||||||
nameInput.value = playerOptions.name;
|
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) => {
|
}).catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
@ -45,7 +57,8 @@ window.addEventListener('load', () => {
|
||||||
|
|
||||||
const resetOptions = () => {
|
const resetOptions = () => {
|
||||||
localStorage.removeItem(gameName);
|
localStorage.removeItem(gameName);
|
||||||
localStorage.removeItem(`${gameName}-hash`)
|
localStorage.removeItem(`${gameName}-hash`);
|
||||||
|
localStorage.removeItem(`${gameName}-preset`);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -77,6 +90,10 @@ const createDefaultOptions = (optionData) => {
|
||||||
}
|
}
|
||||||
localStorage.setItem(gameName, JSON.stringify(newOptions));
|
localStorage.setItem(gameName, JSON.stringify(newOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!localStorage.getItem(`${gameName}-preset`)) {
|
||||||
|
localStorage.setItem(`${gameName}-preset`, '__default');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildUI = (optionData) => {
|
const buildUI = (optionData) => {
|
||||||
|
@ -84,8 +101,11 @@ const buildUI = (optionData) => {
|
||||||
const leftGameOpts = {};
|
const leftGameOpts = {};
|
||||||
const rightGameOpts = {};
|
const rightGameOpts = {};
|
||||||
Object.keys(optionData.gameOptions).forEach((key, index) => {
|
Object.keys(optionData.gameOptions).forEach((key, index) => {
|
||||||
if (index < Object.keys(optionData.gameOptions).length / 2) { leftGameOpts[key] = optionData.gameOptions[key]; }
|
if (index < Object.keys(optionData.gameOptions).length / 2) {
|
||||||
else { rightGameOpts[key] = optionData.gameOptions[key]; }
|
leftGameOpts[key] = optionData.gameOptions[key];
|
||||||
|
} else {
|
||||||
|
rightGameOpts[key] = optionData.gameOptions[key];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||||
|
@ -120,7 +140,7 @@ const buildOptionsTable = (options, romOpts = false) => {
|
||||||
|
|
||||||
const randomButton = document.createElement('button');
|
const randomButton = document.createElement('button');
|
||||||
|
|
||||||
switch(options[option].type){
|
switch(options[option].type) {
|
||||||
case 'select':
|
case 'select':
|
||||||
element = document.createElement('div');
|
element = document.createElement('div');
|
||||||
element.classList.add('select-container');
|
element.classList.add('select-container');
|
||||||
|
@ -129,16 +149,17 @@ const buildOptionsTable = (options, romOpts = false) => {
|
||||||
select.setAttribute('data-key', option);
|
select.setAttribute('data-key', option);
|
||||||
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||||
options[option].options.forEach((opt) => {
|
options[option].options.forEach((opt) => {
|
||||||
const option = document.createElement('option');
|
const optionElement = document.createElement('option');
|
||||||
option.setAttribute('value', opt.value);
|
optionElement.setAttribute('value', opt.value);
|
||||||
option.innerText = opt.name;
|
optionElement.innerText = opt.name;
|
||||||
|
|
||||||
if ((isNaN(currentOptions[gameName][option]) &&
|
if ((isNaN(currentOptions[gameName][option]) &&
|
||||||
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
|
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
|
||||||
(opt.value === 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));
|
select.addEventListener('change', (event) => updateGameOption(event.target));
|
||||||
element.appendChild(select);
|
element.appendChild(select);
|
||||||
|
@ -162,6 +183,7 @@ const buildOptionsTable = (options, romOpts = false) => {
|
||||||
element.classList.add('range-container');
|
element.classList.add('range-container');
|
||||||
|
|
||||||
let range = document.createElement('input');
|
let range = document.createElement('input');
|
||||||
|
range.setAttribute('id', option);
|
||||||
range.setAttribute('type', 'range');
|
range.setAttribute('type', 'range');
|
||||||
range.setAttribute('data-key', option);
|
range.setAttribute('data-key', option);
|
||||||
range.setAttribute('min', options[option].min);
|
range.setAttribute('min', options[option].min);
|
||||||
|
@ -205,11 +227,11 @@ const buildOptionsTable = (options, romOpts = false) => {
|
||||||
let presetOption = document.createElement('option');
|
let presetOption = document.createElement('option');
|
||||||
presetOption.innerText = presetName;
|
presetOption.innerText = presetName;
|
||||||
presetOption.value = options[option].value_names[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++) {
|
for (let i = 0; i < words.length; i++) {
|
||||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||||
}
|
}
|
||||||
presetOption.innerText = words.join(" ");
|
presetOption.innerText = words.join(' ');
|
||||||
specialRangeSelect.appendChild(presetOption);
|
specialRangeSelect.appendChild(presetOption);
|
||||||
});
|
});
|
||||||
let customOption = document.createElement('option');
|
let customOption = document.createElement('option');
|
||||||
|
@ -294,6 +316,90 @@ const buildOptionsTable = (options, romOpts = false) => {
|
||||||
return table;
|
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 toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
|
||||||
const active = event.target.classList.contains('active');
|
const active = event.target.classList.contains('active');
|
||||||
const randomButton = event.target;
|
const randomButton = event.target;
|
||||||
|
@ -321,8 +427,15 @@ const updateBaseOption = (event) => {
|
||||||
localStorage.setItem(gameName, JSON.stringify(options));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGameOption = (optionElement) => {
|
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
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 (optionElement.classList.contains('randomize-button')) {
|
||||||
// If the event passed in is the randomize button, then we know what we must do.
|
// If the event passed in is the randomize button, then we know what we must do.
|
||||||
options[gameName][optionElement.getAttribute('data-key')] = 'random';
|
options[gameName][optionElement.getAttribute('data-key')] = 'random';
|
||||||
|
@ -336,7 +449,21 @@ const updateGameOption = (optionElement) => {
|
||||||
|
|
||||||
const exportOptions = () => {
|
const exportOptions = () => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
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!');
|
return showUserMessage('You must enter a player name!');
|
||||||
}
|
}
|
||||||
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||||
|
|
|
@ -90,6 +90,31 @@ html{
|
||||||
flex-direction: row;
|
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{
|
#player-options .left, #player-options .right{
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
@ -188,6 +213,12 @@ html{
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#player-options #meta-options {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
#player-options #game-options{
|
#player-options #game-options{
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
@ -28,10 +28,24 @@
|
||||||
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
|
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
|
<div id="meta-options">
|
||||||
items if you are playing in a MultiWorld.</label><br />
|
<div>
|
||||||
<input id="player-name" placeholder="Player Name" data-key="name" maxlength="16" />
|
<label for="player-name">
|
||||||
</p>
|
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>
|
<h2>Game Options</h2>
|
||||||
<div id="game-options">
|
<div id="game-options">
|
||||||
|
|
|
@ -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
|
`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'.
|
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
|
### MultiWorld Object
|
||||||
|
|
||||||
The `MultiWorld` object references the whole multiworld (all items and locations
|
The `MultiWorld` object references the whole multiworld (all items and locations
|
||||||
|
|
|
@ -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)."
|
||||||
|
)
|
|
@ -186,6 +186,9 @@ class WebWorld:
|
||||||
bug_report_page: Optional[str]
|
bug_report_page: Optional[str]
|
||||||
"""display a link to a bug report page, most likely a link to a GitHub issue page."""
|
"""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):
|
class World(metaclass=AutoWorldRegister):
|
||||||
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
|
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 .Items import RLItem, RLItemData, event_item_table, get_items_by_category, item_table
|
||||||
from .Locations import RLLocation, location_table
|
from .Locations import RLLocation, location_table
|
||||||
from .Options import rl_options
|
from .Options import rl_options
|
||||||
|
from .Presets import rl_options_presets
|
||||||
from .Regions import create_regions
|
from .Regions import create_regions
|
||||||
from .Rules import set_rules
|
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=" \
|
bug_report_page = "https://github.com/ThePhar/RogueLegacyRandomizer/issues/new?assignees=&labels=bug&template=" \
|
||||||
"report-an-issue---.md&title=%5BIssue%5D"
|
"report-an-issue---.md&title=%5BIssue%5D"
|
||||||
|
options_presets = rl_options_presets
|
||||||
|
|
||||||
|
|
||||||
class RLWorld(World):
|
class RLWorld(World):
|
||||||
|
|
Loading…
Reference in New Issue