Options: introduce SpecialRange (#630)

* Options: introduce SpecialRange

* Include SpecialRange data in player-settings and weighted-settings JSON files

* Add support for SpecialRange to player-settings pages

* Add support for SpecialRange options to weighted-settings. Also fixed a bug which would cause the page to crash if an unknown setting was detected.

Co-authored-by: Chris Wilson <chris@legendserver.info>
This commit is contained in:
Fabian Dill 2022-06-12 23:33:14 +02:00 committed by GitHub
parent 84b6ece31d
commit e7ea827f02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 207 additions and 40 deletions

View File

@ -383,35 +383,7 @@ class Range(NumericOption):
def from_text(cls, text: str) -> Range: def from_text(cls, text: str) -> Range:
text = text.lower() text = text.lower()
if text.startswith("random"): if text.startswith("random"):
if text == "random-low": return cls.weighted_range(text)
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
elif text == "random-high":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
elif text == "random-middle":
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
elif text.startswith("random-range-"):
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
elif text.startswith("random-range-middle"):
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
elif text.startswith("random-range-high"):
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
else:
return cls(int(round(random.randint(random_range[0], random_range[1]))))
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
elif text == "default" and hasattr(cls, "default"): elif text == "default" and hasattr(cls, "default"):
return cls(cls.default) return cls(cls.default)
elif text == "high": elif text == "high":
@ -429,6 +401,45 @@ class Range(NumericOption):
return cls(0) return cls(0)
return cls(int(text)) return cls(int(text))
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
@classmethod
def custom_range(cls, text) -> Range:
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
else:
return cls(random.randint(random_range[0], random_range[1]))
@classmethod @classmethod
def from_any(cls, data: typing.Any) -> Range: def from_any(cls, data: typing.Any) -> Range:
if type(data) == int: if type(data) == int:
@ -442,6 +453,40 @@ class Range(NumericOption):
def __str__(self) -> str: def __str__(self) -> str:
return str(self.value) return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
return int(round(random.triangular(lower, end, tri), 0))
class SpecialRange(Range):
special_range_cutoff = 0
special_range_names: typing.Dict[str, int] = {}
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
if text in cls.special_range_names:
return cls(cls.special_range_names[text])
return super().from_text(text)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
elif text == "random-high":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class VerifyKeys: class VerifyKeys:
valid_keys = frozenset() valid_keys = frozenset()
@ -585,13 +630,18 @@ class Accessibility(Choice):
default = 1 default = 1
class ProgressionBalancing(Range): class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck.""" [0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
display_name = "Progression Balancing" display_name = "Progression Balancing"
special_range_names = {
"Disabled": 0,
"Normal": 50,
"Extreme": 99,
}
common_options = { common_options = {

View File

@ -4,6 +4,7 @@ from Utils import __version__
from jinja2 import Template from jinja2 import Template
import yaml import yaml
import json import json
import typing
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import Options import Options
@ -17,13 +18,30 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
def create(): def create():
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True) os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
def dictify_range(option): def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0, data = {}
option.default: 50} special = getattr(option, "special_range_cutoff", None)
if special is not None:
data[special] = 0
data.update({
option.range_start: 0,
option.range_end: 0,
"random": 0, "random-low": 0, "random-high": 0,
option.default: 50
})
notes = { notes = {
special: "minimum value without special meaning",
option.range_start: "minimum value", option.range_start: "minimum value",
option.range_end: "maximum value" option.range_end: "maximum value"
} }
for name, number in getattr(option, "special_range_names", {}).items():
if number in data:
data[name] = data[number]
del data[number]
else:
data[name] = 0
return data, notes return data, notes
def default_converter(default_value): def default_converter(default_value):
@ -103,6 +121,12 @@ def create():
"max": option.range_end, "max": option.range_end,
} }
if hasattr(option, "special_range_names"):
game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif getattr(option, "verify_item_name", False): elif getattr(option, "verify_item_name", False):
game_options[option_name] = { game_options[option_name] = {
"type": "items-list", "type": "items-list",

View File

@ -36,7 +36,8 @@ window.addEventListener('load', () => {
const nameInput = document.getElementById('player-name'); const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event)); nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name; nameInput.value = playerSettings.name;
}).catch(() => { }).catch((e) => {
console.error(e);
const url = new URL(window.location.href); const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`); window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
}) })
@ -158,6 +159,70 @@ const buildOptionsTable = (settings, romOpts = false) => {
element.appendChild(rangeVal); element.appendChild(rangeVal);
break; break;
case 'special_range':
element = document.createElement('div');
element.classList.add('special-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', setting);
specialRange.setAttribute('min', settings[setting].min);
specialRange.setAttribute('max', settings[setting].max);
specialRange.value = currentSettings[gameName][setting];
// Build rage value element
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
break;
default: default:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`); console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return; return;

View File

@ -77,6 +77,7 @@ const createDefaultSettings = (settingData) => {
}); });
break; break;
case 'range': case 'range':
case 'special_range':
for (let i = setting.min; i <= setting.max; ++i){ for (let i = setting.min; i <= setting.max; ++i){
newSettings[game][gameSetting][i] = newSettings[game][gameSetting][i] =
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0; (setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
@ -285,6 +286,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
break; break;
case 'range': case 'range':
case 'special_range':
const rangeTable = document.createElement('table'); const rangeTable = document.createElement('table');
const rangeTbody = document.createElement('tbody'); const rangeTbody = document.createElement('tbody');
@ -325,6 +327,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' + hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
`below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` + `below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` +
`Maximum value: ${setting.max}`; `Maximum value: ${setting.max}`;
if (setting.hasOwnProperty('value_names')) {
hintText.innerHTML += '<br /><br />Certain values have special meaning:';
Object.keys(setting.value_names).forEach((specialName) => {
hintText.innerHTML += `<br />${specialName}: ${setting.value_names[specialName]}`;
});
}
settingWrapper.appendChild(hintText); settingWrapper.appendChild(hintText);
const addOptionDiv = document.createElement('div'); const addOptionDiv = document.createElement('div');
@ -487,7 +497,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
break; break;
default: default:
console.error(`Unknown setting type for ${game} setting ${setting}: ${settings[setting].type}`); console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
return; return;
} }

View File

@ -137,6 +137,20 @@ html{
margin-left: 0.25rem; margin-left: 0.25rem;
} }
#player-settings table .special-range-container{
display: flex;
flex-direction: column;
}
#player-settings table .special-range-wrapper{
display: flex;
flex-direction: row;
}
#player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1;
}
#player-settings table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;
@ -148,7 +162,7 @@ html{
border: none; border: none;
padding: 3px; padding: 3px;
font-size: 17px; font-size: 17px;
vertical-align: middle; vertical-align: top;
} }
@media all and (max-width: 1000px), all and (orientation: portrait){ @media all and (max-width: 1000px), all and (orientation: portrait){

View File

@ -1,7 +1,7 @@
import typing import typing
from .ExtractedData import logic_options, starts, pool_options from .ExtractedData import logic_options, starts, pool_options
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, SpecialRange
from .Charms import vanilla_costs, names as charm_names from .Charms import vanilla_costs, names as charm_names
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@ -208,12 +208,12 @@ class MaximumCharmPrice(MinimumCharmPrice):
default = 20 default = 20
class RandomCharmCosts(Range): class RandomCharmCosts(SpecialRange):
"""Total Notch Cost of all Charms together. Vanilla sums to 90. """Total Notch Cost of all Charms together. Vanilla sums to 90.
This value is distributed among all charms in a random fashion. This value is distributed among all charms in a random fashion.
Special Cases: Special Cases:
Set to -1 for vanilla costs. Set to -1 or vanilla for vanilla costs.
Set to -2 to shuffle around the vanilla costs to different charms.""" Set to -2 or shuffle to shuffle around the vanilla costs to different charms."""
display_name = "Randomize Charm Notch Costs" display_name = "Randomize Charm Notch Costs"
range_start = -2 range_start = -2
@ -221,6 +221,10 @@ class RandomCharmCosts(Range):
default = -1 default = -1
vanilla_costs: typing.List[int] = vanilla_costs vanilla_costs: typing.List[int] = vanilla_costs
charm_count: int = len(vanilla_costs) charm_count: int = len(vanilla_costs)
special_range_names = {
"vanilla": -1,
"shuffle": -2
}
def get_costs(self, random_source: Random) -> typing.List[int]: def get_costs(self, random_source: Random) -> typing.List[int]:
charms: typing.List[int] charms: typing.List[int]