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 all 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
47 changes: 41 additions & 6 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 @@ -858,15 +859,49 @@ 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) -> bool:
return item in self.value


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 = []

class ItemDict(OptionDict):
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)


Expand Down
3 changes: 3 additions & 0 deletions Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,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 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"}:
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.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
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 OptionCounter options from their name pattern
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
Expand Down
13 changes: 11 additions & 2 deletions WebHostLib/templates/playerOptions/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,19 @@
</div>
{% 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) }}
<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|sort %}
<div class="option-entry">
<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 }}" />
Expand Down
12 changes: 8 additions & 4 deletions WebHostLib/templates/playerOptions/playerOptions.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ <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.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) }}
Expand Down Expand Up @@ -133,8 +135,10 @@ <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.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) }}
Expand Down
13 changes: 11 additions & 2 deletions WebHostLib/templates/weightedOptions/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}

<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|sort %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input
Expand Down
6 changes: 4 additions & 2 deletions WebHostLib/templates/weightedOptions/weightedOptions.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ <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.OptionCounter) and (
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 %}
{{ inputs.OptionList(option_name, option) }}
Expand Down
28 changes: 15 additions & 13 deletions worlds/witness/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from Options import (
Choice,
DefaultOnToggle,
OptionDict,
OptionCounter,
OptionError,
OptionGroup,
OptionSet,
Expand Down Expand Up @@ -344,23 +344,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(OptionCounter):
"""
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