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) }}