From e7ea827f02a015b97071b7265012163f8f1d2b22 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 12 Jun 2022 23:33:14 +0200 Subject: [PATCH] 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 --- Options.py | 110 +++++++++++++----- WebHostLib/options.py | 30 ++++- WebHostLib/static/assets/player-settings.js | 67 ++++++++++- WebHostLib/static/assets/weighted-settings.js | 12 +- WebHostLib/static/styles/player-settings.css | 16 ++- worlds/hk/Options.py | 12 +- 6 files changed, 207 insertions(+), 40 deletions(-) diff --git a/Options.py b/Options.py index abc0facc..9b25368e 100644 --- a/Options.py +++ b/Options.py @@ -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--, random-range-middle--, random-range-high--, or random-range--.") + 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--, random-range-middle--, " + f"random-range-high--, or random-range--.") + + @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--, random-range-middle--, " + f"random-range-high--, or random-range--.") + 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 = { diff --git a/WebHostLib/options.py b/WebHostLib/options.py index f4bf6f15..203f2235 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -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", diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js index 9b455e4f..21c6414d 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-settings.js @@ -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; diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index c963e74c..a438d0c6 100644 --- a/WebHostLib/static/assets/weighted-settings.js +++ b/WebHostLib/static/assets/weighted-settings.js @@ -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.
Minimum value: ${setting.min}
` + `Maximum value: ${setting.max}`; + + if (setting.hasOwnProperty('value_names')) { + hintText.innerHTML += '

Certain values have special meaning:'; + Object.keys(setting.value_names).forEach((specialName) => { + hintText.innerHTML += `
${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; } diff --git a/WebHostLib/static/styles/player-settings.css b/WebHostLib/static/styles/player-settings.css index 4b64448c..951d7622 100644 --- a/WebHostLib/static/styles/player-settings.css +++ b/WebHostLib/static/styles/player-settings.css @@ -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){ diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index ae01af40..7b582440 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -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]