Skip to content

Commit

Permalink
Merge branch 'main' into tunc-logic-rules-redux
Browse files Browse the repository at this point in the history
  • Loading branch information
ScipioWright authored Aug 5, 2024
2 parents df52f1b + c0ef02d commit be9b8d4
Show file tree
Hide file tree
Showing 60 changed files with 1,304 additions and 632 deletions.
112 changes: 93 additions & 19 deletions BaseClasses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import collections
import copy
import itertools
import functools
Expand Down Expand Up @@ -63,7 +64,6 @@ class MultiWorld():
state: CollectionState

plando_options: PlandoOptions
accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems]
Expand Down Expand Up @@ -288,6 +288,86 @@ def set_item_links(self):
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]

def link_items(self) -> None:
"""Called to link together items in the itempool related to the registered item link groups."""
from worlds import AutoWorld

for group_id, group in self.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in self.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification

for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])

if not players:
return None, None

for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
return counters, classifications

common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue

new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)

region = Region("Menu", group_id, self, "ItemLink")
self.regions.append(region)
locations = region.locations
for item in self.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)

locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)

itemcount = len(self.itempool)
self.itempool = new_itempool

while itemcount > len(self.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
self.random.shuffle(items_to_add)
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])

def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
Expand Down Expand Up @@ -523,26 +603,22 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None):
players: Dict[str, Set[int]] = {
"minimal": set(),
"items": set(),
"locations": set()
"full": set()
}
for player, access in self.accessibility.items():
players[access.current_key].add(player)
for player, world in self.worlds.items():
players[world.options.accessibility.current_key].add(player)

beatable_fulfilled = False

def location_condition(location: Location):
def location_condition(location: Location) -> bool:
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
if location.player in players["locations"] or (location.item and location.item.player not in
players["minimal"]):
return True
return False
return location.player in players["full"] or \
(location.item and location.item.player not in players["minimal"])

def location_relevant(location: Location):
def location_relevant(location: Location) -> bool:
"""Determine if this location is relevant to sweep."""
if location.progress_type != LocationProgressType.EXCLUDED \
and (location.player in players["locations"] or location.advancement):
return True
return False
return location.progress_type != LocationProgressType.EXCLUDED \
and (location.player in players["full"] or location.advancement)

def all_done() -> bool:
"""Check if all access rules are fulfilled"""
Expand Down Expand Up @@ -680,13 +756,13 @@ def can_reach_entrance(self, spot: str, player: int) -> bool:
def can_reach_region(self, spot: str, player: int) -> bool:
return self.multiworld.get_region(spot, player).can_reach(self)

def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_events = True
# since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.advancement and location not in self.events and
not key_only or getattr(location.item, "locked_dungeon_item", False)}
locations = {location for location in locations if location.advancement and location not in self.events}

while reachable_events:
reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events
Expand Down Expand Up @@ -1291,8 +1367,6 @@ def create_playthrough(self, create_paths: bool = True) -> None:
state = CollectionState(multiworld)
collection_spheres = []
while required_locations:
state.sweep_for_events(key_only=True)

sphere = set(filter(state.can_reach, required_locations))

for location in sphere:
Expand Down
1 change: 0 additions & 1 deletion Fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,6 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:

def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}

def item_percentage(player: int, num: int) -> float:
Expand Down
77 changes: 1 addition & 76 deletions Main.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,82 +184,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items

# temporary home for item links, should be moved out of Main
for group_id, group in multiworld.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in multiworld.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification

for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])

if not players:
return None, None

for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
return counters, classifications

common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue

new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)

region = Region("Menu", group_id, multiworld, "ItemLink")
multiworld.regions.append(region)
locations = region.locations
for item in multiworld.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)

locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)

itemcount = len(multiworld.itempool)
multiworld.itempool = new_itempool

while itemcount > len(multiworld.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
multiworld.random.shuffle(items_to_add)
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
multiworld.link_items()

if any(multiworld.item_links.values()):
multiworld._all_state = None
Expand Down
67 changes: 49 additions & 18 deletions Options.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,17 +786,22 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False
value: typing.Any

@classmethod
def verify_keys(cls, data: typing.Iterable[str]) -> None:
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
extra = dataset - cls._valid_keys
def verify_keys(self) -> None:
if self.valid_keys:
data = set(self.value)
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
extra = dataset - self._valid_keys
if extra:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls._valid_keys}.")
raise OptionError(
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
f"Allowed keys: {self._valid_keys}."
)

def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
try:
self.verify_keys()
except OptionError as validation_error:
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
Expand Down Expand Up @@ -833,7 +838,6 @@ def __init__(self, value: typing.Dict[str, typing.Any]):
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
Expand Down Expand Up @@ -879,7 +883,6 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))

Expand All @@ -905,7 +908,6 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))

Expand Down Expand Up @@ -948,6 +950,19 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")
else:
super().verify(world, player_name, plando_options)

def verify_keys(self) -> None:
if self.valid_keys:
data = set(text.at for text in self)
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
extra = dataset - self._valid_keys
if extra:
raise OptionError(
f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
f"Allowed placements: {self._valid_keys}."
)

@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
Expand All @@ -971,7 +986,6 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
cls.verify_keys([text.at for text in texts])
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
Expand Down Expand Up @@ -1144,18 +1158,35 @@ def __len__(self) -> int:


class Accessibility(Choice):
"""Set rules for reachability of your items/locations.
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
- **Locations:** ensure everything can be reached and acquired.
- **Items:** ensure all logically relevant items can be acquired.
- **Minimal:** ensure what is needed to reach your goal can be acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
"""
display_name = "Accessibility"
rich_text_doc = True
option_locations = 0
option_items = 1
option_full = 0
option_minimal = 2
alias_none = 2
alias_locations = 0
alias_items = 0
default = 0


class ItemsAccessibility(Accessibility):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
some locations may be inaccessible.
"""
option_items = 1
default = 1


Expand Down
7 changes: 7 additions & 0 deletions WebHostLib/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ def generate_yaml(game: str):

del options[key]

# Detect keys which end with -range, indicating a NamedRange with a possible custom value
elif key_parts[-1].endswith("-range"):
if options[key_parts[-1][:-6]] == "custom":
options[key_parts[-1][:-6]] = val

del options[key]

# Detect random-* keys and set their options accordingly
for key, val in options.copy().items():
if key.startswith("random-"):
Expand Down
Loading

0 comments on commit be9b8d4

Please sign in to comment.