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:
text = text.lower()
if text.startswith("random"):
if text == "random-low":
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>.")
return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"):
return cls(cls.default)
elif text == "high":
@ -429,6 +401,45 @@ class Range(NumericOption):
return cls(0)
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
def from_any(cls, data: typing.Any) -> Range:
if type(data) == int:
@ -442,6 +453,40 @@ class Range(NumericOption):
def __str__(self) -> str:
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:
valid_keys = frozenset()
@ -585,13 +630,18 @@ class Accessibility(Choice):
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.
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
range_start = 0
range_end = 99
display_name = "Progression Balancing"
special_range_names = {
"Disabled": 0,
"Normal": 50,
"Extreme": 99,
}
common_options = {

View File

@ -4,6 +4,7 @@ from Utils import __version__
from jinja2 import Template
import yaml
import json
import typing
from worlds.AutoWorld import AutoWorldRegister
import Options
@ -17,13 +18,30 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
def create():
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
def dictify_range(option):
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
option.default: 50}
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {}
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 = {
special: "minimum value without special meaning",
option.range_start: "minimum 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
def default_converter(default_value):
@ -103,6 +121,12 @@ def create():
"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):
game_options[option_name] = {
"type": "items-list",

View File

@ -36,7 +36,8 @@ window.addEventListener('load', () => {
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch(() => {
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
@ -158,6 +159,70 @@ const buildOptionsTable = (settings, romOpts = false) => {
element.appendChild(rangeVal);
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:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;

View File

@ -77,6 +77,7 @@ const createDefaultSettings = (settingData) => {
});
break;
case 'range':
case 'special_range':
for (let i = setting.min; i <= setting.max; ++i){
newSettings[game][gameSetting][i] =
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
@ -285,6 +286,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
break;
case 'range':
case 'special_range':
const rangeTable = document.createElement('table');
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 ' +
`below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` +
`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);
const addOptionDiv = document.createElement('div');
@ -487,7 +497,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
break;
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;
}

View File

@ -137,6 +137,20 @@ html{
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{
display: block;
min-width: 200px;
@ -148,7 +162,7 @@ html{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: middle;
vertical-align: top;
}
@media all and (max-width: 1000px), all and (orientation: portrait){

View File

@ -1,7 +1,7 @@
import typing
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
if typing.TYPE_CHECKING:
@ -208,12 +208,12 @@ class MaximumCharmPrice(MinimumCharmPrice):
default = 20
class RandomCharmCosts(Range):
class RandomCharmCosts(SpecialRange):
"""Total Notch Cost of all Charms together. Vanilla sums to 90.
This value is distributed among all charms in a random fashion.
Special Cases:
Set to -1 for vanilla costs.
Set to -2 to shuffle around the vanilla costs to different charms."""
Set to -1 or vanilla for vanilla costs.
Set to -2 or shuffle to shuffle around the vanilla costs to different charms."""
display_name = "Randomize Charm Notch Costs"
range_start = -2
@ -221,6 +221,10 @@ class RandomCharmCosts(Range):
default = -1
vanilla_costs: typing.List[int] = 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]:
charms: typing.List[int]