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 27 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
260 changes: 123 additions & 137 deletions Fill.py

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions Generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,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
93 changes: 93 additions & 0 deletions Options.py
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,98 @@ 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.Optional[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
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 random.random() < float(percentage / 100):
Silvris marked this conversation as resolved.
Show resolved Hide resolved
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explcitly throw an error here if not present
if not items:
raise Exception("You must specify at least one item to place items with plando.")
items = [items]
Silvris marked this conversation as resolved.
Show resolved Hide resolved
locations = item.get("locations", [])
if not locations:
locations = item.get("location", None)
if locations:
locations = [locations]
Silvris marked this conversation as resolved.
Show resolved Hide resolved
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 random.random() < float(item.percentage / 100):
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)
plando.items.update({key: value for key in world.item_name_groups[item]})
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 @@ -1431,6 +1523,7 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems


@dataclass
Expand Down
19 changes: 18 additions & 1 deletion test/general/test_implemented.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import unittest

from Fill import distribute_items_restrictive
from Fill import distribute_items_restrictive, distribute_planned
from NetUtils import encode
from Options import PlandoItem
Silvris marked this conversation as resolved.
Show resolved Hide resolved
from worlds.AutoWorld import AutoWorldRegister, call_all
from worlds import failed_world_loads
from . import setup_solo_multiworld
Expand Down Expand Up @@ -52,3 +53,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 @@ -497,29 +497,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 @@ -530,10 +530,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 @@ -805,12 +805,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 @@ -420,10 +420,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
5 changes: 3 additions & 2 deletions worlds/ladx/test/testShop.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Optional

from Fill import distribute_planned
from Options import PlandoItems
from test.general import setup_solo_multiworld
from worlds.AutoWorld import call_all
from . import LADXTestBase
Expand All @@ -19,13 +20,13 @@ class PlandoTest(LADXTestBase):
],
}],
}

def world_setup(self, seed: Optional[int] = None) -> None:
self.multiworld = setup_solo_multiworld(
LinksAwakeningWorld,
("generate_early", "create_regions", "create_items", "set_rules", "generate_basic")
)
self.multiworld.plando_items[1] = self.options["plando_items"]
self.multiworld.worlds[1].options.plando_items = PlandoItems.from_any(self.options["plando_items"])
distribute_planned(self.multiworld)
call_all(self.multiworld, "pre_fill")

Expand Down
4 changes: 3 additions & 1 deletion worlds/oot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

from settings import get_settings
from BaseClasses import MultiWorld, CollectionState, Tutorial, LocationProgressType
from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections
from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections, PlandoItems
from Fill import fill_restrictive, fast_fill, FillError
from worlds.generic.Rules import exclusion_rules, add_item_rule
from worlds.AutoWorld import World, AutoLogicRegister, WebWorld
Expand Down Expand Up @@ -214,6 +214,8 @@ def generate_early(self):
option_value = result.value
elif isinstance(result, PlandoConnections):
option_value = result.value
elif isinstance(result, PlandoItems):
option_value = result.value
else:
option_value = result.current_key
setattr(self, option_name, option_value)
Expand Down
14 changes: 12 additions & 2 deletions worlds/pokemon_emerald/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ def pre_fill(self) -> None:
if location.progress_type == LocationProgressType.EXCLUDED \
else location.progress_type

collection_state = self.multiworld.get_all_state(False)
collection_state = self.multiworld.get_all_state(False, False)

# If HM shuffle is on, HMs are not placed and not in the pool, so
# `get_all_state` did not contain them. Collect them manually for
Expand Down Expand Up @@ -548,7 +548,7 @@ def pre_fill(self) -> None:
if location.progress_type == LocationProgressType.EXCLUDED \
else location.progress_type

collection_state = self.multiworld.get_all_state(False)
collection_state = self.multiworld.get_all_state(False, False)

# In specific very constrained conditions, fill_restrictive may run
# out of swaps before it finds a valid solution if it gets unlucky.
Expand All @@ -568,6 +568,16 @@ def pre_fill(self) -> None:
logging.debug(f"Failed to shuffle HMs for player {self.player}. Retrying.")
continue

def get_pre_fill_items(self) -> List[PokemonEmeraldItem]:
pool = []
if self.options.badges == RandomizeBadges.option_shuffle:
pool.extend(badge for _, badge in self.badge_shuffle_info)

if self.options.hms == RandomizeHms.option_shuffle:
pool.extend(hm for _, hm in self.hm_shuffle_info)

return pool

def generate_output(self, output_directory: str) -> None:
self.modified_trainers = copy.deepcopy(emerald_data.trainers)
self.modified_tmhm_moves = copy.deepcopy(emerald_data.tmhm_moves)
Expand Down
10 changes: 8 additions & 2 deletions worlds/pokemon_rb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ def pre_fill(self) -> None:

# Delete evolution events for Pokémon that are not in logic in an all_state so that accessibility check does not
# fail. Re-use test_state from previous final loop.
all_state = self.multiworld.get_all_state(False)
all_state = self.multiworld.get_all_state(False, False)
evolutions_region = self.multiworld.get_region("Evolution", self.player)
for location in evolutions_region.locations.copy():
if not all_state.can_reach(location, player=self.player):
Expand Down Expand Up @@ -460,7 +460,7 @@ def pre_fill(self) -> None:

self.local_locs = locs

all_state = self.multiworld.get_all_state(False)
all_state = self.multiworld.get_all_state(False, False)

reachable_mons = set()
for mon in poke_data.pokemon_data:
Expand Down Expand Up @@ -520,6 +520,12 @@ def pre_fill(self) -> None:
else:
raise Exception("Failed to remove corresponding item while deleting unreachable Dexsanity location")

def get_pre_fill_items(self) -> typing.List["Item"]:
pool = [self.create_item(mon) for mon in poke_data.pokemon_data]
Silvris marked this conversation as resolved.
Show resolved Hide resolved
return pool



@classmethod
def stage_post_fill(cls, multiworld):
# Convert all but one of each instance of a wild Pokemon to useful classification.
Expand Down
Loading
Loading