From c944ecf628c393ff3d296ce95355298cbce31086 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Nov 2023 00:10:52 +0100 Subject: [PATCH] Core: Introduce new Option class NamedRange (#2330) Co-authored-by: Chris Wilson Co-authored-by: Zach Parks --- Options.py | 29 +- WebHostLib/options.py | 6 +- WebHostLib/static/assets/player-options.js | 62 ++-- WebHostLib/static/assets/weighted-options.js | 337 +++++++++--------- WebHostLib/static/styles/player-options.css | 6 +- docs/options api.md | 9 +- docs/world api.md | 4 +- test/webhost/test_option_presets.py | 8 +- worlds/dlcquest/Options.py | 4 +- worlds/dlcquest/test/TestOptionsLong.py | 4 +- worlds/hk/Options.py | 6 +- worlds/lufia2ac/Options.py | 17 +- worlds/pokemon_rb/options.py | 14 +- worlds/stardew_valley/options.py | 16 +- worlds/stardew_valley/test/TestOptions.py | 8 +- .../test/long/TestOptionsLong.py | 4 +- .../test/long/TestRandomWorlds.py | 4 +- worlds/zillion/options.py | 6 +- 18 files changed, 290 insertions(+), 254 deletions(-) diff --git a/Options.py b/Options.py index 9b4f9d99..2e3927aa 100644 --- a/Options.py +++ b/Options.py @@ -696,11 +696,19 @@ class Range(NumericOption): return int(round(random.triangular(lower, end, tri), 0)) -class SpecialRange(Range): - special_range_cutoff = 0 +class NamedRange(Range): special_range_names: typing.Dict[str, int] = {} """Special Range names have to be all lowercase as matching is done with text.lower()""" + def __init__(self, value: int) -> None: + if value < self.range_start and value not in self.special_range_names.values(): + raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " + + f"and is also not one of the supported named special values: {self.special_range_names}") + elif value > self.range_end and value not in self.special_range_names.values(): + raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + + f"and is also not one of the supported named special values: {self.special_range_names}") + self.value = value + @classmethod def from_text(cls, text: str) -> Range: text = text.lower() @@ -708,6 +716,19 @@ class SpecialRange(Range): return cls(cls.special_range_names[text]) return super().from_text(text) + +class SpecialRange(NamedRange): + special_range_cutoff = 0 + + # TODO: remove class SpecialRange, earliest 3 releases after 0.4.3 + def __new__(cls, value: int) -> SpecialRange: + from Utils import deprecate + deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. " + "Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In " + "NamedRange, range_start specifies the lower end of the regular range, while special values can be " + "placed anywhere (below, inside, or above the regular range).") + return super().__new__(cls, value) + @classmethod def weighted_range(cls, text) -> Range: if text == "random-low": @@ -891,7 +912,7 @@ class Accessibility(Choice): default = 1 -class ProgressionBalancing(SpecialRange): +class ProgressionBalancing(NamedRange): """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. A lower setting means more getting stuck. A higher setting means less getting stuck.""" default = 50 @@ -1108,7 +1129,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge if os.path.isfile(full_path) and full_path.endswith(".yaml"): os.unlink(full_path) - def dictify_range(option: typing.Union[Range, SpecialRange]): + def dictify_range(option: Range): data = {option.default: 50} for sub_option in ["random", "random-low", "random-high"]: if sub_option != option.default: diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 4d17c7fd..0158de7e 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -81,8 +81,8 @@ def create(): "max": option.range_end, } - if issubclass(option, Options.SpecialRange): - game_options[option_name]["type"] = 'special_range' + if issubclass(option, Options.NamedRange): + game_options[option_name]["type"] = 'named_range' game_options[option_name]["value_names"] = {} for key, val in option.special_range_names.items(): game_options[option_name]["value_names"][key] = val @@ -133,7 +133,7 @@ def create(): continue option = world.options_dataclass.type_hints[option_name].from_any(option_value) - if isinstance(option, Options.SpecialRange) and isinstance(option_value, str): + if isinstance(option, Options.NamedRange) 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}." diff --git a/WebHostLib/static/assets/player-options.js b/WebHostLib/static/assets/player-options.js index 2bb4a3ba..37ba7f98 100644 --- a/WebHostLib/static/assets/player-options.js +++ b/WebHostLib/static/assets/player-options.js @@ -216,13 +216,13 @@ const buildOptionsTable = (options, romOpts = false) => { element.appendChild(randomButton); break; - case 'special_range': + case 'named_range': element = document.createElement('div'); - element.classList.add('special-range-container'); + element.classList.add('named-range-container'); // Build the select element - let specialRangeSelect = document.createElement('select'); - specialRangeSelect.setAttribute('data-key', option); + let namedRangeSelect = document.createElement('select'); + namedRangeSelect.setAttribute('data-key', option); Object.keys(options[option].value_names).forEach((presetName) => { let presetOption = document.createElement('option'); presetOption.innerText = presetName; @@ -232,58 +232,58 @@ const buildOptionsTable = (options, romOpts = false) => { words[i] = words[i][0].toUpperCase() + words[i].substring(1); } presetOption.innerText = words.join(' '); - specialRangeSelect.appendChild(presetOption); + namedRangeSelect.appendChild(presetOption); }); let customOption = document.createElement('option'); customOption.innerText = 'Custom'; customOption.value = 'custom'; customOption.selected = true; - specialRangeSelect.appendChild(customOption); + namedRangeSelect.appendChild(customOption); if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) { - specialRangeSelect.value = Number(currentOptions[gameName][option]); + namedRangeSelect.value = Number(currentOptions[gameName][option]); } // 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', option); - specialRange.setAttribute('min', options[option].min); - specialRange.setAttribute('max', options[option].max); - specialRange.value = currentOptions[gameName][option]; + let namedRangeWrapper = document.createElement('div'); + namedRangeWrapper.classList.add('named-range-wrapper'); + let namedRange = document.createElement('input'); + namedRange.setAttribute('type', 'range'); + namedRange.setAttribute('data-key', option); + namedRange.setAttribute('min', options[option].min); + namedRange.setAttribute('max', options[option].max); + namedRange.value = currentOptions[gameName][option]; // Build rage value element - let specialRangeVal = document.createElement('span'); - specialRangeVal.classList.add('range-value'); - specialRangeVal.setAttribute('id', `${option}-value`); - specialRangeVal.innerText = currentOptions[gameName][option] !== 'random' ? + let namedRangeVal = document.createElement('span'); + namedRangeVal.classList.add('range-value'); + namedRangeVal.setAttribute('id', `${option}-value`); + namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ? currentOptions[gameName][option] : options[option].defaultValue; // Configure select event listener - specialRangeSelect.addEventListener('change', (event) => { + namedRangeSelect.addEventListener('change', (event) => { if (event.target.value === 'custom') { return; } // Update range slider - specialRange.value = event.target.value; + namedRange.value = event.target.value; document.getElementById(`${option}-value`).innerText = event.target.value; updateGameOption(event.target); }); // Configure range event handler - specialRange.addEventListener('change', (event) => { + namedRange.addEventListener('change', (event) => { // Update select element - specialRangeSelect.value = + namedRangeSelect.value = (Object.values(options[option].value_names).includes(parseInt(event.target.value))) ? parseInt(event.target.value) : 'custom'; document.getElementById(`${option}-value`).innerText = event.target.value; updateGameOption(event.target); }); - element.appendChild(specialRangeSelect); - specialRangeWrapper.appendChild(specialRange); - specialRangeWrapper.appendChild(specialRangeVal); - element.appendChild(specialRangeWrapper); + element.appendChild(namedRangeSelect); + namedRangeWrapper.appendChild(namedRange); + namedRangeWrapper.appendChild(namedRangeVal); + element.appendChild(namedRangeWrapper); // Randomize button randomButton.innerText = '🎲'; @@ -291,15 +291,15 @@ const buildOptionsTable = (options, romOpts = false) => { randomButton.setAttribute('data-key', option); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); randomButton.addEventListener('click', (event) => toggleRandomize( - event, specialRange, specialRangeSelect) + event, namedRange, namedRangeSelect) ); if (currentOptions[gameName][option] === 'random') { randomButton.classList.add('active'); - specialRange.disabled = true; - specialRangeSelect.disabled = true; + namedRange.disabled = true; + namedRangeSelect.disabled = true; } - specialRangeWrapper.appendChild(randomButton); + namedRangeWrapper.appendChild(randomButton); break; default: diff --git a/WebHostLib/static/assets/weighted-options.js b/WebHostLib/static/assets/weighted-options.js index 19928327..a2fedb53 100644 --- a/WebHostLib/static/assets/weighted-options.js +++ b/WebHostLib/static/assets/weighted-options.js @@ -93,9 +93,10 @@ class WeightedSettings { }); break; case 'range': - case 'special_range': + case 'named_range': this.current[game][gameSetting]['random'] = 0; this.current[game][gameSetting]['random-low'] = 0; + this.current[game][gameSetting]['random-middle'] = 0; this.current[game][gameSetting]['random-high'] = 0; if (setting.hasOwnProperty('defaultValue')) { this.current[game][gameSetting][setting.defaultValue] = 25; @@ -522,178 +523,185 @@ class GameSettings { break; case 'range': - case 'special_range': + case 'named_range': const rangeTable = document.createElement('table'); const rangeTbody = document.createElement('tbody'); - if (((setting.max - setting.min) + 1) < 11) { - for (let i=setting.min; i <= setting.max; ++i) { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = i; - tr.appendChild(tdLeft); + const hintText = document.createElement('p'); + hintText.classList.add('hint-text'); + 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.

Accepted values:
` + + `Normal range: ${setting.min} - ${setting.max}`; - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${this.name}-${settingName}-${i}-range`); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', i); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][i] || 0; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${i}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - rangeTbody.appendChild(tr); - } - } else { - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - 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) => { + const acceptedValuesOutsideRange = []; + if (setting.hasOwnProperty('value_names')) { + Object.keys(setting.value_names).forEach((specialName) => { + if ( + (setting.value_names[specialName] < setting.min) || + (setting.value_names[specialName] > setting.max) + ) { hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; - }); - } - - settingWrapper.appendChild(hintText); - - const addOptionDiv = document.createElement('div'); - addOptionDiv.classList.add('add-option-div'); - const optionInput = document.createElement('input'); - optionInput.setAttribute('id', `${this.name}-${settingName}-option`); - optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`); - addOptionDiv.appendChild(optionInput); - const addOptionButton = document.createElement('button'); - addOptionButton.innerText = 'Add'; - addOptionDiv.appendChild(addOptionButton); - settingWrapper.appendChild(addOptionDiv); - optionInput.addEventListener('keydown', (evt) => { - if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } + acceptedValuesOutsideRange.push(setting.value_names[specialName]); + } }); - addOptionButton.addEventListener('click', () => { - const optionInput = document.getElementById(`${this.name}-${settingName}-option`); - let option = optionInput.value; - if (!option || !option.trim()) { return; } - option = parseInt(option, 10); - if ((option < setting.min) || (option > setting.max)) { return; } - optionInput.value = ''; - if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; } - - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = '❌'; - deleteButton.addEventListener('click', () => { - range.value = 0; - range.dispatchEvent(new Event('change')); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); - - // Save new option to settings - range.dispatchEvent(new Event('change')); - }); - - Object.keys(this.current[settingName]).forEach((option) => { - // These options are statically generated below, and should always appear even if they are deleted - // from localStorage - if (['random-low', 'random', 'random-high'].includes(option)) { return; } - - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = '❌'; - deleteButton.addEventListener('click', () => { - range.value = 0; - const changeEvent = new Event('change'); - changeEvent.action = 'rangeDelete'; - range.dispatchEvent(changeEvent); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); + hintText.innerHTML += '

Certain values have special meaning:'; + Object.keys(setting.value_names).forEach((specialName) => { + hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; }); } - ['random', 'random-low', 'random-high'].forEach((option) => { + settingWrapper.appendChild(hintText); + + const addOptionDiv = document.createElement('div'); + addOptionDiv.classList.add('add-option-div'); + const optionInput = document.createElement('input'); + optionInput.setAttribute('id', `${this.name}-${settingName}-option`); + let placeholderText = `${setting.min} - ${setting.max}`; + acceptedValuesOutsideRange.forEach((aVal) => placeholderText += `, ${aVal}`); + optionInput.setAttribute('placeholder', placeholderText); + addOptionDiv.appendChild(optionInput); + const addOptionButton = document.createElement('button'); + addOptionButton.innerText = 'Add'; + addOptionDiv.appendChild(addOptionButton); + settingWrapper.appendChild(addOptionDiv); + optionInput.addEventListener('keydown', (evt) => { + if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } + }); + + addOptionButton.addEventListener('click', () => { + const optionInput = document.getElementById(`${this.name}-${settingName}-option`); + let option = optionInput.value; + if (!option || !option.trim()) { return; } + option = parseInt(option, 10); + + let optionAcceptable = false; + if ((option > setting.min) && (option < setting.max)) { + optionAcceptable = true; + } + if (setting.hasOwnProperty('value_names') && Object.values(setting.value_names).includes(option)){ + optionAcceptable = true; + } + if (!optionAcceptable) { return; } + + optionInput.value = ''; + if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + if ( + setting.hasOwnProperty('value_names') && + Object.values(setting.value_names).includes(parseInt(option, 10)) + ) { + const optionName = Object.keys(setting.value_names).find( + (key) => setting.value_names[key] === parseInt(option, 10) + ); + tdLeft.innerText += ` [${optionName}]`; + } + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][parseInt(option, 10)]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + const tdDelete = document.createElement('td'); + tdDelete.classList.add('td-delete'); + const deleteButton = document.createElement('span'); + deleteButton.classList.add('range-option-delete'); + deleteButton.innerText = '❌'; + deleteButton.addEventListener('click', () => { + range.value = 0; + range.dispatchEvent(new Event('change')); + rangeTbody.removeChild(tr); + }); + tdDelete.appendChild(deleteButton); + tr.appendChild(tdDelete); + + rangeTbody.appendChild(tr); + + // Save new option to settings + range.dispatchEvent(new Event('change')); + }); + + Object.keys(this.current[settingName]).forEach((option) => { + // These options are statically generated below, and should always appear even if they are deleted + // from localStorage + if (['random', 'random-low', 'random-middle', 'random-high'].includes(option)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + if ( + setting.hasOwnProperty('value_names') && + Object.values(setting.value_names).includes(parseInt(option, 10)) + ) { + const optionName = Object.keys(setting.value_names).find( + (key) => setting.value_names[key] === parseInt(option, 10) + ); + tdLeft.innerText += ` [${optionName}]`; + } + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][parseInt(option, 10)]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + const tdDelete = document.createElement('td'); + tdDelete.classList.add('td-delete'); + const deleteButton = document.createElement('span'); + deleteButton.classList.add('range-option-delete'); + deleteButton.innerText = '❌'; + deleteButton.addEventListener('click', () => { + range.value = 0; + const changeEvent = new Event('change'); + changeEvent.action = 'rangeDelete'; + range.dispatchEvent(changeEvent); + rangeTbody.removeChild(tr); + }); + tdDelete.appendChild(deleteButton); + tr.appendChild(tdDelete); + + rangeTbody.appendChild(tr); + }); + + ['random', 'random-low', 'random-middle', 'random-high'].forEach((option) => { const tr = document.createElement('tr'); const tdLeft = document.createElement('td'); tdLeft.classList.add('td-left'); @@ -704,6 +712,9 @@ class GameSettings { case 'random-low': tdLeft.innerText = "Random (Low)"; break; + case 'random-middle': + tdLeft.innerText = 'Random (Middle)'; + break; case 'random-high': tdLeft.innerText = "Random (High)"; break; diff --git a/WebHostLib/static/styles/player-options.css b/WebHostLib/static/styles/player-options.css index 7d6a1970..cc2d5e2d 100644 --- a/WebHostLib/static/styles/player-options.css +++ b/WebHostLib/static/styles/player-options.css @@ -160,18 +160,18 @@ html{ margin-left: 0.25rem; } -#player-options table .special-range-container{ +#player-options table .named-range-container{ display: flex; flex-direction: column; } -#player-options table .special-range-wrapper{ +#player-options table .named-range-wrapper{ display: flex; flex-direction: row; margin-top: 0.25rem; } -#player-options table .special-range-wrapper input[type=range]{ +#player-options table .named-range-wrapper input[type=range]{ flex-grow: 1; } diff --git a/docs/options api.md b/docs/options api.md index 622d0a7e..80d0737e 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -144,13 +144,20 @@ A numeric option allowing a variety of integers including the endpoints. Has a d `range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string comparisons. -### SpecialRange +### NamedRange Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value. +`special_range_names` can be used to +- give descriptive names to certain values from within the range +- add option values above or below the regular range, to be associated with a special meaning + For example: ```python +range_start = 1 +range_end = 99 special_range_names: { "normal": 20, "extreme": 99, + "unlimited": -1, } ``` diff --git a/docs/world api.md b/docs/world api.md index 71710ac2..6393f245 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -79,9 +79,9 @@ the options and the values are the values to be set for that option. These prese 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` + - If you have a `Range`/`NamedRange` 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 + - If you have a `NamedRange` 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_` values. - If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`. diff --git a/test/webhost/test_option_presets.py b/test/webhost/test_option_presets.py index 8c6ebea2..0c88b6c2 100644 --- a/test/webhost/test_option_presets.py +++ b/test/webhost/test_option_presets.py @@ -1,7 +1,7 @@ import unittest from worlds import AutoWorldRegister -from Options import Choice, SpecialRange, Toggle, Range +from Options import Choice, NamedRange, Toggle, Range class TestOptionPresets(unittest.TestCase): @@ -14,7 +14,7 @@ class TestOptionPresets(unittest.TestCase): 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] + supported_types = [Choice, Toggle, Range, NamedRange] 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. " @@ -46,8 +46,8 @@ class TestOptionPresets(unittest.TestCase): # 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): + # Allow special named values for NamedRange option presets. + if isinstance(option, NamedRange): self.assertTrue( option_value in option.special_range_names, f"Invalid preset '{option_name}': '{option_value}' in preset '{preset_name}' " diff --git a/worlds/dlcquest/Options.py b/worlds/dlcquest/Options.py index ce728b4e..769acbec 100644 --- a/worlds/dlcquest/Options.py +++ b/worlds/dlcquest/Options.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from Options import Choice, DeathLink, PerGameCommonOptions, SpecialRange +from Options import Choice, DeathLink, NamedRange, PerGameCommonOptions class DoubleJumpGlitch(Choice): @@ -33,7 +33,7 @@ class CoinSanity(Choice): default = 0 -class CoinSanityRange(SpecialRange): +class CoinSanityRange(NamedRange): """This is the amount of coins in a coin bundle You need to collect that number of coins to get a location check, and when receiving coin items, you will get bundles of this size It is highly recommended to not set this value below 10, as it generates a very large number of boring locations and items. diff --git a/worlds/dlcquest/test/TestOptionsLong.py b/worlds/dlcquest/test/TestOptionsLong.py index d0a5c0ed..3e9acac7 100644 --- a/worlds/dlcquest/test/TestOptionsLong.py +++ b/worlds/dlcquest/test/TestOptionsLong.py @@ -1,7 +1,7 @@ from typing import Dict from BaseClasses import MultiWorld -from Options import SpecialRange +from Options import NamedRange from .option_names import options_to_include from .checks.world_checks import assert_can_win, assert_same_number_items_locations from . import DLCQuestTestBase, setup_dlc_quest_solo_multiworld @@ -14,7 +14,7 @@ def basic_checks(tester: DLCQuestTestBase, multiworld: MultiWorld): def get_option_choices(option) -> Dict[str, int]: - if issubclass(option, SpecialRange): + if issubclass(option, NamedRange): return option.special_range_names elif option.options: return option.options diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index 2a19ffd3..fcc93847 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -2,7 +2,7 @@ import typing from .ExtractedData import logic_options, starts, pool_options from .Rules import cost_terms -from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, SpecialRange +from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange from .Charms import vanilla_costs, names as charm_names if typing.TYPE_CHECKING: @@ -242,7 +242,7 @@ class MaximumGeoPrice(Range): default = 400 -class RandomCharmCosts(SpecialRange): +class RandomCharmCosts(NamedRange): """Total Notch Cost of all Charms together. Vanilla sums to 90. This value is distributed among all charms in a random fashion. Special Cases: @@ -250,7 +250,7 @@ class RandomCharmCosts(SpecialRange): Set to -2 or shuffle to shuffle around the vanilla costs to different charms.""" display_name = "Randomize Charm Notch Costs" - range_start = -2 + range_start = 0 range_end = 240 default = -1 vanilla_costs: typing.List[int] = vanilla_costs diff --git a/worlds/lufia2ac/Options.py b/worlds/lufia2ac/Options.py index 419532cd..5f33d0bd 100644 --- a/worlds/lufia2ac/Options.py +++ b/worlds/lufia2ac/Options.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import accumulate, chain, combinations from typing import Any, cast, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Type, TYPE_CHECKING, Union -from Options import AssembleOptions, Choice, DeathLink, ItemDict, OptionDict, PerGameCommonOptions, Range, \ - SpecialRange, TextChoice, Toggle +from Options import AssembleOptions, Choice, DeathLink, ItemDict, NamedRange, OptionDict, PerGameCommonOptions, Range, \ + TextChoice, Toggle from .Enemies import enemy_name_to_sprite from .Items import ItemType, l2ac_item_table @@ -255,7 +255,7 @@ class CapsuleCravingsJPStyle(Toggle): display_name = "Capsule cravings JP style" -class CapsuleStartingForm(SpecialRange): +class CapsuleStartingForm(NamedRange): """The starting form of your capsule monsters. Supported values: 1 – 4, m @@ -266,7 +266,6 @@ class CapsuleStartingForm(SpecialRange): range_start = 1 range_end = 5 default = 1 - special_range_cutoff = 1 special_range_names = { "default": 1, "m": 5, @@ -280,7 +279,7 @@ class CapsuleStartingForm(SpecialRange): return self.value - 1 -class CapsuleStartingLevel(LevelMixin, SpecialRange): +class CapsuleStartingLevel(LevelMixin, NamedRange): """The starting level of your capsule monsters. Can be set to the special value party_starting_level to make it the same value as the party_starting_level option. @@ -289,10 +288,9 @@ class CapsuleStartingLevel(LevelMixin, SpecialRange): """ display_name = "Capsule monster starting level" - range_start = 0 + range_start = 1 range_end = 99 default = 1 - special_range_cutoff = 1 special_range_names = { "default": 1, "party_starting_level": 0, @@ -685,7 +683,7 @@ class RunSpeed(Choice): default = option_disabled -class ShopInterval(SpecialRange): +class ShopInterval(NamedRange): """Place shops after a certain number of floors. E.g., if you set this to 5, then you will be given the opportunity to shop after completing B5, B10, B15, etc., @@ -698,10 +696,9 @@ class ShopInterval(SpecialRange): """ display_name = "Shop interval" - range_start = 0 + range_start = 1 range_end = 10 default = 0 - special_range_cutoff = 1 special_range_names = { "disabled": 0, } diff --git a/worlds/pokemon_rb/options.py b/worlds/pokemon_rb/options.py index 794977d3..8afe91b8 100644 --- a/worlds/pokemon_rb/options.py +++ b/worlds/pokemon_rb/options.py @@ -1,4 +1,4 @@ -from Options import Toggle, Choice, Range, SpecialRange, TextChoice, DeathLink +from Options import Toggle, Choice, Range, NamedRange, TextChoice, DeathLink class GameVersion(Choice): @@ -285,7 +285,7 @@ class AllPokemonSeen(Toggle): display_name = "All Pokemon Seen" -class DexSanity(SpecialRange): +class DexSanity(NamedRange): """Adds location checks for Pokemon flagged "owned" on your Pokedex. You may specify a percentage of Pokemon to have checks added. If Accessibility is set to locations, this will be the percentage of all logically reachable Pokemon that will get a location check added to it. With items or minimal Accessibility, it will be the percentage @@ -412,7 +412,7 @@ class LevelScaling(Choice): default = 1 -class ExpModifier(SpecialRange): +class ExpModifier(NamedRange): """Modifier for EXP gained. When specifying a number, exp is multiplied by this amount and divided by 16.""" display_name = "Exp Modifier" default = 16 @@ -607,8 +607,8 @@ class RandomizeTMMoves(Toggle): display_name = "Randomize TM Moves" -class TMHMCompatibility(SpecialRange): - range_start = -1 +class TMHMCompatibility(NamedRange): + range_start = 0 range_end = 100 special_range_names = { "vanilla": -1, @@ -675,12 +675,12 @@ class RandomizeMoveTypes(Toggle): default = 0 -class SecondaryTypeChance(SpecialRange): +class SecondaryTypeChance(NamedRange): """If randomize_pokemon_types is on, this is the chance each Pokemon will have a secondary type. If follow_evolutions is selected, it is the chance a second type will be added at each evolution stage. vanilla will give secondary types to Pokemon that normally have a secondary type.""" display_name = "Secondary Type Chance" - range_start = -1 + range_start = 0 range_end = 100 default = -1 special_range_names = { diff --git a/worlds/stardew_valley/options.py b/worlds/stardew_valley/options.py index d85bbf06..267ebd7a 100644 --- a/worlds/stardew_valley/options.py +++ b/worlds/stardew_valley/options.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Dict -from Options import Range, SpecialRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, Option +from Options import Range, NamedRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, Option from .mods.mod_data import ModNames @@ -48,12 +48,12 @@ class Goal(Choice): return super().get_option_name(value) -class StartingMoney(SpecialRange): +class StartingMoney(NamedRange): """Amount of gold when arriving at the farm. Set to -1 or unlimited for infinite money""" internal_name = "starting_money" display_name = "Starting Gold" - range_start = -1 + range_start = 0 range_end = 50000 default = 5000 @@ -67,7 +67,7 @@ class StartingMoney(SpecialRange): } -class ProfitMargin(SpecialRange): +class ProfitMargin(NamedRange): """Multiplier over all gold earned in-game by the player.""" internal_name = "profit_margin" display_name = "Profit Margin" @@ -283,7 +283,7 @@ class SpecialOrderLocations(Choice): option_board_qi = 2 -class HelpWantedLocations(SpecialRange): +class HelpWantedLocations(NamedRange): """Include location checks for Help Wanted quests Out of every 7 quests, 4 will be item deliveries, and then 1 of each for: Fishing, Gathering and Slaying Monsters. Choosing a multiple of 7 is recommended.""" @@ -429,7 +429,7 @@ class MultipleDaySleepEnabled(Toggle): default = 1 -class MultipleDaySleepCost(SpecialRange): +class MultipleDaySleepCost(NamedRange): """How much gold it will cost to use MultiSleep. You will have to pay that amount for each day skipped.""" internal_name = "multiple_day_sleep_cost" display_name = "Multiple Day Sleep Cost" @@ -446,7 +446,7 @@ class MultipleDaySleepCost(SpecialRange): } -class ExperienceMultiplier(SpecialRange): +class ExperienceMultiplier(NamedRange): """How fast you want to earn skill experience. A lower setting mean less experience. A higher setting means more experience.""" @@ -466,7 +466,7 @@ class ExperienceMultiplier(SpecialRange): } -class FriendshipMultiplier(SpecialRange): +class FriendshipMultiplier(NamedRange): """How fast you want to earn friendship points with villagers. A lower setting mean less friendship per action. A higher setting means more friendship per action.""" diff --git a/worlds/stardew_valley/test/TestOptions.py b/worlds/stardew_valley/test/TestOptions.py index 02b1ebf6..ccffc284 100644 --- a/worlds/stardew_valley/test/TestOptions.py +++ b/worlds/stardew_valley/test/TestOptions.py @@ -4,7 +4,7 @@ from random import random from typing import Dict from BaseClasses import ItemClassification, MultiWorld -from Options import SpecialRange +from Options import NamedRange from . import setup_solo_multiworld, SVTestBase, SVTestCase, allsanity_options_without_mods, allsanity_options_with_mods from .. import StardewItem, items_by_group, Group, StardewValleyWorld from ..locations import locations_by_tag, LocationTags, location_table @@ -42,7 +42,7 @@ def check_no_ginger_island(tester: unittest.TestCase, multiworld: MultiWorld): def get_option_choices(option) -> Dict[str, int]: - if issubclass(option, SpecialRange): + if issubclass(option, NamedRange): return option.special_range_names elif option.options: return option.options @@ -53,7 +53,7 @@ class TestGenerateDynamicOptions(SVTestCase): def test_given_special_range_when_generate_then_basic_checks(self): options = StardewValleyWorld.options_dataclass.type_hints for option_name, option in options.items(): - if not isinstance(option, SpecialRange): + if not isinstance(option, NamedRange): continue for value in option.special_range_names: with self.subTest(f"{option_name}: {value}"): @@ -152,7 +152,7 @@ class TestGenerateAllOptionsWithExcludeGingerIsland(SVTestCase): def test_given_special_range_when_generate_exclude_ginger_island(self): options = StardewValleyWorld.options_dataclass.type_hints for option_name, option in options.items(): - if not isinstance(option, SpecialRange) or option_name == ExcludeGingerIsland.internal_name: + if not isinstance(option, NamedRange) or option_name == ExcludeGingerIsland.internal_name: continue for value in option.special_range_names: with self.subTest(f"{option_name}: {value}"): diff --git a/worlds/stardew_valley/test/long/TestOptionsLong.py b/worlds/stardew_valley/test/long/TestOptionsLong.py index 3634dc5f..e3da6968 100644 --- a/worlds/stardew_valley/test/long/TestOptionsLong.py +++ b/worlds/stardew_valley/test/long/TestOptionsLong.py @@ -2,7 +2,7 @@ import unittest from typing import Dict from BaseClasses import MultiWorld -from Options import SpecialRange +from Options import NamedRange from .option_names import options_to_include from worlds.stardew_valley.test.checks.world_checks import assert_can_win, assert_same_number_items_locations from .. import setup_solo_multiworld, SVTestCase @@ -14,7 +14,7 @@ def basic_checks(tester: unittest.TestCase, multiworld: MultiWorld): def get_option_choices(option) -> Dict[str, int]: - if issubclass(option, SpecialRange): + if issubclass(option, NamedRange): return option.special_range_names elif option.options: return option.options diff --git a/worlds/stardew_valley/test/long/TestRandomWorlds.py b/worlds/stardew_valley/test/long/TestRandomWorlds.py index e22c6c35..1f1d5965 100644 --- a/worlds/stardew_valley/test/long/TestRandomWorlds.py +++ b/worlds/stardew_valley/test/long/TestRandomWorlds.py @@ -2,7 +2,7 @@ from typing import Dict import random from BaseClasses import MultiWorld -from Options import SpecialRange, Range +from Options import NamedRange, Range from .option_names import options_to_include from .. import setup_solo_multiworld, SVTestCase from ..checks.goal_checks import assert_perfection_world_is_valid, assert_goal_world_is_valid @@ -12,7 +12,7 @@ from ..checks.world_checks import assert_same_number_items_locations, assert_vic def get_option_choices(option) -> Dict[str, int]: - if issubclass(option, SpecialRange): + if issubclass(option, NamedRange): return option.special_range_names if issubclass(option, Range): return {f"{val}": val for val in range(option.range_start, option.range_end + 1)} diff --git a/worlds/zillion/options.py b/worlds/zillion/options.py index 80f9469e..cb861e96 100644 --- a/worlds/zillion/options.py +++ b/worlds/zillion/options.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Dict, Tuple from typing_extensions import TypeGuard # remove when Python >= 3.10 -from Options import DefaultOnToggle, PerGameCommonOptions, Range, SpecialRange, Toggle, Choice +from Options import DefaultOnToggle, NamedRange, PerGameCommonOptions, Range, Toggle, Choice from zilliandomizer.options import \ Options as ZzOptions, char_to_gun, char_to_jump, ID, \ @@ -11,7 +11,7 @@ from zilliandomizer.options import \ from zilliandomizer.options.parsing import validate as zz_validate -class ZillionContinues(SpecialRange): +class ZillionContinues(NamedRange): """ number of continues before game over @@ -218,7 +218,7 @@ class ZillionSkill(Range): default = 2 -class ZillionStartingCards(SpecialRange): +class ZillionStartingCards(NamedRange): """ how many ID Cards to start the game with