Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: Add "CounterOption", use it for generic "StartInventory" and Witness "TrapWeights" #3756

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions Options.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import abc
import collections
import functools
import logging
import math
Expand Down Expand Up @@ -854,13 +855,51 @@ def __iter__(self) -> typing.Iterator[str]:
def __len__(self) -> int:
return self.value.__len__()

# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item):
return item in self.value


class CounterOption(OptionDict):
NewSoupVi marked this conversation as resolved.
Show resolved Hide resolved
min: typing.Optional[int] = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debatable but I think a default min of 1 probably has the most universal use cases

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about that? I'm aware of three options that use this or would want to use this, and two of them want min = 0.

max: typing.Optional[int] = None

def __init__(self, value: typing.Dict[str, int]):
super(CounterOption, self).__init__(collections.Counter(value))

def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(CounterOption, self).verify(world, player_name, plando_options)

class ItemDict(OptionDict):
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 len(range_errors) == 1:
raise OptionError(range_errors[0][:-1] + f" for option {getattr(self, 'display_name', self)}.")
NewSoupVi marked this conversation as resolved.
Show resolved Hide resolved
elif range_errors:
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
raise OptionError("\n".join(range_errors))


class ItemDict(CounterOption):
verify_item_name = True

min = 0

def __init__(self, value: typing.Dict[str, int]):
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a CounterOption
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is just for back compat and verify is called after the option itself is initialized, min should probably be 1 and just leave the culling.

Separate conversation ig but it seems weird to leave in the culling at all since it's not like the actual option was getting those keys with 0 Val already

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh, hm, I definitely intended that for start_inventory, {"Sword": 0} is a valid input now.

value = {item_name: amount for item_name, amount in value.items() if amount != 0}

super(ItemDict, self).__init__(value)


Expand Down
3 changes: 3 additions & 0 deletions Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by CounterOption
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"}:
return getattr(self.net_utils_module, name)
Expand Down
4 changes: 2 additions & 2 deletions WebHostLib/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.CounterOption)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
Expand Down Expand Up @@ -216,7 +216,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 CounterOption options from their name pattern
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/playerOptions/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
</div>
{% endmacro %}

{% macro ItemDict(option_name, option) %}
{% macro CounterOption(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
Expand Down
8 changes: 4 additions & 4 deletions WebHostLib/templates/playerOptions/playerOptions.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ <h1>Player Options</h1>
{% 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.CounterOption) and option.valid_keys %}
{{ inputs.CounterOption(option_name, option) }}

{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
Expand Down Expand Up @@ -133,8 +133,8 @@ <h1>Player Options</h1>
{% 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.CounterOption) and option.valid_keys %}
{{ inputs.CounterOption(option_name, option) }}

{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/weightedOptions/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
{{ TextChoice(option_name, option) }}
{% endmacro %}

{% macro ItemDict(option_name, option, world) %}
{% macro CounterOption(option_name, option, world) %}
<div class="dict-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="dict-entry">
Expand Down
4 changes: 2 additions & 2 deletions WebHostLib/templates/weightedOptions/weightedOptions.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ <h4>{{ option.display_name|default(option_name) }}</h4>
{% 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, world) }}
{% elif issubclass(option, Options.CounterOption) and option.valid_keys %}
{{ inputs.CounterOption(option_name, option, world) }}

{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
Expand Down
30 changes: 15 additions & 15 deletions worlds/witness/options.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from dataclasses import dataclass

from schema import And, Schema

from Options import Choice, DefaultOnToggle, OptionDict, OptionGroup, PerGameCommonOptions, Range, Toggle, Visibility
from Options import Choice, CounterOption, DefaultOnToggle, OptionGroup, PerGameCommonOptions, Range, Toggle, Visibility

from .data import static_logic as static_witness_logic
from .data.item_definition_classes import ItemCategory, WeightedItemDefinition
Expand Down Expand Up @@ -234,23 +232,25 @@ class TrapPercentage(Range):
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(CounterOption):
"""
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.
"""
display_name = "Trap Weights"
schema = Schema({
trap_name: And(int, lambda n: n >= 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):
Expand Down
Loading