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: Plando Items "rewrite" #3046

Open
wants to merge 46 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
c811c72
working?
Silvris Mar 28, 2024
3027f37
Add docstring, removed unused
Silvris Mar 28, 2024
b583ebb
fix ladx test
Silvris Mar 28, 2024
dbb8346
Update Options.py
Silvris Mar 28, 2024
702a9a4
support locations is None
Silvris Mar 29, 2024
905b700
account for present but empty plando items for warning
Silvris Mar 29, 2024
a1b3404
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Mar 29, 2024
2e00b07
Update Fill.py
Silvris Mar 30, 2024
7080fea
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Mar 30, 2024
3601dd3
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Apr 16, 2024
d0c3822
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris May 21, 2024
000e071
Merge branch 'main' into plando_items_rewrite
Silvris Jun 11, 2024
afa64a6
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Aug 11, 2024
6bc8701
rewrite candidates, add compat test (limited)
Silvris Aug 11, 2024
9e0f4c7
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Sep 6, 2024
e96a752
fix alttp
Silvris Sep 6, 2024
091504b
add get_all_state arg, fix kh2
Silvris Sep 6, 2024
d8e9041
fix blasphemous and hylics
Silvris Sep 6, 2024
6196c85
fix emerald and incorrect kh2
Silvris Sep 6, 2024
7f94d5f
fix pokemon rb?
Silvris Sep 7, 2024
970a469
forgot the other hylics2 case
Silvris Sep 7, 2024
21d2fa0
fix raft
Silvris Sep 7, 2024
003368e
fix shivers
Silvris Sep 7, 2024
3996911
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Sep 19, 2024
3802767
remove blasphemous changes
Silvris Sep 19, 2024
f9ffe01
Update __init__.py
Silvris Sep 19, 2024
5047eac
fix oot
Silvris Sep 19, 2024
6fa6d13
.
Exempt-Medic Oct 30, 2024
acec513
Changes from some review (untested)
Exempt-Medic Oct 30, 2024
6fbe885
Import doesn't work
Exempt-Medic Oct 30, 2024
3c107e6
Import doesn't work
Exempt-Medic Oct 30, 2024
00f915b
Reverting the default change
Exempt-Medic Oct 30, 2024
5f7a084
Cleaner exception method
Exempt-Medic Oct 30, 2024
6c4126d
Update Fill.py
Exempt-Medic Oct 30, 2024
deb8a09
Some recommended fixes
Exempt-Medic Oct 30, 2024
e8495bc
Merge pull request #9 from Exempt-Medic/plando-items
Silvris Oct 30, 2024
cddc79f
Merge branch 'main' into plando_items_rewrite
Silvris Nov 18, 2024
fdcec1a
Plando items fixes and item_group_method
Exempt-Medic Nov 20, 2024
1a68413
Just the review stuff
Exempt-Medic Nov 21, 2024
a9220f5
Changing the item/location validation
Exempt-Medic Nov 21, 2024
31f6481
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Nov 29, 2024
e64db22
Merge pull request #12 from Exempt-Medic/plando-items-review
Silvris Nov 29, 2024
a30030a
Apply suggestions from code review
Silvris Nov 30, 2024
2794b9a
convert plando item to dataclass
Silvris Dec 2, 2024
d055670
Merge remote-tracking branch 'upstream/main' into plando_items_rewrite
Silvris Dec 2, 2024
28802ad
Merge branch 'main' into plando_items_rewrite
Silvris Dec 11, 2024
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
11 changes: 6 additions & 5 deletions BaseClasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ def get_entrance(self, entrance_name: str, player: int) -> Entrance:
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]

def get_all_state(self, use_cache: bool) -> CollectionState:
def get_all_state(self, use_cache: bool, collect_pre_fill_items: bool = True) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
Expand All @@ -435,10 +435,11 @@ def get_all_state(self, use_cache: bool) -> CollectionState:

for item in self.itempool:
self.worlds[item.player].collect(ret, item)
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
if collect_pre_fill_items:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()

if use_cache:
Expand Down
282 changes: 128 additions & 154 deletions Fill.py

Large diffs are not rendered by default.

12 changes: 2 additions & 10 deletions Generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,12 +291,6 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name


def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)


def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
Expand Down Expand Up @@ -362,7 +356,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if roll_percentage(option_set["percentage"]):
if Options.roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
Expand Down Expand Up @@ -395,7 +389,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
Expand Down Expand Up @@ -494,8 +488,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if option_key in {"triggers", *valid_keys}:
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights)

Expand Down
124 changes: 120 additions & 4 deletions Options.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
import pathlib


def roll_percentage(percentage: typing.Union[int, float]) -> bool:
Silvris marked this conversation as resolved.
Show resolved Hide resolved
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)


class OptionError(ValueError):
pass

Expand Down Expand Up @@ -974,7 +980,7 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
if roll_percentage(text.get("percentage", 100)):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
Expand All @@ -1000,7 +1006,7 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100):
if roll_percentage(text.percentage):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
Expand Down Expand Up @@ -1124,7 +1130,7 @@ def from_any(cls, data: PlandoConFromAnyType) -> Self:
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
if roll_percentage(percentage):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
Expand All @@ -1142,7 +1148,7 @@ def from_any(cls, data: PlandoConFromAnyType) -> Self:
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
if roll_percentage(connection.percentage):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
Expand Down Expand Up @@ -1412,6 +1418,115 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
link["item_pool"] = list(pool)


class PlandoItem(typing.NamedTuple):
items: typing.Union[typing.List[str], typing.Dict[str, typing.Any]]
locations: typing.List[str]
world: typing.Union[int, str, bool, None, typing.Iterable[str], typing.Set[int]] = False
from_pool: bool = True
force: typing.Union[bool, typing.Literal["silent"]] = "silent"
count: typing.Union[int, bool, typing.Dict[str, int]] = False
Silvris marked this conversation as resolved.
Show resolved Hide resolved
Silvris marked this conversation as resolved.
Show resolved Hide resolved
percentage: int = 100


class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"

def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()

@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise Exception(f"Cannot create plando items from non-Iterable type, got {type(data)}")

value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise Exception(f"Plando `percentage` has to be int, not {type(percentage)}.")
Silvris marked this conversation as resolved.
Show resolved Hide resolved
if not (0 <= percentage <= 100):
raise Exception(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise Exception("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, dict):
raise Exception(f"Plando 'item' has to be string or dictionary, not {type(items)}.")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise Exception(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
Silvris marked this conversation as resolved.
Show resolved Hide resolved
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise Exception(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)

def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
Silvris marked this conversation as resolved.
Show resolved Hide resolved
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = sorted(world.item_name_groups[item])
for group_item in group:
if group_item in plando.items:
raise Exception(f"Plando `items` contains both \"{group_item}\" and the group "
f"\"{item}\" which contains it. It cannot have both.")
plando.items.update({key: value for key in group})
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))

@classmethod
def get_option_name(cls, value: typing.List[PlandoItem]) -> str:
return ", ".join(["%s-%s" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be

def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)

def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value

def __len__(self) -> int:
return len(self.value)


class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
Expand All @@ -1434,6 +1549,7 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems


@dataclass
Expand Down
16 changes: 16 additions & 0 deletions test/general/test_implemented.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,19 @@ def test_slot_data(self):
def test_no_failed_world_loads(self):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")

def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "generate_basic"))
allstate = multiworld.get_all_state(False)
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]

self.assertTrue(not unreachable,
f"Locations were not reachable with all state before prefill: "
f"{unreachable}")
43 changes: 23 additions & 20 deletions worlds/alttp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,29 +496,29 @@ def collect_item(self, state: CollectionState, item: Item, remove=False):
def pre_fill(self):
from Fill import fill_restrictive, FillError
attempts = 5
world = self.multiworld
player = self.player
all_state = world.get_all_state(use_cache=True)
all_state = self.multiworld.get_all_state(use_cache=True).copy()
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
crystal_locations = [world.get_location('Turtle Rock - Prize', player),
world.get_location('Eastern Palace - Prize', player),
world.get_location('Desert Palace - Prize', player),
world.get_location('Tower of Hera - Prize', player),
world.get_location('Palace of Darkness - Prize', player),
world.get_location('Thieves\' Town - Prize', player),
world.get_location('Skull Woods - Prize', player),
world.get_location('Swamp Palace - Prize', player),
world.get_location('Ice Palace - Prize', player),
world.get_location('Misery Mire - Prize', player)]
for crystal in crystals:
all_state.remove(crystal)
crystal_locations = [self.multiworld.get_location('Turtle Rock - Prize', self.player),
self.multiworld.get_location('Eastern Palace - Prize', self.player),
self.multiworld.get_location('Desert Palace - Prize', self.player),
self.multiworld.get_location('Tower of Hera - Prize', self.player),
self.multiworld.get_location('Palace of Darkness - Prize', self.player),
self.multiworld.get_location('Thieves\' Town - Prize', self.player),
self.multiworld.get_location('Skull Woods - Prize', self.player),
self.multiworld.get_location('Swamp Palace - Prize', self.player),
self.multiworld.get_location('Ice Palace - Prize', self.player),
self.multiworld.get_location('Misery Mire - Prize', self.player)]
placed_prizes = {loc.item.name for loc in crystal_locations if loc.item}
unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes]
empty_crystal_locations = [loc for loc in crystal_locations if not loc.item]
for attempt in range(attempts):
try:
prizepool = unplaced_prizes.copy()
prize_locs = empty_crystal_locations.copy()
world.random.shuffle(prize_locs)
fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True,
self.multiworld.random.shuffle(prize_locs)
fill_restrictive(self.multiworld, all_state, prize_locs, prizepool, True, lock=True,
name="LttP Dungeon Prizes")
except FillError as e:
lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
Expand All @@ -529,10 +529,10 @@ def pre_fill(self):
break
else:
raise FillError('Unable to place dungeon prizes')
if world.mode[player] == 'standard' and world.small_key_shuffle[player] \
and world.small_key_shuffle[player] != small_key_shuffle.option_universal and \
world.small_key_shuffle[player] != small_key_shuffle.option_own_dungeons:
world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1
if self.multiworld.mode[self.player] == 'standard' and self.multiworld.small_key_shuffle[self.player] \
and self.multiworld.small_key_shuffle[self.player] != small_key_shuffle.option_universal and \
self.multiworld.small_key_shuffle[self.player] != small_key_shuffle.option_own_dungeons:
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1

@classmethod
def stage_pre_fill(cls, world):
Expand Down Expand Up @@ -802,12 +802,15 @@ def get_filler_item_name(self) -> str:
return GetBeemizerItem(self.multiworld, self.player, item)

def get_pre_fill_items(self):
res = []
res = [self.create_item(name) for name in ('Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1',
Berserker66 marked this conversation as resolved.
Show resolved Hide resolved
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5',
'Crystal 6')]
if self.dungeon_local_item_names:
for dungeon in self.dungeons.values():
for item in dungeon.all_items:
if item.name in self.dungeon_local_item_names:
res.append(item)

return res

def fill_slot_data(self):
Expand Down
2 changes: 1 addition & 1 deletion worlds/blasphemous/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def create_items(self):

if self.options.thorn_shuffle == "local_only":
self.options.local_items.value.add("Thorn Upgrade")


def place_items_from_set(self, location_set: Set[str], name: str):
for loc in location_set:
Expand Down
4 changes: 4 additions & 0 deletions worlds/hylics2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ def pre_fill(self):
tv = tvs.pop()
self.get_location(tv).place_locked_item(self.create_item(gesture))

def get_pre_fill_items(self) -> List["Item"]:
if self.options.gesture_shuffle:
return [self.create_item(gesture["name"]) for gesture in Items.gesture_item_table.values()]
return []

def fill_slot_data(self) -> Dict[str, Any]:
slot_data: Dict[str, Any] = {
Expand Down
6 changes: 5 additions & 1 deletion worlds/kh2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,10 +431,14 @@ def keyblade_pre_fill(self):
Fills keyblade slots with abilities determined on player's setting
"""
keyblade_locations = [self.multiworld.get_location(location, self.player) for location in Keyblade_Slots.keys()]
state = self.multiworld.get_all_state(False)
state = self.multiworld.get_all_state(False, False)
keyblade_ability_pool_copy = self.keyblade_ability_pool.copy()
fill_restrictive(self.multiworld, state, keyblade_locations, keyblade_ability_pool_copy, True, True, allow_excluded=True)

def get_pre_fill_items(self) -> List["Item"]:
return [self.create_item(item) for item in [*DonaldAbility_Table.keys(), *GoofyAbility_Table.keys(),
*SupportAbility_Table.keys()]]

def starting_invo_verify(self):
"""
Making sure the player doesn't put too many abilities in their starting inventory.
Expand Down
Loading
Loading