diff --git a/BaseClasses.py b/BaseClasses.py index 973a8d50f0..a70dd70a92 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -325,15 +325,15 @@ class MultiWorld(): def player_ids(self) -> Tuple[int, ...]: return tuple(range(1, self.players + 1)) - @functools.lru_cache() + @Utils.cache_self1 def get_game_players(self, game_name: str) -> Tuple[int, ...]: return tuple(player for player in self.player_ids if self.game[player] == game_name) - @functools.lru_cache() + @Utils.cache_self1 def get_game_groups(self, game_name: str) -> Tuple[int, ...]: return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name) - @functools.lru_cache() + @Utils.cache_self1 def get_game_worlds(self, game_name: str): return tuple(world for player, world in self.worlds.items() if player not in self.groups and self.game[player] == game_name) @@ -605,7 +605,7 @@ PathValue = Tuple[str, Optional["PathValue"]] class CollectionState(): - prog_items: typing.Counter[Tuple[str, int]] + prog_items: Dict[int, Counter[str]] multiworld: MultiWorld reachable_regions: Dict[int, Set[Region]] blocked_connections: Dict[int, Set[Entrance]] @@ -617,7 +617,7 @@ class CollectionState(): additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] def __init__(self, parent: MultiWorld): - self.prog_items = Counter() + self.prog_items = {player: Counter() for player in parent.player_ids} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()} @@ -665,7 +665,7 @@ class CollectionState(): def copy(self) -> CollectionState: ret = CollectionState(self.multiworld) - ret.prog_items = self.prog_items.copy() + ret.prog_items = copy.deepcopy(self.prog_items) ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in self.reachable_regions} ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in @@ -709,23 +709,23 @@ class CollectionState(): self.collect(event.item, True, event) def has(self, item: str, player: int, count: int = 1) -> bool: - return self.prog_items[item, player] >= count + return self.prog_items[player][item] >= count def has_all(self, items: Set[str], player: int) -> bool: """Returns True if each item name of items is in state at least once.""" - return all(self.prog_items[item, player] for item in items) + return all(self.prog_items[player][item] for item in items) def has_any(self, items: Set[str], player: int) -> bool: """Returns True if at least one item name of items is in state at least once.""" - return any(self.prog_items[item, player] for item in items) + return any(self.prog_items[player][item] for item in items) def count(self, item: str, player: int) -> int: - return self.prog_items[item, player] + return self.prog_items[player][item] def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: found: int = 0 for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: - found += self.prog_items[item_name, player] + found += self.prog_items[player][item_name] if found >= count: return True return False @@ -733,11 +733,11 @@ class CollectionState(): def count_group(self, item_name_group: str, player: int) -> int: found: int = 0 for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: - found += self.prog_items[item_name, player] + found += self.prog_items[player][item_name] return found def item_count(self, item: str, player: int) -> int: - return self.prog_items[item, player] + return self.prog_items[player][item] def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool: if location: @@ -746,7 +746,7 @@ class CollectionState(): changed = self.multiworld.worlds[item.player].collect(self, item) if not changed and event: - self.prog_items[item.name, item.player] += 1 + self.prog_items[item.player][item.name] += 1 changed = True self.stale[item.player] = True @@ -920,7 +920,7 @@ class Region: self.locations.append(location_type(self.player, location, address, self)) def connect(self, connecting_region: Region, name: Optional[str] = None, - rule: Optional[Callable[[CollectionState], bool]] = None) -> None: + rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type: """ Connects this Region to another Region, placing the provided rule on the connection. @@ -931,6 +931,7 @@ class Region: if rule: exit_.access_rule = rule exit_.connect(connecting_region) + return exit_ def create_exit(self, name: str) -> Entrance: """ diff --git a/Generate.py b/Generate.py index 34a0084e8d..8113d8a0d7 100644 --- a/Generate.py +++ b/Generate.py @@ -7,8 +7,8 @@ import random import string import urllib.parse import urllib.request -from collections import ChainMap, Counter -from typing import Any, Callable, Dict, Tuple, Union +from collections import Counter +from typing import Any, Dict, Tuple, Union import ModuleUpdate @@ -225,7 +225,7 @@ def main(args=None, callback=ERmain): with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: yaml.dump(important, f) - callback(erargs, seed) + return callback(erargs, seed) def read_weights_yamls(path) -> Tuple[Any, ...]: @@ -639,6 +639,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): if __name__ == '__main__': import atexit confirmation = atexit.register(input, "Press enter to close.") - main() + multiworld = main() + if __debug__: + import gc + import sys + import weakref + weak = weakref.ref(multiworld) + del multiworld + gc.collect() # need to collect to deref all hard references + assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \ + " This would be a memory leak." # in case of error-free exit should not need confirmation atexit.unregister(confirmation) diff --git a/SNIClient.py b/SNIClient.py index 0909c61382..062d7a7cbe 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -207,12 +207,12 @@ class SNIContext(CommonContext): self.killing_player_task = asyncio.create_task(deathlink_kill_player(self)) super(SNIContext, self).on_deathlink(data) - async def handle_deathlink_state(self, currently_dead: bool) -> None: + async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None: # in this state we only care about triggering a death send if self.death_state == DeathState.alive: if currently_dead: self.death_state = DeathState.dead - await self.send_death() + await self.send_death(death_text) # in this state we care about confirming a kill, to move state to dead elif self.death_state == DeathState.killing_player: # this is being handled in deathlink_kill_player(ctx) already diff --git a/Utils.py b/Utils.py index 4cf8ca2200..bb68602cce 100644 --- a/Utils.py +++ b/Utils.py @@ -74,6 +74,8 @@ def snes_to_pc(value: int) -> int: RetType = typing.TypeVar("RetType") +S = typing.TypeVar("S") +T = typing.TypeVar("T") def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]: @@ -91,6 +93,31 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[] return _wrap +def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]: + """Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple.""" + + assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache." + + cache_name = f"__cache_{function.__name__}__" + + @functools.wraps(function) + def wrap(self: S, arg: T) -> RetType: + cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]], + getattr(self, cache_name, None)) + if cache is None: + res = function(self, arg) + setattr(self, cache_name, {arg: res}) + return res + try: + return cache[arg] + except KeyError: + res = function(self, arg) + cache[arg] = res + return res + + return wrap + + def is_frozen() -> bool: return typing.cast(bool, getattr(sys, 'frozen', False)) @@ -147,12 +174,16 @@ def user_path(*path: str) -> str: if user_path.cached_path != local_path(): import filecmp if not os.path.exists(user_path("manifest.json")) or \ + not os.path.exists(local_path("manifest.json")) or \ not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): import shutil - for dn in ("Players", "data/sprites"): + for dn in ("Players", "data/sprites", "data/lua"): shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ("manifest.json",): - shutil.copy2(local_path(fn), user_path(fn)) + if not os.path.exists(local_path("manifest.json")): + warnings.warn(f"Upgrading {user_path()} from something that is not a proper install") + else: + shutil.copy2(local_path("manifest.json"), user_path("manifest.json")) + os.makedirs(user_path("worlds"), exist_ok=True) return os.path.join(user_path.cached_path, *path) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 785785cde0..1a2aab6d88 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -139,7 +139,13 @@ def create(): weighted_options["games"][game_name] = {} weighted_options["games"][game_name]["gameSettings"] = game_options weighted_options["games"][game_name]["gameItems"] = tuple(world.item_names) + weighted_options["games"][game_name]["gameItemGroups"] = [ + group for group in world.item_name_groups.keys() if group != "Everything" + ] weighted_options["games"][game_name]["gameLocations"] = tuple(world.location_names) + weighted_options["games"][game_name]["gameLocationGroups"] = [ + group for group in world.location_name_groups.keys() if group != "Everywhere" + ] with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f: json.dump(weighted_options, f, indent=2, separators=(',', ': ')) diff --git a/WebHostLib/static/assets/weighted-options.js b/WebHostLib/static/assets/weighted-options.js index bdd121eff5..3811bd42ba 100644 --- a/WebHostLib/static/assets/weighted-options.js +++ b/WebHostLib/static/assets/weighted-options.js @@ -43,7 +43,7 @@ const resetSettings = () => { }; const fetchSettingData = () => new Promise((resolve, reject) => { - fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => { + fetch(new Request(`${window.location.origin}/static/generated/weighted-options.json`)).then((response) => { try{ response.json().then((jsonObj) => resolve(jsonObj)); } catch(error){ reject(error); } }); @@ -428,13 +428,13 @@ class GameSettings { const weightedSettingsDiv = this.#buildWeightedSettingsDiv(); gameDiv.appendChild(weightedSettingsDiv); - const itemPoolDiv = this.#buildItemsDiv(); + const itemPoolDiv = this.#buildItemPoolDiv(); gameDiv.appendChild(itemPoolDiv); const hintsDiv = this.#buildHintsDiv(); gameDiv.appendChild(hintsDiv); - const locationsDiv = this.#buildLocationsDiv(); + const locationsDiv = this.#buildPriorityExclusionDiv(); gameDiv.appendChild(locationsDiv); collapseButton.addEventListener('click', () => { @@ -734,107 +734,17 @@ class GameSettings { break; case 'items-list': - const itemsList = document.createElement('div'); - itemsList.classList.add('simple-list'); - - Object.values(this.data.gameItems).forEach((item) => { - const itemRow = document.createElement('div'); - itemRow.classList.add('list-row'); - - const itemLabel = document.createElement('label'); - itemLabel.setAttribute('for', `${this.name}-${settingName}-${item}`) - - const itemCheckbox = document.createElement('input'); - itemCheckbox.setAttribute('id', `${this.name}-${settingName}-${item}`); - itemCheckbox.setAttribute('type', 'checkbox'); - itemCheckbox.setAttribute('data-game', this.name); - itemCheckbox.setAttribute('data-setting', settingName); - itemCheckbox.setAttribute('data-option', item.toString()); - itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - if (this.current[settingName].includes(item)) { - itemCheckbox.setAttribute('checked', '1'); - } - - const itemName = document.createElement('span'); - itemName.innerText = item.toString(); - - itemLabel.appendChild(itemCheckbox); - itemLabel.appendChild(itemName); - - itemRow.appendChild(itemLabel); - itemsList.appendChild((itemRow)); - }); - + const itemsList = this.#buildItemsDiv(settingName); settingWrapper.appendChild(itemsList); break; case 'locations-list': - const locationsList = document.createElement('div'); - locationsList.classList.add('simple-list'); - - Object.values(this.data.gameLocations).forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${this.name}-${settingName}-${location}`) - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('id', `${this.name}-${settingName}-${location}`); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('data-game', this.name); - locationCheckbox.setAttribute('data-setting', settingName); - locationCheckbox.setAttribute('data-option', location.toString()); - locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - if (this.current[settingName].includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - - const locationName = document.createElement('span'); - locationName.innerText = location.toString(); - - locationLabel.appendChild(locationCheckbox); - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - locationsList.appendChild((locationRow)); - }); - + const locationsList = this.#buildLocationsDiv(settingName); settingWrapper.appendChild(locationsList); break; case 'custom-list': - const customList = document.createElement('div'); - customList.classList.add('simple-list'); - - Object.values(this.data.gameSettings[settingName].options).forEach((listItem) => { - const customListRow = document.createElement('div'); - customListRow.classList.add('list-row'); - - const customItemLabel = document.createElement('label'); - customItemLabel.setAttribute('for', `${this.name}-${settingName}-${listItem}`) - - const customItemCheckbox = document.createElement('input'); - customItemCheckbox.setAttribute('id', `${this.name}-${settingName}-${listItem}`); - customItemCheckbox.setAttribute('type', 'checkbox'); - customItemCheckbox.setAttribute('data-game', this.name); - customItemCheckbox.setAttribute('data-setting', settingName); - customItemCheckbox.setAttribute('data-option', listItem.toString()); - customItemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - if (this.current[settingName].includes(listItem)) { - customItemCheckbox.setAttribute('checked', '1'); - } - - const customItemName = document.createElement('span'); - customItemName.innerText = listItem.toString(); - - customItemLabel.appendChild(customItemCheckbox); - customItemLabel.appendChild(customItemName); - - customListRow.appendChild(customItemLabel); - customList.appendChild((customListRow)); - }); - + const customList = this.#buildListDiv(settingName, this.data.gameSettings[settingName].options); settingWrapper.appendChild(customList); break; @@ -849,7 +759,7 @@ class GameSettings { return settingsWrapper; } - #buildItemsDiv() { + #buildItemPoolDiv() { const itemsDiv = document.createElement('div'); itemsDiv.classList.add('items-div'); @@ -1058,35 +968,7 @@ class GameSettings { itemHintsWrapper.classList.add('hints-wrapper'); itemHintsWrapper.innerText = 'Starting Item Hints'; - const itemHintsDiv = document.createElement('div'); - itemHintsDiv.classList.add('simple-list'); - this.data.gameItems.forEach((item) => { - const itemRow = document.createElement('div'); - itemRow.classList.add('list-row'); - - const itemLabel = document.createElement('label'); - itemLabel.setAttribute('for', `${this.name}-start_hints-${item}`); - - const itemCheckbox = document.createElement('input'); - itemCheckbox.setAttribute('type', 'checkbox'); - itemCheckbox.setAttribute('id', `${this.name}-start_hints-${item}`); - itemCheckbox.setAttribute('data-game', this.name); - itemCheckbox.setAttribute('data-setting', 'start_hints'); - itemCheckbox.setAttribute('data-option', item); - if (this.current.start_hints.includes(item)) { - itemCheckbox.setAttribute('checked', 'true'); - } - itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - itemLabel.appendChild(itemCheckbox); - - const itemName = document.createElement('span'); - itemName.innerText = item; - itemLabel.appendChild(itemName); - - itemRow.appendChild(itemLabel); - itemHintsDiv.appendChild(itemRow); - }); - + const itemHintsDiv = this.#buildItemsDiv('start_hints'); itemHintsWrapper.appendChild(itemHintsDiv); itemHintsContainer.appendChild(itemHintsWrapper); @@ -1095,35 +977,7 @@ class GameSettings { locationHintsWrapper.classList.add('hints-wrapper'); locationHintsWrapper.innerText = 'Starting Location Hints'; - const locationHintsDiv = document.createElement('div'); - locationHintsDiv.classList.add('simple-list'); - this.data.gameLocations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${this.name}-start_location_hints-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${this.name}-start_location_hints-${location}`); - locationCheckbox.setAttribute('data-game', this.name); - locationCheckbox.setAttribute('data-setting', 'start_location_hints'); - locationCheckbox.setAttribute('data-option', location); - if (this.current.start_location_hints.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - locationLabel.appendChild(locationCheckbox); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - locationHintsDiv.appendChild(locationRow); - }); - + const locationHintsDiv = this.#buildLocationsDiv('start_location_hints'); locationHintsWrapper.appendChild(locationHintsDiv); itemHintsContainer.appendChild(locationHintsWrapper); @@ -1131,7 +985,7 @@ class GameSettings { return hintsDiv; } - #buildLocationsDiv() { + #buildPriorityExclusionDiv() { const locationsDiv = document.createElement('div'); locationsDiv.classList.add('locations-div'); const locationsHeader = document.createElement('h3'); @@ -1151,35 +1005,7 @@ class GameSettings { priorityLocationsWrapper.classList.add('locations-wrapper'); priorityLocationsWrapper.innerText = 'Priority Locations'; - const priorityLocationsDiv = document.createElement('div'); - priorityLocationsDiv.classList.add('simple-list'); - this.data.gameLocations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${this.name}-priority_locations-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${this.name}-priority_locations-${location}`); - locationCheckbox.setAttribute('data-game', this.name); - locationCheckbox.setAttribute('data-setting', 'priority_locations'); - locationCheckbox.setAttribute('data-option', location); - if (this.current.priority_locations.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - locationLabel.appendChild(locationCheckbox); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - priorityLocationsDiv.appendChild(locationRow); - }); - + const priorityLocationsDiv = this.#buildLocationsDiv('priority_locations'); priorityLocationsWrapper.appendChild(priorityLocationsDiv); locationsContainer.appendChild(priorityLocationsWrapper); @@ -1188,35 +1014,7 @@ class GameSettings { excludeLocationsWrapper.classList.add('locations-wrapper'); excludeLocationsWrapper.innerText = 'Exclude Locations'; - const excludeLocationsDiv = document.createElement('div'); - excludeLocationsDiv.classList.add('simple-list'); - this.data.gameLocations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${this.name}-exclude_locations-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${this.name}-exclude_locations-${location}`); - locationCheckbox.setAttribute('data-game', this.name); - locationCheckbox.setAttribute('data-setting', 'exclude_locations'); - locationCheckbox.setAttribute('data-option', location); - if (this.current.exclude_locations.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - locationLabel.appendChild(locationCheckbox); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - excludeLocationsDiv.appendChild(locationRow); - }); - + const excludeLocationsDiv = this.#buildLocationsDiv('exclude_locations'); excludeLocationsWrapper.appendChild(excludeLocationsDiv); locationsContainer.appendChild(excludeLocationsWrapper); @@ -1224,6 +1022,71 @@ class GameSettings { return locationsDiv; } + // Builds a div for a setting whose value is a list of locations. + #buildLocationsDiv(setting) { + return this.#buildListDiv(setting, this.data.gameLocations, this.data.gameLocationGroups); + } + + // Builds a div for a setting whose value is a list of items. + #buildItemsDiv(setting) { + return this.#buildListDiv(setting, this.data.gameItems, this.data.gameItemGroups); + } + + // Builds a div for a setting named `setting` with a list value that can + // contain `items`. + // + // The `groups` option can be a list of additional options for this list + // (usually `item_name_groups` or `location_name_groups`) that are displayed + // in a special section at the top of the list. + #buildListDiv(setting, items, groups = []) { + const div = document.createElement('div'); + div.classList.add('simple-list'); + + groups.forEach((group) => { + const row = this.#addListRow(setting, group); + div.appendChild(row); + }); + + if (groups.length > 0) { + div.appendChild(document.createElement('hr')); + } + + items.forEach((item) => { + const row = this.#addListRow(setting, item); + div.appendChild(row); + }); + + return div; + } + + // Builds and returns a row for a list of checkboxes. + #addListRow(setting, item) { + const row = document.createElement('div'); + row.classList.add('list-row'); + + const label = document.createElement('label'); + label.setAttribute('for', `${this.name}-${setting}-${item}`); + + const checkbox = document.createElement('input'); + checkbox.setAttribute('type', 'checkbox'); + checkbox.setAttribute('id', `${this.name}-${setting}-${item}`); + checkbox.setAttribute('data-game', this.name); + checkbox.setAttribute('data-setting', setting); + checkbox.setAttribute('data-option', item); + if (this.current[setting].includes(item)) { + checkbox.setAttribute('checked', '1'); + } + checkbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + label.appendChild(checkbox); + + const name = document.createElement('span'); + name.innerText = item; + label.appendChild(name); + + row.appendChild(label); + return row; + } + #updateRangeSetting(evt) { const setting = evt.target.getAttribute('data-setting'); const option = evt.target.getAttribute('data-option'); diff --git a/WebHostLib/static/styles/weighted-options.css b/WebHostLib/static/styles/weighted-options.css index cc5231634e..8a66ca2370 100644 --- a/WebHostLib/static/styles/weighted-options.css +++ b/WebHostLib/static/styles/weighted-options.css @@ -292,6 +292,12 @@ html{ margin-right: 0.5rem; } +#weighted-settings .simple-list hr{ + width: calc(100% - 2px); + margin: 2px auto; + border-bottom: 1px solid rgb(255 255 255 / 0.6); +} + #weighted-settings .invisible{ display: none; } diff --git a/WebHostLib/templates/lttpMultiTracker.html b/WebHostLib/templates/lttpMultiTracker.html index 2b943a22b0..8eb471be39 100644 --- a/WebHostLib/templates/lttpMultiTracker.html +++ b/WebHostLib/templates/lttpMultiTracker.html @@ -153,7 +153,7 @@ {%- endif -%} {% endif %} {%- endfor -%} - {{ percent_total_checks_done[team][player] }} + {{ "{0:.2f}".format(percent_total_checks_done[team][player]) }} {%- if activity_timers[(team, player)] -%} {{ activity_timers[(team, player)].total_seconds() }} {%- else -%} diff --git a/WebHostLib/templates/multiTracker.html b/WebHostLib/templates/multiTracker.html index 40d89eb4c6..1a3d353de1 100644 --- a/WebHostLib/templates/multiTracker.html +++ b/WebHostLib/templates/multiTracker.html @@ -55,7 +55,7 @@ {{ checks["Total"] }}/{{ locations[player] | length }} - {{ percent_total_checks_done[team][player] }} + {{ "{0:.2f}".format(percent_total_checks_done[team][player]) }} {%- if activity_timers[team, player] -%} {{ activity_timers[team, player].total_seconds() }} {%- else -%} @@ -72,7 +72,13 @@ All Games {{ completed_worlds }}/{{ players|length }} Complete {{ players.values()|sum(attribute='Total') }}/{{ total_locations[team] }} - {{ (players.values()|sum(attribute='Total') / total_locations[team] * 100) | int }} + + {% if total_locations[team] == 0 %} + 100 + {% else %} + {{ "{0:.2f}".format(players.values()|sum(attribute='Total') / total_locations[team] * 100) }} + {% endif %} + diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 0d9ead7951..55b98df59e 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -1532,9 +1532,11 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s continue player_locations = locations[player] checks_done[team][player]["Total"] = len(locations_checked) - percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] / - len(player_locations) * 100) \ - if player_locations else 100 + percent_total_checks_done[team][player] = ( + checks_done[team][player]["Total"] / len(player_locations) * 100 + if player_locations + else 100 + ) activity_timers = {} now = datetime.datetime.utcnow() @@ -1690,10 +1692,13 @@ def get_LttP_multiworld_tracker(tracker: UUID): for recipient in recipients: attribute_item(team, recipient, item) checks_done[team][player][player_location_to_area[player][location]] += 1 - checks_done[team][player]["Total"] += 1 - percent_total_checks_done[team][player] = int( - checks_done[team][player]["Total"] / len(player_locations) * 100) if \ - player_locations else 100 + checks_done[team][player]["Total"] = len(locations_checked) + + percent_total_checks_done[team][player] = ( + checks_done[team][player]["Total"] / len(player_locations) * 100 + if player_locations + else 100 + ) for (team, player), game_state in multisave.get("client_game_state", {}).items(): if player in groups: diff --git a/test/bases.py b/test/bases.py index 9911a45bc5..2054c2d187 100644 --- a/test/bases.py +++ b/test/bases.py @@ -1,3 +1,4 @@ +import sys import typing import unittest from argparse import Namespace @@ -107,11 +108,36 @@ class WorldTestBase(unittest.TestCase): game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore" auto_construct: typing.ClassVar[bool] = True """ automatically set up a world for each test in this class """ + memory_leak_tested: typing.ClassVar[bool] = False + """ remember if memory leak test was already done for this class """ def setUp(self) -> None: if self.auto_construct: self.world_setup() + def tearDown(self) -> None: + if self.__class__.memory_leak_tested or not self.options or not self.constructed or \ + sys.version_info < (3, 11, 0): # the leak check in tearDown fails in py<3.11 for an unknown reason + # only run memory leak test once per class, only for constructed with non-default options + # default options will be tested in test/general + super().tearDown() + return + + import gc + import weakref + weak = weakref.ref(self.multiworld) + for attr_name in dir(self): # delete all direct references to MultiWorld and World + attr: object = typing.cast(object, getattr(self, attr_name)) + if type(attr) is MultiWorld or isinstance(attr, AutoWorld.World): + delattr(self, attr_name) + state_cache: typing.Optional[typing.Dict[typing.Any, typing.Any]] = getattr(self, "_state_cache", None) + if state_cache is not None: # in case of multiple inheritance with TestBase, we need to clear its cache + state_cache.clear() + gc.collect() + self.__class__.memory_leak_tested = True + self.assertFalse(weak(), f"World {getattr(self, 'game', '')} leaked MultiWorld object") + super().tearDown() + def world_setup(self, seed: typing.Optional[int] = None) -> None: if type(self) is WorldTestBase or \ (hasattr(WorldTestBase, self._testMethodName) diff --git a/test/general/test_fill.py b/test/general/test_fill.py index 4e8cc2edb7..1e469ef04d 100644 --- a/test/general/test_fill.py +++ b/test/general/test_fill.py @@ -455,8 +455,8 @@ class TestFillRestrictive(unittest.TestCase): location.place_locked_item(item) multi_world.state.sweep_for_events() multi_world.state.sweep_for_events() - self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed") - self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times") + self.assertTrue(multi_world.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed") + self.assertEqual(multi_world.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times") def test_correct_item_instance_removed_from_pool(self): """Test that a placed item gets removed from the submitted pool""" diff --git a/test/general/test_memory.py b/test/general/test_memory.py new file mode 100644 index 0000000000..e352b9e875 --- /dev/null +++ b/test/general/test_memory.py @@ -0,0 +1,16 @@ +import unittest + +from worlds.AutoWorld import AutoWorldRegister +from . import setup_solo_multiworld + + +class TestWorldMemory(unittest.TestCase): + def test_leak(self): + """Tests that worlds don't leak references to MultiWorld or themselves with default options.""" + import gc + import weakref + for game_name, world_type in AutoWorldRegister.world_types.items(): + with self.subTest("Game", game_name=game_name): + weak = weakref.ref(setup_solo_multiworld(world_type)) + gc.collect() + self.assertFalse(weak(), "World leaked a reference") diff --git a/test/utils/test_caches.py b/test/utils/test_caches.py new file mode 100644 index 0000000000..fc681611f0 --- /dev/null +++ b/test/utils/test_caches.py @@ -0,0 +1,66 @@ +# Tests for caches in Utils.py + +import unittest +from typing import Any + +from Utils import cache_argsless, cache_self1 + + +class TestCacheArgless(unittest.TestCase): + def test_cache(self) -> None: + @cache_argsless + def func_argless() -> object: + return object() + + self.assertTrue(func_argless() is func_argless()) + + if __debug__: # assert only available with __debug__ + def test_invalid_decorator(self) -> None: + with self.assertRaises(Exception): + @cache_argsless # type: ignore[arg-type] + def func_with_arg(_: Any) -> None: + pass + + +class TestCacheSelf1(unittest.TestCase): + def test_cache(self) -> None: + class Cls: + @cache_self1 + def func(self, _: Any) -> object: + return object() + + o1 = Cls() + o2 = Cls() + self.assertTrue(o1.func(1) is o1.func(1)) + self.assertFalse(o1.func(1) is o1.func(2)) + self.assertFalse(o1.func(1) is o2.func(1)) + + def test_gc(self) -> None: + # verify that we don't keep a global reference + import gc + import weakref + + class Cls: + @cache_self1 + def func(self, _: Any) -> object: + return object() + + o = Cls() + _ = o.func(o) # keep a hard ref to the result + r = weakref.ref(o) # keep weak ref to the cache + del o # remove hard ref to the cache + gc.collect() + self.assertFalse(r()) # weak ref should be dead now + + if __debug__: # assert only available with __debug__ + def test_no_self(self) -> None: + with self.assertRaises(Exception): + @cache_self1 # type: ignore[arg-type] + def func() -> Any: + pass + + def test_too_many_args(self) -> None: + with self.assertRaises(Exception): + @cache_self1 # type: ignore[arg-type] + def func(_1: Any, _2: Any, _3: Any) -> Any: + pass diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 3e6e60c6f0..d05797cf9e 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -414,16 +414,16 @@ class World(metaclass=AutoWorldRegister): def collect(self, state: "CollectionState", item: "Item") -> bool: name = self.collect_item(state, item) if name: - state.prog_items[name, self.player] += 1 + state.prog_items[self.player][name] += 1 return True return False def remove(self, state: "CollectionState", item: "Item") -> bool: name = self.collect_item(state, item, True) if name: - state.prog_items[name, self.player] -= 1 - if state.prog_items[name, self.player] < 1: - del (state.prog_items[name, self.player]) + state.prog_items[self.player][name] -= 1 + if state.prog_items[self.player][name] < 1: + del (state.prog_items[self.player][name]) return True return False diff --git a/worlds/__init__.py b/worlds/__init__.py index c6208fa9a1..40e0b20f19 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -5,19 +5,20 @@ import typing import warnings import zipimport -folder = os.path.dirname(__file__) +from Utils import user_path, local_path -__all__ = { +local_folder = os.path.dirname(__file__) +user_folder = user_path("worlds") if user_path() != local_path() else None + +__all__ = ( "lookup_any_item_id_to_name", "lookup_any_location_id_to_name", "network_data_package", "AutoWorldRegister", "world_sources", - "folder", -} - -if typing.TYPE_CHECKING: - from .AutoWorld import World + "local_folder", + "user_folder", +) class GamesData(typing.TypedDict): @@ -41,13 +42,13 @@ class WorldSource(typing.NamedTuple): is_zip: bool = False relative: bool = True # relative to regular world import folder - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" @property def resolved_path(self) -> str: if self.relative: - return os.path.join(folder, self.path) + return os.path.join(local_folder, self.path) return self.path def load(self) -> bool: @@ -56,6 +57,7 @@ class WorldSource(typing.NamedTuple): importer = zipimport.zipimporter(self.resolved_path) if hasattr(importer, "find_spec"): # new in Python 3.10 spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0]) + assert spec, f"{self.path} is not a loadable module" mod = importlib.util.module_from_spec(spec) else: # TODO: remove with 3.8 support mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0]) @@ -72,7 +74,7 @@ class WorldSource(typing.NamedTuple): importlib.import_module(f".{self.path}", "worlds") return True - except Exception as e: + except Exception: # A single world failing can still mean enough is working for the user, log and carry on import traceback import io @@ -87,14 +89,16 @@ class WorldSource(typing.NamedTuple): # find potential world containers, currently folders and zip-importable .apworld's world_sources: typing.List[WorldSource] = [] -file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly -for file in os.scandir(folder): - # prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "." - if not file.name.startswith(("_", ".")): - if file.is_dir(): - world_sources.append(WorldSource(file.name)) - elif file.is_file() and file.name.endswith(".apworld"): - world_sources.append(WorldSource(file.name, is_zip=True)) +for folder in (folder for folder in (user_folder, local_folder) if folder): + relative = folder == local_folder + for entry in os.scandir(folder): + # prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "." + if not entry.name.startswith(("_", ".")): + file_name = entry.name if relative else os.path.join(folder, entry.name) + if entry.is_dir(): + world_sources.append(WorldSource(file_name, relative=relative)) + elif entry.is_file() and entry.name.endswith(".apworld"): + world_sources.append(WorldSource(file_name, is_zip=True, relative=relative)) # import all submodules to trigger AutoWorldRegister world_sources.sort() @@ -105,7 +109,7 @@ lookup_any_item_id_to_name = {} lookup_any_location_id_to_name = {} games: typing.Dict[str, GamesPackage] = {} -from .AutoWorld import AutoWorldRegister +from .AutoWorld import AutoWorldRegister # noqa: E402 # Build the data package for each game. for world_name, world in AutoWorldRegister.world_types.items(): diff --git a/worlds/alttp/Client.py b/worlds/alttp/Client.py index 22ef2a39a8..edc68473b9 100644 --- a/worlds/alttp/Client.py +++ b/worlds/alttp/Client.py @@ -520,7 +520,8 @@ class ALTTPSNIClient(SNIClient): gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): currently_dead = gamemode[0] in DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) + await ctx.handle_deathlink_state(currently_dead, + ctx.player_names[ctx.slot] + " ran out of hearts." if ctx.slot else "") gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) diff --git a/worlds/alttp/UnderworldGlitchRules.py b/worlds/alttp/UnderworldGlitchRules.py index 4b6bc54111..a6aefc7412 100644 --- a/worlds/alttp/UnderworldGlitchRules.py +++ b/worlds/alttp/UnderworldGlitchRules.py @@ -31,7 +31,7 @@ def fake_pearl_state(state, player): if state.has('Moon Pearl', player): return state fake_state = state.copy() - fake_state.prog_items['Moon Pearl', player] += 1 + fake_state.prog_items[player]['Moon Pearl'] += 1 return fake_state diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 2666641542..d89e65c59d 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -830,4 +830,4 @@ class ALttPLogic(LogicMixin): return True if self.multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal: return can_buy_unlimited(self, 'Small Key (Universal)', player) - return self.prog_items[item, player] >= count + return self.prog_items[player][item] >= count diff --git a/worlds/archipidle/Rules.py b/worlds/archipidle/Rules.py index cdd48e7604..3bf4bad475 100644 --- a/worlds/archipidle/Rules.py +++ b/worlds/archipidle/Rules.py @@ -5,12 +5,7 @@ from ..generic.Rules import set_rule class ArchipIDLELogic(LogicMixin): def _archipidle_location_is_accessible(self, player_id, items_required): - items_received = 0 - for item in self.prog_items: - if item[1] == player_id: - items_received += 1 - - return items_received >= items_required + return sum(self.prog_items[player_id].values()) >= items_required def set_rules(world: MultiWorld, player: int): diff --git a/worlds/checksfinder/docs/en_ChecksFinder.md b/worlds/checksfinder/docs/en_ChecksFinder.md index bd82660b09..96fb0529df 100644 --- a/worlds/checksfinder/docs/en_ChecksFinder.md +++ b/worlds/checksfinder/docs/en_ChecksFinder.md @@ -14,11 +14,18 @@ many checks as you have gained items, plus five to start with being available. ## When the player receives an item, what happens? When the player receives an item in ChecksFinder, it either can make the future boards they play be bigger in width or -height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being -bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a number +height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being +bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a +number next to an icon, the number is how many you have gotten and the icon represents which item it is. ## What is the victory condition? Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map -Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received. \ No newline at end of file +Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received. + +## Unique Local Commands + +The following command is only available when using the ChecksFinderClient to play with Archipelago. + +- `/resync` Manually trigger a resync. diff --git a/worlds/dlcquest/Rules.py b/worlds/dlcquest/Rules.py index a11e5c504e..5792d9c3ab 100644 --- a/worlds/dlcquest/Rules.py +++ b/worlds/dlcquest/Rules.py @@ -12,11 +12,11 @@ def create_event(player, event: str) -> DLCQuestItem: def has_enough_coin(player: int, coin: int): - return lambda state: state.prog_items[" coins", player] >= coin + return lambda state: state.prog_items[player][" coins"] >= coin def has_enough_coin_freemium(player: int, coin: int): - return lambda state: state.prog_items[" coins freemium", player] >= coin + return lambda state: state.prog_items[player][" coins freemium"] >= coin def set_rules(world, player, World_Options: Options.DLCQuestOptions): diff --git a/worlds/dlcquest/__init__.py b/worlds/dlcquest/__init__.py index 54d27f7b65..e4e0a29274 100644 --- a/worlds/dlcquest/__init__.py +++ b/worlds/dlcquest/__init__.py @@ -92,7 +92,7 @@ class DLCqworld(World): if change: suffix = item.coin_suffix if suffix: - state.prog_items[suffix, self.player] += item.coins + state.prog_items[self.player][suffix] += item.coins return change def remove(self, state: CollectionState, item: DLCQuestItem) -> bool: @@ -100,5 +100,5 @@ class DLCqworld(World): if change: suffix = item.coin_suffix if suffix: - state.prog_items[suffix, self.player] -= item.coins + state.prog_items[self.player][suffix] -= item.coins return change diff --git a/worlds/ff1/docs/en_Final Fantasy.md b/worlds/ff1/docs/en_Final Fantasy.md index 8962919743..59fa85d916 100644 --- a/worlds/ff1/docs/en_Final Fantasy.md +++ b/worlds/ff1/docs/en_Final Fantasy.md @@ -26,6 +26,7 @@ All local and remote items appear the same. Final Fantasy will say that you rece emulator will display what was found external to the in-game text box. ## Unique Local Commands -The following command is only available when using the FF1Client for the Final Fantasy Randomizer. +The following commands are only available when using the FF1Client for the Final Fantasy Randomizer. - `/nes` Shows the current status of the NES connection. +- `/toggle_msgs` Toggle displaying messages in EmuHawk diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index 1a9d4b5d61..c16a108cd1 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -517,12 +517,12 @@ class HKWorld(World): change = super(HKWorld, self).collect(state, item) if change: for effect_name, effect_value in item_effects.get(item.name, {}).items(): - state.prog_items[effect_name, item.player] += effect_value + state.prog_items[item.player][effect_name] += effect_value if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}: - if state.prog_items.get(('RIGHTDASH', item.player), 0) and \ - state.prog_items.get(('LEFTDASH', item.player), 0): - (state.prog_items["RIGHTDASH", item.player], state.prog_items["LEFTDASH", item.player]) = \ - ([max(state.prog_items["RIGHTDASH", item.player], state.prog_items["LEFTDASH", item.player])] * 2) + if state.prog_items[item.player].get('RIGHTDASH', 0) and \ + state.prog_items[item.player].get('LEFTDASH', 0): + (state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"]) = \ + ([max(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"])] * 2) return change def remove(self, state, item: HKItem) -> bool: @@ -530,9 +530,9 @@ class HKWorld(World): if change: for effect_name, effect_value in item_effects.get(item.name, {}).items(): - if state.prog_items[effect_name, item.player] == effect_value: - del state.prog_items[effect_name, item.player] - state.prog_items[effect_name, item.player] -= effect_value + if state.prog_items[item.player][effect_name] == effect_value: + del state.prog_items[item.player][effect_name] + state.prog_items[item.player][effect_name] -= effect_value return change diff --git a/worlds/ladx/Locations.py b/worlds/ladx/Locations.py index 1fd6772cdd..c7b127ef2b 100644 --- a/worlds/ladx/Locations.py +++ b/worlds/ladx/Locations.py @@ -124,13 +124,13 @@ class GameStateAdapater: # Don't allow any money usage if you can't get back wasted rupees if item == "RUPEES": if can_farm_rupees(self.state, self.player): - return self.state.prog_items["RUPEES", self.player] + return self.state.prog_items[self.player]["RUPEES"] return 0 elif item.endswith("_USED"): return 0 else: item = ladxr_item_to_la_item_name[item] - return self.state.prog_items.get((item, self.player), default) + return self.state.prog_items[self.player].get(item, default) class LinksAwakeningEntrance(Entrance): diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index d21190bb91..eaaea5be2f 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -513,7 +513,7 @@ class LinksAwakeningWorld(World): change = super().collect(state, item) if change: rupees = self.rupees.get(item.name, 0) - state.prog_items["RUPEES", item.player] += rupees + state.prog_items[item.player]["RUPEES"] += rupees return change @@ -521,6 +521,6 @@ class LinksAwakeningWorld(World): change = super().remove(state, item) if change: rupees = self.rupees.get(item.name, 0) - state.prog_items["RUPEES", item.player] -= rupees + state.prog_items[item.player]["RUPEES"] -= rupees return change diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 0771989ffc..3fe13a3cb4 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -188,6 +188,6 @@ class MessengerWorld(World): shard_count = int(item.name.strip("Time Shard ()")) if remove: shard_count = -shard_count - state.prog_items["Shards", self.player] += shard_count + state.prog_items[self.player]["Shards"] += shard_count return super().collect_item(state, item, remove) diff --git a/worlds/mmbn3/docs/en_MegaMan Battle Network 3.md b/worlds/mmbn3/docs/en_MegaMan Battle Network 3.md index 854034d5a8..7ffa4665fd 100644 --- a/worlds/mmbn3/docs/en_MegaMan Battle Network 3.md +++ b/worlds/mmbn3/docs/en_MegaMan Battle Network 3.md @@ -72,3 +72,10 @@ what item and what player is receiving the item Whenever you have an item pending, the next time you are not in a battle, menu, or dialog box, you will receive a message on screen notifying you of the item and sender, and the item will be added directly to your inventory. + +## Unique Local Commands + +The following commands are only available when using the MMBN3Client to play with Archipelago. + +- `/gba` Check GBA Connection State +- `/debug` Toggle the Debug Text overlay in ROM diff --git a/worlds/oot/Patches.py b/worlds/oot/Patches.py index f83b34183c..0f1d3f4dcb 100644 --- a/worlds/oot/Patches.py +++ b/worlds/oot/Patches.py @@ -2182,7 +2182,7 @@ def patch_rom(world, rom): 'Shadow Temple': ("the \x05\x45Shadow Temple", 'Bongo Bongo', 0x7f, 0xa3), } for dungeon in world.dungeon_mq: - if dungeon in ['Gerudo Training Ground', 'Ganons Castle']: + if dungeon in ['Thieves Hideout', 'Gerudo Training Ground', 'Ganons Castle']: pass elif dungeon in ['Bottom of the Well', 'Ice Cavern']: dungeon_name, boss_name, compass_id, map_id = dungeon_list[dungeon] diff --git a/worlds/oot/Rules.py b/worlds/oot/Rules.py index 3da3728c59..529411f6fc 100644 --- a/worlds/oot/Rules.py +++ b/worlds/oot/Rules.py @@ -227,7 +227,8 @@ def set_shop_rules(ootworld): # The goal is to automatically set item rules based on age requirements in case entrances were shuffled def set_entrances_based_rules(ootworld): - all_state = ootworld.multiworld.get_all_state(False) + all_state = ootworld.get_state_with_complete_itempool() + all_state.sweep_for_events(locations=ootworld.get_locations()) for location in filter(lambda location: location.type == 'Shop', ootworld.get_locations()): # If a shop is not reachable as adult, it can't have Goron Tunic or Zora Tunic as child can't buy these diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 865ad12545..e9c889d6f6 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -829,8 +829,8 @@ class OOTWorld(World): # Kill unreachable events that can't be gotten even with all items # Make sure to only kill actual internal events, not in-game "events" all_state = self.get_state_with_complete_itempool() - all_state.sweep_for_events() all_locations = self.get_locations() + all_state.sweep_for_events(locations=all_locations) reachable = self.multiworld.get_reachable_locations(all_state, self.player) unreachable = [loc for loc in all_locations if (loc.internal or loc.type == 'Drop') and loc.event and loc.locked and loc not in reachable] @@ -858,7 +858,7 @@ class OOTWorld(World): state = base_state.copy() for item in self.get_pre_fill_items(): self.collect(state, item) - state.sweep_for_events(self.get_locations()) + state.sweep_for_events(locations=self.get_locations()) return state # Prefill shops, songs, and dungeon items @@ -870,7 +870,7 @@ class OOTWorld(World): state = CollectionState(self.multiworld) for item in self.itempool: self.collect(state, item) - state.sweep_for_events(self.get_locations()) + state.sweep_for_events(locations=self.get_locations()) # Place dungeon items special_fill_types = ['GanonBossKey', 'BossKey', 'SmallKey', 'HideoutSmallKey', 'Map', 'Compass'] @@ -1260,16 +1260,16 @@ class OOTWorld(World): def collect(self, state: CollectionState, item: OOTItem) -> bool: if item.advancement and item.special and item.special.get('alias', False): alt_item_name, count = item.special.get('alias') - state.prog_items[alt_item_name, self.player] += count + state.prog_items[self.player][alt_item_name] += count return True return super().collect(state, item) def remove(self, state: CollectionState, item: OOTItem) -> bool: if item.advancement and item.special and item.special.get('alias', False): alt_item_name, count = item.special.get('alias') - state.prog_items[alt_item_name, self.player] -= count - if state.prog_items[alt_item_name, self.player] < 1: - del (state.prog_items[alt_item_name, self.player]) + state.prog_items[self.player][alt_item_name] -= count + if state.prog_items[self.player][alt_item_name] < 1: + del (state.prog_items[self.player][alt_item_name]) return True return super().remove(state, item) diff --git a/worlds/oot/docs/en_Ocarina of Time.md b/worlds/oot/docs/en_Ocarina of Time.md index b4610878b6..fa8e148957 100644 --- a/worlds/oot/docs/en_Ocarina of Time.md +++ b/worlds/oot/docs/en_Ocarina of Time.md @@ -31,3 +31,10 @@ Items belonging to other worlds are represented by the Zelda's Letter item. When the player receives an item, Link will hold the item above his head and display it to the world. It's good for business! + +## Unique Local Commands + +The following commands are only available when using the OoTClient to play with Archipelago. + +- `/n64` Check N64 Connection State +- `/deathlink` Toggle deathlink from client. Overrides default setting. diff --git a/worlds/pokemon_rb/__init__.py b/worlds/pokemon_rb/__init__.py index 6b8008ea0f..b2ee0702c9 100644 --- a/worlds/pokemon_rb/__init__.py +++ b/worlds/pokemon_rb/__init__.py @@ -463,13 +463,17 @@ class PokemonRedBlueWorld(World): locs = {self.multiworld.get_location("Fossil - Choice A", self.player), self.multiworld.get_location("Fossil - Choice B", self.player)} - for loc in locs: + if not self.multiworld.key_items_only[self.player]: + rule = None if self.multiworld.fossil_check_item_types[self.player] == "key_items": - add_item_rule(loc, lambda i: i.advancement) + rule = lambda i: i.advancement elif self.multiworld.fossil_check_item_types[self.player] == "unique_items": - add_item_rule(loc, lambda i: i.name in item_groups["Unique"]) + rule = lambda i: i.name in item_groups["Unique"] elif self.multiworld.fossil_check_item_types[self.player] == "no_key_items": - add_item_rule(loc, lambda i: not i.advancement) + rule = lambda i: not i.advancement + if rule: + for loc in locs: + add_item_rule(loc, rule) for mon in ([" ".join(self.multiworld.get_location( f"Oak's Lab - Starter {i}", self.player).item.name.split(" ")[1:]) for i in range(1, 4)] diff --git a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md index daefd6b2f7..086ec347f3 100644 --- a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md +++ b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md @@ -80,3 +80,9 @@ All items for other games will display simply as "AP ITEM," including those for A "received item" sound effect will play. Currently, there is no in-game message informing you of what the item is. If you are in battle, have menus or text boxes opened, or scripted events are occurring, the items will not be given to you until these have ended. + +## Unique Local Commands + +The following command is only available when using the PokemonClient to play with Archipelago. + +- `/gb` Check Gameboy Connection State diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py index 79739e85ef..0ed0a87b17 100644 --- a/worlds/ror2/Options.py +++ b/worlds/ror2/Options.py @@ -16,7 +16,7 @@ class Goal(Choice): display_name = "Game Mode" option_classic = 0 option_explore = 1 - default = 0 + default = 1 class TotalLocations(Range): @@ -48,7 +48,8 @@ class ScavengersPerEnvironment(Range): display_name = "Scavenger per Environment" range_start = 0 range_end = 1 - default = 1 + default = 0 + class ScannersPerEnvironment(Range): """Explore Mode: The number of scanners locations per environment.""" @@ -57,6 +58,7 @@ class ScannersPerEnvironment(Range): range_end = 1 default = 1 + class AltarsPerEnvironment(Range): """Explore Mode: The number of altars locations per environment.""" display_name = "Newts Per Environment" @@ -64,6 +66,7 @@ class AltarsPerEnvironment(Range): range_end = 2 default = 1 + class TotalRevivals(Range): """Total Percentage of `Dio's Best Friend` item put in the item pool.""" display_name = "Total Revives as percentage" @@ -83,6 +86,7 @@ class ItemPickupStep(Range): range_end = 5 default = 1 + class ShrineUseStep(Range): """ Explore Mode: @@ -131,7 +135,6 @@ class DLC_SOTV(Toggle): display_name = "Enable DLC - SOTV" - class GreenScrap(Range): """Weight of Green Scraps in the item pool. @@ -274,25 +277,8 @@ class ItemWeights(Choice): option_void = 9 - - -# define a class for the weights of the generated item pool. @dataclass -class ROR2Weights: - green_scrap: GreenScrap - red_scrap: RedScrap - yellow_scrap: YellowScrap - white_scrap: WhiteScrap - common_item: CommonItem - uncommon_item: UncommonItem - legendary_item: LegendaryItem - boss_item: BossItem - lunar_item: LunarItem - void_item: VoidItem - equipment: Equipment - -@dataclass -class ROR2Options(PerGameCommonOptions, ROR2Weights): +class ROR2Options(PerGameCommonOptions): goal: Goal total_locations: TotalLocations chests_per_stage: ChestsPerEnvironment @@ -310,4 +296,16 @@ class ROR2Options(PerGameCommonOptions, ROR2Weights): shrine_use_step: ShrineUseStep enable_lunar: AllowLunarItems item_weights: ItemWeights - item_pool_presets: ItemPoolPresetToggle \ No newline at end of file + item_pool_presets: ItemPoolPresetToggle + # define the weights of the generated item pool. + green_scrap: GreenScrap + red_scrap: RedScrap + yellow_scrap: YellowScrap + white_scrap: WhiteScrap + common_item: CommonItem + uncommon_item: UncommonItem + legendary_item: LegendaryItem + boss_item: BossItem + lunar_item: LunarItem + void_item: VoidItem + equipment: Equipment diff --git a/worlds/sc2wol/docs/en_Starcraft 2 Wings of Liberty.md b/worlds/sc2wol/docs/en_Starcraft 2 Wings of Liberty.md index f7c8519a2a..18bda64784 100644 --- a/worlds/sc2wol/docs/en_Starcraft 2 Wings of Liberty.md +++ b/worlds/sc2wol/docs/en_Starcraft 2 Wings of Liberty.md @@ -31,4 +31,24 @@ The goal is to beat the final mission: 'All In'. The config file determines whic By default, any of StarCraft 2's items (specified above) can be in another player's world. See the [Advanced YAML Guide](https://archipelago.gg/tutorial/Archipelago/advanced_settings/en) -for more information on how to change this. \ No newline at end of file +for more information on how to change this. + +## Unique Local Commands + +The following commands are only available when using the Starcraft 2 Client to play with Archipelago. + +- `/difficulty [difficulty]` Overrides the difficulty set for the world. + - Options: casual, normal, hard, brutal +- `/game_speed [game_speed]` Overrides the game speed for the world + - Options: default, slower, slow, normal, fast, faster +- `/color [color]` Changes your color (Currently has no effect) + - Options: white, red, blue, teal, purple, yellow, orange, green, lightpink, violet, lightgrey, darkgreen, brown, + lightgreen, darkgrey, pink, rainbow, random, default +- `/disable_mission_check` Disables the check to see if a mission is available to play. Meant for co-op runs where one + player can play the next mission in a chain the other player is doing. +- `/play [mission_id]` Starts a Starcraft 2 mission based off of the mission_id provided +- `/available` Get what missions are currently available to play +- `/unfinished` Get what missions are currently available to play and have not had all locations checked +- `/set_path [path]` Menually set the SC2 install directory (if the automatic detection fails) +- `/download_data` Download the most recent release of the necassry files for playing SC2 with Archipelago. Will + overwrite existing files diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 4b4002c1c8..3e9015eab7 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -112,15 +112,12 @@ class SMWorld(World): required_client_version = (0, 2, 6) itemManager: ItemManager - spheres = None Logic.factory('vanilla') def __init__(self, world: MultiWorld, player: int): self.rom_name_available_event = threading.Event() self.locations = {} - if SMWorld.spheres != None: - SMWorld.spheres = None super().__init__(world, player) @classmethod @@ -368,7 +365,7 @@ class SMWorld(World): locationsDict[first_local_collected_loc.name]), itemLoc.item.player, True) - for itemLoc in SMWorld.spheres if itemLoc.item.player == self.player and (not progression_only or itemLoc.item.advancement) + for itemLoc in spheres if itemLoc.item.player == self.player and (not progression_only or itemLoc.item.advancement) ] # Having a sorted itemLocs from collection order is required for escapeTrigger when Tourian is Disabled. @@ -376,8 +373,10 @@ class SMWorld(World): # get_spheres could be cached in multiworld? # Another possible solution would be to have a globally accessible list of items in the order in which the get placed in push_item # and use the inversed starting from the first progression item. - if (SMWorld.spheres == None): - SMWorld.spheres = [itemLoc for sphere in self.multiworld.get_spheres() for itemLoc in sorted(sphere, key=lambda location: location.name)] + spheres: List[Location] = getattr(self.multiworld, "_sm_spheres", None) + if spheres is None: + spheres = [itemLoc for sphere in self.multiworld.get_spheres() for itemLoc in sorted(sphere, key=lambda location: location.name)] + setattr(self.multiworld, "_sm_spheres", spheres) self.itemLocs = [ ItemLocation(copy.copy(ItemManager.Items[itemLoc.item.type @@ -390,7 +389,7 @@ class SMWorld(World): escapeTrigger = None if self.variaRando.randoExec.randoSettings.restrictions["EscapeTrigger"]: #used to simulate received items - first_local_collected_loc = next(itemLoc for itemLoc in SMWorld.spheres if itemLoc.player == self.player) + first_local_collected_loc = next(itemLoc for itemLoc in spheres if itemLoc.player == self.player) playerItemsItemLocs = get_player_ItemLocation(False) playerProgItemsItemLocs = get_player_ItemLocation(True) diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index e2eb2ac80a..2cc2ac97d9 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -470,7 +470,7 @@ class SMZ3World(World): def collect(self, state: CollectionState, item: Item) -> bool: state.smz3state[self.player].Add([TotalSMZ3Item.Item(TotalSMZ3Item.ItemType[item.name], self.smz3World if hasattr(self, "smz3World") else None)]) if item.advancement: - state.prog_items[item.name, item.player] += 1 + state.prog_items[item.player][item.name] += 1 return True # indicate that a logical state change has occured return False @@ -478,9 +478,9 @@ class SMZ3World(World): name = self.collect_item(state, item, True) if name: state.smz3state[item.player].Remove([TotalSMZ3Item.Item(TotalSMZ3Item.ItemType[item.name], self.smz3World if hasattr(self, "smz3World") else None)]) - state.prog_items[name, item.player] -= 1 - if state.prog_items[name, item.player] < 1: - del (state.prog_items[name, item.player]) + state.prog_items[item.player][item.name] -= 1 + if state.prog_items[item.player][item.name] < 1: + del (state.prog_items[item.player][item.name]) return True return False diff --git a/worlds/stardew_valley/test/TestRules.py b/worlds/stardew_valley/test/TestRules.py index 0847d8a63b..72337812cd 100644 --- a/worlds/stardew_valley/test/TestRules.py +++ b/worlds/stardew_valley/test/TestRules.py @@ -24,7 +24,7 @@ class TestProgressiveToolsLogic(SVTestBase): def setUp(self): super().setUp() - self.multiworld.state.prog_items = Counter() + self.multiworld.state.prog_items = {1: Counter()} def test_sturgeon(self): self.assertFalse(self.world.logic.has("Sturgeon")(self.multiworld.state)) diff --git a/worlds/terraria/docs/setup_en.md b/worlds/terraria/docs/setup_en.md index 84744a4a33..b69af591fa 100644 --- a/worlds/terraria/docs/setup_en.md +++ b/worlds/terraria/docs/setup_en.md @@ -31,6 +31,8 @@ highly recommended to use utility mods and features to speed up gameplay, such a - (Can be used to break progression) - Reduced Grinding - Upgraded Research + - (WARNING: Do not use without Journey mode) + - (NOTE: If items you pick up aren't showing up in your inventory, check your research menu. This mod automatically researches certain items.) ## Configuring your YAML File diff --git a/worlds/tloz/docs/en_The Legend of Zelda.md b/worlds/tloz/docs/en_The Legend of Zelda.md index e443c9b953..7c2e6deda5 100644 --- a/worlds/tloz/docs/en_The Legend of Zelda.md +++ b/worlds/tloz/docs/en_The Legend of Zelda.md @@ -35,9 +35,17 @@ filler and useful items will cost less, and uncategorized items will be in the m ## Are there any other changes made? -- The map and compass for each dungeon start already acquired, and other items can be found in their place. +- The map and compass for each dungeon start already acquired, and other items can be found in their place. - The Recorder will warp you between all eight levels regardless of Triforce count - - It's possible for this to be your route to level 4! + - It's possible for this to be your route to level 4! - Pressing Select will cycle through your inventory. - Shop purchases are tracked within sessions, indicated by the item being elevated from its normal position. -- What slots from a Take Any Cave have been chosen are similarly tracked. \ No newline at end of file +- What slots from a Take Any Cave have been chosen are similarly tracked. +- + +## Local Unique Commands + +The following commands are only available when using the Zelda1Client to play with Archipelago. + +- `/nes` Check NES Connection State +- `/toggle_msgs` Toggle displaying messages in EmuHawk diff --git a/worlds/undertale/docs/en_Undertale.md b/worlds/undertale/docs/en_Undertale.md index 3905d3bc3e..87011ee16b 100644 --- a/worlds/undertale/docs/en_Undertale.md +++ b/worlds/undertale/docs/en_Undertale.md @@ -42,11 +42,22 @@ In the Pacifist run, you are not required to go to the Ruins to spare Toriel. Th Undyne, and Mettaton EX. Just as it is in the vanilla game, you cannot kill anyone. You are also required to complete the date/hangout with Papyrus, Undyne, and Alphys, in that order, before entering the True Lab. -Additionally, custom items are required to hang out with Papyrus, Undyne, to enter the True Lab, and to fight -Mettaton EX/NEO. The respective items for each interaction are `Complete Skeleton`, `Fish`, `DT Extractor`, +Additionally, custom items are required to hang out with Papyrus, Undyne, to enter the True Lab, and to fight +Mettaton EX/NEO. The respective items for each interaction are `Complete Skeleton`, `Fish`, `DT Extractor`, and `Mettaton Plush`. -The Riverperson will only take you to locations you have seen them at, meaning they will only take you to +The Riverperson will only take you to locations you have seen them at, meaning they will only take you to Waterfall if you have seen them at Waterfall at least once. -If you press `W` while in the save menu, you will teleport back to the flower room, for quick access to the other areas. \ No newline at end of file +If you press `W` while in the save menu, you will teleport back to the flower room, for quick access to the other areas. + +## Unique Local Commands + +The following commands are only available when using the UndertaleClient to play with Archipelago. + +- `/resync` Manually trigger a resync. +- `/patch` Patch the game. +- `/savepath` Redirect to proper save data folder. (Use before connecting!) +- `/auto_patch` Patch the game automatically. +- `/online` Makes you no longer able to see other Undertale players. +- `/deathlink` Toggles deathlink diff --git a/worlds/wargroove/docs/en_Wargroove.md b/worlds/wargroove/docs/en_Wargroove.md index 18474a4269..f08902535d 100644 --- a/worlds/wargroove/docs/en_Wargroove.md +++ b/worlds/wargroove/docs/en_Wargroove.md @@ -26,9 +26,16 @@ Any of the above items can be in another player's world. ## When the player receives an item, what happens? -When the player receives an item, a message will appear in Wargroove with the item name and sender name, once an action +When the player receives an item, a message will appear in Wargroove with the item name and sender name, once an action is taken in game. ## What is the goal of this game when randomized? The goal is to beat the level titled `The End` by finding the `Final Bridges`, `Final Walls`, and `Final Sickle`. + +## Unique Local Commands + +The following commands are only available when using the WargrooveClient to play with Archipelago. + +- `/resync` Manually trigger a resync. +- `/commander` Set the current commander to the given commander. diff --git a/worlds/zillion/docs/en_Zillion.md b/worlds/zillion/docs/en_Zillion.md index b5d37cc202..06a11b7d79 100644 --- a/worlds/zillion/docs/en_Zillion.md +++ b/worlds/zillion/docs/en_Zillion.md @@ -67,8 +67,16 @@ Note that in "restrictive" mode, Champ is the only one that can get Zillion powe Canisters retain their original appearance, so you won't know if an item belongs to another player until you collect it. -When you collect an item, you see the name of the player it goes to. You can see in the client log what item was collected. +When you collect an item, you see the name of the player it goes to. You can see in the client log what item was +collected. ## When the player receives an item, what happens? The item collect sound is played. You can see in the client log what item was received. + +## Unique Local Commands + +The following commands are only available when using the ZillionClient to play with Archipelago. + +- `/sms` Tell the client that Zillion is running in RetroArch. +- `/map` Toggle view of the map tracker.