mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-10-07 14:13:05 -07:00
1909 lines
104 KiB
Python
1909 lines
104 KiB
Python
import abc
|
|
from collections import Counter, defaultdict, deque
|
|
from collections.abc import Iterable, Sequence
|
|
import itertools
|
|
import logging
|
|
from typing import Callable, Literal, ClassVar
|
|
|
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
|
from Options import Accessibility
|
|
|
|
from worlds.AutoWorld import call_all
|
|
from worlds.generic.Rules import add_item_rule
|
|
|
|
|
|
class FillError(RuntimeError):
|
|
def __init__(self, *args: str | object, **kwargs: object) -> None:
|
|
multiworld = kwargs.get("multiworld")
|
|
if isinstance(multiworld, MultiWorld) and isinstance(args[0], str):
|
|
placements = (args[0] + "\nAll Placements:\n" +
|
|
f"{[(loc, loc.item) for loc in multiworld.get_filled_locations()]}")
|
|
args = (placements, *args[1:])
|
|
super().__init__(*args)
|
|
|
|
|
|
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
|
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
|
|
|
|
|
|
def sweep_from_pool(base_state: CollectionState,
|
|
itempool: Sequence[Item] = (),
|
|
locations: Iterable[Location] | None = None) -> CollectionState:
|
|
new_state = base_state.copy()
|
|
for item in itempool:
|
|
new_state.collect(item, True)
|
|
new_state.sweep_for_advancements(locations=locations)
|
|
return new_state
|
|
|
|
|
|
class _RestrictiveFillBatcher:
|
|
"""
|
|
Batcher for item placements in fill_restrictive.
|
|
|
|
Breaks up the item_pool into batches of placements at a time.
|
|
|
|
The batcher reduces CollectionState sweeping costs by each batch creating two extra CollectionStates:
|
|
- A 'batch base state' that contains only the inventory of items which have yet to be placed and are not being
|
|
placed as part of the current batch, and reachable items that were reachable before fill_restrictive was called.
|
|
This assumes that copying the batch's base state is more performant than copying the `base_state` argument of the
|
|
fill_restrictive call and collecting the items into that copy of `base_state`.
|
|
- A 'partial exploration state' that starts from the 'base state' and explores filled locations, collecting all
|
|
reachable items.
|
|
|
|
Creating a maximum exploration state to determine where items can be placed is done by sweeping for advancements
|
|
from the batch's partial exploration state, this saves a lot of work compared to sweeping for advancements from the
|
|
`base_state` argument of the `fill_restrictive` call.
|
|
|
|
Creating a partial exploration state is a cost that typically occurs once per batch (may occur multiple times due to
|
|
some swaps). If batches are too small then the extra cost of creating the partial exploration state can outweigh the
|
|
savings of sweeping from the partial exploration state rather than from the `base_state` of the `fill_restrictive`
|
|
call.
|
|
|
|
Swap states can also be created from the partial exploration state, but only if the partial exploration state has
|
|
not collected the item from the swap location. If the partial exploration state has collected the item from the swap
|
|
location, then the swap state will have to be swept from the base state instead.
|
|
|
|
When a swap displaces an item that the partial exploration state had collected, the partial exploration state must
|
|
be destroyed in some cases. For this reason, the partial exploration state is created lazily and is only re-created
|
|
when needed.
|
|
|
|
:param base_state: The base_state passed as an argument to the fill_restrictive call. When there are lots of items
|
|
already placed, this state should have already swept for advancements to collect every reachable advancement
|
|
item.
|
|
:param reachable_items: Items remaining to place, per-player. Because fill uses a reverse-fill algorithm, these
|
|
items are always considered reachable until they are removed and placed.
|
|
:param item_pool: The entire item pool of items to place. Items are removed from it when placed.
|
|
:param one_item_per_player: True to place one item per player simultaneously, False to place one item at a time.
|
|
When True, this significantly increases fill performance at the cost of introducing a small bias towards
|
|
placing items in earlier locations. This bias occurs because items can, usually, only be placed at locations
|
|
that are reachable without themselves, but when multiple items are placed simultaneously the reachable
|
|
locations will be reduced to locations reachable without *any* of the items that are being placed
|
|
simultaneously.
|
|
"""
|
|
|
|
# Adjust these ClassVars to adjust batch sizes, as needed, when external changes to generation performance are made.
|
|
|
|
_MIN_BATCH_ITEMS_PER_PLAYER: ClassVar[int] = 5
|
|
"""
|
|
Pick no fewer than this many items per player for each batch, unless that player does not have enough items
|
|
remaining. This is a magic number and will typically be used for most batches.
|
|
"""
|
|
_MIN_TOTAL_ITEMS_PER_BATCH: ClassVar[int] = 40
|
|
"""
|
|
Try to pick no fewer than this many items total for each batch. If a player does not have enough items remaining to
|
|
fully fill out a batch, then the total number of items in a batch can end up lower than this value.
|
|
With a low number of players, try to keep the total number of items in the batch from being too small, to prevent
|
|
fills with few players from creating lots of very small batches.
|
|
"""
|
|
_MAX_PERCENT_OF_LARGEST_STARTING_POOL_TO_PLACE: ClassVar[float] = 0.02 # 2%
|
|
"""
|
|
When players have items pools with different sizes, the percentage of remaining items to place gradually approaches
|
|
being 100% the player with the largest item pool. While this happens, gradually increase the number of items in each
|
|
batch until the last player with items remaining places this percentage of their starting item pool in each batch.
|
|
"""
|
|
|
|
class _RestrictiveFillBatch(abc.ABC):
|
|
"""
|
|
Base class for a batch of items to fill in _RestrictiveFillBatcher.
|
|
|
|
:param batch_base_state: Base state for the batch. It must have collected all items that will be placed in
|
|
future batches and must not have explored any locations besides those in the base_state at the start of
|
|
fill_restrictive.
|
|
:param batch_empty_spaces: The number of empty spaces for items for each player in the batch. If the size of
|
|
the batch if 5 items per player and one player only had 3 items remaining, they would have 2 empty
|
|
spaces in the batch.
|
|
When swapping items out of already filled locations, the swapped out item can be added into the current
|
|
batch if there is space for it.
|
|
:param batch_item_pool: All items to be placed in this batch. Items are removed from it when picked to be
|
|
placed.
|
|
:param item_pool: All items remaining to be placed. Items are removed from it when picked to be placed. The
|
|
items in this list must also be present in the deques in `reachable_items`.
|
|
:param batched_placements_remaining: The maximum number of placements the batch can make before being
|
|
exhausted.
|
|
:param reachable_items: All items remaining to be placed, by player. The items in the deques in this dict
|
|
must also be present in `item_pool`.
|
|
"""
|
|
batch_base_state: CollectionState
|
|
batch_empty_spaces: dict[int, int]
|
|
batch_item_pool: list[Item]
|
|
reachable_items: dict[int, deque[Item]]
|
|
batched_placements_remaining: int
|
|
item_pool: list[Item]
|
|
|
|
# An exploration state containing the items reachable starting from `batch_base_state`.
|
|
# Maximum exploration states and some swap states will be swept from `_partial_exploration_state`.
|
|
# Swaps may cause the partial exploration state to become invalid, which the batch accounts for when sweeping
|
|
# new states from it, but the partial exploration state would otherwise be invalid to use in isolation, so it is
|
|
# not exposed as a public attribute.
|
|
_partial_exploration_state: CollectionState | None
|
|
|
|
# When an item is swapped from an existing placement, that _partial_exploration_state has already collected
|
|
# from, into the current batch, the _partial_exploration_state must be destroyed and re-created once it is time
|
|
# to place the swapped item.
|
|
# The set tracks the unique object identifiers of swapped items because Item implements __eq__, so a set[Item]
|
|
# would not be usable here.
|
|
# The list tracks the Item instances, whose unique object identifiers must be in the set.
|
|
_deferred_swap_item_ids: set[int]
|
|
_deferred_swap_items: list[Item]
|
|
|
|
def __init__(self,
|
|
batch_base_state: CollectionState,
|
|
batch_empty_spaces: dict[int, int],
|
|
batch_item_pool: list[Item],
|
|
item_pool: list[Item],
|
|
batched_placements_remaining: int,
|
|
reachable_items: dict[int, deque[Item]]):
|
|
self.batch_base_state = batch_base_state
|
|
self.batch_empty_spaces = batch_empty_spaces
|
|
self.batch_item_pool = batch_item_pool
|
|
self.item_pool = item_pool
|
|
self.batched_placements_remaining = batched_placements_remaining
|
|
self.reachable_items = reachable_items
|
|
|
|
self._partial_exploration_state = None
|
|
|
|
self._deferred_swap_item_ids = set()
|
|
self._deferred_swap_items = []
|
|
|
|
@abc.abstractmethod
|
|
def _pop_items_to_place(self) -> list[Item]:
|
|
"""
|
|
Get and remove items to place from `self.reachable_items`. `self.batched_placements_remaining > 0` must be
|
|
checked before calling this and `self.batched_placements_remaining` must be reduced by 1 afterward.
|
|
|
|
:return: A list of items to place, or an empty list if the batch is exhausted and a new batch should be
|
|
created.
|
|
"""
|
|
...
|
|
|
|
def _update_pools_for_items_to_place(self, items_to_place: list[Item]) -> None:
|
|
"""
|
|
After getting items to place, update the item pools.
|
|
|
|
:param items_to_place: Items that have been removed from the batch, and are going to be placed.
|
|
"""
|
|
batch_item_pool = self.batch_item_pool
|
|
item_pool = self.item_pool
|
|
for item in items_to_place:
|
|
for p, batch_pool_item in enumerate(batch_item_pool):
|
|
if batch_pool_item is item:
|
|
del batch_item_pool[p]
|
|
break
|
|
# The items added into `reachable_items` are placed starting from the end of each deque in
|
|
# `reachable_items`, so the items being placed are more likely to found towards the end of `item_pool`.
|
|
for p, pool_item in enumerate(reversed(item_pool), start=1):
|
|
if pool_item is item:
|
|
del item_pool[-p]
|
|
break
|
|
|
|
def pop_items_to_place(self) -> list[Item]:
|
|
"""
|
|
Get and remove items to place from this batch.
|
|
|
|
:return: A list of items to place, or an empty list if the batch is exhausted.
|
|
"""
|
|
batched_placements_remaining = self.batched_placements_remaining
|
|
if batched_placements_remaining <= 0:
|
|
return []
|
|
self.batched_placements_remaining = batched_placements_remaining - 1
|
|
items_to_place = self._pop_items_to_place()
|
|
|
|
deferred_swap_item_ids = self._deferred_swap_item_ids
|
|
if deferred_swap_item_ids and not deferred_swap_item_ids.isdisjoint(map(id, items_to_place)):
|
|
assert self._partial_exploration_state is not None
|
|
# One of the items being placed was swapped into the current batch, but was already collected by the
|
|
# partial exploration state. It is time to place that item now, so the partial exploration state is
|
|
# invalid and will need to be re-created.
|
|
self._partial_exploration_state = None
|
|
|
|
# Any deferred swap items that are not being placed will need to be added back into the batch item pool.
|
|
# The deferred swap items that *are* being placed do not need to be added back into the batch item pool,
|
|
# but it is simpler to add all the items and then remove the ones being placed because determining which
|
|
# items do not need to be added needs to be done by comparing by identity.
|
|
for item in self._deferred_swap_items:
|
|
self.batch_item_pool.append(item)
|
|
|
|
# Clear the list of deferred swap items and the set of their item IDs.
|
|
self._deferred_swap_items.clear()
|
|
deferred_swap_item_ids.clear()
|
|
|
|
self._update_pools_for_items_to_place(items_to_place)
|
|
return items_to_place
|
|
|
|
def get_maximum_exploration_state(self, explore_locations: list[Location] | None, unplaced_items: list[Item]
|
|
) -> CollectionState:
|
|
"""
|
|
Get the maximum exploration state for the currently reachable items.
|
|
|
|
An item is considered reachable if it matches any one of the following:
|
|
A. It is in `self.item_pool` and is not being placed in this batch. `self.batch_base_state` collected these
|
|
items in advance because they do not change within a batch.
|
|
B. It is in both `self.item_pool` and `self.batch_item_pool` (the items in batch_item_pool should always be
|
|
present in item_pool), so it is an item that is going to be placed as part of this batch, but later on.
|
|
C. It is in `unplaced_items`, so could not be placed at any location. These items must be included because
|
|
fill_restrictive allows for partial fills and retries depending on its arguments.
|
|
D. It is at a location in `explore_locations` that is reachable with all items in A-D.
|
|
|
|
Items already placed at reachable locations, items not being placed in this batch, items yet to be removed
|
|
from this batch in order to be placed, and items that could not be placed at any location are all considered
|
|
reachable.
|
|
|
|
:param explore_locations: The locations to explore for reachable items. Defaults to all filled locations
|
|
when None.
|
|
:param unplaced_items: All items that could not be placed at any location.
|
|
:return: A CollectionState that has collected all reachable advancement items.
|
|
"""
|
|
partial_exploration_state = self._partial_exploration_state
|
|
|
|
# Create the initial partial exploration state or recreate it if it was destroyed by a swap.
|
|
if partial_exploration_state is None:
|
|
# `locations=None` defaults to `multiworld.get_filled_locations()`, so only get it once for both the
|
|
# batch's partial exploration state and the maximum exploration state.
|
|
if explore_locations is None:
|
|
explore_locations = self.batch_base_state.multiworld.get_filled_locations()
|
|
# batch_base_state has already collected all items that still need to be placed, but are not being
|
|
# placed in this batch (A. items).
|
|
# The sweep to create `partial_exploration_state` collects many additional reachable items (some D.
|
|
# items).
|
|
partial_exploration_state = sweep_from_pool(self.batch_base_state, locations=explore_locations)
|
|
self._partial_exploration_state = partial_exploration_state
|
|
|
|
# Collect items in this batch that have yet to be removed in order to be placed (B. items), and collect all
|
|
# items that could not be placed at any location (C. items), and then sweep to collect all remaining
|
|
# reachable items (the remaining D. items).
|
|
maximum_exploration_state = sweep_from_pool(
|
|
partial_exploration_state, self.batch_item_pool + unplaced_items, explore_locations)
|
|
|
|
return maximum_exploration_state
|
|
|
|
def _add_swapped_item_into_batch(self, displaced_item: Item, swap_location: Location) -> None:
|
|
"""
|
|
Add an item displaced by a swap into the current batch.
|
|
`self.batch_empty_spaces[displaced_item.player] > 0` must be True.
|
|
|
|
:param displaced_item: The previously placed item that the swap displaced.
|
|
:param swap_location: The location at which displaced_item was displaced from, and at which the item being
|
|
placed was placed at.
|
|
"""
|
|
assert self.batch_empty_spaces[displaced_item.player] > 0
|
|
self.batch_empty_spaces[displaced_item.player] -= 1
|
|
partial_exploration_state = self._partial_exploration_state
|
|
if partial_exploration_state is not None and swap_location in partial_exploration_state.advancements:
|
|
# The partial exploration state exists and has already collected `displaced_item`.
|
|
# `displaced_item` should be added into `self.batch_item_pool`, but doing so would mean that a maximum
|
|
# exploration state or swap state swept from the partial exploration state would have collected
|
|
# `displaced_item` twice, which would be incorrect.
|
|
# Instead of adding `displaced_item` into `self.batch_item_pool` immediately and re-creating the partial
|
|
# exploration state, adding `displaced_item` into `self.batch_item_pool` is deferred until it is time to
|
|
# place `displaced_item`. Only then does the partial exploration state need to be re-created. This
|
|
# reduces how often the partial exploration state needs to be re-created, increasing performance.
|
|
assert id(displaced_item) not in self._deferred_swap_item_ids, \
|
|
(f"Displaced item {displaced_item} has already been swapped into the batch. This should never"
|
|
f" happen because the set of displaced item IDs should be cleared whenever it is time to place one"
|
|
f" of the displaced items. If this does happen, it is more likely that a world submitted the same"
|
|
f" Item instance to the item pool multiple times instead of creating multiple Item instances.")
|
|
# Multiple items can be displaced into the current batch, and the items need to be compared by identity,
|
|
# so a set of their unique object IDs is used to track them.
|
|
self._deferred_swap_item_ids.add(id(displaced_item))
|
|
self._deferred_swap_items.append(displaced_item)
|
|
|
|
# If a location has already been checked by a state, the state will ignore that location in further
|
|
# sweeps, so `swap_location` needs to be removed so that sweeping will check the location again.
|
|
partial_exploration_state.advancements.remove(swap_location)
|
|
# Also remove it from locations_checked for completeness.
|
|
partial_exploration_state.locations_checked.remove(swap_location)
|
|
else:
|
|
# If the partial exploration state does not currently exist, or has not collected the displaced item,
|
|
# the displaced item can simply be added into the batch's item pool.
|
|
self.batch_item_pool.append(displaced_item)
|
|
|
|
def _add_swapped_item_into_future_batch(self, displaced_item: Item, item_to_place: Item,
|
|
swap_location: Location) -> None:
|
|
"""
|
|
Add an item displaced by a swap into a future batch.
|
|
|
|
:param displaced_item: The previously placed item that the swap displaced.
|
|
:param item_to_place: The item that was placed into the swap location.
|
|
:param swap_location: The location at which displaced_item was displaced from and item_to_place was placed
|
|
at.
|
|
"""
|
|
# A batch's base state collects all items which are yet to be placed and which are not going to be placed as
|
|
# part of the batch. The displaced item matches these criteria, so collect the item.
|
|
self.batch_base_state.collect(displaced_item, True)
|
|
# A batch's partial exploration state is swept from its batch's base state, so it needs to collect the item
|
|
# too, but only if it had not already collected the item.
|
|
partial_exploration_state = self._partial_exploration_state
|
|
# The batch's partial exploration state may have been destroyed by a previous `_add_swapped_item_into_batch`
|
|
# swap, in which case, it will not exist.
|
|
if partial_exploration_state is not None:
|
|
if swap_location not in partial_exploration_state.advancements:
|
|
# The partial exploration state had not already collected the un-placed item by sweeping, so collect
|
|
# the item.
|
|
partial_exploration_state.collect(displaced_item, True)
|
|
else:
|
|
# The partial exploration state had already collected the displaced item from `spot_to_fill`.
|
|
# Rather than destroying the partial exploration state, it can be adjusted.
|
|
# Collect the item being placed if `spot_to_fill` is still reachable.
|
|
if swap_location.can_reach(partial_exploration_state):
|
|
# This is slightly faster than removing `spot_to_fill` from the state's collected advancement
|
|
# locations and requiring future maximum exploration states sweep to pick up `item_to_place`
|
|
# from `swap_location`.
|
|
partial_exploration_state.collect(item_to_place, True)
|
|
else:
|
|
# In rare cases, `spot_to_fill` was only reachable because of the item that was placed at it,
|
|
# which can happen with some self-locking item implementations.
|
|
# Future sweeps will have to retry `spot_to_fill`, so remove it from the state's set of
|
|
# collected advancement locations.
|
|
partial_exploration_state.advancements.remove(swap_location)
|
|
# Also remove the location from locations_checked for completeness.
|
|
partial_exploration_state.locations_checked.remove(swap_location)
|
|
|
|
def update_for_swap(self, displaced_item: Item, item_to_place: Item, swap_location: Location) -> None:
|
|
"""
|
|
Update the batch for the result of a successful swap.
|
|
|
|
:param displaced_item: The item that was previously placed at swap_location and has been displaced by the
|
|
swap.
|
|
:param item_to_place: The item that has been placed at swap_location.
|
|
:param swap_location: The location where the two items have been swapped.
|
|
"""
|
|
# Determine if the displaced item can be added to the current batch.
|
|
empty_spaces_for_items = self.batch_empty_spaces[displaced_item.player]
|
|
if empty_spaces_for_items > 0:
|
|
# There are some empty spaces for items in this batch for this player, so add the item into this batch.
|
|
self._add_swapped_item_into_batch(displaced_item, swap_location)
|
|
else:
|
|
# There are no empty spaces in this batch for this player, so the item will need to be placed in a
|
|
# different batch.
|
|
self._add_swapped_item_into_future_batch(displaced_item, item_to_place, swap_location)
|
|
|
|
def get_swap_state(self, displaced_item: Item, location: Location, explore_locations: list[Location] | None,
|
|
unsafe: bool) -> CollectionState:
|
|
"""
|
|
Get the maximum exploration state for a potential swap.
|
|
|
|
:param displaced_item: The item displaced from `location`.
|
|
:param location: The location of the swap attempt.
|
|
:param explore_locations: The locations the swap state should sweep. `None` will sweep all filled locations.
|
|
:param unsafe: Assume it will be possible to collect `displaced_item` before the item that is being placed,
|
|
by continuing to swap.
|
|
:return: A fully swept CollectionState for the swap attempt.
|
|
"""
|
|
# Use the batch's partial exploration state as the base state if it has not explored the location of the
|
|
# swap attempt. This reduces sweeping costs.
|
|
partial_exploration_state = self._partial_exploration_state
|
|
if partial_exploration_state is not None and location not in partial_exploration_state.advancements:
|
|
swap_base_state = partial_exploration_state
|
|
deferred_swap_items_to_collect = None
|
|
else:
|
|
# Unfortunately, the partial exploration state has explored the location, so the swap state will have to
|
|
# be swept from the batch's base state instead, which has collected fewer items, so will take longer to
|
|
# sweep from.
|
|
swap_base_state = self.batch_base_state
|
|
# The base state won't have collected any deferred swap items, so those will also need to be collected.
|
|
if self._deferred_swap_items:
|
|
deferred_swap_items_to_collect = self._deferred_swap_items
|
|
else:
|
|
deferred_swap_items_to_collect = None
|
|
|
|
if unsafe:
|
|
# Assume we can somehow collect `displaced_item` before the item that is being placed, by continuing to
|
|
# swap.
|
|
if deferred_swap_items_to_collect is not None:
|
|
items_to_collect = self.batch_item_pool + deferred_swap_items_to_collect
|
|
items_to_collect.append(displaced_item)
|
|
else:
|
|
items_to_collect = self.batch_item_pool + [displaced_item]
|
|
else:
|
|
if deferred_swap_items_to_collect is not None:
|
|
items_to_collect = self.batch_item_pool + deferred_swap_items_to_collect
|
|
else:
|
|
items_to_collect = self.batch_item_pool
|
|
|
|
return sweep_from_pool(swap_base_state, items_to_collect, explore_locations)
|
|
|
|
class _RestrictiveFillBatchOneItemPerPlayer(_RestrictiveFillBatch):
|
|
"""
|
|
Batch that places one item per player at a time.
|
|
|
|
This is less accurate because when placing an item for a player, the items belonging to other players that are
|
|
also going to be placed won't be included when determining if a location to place at is reachable. This results
|
|
in a bias towards earlier locations.
|
|
|
|
The tradeoff for reduced accuracy is that placing one item per player is significantly faster than placing one
|
|
item at a time when there are multiple players with items remaining to be placed.
|
|
"""
|
|
|
|
def _pop_items_to_place(self) -> list[Item]:
|
|
# Pop one item per player that has items remaining.
|
|
# The batch is carefully constructed such that `self.batched_placements_remaining` will reach zero before
|
|
# attempting to pop an item from `self.reachable_items` that is not in `self.batch_item_pool`.
|
|
return [items.pop() for items in self.reachable_items.values() if items]
|
|
|
|
class _RestrictiveFillBatchOneItemAtATime(_RestrictiveFillBatch):
|
|
"""
|
|
A batch that places one item at a time.
|
|
|
|
This improves placement accuracy, at the cost of performance.
|
|
|
|
:param batch_base_state: See _RestrictiveFillBatch
|
|
:param batch_empty_spaces: See _RestrictiveFillBatch
|
|
:param batch_item_pool: See _RestrictiveFillBatch
|
|
:param item_pool: See _RestrictiveFillBatch
|
|
:param batched_placements_remaining: See _RestrictiveFillBatch
|
|
:param reachable_items: See _RestrictiveFillBatch
|
|
:param items_per_player_in_batch: The number of items, per player, remaining in the batch.
|
|
:param next_player_override: When set, specifies which player the next item to place will belong to, instead of
|
|
picking randomly.
|
|
This should be set when the previous batch attempted to place an item belonging to a player that still has
|
|
items remaining to be placed, but had no items remaining in the batch.
|
|
"""
|
|
|
|
next_player_override: int | None
|
|
items_per_player_in_batch: dict[int, int]
|
|
|
|
def __init__(self,
|
|
batch_base_state: CollectionState,
|
|
batch_empty_spaces: dict[int, int],
|
|
batch_item_pool: list[Item],
|
|
item_pool: list[Item],
|
|
batched_placements_remaining: int,
|
|
reachable_items: dict[int, deque[Item]],
|
|
items_per_player_in_batch: dict[int, int],
|
|
next_player_override: int | None):
|
|
super().__init__(batch_base_state, batch_empty_spaces, batch_item_pool, item_pool,
|
|
batched_placements_remaining, reachable_items)
|
|
self.items_per_player_in_batch = items_per_player_in_batch
|
|
self.next_player_override = next_player_override
|
|
|
|
def _pop_items_to_place(self) -> list[Item]:
|
|
if self.next_player_override is None:
|
|
# Randomly pick the next player that will have an item placed.
|
|
multiworld = self.batch_base_state.multiworld
|
|
# Only pick from players with items remaining to be placed, including players with no items remaining in
|
|
# this batch.
|
|
player_choices = [player for player, items in self.reachable_items.items() if items]
|
|
next_player = multiworld.random.choice(player_choices)
|
|
player_remaining_items_in_batch = self.items_per_player_in_batch[next_player]
|
|
|
|
# Check that the picked player still has items remaining in this batch.
|
|
if player_remaining_items_in_batch <= 0:
|
|
assert player_remaining_items_in_batch == 0, ("The count of remaining items should never be"
|
|
" negative.")
|
|
# This player still has items to place, but their items in the batch have been exhausted.
|
|
# A new batch needs to be built. This prevents unfairness in picked items at the boundary between
|
|
# two batches.
|
|
self.batched_placements_remaining = 0
|
|
# The current batch is passed as an argument to create the next batch, where this override will be
|
|
# read to force the next batch to start by picking an item belonging to this player.
|
|
self.next_player_override = next_player
|
|
return []
|
|
else:
|
|
# The override being set means that a new batch was started by trying to pick an item belonging to a
|
|
# player that had run out of items in the previous batch, but still has items remaining to be placed.
|
|
next_player = self.next_player_override
|
|
# Clear the override.
|
|
self.next_player_override = None
|
|
player_remaining_items_in_batch = self.items_per_player_in_batch[next_player]
|
|
assert player_remaining_items_in_batch > 0, "The override player should always have items remaining."
|
|
# Pop an item for the chosen player and reduce the count of their remaining items in this batch.
|
|
items_to_place = [self.reachable_items[next_player].pop()]
|
|
self.items_per_player_in_batch[next_player] = player_remaining_items_in_batch - 1
|
|
|
|
return items_to_place
|
|
|
|
def _add_swapped_item_into_batch(self, placed_item: Item, swap_location: Location) -> None:
|
|
super()._add_swapped_item_into_batch(placed_item, swap_location)
|
|
# Update the count of items remaining in the batch for this player.
|
|
self.items_per_player_in_batch[placed_item.player] += 1
|
|
# When placing one item at a time, the number of batched placements remaining is equal to the total number
|
|
# of items in the batch to place.
|
|
self.batched_placements_remaining += 1
|
|
|
|
# Protected attributes for each of the __init__ parameters.
|
|
_base_state: CollectionState
|
|
_reachable_items: dict[int, deque[Item]]
|
|
_item_pool: list[Item]
|
|
_one_item_per_player: bool
|
|
|
|
# The maximum batch size to be reached once only the player with the largest number of items has items remaining to
|
|
# be placed.
|
|
_max_one_item_per_player_batch_size: float
|
|
|
|
# The minimum batch size for this batcher, typically equal to MIN_BATCH_SIZE.
|
|
_min_batch_size: int
|
|
|
|
# The current batch of the batcher, or `None` when there are no more items to place.
|
|
_current_batch: _RestrictiveFillBatch | None
|
|
|
|
def __init__(self,
|
|
base_state: CollectionState,
|
|
reachable_items: dict[int, deque[Item]],
|
|
item_pool: list[Item],
|
|
one_item_per_player: bool):
|
|
self._base_state = base_state
|
|
self._reachable_items = reachable_items
|
|
self._item_pool = item_pool
|
|
self._one_item_per_player = one_item_per_player
|
|
# With a low number of players, adjust the minimum items to take per player so that there is at least
|
|
# MIN_TOTAL_ITEMS_PER_BATCH total items in the batch. Some players could have fewer items remaining assuming
|
|
# each player has enough items remaining to fully fill out the batch.
|
|
num_players = len(reachable_items)
|
|
if num_players > 0 and self._MIN_BATCH_ITEMS_PER_PLAYER * num_players < self._MIN_TOTAL_ITEMS_PER_BATCH:
|
|
self._min_batch_size = self._MIN_TOTAL_ITEMS_PER_BATCH // num_players
|
|
else:
|
|
self._min_batch_size = self._MIN_BATCH_ITEMS_PER_PLAYER
|
|
|
|
# Gradually increase the number of items placed in each batch until only the player with the largest item pool
|
|
# has items remaining, at which point, place a percentage of their original item pool in each batch. Most fills
|
|
# won't go above `min_batch_size`, so this is mostly to account for progression fill with outlier worlds with
|
|
# very large numbers of advancement items that are likely to individually have minimal effect on progression.
|
|
# 0-274 items: 5 # This will be most fills.
|
|
# 275-324 items: 6
|
|
# 325-374 items: 7 # Few worlds will be higher than this in progression fill.
|
|
# 475-524 items: 10
|
|
# 975-1024 items: 20
|
|
# etc.
|
|
largest_player_pool = max(map(len, reachable_items.values()), default=0)
|
|
self._max_one_item_per_player_batch_size = (
|
|
largest_player_pool * self._MAX_PERCENT_OF_LARGEST_STARTING_POOL_TO_PLACE)
|
|
|
|
self._current_batch = self._new_batch(None)
|
|
|
|
def _new_batch(self, previous_batch: _RestrictiveFillBatch | None = None) -> _RestrictiveFillBatch | None:
|
|
"""
|
|
Create and return a new batch of items to place.
|
|
|
|
:param previous_batch: The previous batch, or `None` if there was no previous batch.
|
|
:return: A new batch, or `None` if there are no more items to place.
|
|
"""
|
|
# Calculate the number of items, per player, to place in the new batch.
|
|
reachable_items = self._reachable_items
|
|
|
|
# Get the count of, and individual lengths of, non-empty remaining per-player item pools.
|
|
nonzero_remaining_per_player = [len(items) for items in reachable_items.values() if items]
|
|
num_players_with_remaining_items = len(nonzero_remaining_per_player)
|
|
|
|
if num_players_with_remaining_items == 0:
|
|
# No more items to place, so return None to signal this.
|
|
# Later code in this function does not check for iterables being empty, and would need to be updated if this
|
|
# early return is changed to occur later, or if this early return is removed entirely.
|
|
return None
|
|
|
|
# Find the length of the largest remaining item pool.
|
|
largest_remaining = max(nonzero_remaining_per_player)
|
|
if num_players_with_remaining_items > 1:
|
|
# Adjust the batch size by the ratio of the average remaining item pool length to the largest remaining item
|
|
# pool length.
|
|
# Find the average length of the remaining item pools.
|
|
average_remaining = sum(nonzero_remaining_per_player) / num_players_with_remaining_items
|
|
# As the average remaining item pool length approaches the largest remaining item pool length, the batch
|
|
# size approaches `max_one_item_per_player_batch_size`.
|
|
batch_size_float = self._max_one_item_per_player_batch_size * average_remaining / largest_remaining
|
|
else:
|
|
batch_size_float = self._max_one_item_per_player_batch_size
|
|
|
|
# Round to the nearest integer.
|
|
batch_size = round(batch_size_float)
|
|
# Limit the minimum number of items per player in the batch.
|
|
batch_size = max(self._min_batch_size, batch_size)
|
|
# Don't make the batch larger than the largest remaining item pool.
|
|
batch_size = min(batch_size, largest_remaining)
|
|
|
|
# Make per-batch arguments.
|
|
|
|
# If a player has fewer items remaining than the size of the batch, then that player has some empty spaces for
|
|
# items in the batch. This allows for items belonging to that player, that were displaced by a swap, to be added
|
|
# to the current batch until the empty spaces for that player are used up.
|
|
batch_empty_spaces = {player: batch_size - min(len(player_items), batch_size)
|
|
for player, player_items in reachable_items.items()}
|
|
|
|
# Iterate the first `batch_size` items of each player's remaining items into a list of all items that will be
|
|
# placed in the current batch.
|
|
# Items to place are picked from the end of each player's item pool, so, to get the items in the order they will
|
|
# be placed, the item pools must be iterated in reverse.
|
|
item_iters = [reversed(items) for items in reachable_items.values() if items]
|
|
batch_item_pool = [item for item_iter in item_iters
|
|
for item in itertools.islice(item_iter, batch_size)]
|
|
|
|
# Collect the remaining items, which won't be placed in this batch, into a copy of `base_state` and make that
|
|
# the base state for the batch.
|
|
batch_base_state = self._base_state.copy()
|
|
for item_iter in item_iters:
|
|
for item in item_iter:
|
|
batch_base_state.collect(item, True)
|
|
|
|
if self._one_item_per_player:
|
|
batched_placements_remaining = batch_size
|
|
|
|
return _RestrictiveFillBatcher._RestrictiveFillBatchOneItemPerPlayer(
|
|
batch_base_state,
|
|
batch_empty_spaces,
|
|
batch_item_pool,
|
|
self._item_pool,
|
|
batched_placements_remaining,
|
|
reachable_items)
|
|
else:
|
|
items_per_player_in_batch = {player: batch_size - empty_spaces
|
|
for player, empty_spaces in batch_empty_spaces.items()}
|
|
batched_placements_remaining = sum(items_per_player_in_batch.values())
|
|
|
|
if isinstance(previous_batch, _RestrictiveFillBatcher._RestrictiveFillBatchOneItemAtATime):
|
|
# If the previous batch ended early by picking a player who had no items remaining in the batch, that
|
|
# player should be picked when getting the first item to place in the new batch. This maintains fairness
|
|
# at the boundary between one almost empty batch and the next batch.
|
|
next_player_override = previous_batch.next_player_override
|
|
else:
|
|
next_player_override = None
|
|
|
|
return _RestrictiveFillBatcher._RestrictiveFillBatchOneItemAtATime(
|
|
batch_base_state,
|
|
batch_empty_spaces,
|
|
batch_item_pool,
|
|
self._item_pool,
|
|
batched_placements_remaining,
|
|
reachable_items,
|
|
items_per_player_in_batch,
|
|
next_player_override)
|
|
|
|
def pop_items_to_place(self) -> list[Item]:
|
|
"""
|
|
Get and remove items to place from the ends of deques in `self.reachable_items`, also removing those items from
|
|
`self.item_pool`.
|
|
|
|
Automatically creates new batches internally as needed until all items have been placed.
|
|
|
|
:return: A list of items to place, or an empty list when there are no more items to place.
|
|
"""
|
|
current_batch = self._current_batch
|
|
if current_batch is None:
|
|
# No more items to place.
|
|
return []
|
|
# Batches are given references to `self.reachable_items` and `self.item_pool` when they are created, so do not
|
|
# need to be given these collections of items again.
|
|
popped_items = current_batch.pop_items_to_place()
|
|
if not popped_items:
|
|
# The current batch is exhausted, so create a new one and retry with the new batch.
|
|
self._current_batch = self._new_batch(current_batch)
|
|
# This recursive call is expected to only recurse at most once.
|
|
# In the recursive call, either there are no more items to place, so `self._current_batch` became `None` and
|
|
# `current_batch` will be `None`, or there are items to place, so `popped_items` will be non-empty. Either
|
|
# way will not result in additional recursion.
|
|
return self.pop_items_to_place()
|
|
else:
|
|
return popped_items
|
|
|
|
def get_maximum_exploration_state(self, explore_locations: list[Location] | None, unplaced_items: list[Item]
|
|
) -> CollectionState:
|
|
"""
|
|
Get the maximum exploration state for the currently reachable items.
|
|
|
|
Must not be called after self.pop_items_to_place() has returned an empty list, indicating that there are no more
|
|
items to place, or after self.finish_fill() has been called.
|
|
|
|
An item is reachable if it matches any one of the following:
|
|
A. It is in `self.item_pool`, so has not been placed yet and is not currently being placed.
|
|
B. It is in `unplaced_items`, so could not be placed at any location.
|
|
C. It is at a location in `explore_locations` that is reachable with all items in A-C.
|
|
|
|
:param explore_locations: The locations to explore for reachable items. Defaults to all filled locations when
|
|
`None`.
|
|
:param unplaced_items: All items that could not be placed at any location.
|
|
:return: A CollectionState that has collected all reachable advancement items.
|
|
"""
|
|
assert self._current_batch is not None, "Cannot call when there are no more items to place."
|
|
return self._current_batch.get_maximum_exploration_state(explore_locations, unplaced_items)
|
|
|
|
def get_swap_state(self, displaced_item: Item, location: Location, explore_locations: list[Location] | None,
|
|
unsafe: bool) -> CollectionState:
|
|
"""
|
|
Get the maximum exploration state for a potential swap.
|
|
|
|
Must not be called after self.pop_items_to_place() has returned an empty list, indicating that there are no more
|
|
items to place, or after self.finish_fill() has been called.
|
|
|
|
:param displaced_item: The item displaced from `location`.
|
|
:param location: The location of the swap attempt.
|
|
:param explore_locations: The locations the swap state should sweep. `None` will sweep all filled locations.
|
|
:param unsafe: Assume it will be possible to collect `displaced_item` before the item that is being placed, by
|
|
continuing to swap.
|
|
:return: A CollectionState that has collected all reachable items, additionally including `displaced_item` when
|
|
`unsafe` is `True`.
|
|
"""
|
|
assert self._current_batch is not None, "Cannot call when there are no more items to place."
|
|
return self._current_batch.get_swap_state(displaced_item, location, explore_locations, unsafe)
|
|
|
|
def update_for_swap(self, displaced_item: Item, item_to_place: Item, swap_location: Location) -> None:
|
|
"""
|
|
Update the current batch for the result of a successful swap.
|
|
|
|
Must not be called after self.pop_items_to_place() has returned an empty list, indicating that there are no more
|
|
items to place, or after self.finish_fill() has been called.
|
|
|
|
:param displaced_item: The item that was previously placed at swap_location and has been displaced by the swap.
|
|
:param item_to_place: The item that has been placed at swap_location.
|
|
:param swap_location: The location where the two items have been swapped.
|
|
"""
|
|
assert self._current_batch is not None, "Cannot call when there are no more items to place."
|
|
# Add the item back into the item_pool and the per-player pools in reachable_items.
|
|
self._item_pool.append(displaced_item)
|
|
self._reachable_items[displaced_item.player].appendleft(displaced_item)
|
|
# Update the batch for the swap.
|
|
return self._current_batch.update_for_swap(displaced_item, item_to_place, swap_location)
|
|
|
|
def finish_fill(self, unplaced_items: list[Item]) -> None:
|
|
"""
|
|
Add items that could not be placed back into the `item_pool` argument passed in __init__, and mark the batcher
|
|
as having finished filling.
|
|
|
|
:param unplaced_items: Items that could not be placed.
|
|
"""
|
|
# After this, assertions will fail if an attempt is made to call a function that uses the current batch.
|
|
self._current_batch = None
|
|
self._item_pool.extend(unplaced_items)
|
|
|
|
|
|
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: list[Location],
|
|
item_pool: list[Item], single_player_placement: bool = False, lock: bool = False,
|
|
swap: bool = True, on_place: Callable[[Location], None] | None = None,
|
|
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
|
name: str = "Unknown") -> None:
|
|
"""
|
|
:param multiworld: Multiworld to be filled.
|
|
:param base_state: State assumed before fill.
|
|
:param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled.
|
|
:param item_pool: Items to fill into the locations, gets mutated by removing items that get placed.
|
|
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
|
|
:param lock: locations are set to locked as they are filled
|
|
:param swap: if true, swaps of already place items are done in the event of a dead end
|
|
:param on_place: callback that is called when a placement happens
|
|
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
|
|
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
|
|
:param name: name of this fill step for progress logging purposes
|
|
"""
|
|
unplaced_items: list[Item] = []
|
|
placements: list[Location] = []
|
|
cleanup_required = False
|
|
swapped_items: Counter[tuple[int, str, bool]] = Counter()
|
|
reachable_items: dict[int, deque[Item]] = {}
|
|
for item in item_pool:
|
|
reachable_items.setdefault(item.player, deque()).append(item)
|
|
|
|
# for progress logging
|
|
total = min(len(item_pool), len(locations))
|
|
placed = 0
|
|
|
|
# Fill is performed in batches so that sweeping to produce a maximum exploration state can begin from the state at
|
|
# the start of each batch, rather than having to sweep from `base_state`.
|
|
# The batcher manages all the batches internally, creating new batches automatically when the current batch runs out
|
|
# of items to place.
|
|
batcher = _RestrictiveFillBatcher(base_state, reachable_items, item_pool, one_item_per_player)
|
|
# The batcher is responsible for modifying these from this point onwards.
|
|
del item_pool
|
|
del reachable_items
|
|
|
|
while locations:
|
|
# Pop items to place from the ends of deques in `reachable_items` and pop those same items from `item_pool`.
|
|
items_to_place = batcher.pop_items_to_place()
|
|
if not items_to_place:
|
|
# There are no more items to place.
|
|
break
|
|
|
|
explore_locations = multiworld.get_filled_locations(item.player) if single_player_placement else None
|
|
|
|
maximum_exploration_state = batcher.get_maximum_exploration_state(explore_locations, unplaced_items)
|
|
|
|
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
|
|
|
|
while items_to_place:
|
|
# if we have run out of locations to fill,break out of this loop
|
|
if not locations:
|
|
unplaced_items += items_to_place
|
|
break
|
|
item_to_place = items_to_place.pop(0)
|
|
|
|
spot_to_fill: Location | None = None
|
|
|
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
|
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
|
perform_access_check = (
|
|
(not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player))
|
|
if single_player_placement
|
|
else not has_beaten_game
|
|
)
|
|
else:
|
|
perform_access_check = True
|
|
|
|
for i, location in enumerate(locations):
|
|
if (not single_player_placement or location.player == item_to_place.player) \
|
|
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
|
|
# popping by index is faster than removing by content,
|
|
spot_to_fill = locations.pop(i)
|
|
# skipping a scan for the element
|
|
break
|
|
|
|
else:
|
|
# we filled all reachable spots.
|
|
if swap:
|
|
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
|
|
# swap state, instead of sweeping from `base_state` each time.
|
|
previous_safe_swap_state_cache: deque[CollectionState] = deque()
|
|
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
|
|
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
|
|
max_swap_base_state_cache_length = 3
|
|
|
|
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
|
swap_attempts = ((i, location, unsafe)
|
|
for unsafe in (False, True)
|
|
for i, location in enumerate(placements))
|
|
for (i, location, unsafe) in swap_attempts:
|
|
placed_item = location.item
|
|
if item_to_place == placed_item:
|
|
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
|
|
# itself.
|
|
continue
|
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
|
# number of times we will swap an individual item to prevent this
|
|
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
|
if swap_count > 1:
|
|
continue
|
|
|
|
location.item = None
|
|
placed_item.location = None
|
|
|
|
for previous_safe_swap_state in previous_safe_swap_state_cache:
|
|
# If a state has already checked the location of the swap, then it cannot be used.
|
|
if location not in previous_safe_swap_state.advancements:
|
|
# Previous swap states will have collected all items in `item_pool`, so the new
|
|
# `swap_state` can skip having to collect them again.
|
|
# Previous swap states will also have already checked many locations, making the sweep
|
|
# faster.
|
|
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
|
|
multiworld.get_filled_locations(item.player)
|
|
if single_player_placement else None)
|
|
break
|
|
else:
|
|
# No previous swap_state was usable as a base state to sweep from, so create a new one.
|
|
explore_locations = (multiworld.get_filled_locations(item.player)
|
|
if single_player_placement else None)
|
|
swap_state = batcher.get_swap_state(placed_item, location, explore_locations, unsafe)
|
|
# Unsafe states should not be added to the cache because they have collected `placed_item`.
|
|
if not unsafe:
|
|
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
|
|
# Remove the oldest cached state.
|
|
previous_safe_swap_state_cache.pop()
|
|
# Add the new state to the start of the cache.
|
|
previous_safe_swap_state_cache.appendleft(swap_state)
|
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
|
# to clean that up later, so there is a chance generation fails.
|
|
if (not single_player_placement or location.player == item_to_place.player) \
|
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
|
# Add this item to the existing placement, and
|
|
# add the old item to the back of the queue
|
|
spot_to_fill = placements.pop(i)
|
|
|
|
swap_count += 1
|
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
|
|
|
# cleanup at the end to hopefully get better errors
|
|
cleanup_required = True
|
|
|
|
batcher.update_for_swap(placed_item, item_to_place, spot_to_fill)
|
|
break
|
|
|
|
# Item can't be placed here, restore original item
|
|
location.item = placed_item
|
|
placed_item.location = location
|
|
|
|
if spot_to_fill is None:
|
|
# Can't place this item, move on to the next
|
|
unplaced_items.append(item_to_place)
|
|
continue
|
|
else:
|
|
unplaced_items.append(item_to_place)
|
|
continue
|
|
multiworld.push_item(spot_to_fill, item_to_place, False)
|
|
spot_to_fill.locked = lock
|
|
placements.append(spot_to_fill)
|
|
placed += 1
|
|
if not placed % 1000:
|
|
_log_fill_progress(name, placed, total)
|
|
if on_place:
|
|
on_place(spot_to_fill)
|
|
|
|
if total > 1000:
|
|
_log_fill_progress(name, placed, total)
|
|
|
|
if cleanup_required:
|
|
# validate all placements and remove invalid ones
|
|
state = sweep_from_pool(
|
|
base_state, [], multiworld.get_filled_locations(item.player)
|
|
if single_player_placement else None)
|
|
for placement in placements:
|
|
if (
|
|
multiworld.worlds[placement.item.player].options.accessibility != "minimal" and
|
|
not placement.can_reach(state)
|
|
):
|
|
placement.item.location = None
|
|
unplaced_items.append(placement.item)
|
|
placement.item = None
|
|
locations.append(placement)
|
|
|
|
if allow_excluded:
|
|
# check if partial fill is the result of excluded locations, in which case retry
|
|
excluded_locations = [
|
|
location for location in locations
|
|
if location.progress_type == location.progress_type.EXCLUDED and not location.item
|
|
]
|
|
if excluded_locations:
|
|
for location in excluded_locations:
|
|
location.progress_type = location.progress_type.DEFAULT
|
|
fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
|
|
swap, on_place, allow_partial, False)
|
|
for location in excluded_locations:
|
|
if not location.item:
|
|
location.progress_type = location.progress_type.EXCLUDED
|
|
|
|
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
|
# There are leftover unplaceable items and locations that won't accept them
|
|
if multiworld.can_beat_game():
|
|
logging.warning(
|
|
f"Not all items placed. Game beatable anyway.\nCould not place:\n"
|
|
f"{', '.join(str(item) for item in unplaced_items)}")
|
|
else:
|
|
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
|
|
f"Unplaced items:\n"
|
|
f"{', '.join(str(item) for item in unplaced_items)}\n"
|
|
f"Unfilled locations:\n"
|
|
f"{', '.join(str(location) for location in locations)}\n"
|
|
f"Already placed {len(placements)}:\n"
|
|
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
|
|
|
batcher.finish_fill(unplaced_items)
|
|
|
|
|
|
def remaining_fill(multiworld: MultiWorld,
|
|
locations: list[Location],
|
|
itempool: list[Item],
|
|
name: str = "Remaining",
|
|
move_unplaceable_to_start_inventory: bool = False,
|
|
check_location_can_fill: bool = False) -> None:
|
|
unplaced_items: list[Item] = []
|
|
placements: list[Location] = []
|
|
swapped_items: Counter[tuple[int, str]] = Counter()
|
|
total = min(len(itempool), len(locations))
|
|
placed = 0
|
|
|
|
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
|
if check_location_can_fill:
|
|
state = CollectionState(multiworld)
|
|
|
|
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
|
|
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
|
|
else:
|
|
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
|
|
return location_to_fill.item_rule(item_to_fill)
|
|
|
|
while locations and itempool:
|
|
item_to_place = itempool.pop()
|
|
spot_to_fill: Location | None = None
|
|
|
|
# going through locations in the same order as the provided `locations` argument
|
|
for i, location in enumerate(locations):
|
|
if location_can_fill_item(location, item_to_place):
|
|
# popping by index is faster than removing by content,
|
|
spot_to_fill = locations.pop(i)
|
|
# skipping a scan for the element
|
|
break
|
|
|
|
else:
|
|
# we filled all reachable spots.
|
|
# try swapping this item with previously placed items
|
|
|
|
for (i, location) in enumerate(placements):
|
|
placed_item = location.item
|
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
|
# number of times we will swap an individual item to prevent this
|
|
|
|
if swapped_items[placed_item.player,
|
|
placed_item.name] > 1:
|
|
continue
|
|
|
|
location.item = None
|
|
placed_item.location = None
|
|
if location_can_fill_item(location, item_to_place):
|
|
# Add this item to the existing placement, and
|
|
# add the old item to the back of the queue
|
|
spot_to_fill = placements.pop(i)
|
|
|
|
swapped_items[placed_item.player,
|
|
placed_item.name] += 1
|
|
|
|
itempool.append(placed_item)
|
|
|
|
break
|
|
|
|
# Item can't be placed here, restore original item
|
|
location.item = placed_item
|
|
placed_item.location = location
|
|
|
|
if spot_to_fill is None:
|
|
# Can't place this item, move on to the next
|
|
unplaced_items.append(item_to_place)
|
|
continue
|
|
|
|
multiworld.push_item(spot_to_fill, item_to_place, False)
|
|
placements.append(spot_to_fill)
|
|
placed += 1
|
|
if not placed % 1000:
|
|
_log_fill_progress(name, placed, total)
|
|
|
|
if total > 1000:
|
|
_log_fill_progress(name, placed, total)
|
|
|
|
if unplaced_items and locations:
|
|
# There are leftover unplaceable items and locations that won't accept them
|
|
if move_unplaceable_to_start_inventory:
|
|
last_batch: list[Item] = []
|
|
for item in unplaced_items:
|
|
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
|
multiworld.push_precollected(item)
|
|
last_batch.append(multiworld.worlds[item.player].create_filler())
|
|
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
|
|
else:
|
|
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
|
|
f"Unplaced items:\n"
|
|
f"{', '.join(str(item) for item in unplaced_items)}\n"
|
|
f"Unfilled locations:\n"
|
|
f"{', '.join(str(location) for location in locations)}\n"
|
|
f"Already placed {len(placements)}:\n"
|
|
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
|
|
|
itempool.extend(unplaced_items)
|
|
|
|
|
|
def fast_fill(multiworld: MultiWorld,
|
|
item_pool: list[Item],
|
|
fill_locations: list[Location]) -> tuple[list[Item], list[Location]]:
|
|
placing = min(len(item_pool), len(fill_locations))
|
|
for item, location in zip(item_pool, fill_locations):
|
|
multiworld.push_item(location, item, False)
|
|
return item_pool[placing:], fill_locations[placing:]
|
|
|
|
|
|
def accessibility_corrections(multiworld: MultiWorld,
|
|
state: CollectionState,
|
|
locations: list[Location],
|
|
pool: list[Item] | None = None) -> None:
|
|
if pool is None:
|
|
pool = []
|
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
|
minimal_players = {player
|
|
for player in multiworld.player_ids
|
|
if multiworld.worlds[player].options.accessibility == "minimal"}
|
|
unreachable_locations = [
|
|
location
|
|
for location in multiworld.get_locations()
|
|
if location.player in minimal_players and not location.can_reach(maximum_exploration_state)
|
|
]
|
|
for location in unreachable_locations:
|
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
|
location.locked and location.item.player not in minimal_players):
|
|
pool.append(location.item)
|
|
location.item = None
|
|
if location in state.advancements:
|
|
state.advancements.remove(location)
|
|
state.remove(location.item)
|
|
locations.append(location)
|
|
if pool and locations:
|
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
|
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
|
|
|
|
|
|
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations: Iterable[Location]) -> None:
|
|
maximum_exploration_state = sweep_from_pool(state)
|
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
|
if unreachable_locations:
|
|
def forbid_important_item_rule(item: Item) -> bool:
|
|
return not ((item.classification & 0b0011) and
|
|
multiworld.worlds[item.player].options.accessibility != "minimal")
|
|
|
|
for location in unreachable_locations:
|
|
add_item_rule(location, forbid_important_item_rule)
|
|
|
|
|
|
def distribute_early_items(multiworld: MultiWorld,
|
|
fill_locations: list[Location],
|
|
itempool: list[Item]) -> tuple[list[Location], list[Item]]:
|
|
""" returns new fill_locations and itempool """
|
|
early_items_count: dict[tuple[str, int], list[int]] = {}
|
|
for player in multiworld.player_ids:
|
|
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
|
|
for item in items:
|
|
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
|
|
multiworld.local_early_items[player].get(item, 0)]
|
|
if early_items_count:
|
|
early_locations: list[Location] = []
|
|
early_priority_locations: list[Location] = []
|
|
loc_indexes_to_remove: set[int] = set()
|
|
base_state = multiworld.state.copy()
|
|
base_state.sweep_for_advancements(locations=(loc
|
|
for loc in multiworld.get_filled_locations()
|
|
if loc.address is None))
|
|
for i, loc in enumerate(fill_locations):
|
|
if loc.can_reach(base_state):
|
|
if loc.progress_type == LocationProgressType.PRIORITY:
|
|
early_priority_locations.append(loc)
|
|
else:
|
|
early_locations.append(loc)
|
|
loc_indexes_to_remove.add(i)
|
|
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
|
|
|
|
early_prog_items: list[Item] = []
|
|
early_rest_items: list[Item] = []
|
|
early_local_prog_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
|
|
early_local_rest_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
|
|
item_indexes_to_remove: set[int] = set()
|
|
for i, item in enumerate(itempool):
|
|
if (item.name, item.player) in early_items_count:
|
|
if item.advancement:
|
|
if early_items_count[item.name, item.player][1]:
|
|
early_local_prog_items[item.player].append(item)
|
|
early_items_count[item.name, item.player][1] -= 1
|
|
else:
|
|
early_prog_items.append(item)
|
|
early_items_count[item.name, item.player][0] -= 1
|
|
else:
|
|
if early_items_count[item.name, item.player][1]:
|
|
early_local_rest_items[item.player].append(item)
|
|
early_items_count[item.name, item.player][1] -= 1
|
|
else:
|
|
early_rest_items.append(item)
|
|
early_items_count[item.name, item.player][0] -= 1
|
|
item_indexes_to_remove.add(i)
|
|
if early_items_count[item.name, item.player] == [0, 0]:
|
|
del early_items_count[item.name, item.player]
|
|
if len(early_items_count) == 0:
|
|
break
|
|
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
|
for player in multiworld.player_ids:
|
|
player_local = early_local_rest_items[player]
|
|
fill_restrictive(multiworld, base_state,
|
|
[loc for loc in early_locations if loc.player == player],
|
|
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
|
|
if player_local:
|
|
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
|
early_rest_items.extend(early_local_rest_items[player])
|
|
early_locations = [loc for loc in early_locations if not loc.item]
|
|
fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
|
|
name="Early Items")
|
|
early_locations += early_priority_locations
|
|
for player in multiworld.player_ids:
|
|
player_local = early_local_prog_items[player]
|
|
fill_restrictive(multiworld, base_state,
|
|
[loc for loc in early_locations if loc.player == player],
|
|
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
|
|
if player_local:
|
|
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
|
early_prog_items.extend(player_local)
|
|
early_locations = [loc for loc in early_locations if not loc.item]
|
|
fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
|
|
name="Early Progression")
|
|
unplaced_early_items = early_rest_items + early_prog_items
|
|
if unplaced_early_items:
|
|
logging.warning("Ran out of early locations for early items. Failed to place "
|
|
f"{unplaced_early_items} early.")
|
|
itempool += unplaced_early_items
|
|
|
|
fill_locations.extend(early_locations)
|
|
multiworld.random.shuffle(fill_locations)
|
|
return fill_locations, itempool
|
|
|
|
|
|
def distribute_items_restrictive(multiworld: MultiWorld,
|
|
panic_method: Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
|
assert all(item.location is None for item in multiworld.itempool), (
|
|
"At the start of distribute_items_restrictive, "
|
|
"there are items in the multiworld itempool that are already placed on locations:\n"
|
|
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
|
|
)
|
|
|
|
fill_locations = sorted(multiworld.get_unfilled_locations())
|
|
multiworld.random.shuffle(fill_locations)
|
|
# get items to distribute
|
|
itempool = sorted(multiworld.itempool)
|
|
multiworld.random.shuffle(itempool)
|
|
|
|
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
|
|
|
|
progitempool: list[Item] = []
|
|
usefulitempool: list[Item] = []
|
|
filleritempool: list[Item] = []
|
|
|
|
for item in itempool:
|
|
if item.advancement:
|
|
progitempool.append(item)
|
|
elif item.useful:
|
|
usefulitempool.append(item)
|
|
else:
|
|
filleritempool.append(item)
|
|
|
|
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
|
|
|
locations: dict[LocationProgressType, list[Location]] = {
|
|
loc_type: [] for loc_type in LocationProgressType}
|
|
|
|
for loc in fill_locations:
|
|
locations[loc.progress_type].append(loc)
|
|
|
|
prioritylocations = locations[LocationProgressType.PRIORITY]
|
|
defaultlocations = locations[LocationProgressType.DEFAULT]
|
|
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
|
|
|
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
|
|
lock_later: list[Location] = []
|
|
|
|
def mark_for_locking(location: Location) -> None:
|
|
nonlocal lock_later
|
|
lock_later.append(location)
|
|
|
|
single_player = multiworld.players == 1 and not multiworld.groups
|
|
|
|
if prioritylocations:
|
|
regular_progression: list[Item] = []
|
|
deprioritized_progression: list[Item] = []
|
|
for item in progitempool:
|
|
if item.deprioritized:
|
|
deprioritized_progression.append(item)
|
|
else:
|
|
regular_progression.append(item)
|
|
|
|
# "priority fill"
|
|
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
|
|
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
|
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
|
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
|
|
|
if prioritylocations and regular_progression:
|
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
|
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
|
# allow_partial should only be set if there is deprioritized progression to fall back on.
|
|
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
|
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
name="Priority Retry", one_item_per_player=False,
|
|
allow_partial=bool(deprioritized_progression))
|
|
|
|
if prioritylocations and deprioritized_progression:
|
|
# There are no more regular progression items that can be placed on any priority locations.
|
|
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
|
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
|
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
|
|
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
|
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
|
|
|
|
if prioritylocations and deprioritized_progression:
|
|
# retry with deprioritized items AND without one_item_per_player optimisation
|
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
|
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
|
|
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
|
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
name="Priority Retry 3", one_item_per_player=False)
|
|
|
|
# restore original order of progitempool
|
|
progitempool[:] = [item for item in progitempool if not item.location]
|
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
|
defaultlocations = prioritylocations + defaultlocations
|
|
|
|
if progitempool:
|
|
# "advancement/progression fill"
|
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
|
if panic_method == "swap":
|
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
|
name="Progression", single_player_placement=single_player)
|
|
elif panic_method == "raise":
|
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
|
name="Progression", single_player_placement=single_player)
|
|
elif panic_method == "start_inventory":
|
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
|
if progitempool:
|
|
for item in progitempool:
|
|
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
|
multiworld.push_precollected(item)
|
|
filleritempool.append(multiworld.worlds[item.player].create_filler())
|
|
logging.warning(f"{len(progitempool)} items moved to start inventory,"
|
|
f" due to failure in Progression fill step.")
|
|
progitempool[:] = []
|
|
|
|
else:
|
|
raise ValueError(f"Generator Panic Method {panic_method} not recognized.")
|
|
if progitempool:
|
|
raise FillError(
|
|
f"Not enough locations for progression items. "
|
|
f"There are {len(progitempool)} more progression items than there are available locations.\n"
|
|
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
|
|
multiworld=multiworld,
|
|
)
|
|
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
|
|
|
for location in lock_later:
|
|
if location.item:
|
|
location.locked = True
|
|
del mark_for_locking, lock_later
|
|
|
|
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
|
|
|
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
|
|
move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
|
|
|
|
if excludedlocations:
|
|
raise FillError(
|
|
f"Not enough filler items for excluded locations. "
|
|
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
|
|
multiworld=multiworld,
|
|
)
|
|
|
|
restitempool = filleritempool + usefulitempool
|
|
|
|
remaining_fill(multiworld, defaultlocations, restitempool,
|
|
move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
|
|
|
|
unplaced = restitempool
|
|
unfilled = defaultlocations
|
|
|
|
if unplaced or unfilled:
|
|
logging.warning(
|
|
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
|
|
items_counter = Counter(location.item.player for location in multiworld.get_filled_locations())
|
|
locations_counter = Counter(location.player for location in multiworld.get_locations())
|
|
items_counter.update(item.player for item in unplaced)
|
|
print_data = {"items": items_counter, "locations": locations_counter}
|
|
logging.info(f"Per-Player counts: {print_data})")
|
|
|
|
more_locations = locations_counter - items_counter
|
|
more_items = items_counter - locations_counter
|
|
for player in multiworld.player_ids:
|
|
if more_locations[player]:
|
|
logging.error(
|
|
f"Player {multiworld.get_player_name(player)} had "
|
|
f"{more_locations[player]} more locations than items.")
|
|
elif more_items[player]:
|
|
logging.warning(
|
|
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
|
if unfilled:
|
|
raise FillError(
|
|
"Unable to fill all locations.\n"
|
|
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
|
)
|
|
else:
|
|
logging.warning(
|
|
"Unable to place all items.\n"
|
|
f"Unplaced items({len(unplaced)}): {unplaced}"
|
|
)
|
|
|
|
|
|
def flood_items(multiworld: MultiWorld) -> None:
|
|
# get items to distribute
|
|
multiworld.random.shuffle(multiworld.itempool)
|
|
itempool = multiworld.itempool
|
|
progress_done = False
|
|
|
|
# sweep once to pick up preplaced items
|
|
multiworld.state.sweep_for_advancements()
|
|
|
|
# fill multiworld from top of itempool while we can
|
|
while not progress_done:
|
|
location_list = multiworld.get_unfilled_locations()
|
|
multiworld.random.shuffle(location_list)
|
|
spot_to_fill = None
|
|
for location in location_list:
|
|
if location.can_fill(multiworld.state, itempool[0]):
|
|
spot_to_fill = location
|
|
break
|
|
|
|
if spot_to_fill:
|
|
item = itempool.pop(0)
|
|
multiworld.push_item(spot_to_fill, item, True)
|
|
continue
|
|
|
|
# ran out of spots, check if we need to step in and correct things
|
|
if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
|
|
progress_done = True
|
|
continue
|
|
|
|
# need to place a progress item instead of an already placed item, find candidate
|
|
item_to_place = None
|
|
candidate_item_to_place = None
|
|
for item in itempool:
|
|
if item.advancement:
|
|
candidate_item_to_place = item
|
|
if multiworld.unlocks_new_location(item):
|
|
item_to_place = item
|
|
break
|
|
|
|
# we might be in a situation where all new locations require multiple items to reach.
|
|
# If that is the case, just place any advancement item we've found and continue trying
|
|
if item_to_place is None:
|
|
if candidate_item_to_place is not None:
|
|
item_to_place = candidate_item_to_place
|
|
else:
|
|
raise FillError("No more progress items left to place.", multiworld=multiworld)
|
|
|
|
# find item to replace with progress item
|
|
location_list = multiworld.get_reachable_locations()
|
|
multiworld.random.shuffle(location_list)
|
|
for location in location_list:
|
|
if location.item is not None and not location.item.advancement:
|
|
# safe to replace
|
|
replace_item = location.item
|
|
replace_item.location = None
|
|
itempool.append(replace_item)
|
|
multiworld.push_item(location, item_to_place, True)
|
|
itempool.remove(item_to_place)
|
|
break
|
|
|
|
|
|
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
|
# Overall progression balancing algorithm:
|
|
# Gather up all locations in a sphere.
|
|
# Define a threshold value based on the player with the most available locations.
|
|
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
|
# which gives more locations available by this sphere.
|
|
balanceable_players: dict[int, float] = {
|
|
player: multiworld.worlds[player].options.progression_balancing / 100
|
|
for player in multiworld.player_ids
|
|
if multiworld.worlds[player].options.progression_balancing > 0
|
|
}
|
|
if not balanceable_players:
|
|
logging.info("Skipping multiworld progression balancing.")
|
|
else:
|
|
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
|
logging.debug(balanceable_players)
|
|
state: CollectionState = CollectionState(multiworld)
|
|
checked_locations: set[Location] = set()
|
|
unchecked_locations: set[Location] = set(multiworld.get_locations())
|
|
|
|
total_locations_count: Counter[int] = Counter(
|
|
location.player
|
|
for location in multiworld.get_locations()
|
|
if not location.locked
|
|
)
|
|
reachable_locations_count: dict[int, int] = {
|
|
player: 0
|
|
for player in multiworld.player_ids
|
|
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
|
|
}
|
|
balanceable_players = {
|
|
player: balanceable_players[player]
|
|
for player in balanceable_players
|
|
if total_locations_count[player]
|
|
}
|
|
sphere_num: int = 1
|
|
moved_item_count: int = 0
|
|
|
|
def get_sphere_locations(sphere_state: CollectionState,
|
|
locations: set[Location]) -> set[Location]:
|
|
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
|
|
|
def item_percentage(player: int, num: int) -> float:
|
|
return num / total_locations_count[player]
|
|
|
|
# If there are no locations that aren't locked, there's no point in attempting to balance progression.
|
|
if len(total_locations_count) == 0:
|
|
return
|
|
|
|
while True:
|
|
# Gather non-locked locations.
|
|
# This ensures that only shuffled locations get counted for progression balancing,
|
|
# i.e. the items the players will be checking.
|
|
sphere_locations = get_sphere_locations(state, unchecked_locations)
|
|
for location in sphere_locations:
|
|
unchecked_locations.remove(location)
|
|
if not location.locked:
|
|
reachable_locations_count[location.player] += 1
|
|
|
|
logging.debug(f"Sphere {sphere_num}")
|
|
logging.debug(f"Reachable locations: {reachable_locations_count}")
|
|
debug_percentages = {
|
|
player: round(item_percentage(player, num), 2)
|
|
for player, num in reachable_locations_count.items()
|
|
}
|
|
logging.debug(f"Reachable percentages: {debug_percentages}\n")
|
|
sphere_num += 1
|
|
|
|
if checked_locations:
|
|
max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
|
reachable_locations_count))
|
|
threshold_percentages = {
|
|
player: max_percentage * balanceable_players[player]
|
|
for player in balanceable_players
|
|
}
|
|
logging.debug(f"Thresholds: {threshold_percentages}")
|
|
balancing_players = {
|
|
player
|
|
for player, reachables in reachable_locations_count.items()
|
|
if (player in threshold_percentages
|
|
and item_percentage(player, reachables) < threshold_percentages[player])
|
|
}
|
|
if balancing_players:
|
|
balancing_state = state.copy()
|
|
balancing_unchecked_locations = unchecked_locations.copy()
|
|
balancing_reachables = reachable_locations_count.copy()
|
|
balancing_sphere = sphere_locations.copy()
|
|
candidate_items: dict[int, set[Location]] = defaultdict(set)
|
|
while True:
|
|
# Check locations in the current sphere and gather progression items to swap earlier
|
|
for location in balancing_sphere:
|
|
if location.advancement:
|
|
balancing_state.collect(location.item, True, location)
|
|
player = location.item.player
|
|
# only replace items that end up in another player's world
|
|
if (not location.locked and not location.item.skip_in_prog_balancing and
|
|
player in balancing_players and
|
|
location.player != player and
|
|
location.progress_type != LocationProgressType.PRIORITY):
|
|
candidate_items[player].add(location)
|
|
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
|
|
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
|
|
for location in balancing_sphere:
|
|
balancing_unchecked_locations.remove(location)
|
|
if not location.locked:
|
|
balancing_reachables[location.player] += 1
|
|
if multiworld.has_beaten_game(balancing_state) or all(
|
|
item_percentage(player, reachables) >= threshold_percentages[player]
|
|
for player, reachables in balancing_reachables.items()
|
|
if player in threshold_percentages):
|
|
break
|
|
elif not balancing_sphere:
|
|
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
|
# Gather a set of locations which we can swap items into
|
|
unlocked_locations: dict[int, set[Location]] = defaultdict(set)
|
|
for l in unchecked_locations:
|
|
if l not in balancing_unchecked_locations:
|
|
unlocked_locations[l.player].add(l)
|
|
items_to_replace: list[Location] = []
|
|
for player in balancing_players:
|
|
locations_to_test = unlocked_locations[player]
|
|
items_to_test = list(candidate_items[player])
|
|
items_to_test.sort()
|
|
multiworld.random.shuffle(items_to_test)
|
|
while items_to_test:
|
|
testing = items_to_test.pop()
|
|
reducing_state = state.copy()
|
|
for location in itertools.chain((
|
|
l for l in items_to_replace
|
|
if l.item.player == player
|
|
), items_to_test):
|
|
reducing_state.collect(location.item, True, location)
|
|
|
|
reducing_state.sweep_for_advancements(locations=locations_to_test)
|
|
|
|
if multiworld.has_beaten_game(balancing_state):
|
|
if not multiworld.has_beaten_game(reducing_state):
|
|
items_to_replace.append(testing)
|
|
else:
|
|
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
|
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
|
|
if p < threshold_percentages[player]:
|
|
items_to_replace.append(testing)
|
|
|
|
old_moved_item_count = moved_item_count
|
|
|
|
# sort then shuffle to maintain deterministic behaviour,
|
|
# while allowing use of set for better algorithm growth behaviour elsewhere
|
|
replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked)
|
|
multiworld.random.shuffle(replacement_locations)
|
|
items_to_replace.sort()
|
|
multiworld.random.shuffle(items_to_replace)
|
|
|
|
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
|
|
while replacement_locations and items_to_replace:
|
|
old_location = items_to_replace.pop()
|
|
for i, new_location in enumerate(replacement_locations):
|
|
if new_location.can_fill(state, old_location.item, False) and \
|
|
old_location.can_fill(state, new_location.item, False):
|
|
replacement_locations.pop(i)
|
|
swap_location_item(old_location, new_location)
|
|
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
|
f"displacing {old_location.item} into {old_location}")
|
|
moved_item_count += 1
|
|
state.collect(new_location.item, True, new_location)
|
|
break
|
|
else:
|
|
logging.warning(f"Could not Progression Balance {old_location.item}")
|
|
|
|
if old_moved_item_count < moved_item_count:
|
|
logging.debug(f"Moved {moved_item_count} items so far\n")
|
|
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
|
for location in get_sphere_locations(state, unlocked):
|
|
unchecked_locations.remove(location)
|
|
if not location.locked:
|
|
reachable_locations_count[location.player] += 1
|
|
sphere_locations.add(location)
|
|
|
|
for location in sphere_locations:
|
|
if location.advancement:
|
|
state.collect(location.item, True, location)
|
|
checked_locations |= sphere_locations
|
|
|
|
if multiworld.has_beaten_game(state):
|
|
break
|
|
elif not sphere_locations:
|
|
logging.warning("Progression Balancing ran out of paths.")
|
|
break
|
|
|
|
|
|
def swap_location_item(location_1: Location, location_2: Location, check_locked: bool = True) -> None:
|
|
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
|
|
if check_locked:
|
|
if location_1.locked:
|
|
logging.warning(f"Swapping {location_1}, which is marked as locked.")
|
|
if location_2.locked:
|
|
logging.warning(f"Swapping {location_2}, which is marked as locked.")
|
|
location_2.item, location_1.item = location_1.item, location_2.item
|
|
location_1.item.location = location_1
|
|
location_2.item.location = location_2
|
|
|
|
|
|
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
|
def warn(warning: str, force: bool | str) -> None:
|
|
if isinstance(force, bool):
|
|
logging.warning(f"{warning}")
|
|
else:
|
|
logging.debug(f"{warning}")
|
|
|
|
def failed(warning: str, force: bool | str) -> None:
|
|
if force is True:
|
|
raise Exception(warning)
|
|
else:
|
|
warn(warning, force)
|
|
|
|
world_name_lookup = multiworld.world_name_lookup
|
|
|
|
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
|
player_ids: set[int] = set(multiworld.player_ids)
|
|
for player in player_ids:
|
|
plando_blocks[player] = []
|
|
for block in multiworld.worlds[player].options.plando_items:
|
|
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
|
target_world = block.world
|
|
# TODO: This doesn't handle the plando API correctly
|
|
# It says `world` can be other containers other than list,
|
|
# but this is checking specifically for list.
|
|
# (But it's not simple to just change the check to Iterable,
|
|
# because that will catch the str case too early.)
|
|
# (And it will be broken without failing unit tests. So also TODO: unit test this.)
|
|
if target_world is False or multiworld.players == 1: # target own world
|
|
worlds: set[int] = {player}
|
|
elif target_world is True: # target any worlds besides own
|
|
worlds = set(multiworld.player_ids) - {player}
|
|
elif target_world is None: # target all worlds
|
|
worlds = set(multiworld.player_ids)
|
|
elif type(target_world) == list: # list of target worlds
|
|
worlds = set()
|
|
for listed_world in target_world:
|
|
if listed_world not in world_name_lookup:
|
|
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
|
|
block.force)
|
|
continue
|
|
worlds.add(world_name_lookup[listed_world])
|
|
elif type(target_world) == int: # target world by slot number
|
|
if target_world not in range(1, multiworld.players + 1):
|
|
failed(
|
|
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
|
block.force)
|
|
continue
|
|
worlds = {target_world}
|
|
else: # target world by slot name
|
|
if target_world not in world_name_lookup:
|
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
block.force)
|
|
continue
|
|
worlds = {world_name_lookup[target_world]}
|
|
new_block.worlds = worlds
|
|
|
|
items = block.items
|
|
if isinstance(items, dict):
|
|
item_list: list[str] = []
|
|
for key, value in items.items():
|
|
if value is True:
|
|
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
|
item_list += [key] * value
|
|
items = item_list
|
|
new_block.items = items
|
|
|
|
locations: list[str] = block.locations
|
|
if isinstance(locations, str):
|
|
locations = [locations]
|
|
|
|
resolved_locations: list[Location] = []
|
|
for target_player in worlds:
|
|
locations_from_groups: list[str] = []
|
|
world_locations = multiworld.get_unfilled_locations(target_player)
|
|
for group in multiworld.worlds[target_player].location_name_groups:
|
|
if group in locations:
|
|
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
|
resolved_locations.extend(location for location in world_locations
|
|
if location.name in [*locations, *locations_from_groups])
|
|
new_block.locations = sorted(dict.fromkeys(locations))
|
|
new_block.resolved_locations = sorted(set(resolved_locations))
|
|
|
|
count = block.count
|
|
if not count:
|
|
count = (min(len(new_block.items), len(new_block.resolved_locations))
|
|
if new_block.resolved_locations else len(new_block.items))
|
|
if isinstance(count, int):
|
|
count = {"min": count, "max": count}
|
|
if "min" not in count:
|
|
count["min"] = 0
|
|
if "max" not in count:
|
|
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
|
|
if new_block.resolved_locations else len(new_block.items))
|
|
|
|
|
|
new_block.count = count
|
|
plando_blocks[player].append(new_block)
|
|
|
|
return plando_blocks
|
|
|
|
|
|
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
|
def warn(warning: str, force: bool | str) -> None:
|
|
if isinstance(force, bool):
|
|
logging.warning(f"{warning}")
|
|
else:
|
|
logging.debug(f"{warning}")
|
|
|
|
def failed(warning: str, force: bool | str) -> None:
|
|
if force is True:
|
|
raise Exception(warning)
|
|
else:
|
|
warn(warning, force)
|
|
|
|
swept_state = multiworld.state.copy()
|
|
swept_state.sweep_for_advancements()
|
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
|
early_locations: dict[int, list[Location]] = defaultdict(list)
|
|
non_early_locations: dict[int, list[Location]] = defaultdict(list)
|
|
for loc in multiworld.get_unfilled_locations():
|
|
if loc in reachable:
|
|
early_locations[loc.player].append(loc)
|
|
else: # not reachable with swept state
|
|
non_early_locations[loc.player].append(loc)
|
|
|
|
for player in multiworld.plando_item_blocks:
|
|
removed: list[PlandoItemBlock] = []
|
|
for block in multiworld.plando_item_blocks[player]:
|
|
locations = block.locations
|
|
resolved_locations = block.resolved_locations
|
|
worlds = block.worlds
|
|
if "early_locations" in locations:
|
|
for target_player in worlds:
|
|
resolved_locations += early_locations[target_player]
|
|
if "non_early_locations" in locations:
|
|
for target_player in worlds:
|
|
resolved_locations += non_early_locations[target_player]
|
|
|
|
if block.count["max"] > len(block.items):
|
|
count = block.count["max"]
|
|
failed(f"Plando count {count} greater than items specified", block.force)
|
|
block.count["max"] = len(block.items)
|
|
if block.count["min"] > len(block.items):
|
|
block.count["min"] = len(block.items)
|
|
if block.count["max"] > len(block.resolved_locations) > 0:
|
|
count = block.count["max"]
|
|
failed(f"Plando count {count} greater than locations specified", block.force)
|
|
block.count["max"] = len(block.resolved_locations)
|
|
if block.count["min"] > len(block.resolved_locations):
|
|
block.count["min"] = len(block.resolved_locations)
|
|
block.count["target"] = multiworld.random.randint(block.count["min"], block.count["max"])
|
|
|
|
if not block.count["target"]:
|
|
removed.append(block)
|
|
|
|
for block in removed:
|
|
multiworld.plando_item_blocks[player].remove(block)
|
|
|
|
|
|
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
|
def warn(warning: str, force: bool | str) -> None:
|
|
if isinstance(force, bool):
|
|
logging.warning(f"{warning}")
|
|
else:
|
|
logging.debug(f"{warning}")
|
|
|
|
def failed(warning: str, force: bool | str) -> None:
|
|
if force is True:
|
|
raise Exception(warning)
|
|
else:
|
|
warn(warning, force)
|
|
|
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
|
# so less-flexible blocks get priority
|
|
multiworld.random.shuffle(plando_blocks)
|
|
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
|
if len(block.resolved_locations) > 0
|
|
else len(multiworld.get_unfilled_locations(block.player)) -
|
|
block.count["target"]))
|
|
for placement in plando_blocks:
|
|
player = placement.player
|
|
try:
|
|
worlds = placement.worlds
|
|
locations = placement.resolved_locations
|
|
items = placement.items
|
|
maxcount = placement.count["target"]
|
|
from_pool = placement.from_pool
|
|
|
|
item_candidates: list[Item] = []
|
|
if from_pool:
|
|
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
|
for item in multiworld.random.sample(items, maxcount):
|
|
candidate = next((i for i in instances if i.name == item), None)
|
|
if candidate is None:
|
|
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
|
f"it's already missing from it", placement.force)
|
|
candidate = multiworld.worlds[player].create_item(item)
|
|
else:
|
|
multiworld.itempool.remove(candidate)
|
|
instances.remove(candidate)
|
|
item_candidates.append(candidate)
|
|
else:
|
|
item_candidates = [multiworld.worlds[player].create_item(item)
|
|
for item in multiworld.random.sample(items, maxcount)]
|
|
if any(item.code is None for item in item_candidates) \
|
|
and not all(item.code is None for item in item_candidates):
|
|
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
|
f"event items and non-event items. "
|
|
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
|
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
|
placement.force)
|
|
continue
|
|
else:
|
|
is_real = item_candidates[0].code is not None
|
|
candidates = [candidate
|
|
for candidate in locations
|
|
if candidate.item is None and bool(candidate.address) == is_real]
|
|
multiworld.random.shuffle(candidates)
|
|
allstate = multiworld.get_all_state()
|
|
mincount = placement.count["min"]
|
|
allowed_margin = len(item_candidates) - mincount
|
|
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
|
allow_partial=True, name="Plando Main Fill")
|
|
|
|
if len(item_candidates) > allowed_margin:
|
|
failed(f"Could not place {len(item_candidates)} "
|
|
f"of {mincount + allowed_margin} item(s) "
|
|
f"for {multiworld.player_name[player]}, "
|
|
f"remaining items: {item_candidates}",
|
|
placement.force)
|
|
if from_pool:
|
|
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
|
except Exception as e:
|
|
raise Exception(
|
|
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|