From 05c1751d293ea27fa4ee7c6f4797be772d111ae2 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Thu, 24 Apr 2025 22:06:41 +0200 Subject: [PATCH] Core: Add "OptionCounter", use it for generic "StartInventory" and Witness "TrapWeights" (#3756) * CounterOption * bring back the negative exception for ItemDict * Backwards compatibility * ruff on witness * fix in calls * move the contains * comment * comment * Add option min and max values for CounterOption * Use min 0 for TrapWeights * This is safe now * ruff * This fits on one line again now * OptionCounter * Update Options.py * Couple more typing things * Update Options.py * Make StartInventory work again, also make LocationCounter theoretically work * Docs * more forceful wording * forced line break * Fix unit test (that wasn't breaking?) * Add trapweights to witness option presets to 'prove' that the unit test passes * Make it so you can order stuff * Update macros.html --- Options.py | 47 ++++++++++++++++--- Utils.py | 3 ++ WebHostLib/options.py | 4 +- .../templates/playerOptions/macros.html | 13 ++++- .../playerOptions/playerOptions.html | 12 +++-- .../templates/weightedOptions/macros.html | 13 ++++- .../weightedOptions/weightedOptions.html | 6 ++- docs/options api.md | 9 +++- test/webhost/test_option_presets.py | 4 +- worlds/witness/options.py | 28 ++++++----- worlds/witness/presets.py | 6 +++ 11 files changed, 111 insertions(+), 34 deletions(-) diff --git a/Options.py b/Options.py index 95b9b468..6a6bbe5e 100644 --- a/Options.py +++ b/Options.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import collections import functools import logging import math @@ -866,15 +867,49 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin def __len__(self) -> int: return self.value.__len__() + # __getitem__ fallback fails for Counters, so we define this explicitly + def __contains__(self, item) -> bool: + return item in self.value -class ItemDict(OptionDict): + +class OptionCounter(OptionDict): + min: int | None = None + max: int | None = None + + def __init__(self, value: dict[str, int]) -> None: + super(OptionCounter, self).__init__(collections.Counter(value)) + + def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None: + super(OptionCounter, self).verify(world, player_name, plando_options) + + range_errors = [] + + if self.max is not None: + range_errors += [ + f"\"{key}: {value}\" is higher than maximum allowed value {self.max}." + for key, value in self.value.items() if value > self.max + ] + + if self.min is not None: + range_errors += [ + f"\"{key}: {value}\" is lower than minimum allowed value {self.min}." + for key, value in self.value.items() if value < self.min + ] + + if range_errors: + range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors + raise OptionError("\n".join(range_errors)) + + +class ItemDict(OptionCounter): verify_item_name = True - def __init__(self, value: typing.Dict[str, int]): - if any(item_count is None for item_count in value.values()): - raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .") - if any(item_count < 1 for item_count in value.values()): - raise Exception("Cannot have non-positive item counts.") + min = 0 + + def __init__(self, value: dict[str, int]) -> None: + # Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter + value = {item_name: amount for item_name, amount in value.items() if amount != 0} + super(ItemDict, self).__init__(value) diff --git a/Utils.py b/Utils.py index e4e94a45..46a0d106 100644 --- a/Utils.py +++ b/Utils.py @@ -429,6 +429,9 @@ class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module: str, name: str) -> type: if module == "builtins" and name in safe_builtins: return getattr(builtins, name) + # used by OptionCounter + if module == "collections" and name == "Counter": + return collections.Counter # used by MultiServer -> savegame/multidata if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot", "HintStatus"}: diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 711762ee..38489cee 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -108,7 +108,7 @@ def option_presets(game: str) -> Response: f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." presets[preset_name][preset_option_name] = option.value - elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)): + elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)): presets[preset_name][preset_option_name] = option.value elif isinstance(preset_option, str): # Ensure the option value is valid for Choice and Toggle options @@ -222,7 +222,7 @@ def generate_yaml(game: str): for key, val in options.copy().items(): key_parts = key.rsplit("||", 2) - # Detect and build ItemDict options from their name pattern + # Detect and build OptionCounter options from their name pattern if key_parts[-1] == "qty": if key_parts[0] not in options: options[key_parts[0]] = {} diff --git a/WebHostLib/templates/playerOptions/macros.html b/WebHostLib/templates/playerOptions/macros.html index 972f0317..bbb3c75d 100644 --- a/WebHostLib/templates/playerOptions/macros.html +++ b/WebHostLib/templates/playerOptions/macros.html @@ -111,10 +111,19 @@ {% endmacro %} -{% macro ItemDict(option_name, option) %} +{% macro OptionCounter(option_name, option) %} + {% set relevant_keys = option.valid_keys %} + {% if not relevant_keys %} + {% if option.verify_item_name %} + {% set relevant_keys = world.item_names %} + {% elif option.verify_location_name %} + {% set relevant_keys = world.location_names %} + {% endif %} + {% endif %} + {{ OptionTitle(option_name, option) }}
- {% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} + {% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
diff --git a/WebHostLib/templates/playerOptions/playerOptions.html b/WebHostLib/templates/playerOptions/playerOptions.html index 7e2f0ee1..5e823421 100644 --- a/WebHostLib/templates/playerOptions/playerOptions.html +++ b/WebHostLib/templates/playerOptions/playerOptions.html @@ -93,8 +93,10 @@ {% elif issubclass(option, Options.FreeText) %} {{ inputs.FreeText(option_name, option) }} - {% elif issubclass(option, Options.ItemDict) and option.verify_item_name %} - {{ inputs.ItemDict(option_name, option) }} + {% elif issubclass(option, Options.OptionCounter) and ( + option.valid_keys or option.verify_item_name or option.verify_location_name + ) %} + {{ inputs.OptionCounter(option_name, option) }} {% elif issubclass(option, Options.OptionList) and option.valid_keys %} {{ inputs.OptionList(option_name, option) }} @@ -133,8 +135,10 @@ {% elif issubclass(option, Options.FreeText) %} {{ inputs.FreeText(option_name, option) }} - {% elif issubclass(option, Options.ItemDict) and option.verify_item_name %} - {{ inputs.ItemDict(option_name, option) }} + {% elif issubclass(option, Options.OptionCounter) and ( + option.valid_keys or option.verify_item_name or option.verify_location_name + ) %} + {{ inputs.OptionCounter(option_name, option) }} {% elif issubclass(option, Options.OptionList) and option.valid_keys %} {{ inputs.OptionList(option_name, option) }} diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index d18d0f0b..89ba0a0e 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -113,9 +113,18 @@ {{ TextChoice(option_name, option) }} {% endmacro %} -{% macro ItemDict(option_name, option, world) %} +{% macro OptionCounter(option_name, option, world) %} + {% set relevant_keys = option.valid_keys %} + {% if not relevant_keys %} + {% if option.verify_item_name %} + {% set relevant_keys = world.item_names %} + {% elif option.verify_location_name %} + {% set relevant_keys = world.location_names %} + {% endif %} + {% endif %} +
- {% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} + {% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
= 0) - for trap_name, item_definition in static_witness_logic.ALL_ITEMS.items() - if isinstance(item_definition, WeightedItemDefinition) and item_definition.category is ItemCategory.TRAP - }) - default = { - trap_name: item_definition.weight - for trap_name, item_definition in static_witness_logic.ALL_ITEMS.items() - if isinstance(item_definition, WeightedItemDefinition) and item_definition.category is ItemCategory.TRAP - } + valid_keys = _default_trap_weights.keys() + + min = 0 + + default = _default_trap_weights class PuzzleSkipAmount(Range): diff --git a/worlds/witness/presets.py b/worlds/witness/presets.py index 687d74f7..81dd28d6 100644 --- a/worlds/witness/presets.py +++ b/worlds/witness/presets.py @@ -40,6 +40,8 @@ witness_option_presets: Dict[str, Dict[str, Any]] = { "trap_percentage": TrapPercentage.default, "puzzle_skip_amount": PuzzleSkipAmount.default, + "trap_weights": TrapWeights.default, + "hint_amount": HintAmount.default, "area_hint_percentage": AreaHintPercentage.default, "laser_hints": LaserHints.default, @@ -79,6 +81,8 @@ witness_option_presets: Dict[str, Dict[str, Any]] = { "trap_percentage": TrapPercentage.default, "puzzle_skip_amount": 15, + "trap_weights": TrapWeights.default, + "hint_amount": HintAmount.default, "area_hint_percentage": AreaHintPercentage.default, "laser_hints": LaserHints.default, @@ -118,6 +122,8 @@ witness_option_presets: Dict[str, Dict[str, Any]] = { "trap_percentage": TrapPercentage.default, "puzzle_skip_amount": 15, + "trap_weights": TrapWeights.default, + "hint_amount": HintAmount.default, "area_hint_percentage": AreaHintPercentage.default, "laser_hints": LaserHints.default,