mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00
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
This commit is contained in:
47
Options.py
47
Options.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import collections
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -866,15 +867,49 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
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
|
verify_item_name = True
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, int]):
|
min = 0
|
||||||
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 .")
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
if any(item_count < 1 for item_count in value.values()):
|
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||||
raise Exception("Cannot have non-positive item counts.")
|
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
|
3
Utils.py
3
Utils.py
@@ -429,6 +429,9 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
def find_class(self, module: str, name: str) -> type:
|
def find_class(self, module: str, name: str) -> type:
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
|
# used by OptionCounter
|
||||||
|
if module == "collections" and name == "Counter":
|
||||||
|
return collections.Counter
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||||
|
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
|
|||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
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
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(preset_option, str):
|
elif isinstance(preset_option, str):
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
# 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():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
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[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
|
@@ -111,10 +111,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% 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) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% 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) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||||
|
@@ -93,8 +93,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
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 %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -133,8 +135,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
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 %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
@@ -113,9 +113,18 @@
|
|||||||
{{ TextChoice(option_name, option) }}
|
{{ TextChoice(option_name, option) }}
|
||||||
{% endmacro %}
|
{% 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 %}
|
||||||
|
|
||||||
<div class="dict-container">
|
<div class="dict-container">
|
||||||
{% 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) %}
|
||||||
<div class="dict-entry">
|
<div class="dict-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input
|
<input
|
||||||
|
@@ -83,8 +83,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option, world) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
@@ -352,8 +352,15 @@ template. If you set a [Schema](https://pypi.org/project/schema/) on the class w
|
|||||||
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
||||||
format.
|
format.
|
||||||
|
|
||||||
|
### OptionCounter
|
||||||
|
This is a special case of OptionDict where the dictionary values can only be integers.
|
||||||
|
It returns a [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter).
|
||||||
|
This means that if you access a key that isn't present, its value will be 0.
|
||||||
|
The upside of using an OptionCounter (instead of an OptionDict with integer values) is that an OptionCounter can be
|
||||||
|
displayed on the Options page on WebHost.
|
||||||
|
|
||||||
### ItemDict
|
### ItemDict
|
||||||
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
|
An OptionCounter that will verify that every key in the dictionary is a valid name for an item for your world.
|
||||||
|
|
||||||
### OptionList
|
### OptionList
|
||||||
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
||||||
|
@@ -2,7 +2,7 @@ import unittest
|
|||||||
|
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
|
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet
|
||||||
|
|
||||||
|
|
||||||
class TestOptionPresets(unittest.TestCase):
|
class TestOptionPresets(unittest.TestCase):
|
||||||
@@ -19,7 +19,7 @@ class TestOptionPresets(unittest.TestCase):
|
|||||||
# pass in all plando options in case a preset wants to require certain plando options
|
# pass in all plando options in case a preset wants to require certain plando options
|
||||||
# for some reason
|
# for some reason
|
||||||
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
|
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
|
||||||
supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
|
supported_types = [NumericOption, OptionSet, OptionList, OptionCounter]
|
||||||
if not any([issubclass(option.__class__, t) for t in supported_types]):
|
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}' "
|
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
|
||||||
f"is not a supported type for webhost. "
|
f"is not a supported type for webhost. "
|
||||||
|
@@ -7,7 +7,7 @@ from Options import (
|
|||||||
Choice,
|
Choice,
|
||||||
DefaultOnToggle,
|
DefaultOnToggle,
|
||||||
LocationSet,
|
LocationSet,
|
||||||
OptionDict,
|
OptionCounter,
|
||||||
OptionError,
|
OptionError,
|
||||||
OptionGroup,
|
OptionGroup,
|
||||||
OptionSet,
|
OptionSet,
|
||||||
@@ -414,23 +414,25 @@ class TrapPercentage(Range):
|
|||||||
default = 20
|
default = 20
|
||||||
|
|
||||||
|
|
||||||
class TrapWeights(OptionDict):
|
_default_trap_weights = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TrapWeights(OptionCounter):
|
||||||
"""
|
"""
|
||||||
Specify the weights determining how many copies of each trap item will be in your itempool.
|
Specify the weights determining how many copies of each trap item will be in your itempool.
|
||||||
If you don't want a specific type of trap, you can set the weight for it to 0 (Do not delete the entry outright!).
|
If you don't want a specific type of trap, you can set the weight for it to 0.
|
||||||
If you set all trap weights to 0, you will get no traps, bypassing the "Trap Percentage" option.
|
If you set all trap weights to 0, you will get no traps, bypassing the "Trap Percentage" option.
|
||||||
"""
|
"""
|
||||||
display_name = "Trap Weights"
|
display_name = "Trap Weights"
|
||||||
schema = Schema({
|
valid_keys = _default_trap_weights.keys()
|
||||||
trap_name: And(int, lambda n: n >= 0)
|
|
||||||
for trap_name, item_definition in static_witness_logic.ALL_ITEMS.items()
|
min = 0
|
||||||
if isinstance(item_definition, WeightedItemDefinition) and item_definition.category is ItemCategory.TRAP
|
|
||||||
})
|
default = _default_trap_weights
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PuzzleSkipAmount(Range):
|
class PuzzleSkipAmount(Range):
|
||||||
|
@@ -40,6 +40,8 @@ witness_option_presets: Dict[str, Dict[str, Any]] = {
|
|||||||
|
|
||||||
"trap_percentage": TrapPercentage.default,
|
"trap_percentage": TrapPercentage.default,
|
||||||
"puzzle_skip_amount": PuzzleSkipAmount.default,
|
"puzzle_skip_amount": PuzzleSkipAmount.default,
|
||||||
|
"trap_weights": TrapWeights.default,
|
||||||
|
|
||||||
"hint_amount": HintAmount.default,
|
"hint_amount": HintAmount.default,
|
||||||
"area_hint_percentage": AreaHintPercentage.default,
|
"area_hint_percentage": AreaHintPercentage.default,
|
||||||
"laser_hints": LaserHints.default,
|
"laser_hints": LaserHints.default,
|
||||||
@@ -79,6 +81,8 @@ witness_option_presets: Dict[str, Dict[str, Any]] = {
|
|||||||
|
|
||||||
"trap_percentage": TrapPercentage.default,
|
"trap_percentage": TrapPercentage.default,
|
||||||
"puzzle_skip_amount": 15,
|
"puzzle_skip_amount": 15,
|
||||||
|
"trap_weights": TrapWeights.default,
|
||||||
|
|
||||||
"hint_amount": HintAmount.default,
|
"hint_amount": HintAmount.default,
|
||||||
"area_hint_percentage": AreaHintPercentage.default,
|
"area_hint_percentage": AreaHintPercentage.default,
|
||||||
"laser_hints": LaserHints.default,
|
"laser_hints": LaserHints.default,
|
||||||
@@ -118,6 +122,8 @@ witness_option_presets: Dict[str, Dict[str, Any]] = {
|
|||||||
|
|
||||||
"trap_percentage": TrapPercentage.default,
|
"trap_percentage": TrapPercentage.default,
|
||||||
"puzzle_skip_amount": 15,
|
"puzzle_skip_amount": 15,
|
||||||
|
"trap_weights": TrapWeights.default,
|
||||||
|
|
||||||
"hint_amount": HintAmount.default,
|
"hint_amount": HintAmount.default,
|
||||||
"area_hint_percentage": AreaHintPercentage.default,
|
"area_hint_percentage": AreaHintPercentage.default,
|
||||||
"laser_hints": LaserHints.default,
|
"laser_hints": LaserHints.default,
|
||||||
|
Reference in New Issue
Block a user