Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Nov 16, 2023
48 changes: 41 additions & 7 deletions WebHostLib/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
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",
Expand All @@ -28,7 +25,7 @@ def get_html_doc(option_type: type(Options.Option)) -> str:
weighted_options = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "Player",
"name": "",
"game": {},
},
"games": {},
Expand All @@ -43,7 +40,7 @@ def get_html_doc(option_type: type(Options.Option)) -> str:
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
"name": "Player",
"name": "",
},
}

Expand Down Expand Up @@ -117,10 +114,47 @@ def get_html_doc(option_type: type(Options.Option)) -> str:
}

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 (option_value not in ["random-low", "random-high", "random-middle"] and
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
Comment on lines +131 to +132
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this codepath can only be reached if option_value is "random", just explicitly assign "random" here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

option_value is "random". Why hardcode two strings, when one does the same thing?

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:
Expand Down
157 changes: 142 additions & 15 deletions WebHostLib/static/assets/player-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
Expand All @@ -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();
};

Expand Down Expand Up @@ -77,15 +90,22 @@ const createDefaultOptions = (optionData) => {
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}

if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};

const buildUI = (optionData) => {
// Game Options
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));
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand All @@ -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}:`);
Expand Down
31 changes: 31 additions & 0 deletions WebHostLib/static/styles/player-options.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading