diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c51f155049..d4e1efd466 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,20 @@ name: Build -on: workflow_dispatch +on: + push: + paths: + - '.github/workflows/build.yml' + - 'setup.py' + - 'requirements.txt' + pull_request: + paths: + - '.github/workflows/build.yml' + - 'setup.py' + - 'requirements.txt' + workflow_dispatch: env: - SNI_VERSION: v0.0.88 ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 @@ -15,15 +25,13 @@ jobs: build-win-py38: # RCs will still be built and signed by hand runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.8' - name: Download run-time dependencies run: | - Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip - Expand-Archive -Path sni.zip -DestinationPath SNI -Force Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force - name: Build @@ -39,7 +47,7 @@ jobs: Rename-Item exe.$NAME Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago - name: Store 7z - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ${{ env.ZIP_NAME }} path: dist/${{ env.ZIP_NAME }} @@ -49,14 +57,14 @@ jobs: runs-on: ubuntu-18.04 steps: # - copy code below to release.yml - - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install build-time dependencies @@ -69,10 +77,6 @@ jobs: chmod a+rx appimagetool - name: Download run-time dependencies run: | - wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz - tar xf sni-*.tar.xz - rm sni-*.tar.xz - mv sni-* SNI wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z - name: Build @@ -93,13 +97,13 @@ jobs: echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - copy code above to release.yml - - name: Store AppImage - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }} retention-days: 7 - name: Store .tar.gz - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b331c25506..6aeb477a22 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,9 +14,17 @@ name: "CodeQL" on: push: branches: [ main ] + paths: + - '**.py' + - '**.js' + - '.github/workflows/codeql-analysis.yml' pull_request: # The branches below must be a subset of the branches above branches: [ main ] + paths: + - '**.py' + - '**.js' + - '.github/workflows/codeql-analysis.yml' schedule: - cron: '44 8 * * 1' @@ -35,11 +43,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -50,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -64,4 +72,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 28adb50026..7ecda45eda 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,7 +3,13 @@ name: lint -on: [push, pull_request] +on: + push: + paths: + - '**.py' + pull_request: + paths: + - '**.py' jobs: build: @@ -11,9 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.9 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9559f7856..fa3dd32100 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,6 @@ on: - '*.*.*' env: - SNI_VERSION: v0.0.88 ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 @@ -36,14 +35,14 @@ jobs: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # - code below copied from build.yml - - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install build-time dependencies @@ -56,10 +55,6 @@ jobs: chmod a+rx appimagetool - name: Download run-time dependencies run: | - wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz - tar xf sni-*.tar.xz - rm sni-*.tar.xz - mv sni-* SNI wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z - name: Build diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index c86d637243..93be745a8c 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -3,7 +3,25 @@ name: unittests -on: [push, pull_request] +on: + push: + paths: + - '**' + - '!docs/**' + - '!setup.py' + - '!*.iss' + - '!.gitignore' + - '!.github/workflows/**' + - '.github/workflows/unittests.yml' + pull_request: + paths: + - '**' + - '!docs/**' + - '!setup.py' + - '!*.iss' + - '!.gitignore' + - '!.github/workflows/**' + - '.github/workflows/unittests.yml' jobs: build: @@ -23,11 +41,13 @@ jobs: os: windows-latest - python: {version: '3.10'} # current os: windows-latest + - python: {version: '3.10'} # current + os: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python.version }} - name: Install dependencies diff --git a/.gitignore b/.gitignore index e269202db9..4a9f3402a9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.apm3 *.apmc *.apz5 +*.aptloz *.pyc *.pyd *.sfc @@ -138,6 +139,7 @@ ENV/ env.bak/ venv.bak/ .code-workspace +shell.nix # Spyder project settings .spyderproject @@ -167,6 +169,7 @@ cython_debug/ jdk*/ minecraft*/ minecraft_versions.json +!worlds/minecraft/ # pyenv .python-version diff --git a/BaseClasses.py b/BaseClasses.py index e30dbd3296..d3e7c8db95 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -2,14 +2,13 @@ from __future__ import annotations import copy import functools -import json import logging import random import secrets import typing # this can go away when Python 3.8 support is dropped from argparse import Namespace -from collections import OrderedDict, Counter, deque -from enum import unique, IntEnum, IntFlag +from collections import OrderedDict, Counter, deque, ChainMap +from enum import IntEnum, IntFlag from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple import NetUtils @@ -73,6 +72,11 @@ class MultiWorld(): completion_condition: Dict[int, Callable[[CollectionState], bool]] indirect_connections: Dict[Region, Set[Entrance]] exclude_locations: Dict[int, Options.ExcludeLocations] + priority_locations: Dict[int, Options.PriorityLocations] + start_inventory: Dict[int, Options.StartInventory] + start_hints: Dict[int, Options.StartHints] + start_location_hints: Dict[int, Options.StartLocationHints] + item_links: Dict[int, Options.ItemLinks] game: Dict[int, str] @@ -761,169 +765,9 @@ class CollectionState(): found += self.prog_items[item_name, player] return found - def can_buy_unlimited(self, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for - shop in self.multiworld.shops) - - def can_buy(self, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for - shop in self.multiworld.shops) - def item_count(self, item: str, player: int) -> int: return self.prog_items[item, player] - def has_triforce_pieces(self, count: int, player: int) -> bool: - return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count - - def has_crystals(self, count: int, player: int) -> bool: - found: int = 0 - for crystalnumber in range(1, 8): - found += self.prog_items[f"Crystal {crystalnumber}", player] - if found >= count: - return True - return False - - def can_lift_rocks(self, player: int): - return self.has('Power Glove', player) or self.has('Titans Mitts', player) - - def bottle_count(self, player: int) -> int: - return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit, - self.count_group("Bottles", player)) - - def has_hearts(self, player: int, count: int) -> int: - # Warning: This only considers items that are marked as advancement items - return self.heart_count(player) >= count - - def heart_count(self, player: int) -> int: - # Warning: This only considers items that are marked as advancement items - diff = self.multiworld.difficulty_requirements[player] - return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ - + self.item_count('Sanctuary Heart Container', player) \ - + min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ - + 3 # starting hearts - - def can_lift_heavy_rocks(self, player: int) -> bool: - return self.has('Titans Mitts', player) - - def can_extend_magic(self, player: int, smallmagic: int = 16, - fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has. - basemagic = 8 - if self.has('Magic Upgrade (1/4)', player): - basemagic = 32 - elif self.has('Magic Upgrade (1/2)', player): - basemagic = 16 - if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player): - if self.multiworld.item_functionality[player] == 'hard' and not fullrefill: - basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player)) - elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill: - basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player)) - else: - basemagic = basemagic + basemagic * self.bottle_count(player) - return basemagic >= smallmagic - - def can_kill_most_things(self, player: int, enemies: int = 5) -> bool: - return (self.has_melee_weapon(player) - or self.has('Cane of Somaria', player) - or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player))) - or self.can_shoot_arrows(player) - or self.has('Fire Rod', player) - or (self.has('Bombs (10)', player) and enemies < 6)) - - def can_shoot_arrows(self, player: int) -> bool: - if self.multiworld.retro_bow[player]: - return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player) - return self.has('Bow', player) or self.has('Silver Bow', player) - - def can_get_good_bee(self, player: int) -> bool: - cave = self.multiworld.get_region('Good Bee Cave', player) - return ( - self.has_group("Bottles", player) and - self.has('Bug Catching Net', player) and - (self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and - cave.can_reach(self) and - self.is_not_bunny(cave, player) - ) - - def can_retrieve_tablet(self, player: int) -> bool: - return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or - (self.multiworld.swordless[player] and - self.has("Hammer", player))) - - def has_sword(self, player: int) -> bool: - return self.has('Fighter Sword', player) \ - or self.has('Master Sword', player) \ - or self.has('Tempered Sword', player) \ - or self.has('Golden Sword', player) - - def has_beam_sword(self, player: int) -> bool: - return self.has('Master Sword', player) or self.has('Tempered Sword', player) or self.has('Golden Sword', - player) - - def has_melee_weapon(self, player: int) -> bool: - return self.has_sword(player) or self.has('Hammer', player) - - def has_fire_source(self, player: int) -> bool: - return self.has('Fire Rod', player) or self.has('Lamp', player) - - def can_melt_things(self, player: int) -> bool: - return self.has('Fire Rod', player) or \ - (self.has('Bombos', player) and - (self.multiworld.swordless[player] or - self.has_sword(player))) - - def can_avoid_lasers(self, player: int) -> bool: - return self.has('Mirror Shield', player) or self.has('Cane of Byrna', player) or self.has('Cape', player) - - def is_not_bunny(self, region: Region, player: int) -> bool: - if self.has('Moon Pearl', player): - return True - - return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world - - def can_reach_light_world(self, player: int) -> bool: - if True in [i.is_light_world for i in self.reachable_regions[player]]: - return True - return False - - def can_reach_dark_world(self, player: int) -> bool: - if True in [i.is_dark_world for i in self.reachable_regions[player]]: - return True - return False - - def has_misery_mire_medallion(self, player: int) -> bool: - return self.has(self.multiworld.required_medallions[player][0], player) - - def has_turtle_rock_medallion(self, player: int) -> bool: - return self.has(self.multiworld.required_medallions[player][1], player) - - def can_boots_clip_lw(self, player: int) -> bool: - if self.multiworld.mode[player] == 'inverted': - return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) - return self.has('Pegasus Boots', player) - - def can_boots_clip_dw(self, player: int) -> bool: - if self.multiworld.mode[player] != 'inverted': - return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) - return self.has('Pegasus Boots', player) - - def can_get_glitched_speed_lw(self, player: int) -> bool: - rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] - if self.multiworld.mode[player] == 'inverted': - rules.append(self.has('Moon Pearl', player)) - return all(rules) - - def can_superbunny_mirror_with_sword(self, player: int) -> bool: - return self.has('Magic Mirror', player) and self.has_sword(player) - - def can_get_glitched_speed_dw(self, player: int) -> bool: - rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] - if self.multiworld.mode[player] != 'inverted': - rules.append(self.has('Moon Pearl', player)) - return all(rules) - - def can_bomb_clip(self, region: Region, player: int) -> bool: - return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player) - def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool: if location: self.locations_checked.add(location) @@ -959,12 +803,6 @@ class Region: exits: List[Entrance] locations: List[Location] dungeon: Optional[Dungeon] = None - shop: Optional = None - - # LttP specific. TODO: move to a LttPRegion - # will be set after making connections. - is_light_world: bool = False - is_dark_world: bool = False def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name @@ -1129,7 +967,7 @@ class Location: self.parent_region = parent def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: - return (self.always_allow(state, item) + return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player]) or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful)) and self.item_rule(item) and (not check_access or self.can_reach(state)))) @@ -1261,13 +1099,9 @@ class Spoiler(): self.multiworld = world self.hashes = {} self.entrances = OrderedDict() - self.medallions = {} self.playthrough = {} self.unreachables = set() - self.locations = {} self.paths = {} - self.shops = [] - self.bosses = OrderedDict() def set_entrance(self, entrance: str, exit_: str, direction: str, player: int): if self.multiworld.players == 1: @@ -1277,126 +1111,6 @@ class Spoiler(): self.entrances[(entrance, direction, player)] = OrderedDict( [('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)]) - def parse_data(self): - from worlds.alttp.SubClasses import LTTPRegionType - self.medallions = OrderedDict() - for player in self.multiworld.get_game_players("A Link to the Past"): - self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \ - self.multiworld.required_medallions[player][0] - self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \ - self.multiworld.required_medallions[player][1] - - self.locations = OrderedDict() - listed_locations = set() - lw_locations = [] - dw_locations = [] - cave_locations = [] - for loc in self.multiworld.get_locations(): - if loc.game == "A Link to the Past": - if loc not in listed_locations and loc.parent_region and \ - loc.parent_region.type == LTTPRegionType.LightWorld and loc.show_in_spoiler: - lw_locations.append(loc) - elif loc not in listed_locations and loc.parent_region and \ - loc.parent_region.type == LTTPRegionType.DarkWorld and loc.show_in_spoiler: - dw_locations.append(loc) - elif loc not in listed_locations and loc.parent_region and \ - loc.parent_region.type == LTTPRegionType.Cave and loc.show_in_spoiler: - cave_locations.append(loc) - - self.locations['Light World'] = OrderedDict( - [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in - lw_locations]) - listed_locations.update(lw_locations) - - self.locations['Dark World'] = OrderedDict( - [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in - dw_locations]) - listed_locations.update(dw_locations) - - self.locations['Caves'] = OrderedDict( - [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in - cave_locations]) - listed_locations.update(cave_locations) - - for dungeon in self.multiworld.dungeons.values(): - dungeon_locations = [loc for loc in self.multiworld.get_locations() if - loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler] - self.locations[str(dungeon)] = OrderedDict( - [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in - dungeon_locations]) - listed_locations.update(dungeon_locations) - - other_locations = [loc for loc in self.multiworld.get_locations() if - loc not in listed_locations and loc.show_in_spoiler] - if other_locations: - self.locations['Other Locations'] = OrderedDict( - [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in - other_locations]) - listed_locations.update(other_locations) - - self.shops = [] - from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display - for shop in self.multiworld.shops: - if not shop.custom: - continue - shopdata = { - 'location': str(shop.region), - 'type': 'Take Any' if shop.type == ShopType.TakeAny else 'Shop' - } - for index, item in enumerate(shop.inventory): - if item is None: - continue - my_price = item['price'] // price_rate_display.get(item['price_type'], 1) - shopdata['item_{}'.format( - index)] = f"{item['item']} — {my_price} {price_type_display_name[item['price_type']]}" - - if item['player'] > 0: - shopdata['item_{}'.format(index)] = shopdata['item_{}'.format(index)].replace('—', - '(Player {}) — '.format( - item['player'])) - - if item['max'] == 0: - continue - shopdata['item_{}'.format(index)] += " x {}".format(item['max']) - - if item['replacement'] is None: - continue - shopdata['item_{}'.format( - index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}" - self.shops.append(shopdata) - - for player in self.multiworld.get_game_players("A Link to the Past"): - self.bosses[str(player)] = OrderedDict() - self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name - self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name - self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name - self.bosses[str(player)]["Hyrule Castle"] = "Agahnim" - self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness", - player).boss.name - self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name - self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name - self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name - self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name - self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name - self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name - if self.multiworld.mode[player] != 'inverted': - self.bosses[str(player)]["Ganons Tower Basement"] = \ - self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name - self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[ - 'middle'].name - self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[ - 'top'].name - else: - self.bosses[str(player)]["Ganons Tower Basement"] = \ - self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name - self.bosses[str(player)]["Ganons Tower Middle"] = \ - self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name - self.bosses[str(player)]["Ganons Tower Top"] = \ - self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name - - self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2" - self.bosses[str(player)]["Ganon"] = "Ganon" - def create_playthrough(self, create_paths: bool = True): """Destructive to the world while it is run, damage gets repaired afterwards.""" from itertools import chain @@ -1548,35 +1262,12 @@ class Spoiler(): self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \ get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player)) - def to_json(self): - self.parse_data() - out = OrderedDict() - out['Entrances'] = list(self.entrances.values()) - out.update(self.locations) - out['Special'] = self.medallions - if self.hashes: - out['Hashes'] = self.hashes - if self.shops: - out['Shops'] = self.shops - out['playthrough'] = self.playthrough - out['paths'] = self.paths - out['Bosses'] = self.bosses - - return json.dumps(out) - def to_file(self, filename: str): - self.parse_data() - - def bool_to_text(variable: Union[bool, str]) -> str: - if type(variable) == str: - return variable - return 'Yes' if variable else 'No' - def write_option(option_key: str, option_obj: type(Options.Option)): res = getattr(self.multiworld, option_key)[player] display_name = getattr(option_obj, "display_name", option_key) try: - outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') + outfile.write(f'{display_name + ":":33}{res.current_option_name}\n') except: raise Exception @@ -1593,46 +1284,13 @@ class Spoiler(): if self.multiworld.players > 1: outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - for f_option, option in Options.per_game_common_options.items(): + + options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions) + for f_option, option in options.items(): write_option(f_option, option) - options = self.multiworld.worlds[player].option_definitions - if options: - for f_option, option in options.items(): - write_option(f_option, option) + AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) - if player in self.multiworld.get_game_players("A Link to the Past"): - outfile.write('%s%s\n' % ('Hash: ', self.hashes[player])) - - outfile.write('Logic: %s\n' % self.multiworld.logic[player]) - outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player]) - outfile.write('Mode: %s\n' % self.multiworld.mode[player]) - outfile.write('Goal: %s\n' % self.multiworld.goal[player]) - if "triforce" in self.multiworld.goal[player]: # triforce hunt - outfile.write("Pieces available for Triforce: %s\n" % - self.multiworld.triforce_pieces_available[player]) - outfile.write("Pieces required for Triforce: %s\n" % - self.multiworld.triforce_pieces_required[player]) - outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player]) - outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player]) - outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player]) - if self.multiworld.shuffle[player] != "vanilla": - outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed) - outfile.write('Shop inventory shuffle: %s\n' % - bool_to_text("i" in self.multiworld.shop_shuffle[player])) - outfile.write('Shop price shuffle: %s\n' % - bool_to_text("p" in self.multiworld.shop_shuffle[player])) - outfile.write('Shop upgrade shuffle: %s\n' % - bool_to_text("u" in self.multiworld.shop_shuffle[player])) - outfile.write('New Shop inventory: %s\n' % - bool_to_text("g" in self.multiworld.shop_shuffle[player] or - "f" in self.multiworld.shop_shuffle[player])) - outfile.write('Custom Potion Shop: %s\n' % - bool_to_text("w" in self.multiworld.shop_shuffle[player])) - outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player]) - outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player]) - outfile.write('Prize shuffle %s\n' % - self.multiworld.shuffle_prizes[player]) if self.entrances: outfile.write('\n\nEntrances:\n\n') outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: ' @@ -1641,30 +1299,14 @@ class Spoiler(): '<=' if entry['direction'] == 'exit' else '=>', entry['exit']) for entry in self.entrances.values()])) - if self.medallions: - outfile.write('\n\nMedallions:\n') - for dungeon, medallion in self.medallions.items(): - outfile.write(f'\n{dungeon}: {medallion}') - AutoWorld.call_all(self.multiworld, "write_spoiler", outfile) + locations = [(str(location), str(location.item) if location.item is not None else "Nothing") + for location in self.multiworld.get_locations() if location.show_in_spoiler] outfile.write('\n\nLocations:\n\n') outfile.write('\n'.join( - ['%s: %s' % (location, item) for grouping in self.locations.values() for (location, item) in - grouping.items()])) + ['%s: %s' % (location, item) for location, item in locations])) - if self.shops: - outfile.write('\n\nShops:\n\n') - outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join( - item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if - item)) for shop in self.shops)) - - for player in self.multiworld.get_game_players("A Link to the Past"): - if self.multiworld.boss_shuffle[player] != 'none': - bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses - outfile.write( - f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n') - outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()])) outfile.write('\n\nPlaythrough:\n\n') outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join( [' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [ diff --git a/CommonClient.py b/CommonClient.py index 92f8d76a66..02dd55da98 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -63,7 +63,7 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_received(self) -> bool: """List all received items""" - logger.info(f'{len(self.ctx.items_received)} received items:') + self.output(f'{len(self.ctx.items_received)} received items:') for index, item in enumerate(self.ctx.items_received, 1): self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}") return True diff --git a/Fill.py b/Fill.py index ac3ae8fc6d..92b57af58b 100644 --- a/Fill.py +++ b/Fill.py @@ -840,8 +840,7 @@ def distribute_planned(world: MultiWorld) -> None: maxcount = placement['count']['target'] from_pool = placement['from_pool'] - candidates = list(location for location in world.get_unfilled_locations_for_players(locations, - worlds)) + candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds))) world.random.shuffle(candidates) world.random.shuffle(items) count = 0 diff --git a/Generate.py b/Generate.py index dadabd7ac6..afb34f11c6 100644 --- a/Generate.py +++ b/Generate.py @@ -107,7 +107,7 @@ def main(args=None, callback=ERmain): player_files = {} for file in os.scandir(args.player_files_path): fname = file.name - if file.is_file() and not file.name.startswith(".") and \ + if file.is_file() and not fname.startswith(".") and \ os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}: path = os.path.join(args.player_files_path, fname) try: diff --git a/Launcher.py b/Launcher.py index 7d5b2f7316..c4d9b6fea0 100644 --- a/Launcher.py +++ b/Launcher.py @@ -132,7 +132,8 @@ components: Iterable[Component] = ( Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'), # SNI Component('SNI Client', 'SNIClient', - file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')), + file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', + '.apsmw', '.apl2ac')), Component('LttP Adjuster', 'LttPAdjuster'), # Factorio Component('Factorio Client', 'FactorioClient'), @@ -147,10 +148,14 @@ components: Iterable[Component] = ( Component('FF1 Client', 'FF1Client'), # Pokémon Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')), + # TLoZ + Component('Zelda 1 Client', 'Zelda1Client'), # ChecksFinder Component('ChecksFinder Client', 'ChecksFinderClient'), # Starcraft 2 Component('Starcraft 2 Client', 'Starcraft2Client'), + # Wargroove + Component('Wargroove Client', 'WargrooveClient'), # Zillion Component('Zillion Client', 'ZillionClient', file_identifier=SuffixIdentifier('.apzl')), diff --git a/Main.py b/Main.py index 9bb2cfef9e..5175c29f4f 100644 --- a/Main.py +++ b/Main.py @@ -38,7 +38,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world = MultiWorld(args.multi) logger = logging.getLogger() - world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed)) + world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) world.plando_options = args.plando_options world.shuffle = args.shuffle.copy() @@ -53,7 +53,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.enemy_damage = args.enemy_damage.copy() world.beemizer_total_chance = args.beemizer_total_chance.copy() world.beemizer_trap_chance = args.beemizer_trap_chance.copy() - world.timer = args.timer.copy() world.countdown_start_time = args.countdown_start_time.copy() world.red_clock_time = args.red_clock_time.copy() world.blue_clock_time = args.blue_clock_time.copy() @@ -79,7 +78,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.state = CollectionState(world) logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed) - logger.info("Found World Types:") + logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) max_item = 0 @@ -362,6 +361,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No if game_world.data_version == 0 and game_world.game not in datapackage: datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game] datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups + datapackage[game_world.game]["location_name_groups"] = game_world.location_name_groups multidata = { "slot_data": slot_data, diff --git a/MultiServer.py b/MultiServer.py index f69ce6bc64..40b6582dca 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -163,7 +163,9 @@ class Context: item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') + location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] all_item_and_group_names: typing.Dict[str, typing.Set[str]] + all_location_and_group_names: typing.Dict[str, typing.Set[str]] non_hintable_names: typing.Dict[str, typing.Set[str]] def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, @@ -233,7 +235,9 @@ class Context: # init empty to satisfy linter, I suppose self.gamespackage = {} self.item_name_groups = {} + self.location_name_groups = {} self.all_item_and_group_names = {} + self.all_location_and_group_names = {} self.non_hintable_names = collections.defaultdict(frozenset) self._load_game_data() @@ -245,6 +249,8 @@ class Context: self.item_name_groups = {world_name: world.item_name_groups for world_name, world in worlds.AutoWorldRegister.world_types.items()} + self.location_name_groups = {world_name: world.location_name_groups for world_name, world in + worlds.AutoWorldRegister.world_types.items()} for world_name, world in worlds.AutoWorldRegister.world_types.items(): self.non_hintable_names[world_name] = world.hint_blacklist @@ -256,6 +262,8 @@ class Context: self.location_names[location_id] = location_name self.all_item_and_group_names[game_name] = \ set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) + self.all_location_and_group_names[game_name] = \ + set(game_package["location_name_to_id"]) | set(self.location_name_groups[game_name]) def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None @@ -431,10 +439,14 @@ class Context: logging.info(f"Loading custom datapackage for game {game_name}") self.gamespackage[game_name] = data self.item_name_groups[game_name] = data["item_name_groups"] + self.location_name_groups[game_name] = data["location_name_groups"] del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups + del data["location_name_groups"] self._init_game_data() for game_name, data in self.item_name_groups.items(): self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame] + for game_name, data in self.location_name_groups.items(): + self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame] # saving @@ -1408,7 +1420,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if game not in self.ctx.all_item_and_group_names: self.output("Can't look up item/location for unknown game. Hint for ID instead.") return False - names = self.ctx.location_names_for_game(game) \ + names = self.ctx.all_location_and_group_names[game] \ if for_location else \ self.ctx.all_item_and_group_names[game] hint_name, usable, response = get_intended_text(input_text, names) @@ -1424,6 +1436,11 @@ class ClientMessageProcessor(CommonCommandProcessor): hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) + elif hint_name in self.ctx.location_name_groups[game]: # location group name + hints = [] + for loc_name in self.ctx.location_name_groups[game][hint_name]: + if loc_name in self.ctx.location_names_for_game(game): + hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)) else: # location name hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) diff --git a/Options.py b/Options.py index c01168965b..b7013d84fc 100644 --- a/Options.py +++ b/Options.py @@ -1,5 +1,6 @@ from __future__ import annotations import abc +import logging from copy import deepcopy import math import numbers @@ -9,6 +10,10 @@ import random from schema import Schema, And, Or, Optional from Utils import get_fuzzy_results +if typing.TYPE_CHECKING: + from BaseClasses import PlandoOptions + from worlds.AutoWorld import World + class AssembleOptions(abc.ABCMeta): def __new__(mcs, name, bases, attrs): @@ -95,11 +100,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): supports_weighting = True # filled by AssembleOptions: - name_lookup: typing.Dict[int, str] + name_lookup: typing.Dict[T, str] options: typing.Dict[str, int] def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.get_current_option_name()})" + return f"{self.__class__.__name__}({self.current_option_name})" def __hash__(self) -> int: return hash(self.value) @@ -109,7 +114,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): return self.name_lookup[self.value] def get_current_option_name(self) -> str: - """For display purposes.""" + """Deprecated. use current_option_name instead. TODO remove around 0.4""" + logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated." + f" use current_option_name instead. Worlds should use {self}.current_key")) + return self.current_option_name + + @property + def current_option_name(self) -> str: + """For display purposes. Worlds should be using current_key.""" return self.get_option_name(self.value) @classmethod @@ -131,17 +143,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): ... if typing.TYPE_CHECKING: - from Generate import PlandoOptions - from worlds.AutoWorld import World - - def verify(self, world: World, player_name: str, plando_options: PlandoOptions) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None: pass else: def verify(self, *args, **kwargs) -> None: pass -class FreeText(Option): +class FreeText(Option[str]): """Text option that allows users to enter strings. Needs to be validated by the world or option definition.""" @@ -162,7 +171,7 @@ class FreeText(Option): return cls.from_text(str(data)) @classmethod - def get_option_name(cls, value: T) -> str: + def get_option_name(cls, value: str) -> str: return value @@ -424,6 +433,7 @@ class Choice(NumericOption): class TextChoice(Choice): """Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string""" + value: typing.Union[str, int] def __init__(self, value: typing.Union[str, int]): assert isinstance(value, str) or isinstance(value, int), \ @@ -434,8 +444,7 @@ class TextChoice(Choice): def current_key(self) -> str: if isinstance(self.value, str): return self.value - else: - return self.name_lookup[self.value] + return super().current_key @classmethod def from_text(cls, text: str) -> TextChoice: @@ -450,7 +459,7 @@ class TextChoice(Choice): def get_option_name(cls, value: T) -> str: if isinstance(value, str): return value - return cls.name_lookup[value] + return super().get_option_name(value) def __eq__(self, other: typing.Any): if isinstance(other, self.__class__): @@ -573,12 +582,11 @@ class PlandoBosses(TextChoice, metaclass=BossMeta): def valid_location_name(cls, value: str) -> bool: return value in cls.locations - def verify(self, world, player_name: str, plando_options) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if isinstance(self.value, int): return - from Generate import PlandoOptions + from BaseClasses import PlandoOptions if not(PlandoOptions.bosses & plando_options): - import logging # plando is disabled but plando options were given so pull the option and change it to an int option = self.value.split(";")[-1] self.value = self.options[option] @@ -716,7 +724,7 @@ class VerifyKeys: value: typing.Any @classmethod - def verify_keys(cls, data): + def verify_keys(cls, data: typing.List[str]): if cls.valid_keys: data = set(data) dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data) @@ -725,12 +733,17 @@ class VerifyKeys: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " f"Allowed keys: {cls.valid_keys}.") - def verify(self, world, player_name: str, plando_options) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if self.convert_name_groups and self.verify_item_name: new_value = type(self.value)() # empty container of whatever value is for item_name in self.value: new_value |= world.item_name_groups.get(item_name, {item_name}) self.value = new_value + elif self.convert_name_groups and self.verify_location_name: + new_value = type(self.value)() + for loc_name in self.value: + new_value |= world.location_name_groups.get(loc_name, {loc_name}) + self.value = new_value if self.verify_item_name: for item_name in self.value: if item_name not in world.item_names: @@ -830,7 +843,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys): return item in self.value -local_objective = Toggle # local triforce pieces, local dungeon prizes etc. +class ItemSet(OptionSet): + verify_item_name = True + convert_name_groups = True class Accessibility(Choice): @@ -872,11 +887,6 @@ common_options = { } -class ItemSet(OptionSet): - verify_item_name = True - convert_name_groups = True - - class LocalItems(ItemSet): """Forces these items to be in their native world.""" display_name = "Local Items" @@ -898,22 +908,23 @@ class StartHints(ItemSet): display_name = "Start Hints" -class StartLocationHints(OptionSet): +class LocationSet(OptionSet): + verify_location_name = True + + +class StartLocationHints(LocationSet): """Start with these locations and their item prefilled into the !hint command""" display_name = "Start Location Hints" - verify_location_name = True -class ExcludeLocations(OptionSet): +class ExcludeLocations(LocationSet): """Prevent these locations from having an important item""" display_name = "Excluded Locations" - verify_location_name = True -class PriorityLocations(OptionSet): +class PriorityLocations(LocationSet): """Prevent these locations from having an unimportant item""" display_name = "Priority Locations" - verify_location_name = True class DeathLink(Toggle): @@ -954,7 +965,7 @@ class ItemLinks(OptionList): pool |= {item_name} return pool - def verify(self, world, player_name: str, plando_options) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: link: dict super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() diff --git a/PokemonClient.py b/PokemonClient.py index eb1f124391..e78e76fa00 100644 --- a/PokemonClient.py +++ b/PokemonClient.py @@ -17,7 +17,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP from worlds.pokemon_rb.locations import location_data from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch -location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}} +location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}} location_bytes_bits = {} for location in location_data: if location.ram_address is not None: @@ -40,7 +40,7 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated" DISPLAY_MSGS = True -SCRIPT_VERSION = 1 +SCRIPT_VERSION = 3 class GBCommandProcessor(ClientCommandProcessor): @@ -70,6 +70,8 @@ class GBContext(CommonContext): self.set_deathlink = False self.client_compatibility_mode = 0 self.items_handling = 0b001 + self.sent_release = False + self.sent_collect = False async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -124,7 +126,8 @@ def get_payload(ctx: GBContext): "items": [item.item for item in ctx.items_received], "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() if key[0] > current_time - 10}, - "deathlink": ctx.deathlink_pending + "deathlink": ctx.deathlink_pending, + "options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled')) } ) ctx.deathlink_pending = False @@ -134,10 +137,13 @@ def get_payload(ctx: GBContext): async def parse_locations(data: List, ctx: GBContext): locations = [] flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20], - "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]} + "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], + "Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]} - if len(flags['Rod']) > 1: - return + if len(data) > 0x140 + 0x20 + 0x0E + 0x01: + flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:] + else: + flags["DexSanityFlag"] = [0] * 19 for flag_type, loc_map in location_map.items(): for flag, loc_id in loc_map.items(): @@ -207,6 +213,16 @@ async def gb_sync_task(ctx: GBContext): async_start(parse_locations(data_decoded['locations'], ctx)) if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags: await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!") + if 'options' in data_decoded: + msgs = [] + if data_decoded['options'] & 4 and not ctx.sent_release: + ctx.sent_release = True + msgs.append({"cmd": "Say", "text": "!release"}) + if data_decoded['options'] & 8 and not ctx.sent_collect: + ctx.sent_collect = True + msgs.append({"cmd": "Say", "text": "!collect"}) + if msgs: + await ctx.send_msgs(msgs) if ctx.set_deathlink: await ctx.update_death_link(True) except asyncio.TimeoutError: diff --git a/README.md b/README.md index 42493b5904..b99182f496 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Currently, the following games are supported: * Overcooked! 2 * Zillion * Lufia II Ancient Cave +* Blasphemous +* Wargroove +* Stardew Valley +* The Legend of Zelda +* The Messenger For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/Starcraft2Client.py b/Starcraft2Client.py index 3b05f5aa87..cf16405766 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -52,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor): """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" options = difficulty.split() num_options = len(options) - difficulty_choice = options[0].lower() if num_options > 0: + difficulty_choice = options[0].lower() if difficulty_choice == "casual": self.ctx.difficulty_override = 0 elif difficulty_choice == "normal": @@ -71,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor): return True else: - self.output("Difficulty needs to be specified in the command.") + if self.ctx.difficulty == -1: + self.output("Please connect to a seed before checking difficulty.") + else: + self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty]) + self.output("To change the difficulty, add the name of the difficulty after the command.") return False def _cmd_disable_mission_check(self) -> bool: diff --git a/Utils.py b/Utils.py index 010cc3e5d3..059168c857 100644 --- a/Utils.py +++ b/Utils.py @@ -195,11 +195,11 @@ def get_public_ipv4() -> str: ip = socket.gethostbyname(socket.gethostname()) ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip() + ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() except Exception as e: # noinspection PyBroadException try: - ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip() + ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() except Exception: logging.exception(e) pass # we could be offline, in a local game, so no point in erroring out @@ -213,7 +213,7 @@ def get_public_ipv6() -> str: ip = socket.gethostbyname(socket.gethostname()) ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip() + ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() except Exception as e: logging.exception(e) pass # we could be offline, in a local game, or ipv6 may not be available @@ -310,6 +310,14 @@ def get_default_options() -> OptionsType: "lufia2ac_options": { "rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc", }, + "tloz_options": { + "rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes", + "rom_start": True, + "display_msgs": True, + }, + "wargroove_options": { + "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" + } } return options diff --git a/WargrooveClient.py b/WargrooveClient.py new file mode 100644 index 0000000000..16bfeb15ab --- /dev/null +++ b/WargrooveClient.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +import atexit +import os +import sys +import asyncio +import random +import shutil +from typing import Tuple, List, Iterable, Dict + +from worlds.wargroove import WargrooveWorld +from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData + +import ModuleUpdate +ModuleUpdate.update() + +import Utils +import json +import logging + +if __name__ == "__main__": + Utils.init_logging("WargrooveClient", exception_logger="Client") + +from NetUtils import NetworkItem, ClientStatus +from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ + CommonContext, server_loop + +wg_logger = logging.getLogger("WG") + + +class WargrooveClientCommandProcessor(ClientCommandProcessor): + def _cmd_resync(self): + """Manually trigger a resync.""" + self.output(f"Syncing items.") + self.ctx.syncing = True + + def _cmd_commander(self, *commander_name: Iterable[str]): + """Set the current commander to the given commander.""" + if commander_name: + self.ctx.set_commander(' '.join(commander_name)) + else: + if self.ctx.can_choose_commander: + commanders = self.ctx.get_commanders() + wg_logger.info('Unlocked commanders: ' + + ', '.join((commander.name for commander, unlocked in commanders if unlocked))) + wg_logger.info('Locked commanders: ' + + ', '.join((commander.name for commander, unlocked in commanders if not unlocked))) + else: + wg_logger.error('Cannot set commanders in this game mode.') + + +class WargrooveContext(CommonContext): + command_processor: int = WargrooveClientCommandProcessor + game = "Wargroove" + items_handling = 0b111 # full remote + current_commander: CommanderData = faction_table["Starter"][0] + can_choose_commander: bool = False + commander_defense_boost_multiplier: int = 0 + income_boost_multiplier: int = 0 + starting_groove_multiplier: float + faction_item_ids = { + 'Starter': 0, + 'Cherrystone': 52025, + 'Felheim': 52026, + 'Floran': 52027, + 'Heavensong': 52028, + 'Requiem': 52029, + 'Outlaw': 52030 + } + buff_item_ids = { + 'Income Boost': 52023, + 'Commander Defense Boost': 52024, + } + + def __init__(self, server_address, password): + super(WargrooveContext, self).__init__(server_address, password) + self.send_index: int = 0 + self.syncing = False + self.awaiting_bridge = False + # self.game_communication_path: files go in this path to pass data between us and the actual game + if "appdata" in os.environ: + options = Utils.get_options() + root_directory = os.path.join(options["wargroove_options"]["root_directory"]) + data_directory = os.path.join("lib", "worlds", "wargroove", "data") + dev_data_directory = os.path.join("worlds", "wargroove", "data") + appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove")) + if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")): + print_error_and_close("WargrooveClient couldn't find wargroove64.exe. " + "Unable to infer required game_communication_path") + self.game_communication_path = os.path.join(root_directory, "AP") + if not os.path.exists(self.game_communication_path): + os.makedirs(self.game_communication_path) + self.remove_communication_files() + atexit.register(self.remove_communication_files) + if not os.path.isdir(appdata_wargroove): + print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!" + "Boot Wargroove and then close it to attempt to fix this error") + if not os.path.isdir(data_directory): + data_directory = dev_data_directory + if not os.path.isdir(data_directory): + print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!") + shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True) + else: + print_error_and_close("WargrooveClient couldn't detect system type. " + "Unable to infer required game_communication_path") + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(WargrooveContext, self).server_auth(password_requested) + await self.get_username() + await self.send_connect() + + async def connection_closed(self): + await super(WargrooveContext, self).connection_closed() + self.remove_communication_files() + + @property + def endpoints(self): + if self.server: + return [self.server] + else: + return [] + + async def shutdown(self): + await super(WargrooveContext, self).shutdown() + self.remove_communication_files() + + def remove_communication_files(self): + for root, dirs, files in os.walk(self.game_communication_path): + for file in files: + os.remove(root + "/" + file) + + def on_package(self, cmd: str, args: dict): + if cmd in {"Connected"}: + filename = f"AP_settings.json" + with open(os.path.join(self.game_communication_path, filename), 'w') as f: + slot_data = args["slot_data"] + json.dump(args["slot_data"], f) + self.can_choose_commander = slot_data["can_choose_commander"] + print('can choose commander:', self.can_choose_commander) + self.starting_groove_multiplier = slot_data["starting_groove_multiplier"] + self.income_boost_multiplier = slot_data["income_boost"] + self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"] + f.close() + for ss in self.checked_locations: + filename = f"send{ss}" + with open(os.path.join(self.game_communication_path, filename), 'w') as f: + f.close() + self.update_commander_data() + self.ui.update_tracker() + + random.seed(self.seed_name + str(self.slot)) + # Our indexes start at 1 and we have 24 levels + for i in range(1, 25): + filename = f"seed{i}" + with open(os.path.join(self.game_communication_path, filename), 'w') as f: + f.write(str(random.randint(0, 4294967295))) + f.close() + + if cmd in {"RoomInfo"}: + self.seed_name = args["seed_name"] + + if cmd in {"ReceivedItems"}: + received_ids = [item.item for item in self.items_received] + for network_item in self.items_received: + filename = f"AP_{str(network_item.item)}.item" + path = os.path.join(self.game_communication_path, filename) + + # Newly-obtained items + if not os.path.isfile(path): + open(path, 'w').close() + # Announcing commander unlocks + item_name = self.item_names[network_item.item] + if item_name in faction_table.keys(): + for commander in faction_table[item_name]: + logger.info(f"{commander.name} has been unlocked!") + + with open(path, 'w') as f: + item_count = received_ids.count(network_item.item) + if self.buff_item_ids["Income Boost"] == network_item.item: + f.write(f"{item_count * self.income_boost_multiplier}") + elif self.buff_item_ids["Commander Defense Boost"] == network_item.item: + f.write(f"{item_count * self.commander_defense_boost_multiplier}") + else: + f.write(f"{item_count}") + f.close() + + print_filename = f"AP_{str(network_item.item)}.item.print" + print_path = os.path.join(self.game_communication_path, print_filename) + if not os.path.isfile(print_path): + open(print_path, 'w').close() + with open(print_path, 'w') as f: + f.write("Received " + + self.item_names[network_item.item] + + " from " + + self.player_names[network_item.player]) + f.close() + self.update_commander_data() + self.ui.update_tracker() + + if cmd in {"RoomUpdate"}: + if "checked_locations" in args: + for ss in self.checked_locations: + filename = f"send{ss}" + with open(os.path.join(self.game_communication_path, filename), 'w') as f: + f.close() + + def run_gui(self): + """Import kivy UI system and start running it as self.ui_task.""" + from kvui import GameManager, HoverBehavior, ServerToolTip + from kivy.uix.tabbedpanel import TabbedPanelItem + from kivy.lang import Builder + from kivy.uix.button import Button + from kivy.uix.togglebutton import ToggleButton + from kivy.uix.boxlayout import BoxLayout + from kivy.uix.gridlayout import GridLayout + from kivy.uix.image import AsyncImage, Image + from kivy.uix.stacklayout import StackLayout + from kivy.uix.label import Label + from kivy.properties import ColorProperty + from kivy.uix.image import Image + import pkgutil + + class TrackerLayout(BoxLayout): + pass + + class CommanderSelect(BoxLayout): + pass + + class CommanderButton(ToggleButton): + pass + + class FactionBox(BoxLayout): + pass + + class CommanderGroup(BoxLayout): + pass + + class ItemTracker(BoxLayout): + pass + + class ItemLabel(Label): + pass + + class WargrooveManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago"), + ("WG", "WG Console"), + ] + base_title = "Archipelago Wargroove Client" + ctx: WargrooveContext + unit_tracker: ItemTracker + trigger_tracker: BoxLayout + boost_tracker: BoxLayout + commander_buttons: Dict[int, List[CommanderButton]] + tracker_items = { + "Swordsman": ItemData(None, "Unit", False), + "Dog": ItemData(None, "Unit", False), + **item_table + } + + def build(self): + container = super().build() + panel = TabbedPanelItem(text="Wargroove") + panel.content = self.build_tracker() + self.tabs.add_widget(panel) + return container + + def build_tracker(self) -> TrackerLayout: + try: + tracker = TrackerLayout(orientation="horizontal") + commander_select = CommanderSelect(orientation="vertical") + self.commander_buttons = {} + + for faction, commanders in faction_table.items(): + faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70) + commander_group = CommanderGroup() + commander_buttons = [] + for commander in commanders: + commander_button = CommanderButton(text=commander.name, group="commanders") + if faction == "Starter": + commander_button.disabled = False + commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text)) + commander_buttons.append(commander_button) + commander_group.add_widget(commander_button) + self.commander_buttons[faction] = commander_buttons + faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10)) + faction_box.add_widget(commander_group) + commander_select.add_widget(faction_box) + item_tracker = ItemTracker(padding=[0,20]) + self.unit_tracker = BoxLayout(orientation="vertical") + other_tracker = BoxLayout(orientation="vertical") + self.trigger_tracker = BoxLayout(orientation="vertical") + self.boost_tracker = BoxLayout(orientation="vertical") + other_tracker.add_widget(self.trigger_tracker) + other_tracker.add_widget(self.boost_tracker) + item_tracker.add_widget(self.unit_tracker) + item_tracker.add_widget(other_tracker) + tracker.add_widget(commander_select) + tracker.add_widget(item_tracker) + self.update_tracker() + return tracker + except Exception as e: + print(e) + + def update_tracker(self): + received_ids = [item.item for item in self.ctx.items_received] + for faction, item_id in self.ctx.faction_item_ids.items(): + for commander_button in self.commander_buttons[faction]: + commander_button.disabled = not (faction == "Starter" or item_id in received_ids) + self.unit_tracker.clear_widgets() + self.trigger_tracker.clear_widgets() + for name, item in self.tracker_items.items(): + if item.type in ("Unit", "Trigger"): + status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1) + label = ItemLabel(text=name, color=status_color) + if item.type == "Unit": + self.unit_tracker.add_widget(label) + else: + self.trigger_tracker.add_widget(label) + self.boost_tracker.clear_widgets() + extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier + extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier + income_boost = ItemLabel(text="Extra Income: " + str(extra_income)) + defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense)) + self.boost_tracker.add_widget(income_boost) + self.boost_tracker.add_widget(defense_boost) + + self.ui = WargrooveManager(self) + data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode() + Builder.load_string(data) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def update_commander_data(self): + if self.can_choose_commander: + faction_items = 0 + faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()] + for network_item in self.items_received: + if self.item_names[network_item.item] in faction_item_names: + faction_items += 1 + starting_groove = (faction_items - 1) * self.starting_groove_multiplier + # Must be an integer larger than 0 + starting_groove = int(max(starting_groove, 0)) + data = { + "commander": self.current_commander.internal_name, + "starting_groove": starting_groove + } + else: + data = { + "commander": "seed", + "starting_groove": 0 + } + filename = 'commander.json' + with open(os.path.join(self.game_communication_path, filename), 'w') as f: + json.dump(data, f) + if self.ui: + self.ui.update_tracker() + + def set_commander(self, commander_name: str) -> bool: + """Sets the current commander to the given one, if possible""" + if not self.can_choose_commander: + wg_logger.error("Cannot set commanders in this game mode.") + return + match_name = commander_name.lower() + for commander, unlocked in self.get_commanders(): + if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name: + if unlocked: + self.current_commander = commander + self.syncing = True + wg_logger.info(f"Commander set to {commander.name}.") + self.update_commander_data() + return True + else: + wg_logger.error(f"Commander {commander.name} has not been unlocked.") + return False + else: + wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.") + + def get_commanders(self) -> List[Tuple[CommanderData, bool]]: + """Gets a list of commanders with their unlocked status""" + commanders = [] + received_ids = [item.item for item in self.items_received] + for faction in faction_table.keys(): + unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids + commanders += [(commander, unlocked) for commander in faction_table[faction]] + return commanders + + +async def game_watcher(ctx: WargrooveContext): + from worlds.wargroove.Locations import location_table + while not ctx.exit_event.is_set(): + if ctx.syncing == True: + sync_msg = [{'cmd': 'Sync'}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + ctx.syncing = False + sending = [] + victory = False + for root, dirs, files in os.walk(ctx.game_communication_path): + for file in files: + if file.find("send") > -1: + st = file.split("send", -1)[1] + sending = sending+[(int(st))] + if file.find("victory") > -1: + victory = True + ctx.locations_checked = sending + message = [{"cmd": 'LocationChecks', "locations": sending}] + await ctx.send_msgs(message) + if not ctx.finished_game and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + +def print_error_and_close(msg): + logger.error("Error: " + msg) + Utils.messagebox("Error", msg, error=True) + sys.exit(1) + +if __name__ == '__main__': + async def main(args): + ctx = WargrooveContext(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + progression_watcher = asyncio.create_task( + game_watcher(ctx), name="WargrooveProgressionWatcher") + + await ctx.exit_event.wait() + ctx.server_address = None + + await progression_watcher + + await ctx.shutdown() + + import colorama + + parser = get_base_parser(description="Wargroove Client, for text interfacing.") + + args, rest = parser.parse_known_args() + colorama.init() + asyncio.run(main(args)) + colorama.deinit() diff --git a/WebHost.py b/WebHost.py index d098f6e7fb..40d366a02f 100644 --- a/WebHost.py +++ b/WebHost.py @@ -33,6 +33,11 @@ def get_app(): import yaml app.config.from_file(configpath, yaml.safe_load) logging.info(f"Updated config from {configpath}") + if not app.config["HOST_ADDRESS"]: + logging.info("Getting public IP, as HOST_ADDRESS is empty.") + app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() + logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") + db.bind(**app.config["PONY"]) db.generate_mapping(create_tables=True) return app diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index e8e5b59d89..8bd3609c1d 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -51,7 +51,7 @@ app.config["PONY"] = { app.config["MAX_ROLL"] = 20 app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache" app.config["JSON_AS_ASCII"] = False -app.config["PATCH_TARGET"] = "archipelago.gg" +app.config["HOST_ADDRESS"] = "" cache = Cache(app) Compress(app) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 4cf7243302..484755b3c3 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -179,6 +179,7 @@ class MultiworldInstance(): self.ponyconfig = config["PONY"] self.cert = config["SELFLAUNCHCERT"] self.key = config["SELFLAUNCHKEY"] + self.host = config["HOST_ADDRESS"] def start(self): if self.process and self.process.is_alive(): @@ -187,7 +188,7 @@ class MultiworldInstance(): logging.info(f"Spinning up {self.room_id}") process = multiprocessing.Process(group=None, target=run_server_process, args=(self.room_id, self.ponyconfig, get_static_server_data(), - self.cert, self.key), + self.cert, self.key, self.host), name="MultiHost") process.start() # bind after start to prevent thread sync issues with guardian. diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 9c21fca4f9..584ca9feca 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -131,6 +131,8 @@ def get_static_server_data() -> dict: "gamespackage": worlds.network_data_package["games"], "item_name_groups": {world_name: world.item_name_groups for world_name, world in worlds.AutoWorldRegister.world_types.items()}, + "location_name_groups": {world_name: world.location_name_groups for world_name, world in + worlds.AutoWorldRegister.world_types.items()}, } for world_name, world in worlds.AutoWorldRegister.world_types.items(): @@ -140,7 +142,8 @@ def get_static_server_data() -> dict: def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, - cert_file: typing.Optional[str], cert_key_file: typing.Optional[str]): + cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], + host: str): # establish DB connection for multidata and multisave db.bind(**ponyconfig) db.generate_mapping(check_tables=False) @@ -165,17 +168,18 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, for wssocket in ctx.server.ws_server.sockets: socketname = wssocket.getsockname() if wssocket.family == socket.AF_INET6: - logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}') # Prefer IPv4, as most users seem to not have working ipv6 support if not port: port = socketname[1] elif wssocket.family == socket.AF_INET: - logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}') port = socketname[1] if port: + logging.info(f'Hosting game at {host}:{port}') with db_session: room = Room.get(id=ctx.room_id) room.last_port = port + else: + logging.exception("Could not determine port. Likely hosting failure.") with db_session: ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index d9600d2d16..02ea7320ef 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -26,7 +26,7 @@ def download_patch(room_id, patch_id): with zipfile.ZipFile(filelike, "a") as zf: with zf.open("archipelago.json", "r") as f: manifest = json.load(f) - manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None + manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None with zipfile.ZipFile(new_file, "w") as new_zip: for file in zf.infolist(): if file.filename == "archipelago.json": @@ -64,7 +64,7 @@ def download_slot_file(room_id, player_id: int): if slot_data.game == "Minecraft": from worlds.minecraft import mc_update_output fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" - data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port) + data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port) return send_file(io.BytesIO(data), as_attachment=True, download_name=fname) elif slot_data.game == "Factorio": with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 2397bf91b4..d5c1719863 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,7 +1,7 @@ -flask>=2.2.2 +flask>=2.2.3 pony>=0.7.16 waitress>=2.1.2 -Flask-Caching>=2.0.1 +Flask-Caching>=2.0.2 Flask-Compress>=1.13 -Flask-Limiter>=2.8.1 -bokeh>=3.0.2 +Flask-Limiter>=3.3.0 +bokeh>=3.1.0 diff --git a/WebHostLib/static/assets/baseHeader.js b/WebHostLib/static/assets/baseHeader.js new file mode 100644 index 0000000000..b8ee82dd63 --- /dev/null +++ b/WebHostLib/static/assets/baseHeader.js @@ -0,0 +1,18 @@ +window.addEventListener('load', () => { + const menuButton = document.getElementById('base-header-mobile-menu-button'); + const mobileMenu = document.getElementById('base-header-mobile-menu'); + + menuButton.addEventListener('click', (evt) => { + evt.preventDefault(); + + if (!mobileMenu.style.display || mobileMenu.style.display === 'none') { + return mobileMenu.style.display = 'flex'; + } + + mobileMenu.style.display = 'none'; + }); + + window.addEventListener('resize', () => { + mobileMenu.style.display = 'none'; + }); +}); diff --git a/WebHostLib/static/assets/checksfinderTracker.js b/WebHostLib/static/assets/checksfinderTracker.js new file mode 100644 index 0000000000..61cf1e1559 --- /dev/null +++ b/WebHostLib/static/assets/checksfinderTracker.js @@ -0,0 +1,49 @@ +window.addEventListener('load', () => { + // Reload tracker every 60 seconds + const url = window.location; + setInterval(() => { + const ajax = new XMLHttpRequest(); + ajax.onreadystatechange = () => { + if (ajax.readyState !== 4) { return; } + + // Create a fake DOM using the returned HTML + const domParser = new DOMParser(); + const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html'); + + // Update item tracker + document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML; + // Update only counters in the location-table + let counters = document.getElementsByClassName('counter'); + const fakeCounters = fakeDOM.getElementsByClassName('counter'); + for (let i = 0; i < counters.length; i++) { + counters[i].innerHTML = fakeCounters[i].innerHTML; + } + }; + ajax.open('GET', url); + ajax.send(); +}, 60000) + + // Collapsible advancement sections + const categories = document.getElementsByClassName("location-category"); + for (let i = 0; i < categories.length; i++) { + let hide_id = categories[i].id.split('-')[0]; + if (hide_id == 'Total') { + continue; + } + categories[i].addEventListener('click', function() { + // Toggle the advancement list + document.getElementById(hide_id).classList.toggle("hide"); + // Change text of the header + const tab_header = document.getElementById(hide_id+'-header').children[0]; + const orig_text = tab_header.innerHTML; + let new_text; + if (orig_text.includes("▼")) { + new_text = orig_text.replace("▼", "▲"); + } + else { + new_text = orig_text.replace("▲", "▼"); + } + tab_header.innerHTML = new_text; + }); + } +}); diff --git a/WebHostLib/static/assets/lttpMultiTracker.js b/WebHostLib/static/assets/lttpMultiTracker.js new file mode 100644 index 0000000000..e90331028d --- /dev/null +++ b/WebHostLib/static/assets/lttpMultiTracker.js @@ -0,0 +1,6 @@ +window.addEventListener('load', () => { + $(".table-wrapper").scrollsync({ + y_sync: true, + x_sync: true + }); +}); diff --git a/WebHostLib/static/assets/tracker.js b/WebHostLib/static/assets/trackerCommon.js similarity index 97% rename from WebHostLib/static/assets/tracker.js rename to WebHostLib/static/assets/trackerCommon.js index 23e7f979a5..c08590cbf7 100644 --- a/WebHostLib/static/assets/tracker.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -1,5 +1,7 @@ const adjustTableHeight = () => { const tablesContainer = document.getElementById('tables-container'); + if (!tablesContainer) + return; const upperDistance = tablesContainer.getBoundingClientRect().top; const containerHeight = window.innerHeight - upperDistance; @@ -108,7 +110,7 @@ window.addEventListener('load', () => { const update = () => { const target = $("
"); console.log("Updating Tracker..."); - target.load("/tracker/" + tracker, function (response, status) { + target.load(location.href, function (response, status) { if (status === "success") { target.find(".table").each(function (i, new_table) { const new_trs = $(new_table).find("tbody>tr"); @@ -135,10 +137,5 @@ window.addEventListener('load', () => { tables.draw(); }); - $(".table-wrapper").scrollsync({ - y_sync: true, - x_sync: true - }); - adjustTableHeight(); }); diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.png b/WebHostLib/static/static/button-images/hamburger-menu-icon.png new file mode 100644 index 0000000000..f1c9631635 Binary files /dev/null and b/WebHostLib/static/static/button-images/hamburger-menu-icon.png differ diff --git a/WebHostLib/static/styles/checksfinderTracker.css b/WebHostLib/static/styles/checksfinderTracker.css new file mode 100644 index 0000000000..e0cde61241 --- /dev/null +++ b/WebHostLib/static/styles/checksfinderTracker.css @@ -0,0 +1,30 @@ +#player-tracker-wrapper{ + margin: 0; +} + +#inventory-table{ + padding: 8px 10px 2px 6px; + background-color: #42b149; + border-radius: 4px; + border: 2px solid black; +} + +#inventory-table tr.column-headers td { + font-size: 1rem; + padding: 0 5rem 0 0; +} + +#inventory-table td{ + padding: 0 0.5rem 0.5rem; + font-family: LexendDeca-Light, monospace; + font-size: 2.5rem; + color: #ffffff; +} + +#inventory-table td img{ + vertical-align: middle; +} + +.hide { + display: none; +} diff --git a/WebHostLib/static/styles/themes/base.css b/WebHostLib/static/styles/themes/base.css index fca65a51c1..d38a8e610c 100644 --- a/WebHostLib/static/styles/themes/base.css +++ b/WebHostLib/static/styles/themes/base.css @@ -42,7 +42,7 @@ html{ margin-top: 4px; } -#base-header a{ +#base-header a, #base-header-mobile-menu a{ color: #2f6b83; text-decoration: none; cursor: pointer; @@ -51,3 +51,64 @@ html{ font-family: LondrinaSolid-Light, sans-serif; text-transform: uppercase; } + +#base-header-right-mobile{ + display: none; + margin-top: 2rem; + margin-right: 1rem; +} + +#base-header-mobile-menu{ + display: none; + flex-direction: column; + background-color: #ffffff; + text-align: center; + overflow-y: auto; + z-index: 10000; + width: 100vw; + border-bottom-left-radius: 20px; + border-bottom-right-radius: 20px; + + position: absolute; + top: 7rem; + right: 0; + padding-top: 1rem; +} + +#base-header-mobile-menu a{ + padding: 4rem 2rem; + font-size: 5rem; + line-height: 5rem; + color: #699ca8; + border-top: 1px solid #d3d3d3; +} + +#base-header-right-mobile img{ + height: 3rem; +} + +@media all and (max-width: 1580px){ + html{ + padding-top: 260px; + scroll-padding-top: 230px; + } + + #base-header{ + height: 200px; + background-size: auto 200px; + } + + #base-header #site-title img{ + height: calc(38px * 2); + margin-top: 30px; + margin-left: 20px; + } + + #base-header-right{ + display: none; + } + + #base-header-right-mobile{ + display: unset; + } +} diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css index e203d9e97d..0e00553c72 100644 --- a/WebHostLib/static/styles/tracker.css +++ b/WebHostLib/static/styles/tracker.css @@ -119,6 +119,33 @@ img.alttp-sprite { background-color: #d3c97d; } +#tracker-navigation { + display: inline-flex; + background-color: #b0a77d; + margin: 0.5rem; + border-radius: 4px; +} + +.tracker-navigation-button { + display: block; + margin: 4px; + padding-left: 12px; + padding-right: 12px; + border-radius: 4px; + text-align: center; + font-size: 14px; + color: #000; + font-weight: lighter; +} + +.tracker-navigation-button:hover { + background-color: #e2eabb !important; +} + +.tracker-navigation-button.selected { + background-color: rgb(220, 226, 189); +} + @media all and (max-width: 1700px) { table.dataTable thead th.upper-row{ position: -webkit-sticky; diff --git a/WebHostLib/templates/checksfinderTracker.html b/WebHostLib/templates/checksfinderTracker.html new file mode 100644 index 0000000000..5df77f5e74 --- /dev/null +++ b/WebHostLib/templates/checksfinderTracker.html @@ -0,0 +1,35 @@ + + + + {{ player_name }}'s Tracker + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Checks Available:Map Bombs:
Checks Available{{ checks_available }}Bombs Remaining{{ bombs_display }}/20
Map Width:Map Height:
Map Width{{ width_display }}/10Map Height{{ height_display }}/10
+
+ + diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html index 508c084e7f..1c2fcd44c0 100644 --- a/WebHostLib/templates/genericTracker.html +++ b/WebHostLib/templates/genericTracker.html @@ -4,7 +4,7 @@ {{ player_name }}'s Tracker - + {% endblock %} {% block body %} diff --git a/WebHostLib/templates/header/baseHeader.html b/WebHostLib/templates/header/baseHeader.html index a76835b5d9..80e8d6220d 100644 --- a/WebHostLib/templates/header/baseHeader.html +++ b/WebHostLib/templates/header/baseHeader.html @@ -1,5 +1,6 @@ {% block head %} + {% endblock %} {% block header %} @@ -16,5 +17,17 @@ f.a.q. discord +
+ + Menu + +
+
+ supported games + setup guides + start playing + f.a.q. + discord +
{% endblock %} diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 1c8f3de255..6f02dc0944 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -14,7 +14,7 @@
{% endif %} {% if room.tracker %} - This room has a Multiworld Tracker enabled. + This room has a Multiworld Tracker enabled.
{% endif %} The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. @@ -25,8 +25,8 @@ The most likely failure reason is that the multiworld is too old to be loaded now. {% elif room.last_port %} You can connect to this room by using - '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' + data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}."> + '/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}' in the client.
{% endif %} diff --git a/WebHostLib/templates/tracker.html b/WebHostLib/templates/lttpMultiTracker.html similarity index 97% rename from WebHostLib/templates/tracker.html rename to WebHostLib/templates/lttpMultiTracker.html index 96148e3454..276e1de3ce 100644 --- a/WebHostLib/templates/tracker.html +++ b/WebHostLib/templates/lttpMultiTracker.html @@ -1,14 +1,16 @@ {% extends 'tablepage.html' %} {% block head %} {{ super() }} - Multiworld Tracker + ALttP Multiworld Tracker - + + {% endblock %} {% block body %} {% include 'header/dirtHeader.html' %} + {% include 'multiTrackerNavigation.html' %}
diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index ba6f33a9d8..11e333f05e 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -22,7 +22,7 @@ {% for patch in room.seed.slots|list|sort(attribute="player_id") %} {{ patch.player_id }} - {{ patch.player_name }} + {{ patch.player_name }} {{ patch.game }} {% if patch.game == "Minecraft" %} diff --git a/WebHostLib/templates/multiFactorioTracker.html b/WebHostLib/templates/multiFactorioTracker.html new file mode 100644 index 0000000000..bc0a977ab6 --- /dev/null +++ b/WebHostLib/templates/multiFactorioTracker.html @@ -0,0 +1,44 @@ +{% extends "multiTracker.html" %} +{% block custom_table_headers %} + + Logistic Science Pack + + + Military Science Pack + + + Chemical Science Pack + + + Production Science Pack + + + Utility Science Pack + + + Space Science Pack + +{% endblock %} +{% block custom_table_row scoped %} +{% if games[player] == "Factorio" %} +{% if inventory[team][player][131161] or inventory[team][player][131281] %}✔{% endif %} +{% if inventory[team][player][131172] or inventory[team][player][131281] > 1%}✔{% endif %} +{% if inventory[team][player][131195] or inventory[team][player][131281] > 2%}✔{% endif %} +{% if inventory[team][player][131240] or inventory[team][player][131281] > 3%}✔{% endif %} +{% if inventory[team][player][131240] or inventory[team][player][131281] > 4%}✔{% endif %} +{% if inventory[team][player][131220] or inventory[team][player][131281] > 5%}✔{% endif %} +{% else %} +❌ +❌ +❌ +❌ +❌ +❌ +{% endif %} +{% endblock%} diff --git a/WebHostLib/templates/multiTracker.html b/WebHostLib/templates/multiTracker.html new file mode 100644 index 0000000000..c6defff00b --- /dev/null +++ b/WebHostLib/templates/multiTracker.html @@ -0,0 +1,95 @@ +{% extends 'tablepage.html' %} +{% block head %} + {{ super() }} + Multiworld Tracker + + +{% endblock %} + +{% block body %} + {% include 'header/dirtHeader.html' %} + {% include 'multiTrackerNavigation.html' %} +
+
+ + + + Multistream + + + Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically. +
+
+ {% for team, players in checks_done.items() %} +
+ + + + + + + {% block custom_table_headers %} + {# implement this block in game-specific multi trackers #} + {% endblock %} + + + + + + + {%- for player, checks in players.items() -%} + + + + + {% block custom_table_row scoped %} + {# implement this block in game-specific multi trackers #} + {% endblock %} + + + {%- if activity_timers[(team, player)] -%} + + {%- else -%} + + {%- endif -%} + + {%- endfor -%} + +
#NameGameChecks%Last
Activity
{{ loop.index }}{{ player_names[(team, loop.index)]|e }}{{ games[player] }}{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}{{ percent_total_checks_done[team][player] }}{{ activity_timers[(team, player)].total_seconds() }}None
+
+ {% endfor %} + {% for team, hints in hints.items() %} +
+ + + + + + + + + + + + + {%- for hint in hints -%} + + + + + + + + + {%- endfor -%} + +
FinderReceiverItemLocationEntranceFound
{{ long_player_names[team, hint.finding_player] }}{{ long_player_names[team, hint.receiving_player] }}{{ hint.item|item_name }}{{ hint.location|location_name }}{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}{% if hint.found %}✔{% endif %}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/WebHostLib/templates/multiTrackerNavigation.html b/WebHostLib/templates/multiTrackerNavigation.html new file mode 100644 index 0000000000..f712f33679 --- /dev/null +++ b/WebHostLib/templates/multiTrackerNavigation.html @@ -0,0 +1,9 @@ +{%- if enabled_multiworld_trackers|length > 1 -%} +
+ {% for enabled_tracker in enabled_multiworld_trackers %} + {% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %} + {{ enabled_tracker.name }} + {% endfor %} +
+{%- endif -%} diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index c0b83e4daf..8d9311fc8f 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -5,6 +5,7 @@ from typing import Counter, Optional, Dict, Any, Tuple from uuid import UUID from flask import render_template +from jinja2 import pass_context, runtime from werkzeug.exceptions import abort from MultiServer import Context, get_saving_second @@ -83,9 +84,6 @@ def get_alttp_id(item_name): return Items.item_table[item_name][2] -app.jinja_env.filters["location_name"] = lambda location: lookup_any_location_id_to_name.get(location, location) -app.jinja_env.filters['item_name'] = lambda id: lookup_any_item_id_to_name.get(id, id) - links = {"Bow": "Progressive Bow", "Silver Arrows": "Progressive Bow", "Silver Bow": "Progressive Bow", @@ -212,14 +210,6 @@ del data del item -def attribute_item(inventory, team, recipient, item): - target_item = links.get(item, item) - if item in levels: # non-progressive - inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item]) - else: - inventory[team][recipient][target_item] += 1 - - def attribute_item_solo(inventory, item): """Adds item to inventory counter, converts everything to progressive.""" target_item = links.get(item, item) @@ -237,6 +227,22 @@ def render_timedelta(delta: datetime.timedelta): return f"{hours}:{minutes}" +@pass_context +def get_location_name(context: runtime.Context, loc: int) -> str: + context_locations = context.get("custom_locations", {}) + return collections.ChainMap(lookup_any_location_id_to_name, context_locations).get(loc, loc) + + +@pass_context +def get_item_name(context: runtime.Context, item: int) -> str: + context_items = context.get("custom_items", {}) + return collections.ChainMap(lookup_any_item_id_to_name, context_items).get(item, item) + + +app.jinja_env.filters["location_name"] = get_location_name +app.jinja_env.filters["item_name"] = get_item_name + + _multidata_cache = {} @@ -258,10 +264,23 @@ def get_static_room_data(room: Room): # in > 100 players this can take a bit of time and is the main reason for the cache locations: Dict[int, Dict[int, Tuple[int, int, int]]] = multidata['locations'] names: Dict[int, Dict[int, str]] = multidata["names"] + games = {} groups = {} + custom_locations = {} + custom_items = {} if "slot_info" in multidata: + games = {slot: slot_info.game for slot, slot_info in multidata["slot_info"].items()} groups = {slot: slot_info.group_members for slot, slot_info in multidata["slot_info"].items() if slot_info.type == SlotType.group} + + for game in games.values(): + if game in multidata["datapackage"]: + custom_locations.update( + {id: name for name, id in multidata["datapackage"][game]["location_name_to_id"].items()}) + custom_items.update( + {id: name for name, id in multidata["datapackage"][game]["item_name_to_id"].items()}) + elif "games" in multidata: + games = multidata["games"] seed_checks_in_area = checks_in_area.copy() use_door_tracker = False @@ -282,7 +301,8 @@ def get_static_room_data(room: Room): if playernumber not in groups} saving_second = get_saving_second(multidata["seed_name"]) result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \ - multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups, saving_second + multidata["precollected_items"], games, multidata["slot_data"], groups, saving_second, \ + custom_locations, custom_items _multidata_cache[room.seed.id] = result return result @@ -309,7 +329,8 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w # Collect seed information and pare it down to a single player locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ - precollected_items, games, slot_data, groups, saving_second = get_static_room_data(room) + precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ + get_static_room_data(room) player_name = names[tracked_team][tracked_player - 1] location_to_area = player_location_to_area[tracked_player] inventory = collections.Counter() @@ -351,7 +372,7 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second) else: tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, - seed_checks_in_area, checks_done, saving_second) + seed_checks_in_area, checks_done, saving_second, custom_locations, custom_items) return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker @@ -457,7 +478,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D "Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png", "Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png", "Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png", - "Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png", + "Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png", "Saddle": "https://i.imgur.com/2QtDyR0.png", "Channeling Book": "https://i.imgur.com/J3WsYZw.png", "Silk Touch Book": "https://i.imgur.com/iqERxHQ.png", @@ -465,7 +486,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D } minecraft_location_ids = { - "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, + "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, 42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077], "Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021, 42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014], @@ -627,7 +648,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in if base_name == "hookshot": display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level) - if base_name == "wallet": + if base_name == "wallet": display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level) # Determine display for bottles. Show letter if it's obtained, determine bottle count @@ -645,7 +666,6 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in } for item_name, item_id in multi_items.items(): base_name = item_name.split()[-1].lower() - count = inventory[item_id] display_data[base_name+"_count"] = inventory[item_id] # Gather dungeon locations @@ -775,7 +795,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: } timespinner_location_ids = { - "Present": [ + "Present": [ 1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009, 1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019, 1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029, @@ -796,20 +816,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: 1337150, 1337151, 1337152, 1337153, 1337154, 1337155, 1337171, 1337172, 1337173, 1337174, 1337175], "Ancient Pyramid": [ - 1337236, + 1337236, 1337246, 1337247, 1337248, 1337249] } if(slot_data["DownloadableItems"]): timespinner_location_ids["Present"] += [ 1337156, 1337157, 1337159, - 1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169, + 1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169, 1337170] if(slot_data["Cantoran"]): timespinner_location_ids["Past"].append(1337176) if(slot_data["LoreChecks"]): timespinner_location_ids["Present"] += [ - 1337177, 1337178, 1337179, + 1337177, 1337178, 1337179, 1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187] timespinner_location_ids["Past"] += [ 1337188, 1337189, @@ -1190,11 +1210,89 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, **display_data) +def __renderChecksfinder(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], + inventory: Counter, team: int, player: int, playerName: str, + seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, saving_second: int) -> str: + + icons = { + "Checks Available": "https://0rganics.org/archipelago/cf/spr_tiles_3.png", + "Map Width": "https://0rganics.org/archipelago/cf/spr_tiles_4.png", + "Map Height": "https://0rganics.org/archipelago/cf/spr_tiles_5.png", + "Map Bombs": "https://0rganics.org/archipelago/cf/spr_tiles_6.png", + + "Nothing": "", + } + + checksfinder_location_ids = { + "Tile 1": 81000, + "Tile 2": 81001, + "Tile 3": 81002, + "Tile 4": 81003, + "Tile 5": 81004, + "Tile 6": 81005, + "Tile 7": 81006, + "Tile 8": 81007, + "Tile 9": 81008, + "Tile 10": 81009, + "Tile 11": 81010, + "Tile 12": 81011, + "Tile 13": 81012, + "Tile 14": 81013, + "Tile 15": 81014, + "Tile 16": 81015, + "Tile 17": 81016, + "Tile 18": 81017, + "Tile 19": 81018, + "Tile 20": 81019, + "Tile 21": 81020, + "Tile 22": 81021, + "Tile 23": 81022, + "Tile 24": 81023, + "Tile 25": 81024, + } + + display_data = {} + + # Multi-items + multi_items = { + "Map Width": 80000, + "Map Height": 80001, + "Map Bombs": 80002 + } + for item_name, item_id in multi_items.items(): + base_name = item_name.split()[-1].lower() + count = inventory[item_id] + display_data[base_name + "_count"] = count + display_data[base_name + "_display"] = count + 5 + + # Get location info + checked_locations = multisave.get("location_checks", {}).get((team, player), set()) + lookup_name = lambda id: lookup_any_location_id_to_name[id] + location_info = {tile_name: {lookup_name(tile_location): (tile_location in checked_locations)} for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in set(locations[player])} + checks_done = {tile_name: len([tile_location]) for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in checked_locations and tile_location in set(locations[player])} + checks_done['Total'] = len(checked_locations) + checks_in_area = checks_done + + # Calculate checks available + display_data["checks_unlocked"] = min(display_data["width_count"] + display_data["height_count"] + display_data["bombs_count"] + 5, 25) + display_data["checks_available"] = max(display_data["checks_unlocked"] - len(checked_locations), 0) + + # Victory condition + game_state = multisave.get("client_game_state", {}).get((team, player), 0) + display_data['game_finished'] = game_state == 30 + + return render_template("checksfinderTracker.html", + inventory=inventory, icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id in inventory if + id in lookup_any_item_id_to_name}, + player=player, team=team, room=room, player_name=playerName, + checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, + **display_data) def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], inventory: Counter, team: int, player: int, playerName: str, seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], - saving_second: int) -> str: + saving_second: int, custom_locations: Dict[int, str], custom_items: Dict[int, str]) -> str: checked_locations = multisave.get("location_checks", {}).get((team, player), set()) player_received_items = {} @@ -1212,21 +1310,36 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic player=player, team=team, room=room, player_name=playerName, checked_locations=checked_locations, not_checked_locations=set(locations[player]) - checked_locations, - received_items=player_received_items, - saving_second=saving_second) + received_items=player_received_items, saving_second=saving_second, + custom_items=custom_items, custom_locations=custom_locations) -@app.route('/tracker/') -@cache.memoize(timeout=1) # multisave is currently created at most every minute -def getTracker(tracker: UUID): +def get_enabled_multiworld_trackers(room: Room, current: str): + enabled = [ + { + "name": "Generic", + "endpoint": "get_multiworld_tracker", + "current": current == "Generic" + } + ] + for game_name, endpoint in multi_trackers.items(): + if any(slot.game == game_name for slot in room.seed.slots) or current == game_name: + enabled.append({ + "name": game_name, + "endpoint": endpoint.__name__, + "current": current == game_name} + ) + return enabled + + +def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]: room: Room = Room.get(tracker=tracker) if not room: - abort(404) - locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ - precollected_items, games, slot_data, groups, saving_second = get_static_room_data(room) + return None - inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups} - for teamnumber, team in enumerate(names)} + locations, names, use_door_tracker, checks_in_area, player_location_to_area, \ + precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ + get_static_room_data(room) checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations} for playernumber in range(1, len(team) + 1) if playernumber not in groups} @@ -1236,7 +1349,6 @@ def getTracker(tracker: UUID): for playernumber in range(1, len(team) + 1) if playernumber not in groups} for teamnumber, team in enumerate(names)} - hints = {team: set() for team in range(len(names))} if room.multisave: multisave = restricted_loads(room.multisave) @@ -1246,6 +1358,126 @@ def getTracker(tracker: UUID): for (team, slot), slot_hints in multisave["hints"].items(): hints[team] |= set(slot_hints) + for (team, player), locations_checked in multisave.get("location_checks", {}).items(): + if player in groups: + continue + player_locations = locations[player] + checks_done[team][player]["Total"] = sum(1 for loc in locations_checked if loc in player_locations) + percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] / + checks_in_area[player]["Total"] * 100) \ + if checks_in_area[player]["Total"] else 100 + + activity_timers = {} + now = datetime.datetime.utcnow() + for (team, player), timestamp in multisave.get("client_activity_timers", []): + activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp) + + player_names = {} + for team, names in enumerate(names): + for player, name in enumerate(names, 1): + player_names[(team, player)] = name + long_player_names = player_names.copy() + for (team, player), alias in multisave.get("name_aliases", {}).items(): + player_names[(team, player)] = alias + long_player_names[(team, player)] = f"{alias} ({long_player_names[(team, player)]})" + + video = {} + for (team, player), data in multisave.get("video", []): + video[(team, player)] = data + + return dict(player_names=player_names, room=room, checks_done=checks_done, + percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area, + activity_timers=activity_timers, video=video, hints=hints, + long_player_names=long_player_names, + multisave=multisave, precollected_items=precollected_items, groups=groups, + locations=locations, games=games) + + +def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]: + inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data} + for teamnumber, team_data in data["checks_done"].items()} + + groups = data["groups"] + + for (team, player), locations_checked in data["multisave"].get("location_checks", {}).items(): + if player in data["groups"]: + continue + player_locations = data["locations"][player] + precollected = data["precollected_items"][player] + for item_id in precollected: + inventory[team][player][item_id] += 1 + for location in locations_checked: + item_id, recipient, flags = player_locations[location] + recipients = groups.get(recipient, [recipient]) + for recipient in recipients: + inventory[team][recipient][item_id] += 1 + return inventory + + +@app.route('/tracker/') +@cache.memoize(timeout=60) # multisave is currently created at most every minute +def get_multiworld_tracker(tracker: UUID): + data = _get_multiworld_tracker_data(tracker) + if not data: + abort(404) + + data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic") + + return render_template("multiTracker.html", **data) + + +@app.route('/tracker//Factorio') +@cache.memoize(timeout=60) # multisave is currently created at most every minute +def get_Factorio_multiworld_tracker(tracker: UUID): + data = _get_multiworld_tracker_data(tracker) + if not data: + abort(404) + + data["inventory"] = _get_inventory_data(data) + data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio") + + return render_template("multiFactorioTracker.html", **data) + + +@app.route('/tracker//A Link to the Past') +@cache.memoize(timeout=60) # multisave is currently created at most every minute +def get_LttP_multiworld_tracker(tracker: UUID): + room: Room = Room.get(tracker=tracker) + if not room: + abort(404) + locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ + precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ + get_static_room_data(room) + + inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if + playernumber not in groups} + for teamnumber, team in enumerate(names)} + + checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations} + for playernumber in range(1, len(team) + 1) if playernumber not in groups} + for teamnumber, team in enumerate(names)} + + percent_total_checks_done = {teamnumber: {playernumber: 0 + for playernumber in range(1, len(team) + 1) if playernumber not in groups} + for teamnumber, team in enumerate(names)} + + hints = {team: set() for team in range(len(names))} + if room.multisave: + multisave = restricted_loads(room.multisave) + else: + multisave = {} + if "hints" in multisave: + for (team, slot), slot_hints in multisave["hints"].items(): + hints[team] |= set(slot_hints) + + def attribute_item(team: int, recipient: int, item: int): + nonlocal inventory + target_item = links.get(item, item) + if item in levels: # non-progressive + inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item]) + else: + inventory[team][recipient][target_item] += 1 + for (team, player), locations_checked in multisave.get("location_checks", {}).items(): if player in groups: continue @@ -1253,18 +1485,19 @@ def getTracker(tracker: UUID): if precollected_items: precollected = precollected_items[player] for item_id in precollected: - attribute_item(inventory, team, player, item_id) + attribute_item(team, player, item_id) for location in locations_checked: if location not in player_locations or location not in player_location_to_area[player]: continue - item, recipient, flags = player_locations[location] - - if recipient in names: - attribute_item(inventory, 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"] / seed_checks_in_area[player]["Total"] * 100) if seed_checks_in_area[player]["Total"] else 100 + recipients = groups.get(recipient, [recipient]) + 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"] / seed_checks_in_area[player]["Total"] * 100) if \ + seed_checks_in_area[player]["Total"] else 100 for (team, player), game_state in multisave.get("client_game_state", {}).items(): if player in groups: @@ -1306,14 +1539,19 @@ def getTracker(tracker: UUID): for (team, player), data in multisave.get("video", []): video[(team, player)] = data - return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, + enabled_multiworld_trackers = get_enabled_multiworld_trackers(room, "A Link to the Past") + + return render_template("lttpMultiTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, - multi_items=multi_items, checks_done=checks_done, percent_total_checks_done=percent_total_checks_done, - ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area, activity_timers=activity_timers, + multi_items=multi_items, checks_done=checks_done, + percent_total_checks_done=percent_total_checks_done, + ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area, + activity_timers=activity_timers, key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids, video=video, big_key_locations=group_big_key_locations, - hints=hints, long_player_names=long_player_names) + hints=hints, long_player_names=long_player_names, + enabled_multiworld_trackers=enabled_multiworld_trackers) game_specific_trackers: typing.Dict[str, typing.Callable] = { @@ -1321,6 +1559,12 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = { "Ocarina of Time": __renderOoTTracker, "Timespinner": __renderTimespinnerTracker, "A Link to the Past": __renderAlttpTracker, + "ChecksFinder": __renderChecksfinder, "Super Metroid": __renderSuperMetroidTracker, "Starcraft 2 Wings of Liberty": __renderSC2WoLTracker } + +multi_trackers: typing.Dict[str, typing.Callable] = { + "A Link to the Past": get_LttP_multiworld_tracker, + "Factorio": get_Factorio_multiworld_tracker, +} diff --git a/Zelda1Client.py b/Zelda1Client.py new file mode 100644 index 0000000000..a325e4aebe --- /dev/null +++ b/Zelda1Client.py @@ -0,0 +1,393 @@ +# Based (read: copied almost wholesale and edited) off the FF1 Client. + +import asyncio +import copy +import json +import logging +import os +import subprocess +import time +import typing +from asyncio import StreamReader, StreamWriter +from typing import List + +import Utils +from Utils import async_start +from worlds import lookup_any_location_id_to_name +from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \ + get_base_parser + +from worlds.tloz.Items import item_game_ids +from worlds.tloz.Locations import location_ids +from worlds.tloz import Items, Locations, Rom + +SYSTEM_MESSAGE_ID = 0 + +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart Zelda_connector.lua" +CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure Zelda_connector.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart Zelda_connector.lua" +CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" +CONNECTION_CONNECTED_STATUS = "Connected" +CONNECTION_INITIAL_STATUS = "Connection has not been initiated" + +DISPLAY_MSGS = True + +item_ids = item_game_ids +location_ids = location_ids +items_by_id = {id: item for item, id in item_ids.items()} +locations_by_id = {id: location for location, id in location_ids.items()} + + +class ZeldaCommandProcessor(ClientCommandProcessor): + + def _cmd_nes(self): + """Check NES Connection State""" + if isinstance(self.ctx, ZeldaContext): + logger.info(f"NES Status: {self.ctx.nes_status}") + + def _cmd_toggle_msgs(self): + """Toggle displaying messages in bizhawk""" + global DISPLAY_MSGS + DISPLAY_MSGS = not DISPLAY_MSGS + logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}") + + +class ZeldaContext(CommonContext): + command_processor = ZeldaCommandProcessor + items_handling = 0b101 # get sent remote and starting items + # Infinite Hyrule compatibility + overworld_item = 0x5F + armos_item = 0x24 + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.bonus_items = [] + self.nes_streams: (StreamReader, StreamWriter) = None + self.nes_sync_task = None + self.messages = {} + self.locations_array = None + self.nes_status = CONNECTION_INITIAL_STATUS + self.game = 'The Legend of Zelda' + self.awaiting_rom = False + self.shop_slots_left = 0 + self.shop_slots_middle = 0 + self.shop_slots_right = 0 + self.shop_slots = [self.shop_slots_left, self.shop_slots_middle, self.shop_slots_right] + self.slot_data = dict() + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(ZeldaContext, self).server_auth(password_requested) + if not self.auth: + self.awaiting_rom = True + logger.info('Awaiting connection to NES to get Player information') + return + + await self.send_connect() + + def _set_message(self, msg: str, msg_id: int): + if DISPLAY_MSGS: + self.messages[(time.time(), msg_id)] = msg + + def on_package(self, cmd: str, args: dict): + if cmd == 'Connected': + self.slot_data = args.get("slot_data", {}) + asyncio.create_task(parse_locations(self.locations_array, self, True)) + elif cmd == 'Print': + msg = args['text'] + if ': !' not in msg: + self._set_message(msg, SYSTEM_MESSAGE_ID) + + def on_print_json(self, args: dict): + if self.ui: + self.ui.print_json(copy.deepcopy(args["data"])) + else: + text = self.jsontotextparser(copy.deepcopy(args["data"])) + logger.info(text) + relevant = args.get("type", None) in {"Hint", "ItemSend"} + if relevant: + item = args["item"] + # goes to this world + if self.slot_concerns_self(args["receiving"]): + relevant = True + # found in this world + elif self.slot_concerns_self(item.player): + relevant = True + # not related + else: + relevant = False + if relevant: + item = args["item"] + msg = self.raw_text_parser(copy.deepcopy(args["data"])) + self._set_message(msg, item.item) + + def run_gui(self): + from kvui import GameManager + + class ZeldaManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago Zelda 1 Client" + + self.ui = ZeldaManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + +def get_payload(ctx: ZeldaContext): + current_time = time.time() + bonus_items = [item for item in ctx.bonus_items] + return json.dumps( + { + "items": [item.item for item in ctx.items_received], + "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() + if key[0] > current_time - 10}, + "shops": { + "left": ctx.shop_slots_left, + "middle": ctx.shop_slots_middle, + "right": ctx.shop_slots_right + }, + "bonusItems": bonus_items + } + ) + + +def reconcile_shops(ctx: ZeldaContext): + checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations] + shops = [location for location in checked_location_names if "Shop" in location] + left_slots = [shop for shop in shops if "Left" in shop] + middle_slots = [shop for shop in shops if "Middle" in shop] + right_slots = [shop for shop in shops if "Right" in shop] + for shop in left_slots: + ctx.shop_slots_left |= get_shop_bit_from_name(shop) + for shop in middle_slots: + ctx.shop_slots_middle |= get_shop_bit_from_name(shop) + for shop in right_slots: + ctx.shop_slots_right |= get_shop_bit_from_name(shop) + + +def get_shop_bit_from_name(location_name): + if "Potion" in location_name: + return Rom.potion_shop + elif "Arrow" in location_name: + return Rom.arrow_shop + elif "Shield" in location_name: + return Rom.shield_shop + elif "Ring" in location_name: + return Rom.ring_shop + elif "Candle" in location_name: + return Rom.candle_shop + elif "Take" in location_name: + return Rom.take_any + return 0 # this should never be hit + + +async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone="None"): + if locations_array == ctx.locations_array and not force: + return + else: + # print("New values") + ctx.locations_array = locations_array + locations_checked = [] + location = None + for location in ctx.missing_locations: + location_name = lookup_any_location_id_to_name[location] + + if location_name in Locations.overworld_locations and zone == "overworld": + status = locations_array[Locations.major_location_offsets[location_name]] + if location_name == "Ocean Heart Container": + status = locations_array[ctx.overworld_item] + if location_name == "Armos Knights": + status = locations_array[ctx.armos_item] + if status & 0x10: + ctx.locations_checked.add(location) + locations_checked.append(location) + elif location_name in Locations.underworld1_locations and zone == "underworld1": + status = locations_array[Locations.floor_location_game_offsets_early[location_name]] + if status & 0x10: + ctx.locations_checked.add(location) + locations_checked.append(location) + elif location_name in Locations.underworld2_locations and zone == "underworld2": + status = locations_array[Locations.floor_location_game_offsets_late[location_name]] + if status & 0x10: + ctx.locations_checked.add(location) + locations_checked.append(location) + elif (location_name in Locations.shop_locations or "Take" in location_name) and zone == "caves": + shop_bit = get_shop_bit_from_name(location_name) + slot = 0 + context_slot = 0 + if "Left" in location_name: + slot = "slot1" + context_slot = 0 + elif "Middle" in location_name: + slot = "slot2" + context_slot = 1 + elif "Right" in location_name: + slot = "slot3" + context_slot = 2 + if locations_array[slot] & shop_bit > 0: + locations_checked.append(location) + ctx.shop_slots[context_slot] |= shop_bit + if locations_array["takeAnys"] and locations_array["takeAnys"] >= 4: + if "Take Any" in location_name: + short_name = None + if "Left" in location_name: + short_name = "TakeAnyLeft" + elif "Middle" in location_name: + short_name = "TakeAnyMiddle" + elif "Right" in location_name: + short_name = "TakeAnyRight" + if short_name is not None: + item_code = ctx.slot_data[short_name] + if item_code > 0: + ctx.bonus_items.append(item_code) + locations_checked.append(location) + if locations_checked: + await ctx.send_msgs([ + {"cmd": "LocationChecks", + "locations": locations_checked} + ]) + + +async def nes_sync_task(ctx: ZeldaContext): + logger.info("Starting nes connector. Use /nes for status information") + while not ctx.exit_event.is_set(): + error_status = None + if ctx.nes_streams: + (reader, writer) = ctx.nes_streams + msg = get_payload(ctx).encode() + writer.write(msg) + writer.write(b'\n') + try: + await asyncio.wait_for(writer.drain(), timeout=1.5) + try: + # Data will return a dict with up to two fields: + # 1. A keepalive response of the Players Name (always) + # 2. An array representing the memory values of the locations area (if in game) + data = await asyncio.wait_for(reader.readline(), timeout=5) + data_decoded = json.loads(data.decode()) + if data_decoded["overworldHC"] is not None: + ctx.overworld_item = data_decoded["overworldHC"] + if data_decoded["overworldPB"] is not None: + ctx.armos_item = data_decoded["overworldPB"] + if data_decoded['gameMode'] == 19 and ctx.finished_game == False: + await ctx.send_msgs([ + {"cmd": "StatusUpdate", + "status": 30} + ]) + ctx.finished_game = True + if ctx.game is not None and 'overworld' in data_decoded: + # Not just a keep alive ping, parse + asyncio.create_task(parse_locations(data_decoded['overworld'], ctx, False, "overworld")) + if ctx.game is not None and 'underworld1' in data_decoded: + asyncio.create_task(parse_locations(data_decoded['underworld1'], ctx, False, "underworld1")) + if ctx.game is not None and 'underworld2' in data_decoded: + asyncio.create_task(parse_locations(data_decoded['underworld2'], ctx, False, "underworld2")) + if ctx.game is not None and 'caves' in data_decoded: + asyncio.create_task(parse_locations(data_decoded['caves'], ctx, False, "caves")) + if not ctx.auth: + ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) + if ctx.auth == '': + logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate" + "the ROM using the same link but adding your slot name") + if ctx.awaiting_rom: + await ctx.server_auth(False) + reconcile_shops(ctx) + except asyncio.TimeoutError: + logger.debug("Read Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.nes_streams = None + except ConnectionResetError as e: + logger.debug("Read failed due to Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.nes_streams = None + except TimeoutError: + logger.debug("Connection Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.nes_streams = None + except ConnectionResetError: + logger.debug("Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.nes_streams = None + if ctx.nes_status == CONNECTION_TENTATIVE_STATUS: + if not error_status: + logger.info("Successfully Connected to NES") + ctx.nes_status = CONNECTION_CONNECTED_STATUS + else: + ctx.nes_status = f"Was tentatively connected but error occured: {error_status}" + elif error_status: + ctx.nes_status = error_status + logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates") + else: + try: + logger.debug("Attempting to connect to NES") + ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10) + ctx.nes_status = CONNECTION_TENTATIVE_STATUS + except TimeoutError: + logger.debug("Connection Timed Out, Trying Again") + ctx.nes_status = CONNECTION_TIMING_OUT_STATUS + continue + except ConnectionRefusedError: + logger.debug("Connection Refused, Trying Again") + ctx.nes_status = CONNECTION_REFUSED_STATUS + continue + + +if __name__ == '__main__': + # Text Mode to use !hint and such with games that have no text entry + Utils.init_logging("ZeldaClient") + + options = Utils.get_options() + DISPLAY_MSGS = options["tloz_options"]["display_msgs"] + + + async def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["tloz_options"].get("rom_start", True)) + if auto_start is True: + import webbrowser + webbrowser.open(romfile) + elif isinstance(auto_start, str) and os.path.isfile(auto_start): + subprocess.Popen([auto_start, romfile], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + + async def main(args): + if args.diff_file: + import Patch + logging.info("Patch file was supplied. Creating nes rom..") + meta, romfile = Patch.create_rom_file(args.diff_file) + if "server" in meta: + args.connect = meta["server"] + logging.info(f"Wrote rom file to {romfile}") + async_start(run_game(romfile)) + ctx = ZeldaContext(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync") + + await ctx.exit_event.wait() + ctx.server_address = None + + await ctx.shutdown() + + if ctx.nes_sync_task: + await ctx.nes_sync_task + + + import colorama + + parser = get_base_parser() + parser.add_argument('diff_file', default="", type=str, nargs="?", + help='Path to a Archipelago Binary Patch file') + args = parser.parse_args() + colorama.init() + + asyncio.run(main(args)) + colorama.deinit() diff --git a/data/lua/PKMN_RB/pkmn_rb.lua b/data/lua/PKMN_RB/pkmn_rb.lua index eaf7516547..036f7a6255 100644 --- a/data/lua/PKMN_RB/pkmn_rb.lua +++ b/data/lua/PKMN_RB/pkmn_rb.lua @@ -7,7 +7,7 @@ local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made" local STATE_UNINITIALIZED = "Uninitialized" -local SCRIPT_VERSION = 1 +local SCRIPT_VERSION = 3 local APIndex = 0x1A6E local APDeathLinkAddress = 0x00FD @@ -16,7 +16,8 @@ local EventFlagAddress = 0x1735 local MissableAddress = 0x161A local HiddenItemsAddress = 0x16DE local RodAddress = 0x1716 -local InGame = 0x1A71 +local DexSanityAddress = 0x1A71 +local InGameAddress = 0x1A84 local ClientCompatibilityAddress = 0xFF00 local ItemsReceived = nil @@ -34,6 +35,7 @@ local frame = 0 local u8 = nil local wU8 = nil local u16 +local compat = nil local function defineMemoryFunctions() local memDomain = {} @@ -70,18 +72,6 @@ function slice (tbl, s, e) return new end -function processBlock(block) - if block == nil then - return - end - local itemsBlock = block["items"] - memDomain.wram() - if itemsBlock ~= nil then - ItemsReceived = itemsBlock - end - deathlink_rec = block["deathlink"] -end - function difference(a, b) local aa = {} for k,v in pairs(a) do aa[v]=true end @@ -99,6 +89,7 @@ function generateLocationsChecked() events = uRange(EventFlagAddress, 0x140) missables = uRange(MissableAddress, 0x20) hiddenitems = uRange(HiddenItemsAddress, 0x0E) + dexsanity = uRange(DexSanityAddress, 19) rod = u8(RodAddress) data = {} @@ -108,6 +99,9 @@ function generateLocationsChecked() table.foreach(hiddenitems, function(k, v) table.insert(data, v) end) table.insert(data, rod) + if compat > 1 then + table.foreach(dexsanity, function(k, v) table.insert(data, v) end) + end return data end @@ -141,7 +135,15 @@ function receive() return end if l ~= nil then - processBlock(json.decode(l)) + block = json.decode(l) + if block ~= nil then + local itemsBlock = block["items"] + if itemsBlock ~= nil then + ItemsReceived = itemsBlock + end + deathlink_rec = block["deathlink"] + + end end -- Determine Message to send back memDomain.rom() @@ -156,15 +158,31 @@ function receive() seedName = newSeedName local retTable = {} retTable["scriptVersion"] = SCRIPT_VERSION - retTable["clientCompatibilityVersion"] = u8(ClientCompatibilityAddress) + + if compat == nil then + compat = u8(ClientCompatibilityAddress) + if compat < 2 then + InGameAddress = 0x1A71 + end + end + + retTable["clientCompatibilityVersion"] = compat retTable["playerName"] = playerName retTable["seedName"] = seedName memDomain.wram() - if u8(InGame) == 0xAC then + + in_game = u8(InGameAddress) + if in_game == 0x2A or in_game == 0xAC then retTable["locations"] = generateLocationsChecked() + elseif in_game ~= 0 then + print("Game may have crashed") + curstate = STATE_UNINITIALIZED + return end + retTable["deathLink"] = deathlink_send deathlink_send = false + msg = json.encode(retTable).."\n" local ret, error = gbSocket:send(msg) if ret == nil then @@ -193,16 +211,23 @@ function main() if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then if (frame % 5 == 0) then receive() - if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then - ItemIndex = u16(APIndex) - if deathlink_rec == true then - wU8(APDeathLinkAddress, 1) - elseif u8(APDeathLinkAddress) == 3 then - wU8(APDeathLinkAddress, 0) - deathlink_send = true - end - if ItemsReceived[ItemIndex + 1] ~= nil then - wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000) + in_game = u8(InGameAddress) + if in_game == 0x2A or in_game == 0xAC then + if u8(APItemAddress) == 0x00 then + ItemIndex = u16(APIndex) + if deathlink_rec == true then + wU8(APDeathLinkAddress, 1) + elseif u8(APDeathLinkAddress) == 3 then + wU8(APDeathLinkAddress, 0) + deathlink_send = true + end + if ItemsReceived[ItemIndex + 1] ~= nil then + item_id = ItemsReceived[ItemIndex + 1] - 172000000 + if item_id > 255 then + item_id = item_id - 256 + end + wU8(APItemAddress, item_id) + end end end end diff --git a/data/lua/TLoZ/TheLegendOfZeldaConnector.lua b/data/lua/TLoZ/TheLegendOfZeldaConnector.lua new file mode 100644 index 0000000000..aee4412bc0 --- /dev/null +++ b/data/lua/TLoZ/TheLegendOfZeldaConnector.lua @@ -0,0 +1,702 @@ +--Shamelessly based off the FF1 lua + +local socket = require("socket") +local json = require('json') +local math = require('math') + +local STATE_OK = "Ok" +local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" +local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made" +local STATE_UNINITIALIZED = "Uninitialized" + +local itemMessages = {} +local consumableStacks = nil +local prevstate = "" +local curstate = STATE_UNINITIALIZED +local zeldaSocket = nil +local frame = 0 +local gameMode = 0 + +local cave_index +local triforce_byte +local game_state + +local u8 = nil +local wU8 = nil +local isNesHawk = false + +local shopsChecked = {} +local shopSlotLeft = 0x0628 +local shopSlotMiddle = 0x0629 +local shopSlotRight = 0x062A + +--N.B.: you won't find these in a RAM map. They're flag values that the base patch derives from the cave ID. +local blueRingShopBit = 0x40 +local potionShopBit = 0x02 +local arrowShopBit = 0x08 +local candleShopBit = 0x10 +local shieldShopBit = 0x20 +local takeAnyCaveBit = 0x01 + + +local sword = 0x0657 +local bombs = 0x0658 +local maxBombs = 0x067C +local keys = 0x066E +local arrow = 0x0659 +local bow = 0x065A +local candle = 0x065B +local recorder = 0x065C +local food = 0x065D +local waterOfLife = 0x065E +local magicalRod = 0x065F +local raft = 0x0660 +local bookOfMagic = 0x0661 +local ring = 0x0662 +local stepladder = 0x0663 +local magicalKey = 0x0664 +local powerBracelet = 0x0665 +local letter = 0x0666 +local clockItem = 0x066C +local heartContainers = 0x066F +local partialHearts = 0x0670 +local triforceFragments = 0x0671 +local boomerang = 0x0674 +local magicalBoomerang = 0x0675 +local magicalShield = 0x0676 +local rupeesToAdd = 0x067D +local rupeesToSubtract = 0x067E +local itemsObtained = 0x0677 +local takeAnyCavesChecked = 0x0678 +local localTriforce = 0x0679 +local bonusItemsObtained = 0x067A + +itemAPids = { + ["Boomerang"] = 7100, + ["Bow"] = 7101, + ["Magical Boomerang"] = 7102, + ["Raft"] = 7103, + ["Stepladder"] = 7104, + ["Recorder"] = 7105, + ["Magical Rod"] = 7106, + ["Red Candle"] = 7107, + ["Book of Magic"] = 7108, + ["Magical Key"] = 7109, + ["Red Ring"] = 7110, + ["Silver Arrow"] = 7111, + ["Sword"] = 7112, + ["White Sword"] = 7113, + ["Magical Sword"] = 7114, + ["Heart Container"] = 7115, + ["Letter"] = 7116, + ["Magical Shield"] = 7117, + ["Candle"] = 7118, + ["Arrow"] = 7119, + ["Food"] = 7120, + ["Water of Life (Blue)"] = 7121, + ["Water of Life (Red)"] = 7122, + ["Blue Ring"] = 7123, + ["Triforce Fragment"] = 7124, + ["Power Bracelet"] = 7125, + ["Small Key"] = 7126, + ["Bomb"] = 7127, + ["Recovery Heart"] = 7128, + ["Five Rupees"] = 7129, + ["Rupee"] = 7130, + ["Clock"] = 7131, + ["Fairy"] = 7132 +} + +itemCodes = { + ["Boomerang"] = 0x1D, + ["Bow"] = 0x0A, + ["Magical Boomerang"] = 0x1E, + ["Raft"] = 0x0C, + ["Stepladder"] = 0x0D, + ["Recorder"] = 0x05, + ["Magical Rod"] = 0x10, + ["Red Candle"] = 0x07, + ["Book of Magic"] = 0x11, + ["Magical Key"] = 0x0B, + ["Red Ring"] = 0x13, + ["Silver Arrow"] = 0x09, + ["Sword"] = 0x01, + ["White Sword"] = 0x02, + ["Magical Sword"] = 0x03, + ["Heart Container"] = 0x1A, + ["Letter"] = 0x15, + ["Magical Shield"] = 0x1C, + ["Candle"] = 0x06, + ["Arrow"] = 0x08, + ["Food"] = 0x04, + ["Water of Life (Blue)"] = 0x1F, + ["Water of Life (Red)"] = 0x20, + ["Blue Ring"] = 0x12, + ["Triforce Fragment"] = 0x1B, + ["Power Bracelet"] = 0x14, + ["Small Key"] = 0x19, + ["Bomb"] = 0x00, + ["Recovery Heart"] = 0x22, + ["Five Rupees"] = 0x0F, + ["Rupee"] = 0x18, + ["Clock"] = 0x21, + ["Fairy"] = 0x23 +} + + +--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded +local function defineMemoryFunctions() + local memDomain = {} + local domains = memory.getmemorydomainlist() + if domains[1] == "System Bus" then + --NesHawk + isNesHawk = true + memDomain["systembus"] = function() memory.usememorydomain("System Bus") end + memDomain["ram"] = function() memory.usememorydomain("RAM") end + memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end + memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end + elseif domains[1] == "WRAM" then + --QuickNES + memDomain["systembus"] = function() memory.usememorydomain("System Bus") end + memDomain["ram"] = function() memory.usememorydomain("RAM") end + memDomain["saveram"] = function() memory.usememorydomain("WRAM") end + memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end + end + return memDomain +end + +local memDomain = defineMemoryFunctions() +u8 = memory.read_u8 +wU8 = memory.write_u8 +uRange = memory.readbyterange + +itemIDNames = {} + +for key, value in pairs(itemAPids) do + itemIDNames[value] = key +end + + + +local function determineItem(array) + memdomain.ram() + currentItemsObtained = u8(itemsObtained) + +end + +local function gotSword() + local currentSword = u8(sword) + wU8(sword, math.max(currentSword, 1)) +end + +local function gotWhiteSword() + local currentSword = u8(sword) + wU8(sword, math.max(currentSword, 2)) +end + +local function gotMagicalSword() + wU8(sword, 3) +end + +local function gotBomb() + local currentBombs = u8(bombs) + local currentMaxBombs = u8(maxBombs) + wU8(bombs, math.min(currentBombs + 4, currentMaxBombs)) + wU8(0x505, 0x29) -- Fake bomb to show item get. +end + +local function gotArrow() + local currentArrow = u8(arrow) + wU8(arrow, math.max(currentArrow, 1)) +end + +local function gotSilverArrow() + wU8(arrow, 2) +end + +local function gotBow() + wU8(bow, 1) +end + +local function gotCandle() + local currentCandle = u8(candle) + wU8(candle, math.max(currentCandle, 1)) +end + +local function gotRedCandle() + wU8(candle, 2) +end + +local function gotRecorder() + wU8(recorder, 1) +end + +local function gotFood() + wU8(food, 1) +end + +local function gotWaterOfLifeBlue() + local currentWaterOfLife = u8(waterOfLife) + wU8(waterOfLife, math.max(currentWaterOfLife, 1)) +end + +local function gotWaterOfLifeRed() + wU8(waterOfLife, 2) +end + +local function gotMagicalRod() + wU8(magicalRod, 1) +end + +local function gotBookOfMagic() + wU8(bookOfMagic, 1) +end + +local function gotRaft() + wU8(raft, 1) +end + +local function gotBlueRing() + local currentRing = u8(ring) + wU8(ring, math.max(currentRing, 1)) + memDomain.saveram() + local currentTunicColor = u8(0x0B92) + if currentTunicColor == 0x29 then + wU8(0x0B92, 0x32) + wU8(0x0804, 0x32) + end +end + +local function gotRedRing() + wU8(ring, 2) + memDomain.saveram() + wU8(0x0B92, 0x16) + wU8(0x0804, 0x16) +end + +local function gotStepladder() + wU8(stepladder, 1) +end + +local function gotMagicalKey() + wU8(magicalKey, 1) +end + +local function gotPowerBracelet() + wU8(powerBracelet, 1) +end + +local function gotLetter() + wU8(letter, 1) +end + +local function gotHeartContainer() + local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4) + if currentHeartContainers < 16 then + currentHeartContainers = math.min(currentHeartContainers + 1, 16) + local currentHearts = bit.band(u8(heartContainers), 0x0F) + 1 + wU8(heartContainers, bit.lshift(currentHeartContainers, 4) + currentHearts) + end +end + +local function gotTriforceFragment() + local triforceByte = 0xFF + local newTriforceCount = u8(localTriforce) + 1 + wU8(localTriforce, newTriforceCount) +end + +local function gotBoomerang() + wU8(boomerang, 1) +end + +local function gotMagicalBoomerang() + wU8(magicalBoomerang, 1) +end + +local function gotMagicalShield() + wU8(magicalShield, 1) +end + +local function gotRecoveryHeart() + local currentHearts = bit.band(u8(heartContainers), 0x0F) + local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4) + if currentHearts < currentHeartContainers then + currentHearts = currentHearts + 1 + else + wU8(partialHearts, 0xFF) + end + currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts) + wU8(heartContainers, currentHearts) +end + +local function gotFairy() + local currentHearts = bit.band(u8(heartContainers), 0x0F) + local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4) + if currentHearts < currentHeartContainers then + currentHearts = currentHearts + 3 + if currentHearts > currentHeartContainers then + currentHearts = currentHeartContainers + wU8(partialHearts, 0xFF) + end + else + wU8(partialHearts, 0xFF) + end + currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts) + wU8(heartContainers, currentHearts) +end + +local function gotClock() + wU8(clockItem, 1) +end + +local function gotFiveRupees() + local currentRupeesToAdd = u8(rupeesToAdd) + wU8(rupeesToAdd, math.min(currentRupeesToAdd + 5, 255)) +end + +local function gotSmallKey() + wU8(keys, math.min(u8(keys) + 1, 9)) +end + +local function gotItem(item) + --Write itemCode to itemToLift + --Write 128 to itemLiftTimer + --Write 4 to sound effect queue + itemName = itemIDNames[item] + itemCode = itemCodes[itemName] + wU8(0x505, itemCode) + wU8(0x506, 128) + wU8(0x602, 4) + numberObtained = u8(itemsObtained) + 1 + wU8(itemsObtained, numberObtained) + if itemName == "Boomerang" then gotBoomerang() end + if itemName == "Bow" then gotBow() end + if itemName == "Magical Boomerang" then gotMagicalBoomerang() end + if itemName == "Raft" then gotRaft() end + if itemName == "Stepladder" then gotStepladder() end + if itemName == "Recorder" then gotRecorder() end + if itemName == "Magical Rod" then gotMagicalRod() end + if itemName == "Red Candle" then gotRedCandle() end + if itemName == "Book of Magic" then gotBookOfMagic() end + if itemName == "Magical Key" then gotMagicalKey() end + if itemName == "Red Ring" then gotRedRing() end + if itemName == "Silver Arrow" then gotSilverArrow() end + if itemName == "Sword" then gotSword() end + if itemName == "White Sword" then gotWhiteSword() end + if itemName == "Magical Sword" then gotMagicalSword() end + if itemName == "Heart Container" then gotHeartContainer() end + if itemName == "Letter" then gotLetter() end + if itemName == "Magical Shield" then gotMagicalShield() end + if itemName == "Candle" then gotCandle() end + if itemName == "Arrow" then gotArrow() end + if itemName == "Food" then gotFood() end + if itemName == "Water of Life (Blue)" then gotWaterOfLifeBlue() end + if itemName == "Water of Life (Red)" then gotWaterOfLifeRed() end + if itemName == "Blue Ring" then gotBlueRing() end + if itemName == "Triforce Fragment" then gotTriforceFragment() end + if itemName == "Power Bracelet" then gotPowerBracelet() end + if itemName == "Small Key" then gotSmallKey() end + if itemName == "Bomb" then gotBomb() end + if itemName == "Recovery Heart" then gotRecoveryHeart() end + if itemName == "Five Rupees" then gotFiveRupees() end + if itemName == "Fairy" then gotFairy() end + if itemName == "Clock" then gotClock() end +end + + +local function StateOKForMainLoop() + memDomain.ram() + local gameMode = u8(0x12) + return gameMode == 5 +end + +local function checkCaveItemObtained() + memDomain.ram() + local returnTable = {} + returnTable["slot1"] = u8(shopSlotLeft) + returnTable["slot2"] = u8(shopSlotMiddle) + returnTable["slot3"] = u8(shopSlotRight) + returnTable["takeAnys"] = u8(takeAnyCavesChecked) + return returnTable +end + +function table.empty (self) + for _, _ in pairs(self) do + return false + end + return true +end + +function slice (tbl, s, e) + local pos, new = 1, {} + for i = s + 1, e do + new[pos] = tbl[i] + pos = pos + 1 + end + return new +end + +local bizhawk_version = client.getversion() +local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5") +local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8") + +local function getMaxMessageLength() + if is23Or24Or25 then + return client.screenwidth()/11 + elseif is26To28 then + return client.screenwidth()/12 + end +end + +local function drawText(x, y, message, color) + if is23Or24Or25 then + gui.addmessage(message) + elseif is26To28 then + gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client") + end +end + +local function clearScreen() + if is23Or24Or25 then + return + elseif is26To28 then + drawText(0, 0, "", "black") + end +end + +local function drawMessages() + if table.empty(itemMessages) then + clearScreen() + return + end + local y = 10 + found = false + maxMessageLength = getMaxMessageLength() + for k, v in pairs(itemMessages) do + if v["TTL"] > 0 then + message = v["message"] + while true do + drawText(5, y, message:sub(1, maxMessageLength), v["color"]) + y = y + 16 + + message = message:sub(maxMessageLength + 1, message:len()) + if message:len() == 0 then + break + end + end + newTTL = 0 + if is26To28 then + newTTL = itemMessages[k]["TTL"] - 1 + end + itemMessages[k]["TTL"] = newTTL + found = true + end + end + if found == false then + clearScreen() + end +end + +function generateOverworldLocationChecked() + memDomain.ram() + data = uRange(0x067E, 0x81) + data[0] = nil + return data +end + +function getHCLocation() + memDomain.rom() + data = u8(0x1789A) + return data +end + +function getPBLocation() + memDomain.rom() + data = u8(0x10CB2) + return data +end + +function generateUnderworld16LocationChecked() + memDomain.ram() + data = uRange(0x06FE, 0x81) + data[0] = nil + return data +end + +function generateUnderworld79LocationChecked() + memDomain.ram() + data = uRange(0x077E, 0x81) + data[0] = nil + return data +end + +function updateTriforceFragments() + memDomain.ram() + local triforceByte = 0xFF + totalTriforceCount = u8(localTriforce) + local currentPieces = bit.rshift(triforceByte, 8 - math.min(8, totalTriforceCount)) + wU8(triforceFragments, currentPieces) +end + +function processBlock(block) + if block ~= nil then + local msgBlock = block['messages'] + if msgBlock ~= nil then + for i, v in pairs(msgBlock) do + if itemMessages[i] == nil then + local msg = {TTL=450, message=v, color=0xFFFF0000} + itemMessages[i] = msg + end + end + end + local bonusItems = block["bonusItems"] + if bonusItems ~= nil and isInGame then + for i, item in ipairs(bonusItems) do + memDomain.ram() + if i > u8(bonusItemsObtained) then + if u8(0x505) == 0 then + gotItem(item) + wU8(itemsObtained, u8(itemsObtained) - 1) + wU8(bonusItemsObtained, u8(bonusItemsObtained) + 1) + end + end + end + end + local itemsBlock = block["items"] + memDomain.saveram() + isInGame = StateOKForMainLoop() + updateTriforceFragments() + if itemsBlock ~= nil and isInGame then + memDomain.ram() + --get item from item code + --get function from item + --do function + for i, item in ipairs(itemsBlock) do + memDomain.ram() + if u8(0x505) == 0 then + if i > u8(itemsObtained) then + gotItem(item) + end + end + end + end + local shopsBlock = block["shops"] + if shopsBlock ~= nil then + wU8(shopSlotLeft, bit.bor(u8(shopSlotLeft), shopsBlock["left"])) + wU8(shopSlotMiddle, bit.bor(u8(shopSlotMiddle), shopsBlock["middle"])) + wU8(shopSlotRight, bit.bor(u8(shopSlotRight), shopsBlock["right"])) + end + end +end + +function difference(a, b) + local aa = {} + for k,v in pairs(a) do aa[v]=true end + for k,v in pairs(b) do aa[v]=nil end + local ret = {} + local n = 0 + for k,v in pairs(a) do + if aa[v] then n=n+1 ret[n]=v end + end + return ret +end + +function receive() + l, e = zeldaSocket:receive() + if e == 'closed' then + if curstate == STATE_OK then + print("Connection closed") + end + curstate = STATE_UNINITIALIZED + return + elseif e == 'timeout' then + print("timeout") + return + elseif e ~= nil then + print(e) + curstate = STATE_UNINITIALIZED + return + end + processBlock(json.decode(l)) + + -- Determine Message to send back + memDomain.rom() + local playerName = uRange(0x1F, 0x10) + playerName[0] = nil + local retTable = {} + retTable["playerName"] = playerName + if StateOKForMainLoop() then + retTable["overworld"] = generateOverworldLocationChecked() + retTable["underworld1"] = generateUnderworld16LocationChecked() + retTable["underworld2"] = generateUnderworld79LocationChecked() + end + retTable["caves"] = checkCaveItemObtained() + memDomain.ram() + if gameMode ~= 19 then + gameMode = u8(0x12) + end + retTable["gameMode"] = gameMode + retTable["overworldHC"] = getHCLocation() + retTable["overworldPB"] = getPBLocation() + retTable["itemsObtained"] = u8(itemsObtained) + msg = json.encode(retTable).."\n" + local ret, error = zeldaSocket:send(msg) + if ret == nil then + print(error) + elseif curstate == STATE_INITIAL_CONNECTION_MADE then + curstate = STATE_TENTATIVELY_CONNECTED + elseif curstate == STATE_TENTATIVELY_CONNECTED then + print("Connected!") + itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"} + curstate = STATE_OK + end +end + +function main() + if (is23Or24Or25 or is26To28) == false then + print("Must use a version of bizhawk 2.3.1 or higher") + return + end + server, error = socket.bind('localhost', 52980) + + while true do + gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow") + frame = frame + 1 + drawMessages() + if not (curstate == prevstate) then + -- console.log("Current state: "..curstate) + prevstate = curstate + end + if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then + if (frame % 60 == 0) then + gui.drawEllipse(248, 9, 6, 6, "Black", "Blue") + receive() + else + gui.drawEllipse(248, 9, 6, 6, "Black", "Green") + end + elseif (curstate == STATE_UNINITIALIZED) then + gui.drawEllipse(248, 9, 6, 6, "Black", "White") + if (frame % 60 == 0) then + gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow") + + drawText(5, 8, "Waiting for client", 0xFFFF0000) + drawText(5, 32, "Please start Zelda1Client.exe", 0xFFFF0000) + + -- Advance so the messages are drawn + emu.frameadvance() + server:settimeout(2) + print("Attempting to connect") + local client, timeout = server:accept() + if timeout == nil then + -- print('Initial Connection Made') + curstate = STATE_INITIAL_CONNECTION_MADE + zeldaSocket = client + zeldaSocket:settimeout(0) + end + end + end + emu.frameadvance() + end +end + +main() \ No newline at end of file diff --git a/data/lua/TLoZ/core.dll b/data/lua/TLoZ/core.dll new file mode 100644 index 0000000000..3e9569571a Binary files /dev/null and b/data/lua/TLoZ/core.dll differ diff --git a/data/lua/TLoZ/json.lua b/data/lua/TLoZ/json.lua new file mode 100644 index 0000000000..0833bf6fb4 --- /dev/null +++ b/data/lua/TLoZ/json.lua @@ -0,0 +1,380 @@ +-- +-- json.lua +-- +-- Copyright (c) 2015 rxi +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. +-- + +local json = { _version = "0.1.0" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\\\", + [ "\"" ] = "\\\"", + [ "\b" ] = "\\b", + [ "\f" ] = "\\f", + [ "\n" ] = "\\n", + [ "\r" ] = "\\r", + [ "\t" ] = "\\t", +} + +local escape_char_map_inv = { [ "\\/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return escape_char_map[c] or string.format("\\u%04x", c:byte()) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if val[1] ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + --local line_count = 1 + --local col_count = 1 + --for i = 1, idx - 1 do + -- col_count = col_count + 1 + -- if str:sub(i, i) == "\n" then + -- line_count = line_count + 1 + -- col_count = 1 + -- end + -- end + -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(3, 6), 16 ) + local n2 = tonumber( s:sub(9, 12), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local has_unicode_escape = false + local has_surrogate_escape = false + local has_escape = false + local last + for j = i + 1, #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + end + + if last == 92 then -- "\\" (escape char) + if x == 117 then -- "u" (unicode escape sequence) + local hex = str:sub(j + 1, j + 5) + if not hex:find("%x%x%x%x") then + decode_error(str, j, "invalid unicode escape in string") + end + if hex:find("^[dD][89aAbB]") then + has_surrogate_escape = true + else + has_unicode_escape = true + end + else + local c = string.char(x) + if not escape_chars[c] then + decode_error(str, j, "invalid escape char '" .. c .. "' in string") + end + has_escape = true + end + last = nil + + elseif x == 34 then -- '"' (end of string) + local s = str:sub(i + 1, j - 1) + if has_surrogate_escape then + s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) + end + if has_unicode_escape then + s = s:gsub("\\u....", parse_unicode_escape) + end + if has_escape then + s = s:gsub("\\.", escape_char_map_inv) + end + return s, j + 1 + + else + last = x + end + end + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + return ( parse(str, next_char(str, 1, space_chars, true)) ) +end + + +return json \ No newline at end of file diff --git a/data/lua/TLoZ/socket.lua b/data/lua/TLoZ/socket.lua new file mode 100644 index 0000000000..a98e952115 --- /dev/null +++ b/data/lua/TLoZ/socket.lua @@ -0,0 +1,132 @@ +----------------------------------------------------------------------------- +-- LuaSocket helper module +-- Author: Diego Nehab +-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ +----------------------------------------------------------------------------- + +----------------------------------------------------------------------------- +-- Declare module and import dependencies +----------------------------------------------------------------------------- +local base = _G +local string = require("string") +local math = require("math") +local socket = require("socket.core") +module("socket") + +----------------------------------------------------------------------------- +-- Exported auxiliar functions +----------------------------------------------------------------------------- +function connect(address, port, laddress, lport) + local sock, err = socket.tcp() + if not sock then return nil, err end + if laddress then + local res, err = sock:bind(laddress, lport, -1) + if not res then return nil, err end + end + local res, err = sock:connect(address, port) + if not res then return nil, err end + return sock +end + +function bind(host, port, backlog) + local sock, err = socket.tcp() + if not sock then return nil, err end + sock:setoption("reuseaddr", true) + local res, err = sock:bind(host, port) + if not res then return nil, err end + res, err = sock:listen(backlog) + if not res then return nil, err end + return sock +end + +try = newtry() + +function choose(table) + return function(name, opt1, opt2) + if base.type(name) ~= "string" then + name, opt1, opt2 = "default", name, opt1 + end + local f = table[name or "nil"] + if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) + else return f(opt1, opt2) end + end +end + +----------------------------------------------------------------------------- +-- Socket sources and sinks, conforming to LTN12 +----------------------------------------------------------------------------- +-- create namespaces inside LuaSocket namespace +sourcet = {} +sinkt = {} + +BLOCKSIZE = 2048 + +sinkt["close-when-done"] = function(sock) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function(self, chunk, err) + if not chunk then + sock:close() + return 1 + else return sock:send(chunk) end + end + }) +end + +sinkt["keep-open"] = function(sock) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function(self, chunk, err) + if chunk then return sock:send(chunk) + else return 1 end + end + }) +end + +sinkt["default"] = sinkt["keep-open"] + +sink = choose(sinkt) + +sourcet["by-length"] = function(sock, length) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function() + if length <= 0 then return nil end + local size = math.min(socket.BLOCKSIZE, length) + local chunk, err = sock:receive(size) + if err then return nil, err end + length = length - string.len(chunk) + return chunk + end + }) +end + +sourcet["until-closed"] = function(sock) + local done + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function() + if done then return nil end + local chunk, err, partial = sock:receive(socket.BLOCKSIZE) + if not err then return chunk + elseif err == "closed" then + sock:close() + done = 1 + return partial + else return nil, err end + end + }) +end + + +sourcet["default"] = sourcet["until-closed"] + +source = choose(sourcet) diff --git a/docs/options api.md b/docs/options api.md new file mode 100644 index 0000000000..a1407f2ceb --- /dev/null +++ b/docs/options api.md @@ -0,0 +1,188 @@ +# Archipelago Options API + +This document covers some of the generic options available using Archipelago's options handling system. + +For more information on where these options go in your world please refer to: + - [world api.md](/docs/world%20api.md) + +Archipelago will be abbreviated as "AP" from now on. + +## Option Definitions +Option parsing in AP is done using different Option classes. For each option you would like to have in your game, you +need to create: +- A new option class with a docstring detailing what the option will do to your user. +- A `display_name` to be displayed on the webhost. +- A new entry in the `option_definitions` dict for your World. +By style and convention, the internal names should be snake_case. If the option supports having multiple sub_options +such as Choice options, these can be defined with `option_my_sub_option`, where the preceding `option_` is required and +stripped for users, so will show as `my_sub_option` in yaml files and if `auto_display_name` is True `My Sub Option` +on the webhost. All options support `random` as a generic option. `random` chooses from any of the available +values for that option, and is reserved by AP. You can set this as your default value but you cannot define your own +new `option_random`. + +### Option Creation +As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's +create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our +options: + +```python +# Options.py +class StartingSword(Toggle): + """Adds a sword to your starting inventory.""" + display_name = "Start With Sword" + + +example_options = { + "starting_sword": StartingSword +} +``` + +This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it +to our world's `__init__.py`: + +```python +from worlds.AutoWorld import World +from .Options import options + + +class ExampleWorld(World): + option_definitions = options +``` + +### Option Checking +Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after +world instantiation. These are created as attributes on the MultiWorld and can be accessed with +`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to +relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is +the option class's `value` attribute. For our example above we can do a simple check: +```python +if self.multiworld.starting_sword[self.player]: + do_some_things() +``` + +or if I need a boolean object, such as in my slot_data I can access it as: +```python +start_with_sword = bool(self.multiworld.starting_sword[self.player].value) +``` + +## Generic Option Classes +These options are generically available to every game automatically, but can be overridden for slightly different +behavior, if desired. See `worlds/soe/Options.py` for an example. + +### Accessibility +Sets rules for availability of locations for the player. `Items` is for all items available but not necessarily all +locations, such as self-locking keys, but needs to be set by the world for this to be different from locations access. + +### ProgressionBalancing +Algorithm for moving progression items into earlier spheres to make the gameplay experience a bit smoother. Can be +overridden if you want a different default value. + +### LocalItems +Forces the players' items local to their world. + +### NonLocalItems +Forces the players' items outside their world. + +### StartInventory +Allows the player to define a dictionary of starting items with item name and quantity. + +### StartHints +Gives the player starting hints for where the items defined here are. + +### StartLocationHints +Gives the player starting hints for the items on locations defined here. + +### ExcludeLocations +Marks locations given here as `LocationProgressType.Excluded` so that progression items can't be placed on them. + +### PriorityLocations +Marks locations given here as `LocationProgressType.Priority` forcing progression items on them. + +### ItemLinks +Allows users to share their item pool with other players. Currently item links are per game. A link of one game between +two players will combine their items in the link into a single item, which then gets replaced with `World.create_filler()`. + +## Basic Option Classes +### Toggle +The example above. This simply has 0 and 1 as its available results with 0 (false) being the default value. Cannot be +compared to strings but can be directly compared to True and False. + +### DefaultOnToggle +Like Toggle, but 1 (true) is the default value. + +### Choice +A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do +comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with: +```python +if self.multiworld.sword_availability[self.player] == "early_sword": + do_early_sword_things() +``` + +or: +```python +from .Options import SwordAvailability + +if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword: + do_early_sword_things() +``` + +### Range +A numeric option allowing a variety of integers including the endpoints. Has a default `range_start` of 0 and default +`range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string +comparisons. + +### SpecialRange +Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value. +For example: +```python +special_range_names: { + "normal": 20, + "extreme": 99, +} +``` + +will let users use the names "normal" or "extreme" in their options selections, but will still return those as integers +to you. Useful if you want special handling regarding those specified values. + +## More Advanced Options +### FreeText +This is an option that allows the user to enter any possible string value. Can only be compared with strings, and has +no validation step, so if this needs to be validated, you can either add a validation step to the option class or +within the world. + +### TextChoice +Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any +user defined string as a valid option, so will either need to be validated by adding a validation step to the option +class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified +point, `self.multiworld.my_option[self.player].current_key` will always return a string. + +### PlandoBosses +An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports +everything it does, as well as having multiple validation steps to automatically support boss plando from users. If +using this class, you must define `bosses`, a set of valid boss names, and `locations`, a set of valid boss location +names, and `def can_place_boss`, which passes a boss and location, allowing you to check if that placement is valid for +your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is +also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False +by default, and will reject duplicate boss names from the user. For an example of using this class, refer to +`worlds.alttp.options.py` + +### OptionDict +This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the +template. If you set a [Schema](https://pypi.org/project/schema/) on the class with `schema = Schema()`, then the +options system will automatically validate the user supplied data against the schema to ensure it's in the correct +format. + +### ItemDict +Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world. + +### OptionList +This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You +can define a set of keys in `valid_keys`, and a default list if you want certain options to be available without editing +for this. If `valid_keys_casefold` is true, the verification will be case-insensitive; `verify_item_name` will check +that each value is a valid item name; and`verify_location_name` will check that each value is a valid location name. + +### OptionSet +Like OptionList, but returns a set, preventing duplicates. + +### ItemSet +Like OptionSet, but will verify that all the items in the set are a valid name for an item for your world. diff --git a/docs/webhost configuration sample.yaml b/docs/webhost configuration sample.yaml index f007805b9e..70050b0590 100644 --- a/docs/webhost configuration sample.yaml +++ b/docs/webhost configuration sample.yaml @@ -48,5 +48,5 @@ # TODO #JSON_AS_ASCII: false -# Patch target. This is the address encoded into the patch that will be used for client auto-connect. -#PATCH_TARGET: archipelago.gg \ No newline at end of file +# Host Address. This is the address encoded into the patch that will be used for client auto-connect. +#HOST_ADDRESS: archipelago.gg diff --git a/host.yaml b/host.yaml index ce242fd4c9..5d9ec56ee9 100644 --- a/host.yaml +++ b/host.yaml @@ -107,7 +107,7 @@ factorio_options: filter_item_sends: false # Whether to send chat messages from players on the Factorio server to Archipelago. bridge_chat_out: true -minecraft_options: +minecraft_options: forge_directory: "Minecraft Forge server" max_heap_size: "2G" # release channel, currently "release", or "beta" @@ -125,6 +125,15 @@ soe_options: rom_file: "Secret of Evermore (USA).sfc" ffr_options: display_msgs: true +tloz_options: + # File name of the Zelda 1 + rom_file: "Legend of Zelda, The (U) (PRG0) [!].nes" + # Set this to false to never autostart a rom (such as after patching) + # true for operating system default program + # Alternatively, a path to a program to open the .nes file with + rom_start: true + # Display message inside of Bizhawk + display_msgs: true dkc3_options: # File name of the DKC3 US rom rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc" @@ -139,6 +148,12 @@ pokemon_rb_options: # True for operating system default program # Alternatively, a path to a program to open the .gb file with rom_start: true + +wargroove_options: + # Locate the Wargroove root directory on your system. + # This is used by the Wargroove client, so it knows where to send communication files to + root_directory: "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" + zillion_options: # File name of the Zillion US rom rom_file: "Zillion (UE) [!].sms" diff --git a/inno_setup.iss b/inno_setup.iss index f7748ced02..85dd49c62c 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -25,9 +25,9 @@ OutputBaseFilename=Setup {#MyAppName} {#MyAppVersionText} Compression=lzma2 SolidCompression=yes LZMANumBlockThreads=8 -ArchitecturesInstallIn64BitMode=x64 +ArchitecturesInstallIn64BitMode=x64 arm64 ChangesAssociations=yes -ArchitecturesAllowed=x64 +ArchitecturesAllowed=x64 arm64 AllowNoIcons=yes SetupIconFile={#MyAppIcon} UninstallDisplayIcon={app}\{#MyAppExeName} diff --git a/setup.py b/setup.py index 382d3dc599..5f109d7a05 100644 --- a/setup.py +++ b/setup.py @@ -7,15 +7,21 @@ import sys import sysconfig import typing import zipfile -from collections.abc import Iterable -from hashlib import sha3_512 -from pathlib import Path +import urllib.request +import io +import json +import threading import subprocess import pkg_resources +from collections.abc import Iterable +from hashlib import sha3_512 +from pathlib import Path + + # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it try: - requirement = 'cx-Freeze>=6.14.1' + requirement = 'cx-Freeze>=6.14.7' pkg_resources.require(requirement) import cx_Freeze except pkg_resources.ResolutionError: @@ -45,9 +51,74 @@ apworlds: set = { "Rogue Legacy", "Donkey Kong Country 3", "Super Mario World", + "Stardew Valley", "Timespinner", + "Minecraft", + "The Messenger", } + +def download_SNI(): + print("Updating SNI") + machine_to_go = { + "x86_64": "amd64", + "aarch64": "arm64", + "armv7l": "arm" + } + platform_name = platform.system().lower() + machine_name = platform.machine().lower() + # force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH + machine_name = "amd64" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name) + with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request: + data = json.load(request) + files = data["assets"] + + source_url = None + + for file in files: + download_url: str = file["browser_download_url"] + machine_match = download_url.rsplit("-", 1)[1].split(".", 1)[0] == machine_name + if platform_name in download_url and machine_match: + # prefer "many" builds + if "many" in download_url: + source_url = download_url + break + source_url = download_url + + if source_url and source_url.endswith(".zip"): + with urllib.request.urlopen(source_url) as download: + with zipfile.ZipFile(io.BytesIO(download.read()), "r") as zf: + for member in zf.infolist(): + zf.extract(member, path="SNI") + print(f"Downloaded SNI from {source_url}") + + elif source_url and (source_url.endswith(".tar.xz") or source_url.endswith(".tar.gz")): + import tarfile + mode = "r:xz" if source_url.endswith(".tar.xz") else "r:gz" + with urllib.request.urlopen(source_url) as download: + sni_dir = None + with tarfile.open(fileobj=io.BytesIO(download.read()), mode=mode) as tf: + for member in tf.getmembers(): + if member.name.startswith("/") or "../" in member.name: + raise ValueError(f"Unexpected file '{member.name}' in {source_url}") + elif member.isdir() and not sni_dir: + sni_dir = member.name + elif member.isfile() and not sni_dir or not member.name.startswith(sni_dir): + raise ValueError(f"Expected folder before '{member.name}' in {source_url}") + elif member.isfile() and sni_dir: + tf.extract(member) + # sadly SNI is in its own folder on non-windows, so we need to rename + shutil.rmtree("SNI", True) + os.rename(sni_dir, "SNI") + print(f"Downloaded SNI from {source_url}") + + elif source_url: + print(f"Don't know how to extract SNI from {source_url}") + + else: + print(f"No SNI found for system spec {platform_name} {machine_name}") + + if os.path.exists("X:/pw.txt"): print("Using signtool") with open("X:/pw.txt", encoding="utf-8-sig") as f: @@ -173,6 +244,10 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE): print("Created Manifest") def run(self): + # start downloading sni asap + sni_thread = threading.Thread(target=download_SNI, name="SNI Downloader") + sni_thread.start() + # pre build steps print(f"Outputting to: {self.buildfolder}") os.makedirs(self.buildfolder, exist_ok=True) @@ -184,6 +259,9 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE): self.buildtime = datetime.datetime.utcnow() super().run() + # need to finish download before copying + sni_thread.join() + # include_files seems to not be done automatically. implement here for src, dst in self.include_files: print(f"copying {src} -> {self.buildfolder / dst}") diff --git a/test/TestBase.py b/test/TestBase.py index 5ffcb5ce4d..a2c9bc28aa 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -1,6 +1,6 @@ +import pathlib import typing import unittest -import pathlib from argparse import Namespace import Utils @@ -112,6 +112,12 @@ class WorldTestBase(unittest.TestCase): self.world_setup() def world_setup(self, seed: typing.Optional[int] = None) -> None: + if type(self) is WorldTestBase or \ + (hasattr(WorldTestBase, self._testMethodName) + and not self.run_default_tests and + getattr(self, self._testMethodName).__code__ is + getattr(WorldTestBase, self._testMethodName, None).__code__): + return # setUp gets called for tests defined in the base class. We skip world_setup here. if not hasattr(self, "game"): raise NotImplementedError("didn't define game name") self.multiworld = MultiWorld(1) @@ -128,7 +134,9 @@ class WorldTestBase(unittest.TestCase): for step in gen_steps: call_all(self.multiworld, step) + # methods that can be called within tests def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]]) -> None: + """Collects all pre-placed items and items in the multiworld itempool except those provided""" if isinstance(item_names, str): item_names = (item_names,) for item in self.multiworld.get_items(): @@ -136,12 +144,14 @@ class WorldTestBase(unittest.TestCase): self.multiworld.state.collect(item) def get_item_by_name(self, item_name: str) -> Item: + """Returns the first item found in placed items, or in the itempool with the matching name""" for item in self.multiworld.get_items(): if item.name == item_name: return item raise ValueError("No such item") def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """Returns actual items from the itempool that match the provided name(s)""" if isinstance(item_names, str): item_names = (item_names,) return [item for item in self.multiworld.itempool if item.name in item_names] @@ -153,12 +163,14 @@ class WorldTestBase(unittest.TestCase): return items def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: + """Collects the provided item(s) into state""" if isinstance(items, Item): items = (items,) for item in items: self.multiworld.state.collect(item) def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: + """Removes the provided item(s) from state""" if isinstance(items, Item): items = (items,) for item in items: @@ -167,17 +179,22 @@ class WorldTestBase(unittest.TestCase): self.multiworld.state.remove(item) def can_reach_location(self, location: str) -> bool: + """Determines if the current state can reach the provide location name""" return self.multiworld.state.can_reach(location, "Location", 1) def can_reach_entrance(self, entrance: str) -> bool: + """Determines if the current state can reach the provided entrance name""" return self.multiworld.state.can_reach(entrance, "Entrance", 1) def count(self, item_name: str) -> int: + """Returns the amount of an item currently in state""" return self.multiworld.state.count(item_name, 1) def assertAccessDependency(self, locations: typing.List[str], possible_items: typing.Iterable[typing.Iterable[str]]) -> None: + """Asserts that the provided locations can't be reached without the listed items but can be reached with any + one of the provided combinations""" all_items = [item_name for item_names in possible_items for item_name in item_names] self.collect_all_but(all_items) @@ -190,4 +207,43 @@ class WorldTestBase(unittest.TestCase): self.remove(items) def assertBeatable(self, beatable: bool): + """Asserts that the game can be beaten with the current state""" self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable) + + # following tests are automatically run + @property + def run_default_tests(self) -> bool: + """Not possible or identical to the base test that's always being run already""" + return (self.options + or self.setUp.__code__ is not WorldTestBase.setUp.__code__ + or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__) + + @property + def constructed(self) -> bool: + """A multiworld has been constructed by this point""" + return hasattr(self, "game") and hasattr(self, "multiworld") + + def testAllStateCanReachEverything(self): + """Ensure all state can reach everything and complete the game with the defined options""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + excluded = self.multiworld.exclude_locations[1].value + state = self.multiworld.get_all_state(False) + for location in self.multiworld.get_locations(): + if location.name not in excluded: + with self.subTest("Location should be reached", location=location): + self.assertTrue(location.can_reach(state), f"{location.name} unreachable") + with self.subTest("Beatable"): + self.multiworld.state = state + self.assertBeatable(True) + + def testEmptyStateCanReachSomething(self): + """Ensure empty state can reach at least one location with the defined options""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + state = CollectionState(self.multiworld) + locations = self.multiworld.get_reachable_locations(state, 1) + self.assertGreater(len(locations), 0, + "Need to be able to reach at least one location to get started.") diff --git a/test/general/TestImplemented.py b/test/general/TestImplemented.py index 66a09981ba..22c546eff1 100644 --- a/test/general/TestImplemented.py +++ b/test/general/TestImplemented.py @@ -8,7 +8,7 @@ class TestImplemented(unittest.TestCase): def testCompletionCondition(self): """Ensure a completion condition is set that has requirements.""" for game_name, world_type in AutoWorldRegister.world_types.items(): - if not world_type.hidden and game_name not in {"ArchipIDLE", "Sudoku"}: + if not world_type.hidden and game_name not in {"Sudoku"}: with self.subTest(game_name): multiworld = setup_solo_multiworld(world_type) self.assertFalse(multiworld.completion_condition[1](multiworld.state)) diff --git a/test/general/TestLocations.py b/test/general/TestLocations.py index 5dbb1d55fc..f1b3349eeb 100644 --- a/test/general/TestLocations.py +++ b/test/general/TestLocations.py @@ -1,6 +1,6 @@ import unittest from collections import Counter -from worlds.AutoWorld import AutoWorldRegister +from worlds.AutoWorld import AutoWorldRegister, call_all from . import setup_solo_multiworld @@ -23,3 +23,33 @@ class TestBase(unittest.TestCase): for location in locations: self.assertIn(location.name, world_type.location_name_to_id) self.assertEqual(location.address, world_type.location_name_to_id[location.name]) + + def testLocationCreationSteps(self): + """Tests that Regions and Locations aren't created after `create_items`.""" + gen_steps = ("generate_early", "create_regions", "create_items") + for game_name, world_type in AutoWorldRegister.world_types.items(): + with self.subTest("Game", game_name=game_name): + multiworld = setup_solo_multiworld(world_type, gen_steps) + multiworld._recache() + region_count = len(multiworld.get_regions()) + location_count = len(multiworld.get_locations()) + + call_all(multiworld, "set_rules") + self.assertEqual(region_count, len(multiworld.get_regions()), + f"{game_name} modified region count during rule creation") + self.assertEqual(location_count, len(multiworld.get_locations()), + f"{game_name} modified locations count during rule creation") + + multiworld._recache() + call_all(multiworld, "generate_basic") + self.assertEqual(region_count, len(multiworld.get_regions()), + f"{game_name} modified region count during generate_basic") + self.assertGreaterEqual(location_count, len(multiworld.get_locations()), + f"{game_name} modified locations count during generate_basic") + + multiworld._recache() + call_all(multiworld, "pre_fill") + self.assertEqual(region_count, len(multiworld.get_regions()), + f"{game_name} modified region count during pre_fill") + self.assertGreaterEqual(location_count, len(multiworld.get_locations()), + f"{game_name} modified locations count during pre_fill") diff --git a/test/general/__init__.py b/test/general/__init__.py index 970c4ef936..b0fb7ca32e 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -1,12 +1,13 @@ from argparse import Namespace +from typing import Type, Tuple from BaseClasses import MultiWorld -from worlds.AutoWorld import call_all +from worlds.AutoWorld import call_all, World -gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"] +gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill") -def setup_solo_multiworld(world_type) -> MultiWorld: +def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_steps) -> MultiWorld: multiworld = MultiWorld(1) multiworld.game[1] = world_type.game multiworld.player_name = {1: "Tester"} @@ -16,6 +17,6 @@ def setup_solo_multiworld(world_type) -> MultiWorld: setattr(args, name, {1: option.from_any(option.default)}) multiworld.set_options(args) multiworld.set_default_common_options() - for step in gen_steps: + for step in steps: call_all(multiworld, step) return multiworld diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 7985d47001..3fb705bdf3 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -131,54 +131,72 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - option_definitions: ClassVar[Dict[str, AssembleOptions]] = {} # link your Options mapping - game: ClassVar[str] # name the game - topology_present: ClassVar[bool] = False # indicate if world type has any meaningful layout/pathing + option_definitions: ClassVar[Dict[str, AssembleOptions]] = {} + """link your Options mapping""" + game: ClassVar[str] + """name the game""" + topology_present: ClassVar[bool] = False + """indicate if world type has any meaningful layout/pathing""" - # gets automatically populated with all item and item group names all_item_and_group_names: ClassVar[FrozenSet[str]] = frozenset() + """gets automatically populated with all item and item group names""" - # map names to their IDs item_name_to_id: ClassVar[Dict[str, int]] = {} + """map item names to their IDs""" location_name_to_id: ClassVar[Dict[str, int]] = {} + """map location names to their IDs""" - # maps item group names to sets of items. Example: "Weapons" -> {"Sword", "Bow"} item_name_groups: ClassVar[Dict[str, Set[str]]] = {} + """maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}""" + + location_name_groups: ClassVar[Dict[str, Set[str]]] = {} + """maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}""" - # increment this every time something in your world's names/id mappings changes. - # While this is set to 0 in *any* AutoWorld, the entire DataPackage is considered in testing mode and will be - # retrieved by clients on every connection. data_version: ClassVar[int] = 1 + """ + increment this every time something in your world's names/id mappings changes. + While this is set to 0, this world's DataPackage is considered in testing mode and will be inserted to the multidata + and retrieved by clients on every connection. + """ - # override this if changes to a world break forward-compatibility of the client - # The base version of (0, 1, 6) is provided for backwards compatibility and does *not* need to be updated in the - # future. Protocol level compatibility check moved to MultiServer.min_client_version. required_client_version: Tuple[int, int, int] = (0, 1, 6) + """ + override this if changes to a world break forward-compatibility of the client + The base version of (0, 1, 6) is provided for backwards compatibility and does *not* need to be updated in the + future. Protocol level compatibility check moved to MultiServer.min_client_version. + """ - # update this if the resulting multidata breaks forward-compatibility of the server required_server_version: Tuple[int, int, int] = (0, 2, 4) + """update this if the resulting multidata breaks forward-compatibility of the server""" - hint_blacklist: ClassVar[FrozenSet[str]] = frozenset() # any names that should not be hintable + hint_blacklist: ClassVar[FrozenSet[str]] = frozenset() + """any names that should not be hintable""" - # Hide World Type from various views. Does not remove functionality. hidden: ClassVar[bool] = False + """Hide World Type from various views. Does not remove functionality.""" - # see WebWorld for options web: ClassVar[WebWorld] = WebWorld() + """see WebWorld for options""" - # autoset on creation: multiworld: "MultiWorld" + """autoset on creation. The MultiWorld object for the currently generating multiworld.""" player: int + """autoset on creation. The player number for this World""" - # automatically generated item_id_to_name: ClassVar[Dict[int, str]] + """automatically generated reverse lookup of item id to name""" location_id_to_name: ClassVar[Dict[int, str]] + """automatically generated reverse lookup of location id to name""" - item_names: ClassVar[Set[str]] # set of all potential item names - location_names: ClassVar[Set[str]] # set of all potential location names + item_names: ClassVar[Set[str]] + """set of all potential item names""" + location_names: ClassVar[Set[str]] + """set of all potential location names""" - zip_path: ClassVar[Optional[pathlib.Path]] = None # If loaded from a .apworld, this is the Path to it. - __file__: ClassVar[str] # path it was loaded from + zip_path: ClassVar[Optional[pathlib.Path]] = None + """If loaded from a .apworld, this is the Path to it.""" + __file__: ClassVar[str] + """path it was loaded from""" def __init__(self, multiworld: "MultiWorld", player: int): self.multiworld = multiworld @@ -196,18 +214,32 @@ class World(metaclass=AutoWorldRegister): pass def generate_early(self) -> None: + """ + Run before any general steps of the MultiWorld other than options. Useful for getting and adjusting option + results and determining layouts for entrance rando etc. start inventory gets pushed after this step. + """ pass def create_regions(self) -> None: + """Method for creating and connecting regions for the World.""" pass def create_items(self) -> None: + """ + Method for creating and submitting items to the itempool. Items and Regions should *not* be created and submitted + to the MultiWorld after this step. If items need to be placed during pre_fill use `get_prefill_items`. + """ pass def set_rules(self) -> None: + """Method for setting the rules on the World's regions and locations.""" pass def generate_basic(self) -> None: + """ + Useful for randomizing things that don't affect logic but are better to be determined before the output stage. + i.e. checking what the player has marked as priority or randomizing enemies + """ pass def pre_fill(self) -> None: diff --git a/worlds/alttp/Bosses.py b/worlds/alttp/Bosses.py index 5f915a3342..51615ddc45 100644 --- a/worlds/alttp/Bosses.py +++ b/worlds/alttp/Bosses.py @@ -4,7 +4,7 @@ from typing import Optional, Union, List, Tuple, Callable, Dict from BaseClasses import Boss from Fill import FillError from .Options import LTTPBosses as Bosses - +from .StateHelpers import can_shoot_arrows, can_extend_magic, can_get_good_bee, has_sword, has_beam_sword, has_melee_weapon, has_fire_source def BossFactory(boss: str, player: int) -> Optional[Boss]: if boss in boss_table: @@ -16,33 +16,33 @@ def BossFactory(boss: str, player: int) -> Optional[Boss]: def ArmosKnightsDefeatRule(state, player: int) -> bool: # Magic amounts are probably a bit overkill return ( - state.has_melee_weapon(player) or - state.can_shoot_arrows(player) or - (state.has('Cane of Somaria', player) and state.can_extend_magic(player, 10)) or - (state.has('Cane of Byrna', player) and state.can_extend_magic(player, 16)) or - (state.has('Ice Rod', player) and state.can_extend_magic(player, 32)) or - (state.has('Fire Rod', player) and state.can_extend_magic(player, 32)) or + has_melee_weapon(state, player) or + can_shoot_arrows(state, player) or + (state.has('Cane of Somaria', player) and can_extend_magic(state, player, 10)) or + (state.has('Cane of Byrna', player) and can_extend_magic(state, player, 16)) or + (state.has('Ice Rod', player) and can_extend_magic(state, player, 32)) or + (state.has('Fire Rod', player) and can_extend_magic(state, player, 32)) or state.has('Blue Boomerang', player) or state.has('Red Boomerang', player)) def LanmolasDefeatRule(state, player: int) -> bool: return ( - state.has_melee_weapon(player) or + has_melee_weapon(state, player) or state.has('Fire Rod', player) or state.has('Ice Rod', player) or state.has('Cane of Somaria', player) or state.has('Cane of Byrna', player) or - state.can_shoot_arrows(player)) + can_shoot_arrows(state, player)) def MoldormDefeatRule(state, player: int) -> bool: - return state.has_melee_weapon(player) + return has_melee_weapon(state, player) def HelmasaurKingDefeatRule(state, player: int) -> bool: # TODO: technically possible with the hammer - return state.has_sword(player) or state.can_shoot_arrows(player) + return has_sword(state, player) or can_shoot_arrows(state, player) def ArrghusDefeatRule(state, player: int) -> bool: @@ -51,28 +51,28 @@ def ArrghusDefeatRule(state, player: int) -> bool: # TODO: ideally we would have a check for bow and silvers, which combined with the # hookshot is enough. This is not coded yet because the silvers that only work in pyramid feature # makes this complicated - if state.has_melee_weapon(player): + if has_melee_weapon(state, player): return True - return ((state.has('Fire Rod', player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, + return ((state.has('Fire Rod', player) and (can_shoot_arrows(state, player) or can_extend_magic(state, player, 12))) or # assuming mostly gitting two puff with one shot - (state.has('Ice Rod', player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, 16)))) + (state.has('Ice Rod', player) and (can_shoot_arrows(state, player) or can_extend_magic(state, player, 16)))) def MothulaDefeatRule(state, player: int) -> bool: return ( - state.has_melee_weapon(player) or - (state.has('Fire Rod', player) and state.can_extend_magic(player, 10)) or + has_melee_weapon(state, player) or + (state.has('Fire Rod', player) and can_extend_magic(state, player, 10)) or # TODO: Not sure how much (if any) extend magic is needed for these two, since they only apply # to non-vanilla locations, so are harder to test, so sticking with what VT has for now: - (state.has('Cane of Somaria', player) and state.can_extend_magic(player, 16)) or - (state.has('Cane of Byrna', player) and state.can_extend_magic(player, 16)) or - state.can_get_good_bee(player) + (state.has('Cane of Somaria', player) and can_extend_magic(state, player, 16)) or + (state.has('Cane of Byrna', player) and can_extend_magic(state, player, 16)) or + can_get_good_bee(state, player) ) def BlindDefeatRule(state, player: int) -> bool: - return state.has_melee_weapon(player) or state.has('Cane of Somaria', player) or state.has('Cane of Byrna', player) + return has_melee_weapon(state, player) or state.has('Cane of Somaria', player) or state.has('Cane of Byrna', player) def KholdstareDefeatRule(state, player: int) -> bool: @@ -81,56 +81,56 @@ def KholdstareDefeatRule(state, player: int) -> bool: state.has('Fire Rod', player) or ( state.has('Bombos', player) and - (state.has_sword(player) or state.multiworld.swordless[player]) + (has_sword(state, player) or state.multiworld.swordless[player]) ) ) and ( - state.has_melee_weapon(player) or - (state.has('Fire Rod', player) and state.can_extend_magic(player, 20)) or + has_melee_weapon(state, player) or + (state.has('Fire Rod', player) and can_extend_magic(state, player, 20)) or ( state.has('Fire Rod', player) and state.has('Bombos', player) and state.multiworld.swordless[player] and - state.can_extend_magic(player, 16) + can_extend_magic(state, player, 16) ) ) ) def VitreousDefeatRule(state, player: int) -> bool: - return state.can_shoot_arrows(player) or state.has_melee_weapon(player) + return can_shoot_arrows(state, player) or has_melee_weapon(state, player) def TrinexxDefeatRule(state, player: int) -> bool: if not (state.has('Fire Rod', player) and state.has('Ice Rod', player)): return False return state.has('Hammer', player) or state.has('Tempered Sword', player) or state.has('Golden Sword', player) or \ - (state.has('Master Sword', player) and state.can_extend_magic(player, 16)) or \ - (state.has_sword(player) and state.can_extend_magic(player, 32)) + (state.has('Master Sword', player) and can_extend_magic(state, player, 16)) or \ + (has_sword(state, player) and can_extend_magic(state, player, 32)) def AgahnimDefeatRule(state, player: int) -> bool: - return state.has_sword(player) or state.has('Hammer', player) or state.has('Bug Catching Net', player) + return has_sword(state, player) or state.has('Hammer', player) or state.has('Bug Catching Net', player) def GanonDefeatRule(state, player: int) -> bool: if state.multiworld.swordless[player]: return state.has('Hammer', player) and \ - state.has_fire_source(player) and \ + has_fire_source(state, player) and \ state.has('Silver Bow', player) and \ - state.can_shoot_arrows(player) + can_shoot_arrows(state, player) - can_hurt = state.has_beam_sword(player) - common = can_hurt and state.has_fire_source(player) + can_hurt = has_beam_sword(state, player) + common = can_hurt and has_fire_source(state, player) # silverless ganon may be needed in anything higher than no glitches if state.multiworld.logic[player] != 'noglitches': # need to light torch a sufficient amount of times return common and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or ( - state.has('Silver Bow', player) and state.can_shoot_arrows(player)) or - state.has('Lamp', player) or state.can_extend_magic(player, 12)) + state.has('Silver Bow', player) and can_shoot_arrows(state, player)) or + state.has('Lamp', player) or can_extend_magic(state, player, 12)) else: - return common and state.has('Silver Bow', player) and state.can_shoot_arrows(player) + return common and state.has('Silver Bow', player) and can_shoot_arrows(state, player) boss_table: Dict[str, Tuple[str, Optional[Callable]]] = { diff --git a/worlds/alttp/ItemPool.py b/worlds/alttp/ItemPool.py index f7326092ec..7fd93ab93e 100644 --- a/worlds/alttp/ItemPool.py +++ b/worlds/alttp/ItemPool.py @@ -9,7 +9,8 @@ from worlds.alttp.Dungeons import get_dungeon_item_pool_player from worlds.alttp.EntranceShuffle import connect_entrance from Fill import FillError from worlds.alttp.Items import ItemFactory, GetBeemizerItem -from worlds.alttp.Options import smallkey_shuffle, compass_shuffle, bigkey_shuffle, map_shuffle +from worlds.alttp.Options import smallkey_shuffle, compass_shuffle, bigkey_shuffle, map_shuffle, LTTPBosses +from .StateHelpers import has_triforce_pieces, has_melee_weapon # This file sets the item pools for various modes. Timed modes and triforce hunt are enforced first, and then extra items are specified per mode to fill in the remaining space. # Some basic items that various modes require are placed here, including pendants and crystals. Medallion requirements for the two relevant entrances are also decided. @@ -249,8 +250,10 @@ def generate_itempool(world): world.push_item(loc, ItemFactory('Triforce Piece', player), False) world.treasure_hunt_count[player] = 1 if world.boss_shuffle[player] != 'none': - if 'turtle rock-' not in world.boss_shuffle[player]: - world.boss_shuffle[player] = f'Turtle Rock-Trinexx;{world.boss_shuffle[player]}' + if isinstance(world.boss_shuffle[player].value, str) and 'turtle rock-' not in world.boss_shuffle[player].value: + world.boss_shuffle[player] = LTTPBosses.from_text(f'Turtle Rock-Trinexx;{world.boss_shuffle[player].current_key}') + elif isinstance(world.boss_shuffle[player].value, int): + world.boss_shuffle[player] = LTTPBosses.from_text(f'Turtle Rock-Trinexx;{world.boss_shuffle[player].current_key}') else: logging.warning(f'Cannot guarantee that Trinexx is the boss of Turtle Rock for player {player}') loc.event = True @@ -286,7 +289,7 @@ def generate_itempool(world): region = world.get_region('Light World', player) loc = ALttPLocation(player, "Murahdahla", parent=region) - loc.access_rule = lambda state: state.has_triforce_pieces(state.multiworld.treasure_hunt_count[player], player) + loc.access_rule = lambda state: has_triforce_pieces(state, player) region.locations.append(loc) world.clear_location_cache() @@ -327,7 +330,7 @@ def generate_itempool(world): for item in precollected_items: world.push_precollected(ItemFactory(item, player)) - if world.mode[player] == 'standard' and not world.state.has_melee_weapon(player): + if world.mode[player] == 'standard' and not has_melee_weapon(world.state, player): if "Link's Uncle" not in placed_items: found_sword = False found_bow = False diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index b1cfbc674e..dd007954e1 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -107,10 +107,14 @@ class Crystals(Range): class CrystalsTower(Crystals): + """Number of crystals needed to open Ganon's Tower""" + display_name = "Crystals for GT" default = 7 class CrystalsGanon(Crystals): + """Number of crystals needed to damage Ganon""" + display_name = "Crystals for Ganon" default = 7 @@ -121,12 +125,15 @@ class TriforcePieces(Range): class ShopItemSlots(Range): + """Number of slots in all shops available to have items from the multiworld""" + display_name = "Available Shop Slots" range_start = 0 range_end = 30 class ShopPriceModifier(Range): """Percentage modifier for shuffled item prices in shops""" + display_name = "Shop Price Cost Percent" range_start = 0 default = 100 range_end = 400 @@ -144,7 +151,7 @@ class LTTPBosses(PlandoBosses): Full chooses 3 bosses at random to be placed twice instead of Lanmolas, Moldorm, and Helmasaur. Chaos allows any boss to appear any number of times. Singularity places a single boss in as many places as possible, and a second boss in any remaining locations. - Supports plando placement. Formatting here: https://archipelago.gg/tutorial/A%20Link%20to%20the%20Past/plando/en""" + Supports plando placement.""" display_name = "Boss Shuffle" option_none = 0 option_basic = 1 @@ -202,6 +209,7 @@ class Enemies(Choice): class Progressive(Choice): + """How item types that have multiple tiers (armor, bows, gloves, shields, and swords) should be rewarded""" display_name = "Progressive Items" option_off = 0 option_grouped_random = 1 @@ -305,22 +313,27 @@ class Palette(Choice): class OWPalette(Palette): + """The type of palette shuffle to use for the overworld""" display_name = "Overworld Palette" class UWPalette(Palette): + """The type of palette shuffle to use for the underworld (caves, dungeons, etc.)""" display_name = "Underworld Palette" class HUDPalette(Palette): + """The type of palette shuffle to use for the HUD""" display_name = "Menu Palette" class SwordPalette(Palette): + """The type of palette shuffle to use for the sword""" display_name = "Sword Palette" class ShieldPalette(Palette): + """The type of palette shuffle to use for the shield""" display_name = "Shield Palette" @@ -329,6 +342,7 @@ class ShieldPalette(Palette): class HeartBeep(Choice): + """How quickly the heart beep sound effect will play""" display_name = "Heart Beep Rate" option_normal = 0 option_double = 1 @@ -338,6 +352,7 @@ class HeartBeep(Choice): class HeartColor(Choice): + """The color of hearts in the HUD""" display_name = "Heart Color" option_red = 0 option_blue = 1 @@ -346,10 +361,12 @@ class HeartColor(Choice): class QuickSwap(DefaultOnToggle): + """Allows you to quickly swap items while playing with L/R""" display_name = "L/R Quickswapping" class MenuSpeed(Choice): + """How quickly the menu appears/disappears""" display_name = "Menu Speed" option_normal = 0 option_instant = 1, @@ -360,14 +377,17 @@ class MenuSpeed(Choice): class Music(DefaultOnToggle): + """Whether background music will play in game""" display_name = "Play music" class ReduceFlashing(DefaultOnToggle): + """Reduces flashing for certain scenes such as the Misery Mire and Ganon's Tower opening cutscenes""" display_name = "Reduce Screen Flashes" class TriforceHud(Choice): + """When and how the triforce hunt HUD should display""" display_name = "Display Method for Triforce Hunt" option_normal = 0 option_hide_goal = 1 @@ -375,6 +395,11 @@ class TriforceHud(Choice): option_hide_both = 3 +class GlitchBoots(DefaultOnToggle): + """If this is enabled, the player will start with Pegasus Boots when playing with overworld glitches or harder logic.""" + display_name = "Glitched Starting Boots" + + class BeemizerRange(Range): value: int range_start = 0 @@ -437,7 +462,7 @@ alttp_options: typing.Dict[str, type(Option)] = { "music": Music, "reduceflashing": ReduceFlashing, "triforcehud": TriforceHud, - "glitch_boots": DefaultOnToggle, + "glitch_boots": GlitchBoots, "beemizer_total_chance": BeemizerTotalChance, "beemizer_trap_chance": BeemizerTrapChance, "death_link": DeathLink, diff --git a/worlds/alttp/OverworldGlitchRules.py b/worlds/alttp/OverworldGlitchRules.py index 705db7e7c0..f6c3ec8d14 100644 --- a/worlds/alttp/OverworldGlitchRules.py +++ b/worlds/alttp/OverworldGlitchRules.py @@ -4,6 +4,7 @@ Helper functions to deliver entrance/exit/region sets to OWG rules. from BaseClasses import Entrance +from .StateHelpers import can_lift_heavy_rocks, can_boots_clip_lw, can_boots_clip_dw, can_get_glitched_speed_dw def get_sword_required_superbunny_mirror_regions(): """ @@ -169,7 +170,7 @@ def get_boots_clip_exits_dw(inverted, player): yield ('Ganons Tower Ascent', 'Dark Death Mountain (West Bottom)', 'Dark Death Mountain (Top)') # This only gets you to the GT entrance yield ('Dark Death Mountain Glitched Bridge', 'Dark Death Mountain (West Bottom)', 'Dark Death Mountain (Top)') yield ('Turtle Rock (Top) Clip Spot', 'Dark Death Mountain (Top)', 'Turtle Rock (Top)') - yield ('Ice Palace Clip', 'South Dark World', 'Dark Lake Hylia Central Island', lambda state: state.can_boots_clip_dw(player) and state.has('Flippers', player)) + yield ('Ice Palace Clip', 'South Dark World', 'Dark Lake Hylia Central Island', lambda state: can_boots_clip_dw(state, player) and state.has('Flippers', player)) else: yield ('Dark Desert Teleporter Clip Spot', 'Dark Desert', 'Dark Desert Ledge') @@ -203,7 +204,7 @@ def get_mirror_offset_spots_lw(player): Mirror shenanigans placing a mirror portal with a broken camera """ yield ('Death Mountain Offset Mirror', 'Death Mountain', 'Light World') - yield ('Death Mountain Offset Mirror (Houlihan Exit)', 'Death Mountain', 'Hyrule Castle Ledge', lambda state: state.has('Magic Mirror', player) and state.can_boots_clip_dw(player) and state.has('Moon Pearl', player)) + yield ('Death Mountain Offset Mirror (Houlihan Exit)', 'Death Mountain', 'Hyrule Castle Ledge', lambda state: state.has('Magic Mirror', player) and can_boots_clip_dw(state, player) and state.has('Moon Pearl', player)) @@ -255,11 +256,11 @@ def overworld_glitch_connections(world, player): def overworld_glitches_rules(world, player): # Boots-accessible locations. - set_owg_connection_rules(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'), lambda state: state.can_boots_clip_lw(player)) - set_owg_connection_rules(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player), lambda state: state.can_boots_clip_dw(player)) + set_owg_connection_rules(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'), lambda state: can_boots_clip_lw(state, player)) + set_owg_connection_rules(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player), lambda state: can_boots_clip_dw(state, player)) # Glitched speed drops. - set_owg_connection_rules(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted'), lambda state: state.can_get_glitched_speed_dw(player)) + set_owg_connection_rules(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted'), lambda state: can_get_glitched_speed_dw(state, player)) # Dark Death Mountain Ledge Clip Spot also accessible with mirror. if world.mode[player] != 'inverted': add_alternate_rule(world.get_entrance('Dark Death Mountain Ledge Clip Spot', player), lambda state: state.has('Magic Mirror', player)) @@ -267,20 +268,20 @@ def overworld_glitches_rules(world, player): # Mirror clip spots. if world.mode[player] != 'inverted': set_owg_connection_rules(player, world, get_mirror_clip_spots_dw(), lambda state: state.has('Magic Mirror', player)) - set_owg_connection_rules(player, world, get_mirror_offset_spots_dw(), lambda state: state.has('Magic Mirror', player) and state.can_boots_clip_lw(player)) + set_owg_connection_rules(player, world, get_mirror_offset_spots_dw(), lambda state: state.has('Magic Mirror', player) and can_boots_clip_lw(state, player)) else: - set_owg_connection_rules(player, world, get_mirror_offset_spots_lw(player), lambda state: state.has('Magic Mirror', player) and state.can_boots_clip_dw(player)) + set_owg_connection_rules(player, world, get_mirror_offset_spots_lw(player), lambda state: state.has('Magic Mirror', player) and can_boots_clip_dw(state, player)) # Regions that require the boots and some other stuff. if world.mode[player] != 'inverted': - world.get_entrance('Turtle Rock Teleporter', player).access_rule = lambda state: (state.can_boots_clip_lw(player) or state.can_lift_heavy_rocks(player)) and state.has('Hammer', player) + world.get_entrance('Turtle Rock Teleporter', player).access_rule = lambda state: (can_boots_clip_lw(state, player) or can_lift_heavy_rocks(state, player)) and state.has('Hammer', player) add_alternate_rule(world.get_entrance('Waterfall of Wishing', player), lambda state: state.has('Moon Pearl', player) or state.has('Pegasus Boots', player)) else: add_alternate_rule(world.get_entrance('Waterfall of Wishing Cave', player), lambda state: state.has('Moon Pearl', player)) - world.get_entrance('Dark Desert Teleporter', player).access_rule = lambda state: (state.has('Flute', player) or state.has('Pegasus Boots', player)) and state.can_lift_heavy_rocks(player) - add_alternate_rule(world.get_entrance('Catfish Exit Rock', player), lambda state: state.can_boots_clip_dw(player)) - add_alternate_rule(world.get_entrance('East Dark World Broken Bridge Pass', player), lambda state: state.can_boots_clip_dw(player)) + world.get_entrance('Dark Desert Teleporter', player).access_rule = lambda state: (state.has('Flute', player) or state.has('Pegasus Boots', player)) and can_lift_heavy_rocks(state, player) + add_alternate_rule(world.get_entrance('Catfish Exit Rock', player), lambda state: can_boots_clip_dw(state, player)) + add_alternate_rule(world.get_entrance('East Dark World Broken Bridge Pass', player), lambda state: can_boots_clip_dw(state, player)) # Zora's Ledge via waterwalk setup. add_alternate_rule(world.get_location('Zora\'s Ledge', player), lambda state: state.has('Pegasus Boots', player)) diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index a31641d679..e6c5f15a2f 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -2,17 +2,24 @@ import collections import logging from typing import Iterator, Set -from worlds.alttp import OverworldGlitchRules -from BaseClasses import MultiWorld, Entrance -from worlds.alttp.Items import ItemFactory, progression_items, item_name_groups, item_table -from worlds.alttp.OverworldGlitchRules import overworld_glitches_rules, no_logic_rules -from worlds.alttp.Regions import location_table -from worlds.alttp.UnderworldGlitchRules import underworld_glitches_rules -from worlds.alttp.Bosses import GanonDefeatRule -from worlds.generic.Rules import set_rule, add_rule, forbid_item, add_item_rule, item_in_locations, \ - item_name -from worlds.alttp.Options import smallkey_shuffle -from worlds.alttp.Regions import LTTPRegionType +from BaseClasses import Entrance, MultiWorld +from worlds.generic.Rules import (add_item_rule, add_rule, forbid_item, + item_in_locations, location_item_name, set_rule, allow_self_locking_items) + +from . import OverworldGlitchRules +from .Bosses import GanonDefeatRule +from .Items import ItemFactory, item_name_groups, item_table, progression_items +from .Options import smallkey_shuffle +from .OverworldGlitchRules import no_logic_rules, overworld_glitches_rules +from .Regions import LTTPRegionType, location_table +from .StateHelpers import (can_extend_magic, can_kill_most_things, + can_lift_heavy_rocks, can_lift_rocks, + can_melt_things, can_retrieve_tablet, + can_shoot_arrows, has_beam_sword, has_crystals, + has_fire_source, has_hearts, + has_misery_mire_medallion, has_sword, has_turtle_rock_medallion, + has_triforce_pieces) +from .UnderworldGlitchRules import underworld_glitches_rules def set_rules(world): @@ -76,7 +83,7 @@ def set_rules(world): if world.goal[player] == 'bosses': # require all bosses to beat ganon - add_rule(world.get_location('Ganon', player), lambda state: state.can_reach('Master Sword Pedestal', 'Location', player) and state.has('Beat Agahnim 1', player) and state.has('Beat Agahnim 2', player) and state.has_crystals(7, player)) + add_rule(world.get_location('Ganon', player), lambda state: state.can_reach('Master Sword Pedestal', 'Location', player) and state.has('Beat Agahnim 1', player) and state.has('Beat Agahnim 2', player) and has_crystals(state, 7, player)) elif world.goal[player] == 'ganon': # require aga2 to beat ganon add_rule(world.get_location('Ganon', player), lambda state: state.has('Beat Agahnim 2', player)) @@ -101,7 +108,7 @@ def set_rules(world): set_trock_key_rules(world, player) - set_rule(ganons_tower, lambda state: state.has_crystals(state.multiworld.crystals_needed_for_gt[player], player)) + set_rule(ganons_tower, lambda state: has_crystals(state, state.multiworld.crystals_needed_for_gt[player], player)) if world.mode[player] != 'inverted' and world.logic[player] in ['owglitches', 'hybridglitches', 'nologic']: add_rule(world.get_entrance('Ganons Tower', player), lambda state: state.multiworld.get_entrance('Ganons Tower Ascent', player).can_reach(state), 'or') @@ -199,7 +206,7 @@ def global_rules(world, player): set_rule(world.get_location('Dark Blacksmith Ruins', player), lambda state: state.has('Return Smith', player)) set_rule(world.get_location('Purple Chest', player), lambda state: state.has('Pick Up Purple Chest', player)) # Can S&Q with chest - set_rule(world.get_location('Ether Tablet', player), lambda state: state.can_retrieve_tablet(player)) + set_rule(world.get_location('Ether Tablet', player), lambda state: can_retrieve_tablet(state, player)) set_rule(world.get_location('Master Sword Pedestal', player), lambda state: state.has('Red Pendant', player) and state.has('Blue Pendant', player) and state.has('Green Pendant', player)) set_rule(world.get_location('Missing Smith', player), lambda state: state.has('Get Frog', player) and state.can_reach('Blacksmiths Hut', 'Region', player)) # Can't S&Q with smith @@ -212,11 +219,11 @@ def global_rules(world, player): set_rule(world.get_location('Spike Cave', player), lambda state: - state.has('Hammer', player) and state.can_lift_rocks(player) and - ((state.has('Cape', player) and state.can_extend_magic(player, 16, True)) or + state.has('Hammer', player) and can_lift_rocks(state, player) and + ((state.has('Cape', player) and can_extend_magic(state, player, 16, True)) or (state.has('Cane of Byrna', player) and - (state.can_extend_magic(player, 12, True) or - (state.multiworld.can_take_damage[player] and (state.has('Pegasus Boots', player) or state.has_hearts(player, 4)))))) + (can_extend_magic(state, player, 12, True) or + (state.multiworld.can_take_damage[player] and (state.has('Pegasus Boots', player) or has_hearts(state, player, 4)))))) ) set_rule(world.get_location('Hookshot Cave - Top Right', player), lambda state: state.has('Hookshot', player)) @@ -232,11 +239,11 @@ def global_rules(world, player): set_rule(world.get_entrance('Sewers Back Door', player), lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player)) set_rule(world.get_entrance('Agahnim 1', player), - lambda state: state.has_sword(player) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 2)) + lambda state: has_sword(state, player) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 2)) - set_rule(world.get_location('Castle Tower - Room 03', player), lambda state: state.can_kill_most_things(player, 8)) + set_rule(world.get_location('Castle Tower - Room 03', player), lambda state: can_kill_most_things(state, player, 8)) set_rule(world.get_location('Castle Tower - Dark Maze', player), - lambda state: state.can_kill_most_things(player, 8) and state._lttp_has_key('Small Key (Agahnims Tower)', + lambda state: can_kill_most_things(state, player, 8) and state._lttp_has_key('Small Key (Agahnims Tower)', player)) set_rule(world.get_location('Eastern Palace - Big Chest', player), @@ -248,62 +255,62 @@ def global_rules(world, player): set_rule(ep_prize, lambda state: state.has('Big Key (Eastern Palace)', player) and ep_prize.parent_region.dungeon.boss.can_defeat(state)) if not world.enemy_shuffle[player]: - add_rule(ep_boss, lambda state: state.can_shoot_arrows(player)) - add_rule(ep_prize, lambda state: state.can_shoot_arrows(player)) + add_rule(ep_boss, lambda state: can_shoot_arrows(state, player)) + add_rule(ep_prize, lambda state: can_shoot_arrows(state, player)) set_rule(world.get_location('Desert Palace - Big Chest', player), lambda state: state.has('Big Key (Desert Palace)', player)) set_rule(world.get_location('Desert Palace - Torch', player), lambda state: state.has('Pegasus Boots', player)) set_rule(world.get_entrance('Desert Palace East Wing', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player)) - set_rule(world.get_location('Desert Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player) and state.has('Big Key (Desert Palace)', player) and state.has_fire_source(player) and state.multiworld.get_location('Desert Palace - Prize', player).parent_region.dungeon.boss.can_defeat(state)) - set_rule(world.get_location('Desert Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player) and state.has('Big Key (Desert Palace)', player) and state.has_fire_source(player) and state.multiworld.get_location('Desert Palace - Boss', player).parent_region.dungeon.boss.can_defeat(state)) + set_rule(world.get_location('Desert Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player) and state.has('Big Key (Desert Palace)', player) and has_fire_source(state, player) and state.multiworld.get_location('Desert Palace - Prize', player).parent_region.dungeon.boss.can_defeat(state)) + set_rule(world.get_location('Desert Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player) and state.has('Big Key (Desert Palace)', player) and has_fire_source(state, player) and state.multiworld.get_location('Desert Palace - Boss', player).parent_region.dungeon.boss.can_defeat(state)) # logic patch to prevent placing a crystal in Desert that's required to reach the required keys if not (world.smallkey_shuffle[player] and world.bigkey_shuffle[player]): add_rule(world.get_location('Desert Palace - Prize', player), lambda state: state.multiworld.get_region('Desert Palace Main (Outer)', player).can_reach(state)) - set_rule(world.get_entrance('Tower of Hera Small Key Door', player), lambda state: state._lttp_has_key('Small Key (Tower of Hera)', player) or item_name(state, 'Tower of Hera - Big Key Chest', player) == ('Small Key (Tower of Hera)', player)) + set_rule(world.get_entrance('Tower of Hera Small Key Door', player), lambda state: state._lttp_has_key('Small Key (Tower of Hera)', player) or location_item_name(state, 'Tower of Hera - Big Key Chest', player) == ('Small Key (Tower of Hera)', player)) set_rule(world.get_entrance('Tower of Hera Big Key Door', player), lambda state: state.has('Big Key (Tower of Hera)', player)) set_rule(world.get_location('Tower of Hera - Big Chest', player), lambda state: state.has('Big Key (Tower of Hera)', player)) - set_rule(world.get_location('Tower of Hera - Big Key Chest', player), lambda state: state.has_fire_source(player)) + set_rule(world.get_location('Tower of Hera - Big Key Chest', player), lambda state: has_fire_source(state, player)) if world.accessibility[player] != 'locations': set_always_allow(world.get_location('Tower of Hera - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Tower of Hera)' and item.player == player) set_rule(world.get_entrance('Swamp Palace Moat', player), lambda state: state.has('Flippers', player) and state.has('Open Floodgate', player)) set_rule(world.get_entrance('Swamp Palace Small Key Door', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player)) set_rule(world.get_entrance('Swamp Palace (Center)', player), lambda state: state.has('Hammer', player)) - set_rule(world.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player) or item_name(state, 'Swamp Palace - Big Chest', player) == ('Big Key (Swamp Palace)', player)) + set_rule(world.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player)) if world.accessibility[player] != 'locations': - set_always_allow(world.get_location('Swamp Palace - Big Chest', player), lambda state, item: item.name == 'Big Key (Swamp Palace)' and item.player == player) + allow_self_locking_items(world.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)') set_rule(world.get_entrance('Swamp Palace (North)', player), lambda state: state.has('Hookshot', player)) if not world.smallkey_shuffle[player] and world.logic[player] not in ['hybridglitches', 'nologic']: forbid_item(world.get_location('Swamp Palace - Entrance', player), 'Big Key (Swamp Palace)', player) set_rule(world.get_entrance('Thieves Town Big Key Door', player), lambda state: state.has('Big Key (Thieves Town)', player)) set_rule(world.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player)) - set_rule(world.get_location('Thieves\' Town - Big Chest', player), lambda state: (state._lttp_has_key('Small Key (Thieves Town)', player) or item_name(state, 'Thieves\' Town - Big Chest', player) == ('Small Key (Thieves Town)', player)) and state.has('Hammer', player)) + set_rule(world.get_location('Thieves\' Town - Big Chest', player), lambda state: (state._lttp_has_key('Small Key (Thieves Town)', player)) and state.has('Hammer', player)) if world.accessibility[player] != 'locations': - set_always_allow(world.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player and state.has('Hammer', player)) + allow_self_locking_items(world.get_location('Thieves\' Town - Big Chest', player), 'Small Key (Thieves Town)') set_rule(world.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player)) set_rule(world.get_entrance('Skull Woods First Section South Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player)) set_rule(world.get_entrance('Skull Woods First Section (Right) North Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player)) set_rule(world.get_entrance('Skull Woods First Section West Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 2)) # ideally would only be one key, but we may have spent thst key already on escaping the right section set_rule(world.get_entrance('Skull Woods First Section (Left) Door to Exit', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 2)) - set_rule(world.get_location('Skull Woods - Big Chest', player), lambda state: state.has('Big Key (Skull Woods)', player) or item_name(state, 'Skull Woods - Big Chest', player) == ('Big Key (Skull Woods)', player)) + set_rule(world.get_location('Skull Woods - Big Chest', player), lambda state: state.has('Big Key (Skull Woods)', player)) if world.accessibility[player] != 'locations': - set_always_allow(world.get_location('Skull Woods - Big Chest', player), lambda state, item: item.name == 'Big Key (Skull Woods)' and item.player == player) - set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 3) and state.has('Fire Rod', player) and state.has_sword(player)) # sword required for curtain + allow_self_locking_items(world.get_location('Skull Woods - Big Chest', player), 'Big Key (Skull Woods)') + set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 3) and state.has('Fire Rod', player) and has_sword(state, player)) # sword required for curtain - set_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: state.can_melt_things(player)) + set_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: can_melt_things(state, player)) set_rule(world.get_location('Ice Palace - Big Chest', player), lambda state: state.has('Big Key (Ice Palace)', player)) - set_rule(world.get_entrance('Ice Palace (Kholdstare)', player), lambda state: state.can_lift_rocks(player) and state.has('Hammer', player) and state.has('Big Key (Ice Palace)', player) and (state._lttp_has_key('Small Key (Ice Palace)', player, 2) or (state.has('Cane of Somaria', player) and state._lttp_has_key('Small Key (Ice Palace)', player, 1)))) + set_rule(world.get_entrance('Ice Palace (Kholdstare)', player), lambda state: can_lift_rocks(state, player) and state.has('Hammer', player) and state.has('Big Key (Ice Palace)', player) and (state._lttp_has_key('Small Key (Ice Palace)', player, 2) or (state.has('Cane of Somaria', player) and state._lttp_has_key('Small Key (Ice Palace)', player, 1)))) set_rule(world.get_entrance('Ice Palace (East)', player), lambda state: (state.has('Hookshot', player) or ( item_in_locations(state, 'Big Key (Ice Palace)', player, [('Ice Palace - Spike Room', player), ('Ice Palace - Big Key Chest', player), ('Ice Palace - Map Chest', player)]) and state._lttp_has_key('Small Key (Ice Palace)', player))) and (state.multiworld.can_take_damage[player] or state.has('Hookshot', player) or state.has('Cape', player) or state.has('Cane of Byrna', player))) - set_rule(world.get_entrance('Ice Palace (East Top)', player), lambda state: state.can_lift_rocks(player) and state.has('Hammer', player)) + set_rule(world.get_entrance('Ice Palace (East Top)', player), lambda state: can_lift_rocks(state, player) and state.has('Hammer', player)) - set_rule(world.get_entrance('Misery Mire Entrance Gap', player), lambda state: (state.has('Pegasus Boots', player) or state.has('Hookshot', player)) and (state.has_sword(player) or state.has('Fire Rod', player) or state.has('Ice Rod', player) or state.has('Hammer', player) or state.has('Cane of Somaria', player) or state.can_shoot_arrows(player))) # need to defeat wizzrobes, bombs don't work ... + set_rule(world.get_entrance('Misery Mire Entrance Gap', player), lambda state: (state.has('Pegasus Boots', player) or state.has('Hookshot', player)) and (has_sword(state, player) or state.has('Fire Rod', player) or state.has('Ice Rod', player) or state.has('Hammer', player) or state.has('Cane of Somaria', player) or can_shoot_arrows(state, player))) # need to defeat wizzrobes, bombs don't work ... set_rule(world.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player)) - set_rule(world.get_location('Misery Mire - Spike Chest', player), lambda state: (state.multiworld.can_take_damage[player] and state.has_hearts(player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player)) + set_rule(world.get_location('Misery Mire - Spike Chest', player), lambda state: (state.multiworld.can_take_damage[player] and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player)) set_rule(world.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('Big Key (Misery Mire)', player)) # you can squander the free small key from the pot by opening the south door to the north west switch room, locking you out of accessing a color switch ... # big key gives backdoor access to that from the teleporter in the north west @@ -311,11 +318,11 @@ def global_rules(world, player): set_rule(world.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 1) or state._lttp_has_key('Big Key (Misery Mire)', player)) # we can place a small key in the West wing iff it also contains/blocks the Big Key, as we cannot reach and softlock with the basement key door yet set_rule(world.get_entrance('Misery Mire (West)', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2) if (( - item_name(state, 'Misery Mire - Compass Chest', player) in [('Big Key (Misery Mire)', player)]) or + location_item_name(state, 'Misery Mire - Compass Chest', player) in [('Big Key (Misery Mire)', player)]) or ( - item_name(state, 'Misery Mire - Big Key Chest', player) in [('Big Key (Misery Mire)', player)])) else state._lttp_has_key('Small Key (Misery Mire)', player, 3)) - set_rule(world.get_location('Misery Mire - Compass Chest', player), lambda state: state.has_fire_source(player)) - set_rule(world.get_location('Misery Mire - Big Key Chest', player), lambda state: state.has_fire_source(player)) + location_item_name(state, 'Misery Mire - Big Key Chest', player) in [('Big Key (Misery Mire)', player)])) else state._lttp_has_key('Small Key (Misery Mire)', player, 3)) + set_rule(world.get_location('Misery Mire - Compass Chest', player), lambda state: has_fire_source(state, player)) + set_rule(world.get_location('Misery Mire - Big Key Chest', player), lambda state: has_fire_source(state, player)) set_rule(world.get_entrance('Misery Mire (Vitreous)', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('Turtle Rock Entrance Gap', player), lambda state: state.has('Cane of Somaria', player)) @@ -335,20 +342,20 @@ def global_rules(world, player): set_rule(world.get_entrance('Turtle Rock (Trinexx)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4) and state.has('Big Key (Turtle Rock)', player) and state.has('Cane of Somaria', player)) if not world.enemy_shuffle[player]: - set_rule(world.get_entrance('Palace of Darkness Bonk Wall', player), lambda state: state.can_shoot_arrows(player)) + set_rule(world.get_entrance('Palace of Darkness Bonk Wall', player), lambda state: can_shoot_arrows(state, player)) set_rule(world.get_entrance('Palace of Darkness Hammer Peg Drop', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('Palace of Darkness Bridge Room', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 1)) # If we can reach any other small key door, we already have back door access to this area - set_rule(world.get_entrance('Palace of Darkness Big Key Door', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 6) and state.has('Big Key (Palace of Darkness)', player) and state.can_shoot_arrows(player) and state.has('Hammer', player)) + set_rule(world.get_entrance('Palace of Darkness Big Key Door', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 6) and state.has('Big Key (Palace of Darkness)', player) and can_shoot_arrows(state, player) and state.has('Hammer', player)) set_rule(world.get_entrance('Palace of Darkness (North)', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 4)) set_rule(world.get_location('Palace of Darkness - Big Chest', player), lambda state: state.has('Big Key (Palace of Darkness)', player)) set_rule(world.get_entrance('Palace of Darkness Big Key Chest Staircase', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 6) or ( - item_name(state, 'Palace of Darkness - Big Key Chest', player) in [('Small Key (Palace of Darkness)', player)] and state._lttp_has_key('Small Key (Palace of Darkness)', player, 3))) + location_item_name(state, 'Palace of Darkness - Big Key Chest', player) in [('Small Key (Palace of Darkness)', player)] and state._lttp_has_key('Small Key (Palace of Darkness)', player, 3))) if world.accessibility[player] != 'locations': set_always_allow(world.get_location('Palace of Darkness - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Palace of Darkness)' and item.player == player and state._lttp_has_key('Small Key (Palace of Darkness)', player, 5)) set_rule(world.get_entrance('Palace of Darkness Spike Statue Room Door', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 6) or ( - item_name(state, 'Palace of Darkness - Harmless Hellway', player) in [('Small Key (Palace of Darkness)', player)] and state._lttp_has_key('Small Key (Palace of Darkness)', player, 4))) + location_item_name(state, 'Palace of Darkness - Harmless Hellway', player) in [('Small Key (Palace of Darkness)', player)] and state._lttp_has_key('Small Key (Palace of Darkness)', player, 4))) if world.accessibility[player] != 'locations': set_always_allow(world.get_location('Palace of Darkness - Harmless Hellway', player), lambda state, item: item.name == 'Small Key (Palace of Darkness)' and item.player == player and state._lttp_has_key('Small Key (Palace of Darkness)', player, 5)) @@ -362,7 +369,7 @@ def global_rules(world, player): set_rule(world.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player))) set_rule(world.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 4) or ( - item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player), ('Small Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 3))) + location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player), ('Small Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 3))) if world.accessibility[player] != 'locations': set_always_allow(world.get_location('Ganons Tower - Map Chest', player), lambda state, item: item.name == 'Small Key (Ganons Tower)' and item.player == player and state._lttp_has_key('Small Key (Ganons Tower)', player, 3) and state.can_reach('Ganons Tower (Hookshot Room)', 'region', player)) @@ -399,9 +406,9 @@ def global_rules(world, player): lambda state: state.has('Big Key (Ganons Tower)', player)) else: set_rule(world.get_entrance('Ganons Tower Big Key Door', player), - lambda state: state.has('Big Key (Ganons Tower)', player) and state.can_shoot_arrows(player)) + lambda state: state.has('Big Key (Ganons Tower)', player) and can_shoot_arrows(state, player)) set_rule(world.get_entrance('Ganons Tower Torch Rooms', player), - lambda state: state.has_fire_source(player) and state.multiworld.get_entrance('Ganons Tower Torch Rooms', player).parent_region.dungeon.bosses['middle'].can_defeat(state)) + lambda state: has_fire_source(state, player) and state.multiworld.get_entrance('Ganons Tower Torch Rooms', player).parent_region.dungeon.bosses['middle'].can_defeat(state)) set_rule(world.get_location('Ganons Tower - Pre-Moldorm Chest', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 3)) set_rule(world.get_entrance('Ganons Tower Moldorm Door', player), @@ -412,12 +419,12 @@ def global_rules(world, player): ganon = world.get_location('Ganon', player) set_rule(ganon, lambda state: GanonDefeatRule(state, player)) if world.goal[player] in ['ganontriforcehunt', 'localganontriforcehunt']: - add_rule(ganon, lambda state: state.has_triforce_pieces(state.multiworld.treasure_hunt_count[player], player)) + add_rule(ganon, lambda state: has_triforce_pieces(state, player)) elif world.goal[player] == 'ganonpedestal': add_rule(world.get_location('Ganon', player), lambda state: state.can_reach('Master Sword Pedestal', 'Location', player)) else: - add_rule(ganon, lambda state: state.has_crystals(state.multiworld.crystals_needed_for_ganon[player], player)) - set_rule(world.get_entrance('Ganon Drop', player), lambda state: state.has_beam_sword(player)) # need to damage ganon to get tiles to drop + add_rule(ganon, lambda state: has_crystals(state, state.multiworld.crystals_needed_for_ganon[player], player)) + set_rule(world.get_entrance('Ganon Drop', player), lambda state: has_beam_sword(state, player)) # need to damage ganon to get tiles to drop set_rule(world.get_location('Flute Activation Spot', player), lambda state: state.has('Flute', player)) @@ -426,51 +433,51 @@ def default_rules(world, player): """Default world rules when world state is not inverted.""" # overworld requirements set_rule(world.get_entrance('Kings Grave', player), lambda state: state.has('Pegasus Boots', player)) - set_rule(world.get_entrance('Kings Grave Outer Rocks', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Kings Grave Inner Rocks', player), lambda state: state.can_lift_heavy_rocks(player)) + set_rule(world.get_entrance('Kings Grave Outer Rocks', player), lambda state: can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Kings Grave Inner Rocks', player), lambda state: can_lift_heavy_rocks(state, player)) set_rule(world.get_entrance('Kings Grave Mirror Spot', player), lambda state: state.has('Moon Pearl', player) and state.has('Magic Mirror', player)) # Caution: If king's grave is releaxed at all to account for reaching it via a two way cave's exit in insanity mode, then the bomb shop logic will need to be updated (that would involve create a small ledge-like Region for it) set_rule(world.get_entrance('Bonk Fairy (Light)', player), lambda state: state.has('Pegasus Boots', player)) set_rule(world.get_entrance('Lumberjack Tree Tree', player), lambda state: state.has('Pegasus Boots', player) and state.has('Beat Agahnim 1', player)) set_rule(world.get_entrance('Bonk Rock Cave', player), lambda state: state.has('Pegasus Boots', player)) set_rule(world.get_entrance('Desert Palace Stairs', player), lambda state: state.has('Book of Mudora', player)) - set_rule(world.get_entrance('Sanctuary Grave', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('20 Rupee Cave', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('50 Rupee Cave', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('Death Mountain Entrance Rock', player), lambda state: state.can_lift_rocks(player)) + set_rule(world.get_entrance('Sanctuary Grave', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('20 Rupee Cave', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('50 Rupee Cave', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('Death Mountain Entrance Rock', player), lambda state: can_lift_rocks(state, player)) set_rule(world.get_entrance('Bumper Cave Entrance Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Flute Spot 1', player), lambda state: state.has('Activated Flute', player)) - set_rule(world.get_entrance('Lake Hylia Central Island Teleporter', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Dark Desert Teleporter', player), lambda state: state.has('Activated Flute', player) and state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('East Hyrule Teleporter', player), lambda state: state.has('Hammer', player) and state.can_lift_rocks(player) and state.has('Moon Pearl', player)) # bunny cannot use hammer - set_rule(world.get_entrance('South Hyrule Teleporter', player), lambda state: state.has('Hammer', player) and state.can_lift_rocks(player) and state.has('Moon Pearl', player)) # bunny cannot use hammer - set_rule(world.get_entrance('Kakariko Teleporter', player), lambda state: ((state.has('Hammer', player) and state.can_lift_rocks(player)) or state.can_lift_heavy_rocks(player)) and state.has('Moon Pearl', player)) # bunny cannot lift bushes + set_rule(world.get_entrance('Lake Hylia Central Island Teleporter', player), lambda state: can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Dark Desert Teleporter', player), lambda state: state.has('Activated Flute', player) and can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('East Hyrule Teleporter', player), lambda state: state.has('Hammer', player) and can_lift_rocks(state, player) and state.has('Moon Pearl', player)) # bunny cannot use hammer + set_rule(world.get_entrance('South Hyrule Teleporter', player), lambda state: state.has('Hammer', player) and can_lift_rocks(state, player) and state.has('Moon Pearl', player)) # bunny cannot use hammer + set_rule(world.get_entrance('Kakariko Teleporter', player), lambda state: ((state.has('Hammer', player) and can_lift_rocks(state, player)) or can_lift_heavy_rocks(state, player)) and state.has('Moon Pearl', player)) # bunny cannot lift bushes set_rule(world.get_location('Flute Spot', player), lambda state: state.has('Shovel', player)) set_rule(world.get_entrance('Bat Cave Drop Ledge', player), lambda state: state.has('Hammer', player)) set_rule(world.get_location('Zora\'s Ledge', player), lambda state: state.has('Flippers', player)) set_rule(world.get_entrance('Waterfall of Wishing', player), lambda state: state.has('Flippers', player)) - set_rule(world.get_location('Frog', player), lambda state: state.can_lift_heavy_rocks(player)) # will get automatic moon pearl requirement + set_rule(world.get_location('Frog', player), lambda state: can_lift_heavy_rocks(state, player)) # will get automatic moon pearl requirement set_rule(world.get_location('Potion Shop', player), lambda state: state.has('Mushroom', player)) - set_rule(world.get_entrance('Desert Palace Entrance (North) Rocks', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('Desert Ledge Return Rocks', player), lambda state: state.can_lift_rocks(player)) # should we decide to place something that is not a dungeon end up there at some point - set_rule(world.get_entrance('Checkerboard Cave', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('Agahnims Tower', player), lambda state: state.has('Cape', player) or state.has_beam_sword(player) or state.has('Beat Agahnim 1', player)) # barrier gets removed after killing agahnim, relevant for entrance shuffle + set_rule(world.get_entrance('Desert Palace Entrance (North) Rocks', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('Desert Ledge Return Rocks', player), lambda state: can_lift_rocks(state, player)) # should we decide to place something that is not a dungeon end up there at some point + set_rule(world.get_entrance('Checkerboard Cave', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('Agahnims Tower', player), lambda state: state.has('Cape', player) or has_beam_sword(state, player) or state.has('Beat Agahnim 1', player)) # barrier gets removed after killing agahnim, relevant for entrance shuffle set_rule(world.get_entrance('Top of Pyramid', player), lambda state: state.has('Beat Agahnim 1', player)) set_rule(world.get_entrance('Old Man Cave Exit (West)', player), lambda state: False) # drop cannot be climbed up set_rule(world.get_entrance('Broken Bridge (West)', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('Broken Bridge (East)', player), lambda state: state.has('Hookshot', player)) - set_rule(world.get_entrance('East Death Mountain Teleporter', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Fairy Ascension Rocks', player), lambda state: state.can_lift_heavy_rocks(player)) + set_rule(world.get_entrance('East Death Mountain Teleporter', player), lambda state: can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Fairy Ascension Rocks', player), lambda state: can_lift_heavy_rocks(state, player)) set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: state.has('Mirror', player)) # can erase block set_rule(world.get_entrance('Death Mountain (Top)', player), lambda state: state.has('Hammer', player)) - set_rule(world.get_entrance('Turtle Rock Teleporter', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Hammer', player)) + set_rule(world.get_entrance('Turtle Rock Teleporter', player), lambda state: can_lift_heavy_rocks(state, player) and state.has('Hammer', player)) set_rule(world.get_entrance('East Death Mountain (Top)', player), lambda state: state.has('Hammer', player)) - set_rule(world.get_entrance('Catfish Exit Rock', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('Catfish Entrance Rock', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('Northeast Dark World Broken Bridge Pass', player), lambda state: state.has('Moon Pearl', player) and (state.can_lift_rocks(player) or state.has('Hammer', player) or state.has('Flippers', player))) - set_rule(world.get_entrance('East Dark World Broken Bridge Pass', player), lambda state: state.has('Moon Pearl', player) and (state.can_lift_rocks(player) or state.has('Hammer', player))) + set_rule(world.get_entrance('Catfish Exit Rock', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('Catfish Entrance Rock', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('Northeast Dark World Broken Bridge Pass', player), lambda state: state.has('Moon Pearl', player) and (can_lift_rocks(state, player) or state.has('Hammer', player) or state.has('Flippers', player))) + set_rule(world.get_entrance('East Dark World Broken Bridge Pass', player), lambda state: state.has('Moon Pearl', player) and (can_lift_rocks(state, player) or state.has('Hammer', player))) set_rule(world.get_entrance('South Dark World Bridge', player), lambda state: state.has('Hammer', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Bonk Fairy (Dark)', player), lambda state: state.has('Moon Pearl', player) and state.has('Pegasus Boots', player)) set_rule(world.get_entrance('West Dark World Gap', player), lambda state: state.has('Moon Pearl', player) and state.has('Hookshot', player)) @@ -478,12 +485,12 @@ def default_rules(world, player): set_rule(world.get_entrance('Hyrule Castle Ledge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Hyrule Castle Main Gate', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Dark Lake Hylia Drop (East)', player), lambda state: (state.has('Moon Pearl', player) and state.has('Flippers', player) or state.has('Magic Mirror', player))) # Overworld Bunny Revival - set_rule(world.get_location('Bombos Tablet', player), lambda state: state.can_retrieve_tablet(player)) + set_rule(world.get_location('Bombos Tablet', player), lambda state: can_retrieve_tablet(state, player)) set_rule(world.get_entrance('Dark Lake Hylia Drop (South)', player), lambda state: state.has('Moon Pearl', player) and state.has('Flippers', player)) # ToDo any fake flipper set up? set_rule(world.get_entrance('Dark Lake Hylia Ledge Fairy', player), lambda state: state.has('Moon Pearl', player)) # bomb required - set_rule(world.get_entrance('Dark Lake Hylia Ledge Spike Cave', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Dark Lake Hylia Ledge Spike Cave', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Dark Lake Hylia Teleporter', player), lambda state: state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Village of Outcasts Heavy Rock', player), lambda state: state.has('Moon Pearl', player) and state.can_lift_heavy_rocks(player)) + set_rule(world.get_entrance('Village of Outcasts Heavy Rock', player), lambda state: state.has('Moon Pearl', player) and can_lift_heavy_rocks(state, player)) set_rule(world.get_entrance('Hype Cave', player), lambda state: state.has('Moon Pearl', player)) # bomb required set_rule(world.get_entrance('Brewery', player), lambda state: state.has('Moon Pearl', player)) # bomb required set_rule(world.get_entrance('Thieves Town', player), lambda state: state.has('Moon Pearl', player)) # bunny cannot pull @@ -497,26 +504,26 @@ def default_rules(world, player): set_rule(world.get_entrance('Lake Hylia Central Island Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('East Dark World River Pier', player), lambda state: state.has('Moon Pearl', player) and state.has('Flippers', player)) set_rule(world.get_entrance('Graveyard Ledge Mirror Spot', player), lambda state: state.has('Moon Pearl', player) and state.has('Magic Mirror', player)) - set_rule(world.get_entrance('Bumper Cave Entrance Rock', player), lambda state: state.has('Moon Pearl', player) and state.can_lift_rocks(player)) + set_rule(world.get_entrance('Bumper Cave Entrance Rock', player), lambda state: state.has('Moon Pearl', player) and can_lift_rocks(state, player)) set_rule(world.get_entrance('Bumper Cave Ledge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Bat Cave Drop Ledge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Dark World Hammer Peg Cave', player), lambda state: state.has('Moon Pearl', player) and state.has('Hammer', player)) - set_rule(world.get_entrance('Village of Outcasts Eastern Rocks', player), lambda state: state.has('Moon Pearl', player) and state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Peg Area Rocks', player), lambda state: state.has('Moon Pearl', player) and state.can_lift_heavy_rocks(player)) + set_rule(world.get_entrance('Village of Outcasts Eastern Rocks', player), lambda state: state.has('Moon Pearl', player) and can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Peg Area Rocks', player), lambda state: state.has('Moon Pearl', player) and can_lift_heavy_rocks(state, player)) set_rule(world.get_entrance('Village of Outcasts Pegs', player), lambda state: state.has('Moon Pearl', player) and state.has('Hammer', player)) set_rule(world.get_entrance('Grassy Lawn Pegs', player), lambda state: state.has('Moon Pearl', player) and state.has('Hammer', player)) set_rule(world.get_entrance('Bumper Cave Exit (Top)', player), lambda state: state.has('Cape', player)) set_rule(world.get_entrance('Bumper Cave Exit (Bottom)', player), lambda state: state.has('Cape', player) or state.has('Hookshot', player)) set_rule(world.get_entrance('Skull Woods Final Section', player), lambda state: state.has('Fire Rod', player) and state.has('Moon Pearl', player)) # bunny cannot use fire rod - set_rule(world.get_entrance('Misery Mire', player), lambda state: state.has('Moon Pearl', player) and state.has_sword(player) and state.has_misery_mire_medallion(player)) # sword required to cast magic (!) + set_rule(world.get_entrance('Misery Mire', player), lambda state: state.has('Moon Pearl', player) and has_sword(state, player) and has_misery_mire_medallion(state, player)) # sword required to cast magic (!) set_rule(world.get_entrance('Desert Ledge (Northeast) Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Desert Ledge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Desert Palace Stairs Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Desert Palace Entrance (North) Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Spectacle Rock Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) - set_rule(world.get_entrance('Hookshot Cave', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Hookshot Cave', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('East Death Mountain (Top) Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Mimic Cave Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) @@ -525,7 +532,7 @@ def default_rules(world, player): set_rule(world.get_entrance('Isolated Ledge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Superbunny Cave Exit (Bottom)', player), lambda state: False) # Cannot get to bottom exit from top. Just exists for shuffling set_rule(world.get_entrance('Floating Island Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) - set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has('Moon Pearl', player) and state.has_sword(player) and state.has_turtle_rock_medallion(player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword required to cast magic (!) + set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has('Moon Pearl', player) and has_sword(state, player) and has_turtle_rock_medallion(state, player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword required to cast magic (!) set_rule(world.get_entrance('Pyramid Hole', player), lambda state: state.has('Beat Agahnim 2', player) or world.open_pyramid[player].to_bool(world, player)) @@ -545,12 +552,12 @@ def inverted_rules(world, player): set_rule(world.get_entrance('Potion Shop Pier', player), lambda state: state.has('Flippers', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Light World Pier', player), lambda state: state.has('Flippers', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Kings Grave', player), lambda state: state.has('Pegasus Boots', player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Kings Grave Outer Rocks', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Kings Grave Inner Rocks', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Kings Grave Outer Rocks', player), lambda state: can_lift_heavy_rocks(state, player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Kings Grave Inner Rocks', player), lambda state: can_lift_heavy_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Potion Shop Inner Bushes', player), lambda state: state.has('Moon Pearl', player)) set_rule(world.get_entrance('Potion Shop Outer Bushes', player), lambda state: state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Potion Shop Outer Rock', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Potion Shop Inner Rock', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Potion Shop Outer Rock', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Potion Shop Inner Rock', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Graveyard Cave Inner Bushes', player), lambda state: state.has('Moon Pearl', player)) set_rule(world.get_entrance('Graveyard Cave Outer Bushes', player), lambda state: state.has('Moon Pearl', player)) set_rule(world.get_entrance('Secret Passage Inner Bushes', player), lambda state: state.has('Moon Pearl', player)) @@ -560,23 +567,23 @@ def inverted_rules(world, player): set_rule(world.get_entrance('Lumberjack Tree Tree', player), lambda state: state.has('Pegasus Boots', player) and state.has('Moon Pearl', player) and state.has('Beat Agahnim 1', player)) set_rule(world.get_entrance('Bonk Rock Cave', player), lambda state: state.has('Pegasus Boots', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Desert Palace Stairs', player), lambda state: state.has('Book of Mudora', player)) # bunny can use book - set_rule(world.get_entrance('Sanctuary Grave', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('20 Rupee Cave', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('50 Rupee Cave', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Death Mountain Entrance Rock', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Sanctuary Grave', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('20 Rupee Cave', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('50 Rupee Cave', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Death Mountain Entrance Rock', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Bumper Cave Entrance Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Lake Hylia Central Island Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) - set_rule(world.get_entrance('Dark Lake Hylia Central Island Teleporter', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Dark Desert Teleporter', player), lambda state: state.has('Activated Flute', player) and state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('East Dark World Teleporter', player), lambda state: state.has('Hammer', player) and state.can_lift_rocks(player) and state.has('Moon Pearl', player)) # bunny cannot use hammer - set_rule(world.get_entrance('South Dark World Teleporter', player), lambda state: state.has('Hammer', player) and state.can_lift_rocks(player) and state.has('Moon Pearl', player)) # bunny cannot use hammer - set_rule(world.get_entrance('West Dark World Teleporter', player), lambda state: ((state.has('Hammer', player) and state.can_lift_rocks(player)) or state.can_lift_heavy_rocks(player)) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Dark Lake Hylia Central Island Teleporter', player), lambda state: can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Dark Desert Teleporter', player), lambda state: state.has('Activated Flute', player) and can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('East Dark World Teleporter', player), lambda state: state.has('Hammer', player) and can_lift_rocks(state, player) and state.has('Moon Pearl', player)) # bunny cannot use hammer + set_rule(world.get_entrance('South Dark World Teleporter', player), lambda state: state.has('Hammer', player) and can_lift_rocks(state, player) and state.has('Moon Pearl', player)) # bunny cannot use hammer + set_rule(world.get_entrance('West Dark World Teleporter', player), lambda state: ((state.has('Hammer', player) and can_lift_rocks(state, player)) or can_lift_heavy_rocks(state, player)) and state.has('Moon Pearl', player)) set_rule(world.get_location('Flute Spot', player), lambda state: state.has('Shovel', player) and state.has('Moon Pearl', player)) set_rule(world.get_location('Zora\'s Ledge', player), lambda state: state.has('Flippers', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Waterfall of Wishing Cave', player), lambda state: state.has('Flippers', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Northeast Light World Return', player), lambda state: state.has('Flippers', player) and state.has('Moon Pearl', player)) - set_rule(world.get_location('Frog', player), lambda state: state.can_lift_heavy_rocks(player) and (state.has('Moon Pearl', player) or state.has('Beat Agahnim 1', player)) or (state.can_reach('Light World', 'Region', player) and state.has('Magic Mirror', player))) # Need LW access using Mirror or Portal + set_rule(world.get_location('Frog', player), lambda state: can_lift_heavy_rocks(state, player) and (state.has('Moon Pearl', player) or state.has('Beat Agahnim 1', player)) or (state.can_reach('Light World', 'Region', player) and state.has('Magic Mirror', player))) # Need LW access using Mirror or Portal set_rule(world.get_location('Missing Smith', player), lambda state: state.has('Get Frog', player) and state.can_reach('Blacksmiths Hut', 'Region', player)) # Can't S&Q with smith set_rule(world.get_location('Blacksmith', player), lambda state: state.has('Return Smith', player)) set_rule(world.get_location('Magic Bat', player), lambda state: state.has('Magic Powder', player) and state.has('Moon Pearl', player)) @@ -591,52 +598,52 @@ def inverted_rules(world, player): set_rule(world.get_entrance('North Fairy Cave Drop', player), lambda state: state.has('Moon Pearl', player)) set_rule(world.get_entrance('Lost Woods Hideout Drop', player), lambda state: state.has('Moon Pearl', player)) set_rule(world.get_location('Potion Shop', player), lambda state: state.has('Mushroom', player) and (state.can_reach('Potion Shop Area', 'Region', player))) # new inverted region, need pearl for bushes or access to potion shop door/waterfall fairy - set_rule(world.get_entrance('Desert Palace Entrance (North) Rocks', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Desert Ledge Return Rocks', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) # should we decide to place something that is not a dungeon end up there at some point - set_rule(world.get_entrance('Checkerboard Cave', player), lambda state: state.can_lift_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Desert Palace Entrance (North) Rocks', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Desert Ledge Return Rocks', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) # should we decide to place something that is not a dungeon end up there at some point + set_rule(world.get_entrance('Checkerboard Cave', player), lambda state: can_lift_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Hyrule Castle Secret Entrance Drop', player), lambda state: state.has('Moon Pearl', player)) set_rule(world.get_entrance('Old Man Cave Exit (West)', player), lambda state: False) # drop cannot be climbed up set_rule(world.get_entrance('Broken Bridge (West)', player), lambda state: state.has('Hookshot', player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Broken Bridge (East)', player), lambda state: state.has('Hookshot', player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Dark Death Mountain Teleporter (East Bottom)', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Fairy Ascension Rocks', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Moon Pearl', player)) + set_rule(world.get_entrance('Dark Death Mountain Teleporter (East Bottom)', player), lambda state: can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Fairy Ascension Rocks', player), lambda state: can_lift_heavy_rocks(state, player) and state.has('Moon Pearl', player)) set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: state.has('Mirror', player)) # can erase block set_rule(world.get_entrance('Death Mountain (Top)', player), lambda state: state.has('Hammer', player) and state.has('Moon Pearl', player)) - set_rule(world.get_entrance('Dark Death Mountain Teleporter (East)', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Hammer', player) and state.has('Moon Pearl', player)) # bunny cannot use hammer + set_rule(world.get_entrance('Dark Death Mountain Teleporter (East)', player), lambda state: can_lift_heavy_rocks(state, player) and state.has('Hammer', player) and state.has('Moon Pearl', player)) # bunny cannot use hammer set_rule(world.get_entrance('East Death Mountain (Top)', player), lambda state: state.has('Hammer', player) and state.has('Moon Pearl', player)) # bunny can not use hammer - set_rule(world.get_entrance('Catfish Entrance Rock', player), lambda state: state.can_lift_rocks(player)) - set_rule(world.get_entrance('Northeast Dark World Broken Bridge Pass', player), lambda state: ((state.can_lift_rocks(player) or state.has('Hammer', player)) or state.has('Flippers', player))) - set_rule(world.get_entrance('East Dark World Broken Bridge Pass', player), lambda state: (state.can_lift_rocks(player) or state.has('Hammer', player))) + set_rule(world.get_entrance('Catfish Entrance Rock', player), lambda state: can_lift_rocks(state, player)) + set_rule(world.get_entrance('Northeast Dark World Broken Bridge Pass', player), lambda state: ((can_lift_rocks(state, player) or state.has('Hammer', player)) or state.has('Flippers', player))) + set_rule(world.get_entrance('East Dark World Broken Bridge Pass', player), lambda state: (can_lift_rocks(state, player) or state.has('Hammer', player))) set_rule(world.get_entrance('South Dark World Bridge', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('Bonk Fairy (Dark)', player), lambda state: state.has('Pegasus Boots', player)) set_rule(world.get_entrance('West Dark World Gap', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('Dark Lake Hylia Drop (East)', player), lambda state: state.has('Flippers', player)) - set_rule(world.get_location('Bombos Tablet', player), lambda state: state.can_retrieve_tablet(player)) + set_rule(world.get_location('Bombos Tablet', player), lambda state: can_retrieve_tablet(state, player)) set_rule(world.get_entrance('Dark Lake Hylia Drop (South)', player), lambda state: state.has('Flippers', player)) # ToDo any fake flipper set up? set_rule(world.get_entrance('Dark Lake Hylia Ledge Pier', player), lambda state: state.has('Flippers', player)) - set_rule(world.get_entrance('Dark Lake Hylia Ledge Spike Cave', player), lambda state: state.can_lift_rocks(player)) + set_rule(world.get_entrance('Dark Lake Hylia Ledge Spike Cave', player), lambda state: can_lift_rocks(state, player)) set_rule(world.get_entrance('Dark Lake Hylia Teleporter', player), lambda state: state.has('Flippers', player)) # Fake Flippers set_rule(world.get_entrance('Dark Lake Hylia Shallows', player), lambda state: state.has('Flippers', player)) - set_rule(world.get_entrance('Village of Outcasts Heavy Rock', player), lambda state: state.can_lift_heavy_rocks(player)) + set_rule(world.get_entrance('Village of Outcasts Heavy Rock', player), lambda state: can_lift_heavy_rocks(state, player)) set_rule(world.get_entrance('East Dark World Bridge', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('Lake Hylia Central Island Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('East Dark World River Pier', player), lambda state: state.has('Flippers', player)) - set_rule(world.get_entrance('Bumper Cave Entrance Rock', player), lambda state: state.can_lift_rocks(player)) + set_rule(world.get_entrance('Bumper Cave Entrance Rock', player), lambda state: can_lift_rocks(state, player)) set_rule(world.get_entrance('Bumper Cave Ledge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Hammer Peg Area Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Dark World Hammer Peg Cave', player), lambda state: state.has('Hammer', player)) - set_rule(world.get_entrance('Village of Outcasts Eastern Rocks', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Peg Area Rocks', player), lambda state: state.can_lift_heavy_rocks(player)) + set_rule(world.get_entrance('Village of Outcasts Eastern Rocks', player), lambda state: can_lift_heavy_rocks(state, player)) + set_rule(world.get_entrance('Peg Area Rocks', player), lambda state: can_lift_heavy_rocks(state, player)) set_rule(world.get_entrance('Village of Outcasts Pegs', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('Grassy Lawn Pegs', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('Bumper Cave Exit (Top)', player), lambda state: state.has('Cape', player)) set_rule(world.get_entrance('Bumper Cave Exit (Bottom)', player), lambda state: state.has('Cape', player) or state.has('Hookshot', player)) set_rule(world.get_entrance('Skull Woods Final Section', player), lambda state: state.has('Fire Rod', player)) - set_rule(world.get_entrance('Misery Mire', player), lambda state: state.has_sword(player) and state.has_misery_mire_medallion(player)) # sword required to cast magic (!) + set_rule(world.get_entrance('Misery Mire', player), lambda state: has_sword(state, player) and has_misery_mire_medallion(state, player)) # sword required to cast magic (!) - set_rule(world.get_entrance('Hookshot Cave', player), lambda state: state.can_lift_rocks(player)) + set_rule(world.get_entrance('Hookshot Cave', player), lambda state: can_lift_rocks(state, player)) set_rule(world.get_entrance('East Death Mountain Mirror Spot (Top)', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Death Mountain (Top) Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) @@ -646,7 +653,7 @@ def inverted_rules(world, player): set_rule(world.get_entrance('Dark Death Mountain Ledge Mirror Spot (West)', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Laser Bridge Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) set_rule(world.get_entrance('Floating Island Mirror Spot', player), lambda state: state.has('Magic Mirror', player)) - set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has_sword(player) and state.has_turtle_rock_medallion(player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword required to cast magic (!) + set_rule(world.get_entrance('Turtle Rock', player), lambda state: has_sword(state, player) and has_turtle_rock_medallion(state, player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword required to cast magic (!) # new inverted spots set_rule(world.get_entrance('Post Aga Teleporter', player), lambda state: state.has('Beat Agahnim 1', player)) @@ -687,7 +694,7 @@ def inverted_rules(world, player): def no_glitches_rules(world, player): """""" if world.mode[player] == 'inverted': - set_rule(world.get_entrance('Zoras River', player), lambda state: state.has('Moon Pearl', player) and (state.has('Flippers', player) or state.can_lift_rocks(player))) + set_rule(world.get_entrance('Zoras River', player), lambda state: state.has('Moon Pearl', player) and (state.has('Flippers', player) or can_lift_rocks(state, player))) set_rule(world.get_entrance('Lake Hylia Central Island Pier', player), lambda state: state.has('Moon Pearl', player) and state.has('Flippers', player)) # can be fake flippered to set_rule(world.get_entrance('Lake Hylia Island Pier', player), lambda state: state.has('Moon Pearl', player) and state.has('Flippers', player)) # can be fake flippered to set_rule(world.get_entrance('Lake Hylia Warp', player), lambda state: state.has('Moon Pearl', player) and state.has('Flippers', player)) # can be fake flippered to @@ -698,7 +705,7 @@ def no_glitches_rules(world, player): set_rule(world.get_entrance('Dark Lake Hylia Ledge Drop', player), lambda state: state.has('Flippers', player)) set_rule(world.get_entrance('East Dark World Pier', player), lambda state: state.has('Flippers', player)) else: - set_rule(world.get_entrance('Zoras River', player), lambda state: state.has('Flippers', player) or state.can_lift_rocks(player)) + set_rule(world.get_entrance('Zoras River', player), lambda state: state.has('Flippers', player) or can_lift_rocks(state, player)) set_rule(world.get_entrance('Lake Hylia Central Island Pier', player), lambda state: state.has('Flippers', player)) # can be fake flippered to set_rule(world.get_entrance('Hobo Bridge', player), lambda state: state.has('Flippers', player)) set_rule(world.get_entrance('Dark Lake Hylia Drop (East)', player), lambda state: state.has('Moon Pearl', player) and state.has('Flippers', player)) @@ -820,19 +827,19 @@ def open_rules(world, player): def swordless_rules(world, player): - set_rule(world.get_entrance('Agahnim 1', player), lambda state: (state.has('Hammer', player) or state.has('Fire Rod', player) or state.can_shoot_arrows(player) or state.has('Cane of Somaria', player)) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 2)) + set_rule(world.get_entrance('Agahnim 1', player), lambda state: (state.has('Hammer', player) or state.has('Fire Rod', player) or can_shoot_arrows(state, player) or state.has('Cane of Somaria', player)) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 2)) set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 3) and state.has('Fire Rod', player)) # no curtain set_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) #in swordless mode bombos pads are present in the relevant parts of ice palace set_rule(world.get_entrance('Ganon Drop', player), lambda state: state.has('Hammer', player)) # need to damage ganon to get tiles to drop if world.mode[player] != 'inverted': set_rule(world.get_entrance('Agahnims Tower', player), lambda state: state.has('Cape', player) or state.has('Hammer', player) or state.has('Beat Agahnim 1', player)) # barrier gets removed after killing agahnim, relevant for entrance shuffle - set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has('Moon Pearl', player) and state.has_turtle_rock_medallion(player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword not required to use medallion for opening in swordless (!) - set_rule(world.get_entrance('Misery Mire', player), lambda state: state.has('Moon Pearl', player) and state.has_misery_mire_medallion(player)) # sword not required to use medallion for opening in swordless (!) + set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has('Moon Pearl', player) and has_turtle_rock_medallion(state, player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword not required to use medallion for opening in swordless (!) + set_rule(world.get_entrance('Misery Mire', player), lambda state: state.has('Moon Pearl', player) and has_misery_mire_medallion(state, player)) # sword not required to use medallion for opening in swordless (!) else: # only need ddm access for aga tower in inverted - set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has_turtle_rock_medallion(player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword not required to use medallion for opening in swordless (!) - set_rule(world.get_entrance('Misery Mire', player), lambda state: state.has_misery_mire_medallion(player)) # sword not required to use medallion for opening in swordless (!) + set_rule(world.get_entrance('Turtle Rock', player), lambda state: has_turtle_rock_medallion(state, player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword not required to use medallion for opening in swordless (!) + set_rule(world.get_entrance('Misery Mire', player), lambda state: has_misery_mire_medallion(state, player)) # sword not required to use medallion for opening in swordless (!) def add_connection(parent_name, target_name, entrance_name, world, player): @@ -904,7 +911,7 @@ def set_trock_key_rules(world, player): # Now we need to set rules based on which entrances we have access to. The most important point is whether we have back access. If we have back access, we # might open all the locked doors in any order so we need maximally restrictive rules. if can_reach_back: - set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 4) or item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player))) + set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 4) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player))) set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4)) # Only consider wasting the key on the Trinexx door for going from the front entrance to middle section. If other key doors are accessible, then these doors can be avoided set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)) @@ -924,7 +931,7 @@ def set_trock_key_rules(world, player): def tr_big_key_chest_keys_needed(state): # This function handles the key requirements for the TR Big Chest in the situations it having the Big Key should logically require 2 keys, small key # should logically require no keys, and anything else should logically require 4 keys. - item = item_name(state, 'Turtle Rock - Big Key Chest', player) + item = location_item_name(state, 'Turtle Rock - Big Key Chest', player) if item in [('Small Key (Turtle Rock)', player)]: return 0 if item in [('Big Key (Turtle Rock)', player)]: @@ -1083,7 +1090,7 @@ def set_big_bomb_rules(world, player): # returning via the eastern and southern teleporters needs the same items, so we use the southern teleporter for out routing. # crossing preg bridge already requires hammer so we just add the gloves to the requirement def southern_teleporter(state): - return state.can_lift_rocks(player) and cross_peg_bridge(state) + return can_lift_rocks(state, player) and cross_peg_bridge(state) # the basic routes assume you can reach eastern light world with the bomb. # you can then use the southern teleporter, or (if you have beaten Aga1) the hyrule castle gate warp @@ -1110,13 +1117,13 @@ def set_big_bomb_rules(world, player): #1. Mirror and basic routes #2. Go to south DW and then cross peg bridge: Need Mitts and hammer and moon pearl # -> (Mitts and CPB) or (M and BR) - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.can_lift_heavy_rocks(player) and cross_peg_bridge(state)) or (state.has('Magic Mirror', player) and basic_routes(state))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (can_lift_heavy_rocks(state, player) and cross_peg_bridge(state)) or (state.has('Magic Mirror', player) and basic_routes(state))) elif bombshop_entrance.name == 'Bumper Cave (Bottom)': #1. Mirror and Lift rock and basic_routes #2. Mirror and Flute and basic routes (can make difference if accessed via insanity or w/ mirror from connector, and then via hyrule castle gate, because no gloves are needed in that case) #3. Go to south DW and then cross peg bridge: Need Mitts and hammer and moon pearl # -> (Mitts and CPB) or (((G or Flute) and M) and BR)) - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.can_lift_heavy_rocks(player) and cross_peg_bridge(state)) or (((state.can_lift_rocks(player) or state.has('Flute', player)) and state.has('Magic Mirror', player)) and basic_routes(state))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (can_lift_heavy_rocks(state, player) and cross_peg_bridge(state)) or (((can_lift_rocks(state, player) or state.has('Flute', player)) and state.has('Magic Mirror', player)) and basic_routes(state))) elif bombshop_entrance.name in Southern_DW_entrances: #1. Mirror and enter via gate: Need mirror and Aga1 #2. cross peg bridge: Need hammer and moon pearl @@ -1144,7 +1151,7 @@ def set_big_bomb_rules(world, player): elif bombshop_entrance.name == 'Fairy Ascension Cave (Bottom)': # Same as East_LW_DM_entrances except navigation without BR requires Mitts # -> Flute and ((M and Hookshot and Mitts) or BR) - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) and ((state.has('Magic Mirror', player) and state.has('Hookshot', player) and state.can_lift_heavy_rocks(player)) or basic_routes(state))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) and ((state.has('Magic Mirror', player) and state.has('Hookshot', player) and can_lift_heavy_rocks(state, player)) or basic_routes(state))) elif bombshop_entrance.name in Castle_ledge_entrances: # 1. mirror on pyramid to castle ledge, grab bomb, return through mirror spot: Needs mirror # 2. flute then basic routes @@ -1160,7 +1167,7 @@ def set_big_bomb_rules(world, player): # 1. Lift rock then basic_routes # 2. flute then basic_routes # -> (Flute or G) and BR - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.has('Activated Flute', player) or state.can_lift_rocks(player)) and basic_routes(state)) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.has('Activated Flute', player) or can_lift_rocks(state, player)) and basic_routes(state)) elif bombshop_entrance.name == 'Graveyard Cave': # 1. flute then basic routes # 2. (has west dark world access) use existing mirror spot (required Pearl), mirror again off ledge @@ -1176,13 +1183,13 @@ def set_big_bomb_rules(world, player): # 2. walk down by hammering peg: needs hammer and pearl # 3. mirror and basic routes # -> (P and (H or Gloves)) or (M and BR) - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.has('Moon Pearl', player) and (state.has('Hammer', player) or state.can_lift_rocks(player))) or (state.has('Magic Mirror', player) and basic_routes(state))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.has('Moon Pearl', player) and (state.has('Hammer', player) or can_lift_rocks(state, player))) or (state.has('Magic Mirror', player) and basic_routes(state))) elif bombshop_entrance.name == 'Kings Grave': # same as the Normal_LW_entrances case except that the pre-existing mirror is only possible if you have mitts # (because otherwise mirror was used to reach the grave, so would cancel a pre-existing mirror spot) # to account for insanity, must consider a way to escape without a cave for basic_routes # -> (M and Mitts) or ((Mitts or Flute or (M and P and West Dark World access)) and BR) - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.can_lift_heavy_rocks(player) and state.has('Magic Mirror', player)) or ((state.can_lift_heavy_rocks(player) or state.has('Activated Flute', player) or (state.can_reach('West Dark World', 'Region', player) and state.has('Moon Pearl', player) and state.has('Magic Mirror', player))) and basic_routes(state))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (can_lift_heavy_rocks(state, player) and state.has('Magic Mirror', player)) or ((can_lift_heavy_rocks(state, player) or state.has('Activated Flute', player) or (state.can_reach('West Dark World', 'Region', player) and state.has('Moon Pearl', player) and state.has('Magic Mirror', player))) and basic_routes(state))) elif bombshop_entrance.name == 'Waterfall of Wishing': # same as the Normal_LW_entrances case except in insanity it's possible you could be here without Flippers which # means you need an escape route of either Flippers or Flute @@ -1329,7 +1336,7 @@ def set_inverted_big_bomb_rules(world, player): elif bombshop_entrance.name in Northern_DW_entrances: # You can just fly with the Flute, you can take a long walk with Mitts and Hammer, # or you can leave a Mirror portal nearby and then walk to the castle to Mirror again. - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (state.can_lift_heavy_rocks(player) and state.has('Hammer', player)) or (state.has('Magic Mirror', player) and state.can_reach('Light World', 'Region', player))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (can_lift_heavy_rocks(state, player) and state.has('Hammer', player)) or (state.has('Magic Mirror', player) and state.can_reach('Light World', 'Region', player))) elif bombshop_entrance.name in Southern_DW_entrances: # This is the same as north DW without the Mitts rock present. add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Hammer', player) or state.has('Activated Flute', player) or (state.has('Magic Mirror', player) and state.can_reach('Light World', 'Region', player))) @@ -1341,22 +1348,22 @@ def set_inverted_big_bomb_rules(world, player): add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (state.has('Magic Mirror', player) and state.can_reach('Light World', 'Region', player))) elif bombshop_entrance.name in LW_bush_entrances: # These entrances are behind bushes in LW so you need either Pearl or the tools to solve NDW bomb shop locations. - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Magic Mirror', player) and (state.has('Activated Flute', player) or state.has('Moon Pearl', player) or (state.can_lift_heavy_rocks(player) and state.has('Hammer', player)))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Magic Mirror', player) and (state.has('Activated Flute', player) or state.has('Moon Pearl', player) or (can_lift_heavy_rocks(state, player) and state.has('Hammer', player)))) elif bombshop_entrance.name == 'Village of Outcasts Shop': # This is mostly the same as NDW but the Mirror path requires the Pearl, or using the Hammer - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (state.can_lift_heavy_rocks(player) and state.has('Hammer', player)) or (state.has('Magic Mirror', player) and state.can_reach('Light World', 'Region', player) and (state.has('Moon Pearl', player) or state.has('Hammer', player)))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (can_lift_heavy_rocks(state, player) and state.has('Hammer', player)) or (state.has('Magic Mirror', player) and state.can_reach('Light World', 'Region', player) and (state.has('Moon Pearl', player) or state.has('Hammer', player)))) elif bombshop_entrance.name == 'Bumper Cave (Bottom)': # This is mostly the same as NDW but the Mirror path requires being able to lift a rock. - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (state.can_lift_heavy_rocks(player) and state.has('Hammer', player)) or (state.has('Magic Mirror', player) and state.can_lift_rocks(player) and state.can_reach('Light World', 'Region', player))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or (can_lift_heavy_rocks(state, player) and state.has('Hammer', player)) or (state.has('Magic Mirror', player) and can_lift_rocks(state, player) and state.can_reach('Light World', 'Region', player))) elif bombshop_entrance.name == 'Old Man Cave (West)': # The three paths back are Mirror and DW walk, Mirror and Flute, or LW walk and then Mirror. - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Magic Mirror', player) and ((state.can_lift_heavy_rocks(player) and state.has('Hammer', player)) or (state.can_lift_rocks(player) and state.has('Moon Pearl', player)) or state.has('Activated Flute', player))) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Magic Mirror', player) and ((can_lift_heavy_rocks(state, player) and state.has('Hammer', player)) or (can_lift_rocks(state, player) and state.has('Moon Pearl', player)) or state.has('Activated Flute', player))) elif bombshop_entrance.name == 'Dark World Potion Shop': # You either need to Flute to 5 or cross the rock/hammer choice pass to the south. - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or state.has('Hammer', player) or state.can_lift_rocks(player)) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Activated Flute', player) or state.has('Hammer', player) or can_lift_rocks(state, player)) elif bombshop_entrance.name == 'Kings Grave': # Either lift the rock and walk to the castle to Mirror or Mirror immediately and Flute. - add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.has('Activated Flute', player) or state.can_lift_heavy_rocks(player)) and state.has('Magic Mirror', player)) + add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: (state.has('Activated Flute', player) or can_lift_heavy_rocks(state, player)) and state.has('Magic Mirror', player)) elif bombshop_entrance.name == 'Waterfall of Wishing': # You absolutely must be able to swim to return it from here. add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.has('Flippers', player) and state.has('Moon Pearl', player) and state.has('Magic Mirror', player)) @@ -1421,7 +1428,7 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool): if region.name == 'Swamp Palace (Entrance)': # Need to 0hp revive - not in logic return lambda state: state.has('Moon Pearl', player) if region.name == 'Tower of Hera (Bottom)': # Need to hit the crystal switch - return lambda state: state.has('Magic Mirror', player) and state.has_sword(player) or state.has('Moon Pearl', player) + return lambda state: state.has('Magic Mirror', player) and has_sword(state, player) or state.has('Moon Pearl', player) if region.name in OverworldGlitchRules.get_invalid_bunny_revival_dungeons(): return lambda state: state.has('Magic Mirror', player) or state.has('Moon Pearl', player) if region.type == LTTPRegionType.Dungeon: @@ -1459,7 +1466,7 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool): # For glitch rulesets, establish superbunny and revival rules. if world.logic[player] in ['minorglitches', 'owglitches', 'hybridglitches', 'nologic'] and entrance.name not in OverworldGlitchRules.get_invalid_bunny_revival_dungeons(): if region.name in OverworldGlitchRules.get_sword_required_superbunny_mirror_regions(): - possible_options.append(lambda state: path_to_access_rule(new_path, entrance) and state.has('Magic Mirror', player) and state.has_sword(player)) + possible_options.append(lambda state: path_to_access_rule(new_path, entrance) and state.has('Magic Mirror', player) and has_sword(state, player)) elif (region.name in OverworldGlitchRules.get_boots_required_superbunny_mirror_regions() or location is not None and location.name in OverworldGlitchRules.get_boots_required_superbunny_mirror_locations()): possible_options.append(lambda state: path_to_access_rule(new_path, entrance) and state.has('Magic Mirror', player) and state.has('Pegasus Boots', player)) @@ -1507,4 +1514,4 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool): continue if location.name in bunny_accessible_locations: continue - add_rule(location, get_rule_to_add(entrance.connected_region, location)) \ No newline at end of file + add_rule(location, get_rule_to_add(entrance.connected_region, location)) diff --git a/worlds/alttp/StateHelpers.py b/worlds/alttp/StateHelpers.py new file mode 100644 index 0000000000..33cea8fbfb --- /dev/null +++ b/worlds/alttp/StateHelpers.py @@ -0,0 +1,137 @@ +from .SubClasses import LTTPRegion +from BaseClasses import CollectionState + +def is_not_bunny(state: CollectionState, region: LTTPRegion, player: int) -> bool: + if state.has('Moon Pearl', player): + return True + + return region.is_light_world if state.multiworld.mode[player] != 'inverted' else region.is_dark_world + +def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bool: + return is_not_bunny(state, region, player) and state.has('Pegasus Boots', player) + +def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool: + return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for + shop in state.multiworld.shops) + +def can_buy(state: CollectionState, item: str, player: int) -> bool: + return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for + shop in state.multiworld.shops) + +def can_shoot_arrows(state: CollectionState, player: int) -> bool: + if state.multiworld.retro_bow[player]: + return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_buy(state, 'Single Arrow', player) + return state.has('Bow', player) or state.has('Silver Bow', player) + +def has_triforce_pieces(state: CollectionState, player: int) -> bool: + count = state.multiworld.treasure_hunt_count[player] + return state.item_count('Triforce Piece', player) + state.item_count('Power Star', player) >= count + +def has_crystals(state: CollectionState, count: int, player: int) -> bool: + found = state.count_group("Crystals", player) + return found >= count + +def can_lift_rocks(state: CollectionState, player: int): + return state.has('Power Glove', player) or state.has('Titans Mitts', player) + +def can_lift_heavy_rocks(state: CollectionState, player: int) -> bool: + return state.has('Titans Mitts', player) + +def bottle_count(state: CollectionState, player: int) -> int: + return min(state.multiworld.difficulty_requirements[player].progressive_bottle_limit, + state.count_group("Bottles", player)) + +def has_hearts(state: CollectionState, player: int, count: int) -> int: + # Warning: This only considers items that are marked as advancement items + return heart_count(state, player) >= count + +def heart_count(state: CollectionState, player: int) -> int: + # Warning: This only considers items that are marked as advancement items + diff = state.multiworld.difficulty_requirements[player] + return min(state.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ + + state.item_count('Sanctuary Heart Container', player) \ + + min(state.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ + + 3 # starting hearts + +def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16, + fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has. + basemagic = 8 + if state.has('Magic Upgrade (1/4)', player): + basemagic = 32 + elif state.has('Magic Upgrade (1/2)', player): + basemagic = 16 + if can_buy_unlimited(state, 'Green Potion', player) or can_buy_unlimited(state, 'Blue Potion', player): + if state.multiworld.item_functionality[player] == 'hard' and not fullrefill: + basemagic = basemagic + int(basemagic * 0.5 * bottle_count(state, player)) + elif state.multiworld.item_functionality[player] == 'expert' and not fullrefill: + basemagic = basemagic + int(basemagic * 0.25 * bottle_count(state, player)) + else: + basemagic = basemagic + basemagic * bottle_count(state, player) + return basemagic >= smallmagic + +def can_kill_most_things(state: CollectionState, player: int, enemies: int = 5) -> bool: + return (has_melee_weapon(state, player) + or state.has('Cane of Somaria', player) + or (state.has('Cane of Byrna', player) and (enemies < 6 or can_extend_magic(state, player))) + or can_shoot_arrows(state, player) + or state.has('Fire Rod', player) + or (state.has('Bombs (10)', player) and enemies < 6)) + +def can_get_good_bee(state: CollectionState, player: int) -> bool: + cave = state.multiworld.get_region('Good Bee Cave', player) + return ( + state.has_group("Bottles", player) and + state.has('Bug Catching Net', player) and + (state.has('Pegasus Boots', player) or (has_sword(state, player) and state.has('Quake', player))) and + cave.can_reach(state) and + is_not_bunny(state, cave, player) + ) + +def can_retrieve_tablet(state: CollectionState, player: int) -> bool: + return state.has('Book of Mudora', player) and (has_beam_sword(state, player) or + (state.multiworld.swordless[player] and + state.has("Hammer", player))) + +def has_sword(state: CollectionState, player: int) -> bool: + return state.has('Fighter Sword', player) \ + or state.has('Master Sword', player) \ + or state.has('Tempered Sword', player) \ + or state.has('Golden Sword', player) + +def has_beam_sword(state: CollectionState, player: int) -> bool: + return state.has('Master Sword', player) or state.has('Tempered Sword', player) or state.has('Golden Sword', + player) + +def has_melee_weapon(state: CollectionState, player: int) -> bool: + return has_sword(state, player) or state.has('Hammer', player) + +def has_fire_source(state: CollectionState, player: int) -> bool: + return state.has('Fire Rod', player) or state.has('Lamp', player) + +def can_melt_things(state: CollectionState, player: int) -> bool: + return state.has('Fire Rod', player) or \ + (state.has('Bombos', player) and + (state.multiworld.swordless[player] or + has_sword(state, player))) + +def has_misery_mire_medallion(state: CollectionState, player: int) -> bool: + return state.has(state.multiworld.required_medallions[player][0], player) + +def has_turtle_rock_medallion(state: CollectionState, player: int) -> bool: + return state.has(state.multiworld.required_medallions[player][1], player) + +def can_boots_clip_lw(state: CollectionState, player: int) -> bool: + if state.multiworld.mode[player] == 'inverted': + return state.has('Pegasus Boots', player) and state.has('Moon Pearl', player) + return state.has('Pegasus Boots', player) + +def can_boots_clip_dw(state: CollectionState, player: int) -> bool: + if state.multiworld.mode[player] != 'inverted': + return state.has('Pegasus Boots', player) and state.has('Moon Pearl', player) + return state.has('Pegasus Boots', player) + +def can_get_glitched_speed_dw(state: CollectionState, player: int) -> bool: + rules = [state.has('Pegasus Boots', player), any([state.has('Hookshot', player), has_sword(state, player)])] + if state.multiworld.mode[player] != 'inverted': + rules.append(state.has('Moon Pearl', player)) + return all(rules) \ No newline at end of file diff --git a/worlds/alttp/SubClasses.py b/worlds/alttp/SubClasses.py index 50f3ca47d9..5fc2aa0ba3 100644 --- a/worlds/alttp/SubClasses.py +++ b/worlds/alttp/SubClasses.py @@ -4,7 +4,6 @@ from enum import IntEnum from BaseClasses import Location, Item, ItemClassification, Region, MultiWorld - class ALttPLocation(Location): game: str = "A Link to the Past" crystal: bool @@ -81,6 +80,12 @@ class LTTPRegionType(IntEnum): class LTTPRegion(Region): type: LTTPRegionType + # will be set after making connections. + is_light_world: bool = False + is_dark_world: bool = False + + shop: Optional = None + def __init__(self, name: str, type_: LTTPRegionType, hint: str, player: int, multiworld: MultiWorld): super().__init__(name, player, multiworld, hint) self.type = type_ diff --git a/worlds/alttp/UnderworldGlitchRules.py b/worlds/alttp/UnderworldGlitchRules.py index f7e7736702..f3d78e365c 100644 --- a/worlds/alttp/UnderworldGlitchRules.py +++ b/worlds/alttp/UnderworldGlitchRules.py @@ -1,6 +1,8 @@ from BaseClasses import Entrance +from .SubClasses import LTTPRegion from worlds.generic.Rules import set_rule, add_rule +from .StateHelpers import can_bomb_clip, has_sword, has_beam_sword, has_fire_source, can_melt_things, has_misery_mire_medallion # We actually need the logic to properly "mark" these regions as Light or Dark world. # Therefore we need to make these connections during the normal link_entrances stage, rather than during set_rules. @@ -46,9 +48,9 @@ def dungeon_reentry_rules(world, player, clip: Entrance, dungeon_region: str, du if dungeon_entrance.name == 'Skull Woods Final Section': set_rule(clip, lambda state: False) # entrance doesn't exist until you fire rod it from the other side elif dungeon_entrance.name == 'Misery Mire': - add_rule(clip, lambda state: state.has_sword(player) and state.has_misery_mire_medallion(player)) # open the dungeon + add_rule(clip, lambda state: has_sword(state, player) and has_misery_mire_medallion(state, player)) # open the dungeon elif dungeon_entrance.name == 'Agahnims Tower': - add_rule(clip, lambda state: state.has('Cape', player) or state.has_beam_sword(player) or state.has('Beat Agahnim 1', player)) # kill/bypass barrier + add_rule(clip, lambda state: state.has('Cape', player) or has_beam_sword(state, player) or state.has('Beat Agahnim 1', player)) # kill/bypass barrier # Then we set a restriction on exiting the dungeon, so you can't leave unless you got in normally. add_rule(world.get_entrance(dungeon_exit, player), lambda state: dungeon_entrance.can_reach(state)) elif not fix_fake_worlds: # full, dungeonsfull; fixed dungeon exits, but no fake worlds fix @@ -66,21 +68,21 @@ def underworld_glitches_rules(world, player): # Ice Palace Entrance Clip # This is the easiest one since it's a simple internal clip. Just need to also add melting to freezor chest since it's otherwise assumed. - add_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: state.can_bomb_clip(world.get_region('Ice Palace (Entrance)', player), player), combine='or') - add_rule(world.get_location('Ice Palace - Freezor Chest', player), lambda state: state.can_melt_things(player)) + add_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: can_bomb_clip(state, world.get_region('Ice Palace (Entrance)', player), player), combine='or') + add_rule(world.get_location('Ice Palace - Freezor Chest', player), lambda state: can_melt_things(state, player)) # Kiki Skip kikiskip = world.get_entrance('Kiki Skip', player) - set_rule(kikiskip, lambda state: state.can_bomb_clip(kikiskip.parent_region, player)) + set_rule(kikiskip, lambda state: can_bomb_clip(state, kikiskip.parent_region, player)) dungeon_reentry_rules(world, player, kikiskip, 'Palace of Darkness (Entrance)', 'Palace of Darkness Exit') # Mire -> Hera -> Swamp # Using mire keys on other dungeon doors mire = world.get_region('Misery Mire (West)', player) - mire_clip = lambda state: state.can_reach('Misery Mire (West)', 'Region', player) and state.can_bomb_clip(mire, player) and state.has_fire_source(player) - hera_clip = lambda state: state.can_reach('Tower of Hera (Top)', 'Region', player) and state.can_bomb_clip(world.get_region('Tower of Hera (Top)', player), player) + mire_clip = lambda state: state.can_reach('Misery Mire (West)', 'Region', player) and can_bomb_clip(state, mire, player) and has_fire_source(state, player) + hera_clip = lambda state: state.can_reach('Tower of Hera (Top)', 'Region', player) and can_bomb_clip(state, world.get_region('Tower of Hera (Top)', player), player) add_rule(world.get_entrance('Tower of Hera Big Key Door', player), lambda state: mire_clip(state) and state.has('Big Key (Misery Mire)', player), combine='or') add_rule(world.get_entrance('Swamp Palace Small Key Door', player), lambda state: mire_clip(state), combine='or') add_rule(world.get_entrance('Swamp Palace (Center)', player), lambda state: mire_clip(state) or hera_clip(state), combine='or') diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index d925048433..8ca82d43d5 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -3,6 +3,7 @@ import os import random import threading import typing +from collections import OrderedDict import Utils from BaseClasses import Item, CollectionState, Tutorial, MultiWorld @@ -19,9 +20,10 @@ from .Client import ALTTPSNIClient from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \ get_hash_string, get_base_rom_path, LttPDeltaPatch from .Rules import set_rules -from .Shops import create_shops, ShopSlotFill -from .SubClasses import ALttPItem +from .Shops import create_shops, Shop, ShopSlotFill, ShopType, price_rate_display, price_type_display_name +from .SubClasses import ALttPItem, LTTPRegionType from worlds.AutoWorld import World, WebWorld, LogicMixin +from .StateHelpers import can_buy_unlimited lttp_logger = logging.getLogger("A Link to the Past") @@ -115,6 +117,75 @@ class ALTTPWorld(World): option_definitions = alttp_options topology_present = True item_name_groups = item_name_groups + location_name_groups = { + "Blind's Hideout": {"Blind's Hideout - Top", "Blind's Hideout - Left", "Blind's Hideout - Right", + "Blind's Hideout - Far Left", "Blind's Hideout - Far Right"}, + "Kakariko Well": {"Kakariko Well - Top", "Kakariko Well - Left", "Kakariko Well - Middle", + "Kakariko Well - Right", "Kakariko Well - Bottom"}, + "Mini Moldorm Cave": {"Mini Moldorm Cave - Far Left", "Mini Moldorm Cave - Left", "Mini Moldorm Cave - Right", + "Mini Moldorm Cave - Far Right", "Mini Moldorm Cave - Generous Guy"}, + "Paradox Cave": {"Paradox Cave Lower - Far Left", "Paradox Cave Lower - Left", "Paradox Cave Lower - Right", + "Paradox Cave Lower - Far Right", "Paradox Cave Lower - Middle", "Paradox Cave Upper - Left", + "Paradox Cave Upper - Right"}, + "Hype Cave": {"Hype Cave - Top", "Hype Cave - Middle Right", "Hype Cave - Middle Left", + "Hype Cave - Bottom", "Hype Cave - Generous Guy"}, + "Hookshot Cave": {"Hookshot Cave - Top Right", "Hookshot Cave - Top Left", "Hookshot Cave - Bottom Right", + "Hookshot Cave - Bottom Left"}, + "Hyrule Castle": {"Hyrule Castle - Boomerang Chest", "Hyrule Castle - Map Chest", + "Hyrule Castle - Zelda's Chest", "Sewers - Dark Cross", "Sewers - Secret Room - Left", + "Sewers - Secret Room - Middle", "Sewers - Secret Room - Right"}, + "Eastern Palace": {"Eastern Palace - Compass Chest", "Eastern Palace - Big Chest", + "Eastern Palace - Cannonball Chest", "Eastern Palace - Big Key Chest", + "Eastern Palace - Map Chest", "Eastern Palace - Boss"}, + "Desert Palace": {"Desert Palace - Big Chest", "Desert Palace - Torch", "Desert Palace - Map Chest", + "Desert Palace - Compass Chest", "Desert Palace Big Key Chest", "Desert Palace - Boss"}, + "Tower of Hera": {"Tower of Hera - Basement Cage", "Tower of Hera - Map Chest", "Tower of Hera - Big Key Chest", + "Tower of Hera - Compass Chest", "Tower of Hera - Big Chest", "Tower of Hera - Boss"}, + "Palace of Darkness": {"Palace of Darkness - Shooter Room", "Palace of Darkness - The Arena - Bridge", + "Palace of Darkness - Stalfos Basement", "Palace of Darkness - Big Key Chest", + "Palace of Darkness - The Arena - Ledge", "Palace of Darkness - Map Chest", + "Palace of Darkness - Compass Chest", "Palace of Darkness - Dark Basement - Left", + "Palace of Darkness - Dark Basement - Right", "Palace of Darkness - Dark Maze - Top", + "Palace of Darkness - Dark Maze - Bottom", "Palace of Darkness - Big Chest", + "Palace of Darkness - Harmless Hellway", "Palace of Darkness - Boss"}, + "Swamp Palace": {"Swamp Palace - Entrance", "Swamp Palace - Swamp Palace - Map Chest", + "Swamp Palace - Big Chest", "Swamp Palace - Compass Chest", "Swamp Palace - Big Key Chest", + "Swamp Palace - West Chest", "Swamp Palace - Flooded Room - Left", + "Swamp Palace - Flooded Room - Right", "Swamp Palace - Waterfall Room", "Swamp Palace - Boss"}, + "Thieves' Town": {"Thieves' Town - Big Key Chest", "Thieves' Town - Map Chest", "Thieves' Town - Compass Chest", + "Thieves' Town - Ambush Chest", "Thieves' Town - Attic", "Thieves' Town - Big Chest", + "Thieves' Town - Blind's Cell", "Thieves' Town - Boss"}, + "Skull Woods": {"Skull Woods - Map Chest", "Skull Woods - Pinball Room", "Skull Woods - Compass Chest", + "Skull Woods - Pot Prison", "Skull Woods - Big Chest", "Skull Woods - Big Key Chest", + "Skull Woods - Bridge Room", "Skull Woods - Boss"}, + "Ice Palace": {"Ice Palace - Compass Chest", "Ice Palace - Freezor Chest", "Ice Palace - Big Chest", + "Ice Palace - Freezor Chest", "Ice Palace - Big Chest", "Ice Palace - Iced T Room", + "Ice Palace - Spike Room", "Ice Palace - Big Key Chest", "Ice Palace - Map Chest", + "Ice Palace - Boss"}, + "Misery Mire": {"Misery Mire - Big Chest", "Misery Mire - Map Chest", "Misery Mire - Main Lobby", + "Misery Mire - Bridge Chest", "Misery Mire - Spike Chest", "Misery Mire - Compass Chest", + "Misery Mire - Big Key Chest", "Misery Mire - Boss"}, + "Turtle Rock": {"Turtle Rock - Compass Chest", "Turtle Rock - Roller Room - Left", + "Turtle Rock - Roller Room - Right", "Turtle Room - Chain Chomps", "Turtle Rock - Big Key Chest", + "Turtle Rock - Big Chest", "Turtle Rock - Crystaroller Room", + "Turtle Rock - Eye Bridge - Bottom Left", "Turtle Rock - Eye Bridge - Bottom Right", + "Turtle Rock - Eye Bridge - Top Left", "Turtle Rock - Eye Bridge - Top Right", "Turtle Rock - Boss"}, + "Ganons Tower": {"Ganons Tower - Bob's Torch", "Ganon's Tower - Hope Room - Left", + "Ganons Tower - Hope Room - Right", "Ganons Tower - Tile Room", + "Ganons Tower - Compass Room - Top Left", "Ganons Tower - Compass Room - Top Right", + "Ganons Tower - Compass Room - Bottom Left", "Ganons Tower - Compass Room - Bottom Left", + "Ganons Tower - DMs Room - Top Left", "Ganons Tower - DMs Room - Top Right", + "Ganons Tower - DMs Room - Bottom Left", "Ganons Tower - DMs Room - Bottom Right", + "Ganons Tower - Map Chest", "Ganons Tower - Firesnake Room", + "Ganons Tower - Randomizer Room - Top Left", "Ganons Tower - Randomizer Room - Top Right", + "Ganons Tower - Randomizer Room - Bottom Left", "Ganons Tower - Randomizer Room - Bottom Right", + "Ganons Tower - Bob's Chest", "Ganons Tower - Big Chest", "Ganons Tower - Big Key Room - Left", + "Ganons Tower - Big Key Room - Right", "Ganons Tower - Big Key Chest", + "Ganons Tower - Mini Helmasaur Room - Left", "Ganons Tower - Mini Helmasaur Room - Right", + "Ganons Tower - Pre-Moldorm Room", "Ganons Tower - Validation Chest"}, + "Ganons Tower Climb": {"Ganons Tower - Mini Helmasaur Room - Left", "Ganons Tower - Mini Helmasaur Room - Right", + "Ganons Tower - Pre-Moldorm Room", "Ganons Tower - Validation Chest"}, + } hint_blacklist = {"Triforce"} item_name_to_id = {name: data.item_code for name, data in item_table.items() if type(data.item_code) == int} @@ -520,6 +591,122 @@ class ALTTPWorld(World): else: logging.warning(f"Could not trash fill Ganon's Tower for player {player}.") + def write_spoiler_header(self, spoiler_handle: typing.TextIO) -> None: + def bool_to_text(variable: typing.Union[bool, str]) -> str: + if type(variable) == str: + return variable + return "Yes" if variable else "No" + + spoiler_handle.write('Logic: %s\n' % self.multiworld.logic[self.player]) + spoiler_handle.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[self.player]) + spoiler_handle.write('Mode: %s\n' % self.multiworld.mode[self.player]) + spoiler_handle.write('Goal: %s\n' % self.multiworld.goal[self.player]) + if "triforce" in self.multiworld.goal[self.player]: # triforce hunt + spoiler_handle.write("Pieces available for Triforce: %s\n" % + self.multiworld.triforce_pieces_available[self.player]) + spoiler_handle.write("Pieces required for Triforce: %s\n" % + self.multiworld.triforce_pieces_required[self.player]) + spoiler_handle.write('Difficulty: %s\n' % self.multiworld.difficulty[self.player]) + spoiler_handle.write('Item Functionality: %s\n' % self.multiworld.item_functionality[self.player]) + spoiler_handle.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[self.player]) + if self.multiworld.shuffle[self.player] != "vanilla": + spoiler_handle.write('Entrance Shuffle Seed %s\n' % self.er_seed) + spoiler_handle.write('Shop inventory shuffle: %s\n' % + bool_to_text("i" in self.multiworld.shop_shuffle[self.player])) + spoiler_handle.write('Shop price shuffle: %s\n' % + bool_to_text("p" in self.multiworld.shop_shuffle[self.player])) + spoiler_handle.write('Shop upgrade shuffle: %s\n' % + bool_to_text("u" in self.multiworld.shop_shuffle[self.player])) + spoiler_handle.write('New Shop inventory: %s\n' % + bool_to_text("g" in self.multiworld.shop_shuffle[self.player] or + "f" in self.multiworld.shop_shuffle[self.player])) + spoiler_handle.write('Custom Potion Shop: %s\n' % + bool_to_text("w" in self.multiworld.shop_shuffle[self.player])) + spoiler_handle.write('Enemy health: %s\n' % self.multiworld.enemy_health[self.player]) + spoiler_handle.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[self.player]) + spoiler_handle.write('Prize shuffle %s\n' % self.multiworld.shuffle_prizes[self.player]) + + def write_spoiler(self, spoiler_handle: typing.TextIO) -> None: + spoiler_handle.write("\n\nMedallions:\n") + spoiler_handle.write(f"\nMisery Mire ({self.multiworld.get_player_name(self.player)}):" + f" {self.multiworld.required_medallions[self.player][0]}") + spoiler_handle.write( + f"\nTurtle Rock ({self.multiworld.get_player_name(self.player)}):" + f" {self.multiworld.required_medallions[self.player][1]}") + + if self.multiworld.boss_shuffle[self.player] != "none": + def create_boss_map() -> typing.Dict: + boss_map = { + "Eastern Palace": self.multiworld.get_dungeon("Eastern Palace", self.player).boss.name, + "Desert Palace": self.multiworld.get_dungeon("Desert Palace", self.player).boss.name, + "Tower Of Hera": self.multiworld.get_dungeon("Tower of Hera", self.player).boss.name, + "Hyrule Castle": "Agahnim", + "Palace Of Darkness": self.multiworld.get_dungeon("Palace of Darkness", + self.player).boss.name, + "Swamp Palace": self.multiworld.get_dungeon("Swamp Palace", self.player).boss.name, + "Skull Woods": self.multiworld.get_dungeon("Skull Woods", self.player).boss.name, + "Thieves Town": self.multiworld.get_dungeon("Thieves Town", self.player).boss.name, + "Ice Palace": self.multiworld.get_dungeon("Ice Palace", self.player).boss.name, + "Misery Mire": self.multiworld.get_dungeon("Misery Mire", self.player).boss.name, + "Turtle Rock": self.multiworld.get_dungeon("Turtle Rock", self.player).boss.name, + "Ganons Tower": "Agahnim 2", + "Ganon": "Ganon" + } + if self.multiworld.mode[self.player] != 'inverted': + boss_map.update({ + "Ganons Tower Basement": + self.multiworld.get_dungeon("Ganons Tower", self.player).bosses["bottom"].name, + "Ganons Tower Middle": self.multiworld.get_dungeon("Ganons Tower", self.player).bosses[ + "middle"].name, + "Ganons Tower Top": self.multiworld.get_dungeon("Ganons Tower", self.player).bosses[ + "top"].name + }) + else: + boss_map.update({ + "Ganons Tower Basement": self.multiworld.get_dungeon("Inverted Ganons Tower", self.player).bosses["bottom"].name, + "Ganons Tower Middle": self.multiworld.get_dungeon("Inverted Ganons Tower", self.player).bosses["middle"].name, + "Ganons Tower Top": self.multiworld.get_dungeon("Inverted Ganons Tower", self.player).bosses["top"].name + }) + return boss_map + + bossmap = create_boss_map() + spoiler_handle.write( + f'\n\nBosses{(f" ({self.multiworld.get_player_name(self.player)})" if self.multiworld.players > 1 else "")}:\n') + spoiler_handle.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()])) + + def build_shop_info(shop: Shop) -> typing.Dict[str, str]: + shop_data = { + "location": str(shop.region), + "type": "Take Any" if shop.type == ShopType.TakeAny else "Shop" + } + + for index, item in enumerate(shop.inventory): + if item is None: + continue + price = item["price"] // price_rate_display.get(item["price_type"], 1) + shop_data["item_{}".format(index)] = f"{item['item']} - {price} {price_type_display_name[item['price_type']]}" + if item["player"]: + shop_data["item_{}".format(index)] =\ + shop_data["item_{}".format(index)].replace("—", "(Player {}) — ".format(item["player"])) + + if item["max"] == 0: + continue + shop_data["item_{}".format(index)] += " x {}".format(item["max"]) + if item["replacement"] is None: + continue + shop_data["item_{}".format(index)] +=\ + f", {item['replacement']} - {item['replacement_price']}" \ + f" {price_type_display_name[item['replacement_price_type']]}" + + return shop_data + + if shop_info := [build_shop_info(shop) for shop in self.multiworld.shops if shop.custom]: + spoiler_handle.write('\n\nShops:\n\n') + for shop_data in shop_info: + spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join( + item for item in [shop_data.get('item_0', None), shop_data.get('item_1', None), shop_data.get('item_2', None)] if + item))) + def get_filler_item_name(self) -> str: if self.multiworld.goal[self.player] == "icerodhunt": item = "Nothing" @@ -552,5 +739,5 @@ class ALttPLogic(LogicMixin): if self.multiworld.logic[player] == 'nologic': return True if self.multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal: - return self.can_buy_unlimited('Small Key (Universal)', player) + return can_buy_unlimited(self, 'Small Key (Universal)', player) return self.prog_items[item, player] >= count diff --git a/worlds/alttp/test/options/TestPlandoBosses.py b/worlds/alttp/test/options/TestPlandoBosses.py index a6c3485f60..83c1510a3e 100644 --- a/worlds/alttp/test/options/TestPlandoBosses.py +++ b/worlds/alttp/test/options/TestPlandoBosses.py @@ -1,5 +1,5 @@ import unittest -import Generate +from BaseClasses import PlandoOptions from Options import PlandoBosses @@ -123,14 +123,14 @@ class TestPlandoBosses(unittest.TestCase): regular = MultiBosses.from_any(regular_string) # plando should work with boss plando - plandoed.verify(None, "Player", Generate.PlandoOptions.bosses) + plandoed.verify(None, "Player", PlandoOptions.bosses) self.assertTrue(plandoed.value.startswith(plandoed_string)) # plando should fall back to default without boss plando - plandoed.verify(None, "Player", Generate.PlandoOptions.items) + plandoed.verify(None, "Player", PlandoOptions.items) self.assertEqual(plandoed, MultiBosses.option_vanilla) # mixed should fall back to mode - mixed.verify(None, "Player", Generate.PlandoOptions.items) # should produce a warning and still work + mixed.verify(None, "Player", PlandoOptions.items) # should produce a warning and still work self.assertEqual(mixed, MultiBosses.option_shuffle) # mode stuff should just work - regular.verify(None, "Player", Generate.PlandoOptions.items) + regular.verify(None, "Player", PlandoOptions.items) self.assertEqual(regular, MultiBosses.option_shuffle) diff --git a/worlds/archipidle/Rules.py b/worlds/archipidle/Rules.py index 94c6e099cc..ddf906c21a 100644 --- a/worlds/archipidle/Rules.py +++ b/worlds/archipidle/Rules.py @@ -31,3 +31,7 @@ def set_rules(world: MultiWorld, player: int): world.get_location(f"IDLE for at least {int(i / 2)} minutes {30 if (i % 2) else 0} seconds", player), lambda state: state._archipidle_location_is_accessible(player, 20) ) + + world.completion_condition[player] =\ + lambda state:\ + state.can_reach(world.get_location("IDLE for at least 50 minutes 0 seconds", player), "Location", player) diff --git a/worlds/blasphemous/Exits.py b/worlds/blasphemous/Exits.py new file mode 100644 index 0000000000..a4460e2aed --- /dev/null +++ b/worlds/blasphemous/Exits.py @@ -0,0 +1,135 @@ +from typing import List, Dict + + +region_exit_table: Dict[str, List[str]] = { + "menu" : ["New Game"], + + "albero" : ["To The Holy Line", + "To Desecrated Cistern", + "To Wasteland of the Buried Churches", + "To Dungeons"], + + "attots" : ["To Mother of Mothers"], + + "ar" : ["To Mother of Mothers", + "To Wall of the Holy Prohibitions", + "To Deambulatory of His Holiness"], + + "bottc" : ["To Wasteland of the Buried Churches", + "To Ferrous Tree"], + + "botss" : ["To The Holy Line", + "To Mountains of the Endless Dusk"], + + "coolotcv" : ["To Graveyard of the Peaks", + "To Wall of the Holy Prohibitions"], + + "dohh" : ["To Archcathedral Rooftops"], + + "dc" : ["To Albero", + "To Mercy Dreams", + "To Mountains of the Endless Dusk", + "To Echoes of Salt", + "To Grievance Ascends"], + + "eos" : ["To Jondo", + "To Mountains of the Endless Dusk", + "To Desecrated Cistern", + "To The Resting Place of the Sister", + "To Mourning and Havoc"], + + "ft" : ["To Bridge of the Three Cavalries", + "To Hall of the Dawning", + "To Patio of the Silent Steps"], + + "gotp" : ["To Where Olive Trees Wither", + "To Convent of Our Lady of the Charred Visage"], + + "ga" : ["To Jondo", + "To Desecrated Cistern"], + + "hotd" : ["To Ferrous Tree"], + + "jondo" : ["To Mountains of the Endless Dusk", + "To Grievance Ascends"], + + "kottw" : ["To Mother of Mothers"], + + "lotnw" : ["To Mother of Mothers", + "To The Sleeping Canvases"], + + "md" : ["To Wasteland of the Buried Churches", + "To Desecrated Cistern", + "To The Sleeping Canvases"], + + "mom" : ["To Patio of the Silent Steps", + "To Archcathedral Rooftops", + "To Knot of the Three Words", + "To Library of the Negated Words", + "To All the Tears of the Sea"], + + "moted" : ["To Brotherhood of the Silent Sorrow", + "To Jondo", + "To Desecrated Cistern"], + + "mah" : ["To Echoes of Salt", + "To Mother of Mothers"], + + "potss" : ["To Ferrous Tree", + "To Mother of Mothers", + "To Wall of the Holy Prohibitions"], + + "petrous" : ["To The Holy Line"], + + "thl" : ["To Brotherhood of the Silent Sorrow", + "To Petrous", + "To Albero"], + + "trpots" : ["To Echoes of Salt"], + + "tsc" : ["To Library of the Negated Words", + "To Mercy Dreams"], + + "wothp" : ["To Archcathedral Rooftops", + "To Convent of Our Lady of the Charred Visage"], + + "wotbc" : ["To Albero", + "To Where Olive Trees Wither", + "To Mercy Dreams"], + + "wotw" : ["To Wasteland of the Buried Churches", + "To Graveyard of the Peaks"] +} + +exit_lookup_table: Dict[str, str] = { + "New Game": "botss", + "To Albero": "albero", + "To All the Tears of the Sea": "attots", + "To Archcathedral Rooftops": "ar", + "To Bridge of the Three Cavalries": "bottc", + "To Brotherhood of the Silent Sorrow": "botss", + "To Convent of Our Lady of the Charred Visage": "coolotcv", + "To Deambulatory of His Holiness": "dohh", + "To Desecrated Cistern": "dc", + "To Echoes of Salt": "eos", + "To Ferrous Tree": "ft", + "To Graveyard of the Peaks": "gotp", + "To Grievance Ascends": "ga", + "To Hall of the Dawning": "hotd", + "To Jondo": "jondo", + "To Knot of the Three Words": "kottw", + "To Library of the Negated Words": "lotnw", + "To Mercy Dreams": "md", + "To Mother of Mothers": "mom", + "To Mountains of the Endless Dusk": "moted", + "To Mourning and Havoc": "mah", + "To Patio of the Silent Steps": "potss", + "To Petrous": "petrous", + "To The Holy Line": "thl", + "To The Resting Place of the Sister": "trpots", + "To The Sleeping Canvases": "tsc", + "To Wall of the Holy Prohibitions": "wothp", + "To Wasteland of the Buried Churches": "wotbc", + "To Where Olive Trees Wither": "wotw", + "To Dungeons": "dungeon" +} \ No newline at end of file diff --git a/worlds/blasphemous/Items.py b/worlds/blasphemous/Items.py new file mode 100644 index 0000000000..97dcefde7e --- /dev/null +++ b/worlds/blasphemous/Items.py @@ -0,0 +1,754 @@ +from BaseClasses import ItemClassification +from typing import TypedDict, Dict, List, Set + + +class ItemDict(TypedDict): + name: str + count: int + classification: ItemClassification + +base_id = 1909000 + +item_table: List[ItemDict] = [ + # Rosary Beads + {'name': "Dove Skull", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Ember of the Holy Cremation", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Silver Grape", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Uvula of Proclamation", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Hollow Pearl", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Knot of Hair", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Painted Wood Bead", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Piece of a Golden Mask", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Moss Preserved in Glass", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Frozen Olive", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Quirce's Scorched Bead", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Wicker Knot", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Perpetva's Protection", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Thorned Symbol", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Piece of a Tombstone", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Sphere of the Sacred Smoke", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Bead of Red Wax", + 'count': 3, + 'classification': ItemClassification.progression}, + {'name': "Little Toe made of Limestone", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Big Toe made of Limestone", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Fourth Toe made of Limestone", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Bead of Blue Wax", + 'count': 3, + 'classification': ItemClassification.progression}, + {'name': "Pelican Effigy", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Drop of Coagulated Ink", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Amber Eye", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Muted Bell", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Consecrated Amethyst", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Embers of a Broken Star", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Scaly Coin", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Seashell of the Inverted Spiral", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Calcified Eye of Erudition", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Weight of True Guilt", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Reliquary of the Fervent Heart", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Reliquary of the Suffering Heart", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Reliquary of the Sorrowful Heart", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Token of Appreciation", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Cloistered Ruby", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Bead of Gold Thread", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Cloistered Sapphire", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Fire Enclosed in Enamel", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Light of the Lady of the Lamp", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Scale of Burnished Alabaster", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "The Young Mason's Wheel", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Crown of Gnawed Iron", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Crimson Heart of a Miura", + 'count': 1, + 'classification': ItemClassification.useful}, + + # Prayers + {'name': "Seguiriya to your Eyes like Stars", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Debla of the Lights", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Saeta Dolorosa", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Campanillero to the Sons of the Aurora", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Lorquiana", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Zarabanda of the Safe Haven", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Taranto to my Sister", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Solea of Excommunication", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Tiento to your Thorned Hairs", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Cante Jondo of the Three Sisters", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Verdiales of the Forsaken Hamlet", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Romance to the Crimson Mist", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Zambra to the Resplendent Crown", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Aubade of the Nameless Guardian", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Cantina of the Blue Rose", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Mirabras of the Return to Port", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Tirana of the Celestial Bastion", + 'count': 1, + 'classification': ItemClassification.progression}, + + # Relics + {'name': "Blood Perpetuated in Sand", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Incorrupt Hand of the Fraternal Master", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Nail Uprooted from Dirt", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Shroud of Dreamt Sins", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Linen of Golden Thread", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Silvered Lung of Dolphos", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Three Gnarled Tongues", + 'count': 1, + 'classification': ItemClassification.progression}, + + # Mea Culpa Hearts + {'name': "Smoking Heart of Incense", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of the Virtuous Pain", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of Saltpeter Blood", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of Oils", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of Cerulean Incense", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of the Holy Purge", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Molten Heart of Boiling Blood", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of the Single Tone", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Heart of the Unnamed Minstrel", + 'count': 1, + 'classification': ItemClassification.useful}, + {'name': "Brilliant Heart of Dawn", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Apodictic Heart of Mea Culpa", + 'count': 1, + 'classification': ItemClassification.progression}, + + # Quest Items + {'name': "Cord of the True Burying", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Mark of the First Refuge", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Mark of the Second Refuge", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Mark of the Third Refuge", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Tentudia's Carnal Remains", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Remains of Tentudia's Hair", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Tentudia's Skeletal Remains", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Melted Golden Coins", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Torn Bridal Ribbon", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Black Grieving Veil", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Egg of Deformity", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Hatched Egg of Deformity", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Bouquet of Rosemary", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Incense Garlic", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Thorn Upgrade", + 'count': 8, + 'classification': ItemClassification.progression}, + {'name': "Olive Seeds", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Holy Wound of Attrition", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Holy Wound of Contrition", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Holy Wound of Compunction", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Empty Bile Vessel", + 'count': 8, + 'classification': ItemClassification.progression}, + {'name': "Knot of Rosary Rope", + 'count': 6, + 'classification': ItemClassification.progression}, + {'name': "Golden Thimble Filled with Burning Oil", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Key to the Chamber of the Eldest Brother", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Empty Golden Thimble", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Deformed Mask of Orestes", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Mirrored Mask of Dolphos", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Embossed Mask of Crescente", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Dried Clove", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Sooty Garlic", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Bouquet of Thyme", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Linen Cloth", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Severed Hand", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Dried Flowers bathed in Tears", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Key of the Secular", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Key of the Scribe", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Key of the Inquisitor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Key of the High Peaks", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Chalice of Inverted Verses", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Quicksilver", + 'count': 5, + 'classification': ItemClassification.useful}, + {'name': "Petrified Bell", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Verses Spun from Gold", + 'count': 4, + 'classification': ItemClassification.progression}, + {'name': "Severed Right Eye of the Traitor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Broken Left Eye of the Traitor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Incomplete Scapular", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Key Grown from Twisted Wood", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Holy Wound of Abnegation", + 'count': 1, + 'classification': ItemClassification.progression}, + + # Skills + {'name': "Combo Skill", + 'count': 3, + 'classification': ItemClassification.useful}, + {'name': "Charged Skill", + 'count': 3, + 'classification': ItemClassification.progression}, + {'name': "Ranged Skill", + 'count': 3, + 'classification': ItemClassification.progression}, + {'name': "Dive Skill", + 'count': 3, + 'classification': ItemClassification.progression}, + {'name': "Lunge Skill", + 'count': 3, + 'classification': ItemClassification.useful}, + + # Other + {'name': "Parietal bone of Lasser, the Inquisitor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Jaw of Ashgan, the Inquisitor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Cervical vertebra of Zicher, the Brewmaster", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Clavicle of Dalhuisen, the Schoolchild", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Sternum of Vitas, the Performer", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Ribs of Sabnock, the Guardian", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Vertebra of John, the Gambler", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Scapula of Carlos, the Executioner", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Humerus of McMittens, the Nurse", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Ulna of Koke, the Troubadour", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Radius of Helzer, the Poet", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Frontal of Martinus, the Ropemaker", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Metacarpus of Hodges, the Blacksmith", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Phalanx of Arthur, the Sailor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Phalanx of Miriam, the Counsellor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Phalanx of Brannon, the Gravedigger", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Coxal of June, the Prostitute", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Sacrum of the Dark Warlock", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Coccyx of Daniel, the Possessed", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Femur of Karpow, the Bounty Hunter", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Kneecap of Sebastien, the Puppeteer", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Tibia of Alsahli, the Mystic", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Fibula of Rysp, the Ranger", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Temporal of Joel, the Thief", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Metatarsus of Rikusyo, the Traveller", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Phalanx of Zeth, the Prisoner", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Phalanx of William, the Sceptic", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Phalanx of Aralcarim, the Archivist", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Occipital of Tequila, the Metalsmith", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Maxilla of Tarradax, the Cleric", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Nasal bone of Charles, the Artist", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Hyoid bone of Senex, the Beggar", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Vertebra of Lindquist, the Forger", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Trapezium of Jeremiah, the Hangman", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Trapezoid of Yeager, the Jeweller", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Capitate of Barock, the Herald", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Hamate of Vukelich, the Copyist", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Pisiform of Hernandez, the Explorer", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Triquetral of Luca, the Tailor", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Lunate of Keiya, the Butcher", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Scaphoid of Fierce, the Leper", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Anklebone of Weston, the Pilgrim", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Calcaneum of Persian, the Bandit", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Navicular of Kahnnyhoo, the Murderer", + 'count': 1, + 'classification': ItemClassification.progression}, + {'name': "Child of Moonlight", + 'count': 38, + 'classification': ItemClassification.progression}, + {'name': "Life Upgrade", + 'count': 6, + 'classification': ItemClassification.progression}, + {'name': "Fervour Upgrade", + 'count': 6, + 'classification': ItemClassification.progression}, + {'name': "Mea Culpa Upgrade", + 'count': 7, + 'classification': ItemClassification.progression}, + {'name': "Tears of Atonement (250)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (300)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (500)", + 'count': 3, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (625)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (750)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (1000)", + 'count': 4, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (1250)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (1500)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (1750)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (2000)", + 'count': 2, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (2100)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (2500)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (2600)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (3000)", + 'count': 2, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (4300)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (5000)", + 'count': 4, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (5500)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (9000)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (10000)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (11250)", + 'count': 1, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (18000)", + 'count': 5, + 'classification': ItemClassification.filler}, + {'name': "Tears of Atonement (30000)", + 'count': 1, + 'classification': ItemClassification.filler} +] + +group_table: Dict[str, Set[str]] = { + "wounds" : ["Holy Wound of Attrition", + "Holy Wound of Contrition", + "Holy Wound of Compunction"], + + "masks" : ["Deformed Mask of Orestes", + "Mirrored Mask of Dolphos", + "Embossed Mask of Crescente"], + + "tirso" : ["Bouquet of Rosemary", + "Incense Garlic", + "Olive Seeds", + "Dried Clove", + "Sooty Garlic", + "Bouquet of Thyme"], + + "tentudia": ["Tentudia's Carnal Remains", + "Remains of Tentudia's Hair", + "Tentudia's Skeletal Remains"], + + "egg" : ["Melted Golden Coins", + "Torn Bridal Ribbon", + "Black Grieving Veil"], + + "bones" : ["Parietal bone of Lasser, the Inquisitor", + "Jaw of Ashgan, the Inquisitor", + "Cervical vertebra of Zicher, the Brewmaster", + "Clavicle of Dalhuisen, the Schoolchild", + "Sternum of Vitas, the Performer", + "Ribs of Sabnock, the Guardian", + "Vertebra of John, the Gambler", + "Scapula of Carlos, the Executioner", + "Humerus of McMittens, the Nurse", + "Ulna of Koke, the Troubadour", + "Radius of Helzer, the Poet", + "Frontal of Martinus, the Ropemaker", + "Metacarpus of Hodges, the Blacksmith", + "Phalanx of Arthur, the Sailor", + "Phalanx of Miriam, the Counsellor", + "Phalanx of Brannon, the Gravedigger", + "Coxal of June, the Prostitute", + "Sacrum of the Dark Warlock", + "Coccyx of Daniel, the Possessed", + "Femur of Karpow, the Bounty Hunter", + "Kneecap of Sebastien, the Puppeteer", + "Tibia of Alsahli, the Mystic", + "Fibula of Rysp, the Ranger", + "Temporal of Joel, the Thief", + "Metatarsus of Rikusyo, the Traveller", + "Phalanx of Zeth, the Prisoner", + "Phalanx of William, the Sceptic", + "Phalanx of Aralcarim, the Archivist", + "Occipital of Tequila, the Metalsmith", + "Maxilla of Tarradax, the Cleric", + "Nasal bone of Charles, the Artist", + "Hyoid bone of Senex, the Beggar", + "Vertebra of Lindquist, the Forger", + "Trapezium of Jeremiah, the Hangman", + "Trapezoid of Yeager, the Jeweller", + "Capitate of Barock, the Herald", + "Hamate of Vukelich, the Copyist", + "Pisiform of Hernandez, the Explorer", + "Triquetral of Luca, the Tailor", + "Lunate of Keiya, the Butcher", + "Scaphoid of Fierce, the Leper", + "Anklebone of Weston, the Pilgrim", + "Calcaneum of Persian, the Bandit", + "Navicular of Kahnnyhoo, the Murderer"], + + "power" : ["Life Upgrade", + "Fervour Upgrade", + "Empty Bile Vessel", + "Quicksilver"], + + "prayer" : ["Seguiriya to your Eyes like Stars", + "Debla of the Lights", + "Saeta Dolorosa", + "Campanillero to the Sons of the Aurora", + "Lorquiana", + "Zarabanda of the Safe Haven", + "Taranto to my Sister", + "Solea of Excommunication", + "Tiento to your Thorned Hairs", + "Cante Jondo of the Three Sisters", + "Verdiales of the Forsaken Hamlet", + "Romance to the Crimson Mist", + "Zambra to the Resplendent Crown", + "Cantina of the Blue Rose", + "Mirabras of the Return to Port"] +} + +tears_set: Set[str] = [ + "Tears of Atonement (500)", + "Tears of Atonement (625)", + "Tears of Atonement (750)", + "Tears of Atonement (1000)", + "Tears of Atonement (1250)", + "Tears of Atonement (1500)", + "Tears of Atonement (1750)", + "Tears of Atonement (2000)", + "Tears of Atonement (2100)", + "Tears of Atonement (2500)", + "Tears of Atonement (2600)", + "Tears of Atonement (3000)", + "Tears of Atonement (4300)", + "Tears of Atonement (5000)", + "Tears of Atonement (5500)", + "Tears of Atonement (9000)", + "Tears of Atonement (10000)", + "Tears of Atonement (11250)", + "Tears of Atonement (18000)", + "Tears of Atonement (30000)" +] + +reliquary_set: Set[str] = [ + "Reliquary of the Fervent Heart", + "Reliquary of the Suffering Heart", + "Reliquary of the Sorrowful Heart" +] + +skill_set: Set[str] = [ + "Combo Skill", + "Charged Skill", + "Ranged Skill", + "Dive Skill", + "Lunge Skill" +] \ No newline at end of file diff --git a/worlds/blasphemous/Locations.py b/worlds/blasphemous/Locations.py new file mode 100644 index 0000000000..88065de442 --- /dev/null +++ b/worlds/blasphemous/Locations.py @@ -0,0 +1,1295 @@ +from typing import List, Set, TypedDict + + +class LocationDict(TypedDict): + name: str + region: str + game_id: str + room: str + + +location_table: List[LocationDict] = [ + # Albero (35) + {'name': "Albero: Tirso's house, top floor", + 'region': "albero", + 'game_id': "RB01", + 'room': "D01Z02S02"}, + {'name': "Albero: Outside Ossuary", + 'region': "albero", + 'game_id': "CO43", + 'room': "D01Z02S04"}, + {'name': "Albero: Graveyard", + 'region': "albero", + 'game_id': "CO16", + 'room': "D01Z02S05"}, + {'name': "Albero: Gate of Travel room", + 'region': "albero", + 'game_id': "QI65", + 'room': "D01Z02S07"}, + {'name': "Albero: Child of Moonlight", + 'region': "albero", + 'game_id': "RESCUED_CHERUB_08", + 'room': "D01Z02S03"}, + {'name': "Albero: Bless Linen Cloth", + 'region': "albero", + 'game_id': "RE04", + 'room': "D01Z02S01"}, + {'name': "Albero: Bless Hatched Egg", + 'region': "albero", + 'game_id': "RE10", + 'room': "D01Z02S01"}, + {'name': "Albero: Bless Severed Hand", + 'region': "albero", + 'game_id': "RE02", + 'room': "D01Z02S01"}, + {'name': "Albero: First gift for Cleofas", + 'region': "albero", + 'game_id': "QI01", + 'room': "D01Z02S03"}, + {'name': "Albero: Final gift for Cleofas", + 'region': "albero", + 'game_id': "PR11", + 'room': "D01Z02S03"}, + {'name': "Albero: Tirso's 1st reward", + 'region': "albero", + 'game_id': "QI66", + 'room': "D01Z02S02"}, + {'name': "Albero: Tirso's 2nd reward", + 'region': "albero", + 'game_id': "Tirso[500]", + 'room': "D01Z02S02"}, + {'name': "Albero: Tirso's 3rd reward", + 'region': "albero", + 'game_id': "Tirso[1000]", + 'room': "D01Z02S02"}, + {'name': "Albero: Tirso's 4th reward", + 'region': "albero", + 'game_id': "Tirso[2000]", + 'room': "D01Z02S02"}, + {'name': "Albero: Tirso's 5th reward", + 'region': "albero", + 'game_id': "Tirso[5000]", + 'room': "D01Z02S02"}, + {'name': "Albero: Tirso's 6th reward", + 'region': "albero", + 'game_id': "Tirso[10000]", + 'room': "D01Z02S02"}, + {'name': "Albero: Tirso's final reward", + 'region': "albero", + 'game_id': "QI56", + 'room': "D01Z02S02"}, + {'name': "Albero: Lvdovico's 1st reward", + 'region': "albero", + 'game_id': "Lvdovico[500]", + 'room': "D01Z02S03"}, + {'name': "Albero: Lvdovico's 2nd reward", + 'region': "albero", + 'game_id': "Lvdovico[1000]", + 'room': "D01Z02S03"}, + {'name': "Albero: Lvdovico's 3rd reward", + 'region': "albero", + 'game_id': "PR03", + 'room': "D01Z02S03"}, + {'name': "Ossuary: Isidora, Voice of the Dead", + 'region': "albero", + 'game_id': "QI201", + 'room': "D01BZ08S01"}, + {'name': "Albero: Mea Culpa altar", + 'region': "albero", + 'game_id': "Sword[D01Z02S06]", + 'room': "D01Z02S06"}, + {'name': "Albero: Donate 5000 Tears", + 'region': "albero", + 'game_id': "RB104", + 'room': "D01BZ04S01"}, + {'name': "Albero: Donate 50000 Tears", + 'region': "albero", + 'game_id': "RB105", + 'room': "D01BZ04S01"}, + {'name': "Ossuary: 1st reward", + 'region': "albero", + 'game_id': "Undertaker[250]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 2nd reward", + 'region': "albero", + 'game_id': "Undertaker[500]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 3rd reward", + 'region': "albero", + 'game_id': "Undertaker[750]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 4th reward", + 'region': "albero", + 'game_id': "Undertaker[1000]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 5th reward", + 'region': "albero", + 'game_id': "Undertaker[1250]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 6th reward", + 'region': "albero", + 'game_id': "Undertaker[1500]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 7th reward", + 'region': "albero", + 'game_id': "Undertaker[1750]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 8th reward", + 'region': "albero", + 'game_id': "Undertaker[2000]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 9th reward", + 'region': "albero", + 'game_id': "Undertaker[2500]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 10th reward", + 'region': "albero", + 'game_id': "Undertaker[3000]", + 'room': "D01BZ06S01"}, + {'name': "Ossuary: 11th reward", + 'region': "albero", + 'game_id': "Undertaker[5000]", + 'room': "D01BZ06S01"}, + + # All the Tears of the Sea (1) + {'name': "AtTotS: Miriam's gift", + 'region': "attots", + 'game_id': "PR201", + 'room': "D04Z04S02"}, + + # Archcathedral Rooftops (11) + {'name': "AR: First soldier fight", + 'region': "ar", + 'game_id': "QI02", + 'room': "D06Z01S03"}, + {'name': "AR: Second soldier fight", + 'region': "ar", + 'game_id': "QI03", + 'room': "D06Z01S06"}, + {'name': "AR: Third soldier fight", + 'region': "ar", + 'game_id': "QI04", + 'room': "D06Z01S21"}, + {'name': "AR: Upper west shaft ledge", + 'region': "ar", + 'game_id': "CO06", + 'room': "D06Z01S12"}, + {'name': "AR: Upper west shaft Child of Moonlight", + 'region': "ar", + 'game_id': "RESCUED_CHERUB_36", + 'room': "D06Z01S12"}, + {'name': "AR: Upper west shaft chest", + 'region': "ar", + 'game_id': "PR12", + 'room': "D06Z01S12"}, + {'name': "AR: Statue near MoM", + 'region': "ar", + 'game_id': "HE04", + 'room': "D06Z01S22"}, + {'name': "AR: Lady of the Six Sorrows", + 'region': "ar", + 'game_id': "Lady[D06Z01S24]", + 'room': "D06Z01S24"}, + {'name': "AR: Upper east shaft ledge", + 'region': "ar", + 'game_id': "CO40", + 'room': "D06Z01S15"}, + {'name': "AR: Mea Culpa altar", + 'region': "ar", + 'game_id': "Sword[D06Z01S11]", + 'room': "D06Z01S11"}, + {'name': "AR: Crisanta of the Wrapped Agony", + 'region': "ar", + 'game_id': "BS16", + 'room': "D06Z01S25"}, + + # Bridge of the Three Cavalries (3) + {'name': "BotTC: Esdras, of the Anointed Legion", + 'region': "bottc", + 'game_id': "BS12", + 'room': "D08Z01S01"}, + {'name': "BotTC: Esdras' gift", + 'region': "bottc", + 'game_id': "PR09", + 'room': "D08Z01S01"}, + {'name': "BotTC: Inside giant statue", + 'region': "bottc", + 'game_id': "HE101", + 'room': "D08Z01S02"}, + + # Brotherhood of the Silent Sorrow (11) + {'name': "BotSS: Beginning gift", + 'region': "botss", + 'game_id': "QI106", + 'room': "D17Z01S01"}, + {'name': "BotSS: Starting room Child of Moonlight", + 'region': "botss", + 'game_id': "RESCUED_CHERUB_06", + 'room': "D17Z01S01"}, + {'name': "BotSS: Starting room ledge", + 'region': "botss", + 'game_id': "RB204", + 'room': "D17Z01S01"}, + {'name': "BotSS: Chamber of the Eldest Brother", + 'region': "botss", + 'game_id': "RE01", + 'room': "D17BZ01S01[relic]"}, + {'name': "BotSS: Mea Culpa altar", + 'region': "botss", + 'game_id': "Sword[D17Z01S08]", + 'room': "D17Z01S08"}, + {'name': "BotSS: Platforming gauntlet", + 'region': "botss", + 'game_id': "CO25", + 'room': "D17Z01S04"}, + {'name': "BotSS: Blue candle", + 'region': "botss", + 'game_id': "RB25", + 'room': "D17Z01S04"}, + {'name': "BotSS: Outside church", + 'region': "botss", + 'game_id': "PR203", + 'room': "D17Z01S14"}, + {'name': "BotSS: Esdras' final gift", + 'region': "botss", + 'game_id': "QI204", + 'room': "D17Z01S15"}, + {'name': "BotSS: Crisanta's gift", + 'region': "botss", + 'game_id': "QI301", + 'room': "D17Z01S15"}, + {'name': "BotSS: Warden of the Silent Sorrow", + 'region': "botss", + 'game_id': "BS13", + 'room': "D17Z01S11"}, + + # Convent of Our Lady of the Charred Visage (13) + {'name': "CoOLotCV: Snowy window ledge", + 'region': "coolotcv", + 'game_id': "CO05", + 'room': "D02Z03S03"}, + {'name': "CoOLotCV: Center enemy lineup", + 'region': "coolotcv", + 'game_id': "CO15", + 'room': "D02Z03S07"}, + {'name': "CoOLotCV: Center miasma room", + 'region': "coolotcv", + 'game_id': "RB08", + 'room': "D02Z03S05"}, + {'name': "CoOLotCV: Lower west statue", + 'region': "coolotcv", + 'game_id': "HE03", + 'room': "D02Z03S12"}, + {'name': "CoOLotCV: Lady of the Six Sorrows", + 'region': "coolotcv", + 'game_id': "Lady[D02Z03S15]", + 'room': "D02Z03S15"}, + {'name': "CoOLotCV: Mea Culpa altar", + 'region': "coolotcv", + 'game_id': "Sword[D02Z03S13]", + 'room': "D02Z03S13"}, + {'name': "CoOLotCV: Red candle", + 'region': "coolotcv", + 'game_id': "RB18", + 'room': "D02Z03S06"}, + {'name': "CoOLotCV: Blue candle", + 'region': "coolotcv", + 'game_id': "RB24", + 'room': "D02Z03S17"}, + {'name': "CoOLotCV: Outside pathway", + 'region': "coolotcv", + 'game_id': "RB107", + 'room': "D02Z03S23"}, + {'name': "CoOLotCV: Fountain of burning oil", + 'region': "coolotcv", + 'game_id': "QI57", + 'room': "D02Z03S21"}, + {'name': "CoOLotCV: Our Lady of the Charred Visage", + 'region': "coolotcv", + 'game_id': "BS03", + 'room': "D02Z03S20"}, + {'name': "CoOLotCV: Visage of Compunction", + 'region': "coolotcv", + 'game_id': "QI40", + 'room': "D02Z03S21"}, + {'name': "CoOLotCV: Mask room", + 'region': "coolotcv", + 'game_id': "QI61", + 'room': "D02Z03S19"}, + + # Deambulatory of His Holiness (3) + {'name': "DoHH: Viridiana's gift", + 'region': "dohh", + 'game_id': "PR08", + 'room': "D07Z01S01"}, + + # Desecrated Cistern (20) + {'name': "DC: Lady of the Six Sorrows, from MD", + 'region': "dc", + 'game_id': "Lady[D01Z05S22]", + 'room': "D01Z05S22"}, + {'name': "DC: Behind sewage drips", + 'region': "dc", + 'game_id': "CO41", + 'room': "D01Z05S15"}, + {'name': "DC: Child of Moonlight, above water", + 'region': "dc", + 'game_id': "RESCUED_CHERUB_11", + 'room': "D01Z05S14"}, + {'name': "DC: Lower east tunnel chest", + 'region': "dc", + 'game_id': "QI45", + 'room': "D01Z05S11"}, + {'name': "DC: Upper east tunnel chest", + 'region': "dc", + 'game_id': "PR16", + 'room': "D01Z05S06"}, + {'name': "DC: Upper east Child of Moonlight", + 'region': "dc", + 'game_id': "RESCUED_CHERUB_13", + 'room': "D01Z05S06"}, + {'name': "DC: Hidden alcove near fountain", + 'region': "dc", + 'game_id': "QI67", + 'room': "D01Z05S05"}, + {'name': "DC: Shortcut to WotBC", + 'region': "dc", + 'game_id': "CO09", + 'room': "D01Z05S05"}, + {'name': "DC: Oil of the Pilgrims", + 'region': "dc", + 'game_id': "Oil[D01Z05S07]", + 'room': "D01Z05S07"}, + {'name': "DC: Child of Moonlight, miasma room", + 'region': "dc", + 'game_id': "RESCUED_CHERUB_14", + 'room': "D01Z05S08"}, + {'name': "DC: Behind gate in miasma room", + 'region': "dc", + 'game_id': "QI12", + 'room': "D01Z05S08"}, + {'name': "DC: Child of Moonlight, behind pillar", + 'region': "dc", + 'game_id': "RESCUED_CHERUB_12", + 'room': "D01Z05S13"}, + {'name': "DC: High ledge near elevator shaft", + 'region': "dc", + 'game_id': "CO32", + 'room': "D01Z05S17"}, + {'name': "DC: Shroud puzzle", + 'region': "dc", + 'game_id': "RB03", + 'room': "D01Z05S21"}, + {'name': "DC: Chalice room", + 'region': "dc", + 'game_id': "QI75", + 'room': "D01Z05S23"}, + {'name': "DC: Mea Culpa altar", + 'region': "dc", + 'game_id': "Sword[D01Z05S24]", + 'room': "D01Z05S24"}, + {'name': "DC: Lady of the Six Sorrows, elevator shaft", + 'region': "dc", + 'game_id': "Lady[D01Z05S26]", + 'room': "D01Z05S26"}, + {'name': "DC: Top of elevator Child of Moonlight", + 'region': "dc", + 'game_id': "RESCUED_CHERUB_15", + 'room': "D01Z05S20"}, + {'name': "DC: Elevator shaft Child of Moonlight", + 'region': "dc", + 'game_id': "RESCUED_CHERUB_22", + 'room': "D01Z05S25"}, + {'name': "DC: Elevator shaft ledge", + 'region': "dc", + 'game_id': "CO44", + 'room': "D01Z05S25"}, + + # Echoes of Salt (2) + {'name': "EoS: Lantern jump near MotED", + 'region': "eos", + 'game_id': "RB108", + 'room': "D20Z01S02"}, + {'name': "EoS: Lantern jump near elevator", + 'region': "eos", + 'game_id': "RB202", + 'room': "D20Z01S09"}, + + # Graveyard of the Peaks (21) + {'name': "GotP: Shop cave Child of Moonlight", + 'region': "gotp", + 'game_id': "RESCUED_CHERUB_31", + 'room': "D02Z02S08"}, + {'name': "GotP: Shop cave hidden hole", + 'region': "gotp", + 'game_id': "CO42", + 'room': "D02Z02S08"}, + {'name': "GotP: Shop item 1", + 'region': "gotp", + 'game_id': "QI11", + 'room': "D02BZ01S01"}, + {'name': "GotP: Shop item 2", + 'region': "gotp", + 'game_id': "RB37", + 'room': "D02BZ01S01"}, + {'name': "GotP: Shop item 3", + 'region': "gotp", + 'game_id': "RB02", + 'room': "D02BZ01S01"}, + {'name': "GotP: Confessor Dungeon room", + 'region': "gotp", + 'game_id': "RB38", + 'room': "D02Z02S06"}, + {'name': "GotP: Elevator shaft Child of Moonlight", + 'region': "gotp", + 'game_id': "RESCUED_CHERUB_26", + 'room': "D02Z02S11"}, + {'name': "GotP: Elevator shaft ledge", + 'region': "gotp", + 'game_id': "QI53", + 'room': "D02Z02S11"}, + {'name': "GotP: Lady of the Six Sorrows", + 'region': "gotp", + 'game_id': "Lady[D02Z02S12]", + 'room': "D02Z02S12"}, + {'name': "GotP: Self sacrifice statue", + 'region': "gotp", + 'game_id': "HE11", + 'room': "D02Z02S13"}, + {'name': "GotP: Lower east shaft", + 'region': "gotp", + 'game_id': "QI46", + 'room': "D02Z02S03"}, + {'name': "GotP: Center east shaft", + 'region': "gotp", + 'game_id': "CO29", + 'room': "D02Z02S03"}, + {'name': "GotP: Upper east shaft", + 'region': "gotp", + 'game_id': "QI08", + 'room': "D02Z02S03"}, + {'name': "GotP: East cliffside", + 'region': "gotp", + 'game_id': "RB106", + 'room': "D02Z02S14"}, + {'name': "GotP: West shaft Child of Moonlight", + 'region': "gotp", + 'game_id': "RESCUED_CHERUB_25", + 'room': "D02Z02S04"}, + {'name': "GotP: Lower west shaft", + 'region': "gotp", + 'game_id': "RB32", + 'room': "D02Z02S04"}, + {'name': "GotP: Upper west shaft", + 'region': "gotp", + 'game_id': "CO01", + 'room': "D02Z02S04"}, + {'name': "GotP: Center shaft Child of Moonlight", + 'region': "gotp", + 'game_id': "RESCUED_CHERUB_24", + 'room': "D02Z02S02"}, + {'name': "GotP: Center shaft ledge", + 'region': "gotp", + 'game_id': "RB15", + 'room': "D02Z02S05"}, + {'name': "GotP: Oil of the Pilgrims", + 'region': "gotp", + 'game_id': "Oil[D02Z02S10]", + 'room': "D02Z02S10"}, + {'name': "GotP: Amanecida of the Bejeweled Arrow", + 'region': "gotp", + 'game_id': "D02Z02S14[18000]", + 'room': "D02Z02S14"}, + + # Grievance Ascends (12) + {'name': "GA: Lower west ledge", + 'region': "ga", + 'game_id': "QI44", + 'room': "D03Z03S02"}, + {'name': "GA: Miasma room treasure", + 'region': "ga", + 'game_id': "RE07", + 'room': "D03Z03S06"}, + {'name': "GA: Miasma room Child of Moonlight", + 'region': "ga", + 'game_id': "RESCUED_CHERUB_19", + 'room': "D03Z03S06"}, + {'name': "GA: Miasma room floor", + 'region': "ga", + 'game_id': "CO12", + 'room': "D03Z03S06"}, + {'name': "GA: Oil of the Pilgrims", + 'region': "ga", + 'game_id': "Oil[D03Z03S13]", + 'room': "D03Z03S13"}, + {'name': "GA: End of blood bridge", + 'region': "ga", + 'game_id': "QI10", + 'room': "D03Z03S08"}, + {'name': "GA: Blood bridge Child of Moonlight", + 'region': "ga", + 'game_id': "RESCUED_CHERUB_21", + 'room': "D03Z03S08"}, + {'name': "GA: Lower east Child of Moonlight", + 'region': "ga", + 'game_id': "RESCUED_CHERUB_20", + 'room': "D03Z03S09"}, + {'name': "GA: Altasgracias' gift", + 'region': "ga", + 'game_id': "QI13", + 'room': "D03Z03S10"}, + {'name': "GA: Empty giant egg", + 'region': "ga", + 'game_id': "RB06", + 'room': "D03Z03S10"}, + {'name': "GA: Tres Angustias", + 'region': "ga", + 'game_id': "BS04", + 'room': "D03Z03S15"}, + {'name': "GA: Visage of Contrition", + 'region': "ga", + 'game_id': "QI39", + 'room': "D03Z03S16"}, + + # Hall of the Dawning (2) + {'name': "HotD: Mirror room", + 'region': "hotd", + 'game_id': "QI105", + 'room': "D08Z02S01"}, + {'name': "HotD: Laudes, the First of the Amanecidas", + 'region': "hotd", + 'game_id': "LaudesBossTrigger[30000]", + 'room': "D08Z02S03"}, + + # Jondo (13) + {'name': "Jondo: Upper east ledge", + 'region': "jondo", + 'game_id': "CO08", + 'room': "D03Z03S01"}, + {'name': "Jondo: Upper east chest", + 'region': "jondo", + 'game_id': "PR10", + 'room': "D03Z03S01"}, + {'name': "Jondo: Lower east under chargers", + 'region': "jondo", + 'game_id': "CO33", + 'room': "D03Z03S04"}, + {'name': "Jondo: Lower east bell trap", + 'region': "jondo", + 'game_id': "QI19", + 'room': "D03Z03S06"}, + {'name': "Jondo: Upper east Child of Moonlight", + 'region': "jondo", + 'game_id': "RESCUED_CHERUB_18", + 'room': "D03Z03S05"}, + {'name': "Jondo: Spike tunnel Child of Moonlight", + 'region': "jondo", + 'game_id': "RESCUED_CHERUB_37", + 'room': "D03Z03S11"}, + {'name': "Jondo: Spike tunnel statue", + 'region': "jondo", + 'game_id': "HE06", + 'room': "D03Z03S11"}, + {'name': "Jondo: Spike tunnel cave", + 'region': "jondo", + 'game_id': "QI103", + 'room': "D03Z03S15"}, + {'name': "Jondo: Lower west lift alcove", + 'region': "jondo", + 'game_id': "CO07", + 'room': "D03Z03S07"}, + {'name': "Jondo: Lower west bell alcove", + 'region': "jondo", + 'game_id': "QI41", + 'room': "D03Z03S08"}, + {'name': "Jondo: Upper west bell puzzle", + 'region': "jondo", + 'game_id': "QI52", + 'room': "D03Z03S12"}, + {'name': "Jondo: Upper west tree root", + 'region': "jondo", + 'game_id': "RB28", + 'room': "D03Z03S13"}, + {'name': "Jondo: Upper west Child of Moonlight", + 'region': "jondo", + 'game_id': "RESCUED_CHERUB_17", + 'room': "D03Z03S10"}, + + # Knot of the Three Words (1) + {'name': "KotTW: Gift from the Traitor", + 'region': "kottw", + 'game_id': "HE201", + 'room': "D04Z03S02"}, + + # Library of the Negated Words (18) + {'name': "LotNW: Platform room Child of Moonlight", + 'region': "lotnw", + 'game_id': "RESCUED_CHERUB_01", + 'room': "D05Z01S04"}, + {'name': "LotNW: Platform room ledge", + 'region': "lotnw", + 'game_id': "CO18", + 'room': "D05Z01S04"}, + {'name': "LotNW: Root ceiling platform", + 'region': "lotnw", + 'game_id': "CO22", + 'room': "D05Z01S05"}, + {'name': "LotNW: Hidden floor", + 'region': "lotnw", + 'game_id': "QI50", + 'room': "D05Z01S05"}, + {'name': "LotNW: Miasma hallway chest", + 'region': "lotnw", + 'game_id': "RB31", + 'room': "D05Z01S06"}, + {'name': "LotNW: Lady of the Six Sorrows", + 'region': "lotnw", + 'game_id': "Lady[D05Z01S14]", + 'room': "D05Z01S14"}, + {'name': "LotNW: Bone puzzle", + 'region': "lotnw", + 'game_id': "PR15", + 'room': "D05Z01S18"}, + {'name': "LotNW: Lowest west upper ledge", + 'region': "lotnw", + 'game_id': "CO28", + 'room': "D05Z01S11"}, + {'name': "LotNW: Platform puzzle chest", + 'region': "lotnw", + 'game_id': "PR07", + 'room': "D05Z01S10"}, + {'name': "LotNW: Lowest west center ledge", + 'region': "lotnw", + 'game_id': "RB30", + 'room': "D05Z01S11"}, + {'name': "LotNW: Lowest west Child of Moonlight", + 'region': "lotnw", + 'game_id': "RESCUED_CHERUB_02", + 'room': "D05Z01S11"}, + {'name': "LotNW: Oil of the Pilgrims", + 'region': "lotnw", + 'game_id': "Oil[D05Z01S19]", + 'room': "D05Z01S19"}, + {'name': "LotNW: Elevator Child of Moonlight", + 'region': "lotnw", + 'game_id': "RESCUED_CHERUB_32", + 'room': "D05Z01S21"}, + {'name': "LotNW: Mask room", + 'region': "lotnw", + 'game_id': "QI62", + 'room': "D05Z01S15"}, + {'name': "LotNW: Mea Culpa altar", + 'region': "lotnw", + 'game_id': "Sword[D05Z01S13]", + 'room': "D05Z01S13"}, + {'name': "LotNW: Red candle", + 'region': "lotnw", + 'game_id': "RB19", + 'room': "D05Z01S02"}, + {'name': "LotNW: Silence for Diosdado", + 'region': "lotnw", + 'game_id': "RB203", + 'room': "D05Z01S11"}, # ? + {'name': "LotNW: Twisted wood hidden wall", + 'region': "lotnw", + 'game_id': "RB301", + 'room': "D05BZ01S01"}, + + # Mercy Dreams (15) + {'name': "MD: First area hidden wall", + 'region': "md", + 'game_id': "CO30", + 'room': "D01Z04S05"}, + {'name': "MD: Second area trapped chest", + 'region': "md", + 'game_id': "PR01", + 'room': "D01Z04S07"}, + {'name': "MD: Second area ledge", + 'region': "md", + 'game_id': "CO03", + 'room': "D01Z04S06"}, + {'name': "MD: Second area Child of Moonlight", + 'region': "md", + 'game_id': "RESCUED_CHERUB_09", + 'room': "D01Z04S06"}, + {'name': "MD: Red candle", + 'region': "md", + 'game_id': "RB17", + 'room': "D01Z04S08"}, + {'name': "MD: Shop item 1", + 'region': "md", + 'game_id': "QI58", + 'room': "D01BZ02S01"}, + {'name': "MD: Shop item 2", + 'region': "md", + 'game_id': "RB05", + 'room': "D01BZ02S01"}, + {'name': "MD: Shop item 3", + 'region': "md", + 'game_id': "RB09", + 'room': "D01BZ02S01"}, + {'name': "MD: Third area hidden room", + 'region': "md", + 'game_id': "QI48", + 'room': "D01Z04S11"}, + {'name': "MD: Sliding challenge", + 'region': "md", + 'game_id': "CO38", + 'room': "D01Z04S14"}, + {'name': "MD: Ten Piedad", + 'region': "md", + 'game_id': "BS01", + 'room': "D01Z04S18"}, + {'name': "MD: Visage of Attrition", + 'region': "md", + 'game_id': "QI38", + 'room': "D01Z04S19"}, + {'name': "MD: Blue candle", + 'region': "md", + 'game_id': "RB26", + 'room': "D01Z04S16"}, + {'name': "MD: Cave Child of Moonlight", + 'region': "md", + 'game_id': "RESCUED_CHERUB_33", + 'room': "D01Z04S16"}, + {'name': "MD: Behind gate to TSC", + 'region': "md", + 'game_id': "CO21", + 'room': "D01Z04S13"}, + + # Mother of Mothers (14) + {'name': "MoM: Oil of the Pilgrims", + 'region': "mom", + 'game_id': "Oil[D04Z02S14]", + 'room': "D04Z02S14"}, + {'name': "MoM: Upper east ledge", + 'region': "mom", + 'game_id': "RB33", + 'room': "D04Z02S07"}, + {'name': "MoM: East chandelier platform", + 'region': "mom", + 'game_id': "CO35", + 'room': "D04Z02S"}, + {'name': "MoM: Lower west Child of Moonlight", + 'region': "mom", + 'game_id': "RESCUED_CHERUB_30", + 'room': ""}, + {'name': "MoM: Upper west floor", + 'region': "mom", + 'game_id': "CO17", + 'room': "D04Z02S02"}, + {'name': "MoM: Redento's treasure", + 'region': "mom", + 'game_id': "RE03", + 'room': "D04BZ02S01"}, + {'name': "MoM: Final meeting with Redento", + 'region': "mom", + 'game_id': "QI54", + 'room': "D04BZ02S01"}, + {'name': "MoM: Giant chandelier statue", + 'region': "mom", + 'game_id': "HE01", + 'room': "D04Z02S16"}, + {'name': "MoM: Outside Cleofas' room", + 'region': "mom", + 'game_id': "CO34", + 'room': "D04Z02S06"}, + {'name': "MoM: Upper center floor", + 'region': "mom", + 'game_id': "CO20", + 'room': "D04Z02S11"}, + {'name': "MoM: Upper center Child of Moonlight", + 'region': "mom", + 'game_id': "RESCUED_CHERUB_29", + 'room': ""}, + {'name': "MoM: Mea Culpa altar", + 'region': "mom", + 'game_id': "Sword[D04Z02S12]", + 'room': "D04Z02S12"}, + {'name': "MoM: Melquiades, The Exhumed Archbishop", + 'region': "mom", + 'game_id': "BS05", + 'room': "D04Z02S22"}, + {'name': "MoM: Mask room", + 'region': "mom", + 'game_id': "QI60", + 'room': "D04Z02S15"}, + + # Mountains of the Endless Dusk (8) + {'name': "MotED: Under entrance to DC", + 'region': "moted", + 'game_id': "CO13", + 'room': "D03Z01S01"}, + {'name': "MotED: Perpetva", + 'region': "moted", + 'game_id': "RB13", + 'room': "D03Z01S06"}, + {'name': "MotED: Child of Moonlight, above chasm", + 'region': "moted", + 'game_id': "RESCUED_CHERUB_16", + 'room': "D03Z01S03"}, + {'name': "MotED: Platform above chasm", + 'region': "moted", + 'game_id': "QI47", + 'room': "D03Z01S03"}, + {'name': "MotED: 1st meeting with Redento", + 'region': "moted", + 'game_id': "RB22", + 'room': "D03Z01S03"}, + {'name': "MotED: Blood platform alcove", + 'region': "moted", + 'game_id': "QI63", + 'room': "D03Z01S04"}, + {'name': "MotED: Egg hatching", + 'region': "moted", + 'game_id': "QI14", + 'room': "D03Z01S06"}, + {'name': "MotED: Amanecida of the Golden Blades", + 'region': "moted", + 'game_id': "D03Z01S03[18000]", + 'room': "D03Z01S03"}, + + # Mourning and Havoc (4) + {'name': "MaH: West chest", + 'region': "mah", + 'game_id': "PR202", + 'room': "D20Z02S11"}, + {'name': "MaH: Upper east chest", + 'region': "mah", + 'game_id': "RB201", + 'room': "D20Z02S02"}, + {'name': "MaH: Sierpes' eye", + 'region': "mah", + 'game_id': "QI202", + 'room': "D20Z02S08"}, + {'name': "MaH: Sierpes", + 'region': "mah", + 'game_id': "BossTrigger[5000]", + 'room': "D20Z02S08"}, + + # Patio of the Silent Steps (9) + {'name': "PotSS: First area Child of Moonlight", + 'region': "potss", + 'game_id': "RESCUED_CHERUB_35", + 'room': "D04Z01S01"}, + {'name': "PotSS: First area ledge", + 'region': "potss", + 'game_id': "CO23", + 'room': "D04Z01S01"}, + {'name': "PotSS: Second area ledge", + 'region': "potss", + 'game_id': "RB14", + 'room': "D04Z01S02"}, + {'name': "PotSS: Third area Child of Moonlight", + 'region': "potss", + 'game_id': "RESCUED_CHERUB_28", + 'room': "D04Z01S03"}, + {'name': "PotSS: Third area lower ledge", + 'region': "potss", + 'game_id': "QI37", + 'room': "D04Z01S03"}, + {'name': "PotSS: Third area upper ledge", + 'region': "potss", + 'game_id': "CO39", + 'room': "D04Z01S03"}, + {'name': "PotSS: Climb to WotHP", + 'region': "potss", + 'game_id': "QI102", + 'room': "D04Z01S05"}, + {'name': "PotSS: 4th meeting with Redento", + 'region': "potss", + 'game_id': "RB21", + 'room': "D04Z01S01"}, + {'name': "PotSS: Amanecida of the Chiselled Steel", + 'region': "potss", + 'game_id': "D04Z01S04[18000]", + 'room': "D04Z01S04"}, + + # Petrous (1) + {'name': "Petrous: Temple entrance", + 'region': "petrous", + 'game_id': "QI101", + 'room': "D01Z06S01"}, + + # The Resting Place of the Sister (1) + {'name': "TRPotS: Perpetva's shrine", + 'region': "trpots", + 'game_id': "QI203", + 'room': "D20Z03S01"}, + + # The Sleeping Canvases (10) + {'name': "TSC: Painting ladder ledge", + 'region': "tsc", + 'game_id': "QI64", + 'room': "D05Z02S02"}, + {'name': "TSC: Candle wax puzzle", + 'region': "tsc", + 'game_id': "HE07", + 'room': "D05Z02S08"}, + {'name': "TSC: Shop item 1", + 'region': "tsc", + 'game_id': "RB12", + 'room': "D05BZ02S01"}, + {'name': "TSC: Shop item 2", + 'region': "tsc", + 'game_id': "QI49", + 'room': "D05BZ02S01"}, + {'name': "TSC: Shop item 3", + 'region': "tsc", + 'game_id': "QI71", + 'room': "D05BZ02S01"}, + {'name': "TSC: Swinging blade tunnel", + 'region': "tsc", + 'game_id': "QI104", + 'room': "D05Z02S15"}, + {'name': "TSC: Exposito, Scion of Abjuration", + 'region': "tsc", + 'game_id': "BS06", + 'room': "D05Z02S14"}, + {'name': "TSC: Under elevator shaft", + 'region': "tsc", + 'game_id': "CO31", + 'room': "D05Z02S11"}, + {'name': "TSC: Jocinero's 1st reward", + 'region': "tsc", + 'game_id': "RE05", + 'room': "D05Z02S10"}, # ? + {'name': "TSC: Jocinero's final reward", + 'region': "tsc", + 'game_id': "PR05", + 'room': "D05Z02S10"}, # ? + + # The Holy Line (6) + {'name': "THL: Deogracias' gift", + 'region': "thl", + 'game_id': "QI31", + 'room': "D01Z01S07"}, + {'name': "THL: Hanging skeleton", + 'region': "thl", + 'game_id': "PR14", + 'room': "D01Z01S02"}, + {'name': "THL: Across blood platforms", + 'region': "thl", + 'game_id': "RB07", + 'room': "D01Z01S02"}, + {'name': "THL: Child of Moonlight", + 'region': "thl", + 'game_id': "RESCUED_CHERUB_07", + 'room': "D01Z01S03"}, + {'name': "THL: Underground ledge", + 'region': "thl", + 'game_id': "CO04", + 'room': "D01Z01S03"}, + {'name': "THL: Underground chest", + 'region': "thl", + 'game_id': "QI55", + 'room': "D01Z01S03"}, + + # Wall of the Holy Prohibitions (19) + {'name': "WotHP: Upper east room, lift puzzle", + 'region': "wothp", + 'game_id': "RB11", + 'room': "D09Z01S02"}, + {'name': "WotHP: Upper east room, center cell ledge", + 'region': "wothp", + 'game_id': "CO10", + 'room': "D09BZ01S01[Cell22]"}, + {'name': "WotHP: Upper east room, center cell floor", + 'region': "wothp", + 'game_id': "QI69", + 'room': "D09BZ01S01[Cell22]"}, + {'name': "WotHP: Upper east room, top bronze cell", + 'region': "wothp", + 'game_id': "RESCUED_CHERUB_03", + 'room': "D09BZ01S01[Cell1]"}, + {'name': "WotHP: Upper east room, top silver cell", + 'region': "wothp", + 'game_id': "CO24", + 'room': "D09BZ01S01[Cell6]"}, + {'name': "WotHP: Upper east room, center gold cell", + 'region': "wothp", + 'game_id': "QI51", + 'room': "D09Z01S02"}, + {'name': "WotHP: Upper west room, center gold cell", + 'region': "wothp", + 'game_id': "CO26", + 'room': "D09BZ01S01[Cell16]"}, + {'name': "WotHP: Lower west room, bottom gold cell", + 'region': "wothp", + 'game_id': "CO02", + 'room': "D09BZ01S01[Cell21]"}, + {'name': "WotHP: Upper west room, top silver cell", + 'region': "wothp", + 'game_id': "RESCUED_CHERUB_34", + 'room': "D09BZ01S01[Cell17~18]"}, # ? + {'name': "WotHP: Lower west room, top ledge", + 'region': "wothp", + 'game_id': "RB16", + 'room': "D09BZ01S01[Cell24]"}, + {'name': "WotHP: Lower east room, hidden ledge", + 'region': "wothp", + 'game_id': "CO27", + 'room': "D09Z01S10"}, + {'name': "WotHP: Lower east room, bottom silver cell", + 'region': "wothp", + 'game_id': "RESCUED_CHERUB_04", + 'room': "D09BZ01S01[Cell11]"}, + {'name': "WotHP: Lower east room, top bronze cell", + 'region': "wothp", + 'game_id': "QI70", + 'room': "D09Z01S10"}, + {'name': "WotHP: Lower east room, top silver cell", + 'region': "wothp", + 'game_id': "CO37", + 'room': "D09BZ01S01[Cell10]"}, + {'name': "WotHP: Outside Child of Moonlight", + 'region': "wothp", + 'game_id': "RESCUED_CHERUB_05", + 'room': "D09Z01S06"}, + {'name': "WotHP: Oil of the Pilgrims", + 'region': "wothp", + 'game_id': "Oil[D09Z01S12]", + 'room': "D09Z01S12"}, + {'name': "WotHP: Quirce, Returned By The Flames", + 'region': "wothp", + 'game_id': "BS14", + 'room': "D09Z01S03"}, + {'name': "WotHP: Collapsing floor ledge", + 'region': "wothp", + 'game_id': "QI72", + 'room': "D09Z01S08"}, + {'name': "WotHP: Amanecida of the Molten Thorn", + 'region': "wothp", + 'game_id': "D09Z01S01[18000]", + 'room': "D09Z01S01"}, + + # Wasteland of the Buried Churches (8) + {'name': "WotBC: Lower log path", + 'region': "wotbc", + 'game_id': "RB04", + 'room': "D01Z03S01"}, + {'name': "WotBC: Hidden alcove", + 'region': "wotbc", + 'game_id': "CO14", + 'room': "D01Z03S02"}, + {'name': "WotBC: Outside ledge", + 'region': "wotbc", + 'game_id': "CO36", + 'room': "D01Z03S03"}, + {'name': "WotBC: Outside Child of Moonlight", + 'region': "wotbc", + 'game_id': "RESCUED_CHERUB_10", + 'room': "D01Z03S03"}, + {'name': "WotBC: Under broken bridge", + 'region': "wotbc", + 'game_id': "QI06", + 'room': "D01Z03S05"}, + {'name': "WotBC: Cliffside statue", + 'region': "wotbc", + 'game_id': "HE02", + 'room': "D01Z03S07"}, + {'name': "WotBC: Cliffside Child of Moonlight", + 'region': "wotbc", + 'game_id': "RESCUED_CHERUB_38", + 'room': "D01Z03S07"}, + {'name': "WotBC: 3rd meeting with Redento", + 'region': "wotbc", + 'game_id': "RB20", + 'room': "D01Z03S01"}, # ? + + # Where Olive Trees Wither (11) + {'name': "WOTW: Below Prie Dieu", + 'region': "wotw", + 'game_id': "CO11", + 'room': "D02Z01S01"}, + {'name': "WOTW: Entrance to tomb", + 'region': "wotw", + 'game_id': "QI20", + 'room': "D02Z01S04"}, + {'name': "WOTW: Gift for the tomb", + 'region': "wotw", + 'game_id': "QI68", + 'room': "D02Z01S"}, + {'name': "WOTW: Underground tomb", + 'region': "wotw", + 'game_id': "PR04", + 'room': "D02Z01S08"}, + {'name': "WOTW: Underground Child of Moonlight", + 'region': "wotw", + 'game_id': "RESCUED_CHERUB_27", + 'room': "D02Z01S06"}, + {'name': "WOTW: Underground ledge", + 'region': "wotw", + 'game_id': "CO19", + 'room': "D02Z01S06"}, + {'name': "WOTW: Upper east Child of Moonlight", + 'region': "wotw", + 'game_id': "RESCUED_CHERUB_23", + 'room': "D02Z01S09"}, + {'name': "WOTW: Upper east statue", + 'region': "wotw", + 'game_id': "HE05", + 'room': "D02Z01S09"}, + {'name': "WOTW: Death run", + 'region': "wotw", + 'game_id': "QI07", + 'room': "D02Z01S05"}, + {'name': "WOTW: Gemino's gift", + 'region': "wotw", + 'game_id': "QI59", + 'room': "D02Z01S01"}, + {'name': "WOTW: Gemino's reward", + 'region': "wotw", + 'game_id': "RB10", + 'room': "D02Z01S01"}, + + # Various (20) + {'name': "Confessor Dungeon 1 extra", + 'region': "dungeon", + 'game_id': "Arena_NailManager[1000]", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 1 main", + 'region': "dungeon", + 'game_id': "QI32", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 2 extra", + 'region': "dungeon", + 'game_id': "HE10", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 2 main", + 'region': "dungeon", + 'game_id': "QI33", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 3 extra", + 'region': "dungeon", + 'game_id': "Arena_NailManager[3000]", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 3 main", + 'region': "dungeon", + 'game_id': "QI34", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 4 extra", + 'region': "dungeon", + 'game_id': "RB34", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 4 main", + 'region': "dungeon", + 'game_id': "QI35", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 5 extra", + 'region': "dungeon", + 'game_id': "Arena_NailManager[5000]", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 5 main", + 'region': "dungeon", + 'game_id': "QI79", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 6 extra", + 'region': "dungeon", + 'game_id': "RB35", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 6 main", + 'region': "dungeon", + 'game_id': "QI80", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 7 extra", + 'region': "dungeon", + 'game_id': "RB36", + 'room': "dungeon"}, + {'name': "Confessor Dungeon 7 main", + 'region': "dungeon", + 'game_id': "QI81", + 'room': "dungeon"}, + {'name': "Defeat 1 Amanecida", + 'region': "dungeon", + 'game_id': "QI107", + 'room': "dungeon"}, + {'name': "Defeat 2 Amanecidas", + 'region': "dungeon", + 'game_id': "QI108", + 'room': "dungeon"}, + {'name': "Defeat 3 Amanecidas", + 'region': "dungeon", + 'game_id': "QI109", + 'room': "dungeon"}, + {'name': "Defeat 4 Amanecidas", + 'region': "dungeon", + 'game_id': "QI110", + 'room': "dungeon"}, + {'name': "Defeat all Amanecidas", + 'region': "dungeon", + 'game_id': "PR101", + 'room': "dungeon"}, + {'name': "Skill 1, Tier 1", + 'region': "dungeon", + 'game_id': "COMBO_1", + 'room': "dungeon"}, + {'name': "Skill 1, Tier 2", + 'region': "dungeon", + 'game_id': "COMBO_2", + 'room': "dungeon"}, + {'name': "Skill 1, Tier 3", + 'region': "dungeon", + 'game_id': "COMBO_3", + 'room': "dungeon"}, + {'name': "Skill 2, Tier 1", + 'region': "dungeon", + 'game_id': "CHARGED_1", + 'room': "dungeon"}, + {'name': "Skill 2, Tier 2", + 'region': "dungeon", + 'game_id': "CHARGED_2", + 'room': "dungeon"}, + {'name': "Skill 2, Tier 3", + 'region': "dungeon", + 'game_id': "CHARGED_3", + 'room': "dungeon"}, + {'name': "Skill 3, Tier 1", + 'region': "dungeon", + 'game_id': "RANGED_1", + 'room': "dungeon"}, + {'name': "Skill 3, Tier 2", + 'region': "dungeon", + 'game_id': "RANGED_2", + 'room': "dungeon"}, + {'name': "Skill 3, Tier 3", + 'region': "dungeon", + 'game_id': "RANGED_3", + 'room': "dungeon"}, + {'name': "Skill 4, Tier 1", + 'region': "dungeon", + 'game_id': "VERTICAL_1", + 'room': "dungeon"}, + {'name': "Skill 4, Tier 2", + 'region': "dungeon", + 'game_id': "VERTICAL_2", + 'room': "dungeon"}, + {'name': "Skill 4, Tier 3", + 'region': "dungeon", + 'game_id': "VERTICAL_3", + 'room': "dungeon"}, + {'name': "Skill 5, Tier 1", + 'region': "dungeon", + 'game_id': "LUNGE_1", + 'room': "dungeon"}, + {'name': "Skill 5, Tier 2", + 'region': "dungeon", + 'game_id': "LUNGE_2", + 'room': "dungeon"}, + {'name': "Skill 5, Tier 3", + 'region': "dungeon", + 'game_id': "LUNGE_3", + 'room': "dungeon"}, +] + +shop_set: Set[str] = [ + "GotP: Shop item 1", + "GotP: Shop item 2", + "GotP: Shop item 3", + "MD: Shop item 1", + "MD: Shop item 2", + "MD: Shop item 3", + "TSC: Shop item 1", + "TSC: Shop item 2", + "TSC: Shop item 3" +] \ No newline at end of file diff --git a/worlds/blasphemous/Options.py b/worlds/blasphemous/Options.py new file mode 100644 index 0000000000..be43d8b7c8 --- /dev/null +++ b/worlds/blasphemous/Options.py @@ -0,0 +1,257 @@ +from Options import Choice, Toggle, DefaultOnToggle, DeathLink + + +class PrieDieuWarp(DefaultOnToggle): + """Automatically unlocks the ability to warp between Prie Dieu shrines.""" + display_name = "Unlock Fast Travel" + + +class SkipCutscenes(DefaultOnToggle): + """Automatically skips most cutscenes.""" + display_name = "Auto Skip Cutscenes" + + +class CorpseHints(DefaultOnToggle): + """Changes the 34 corpses in game to give various hints about item locations.""" + display_name = "Corpse Hints" + + +class Difficulty(Choice): + """Adjusts the logic required to defeat bosses. + Impossible: Removes all logic requirements for bosses. Good luck.""" + display_name = "Difficulty" + option_easy = 0 + option_normal = 1 + option_hard = 2 + option_impossible = 3 + default = 1 + + +class Penitence(Toggle): + """Allows one of the three Penitences to be chosen at the beginning of the game.""" + display_name = "Penitence" + + +class ExpertLogic(Toggle): + """Expands the logic used by the randomizer to allow for some difficult and/or lesser known tricks.""" + display_name = "Expert Logic" + + +class Ending(Choice): + """Choose which ending is required to complete the game.""" + display_name = "Ending" + option_any_ending = 0 + option_ending_b = 1 + option_ending_c = 2 + default = 0 + + +class ThornShuffle(Choice): + """Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool.""" + display_name = "Shuffle Thorn" + option_anywhere = 0 + option_local_only = 1 + option_vanilla = 2 + default = 0 + + +class ReliquaryShuffle(DefaultOnToggle): + """Adds the True Torment exclusive Reliquary rosary beads into the item pool.""" + display_name = "Shuffle Penitence Rewards" + + +class CherubShuffle(DefaultOnToggle): + """Shuffles Children of Moonlight into the item pool.""" + display_name = "Shuffle Children of Moonlight" + + +class LifeShuffle(DefaultOnToggle): + """Shuffles life upgrades from the Lady of the Six Sorrows into the item pool.""" + display_name = "Shuffle Life Upgrades" + + +class FervourShuffle(DefaultOnToggle): + """Shuffles fervour upgrades from the Oil of the Pilgrims into the item pool.""" + display_name = "Shuffle Fervour Upgrades" + + +class SwordShuffle(DefaultOnToggle): + """Shuffles Mea Culpa upgrades from the Mea Culpa Altars into the item pool.""" + display_name = "Shuffle Mea Culpa Upgrades" + + +class BlessingShuffle(DefaultOnToggle): + """Shuffles blessings from the Lake of Silent Pilgrims into the item pool.""" + display_name = "Shuffle Blessings" + + +class DungeonShuffle(DefaultOnToggle): + """Shuffles rewards from completing Confessor Dungeons into the item pool.""" + display_name = "Shuffle Dungeon Rewards" + + +class TirsoShuffle(DefaultOnToggle): + """Shuffles rewards from delivering herbs to Tirso into the item pool.""" + display_name = "Shuffle Tirso's Rewards" + + +class MiriamShuffle(DefaultOnToggle): + """Shuffles the prayer given by Miriam into the item pool.""" + display_name = "Shuffle Miriram's Reward" + + +class RedentoShuffle(DefaultOnToggle): + """Shuffles rewards from assisting Redento into the item pool.""" + display_name = "Shuffle Redento's Rewards" + + +class JocineroShuffle(DefaultOnToggle): + """Shuffles rewards from rescuing 20 and 38 Children of Moonlight into the item pool.""" + display_name = "Shuffle Jocinero's Rewards" + + +class AltasgraciasShuffle(DefaultOnToggle): + """Shuffles the reward given by Altasgracias and the item left behind by them into the item pool.""" + display_name = "Shuffle Altasgracias' Rewards" + + +class TentudiaShuffle(DefaultOnToggle): + """Shuffles the rewards from delivering Tentudia's remains to Lvdovico into the item pool.""" + display_name = "Shuffle Lvdovico's Rewards" + + +class GeminoShuffle(DefaultOnToggle): + """Shuffles the rewards from Gemino's quest and the hidden tomb into the item pool.""" + display_name = "Shuffle Gemino's Rewards" + + +class GuiltShuffle(DefaultOnToggle): + """Shuffles the Weight of True Guilt into the item pool.""" + display_name = "Shuffle Immaculate Bead" + + +class OssuaryShuffle(DefaultOnToggle): + """Shuffles the rewards from delivering bones to the Ossuary into the item pool.""" + display_name = "Shuffle Ossuary Rewards" + + +class BossShuffle(DefaultOnToggle): + """Shuffles the Tears of Atonement from defeating bosses into the item pool.""" + display_name = "Shuffle Boss Tears" + + +class WoundShuffle(DefaultOnToggle): + """Shuffles the Holy Wounds required to pass the Bridge of the Three Cavalries into the item pool.""" + display_name = "Shuffle Holy Wounds" + + +class MaskShuffle(DefaultOnToggle): + """Shuffles the masks required to use the elevator in Archcathedral Rooftops into the item pool.""" + display_name = "Shuffle Masks" + + +class EyeShuffle(DefaultOnToggle): + """Shuffles the Eyes of the Traitor from defeating Isidora and Sierpes into the item pool.""" + display_name = "Shuffle Traitor's Eyes" + + +class HerbShuffle(DefaultOnToggle): + """Shuffles the herbs required for Tirso's quest into the item pool.""" + display_name = "Shuffle Herbs" + + +class ChurchShuffle(DefaultOnToggle): + """Shuffles the rewards from donating 5,000 and 50,000 Tears of Atonement to the Church in Albero into the item pool.""" + display_name = "Shuffle Donation Rewards" + + +class ShopShuffle(DefaultOnToggle): + """Shuffles the items sold in Candelaria's shops into the item pool.""" + display_name = "Shuffle Shop Items" + + +class CandleShuffle(DefaultOnToggle): + """Shuffles the Beads of Wax and their upgrades into the item pool.""" + display_name = "Shuffle Candles" + + +class StartWheel(Toggle): + """Changes the beginning gift to The Young Mason's Wheel.""" + display_name = "Start with Wheel" + + +class SkillRando(Toggle): + """Randomizes the abilities from the skill tree into the item pool.""" + display_name = "Skill Randomizer" + + +class EnemyRando(Choice): + """Randomizes the enemies that appear in each room. + Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in a standard game. + Randomized: Every enemy is completely random, and can appear any number of times. + Some enemies will never be randomized.""" + display_name = "Enemy Randomizer" + option_disabled = 0 + option_shuffled = 1 + option_randomized = 2 + default = 0 + + +class EnemyGroups(DefaultOnToggle): + """Randomized enemies will chosen from sets of specific groups. + (Weak, normal, large, flying) + Has no effect if Enemy Randomizer is disabled.""" + display_name = "Enemy Groups" + + +class EnemyScaling(DefaultOnToggle): + """Randomized enemies will have their stats increased or decreased depending on the area they appear in. + Has no effect if Enemy Randomizer is disabled.""" + display_name = "Enemy Scaling" + + +class BlasphemousDeathLink(DeathLink): + """When you die, everyone dies. The reverse is also true. + Note that Guilt Fragments will not appear when killed by Death Link.""" + + +blasphemous_options = { + "prie_dieu_warp": PrieDieuWarp, + "skip_cutscenes": SkipCutscenes, + "corpse_hints": CorpseHints, + "difficulty": Difficulty, + "penitence": Penitence, + "expert_logic": ExpertLogic, + "ending": Ending, + "thorn_shuffle" : ThornShuffle, + "reliquary_shuffle": ReliquaryShuffle, + "cherub_shuffle" : CherubShuffle, + "life_shuffle" : LifeShuffle, + "fervour_shuffle" : FervourShuffle, + "sword_shuffle" : SwordShuffle, + "blessing_shuffle" : BlessingShuffle, + "dungeon_shuffle" : DungeonShuffle, + "tirso_shuffle" : TirsoShuffle, + "miriam_shuffle" : MiriamShuffle, + "redento_shuffle" : RedentoShuffle, + "jocinero_shuffle" : JocineroShuffle, + "altasgracias_shuffle" : AltasgraciasShuffle, + "tentudia_shuffle" : TentudiaShuffle, + "gemino_shuffle" : GeminoShuffle, + "guilt_shuffle" : GuiltShuffle, + "ossuary_shuffle" : OssuaryShuffle, + "boss_shuffle" : BossShuffle, + "wound_shuffle" : WoundShuffle, + "mask_shuffle" : MaskShuffle, + "eye_shuffle": EyeShuffle, + "herb_shuffle" : HerbShuffle, + "church_shuffle" : ChurchShuffle, + "shop_shuffle" : ShopShuffle, + "candle_shuffle" : CandleShuffle, + "start_wheel": StartWheel, + "skill_randomizer": SkillRando, + "enemy_randomizer": EnemyRando, + "enemy_groups": EnemyGroups, + "enemy_scaling": EnemyScaling, + "death_link": BlasphemousDeathLink +} \ No newline at end of file diff --git a/worlds/blasphemous/Rules.py b/worlds/blasphemous/Rules.py new file mode 100644 index 0000000000..6bf4a6858d --- /dev/null +++ b/worlds/blasphemous/Rules.py @@ -0,0 +1,1455 @@ +from worlds.generic.Rules import set_rule, add_rule +from ..AutoWorld import LogicMixin + + +class BlasphemousLogic(LogicMixin): + def _blasphemous_blood_relic(self, player): + return self.has("Blood Perpetuated in Sand", player) + + def _blasphemous_water_relic(self, player): + return self.has("Nail Uprooted from Dirt", player) + + def _blasphemous_corpse_relic(self, player): + return self.has("Shroud of Dreamt Sins", player) + + def _blasphemous_fall_relic(self, player): + return self.has("Linen of Golden Thread", player) + + def _blasphemous_miasma_relic(self, player): + return self.has("Silvered Lung of Dolphos", player) + + def _blasphemous_root_relic(self, player): + return self.has("Three Gnarled Tongues", player) + + def _blasphemous_open_holes(self, player): + return self.has_any({"Dive Skill", "Charged Skill"}, player) or \ + self.has_group("prayer", player, 1) or \ + (self.has_any({"Tirana of the Celestial Bastion", "Aubade of the Nameless Guardian"}, player) and \ + self.has("Fervour Upgrade", player, 2)) + + def _blasphemous_bell(self, player): + return self.has("Petrified Bell", player) + + def _blasphemous_bead(self, player): + return self.has("Weight of True Guilt", player) + + def _blasphemous_cloth(self, player): + return self.has("Linen Cloth", player) + + def _blasphemous_pre_egg(self, player): + return self.has("Egg of Deformity", player) + + def _blasphemous_egg(self, player): + return self.has("Hatched Egg of Deformity", player) + + def _blasphemous_hand(self, player): + return self.has("Severed Hand", player) + + def _blasphemous_chalice(self, player): + return self.has("Chalice of Inverted Verses", player) + + def _blasphemous_thimble(self, player): + return self.has("Empty Golden Thimble", player) + + def _blasphemous_full_thimble(self, player): + return self.has("Golden Thimble Filled with Burning Oil", player) + + def _blasphemous_flowers(self, player): + return self.has("Dried Flowers bathed in Tears", player) + + def _blasphemous_redento(self, player): + return self.has_all({"Little Toe made of Limestone", "Big Toe made of Limestone", \ + "Fourth Toe made of Limestone"}, player) and \ + self.has("Knot of Rosary Rope", player) + + def _blasphemous_cord(self, player): + return self.has("Cord of the True Burying", player) + + def _blasphemous_marks(self, player): + return self.has_all({"Mark of the First Refuge", "Mark of the Second Refuge", \ + "Mark of the Third Refuge"}, player) + + def _blasphemous_red_wax(self, player): + return self.has("Bead of Red Wax", player) + + def _blasphemous_blue_wax(self, player): + return self.has("Bead of Blue Wax", player) + + def _blasphemous_both_wax(self, player): + return self.has("Bead of Red Wax", player, 3) and \ + self.has("Bead of Blue Wax", player, 3) + + def _blasphemous_elder_key(self, player): + return self.has("Key to the Chamber of the Eldest Brother", player) + + def _blasphemous_bronze_key(self, player): + return self.has("Key of the Secular", player) + + def _blasphemous_silver_key(self, player): + return self.has("Key of the Scribe", player) + + def _blasphemous_gold_key(self, player): + return self.has("Key of the Inquisitor", player) + + def _blasphemous_high_key(self, player): + return self.has("Key of the High Peaks", player) + + def _blasphemous_wood_key(self, player): + return self.has("Key Grown from Twisted Wood", player) + + def _blasphemous_scapular(self, player): + return self.has("Incomplete Scapular", player) + + def _blasphemous_heart_c(self, player): + return self.has("Apodictic Heart of Mea Culpa", player) + + def _blasphemous_eyes(self, player): + return self.has("Severed Right Eye of the Traitor", player) and \ + self.has("Broken Left Eye of the Traitor", player) + + def _blasphemous_debla(self, player): + return self.has("Debla of the Lights", player) + + def _blasphemous_taranto(self, player): + return self.has("Taranto to my Sister", player) + + def _blasphemous_tirana(self, player): + return self.has("Tirana of the Celestial Bastion", player) + + def _blasphemous_aubade(self, player): + return self.has("Aubade of the Nameless Guardian", player) + + def _blasphemous_cherub_6(self, player): + return self.has_any({"Debla of the Lights", "Taranto to my Sister", "Verdiales of the Forsaken Hamlet", \ + "Tirana of the Celestial Bastion", "Cloistered Ruby"}, player) + + def _blasphemous_cherub_13(self, player): + return self.has_any({"Ranged Skill", "Debla of the Lights", "Taranto to my Sister", \ + "Cante Jondo of the Three Sisters", "Aubade of the Nameless Guardian", "Tirana of the Celestial Bastion", \ + "Cloistered Ruby"}, player) + + def _blasphemous_cherub_20(self, player): + return self.has_any({"Debla of the Lights", "Lorqiana", "Zarabanda of the Safe Haven", "Taranto to my Sister", \ + "Cante Jondo of the Three Sisters", "Aubade of the Nameless Guardian", "Tirana of the Celestial Bastion", \ + "Cloistered Ruby"}, player) + + def _blasphemous_cherub_21(self, player): + return self.has_any({"Debla of the Lights", "Taranto to my Sister", "Cante Jondo of the Three Sisters", \ + "Verdiales of the Forsaken Hamlet", "Tirana of the Celestial Bastion", "Cloistered Ruby"}, player) + + def _blasphemous_cherub_22_23_31_32(self, player): + return self.has_any({"Debla of the Lights", "Taranto to my Sister", "Cloistered Ruby"}, player) + + def _blasphemous_cherub_24_33(self, player): + return self.has_any({"Debla of the Lights", "Taranto to my Sister", "Cante Jondo of the Three Sisters", \ + "Tirana of the Celestial Bastion", "Cloistered Ruby"}, player) + + def _blasphemous_cherub_25(self, player): + return self.has_any({"Debla of the Lights", "Lorquiana", "Taranto to my Sister", \ + "Cante Jondo of the Three Sisters", "Verdiales of the Forsaken Hamlet", "Aubade of the Nameless Guardian", \ + "Cantina of the Blue Rose", "Cloistered Ruby"}, player) + + def _blasphemous_cherub_27(self, player): + return self.has_any({"Ranged Skill", "Debla of the Lights", "Lorquiana", "Taranto to my Sister", \ + "Cante Jondo of the Three Sisters", "Aubade of the Nameless Guardian", "Cantina of the Blue Rose", \ + "Cloistered Ruby"}, player) + + def _blasphemous_cherub_38(self, player): + return self.has_any({"Ranged Skill", "Lorquiana", "Cante Jondo of the Three Sisters", \ + "Aubade of the Nameless Guardian", "Cantina of the Blue Rose", "Cloistered Ruby"}, player) or \ + (self.has("The Young Mason's Wheel", player) and \ + self.has("Brilliant Heart of Dawn", player)) + + def _blasphemous_wheel(self, player): + return self.has("The Young Mason's Wheel", player) + + def _blasphemous_dawn_heart(self, player): + return self.has("Brilliant Heart of Dawn", player) + + def _blasphemous_tirso_1(self, player): + return self.has_group("tirso", player, 1) + + def _blasphemous_tirso_2(self, player): + return self.has_group("tirso", player, 2) + + def _blasphemous_tirso_3(self, player): + return self.has_group("tirso", player, 3) + + def _blasphemous_tirso_4(self, player): + return self.has_group("tirso", player, 4) + + def _blasphemous_tirso_5(self, player): + return self.has_group("tirso", player, 5) + + def _blasphemous_tirso_6(self, player): + return self.has_group("tirso", player, 6) + + def _blasphemous_tentudia_1(self, player): + return self.has_group("tentudia", player, 1) + + def _blasphemous_tentudia_2(self, player): + return self.has_group("tentudia", player, 2) + + def _blasphemous_tentudia_3(self, player): + return self.has_group("tentudia", player, 3) + + def _blasphemous_altasgracias_3(self, player): + return self.has_group("egg", player, 3) + + def _blasphemous_cherubs_20(self, player): + return self.has("Child of Moonlight", player, 20) + + def _blasphemous_cherubs_all(self, player): + return self.has("Child of Moonlight", player, 38) + + def _blasphemous_bones_4(self, player): + return self.has_group("bones", player, 4) + + def _blasphemous_bones_8(self, player): + return self.has_group("bones", player, 8) + + def _blasphemous_bones_12(self, player): + return self.has_group("bones", player, 12) + + def _blasphemous_bones_16(self, player): + return self.has_group("bones", player, 16) + + def _blasphemous_bones_20(self, player): + return self.has_group("bones", player, 20) + + def _blasphemous_bones_24(self, player): + return self.has_group("bones", player, 24) + + def _blasphemous_bones_28(self, player): + return self.has_group("bones", player, 28) + + def _blasphemous_bones_30(self, player): + return self.has_group("bones", player, 30) + + def _blasphemous_bones_32(self, player): + return self.has_group("bones", player, 32) + + def _blasphemous_bones_36(self, player): + return self.has_group("bones", player, 36) + + def _blasphemous_bones_40(self, player): + return self.has_group("bones", player, 40) + + def _blasphemous_bones_44(self, player): + return self.has_group("bones", player, 44) + + def _blasphemous_sword_1(self, player): + return self.has("Mea Culpa Upgrade", player) + + def _blasphemous_sword_2(self, player): + return self.has("Mea Culpa Upgrade", player, 2) + + def _blasphemous_sword_3(self, player): + return self.has("Mea Culpa Upgrade", player, 3) + + def _blasphemous_sword_4(self, player): + return self.has("Mea Culpa Upgrade", player, 4) + + def _blasphemous_sword_5(self, player): + return self.has("Mea Culpa Upgrade", player, 5) + + def _blasphemous_sword_6(self, player): + return self.has("Mea Culpa Upgrade", player, 6) + + def _blasphemous_sword_7(self, player): + return self.has("Mea Culpa Upgrade", player, 7) + + def _blasphemous_ranged(self, player): + return self.has("Ranged Skill", player) + + def _blasphemous_bridge_access(self, player): + return self.has_group("wounds", player, 3) + + def _blasphemous_ex_bridge_access(self, player): + return self.has_group("wounds", player, 3) or \ + (self.has("Brilliant Heart of Dawn", player) and \ + self.has("Ranged Skill", player) and \ + self.has("Blood Perpetuated in Sand", player)) or \ + (self.has("Blood Perpetuated in Sand", player) and \ + self.has("Tirana of the Celestial Bastion", player) and \ + self.has("Fervour Upgrade", player, 2)) + + def _blasphemous_1_mask(self, player): + return self.has_group("masks", player, 1) + + def _blasphemous_2_masks(self, player): + return self.has_group("masks", player, 2) + + def _blasphemous_3_masks(self, player): + return self.has_group("masks", player, 3) + + def _blasphemous_laudes_gate(self, player): + return self.has_all({"Petrified Bell", "Blood Perpetuated in Sand", "Three Gnarled Tongues", "Key of the Secular", "Key of the Scribe", "Verses Spun from Gold"}, player) + + # Ten Piedad, Tres Angustias, Our Lady of the Charred Visage + def _blasphemous_wound_boss_easy(self, player): + return self.has("Mea Culpa Upgrade", player, 2) and \ + self.has_group("power", player, 3) + + def _blasphemous_wound_boss_normal(self, player): + return self.has("Mea Culpa Upgrade", player, 1) + + def _blasphemous_wound_boss_hard(self, player): + return True + + # Esdras + def _blasphemous_esdras_boss_easy(self, player): + return self.has("Mea Culpa Upgrade", player, 3) and \ + self.has_group("power", player, 5) + + def _blasphemous_esdras_boss_normal(self, player): + return self.has("Mea Culpa Upgrade", player, 2) and \ + self.has_group("power", player, 2) + + def _blasphemous_esdras_boss_hard(self, player): + return self.has("Mea Culpa Upgrade", player, 1) and \ + self.has_group("power", player, 1) + + # Melquiades, Exposito, Quirce + def _blasphemous_mask_boss_easy(self, player): + return self.has("Mea Culpa Upgrade", player, 4) and \ + self.has_group("power", player, 8) + + def _blasphemous_mask_boss_normal(self, player): + return self.has("Mea Culpa Upgrade", player, 3) and \ + self.has_group("power", player, 4) + + def _blasphemous_mask_boss_hard(self, player): + return self.has("Mea Culpa Upgrade", player, 2) and \ + self.has_group("power", player, 2) + + # Crisanta, Isidora, Sierpes, Amanecidas, Laudes + def _blasphemous_endgame_boss_easy(self, player): + return self.has("Mea Culpa Upgrade", player, 6) and \ + self.has_group("power", player, 16) + + def _blasphemous_endgame_boss_normal(self, player): + return self.has("Mea Culpa Upgrade", player, 5) and \ + self.has_group("power", player, 8) + + def _blasphemous_endgame_boss_hard(self, player): + return self.has("Mea Culpa Upgrade", player, 4) and \ + self.has_group("power", player, 5) + + +def rules(blasphemousworld): + world = blasphemousworld.multiworld + player = blasphemousworld.player + + # entrances + for i in world.get_region("Deambulatory of His Holiness", player).entrances: + set_rule(i, lambda state: state._blasphemous_3_masks(player)) + for i in world.get_region("Ferrous Tree", player).entrances: + set_rule(i, lambda state: state._blasphemous_bridge_access(player)) + for i in world.get_region("Mother of Mothers", player).entrances: + set_rule(i, lambda state: state._blasphemous_bridge_access(player)) + for i in world.get_region("Mourning and Havoc", player).entrances: + set_rule(i, lambda state: state._blasphemous_blood_relic(player) or \ + state.can_reach(world.get_region("Mother of Mothers", player), player)) + for i in world.get_region("Patio of the Silent Steps", player).entrances: + set_rule(i, lambda state: state._blasphemous_bridge_access(player)) + for i in world.get_region("The Resting Place of the Sister", player).entrances: + set_rule(i, lambda state: state._blasphemous_blood_relic(player)) + for i in world.get_region("The Sleeping Canvases", player).entrances: + set_rule(i, lambda state: state._blasphemous_bridge_access(player)) + for i in world.get_region("Wall of the Holy Prohibitions", player).entrances: + set_rule(i, lambda state: state._blasphemous_1_mask(player) and \ + state._blasphemous_bridge_access(player)) + + # Albero + set_rule(world.get_location("Albero: Bless Linen Cloth", player), + lambda state: state._blasphemous_cloth(player)) + set_rule(world.get_location("Albero: Bless Hatched Egg", player), + lambda state: state._blasphemous_egg(player)) + set_rule(world.get_location("Albero: Bless Severed Hand", player), + lambda state: state._blasphemous_hand(player)) + set_rule(world.get_location("Albero: First gift for Cleofas", player), + lambda state: state.can_reach(world.get_region("Mother of Mothers", player))) + set_rule(world.get_location("Albero: Final gift for Cleofas", player), + lambda state: state.can_reach(world.get_region("Mother of Mothers", player)) and \ + state._blasphemous_marks(player) and \ + state._blasphemous_cord(player)) + set_rule(world.get_location("Albero: Tirso's 1st reward", player), + lambda state: state._blasphemous_tirso_1(player)) + set_rule(world.get_location("Albero: Tirso's 2nd reward", player), + lambda state: state._blasphemous_tirso_2(player)) + set_rule(world.get_location("Albero: Tirso's 3rd reward", player), + lambda state: state._blasphemous_tirso_3(player)) + set_rule(world.get_location("Albero: Tirso's 4th reward", player), + lambda state: state._blasphemous_tirso_4(player)) + set_rule(world.get_location("Albero: Tirso's 5th reward", player), + lambda state: state._blasphemous_tirso_5(player)) + set_rule(world.get_location("Albero: Tirso's 6th reward", player), + lambda state: state._blasphemous_tirso_6(player)) + set_rule(world.get_location("Albero: Tirso's final reward", player), + lambda state: state._blasphemous_tirso_6(player) and \ + state.can_reach(world.get_region("Wall of the Holy Prohibitions", player)) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("Albero: Lvdovico's 1st reward", player), + lambda state: state._blasphemous_tentudia_1(player)) + set_rule(world.get_location("Albero: Lvdovico's 2nd reward", player), + lambda state: state._blasphemous_tentudia_2(player)) + set_rule(world.get_location("Albero: Lvdovico's 3rd reward", player), + lambda state: state._blasphemous_tentudia_3(player)) + set_rule(world.get_location("Ossuary: Isidora, Voice of the Dead", player), + lambda state: state._blasphemous_bones_30(player)) + set_rule(world.get_location("Ossuary: 1st reward", player), + lambda state: state._blasphemous_bones_4(player)) + set_rule(world.get_location("Ossuary: 2nd reward", player), + lambda state: state._blasphemous_bones_8(player)) + set_rule(world.get_location("Ossuary: 3rd reward", player), + lambda state: state._blasphemous_bones_12(player)) + set_rule(world.get_location("Ossuary: 4th reward", player), + lambda state: state._blasphemous_bones_16(player)) + set_rule(world.get_location("Ossuary: 5th reward", player), + lambda state: state._blasphemous_bones_20(player)) + set_rule(world.get_location("Ossuary: 6th reward", player), + lambda state: state._blasphemous_bones_24(player)) + set_rule(world.get_location("Ossuary: 7th reward", player), + lambda state: state._blasphemous_bones_28(player)) + set_rule(world.get_location("Ossuary: 8th reward", player), + lambda state: state._blasphemous_bones_32(player)) + set_rule(world.get_location("Ossuary: 9th reward", player), + lambda state: state._blasphemous_bones_36(player)) + set_rule(world.get_location("Ossuary: 10th reward", player), + lambda state: state._blasphemous_bones_40(player)) + set_rule(world.get_location("Ossuary: 11th reward", player), + lambda state: state._blasphemous_bones_44(player)) + + # All the Tears of the Sea + set_rule(world.get_location("AtTotS: Miriam's gift", player), + lambda state: state._blasphemous_2_masks(player) and \ + state._blasphemous_fall_relic(player) and \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_miasma_relic(player)) + + # Archcathedral Rooftops + set_rule(world.get_location("AR: Second soldier fight", player), + lambda state: state._blasphemous_1_mask(player)) + set_rule(world.get_location("AR: Third soldier fight", player), + lambda state: state._blasphemous_2_masks(player)) + set_rule(world.get_location("AR: Upper west shaft Child of Moonlight", player), + lambda state: state._blasphemous_1_mask(player)) + set_rule(world.get_location("AR: Upper west shaft chest", player), + lambda state: state._blasphemous_2_masks(player) and \ + state._blasphemous_fall_relic(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("AR: Lady of the Six Sorrows", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("AR: Upper east shaft ledge", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_1_mask(player)) + set_rule(world.get_location("AR: Mea Culpa altar", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_2_masks(player)) + set_rule(world.get_location("AR: Crisanta of the Wrapped Agony", player), + lambda state: state._blasphemous_3_masks(player)) + + # Bridge of the Three Cavalries + set_rule(world.get_location("BotTC: Esdras, of the Anointed Legion", player), + lambda state: state._blasphemous_bridge_access(player)) + set_rule(world.get_location("BotTC: Esdras' gift", player), + lambda state: state._blasphemous_bridge_access(player)) + set_rule(world.get_location("BotTC: Inside giant statue", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_laudes_gate(player) and \ + state._blasphemous_1_mask(player)) + + # Brotherhood of the Silent Sorrow + set_rule(world.get_location("BotSS: Starting room Child of Moonlight", player), + lambda state: (state._blasphemous_blood_relic(player) and \ + (state._blasphemous_root_relic(player)) or \ + (state._blasphemous_fall_relic(player))) or \ + (state._blasphemous_blood_relic(player) and \ + state._blasphemous_cherub_6(player)) or \ + (state._blasphemous_debla(player) or \ + state._blasphemous_taranto(player))) + set_rule(world.get_location("BotSS: Starting room ledge", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_fall_relic(player)) + set_rule(world.get_location("BotSS: Chamber of the Eldest Brother", player), + lambda state: state._blasphemous_elder_key(player)) + set_rule(world.get_location("BotSS: Blue candle", player), + lambda state: state._blasphemous_blue_wax(player)) + set_rule(world.get_location("BotSS: Outside church", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("BotSS: Esdras' final gift", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_scapular(player) and \ + state._blasphemous_bridge_access(player)) + set_rule(world.get_location("BotSS: Crisanta's gift", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_scapular(player) and \ + state._blasphemous_heart_c(player) and \ + state._blasphemous_3_masks(player) and \ + state._blasphemous_bridge_access(player)) + + # Convent of our Lady of the Charred Visage + set_rule(world.get_location("CoOLotCV: Lower west statue", player), + lambda state: state._blasphemous_miasma_relic(player)) + set_rule(world.get_location("CoOLotCV: Lady of the Six Sorrows", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_high_key(player)) + set_rule(world.get_location("CoOLotCV: Red candle", player), + lambda state: state._blasphemous_red_wax(player)) + set_rule(world.get_location("CoOLotCV: Fountain of burning oil", player), + lambda state: state._blasphemous_thimble(player)) + set_rule(world.get_location("CoOLotCV: Mask room", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_high_key(player)) + + # Desecrated Cistern + set_rule(world.get_location("DC: Upper east tunnel chest", player), + lambda state: state._blasphemous_water_relic(player) or \ + state._blasphemous_fall_relic(player)) + set_rule(world.get_location("DC: Upper east Child of Moonlight", player), + lambda state: state._blasphemous_water_relic(player) or \ + state._blasphemous_fall_relic(player) or \ + state._blasphemous_cherub_13(player)) + set_rule(world.get_location("DC: Hidden alcove near fountain", player), + lambda state: state._blasphemous_water_relic(player)) + set_rule(world.get_location("DC: Shroud puzzle", player), + lambda state: state._blasphemous_corpse_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player)) + set_rule(world.get_location("DC: Chalice room", player), + lambda state: (state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + state._blasphemous_root_relic(player)) or \ + (state._blasphemous_fall_relic(player) and \ + state._blasphemous_root_relic(player))) + set_rule(world.get_location("DC: Mea Culpa altar", player), + lambda state: state._blasphemous_chalice(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("DC: Child of Moonlight, behind pillar", player), + lambda state: state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player)) + set_rule(world.get_location("DC: High ledge near elevator shaft", player), + lambda state: state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player)) + set_rule(world.get_location("DC: Elevator shaft Child of Moonlight", player), + lambda state: state._blasphemous_fall_relic(player) or \ + (state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + state._blasphemous_cherub_22_23_31_32(player))) + set_rule(world.get_location("DC: Elevator shaft ledge", player), + lambda state: state._blasphemous_fall_relic(player)) + + # Graveyard of the Peaks + set_rule(world.get_location("GotP: Shop cave Child of Moonlight", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_fall_relic(player) or \ + state._blasphemous_cherub_22_23_31_32(player)) + # to do: or dive + set_rule(world.get_location("GotP: Shop cave hidden hole", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_open_holes(player)) + set_rule(world.get_location("GotP: Upper east shaft", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("GotP: East cliffside", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("GotP: West shaft Child of Moonlight", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_cherub_25(player)) + set_rule(world.get_location("GotP: Center shaft Child of Moonlight", player), + lambda state: state._blasphemous_fall_relic(player) or \ + state._blasphemous_cherub_24_33(player)) + # to do: requires dive + set_rule(world.get_location("GotP: Amanecida of the Bejeweled Arrow", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_open_holes(player)) + + # Grievance Ascends + set_rule(world.get_location("GA: Lower west ledge", player), + lambda state: state._blasphemous_miasma_relic(player)) + set_rule(world.get_location("GA: Miasma room floor", player), + lambda state: state._blasphemous_miasma_relic(player)) + set_rule(world.get_location("GA: Oil of the Pilgrims", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("GA: End of blood bridge", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("GA: Blood bridge Child of Moonlight", player), + lambda state: state._blasphemous_blood_relic(player) and \ + ((state._blasphemous_aubade(player) and \ + state._blasphemous_ranged(player)) or \ + state._blasphemous_cherub_21(player))) + set_rule(world.get_location("GA: Lower east Child of Moonlight", player), + lambda state: state._blasphemous_root_relic(player) or \ + state._blasphemous_cherub_20(player)) + set_rule(world.get_location("GA: Altasgracias' gift", player), + lambda state: state._blasphemous_altasgracias_3(player)) + set_rule(world.get_location("GA: Empty giant egg", player), + lambda state: state._blasphemous_altasgracias_3(player) and \ + state._blasphemous_egg(player)) + + # Hall of the Dawning + set_rule(world.get_location("HotD: Laudes, the First of the Amanecidas", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_laudes_gate(player)) + + # Jondo + set_rule(world.get_location("Jondo: Upper east chest", player), + lambda state: state._blasphemous_fall_relic(player) or \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("Jondo: Upper west tree root", player), + lambda state: state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player)) + + # Knot of the Three Words + set_rule(world.get_location("KotTW: Gift from the Traitor", player), + lambda state: state._blasphemous_wood_key(player) and \ + state._blasphemous_eyes(player)) + + # Library of the Negated Words + set_rule(world.get_location("LotNW: Root ceiling platform", player), + lambda state: state._blasphemous_root_relic(player)) + # to do: requires dive (sometimes opens with other skills?) + set_rule(world.get_location("LotNW: Hidden floor", player), + lambda state: state._blasphemous_open_holes(player)) + set_rule(world.get_location("LotNW: Miasma hallway chest", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_miasma_relic(player)) + set_rule(world.get_location("LotNW: Platform puzzle chest", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("LotNW: Elevator Child of Moonlight", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("LotNW: Red candle", player), + lambda state: state._blasphemous_red_wax(player)) + set_rule(world.get_location("LotNW: Twisted wood hidden wall", player), + lambda state: state._blasphemous_wood_key(player)) + + # Mercy Dreams + set_rule(world.get_location("MD: Blue candle", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_blue_wax(player)) + set_rule(world.get_location("MD: Cave Child of Moonlight", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_cherub_24_33(player)) + set_rule(world.get_location("MD: Behind gate to TSC", player), + lambda state: state._blasphemous_bridge_access(player)) + + # Mother of Mothers + set_rule(world.get_location("MoM: East chandelier platform", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_dawn_heart(player)) + set_rule(world.get_location("MoM: Redento's treasure", player), + lambda state: state._blasphemous_redento(player)) + set_rule(world.get_location("MoM: Final meeting with Redento", player), + lambda state: state._blasphemous_redento(player)) + set_rule(world.get_location("MoM: Giant chandelier statue", player), + lambda state: state._blasphemous_blood_relic(player)) + + # Mountains of the Endless Dusk + set_rule(world.get_location("MotED: Platform above chasm", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("MotED: Blood platform alcove", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("MotED: Egg hatching", player), + lambda state: state._blasphemous_pre_egg(player)) + # to do: requires dive + set_rule(world.get_location("MotED: Amanecida of the Golden Blades", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player)) + + # Mourning and Havoc + set_rule(world.get_location("MaH: Upper east chest", player), + lambda state: state._blasphemous_bridge_access(player) and \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("MaH: Sierpes' eye", player), + lambda state: state._blasphemous_bridge_access(player) and \ + (state._blasphemous_root_relic(player)) or \ + state._blasphemous_water_relic(player) or \ + state._blasphemous_dawn_heart(player)) + set_rule(world.get_location("MaH: Sierpes", player), + lambda state: state._blasphemous_bridge_access(player) and \ + (state._blasphemous_root_relic(player)) or \ + state._blasphemous_water_relic(player) or \ + state._blasphemous_dawn_heart(player)) + + # Patio of the Silent Steps + set_rule(world.get_location("PotSS: Second area ledge", player), + lambda state: state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("PotSS: Third area upper ledge", player), + lambda state: state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player)) + set_rule(world.get_location("PotSS: Climb to WotHP", player), + lambda state: (state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player)) or \ + (state.can_reach(world.get_region("Wall of the Holy Prohibitions", player)) and \ + state._blasphemous_bronze_key(player))) + # to do: requires dive + set_rule(world.get_location("PotSS: Amanecida of the Chiselled Steel", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player)) + + # Petrous + # to do: requires dive + set_rule(world.get_location("Petrous: Temple entrance", player), + lambda state: state._blasphemous_open_holes(player)) + + # The Sleeping Canvases + set_rule(world.get_location("TSC: Candle wax puzzle", player), + lambda state: state._blasphemous_both_wax(player)) + set_rule(world.get_location("TSC: Under elevator shaft", player), + lambda state: state._blasphemous_fall_relic(player)) + set_rule(world.get_location("TSC: Jocinero's 1st reward", player), + lambda state: state._blasphemous_cherubs_20(player)) + set_rule(world.get_location("TSC: Jocinero's final reward", player), + lambda state: state._blasphemous_cherubs_all(player)) + + # The Holy Line + set_rule(world.get_location("THL: Across blood platforms", player), + lambda state: state._blasphemous_blood_relic(player)) + set_rule(world.get_location("THL: Underground chest", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_water_relic(player)) + + # Wall of the Holy Prohibitions + set_rule(world.get_location("WotHP: Upper east room, top bronze cell", player), + lambda state: state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Upper east room, top silver cell", player), + lambda state: state._blasphemous_silver_key(player)) + set_rule(world.get_location("WotHP: Upper east room, center gold cell", player), + lambda state: state._blasphemous_gold_key(player)) + set_rule(world.get_location("WotHP: Upper west room, center gold cell", player), + lambda state: state._blasphemous_gold_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Lower west room, bottom gold cell", player), + lambda state: state._blasphemous_gold_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Upper west room, top silver cell", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Lower west room, top ledge", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Lower east room, hidden ledge", player), + lambda state: state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Lower east room, bottom silver cell", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Lower east room, top bronze cell", player), + lambda state: state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Lower east room, top silver cell", player), + lambda state: state._blasphemous_silver_key(player)) + set_rule(world.get_location("WotHP: Outside Child of Moonlight", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Oil of the Pilgrims", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Quirce, Returned By The Flames", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Collapsing floor ledge", player), + lambda state: state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + set_rule(world.get_location("WotHP: Amanecida of the Molten Thorn", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player)) + + # Wasteland of the Buried Churches + set_rule(world.get_location("WotBC: Under broken bridge", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_dawn_heart(player)) + set_rule(world.get_location("WotBC: Cliffside Child of Moonlight", player), + lambda state: state._blasphemous_cherub_38(player)) + + # Where Olive Trees Wither + set_rule(world.get_location("WOTW: Gift for the tomb", player), + lambda state: state._blasphemous_full_thimble(player)) + set_rule(world.get_location("WOTW: Underground tomb", player), + lambda state: state._blasphemous_flowers(player) and \ + (state._blasphemous_full_thimble(player) or \ + state._blasphemous_fall_relic(player))) + set_rule(world.get_location("WOTW: Underground Child of Moonlight", player), + lambda state: (state._blasphemous_full_thimble(player) or \ + state._blasphemous_fall_relic(player)) and \ + state._blasphemous_cherub_27(player)) + set_rule(world.get_location("WOTW: Underground ledge", player), + lambda state: (state._blasphemous_full_thimble(player) or \ + state._blasphemous_fall_relic(player)) and \ + state._blasphemous_blood_relic(player)) + set_rule(world.get_location("WOTW: Upper east Child of Moonlight", player), + lambda state: state._blasphemous_root_relic(player) or \ + state._blasphemous_cherub_22_23_31_32(player)) + set_rule(world.get_location("WOTW: Upper east statue", player), + lambda state: state._blasphemous_root_relic(player)) + set_rule(world.get_location("WOTW: Gemino's reward", player), + lambda state: state._blasphemous_full_thimble(player)) + + # Various + set_rule(world.get_location("Confessor Dungeon 1 extra", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 1 main", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 2 extra", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 2 main", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 3 extra", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 3 main", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 4 extra", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 4 main", player), + lambda state: state._blasphemous_bead(player)) + set_rule(world.get_location("Confessor Dungeon 5 extra", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Confessor Dungeon 5 main", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Confessor Dungeon 6 extra", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_bridge_access(player) and \ + (state._blasphemous_1_mask(player) or \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player))) + set_rule(world.get_location("Confessor Dungeon 6 main", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_bridge_access(player) and \ + (state._blasphemous_1_mask(player) or \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player))) + set_rule(world.get_location("Confessor Dungeon 7 extra", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_blood_relic(player)) + set_rule(world.get_location("Confessor Dungeon 7 main", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_blood_relic(player)) + # to do: requires dive + set_rule(world.get_location("Defeat 1 Amanecida", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player)) + set_rule(world.get_location("Defeat 2 Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_blood_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_bridge_access(player))) + set_rule(world.get_location("Defeat 3 Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_blood_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + (state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player)))) + set_rule(world.get_location("Defeat 4 Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player)) + set_rule(world.get_location("Defeat all Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player)) + + # expert logic + if world.expert_logic[player]: + # entrances + for i in world.get_region("Ferrous Tree", player).entrances: + set_rule(i, lambda state: state._blasphemous_ex_bridge_access(player)) + for i in world.get_region("Mother of Mothers", player).entrances: + set_rule(i, lambda state: state._blasphemous_ex_bridge_access(player)) + for i in world.get_region("Patio of the Silent Steps", player).entrances: + set_rule(i, lambda state: state._blasphemous_ex_bridge_access(player)) + for i in world.get_region("The Sleeping Canvases", player).entrances: + set_rule(i, lambda state: state._blasphemous_ex_bridge_access(player)) + for i in world.get_region("Wall of the Holy Prohibitions", player).entrances: + set_rule(i, lambda state: state._blasphemous_1_mask(player) and \ + state._blasphemous_ex_bridge_access(player)) + + # locations + set_rule(world.get_location("AR: Upper west shaft chest", player), + lambda state: state._blasphemous_2_masks(player) and \ + state._blasphemous_fall_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("BotTC: Esdras, of the Anointed Legion", player), + lambda state: state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("BotTC: Esdras' gift", player), + lambda state: state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("BotTC: Inside giant statue", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_laudes_gate(player) and \ + state._blasphemous_1_mask(player)) + set_rule(world.get_location("BotSS: Esdras' final gift", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_scapular(player) and \ + state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("BotSS: Crisanta's gift", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_scapular(player) and \ + state._blasphemous_heart_c(player) and \ + state._blasphemous_3_masks(player) and \ + state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("CoOLotCV: Lady of the Six Sorrows", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_high_key(player)) + set_rule(world.get_location("CoOLotCV: Mask room", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_high_key(player)) + set_rule(world.get_location("DC: Chalice room", player), + lambda state: (state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player)))) or \ + (state._blasphemous_fall_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_ranged(player)))) + set_rule(world.get_location("DC: Mea Culpa altar", player), + lambda state: state._blasphemous_chalice(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + (state._blasphemous_fall_relic(player) and \ + (state._blasphemous_ranged(player) or \ + state._blasphemous_root_relic(player))) or \ + (state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))))) + set_rule(world.get_location("HotD: Laudes, the First of the Amanecidas", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_laudes_gate(player)) + set_rule(world.get_location("LotNW: Elevator Child of Moonlight", player), + lambda state: state._blasphemous_blood_relic(player) and \ + (state._blasphemous_cherub_22_23_31_32(player) and \ + state._blasphemous_dawn_heart(player) and \ + state._blasphemous_ranged(player)) or \ + state._blasphemous_root_relic(player)) + set_rule(world.get_location("MD: Cave Child of Moonlight", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_cherub_24_33(player)) + set_rule(world.get_location("MD: Behind gate to TSC", player), + lambda state: state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("MoM: East chandelier platform", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("MaH: Upper east chest", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + (state._blasphemous_root_relic(player)) or \ + (state._blasphemous_dawn_heart(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("MaH: Sierpes' eye", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + (state._blasphemous_root_relic(player)) or \ + state._blasphemous_dawn_heart(player) or \ + state._blasphemous_water_relic(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("MaH: Sierpes", player), + lambda state: state._blasphemous_ex_bridge_access(player) and \ + (state._blasphemous_root_relic(player)) or \ + state._blasphemous_dawn_heart(player) or \ + state._blasphemous_water_relic(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("PotSS: Third area upper ledge", player), + lambda state: state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("WotBC: Under broken bridge", player), + lambda state: state._blasphemous_blood_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))) + set_rule(world.get_location("Confessor Dungeon 5 extra", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Confessor Dungeon 5 main", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Confessor Dungeon 6 extra", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + (state._blasphemous_1_mask(player) or \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player))) + set_rule(world.get_location("Confessor Dungeon 6 main", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + (state._blasphemous_1_mask(player) or \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_bronze_key(player))) + set_rule(world.get_location("Confessor Dungeon 7 extra", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_blood_relic(player)) + set_rule(world.get_location("Confessor Dungeon 7 main", player), + lambda state: state._blasphemous_bead(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player) and \ + state._blasphemous_blood_relic(player)) + set_rule(world.get_location("Defeat 2 Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_blood_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_ex_bridge_access(player))) + set_rule(world.get_location("Defeat 3 Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_blood_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + (state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player)))) + set_rule(world.get_location("Defeat 4 Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player)) + set_rule(world.get_location("Defeat all Amanecidas", player), + lambda state: state._blasphemous_bell(player) and \ + state._blasphemous_open_holes(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_blood_relic(player) and \ + state._blasphemous_root_relic(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_silver_key(player)) + + # skill rando + if world.skill_randomizer[player] and not world.expert_logic[player]: + set_rule(world.get_location("Skill 1, Tier 3", player), + lambda state: state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Skill 5, Tier 3", player), + lambda state: state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Skill 3, Tier 2", player), + lambda state: state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Skill 2, Tier 3", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_2_masks(player) and \ + state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Skill 4, Tier 3", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_2_masks(player) and \ + state._blasphemous_bridge_access(player)) + set_rule(world.get_location("Skill 3, Tier 3", player), + lambda state: state._blasphemous_chalice(player) and \ + state._blasphemous_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + state._blasphemous_root_relic(player)) + elif world.skill_randomizer[player] and world.expert_logic[player]: + set_rule(world.get_location("Skill 1, Tier 3", player), + lambda state: state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Skill 5, Tier 3", player), + lambda state: state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Skill 3, Tier 2", player), + lambda state: state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Skill 2, Tier 3", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_2_masks(player) and \ + state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Skill 4, Tier 3", player), + lambda state: state._blasphemous_blood_relic(player) and \ + state._blasphemous_miasma_relic(player) and \ + state._blasphemous_2_masks(player) and \ + state._blasphemous_ex_bridge_access(player)) + set_rule(world.get_location("Skill 3, Tier 3", player), + lambda state: state._blasphemous_chalice(player) and \ + state._blasphemous_ex_bridge_access(player) and \ + state._blasphemous_1_mask(player) and \ + state._blasphemous_bronze_key(player) and \ + (state._blasphemous_fall_relic(player) and \ + (state._blasphemous_ranged(player) or \ + state._blasphemous_root_relic(player))) or \ + (state._blasphemous_miasma_relic(player) and \ + state._blasphemous_water_relic(player) and \ + (state._blasphemous_root_relic(player) or \ + state._blasphemous_dawn_heart(player) or \ + (state._blasphemous_wheel(player) and \ + state._blasphemous_ranged(player))))) + + # difficulty (easy) + if world.difficulty[player].value == 0: + for i in world.get_region("Desecrated Cistern", player).entrances: + add_rule(i, lambda state: state._blasphemous_wound_boss_easy(player)) + for i in world.get_region("Ferrous Tree", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_easy(player)) + for i in world.get_region("Patio of the Silent Steps", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_easy(player)) + for i in world.get_region("The Sleeping Canvases", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_easy(player)) + for i in world.get_region("Deambulatory of His Holiness", player).entrances: + add_rule(i, lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("Albero: Donate 5000 Tears", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Albero: Donate 50000 Tears", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Albero: Tirso's final reward", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("Ossuary: Isidora, Voice of the Dead", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("AR: Crisanta of the Wrapped Agony", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("BotTC: Esdras, of the Anointed Legion", player), + lambda state: state._blasphemous_esdras_boss_easy(player)) + add_rule(world.get_location("BotTC: Esdras' gift", player), + lambda state: state._blasphemous_esdras_boss_easy(player)) + add_rule(world.get_location("BotTC: Inside giant statue", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("BotSS: Crisanta's gift", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("CoOLotCV: Lady of the Six Sorrows", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("CoOLotCV: Mask room", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("GotP: Amanecida of the Bejeweled Arrow", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("HotD: Laudes, the First of the Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("LotNW: Elevator Child of Moonlight", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("LotNW: Mask room", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("LotNW: Mea Culpa altar", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("LotNW: Red candle", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("MD: Blue candle", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("MD: Cave Child of Moonlight", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("MD: Behind gate to TSC", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("MoM: Melquiades, The Exhumed Archbishop", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("MoM: Mask room", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("MotED: Amanecida of the Golden Blades", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("MaH: Sierpes' eye", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("MaH: Sierpes", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("PotSS: Amanecida of the Chiselled Steel", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("TSC: Under elevator shaft", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("TSC: Exposito, Scion of Abjuration", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("WotHP: Quirce, Returned By The Flames", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("WotHP: Collapsing floor ledge", player), + lambda state: state._blasphemous_mask_boss_easy(player)) + add_rule(world.get_location("WotHP: Amanecida of the Molten Thorn", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 4 extra", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 4 main", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 5 extra", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 5 main", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 6 extra", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 6 main", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 7 extra", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Confessor Dungeon 7 main", player), + lambda state: state._blasphemous_wound_boss_easy(player)) + add_rule(world.get_location("Defeat 1 Amanecida", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("Defeat 2 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("Defeat 3 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("Defeat 4 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + add_rule(world.get_location("Defeat all Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_easy(player)) + + # difficulty (normal) + elif world.difficulty[player].value == 1: + for i in world.get_region("Desecrated Cistern", player).entrances: + add_rule(i, lambda state: state._blasphemous_wound_boss_normal(player)) + for i in world.get_region("Ferrous Tree", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_normal(player)) + for i in world.get_region("Patio of the Silent Steps", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_normal(player)) + for i in world.get_region("The Sleeping Canvases", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_normal(player)) + for i in world.get_region("Deambulatory of His Holiness", player).entrances: + add_rule(i, lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("Albero: Donate 5000 Tears", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Albero: Donate 50000 Tears", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Albero: Tirso's final reward", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("Ossuary: Isidora, Voice of the Dead", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("AR: Crisanta of the Wrapped Agony", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("BotTC: Esdras, of the Anointed Legion", player), + lambda state: state._blasphemous_esdras_boss_normal(player)) + add_rule(world.get_location("BotTC: Esdras' gift", player), + lambda state: state._blasphemous_esdras_boss_normal(player)) + add_rule(world.get_location("BotTC: Inside giant statue", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("BotSS: Crisanta's gift", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("CoOLotCV: Lady of the Six Sorrows", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("CoOLotCV: Mask room", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("GotP: Amanecida of the Bejeweled Arrow", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("HotD: Laudes, the First of the Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("LotNW: Elevator Child of Moonlight", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("LotNW: Mask room", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("LotNW: Mea Culpa altar", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("LotNW: Red candle", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("MD: Blue candle", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("MD: Cave Child of Moonlight", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("MD: Behind gate to TSC", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("MoM: Melquiades, The Exhumed Archbishop", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("MoM: Mask room", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("MotED: Amanecida of the Golden Blades", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("MaH: Sierpes' eye", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("MaH: Sierpes", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("PotSS: Amanecida of the Chiselled Steel", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("TSC: Under elevator shaft", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("TSC: Exposito, Scion of Abjuration", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("WotHP: Quirce, Returned By The Flames", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("WotHP: Collapsing floor ledge", player), + lambda state: state._blasphemous_mask_boss_normal(player)) + add_rule(world.get_location("WotHP: Amanecida of the Molten Thorn", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 4 extra", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 4 main", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 5 extra", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 5 main", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 6 extra", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 6 main", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 7 extra", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Confessor Dungeon 7 main", player), + lambda state: state._blasphemous_wound_boss_normal(player)) + add_rule(world.get_location("Defeat 1 Amanecida", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("Defeat 2 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("Defeat 3 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("Defeat 4 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + add_rule(world.get_location("Defeat all Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_normal(player)) + + # difficulty (hard) + elif world.difficulty[player].value == 2: + for i in world.get_region("Desecrated Cistern", player).entrances: + add_rule(i, lambda state: state._blasphemous_wound_boss_hard(player)) + for i in world.get_region("Ferrous Tree", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_hard(player)) + for i in world.get_region("Patio of the Silent Steps", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_hard(player)) + for i in world.get_region("The Sleeping Canvases", player).entrances: + add_rule(i, lambda state: state._blasphemous_esdras_boss_hard(player)) + for i in world.get_region("Deambulatory of His Holiness", player).entrances: + add_rule(i, lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("Albero: Donate 5000 Tears", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Albero: Donate 50000 Tears", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Albero: Tirso's final reward", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("Ossuary: Isidora, Voice of the Dead", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("AR: Crisanta of the Wrapped Agony", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("BotTC: Esdras, of the Anointed Legion", player), + lambda state: state._blasphemous_esdras_boss_hard(player)) + add_rule(world.get_location("BotTC: Esdras' gift", player), + lambda state: state._blasphemous_esdras_boss_hard(player)) + add_rule(world.get_location("BotTC: Inside giant statue", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("BotSS: Crisanta's gift", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("CoOLotCV: Lady of the Six Sorrows", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("CoOLotCV: Mask room", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("GotP: Amanecida of the Bejeweled Arrow", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("HotD: Laudes, the First of the Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("LotNW: Elevator Child of Moonlight", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("LotNW: Mask room", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("LotNW: Mea Culpa altar", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("LotNW: Red candle", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("MD: Blue candle", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("MD: Cave Child of Moonlight", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("MD: Behind gate to TSC", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("MoM: Melquiades, The Exhumed Archbishop", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("MoM: Mask room", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("MotED: Amanecida of the Golden Blades", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("MaH: Sierpes' eye", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("MaH: Sierpes", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("PotSS: Amanecida of the Chiselled Steel", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("TSC: Under elevator shaft", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("TSC: Exposito, Scion of Abjuration", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("WotHP: Quirce, Returned By The Flames", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("WotHP: Collapsing floor ledge", player), + lambda state: state._blasphemous_mask_boss_hard(player)) + add_rule(world.get_location("WotHP: Amanecida of the Molten Thorn", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 4 extra", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 4 main", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 5 extra", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 5 main", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 6 extra", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 6 main", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 7 extra", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Confessor Dungeon 7 main", player), + lambda state: state._blasphemous_wound_boss_hard(player)) + add_rule(world.get_location("Defeat 1 Amanecida", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("Defeat 2 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("Defeat 3 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("Defeat 4 Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) + add_rule(world.get_location("Defeat all Amanecidas", player), + lambda state: state._blasphemous_endgame_boss_hard(player)) \ No newline at end of file diff --git a/worlds/blasphemous/Vanilla.py b/worlds/blasphemous/Vanilla.py new file mode 100644 index 0000000000..82ec7c5f4e --- /dev/null +++ b/worlds/blasphemous/Vanilla.py @@ -0,0 +1,246 @@ +from typing import Set, Dict + +unrandomized_dict: Dict[str, str] = { + "CoOLotCV: Fountain of burning oil": "Golden Thimble Filled with Burning Oil", + "MotED: Egg hatching": "Hatched Egg of Deformity", + "BotSS: Crisanta's gift": "Holy Wound of Abnegation", + "DC: Chalice room": "Chalice of Inverted Verses" +} + +cherub_set: Set[str] = [ + "Albero: Child of Moonlight", + "AR: Upper west shaft Child of Moonlight", + "BotSS: Starting room Child of Moonlight", + "DC: Child of Moonlight, above water", + "DC: Upper east Child of Moonlight", + "DC: Child of Moonlight, miasma room", + "DC: Child of Moonlight, behind pillar", + "DC: Top of elevator Child of Moonlight", + "DC: Elevator shaft Child of Moonlight", + "GotP: Shop cave Child of Moonlight", + "GotP: Elevator shaft Child of Moonlight", + "GotP: West shaft Child of Moonlight", + "GotP: Center shaft Child of Moonlight", + "GA: Miasma room Child of Moonlight", + "GA: Blood bridge Child of Moonlight", + "GA: Lower east Child of Moonlight", + "Jondo: Upper east Child of Moonlight", + "Jondo: Spike tunnel Child of Moonlight", + "Jondo: Upper west Child of Moonlight", + "LotNW: Platform room Child of Moonlight", + "LotNW: Lowest west Child of Moonlight", + "LotNW: Elevator Child of Moonlight", + "MD: Second area Child of Moonlight", + "MD: Cave Child of Moonlight", + "MoM: Lower west Child of Moonlight", + "MoM: Upper center Child of Moonlight", + "MotED: Child of Moonlight, above chasm", + "PotSS: First area Child of Moonlight", + "PotSS: Third area Child of Moonlight", + "THL: Child of Moonlight", + "WotHP: Upper east room, top bronze cell", + "WotHP: Upper west room, top silver cell", + "WotHP: Lower east room, bottom silver cell", + "WotHP: Outside Child of Moonlight", + "WotBC: Outside Child of Moonlight", + "WotBC: Cliffside Child of Moonlight", + "WOTW: Underground Child of Moonlight", + "WOTW: Upper east Child of Moonlight", +] + +life_set: Set[str] = [ + "AR: Lady of the Six Sorrows", + "CoOLotCV: Lady of the Six Sorrows", + "DC: Lady of the Six Sorrows, from MD", + "DC: Lady of the Six Sorrows, elevator shaft", + "GotP: Lady of the Six Sorrows", + "LotNW: Lady of the Six Sorrows" +] + +fervour_set: Set[str] = [ + "DC: Oil of the Pilgrims", + "GotP: Oil of the Pilgrims", + "GA: Oil of the Pilgrims", + "LotNW: Oil of the Pilgrims", + "MoM: Oil of the Pilgrims", + "WotHP: Oil of the Pilgrims" +] + +sword_set: Set[str] = [ + "Albero: Mea Culpa altar", + "AR: Mea Culpa altar", + "BotSS: Mea Culpa altar", + "CoOLotCV: Mea Culpa altar", + "DC: Mea Culpa altar", + "LotNW: Mea Culpa altar", + "MoM: Mea Culpa altar" +] + +blessing_dict: Dict[str, str] = { + "Albero: Bless Severed Hand": "Incorrupt Hand of the Fraternal Master", + "Albero: Bless Linen Cloth": "Shroud of Dreamt Sins", + "Albero: Bless Hatched Egg": "Three Gnarled Tongues" +} + +dungeon_dict: Dict[str, str] = { + "Confessor Dungeon 1 extra": "Tears of Atonement (1000)", + "Confessor Dungeon 2 extra": "Heart of the Single Tone", + "Confessor Dungeon 3 extra": "Tears of Atonement (3000)", + "Confessor Dungeon 4 extra": "Embers of a Broken Star", + "Confessor Dungeon 5 extra": "Tears of Atonement (5000)", + "Confessor Dungeon 6 extra": "Scaly Coin", + "Confessor Dungeon 7 extra": "Seashell of the Inverted Spiral" +} + +tirso_dict: Dict[str, str] = { + "Albero: Tirso's 1st reward": "Linen Cloth", + "Albero: Tirso's 2nd reward": "Tears of Atonement (500)", + "Albero: Tirso's 3rd reward": "Tears of Atonement (1000)", + "Albero: Tirso's 4th reward": "Tears of Atonement (2000)", + "Albero: Tirso's 5th reward": "Tears of Atonement (5000)", + "Albero: Tirso's 6th reward": "Tears of Atonement (10000)", + "Albero: Tirso's final reward": "Knot of Rosary Rope" +} + +redento_dict: Dict[str, str] = { + "MoM: Redento's treasure": "Nail Uprooted from Dirt", + "MoM: Final meeting with Redento": "Knot of Rosary Rope", + "MotED: 1st meeting with Redento": "Fourth Toe made of Limestone", + "PotSS: 4th meeting with Redento": "Big Toe made of Limestone", + "WotBC: 3rd meeting with Redento": "Little Toe made of Limestone" +} + +jocinero_dict: Dict[str, str] = { + "TSC: Jocinero's 1st reward": "Linen of Golden Thread", + "TSC: Jocinero's final reward": "Campanillero to the Sons of the Aurora" +} + +altasgracias_dict: Dict[str, str] = { + "GA: Altasgracias' gift": "Egg of Deformity", + "GA: Empty giant egg": "Knot of Hair" +} + +tentudia_dict: Dict[str, str] = { + "Albero: Lvdovico's 1st reward": "Tears of Atonement (500)", + "Albero: Lvdovico's 2nd reward": "Tears of Atonement (1000)", + "Albero: Lvdovico's 3rd reward": "Debla of the Lights" +} + +gemino_dict: Dict[str, str] = { + "WOTW: Gift for the tomb": "Dried Flowers bathed in Tears", + "WOTW: Underground tomb": "Saeta Dolorosa", + "WOTW: Gemino's gift": "Empty Golden Thimble", + "WOTW: Gemino's reward": "Frozen Olive" +} + +ossuary_dict: Dict[str, str] = { + "Ossuary: 1st reward": "Tears of Atonement (250)", + "Ossuary: 2nd reward": "Tears of Atonement (500)", + "Ossuary: 3rd reward": "Tears of Atonement (750)", + "Ossuary: 4th reward": "Tears of Atonement (1000)", + "Ossuary: 5th reward": "Tears of Atonement (1250)", + "Ossuary: 6th reward": "Tears of Atonement (1500)", + "Ossuary: 7th reward": "Tears of Atonement (1750)", + "Ossuary: 8th reward": "Tears of Atonement (2000)", + "Ossuary: 9th reward": "Tears of Atonement (2500)", + "Ossuary: 10th reward": "Tears of Atonement (3000)", + "Ossuary: 11th reward": "Tears of Atonement (5000)", +} + +boss_dict: Dict[str, str] = { + "BotTC: Esdras, of the Anointed Legion": "Tears of Atonement (4300)", + "BotSS: Warden of the Silent Sorrow": "Tears of Atonement (300)", + "CoOLotCV: Our Lady of the Charred Visage": "Tears of Atonement (2600)", + "HotD: Laudes, the First of the Amanecidas": "Tears of Atonement (30000)", + "GotP: Amanecida of the Bejeweled Arrow": "Tears of Atonement (18000)", + "GA: Tres Angustias": "Tears of Atonement (2100)", + "MD: Ten Piedad": "Tears of Atonement (625)", + "MoM: Melquiades, The Exhumed Archbishop": "Tears of Atonement (5500)", + "MotED: Amanecida of the Golden Blades": "Tears of Atonement (18000)", + "MaH: Sierpes": "Tears of Atonement (5000)", + "PotSS: Amanecida of the Chiselled Steel": "Tears of Atonement (18000)", + "TSC: Exposito, Scion of Abjuration": "Tears of Atonement (9000)", + "WotHP: Quirce, Returned By The Flames": "Tears of Atonement (11250)", + "WotHP: Amanecida of the Molten Thorn": "Tears of Atonement (18000)" +} + +wound_dict: Dict[str, str] = { + "CoOLotCV: Visage of Compunction": "Holy Wound of Compunction", + "GA: Visage of Contrition": "Holy Wound of Contrition", + "MD: Visage of Attrition": "Holy Wound of Attrition" +} + +mask_dict: Dict[str, str] = { + "CoOLotCV: Mask room": "Mirrored Mask of Dolphos", + "LotNW: Mask room": "Embossed Mask of Crescente", + "MoM: Mask room": "Deformed Mask of Orestes" +} + +eye_dict: Dict[str, str] = { + "Ossuary: Isidora, Voice of the Dead": "Severed Right Eye of the Traitor", + "MaH: Sierpes' eye": "Broken Left Eye of the Traitor" +} + +herb_dict: Dict[str, str] = { + "Albero: Gate of Travel room": "Bouquet of Thyme", + "Jondo: Lower east bell trap": "Bouquet of Rosemary", + "MotED: Blood platform alcove": "Dried Clove", + "PotSS: Third area lower ledge": "Olive Seeds", + "TSC: Painting ladder ledge": "Sooty Garlic", + "WOTW: Entrance to tomb": "Incense Garlic" +} + +church_dict: Dict[str, str] = { + "Albero: Donate 5000 Tears": "Token of Appreciation", + "Albero: Donate 50000 Tears": "Cloistered Ruby" +} + +shop_dict: Dict[str, str] = { + "GotP: Shop item 1": "Torn Bridal Ribbon", + "GotP: Shop item 2": "Calcified Eye of Erudition", + "GotP: Shop item 3": "Ember of the Holy Cremation", + "MD: Shop item 1": "Key to the Chamber of the Eldest Brother", + "MD: Shop item 2": "Hollow Pearl", + "MD: Shop item 3": "Moss Preserved in Glass", + "TSC: Shop item 1": "Wicker Knot", + "TSC: Shop item 2": "Empty Bile Vessel", + "TSC: Shop item 3": "Key of the Inquisitor" +} + +thorn_set: Set[str] = { + "THL: Deogracias' gift", + "Confessor Dungeon 1 main", + "Confessor Dungeon 2 main", + "Confessor Dungeon 3 main", + "Confessor Dungeon 4 main", + "Confessor Dungeon 5 main", + "Confessor Dungeon 6 main", + "Confessor Dungeon 7 main", +} + +candle_dict: Dict[str, str] = { + "CoOLotCV: Red candle": "Bead of Red Wax", + "LotNW: Red candle": "Bead of Red Wax", + "MD: Red candle": "Bead of Red Wax", + "BotSS: Blue candle": "Bead of Blue Wax", + "CoOLotCV: Blue candle": "Bead of Blue Wax", + "MD: Blue candle": "Bead of Blue Wax" +} + +skill_dict: Dict[str, str] = { + "Skill 1, Tier 1": "Combo Skill", + "Skill 1, Tier 2": "Combo Skill", + "Skill 1, Tier 3": "Combo Skill", + "Skill 2, Tier 1": "Charged Skill", + "Skill 2, Tier 2": "Charged Skill", + "Skill 2, Tier 3": "Charged Skill", + "Skill 3, Tier 1": "Ranged Skill", + "Skill 3, Tier 2": "Ranged Skill", + "Skill 3, Tier 3": "Ranged Skill", + "Skill 4, Tier 1": "Dive Skill", + "Skill 4, Tier 2": "Dive Skill", + "Skill 4, Tier 3": "Dive Skill", + "Skill 5, Tier 1": "Lunge Skill", + "Skill 5, Tier 2": "Lunge Skill", + "Skill 5, Tier 3": "Lunge Skill", +} \ No newline at end of file diff --git a/worlds/blasphemous/__init__.py b/worlds/blasphemous/__init__.py new file mode 100644 index 0000000000..70aea1ef76 --- /dev/null +++ b/worlds/blasphemous/__init__.py @@ -0,0 +1,413 @@ +from typing import Dict, Set, Any +from collections import Counter +from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification +from ..AutoWorld import World, WebWorld +from .Items import base_id, item_table, group_table, tears_set, reliquary_set, skill_set +from .Locations import location_table, shop_set +from .Exits import region_exit_table, exit_lookup_table +from .Rules import rules +from worlds.generic.Rules import set_rule +from .Options import blasphemous_options +from . import Vanilla + + +class BlasphemousWeb(WebWorld): + theme = "stone" + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Blasphemous randomizer connected to an Archipelago Multiworld", + "English", + "setup_en.md", + "setup/en", + ["TRPG"] + )] + + +class BlasphemousWorld(World): + """ + Blasphemous is a challenging Metroidvania set in the cursed land of Cvstodia. Play as the Penitent One, trapped + in an endless cycle of death and rebirth, and free the world from it's terrible fate in your quest to break + your eternal damnation! + """ + + game: str = "Blasphemous" + web = BlasphemousWeb() + data_version: 1 + + item_name_to_id = {item["name"]: (base_id + index) for index, item in enumerate(item_table)} + location_name_to_id = {loc["name"]: (base_id + index) for index, loc in enumerate(location_table)} + location_name_to_game_id = {loc["name"]: loc["game_id"] for loc in location_table} + + item_name_groups = group_table + option_definitions = blasphemous_options + + + def set_rules(self): + rules(self) + + + def create_item(self, name: str) -> "BlasphemousItem": + item_id: int = self.item_name_to_id[name] + id = item_id - base_id + + return BlasphemousItem(name, item_table[id]["classification"], item_id, player=self.player) + + + def create_event(self, event: str): + return BlasphemousItem(event, ItemClassification.progression_skip_balancing, None, self.player) + + + def get_filler_item_name(self) -> str: + return self.multiworld.random.choice(tears_set) + + + def generate_basic(self): + placed_items = [] + + placed_items.extend(Vanilla.unrandomized_dict.values()) + + if not self.multiworld.reliquary_shuffle[self.player]: + placed_items.extend(reliquary_set) + elif self.multiworld.reliquary_shuffle[self.player]: + placed_items.append("Tears of Atonement (250)") + placed_items.append("Tears of Atonement (300)") + placed_items.append("Tears of Atonement (500)") + + if not self.multiworld.cherub_shuffle[self.player]: + for i in range(38): + placed_items.append("Child of Moonlight") + + if not self.multiworld.life_shuffle[self.player]: + for i in range(6): + placed_items.append("Life Upgrade") + + if not self.multiworld.fervour_shuffle[self.player]: + for i in range(6): + placed_items.append("Fervour Upgrade") + + if not self.multiworld.sword_shuffle[self.player]: + for i in range(7): + placed_items.append("Mea Culpa Upgrade") + + if not self.multiworld.blessing_shuffle[self.player]: + placed_items.extend(Vanilla.blessing_dict.values()) + + if not self.multiworld.dungeon_shuffle[self.player]: + placed_items.extend(Vanilla.dungeon_dict.values()) + + if not self.multiworld.tirso_shuffle[self.player]: + placed_items.extend(Vanilla.tirso_dict.values()) + + if not self.multiworld.miriam_shuffle[self.player]: + placed_items.append("Cantina of the Blue Rose") + + if not self.multiworld.redento_shuffle[self.player]: + placed_items.extend(Vanilla.redento_dict.values()) + + if not self.multiworld.jocinero_shuffle[self.player]: + placed_items.extend(Vanilla.jocinero_dict.values()) + + if not self.multiworld.altasgracias_shuffle[self.player]: + placed_items.extend(Vanilla.altasgracias_dict.values()) + + if not self.multiworld.tentudia_shuffle[self.player]: + placed_items.extend(Vanilla.tentudia_dict.values()) + + if not self.multiworld.gemino_shuffle[self.player]: + placed_items.extend(Vanilla.gemino_dict.values()) + + if not self.multiworld.guilt_shuffle[self.player]: + placed_items.append("Weight of True Guilt") + + if not self.multiworld.ossuary_shuffle[self.player]: + placed_items.extend(Vanilla.ossuary_dict.values()) + + if not self.multiworld.boss_shuffle[self.player]: + placed_items.extend(Vanilla.boss_dict.values()) + + if not self.multiworld.wound_shuffle[self.player]: + placed_items.extend(Vanilla.wound_dict.values()) + + if not self.multiworld.mask_shuffle[self.player]: + placed_items.extend(Vanilla.mask_dict.values()) + + if not self.multiworld.eye_shuffle[self.player]: + placed_items.extend(Vanilla.eye_dict.values()) + + if not self.multiworld.herb_shuffle[self.player]: + placed_items.extend(Vanilla.herb_dict.values()) + + if not self.multiworld.church_shuffle[self.player]: + placed_items.extend(Vanilla.church_dict.values()) + + if not self.multiworld.shop_shuffle[self.player]: + placed_items.extend(Vanilla.shop_dict.values()) + + if self.multiworld.thorn_shuffle[self.player] == 2: + for i in range(8): + placed_items.append("Thorn Upgrade") + + if not self.multiworld.candle_shuffle[self.player]: + placed_items.extend(Vanilla.candle_dict.values()) + + if self.multiworld.start_wheel[self.player]: + placed_items.append("The Young Mason's Wheel") + + if not self.multiworld.skill_randomizer[self.player]: + placed_items.extend(Vanilla.skill_dict.values()) + + counter = Counter(placed_items) + + pool = [] + + for item in item_table: + count = item["count"] - counter[item["name"]] + + if count <= 0: + continue + else: + for i in range(count): + pool.append(self.create_item(item["name"])) + + self.multiworld.itempool += pool + + + def pre_fill(self): + self.place_items_from_dict(Vanilla.unrandomized_dict) + + if not self.multiworld.cherub_shuffle[self.player]: + self.place_items_from_set(Vanilla.cherub_set, "Child of Moonlight") + + if not self.multiworld.life_shuffle[self.player]: + self.place_items_from_set(Vanilla.life_set, "Life Upgrade") + + if not self.multiworld.fervour_shuffle[self.player]: + self.place_items_from_set(Vanilla.fervour_set, "Fervour Upgrade") + + if not self.multiworld.sword_shuffle[self.player]: + self.place_items_from_set(Vanilla.sword_set, "Mea Culpa Upgrade") + + if not self.multiworld.blessing_shuffle[self.player]: + self.place_items_from_dict(Vanilla.blessing_dict) + + if not self.multiworld.dungeon_shuffle[self.player]: + self.place_items_from_dict(Vanilla.dungeon_dict) + + if not self.multiworld.tirso_shuffle[self.player]: + self.place_items_from_dict(Vanilla.tirso_dict) + + if not self.multiworld.miriam_shuffle[self.player]: + self.multiworld.get_location("AtTotS: Miriam's gift", self.player)\ + .place_locked_item(self.create_item("Cantina of the Blue Rose")) + + if not self.multiworld.redento_shuffle[self.player]: + self.place_items_from_dict(Vanilla.redento_dict) + + if not self.multiworld.jocinero_shuffle[self.player]: + self.place_items_from_dict(Vanilla.jocinero_dict) + + if not self.multiworld.altasgracias_shuffle[self.player]: + self.place_items_from_dict(Vanilla.altasgracias_dict) + + if not self.multiworld.tentudia_shuffle[self.player]: + self.place_items_from_dict(Vanilla.tentudia_dict) + + if not self.multiworld.gemino_shuffle[self.player]: + self.place_items_from_dict(Vanilla.gemino_dict) + + if not self.multiworld.guilt_shuffle[self.player]: + self.multiworld.get_location("GotP: Confessor Dungeon room", self.player)\ + .place_locked_item(self.create_item("Weight of True Guilt")) + + if not self.multiworld.ossuary_shuffle[self.player]: + self.place_items_from_dict(Vanilla.ossuary_dict) + + if not self.multiworld.boss_shuffle[self.player]: + self.place_items_from_dict(Vanilla.boss_dict) + + if not self.multiworld.wound_shuffle[self.player]: + self.place_items_from_dict(Vanilla.wound_dict) + + if not self.multiworld.mask_shuffle[self.player]: + self.place_items_from_dict(Vanilla.mask_dict) + + if not self.multiworld.eye_shuffle[self.player]: + self.place_items_from_dict(Vanilla.eye_dict) + + if not self.multiworld.herb_shuffle[self.player]: + self.place_items_from_dict(Vanilla.herb_dict) + + if not self.multiworld.church_shuffle[self.player]: + self.place_items_from_dict(Vanilla.church_dict) + + if not self.multiworld.shop_shuffle[self.player]: + self.place_items_from_dict(Vanilla.shop_dict) + + if self.multiworld.thorn_shuffle[self.player] == 2: + self.place_items_from_set(Vanilla.thorn_set, "Thorn Upgrade") + + if not self.multiworld.candle_shuffle[self.player]: + self.place_items_from_dict(Vanilla.candle_dict) + + if self.multiworld.start_wheel[self.player]: + self.multiworld.get_location("BotSS: Beginning gift", self.player)\ + .place_locked_item(self.create_item("The Young Mason's Wheel")) + + if not self.multiworld.skill_randomizer[self.player]: + self.place_items_from_dict(Vanilla.skill_dict) + + if self.multiworld.thorn_shuffle[self.player] == 1: + self.multiworld.local_items[self.player].value.add("Thorn Upgrade") + + + def place_items_from_set(self, location_set: Set[str], name: str): + for loc in location_set: + self.multiworld.get_location(loc, self.player)\ + .place_locked_item(self.create_item(name)) + + + def place_items_from_dict(self, option_dict: Dict[str, str]): + for loc, item in option_dict.items(): + self.multiworld.get_location(loc, self.player)\ + .place_locked_item(self.create_item(item)) + + + def create_regions(self) -> None: + + player = self.player + world = self.multiworld + + region_table: Dict[str, Region] = { + "menu" : Region("Menu", player, world), + "albero" : Region("Albero", player, world), + "attots" : Region("All the Tears of the Sea", player, world), + "ar" : Region("Archcathedral Rooftops", player, world), + "bottc" : Region("Bridge of the Three Cavalries", player, world), + "botss" : Region("Brotherhood of the Silent Sorrow", player, world), + "coolotcv": Region("Convent of Our Lady of the Charred Visage", player, world), + "dohh" : Region("Deambulatory of His Holiness", player, world), + "dc" : Region("Desecrated Cistern", player, world), + "eos" : Region("Echoes of Salt", player, world), + "ft" : Region("Ferrous Tree", player, world), + "gotp" : Region("Graveyard of the Peaks", player, world), + "ga" : Region("Grievance Ascends", player, world), + "hotd" : Region("Hall of the Dawning", player, world), + "jondo" : Region("Jondo", player, world), + "kottw" : Region("Knot of the Three Words", player, world), + "lotnw" : Region("Library of the Negated Words", player, world), + "md" : Region("Mercy Dreams", player, world), + "mom" : Region("Mother of Mothers", player, world), + "moted" : Region("Mountains of the Endless Dusk", player, world), + "mah" : Region("Mourning and Havoc", player, world), + "potss" : Region("Patio of the Silent Steps", player, world), + "petrous" : Region("Petrous", player, world), + "thl" : Region("The Holy Line", player, world), + "trpots" : Region("The Resting Place of the Sister", player, world), + "tsc" : Region("The Sleeping Canvases", player, world), + "wothp" : Region("Wall of the Holy Prohibitions", player, world), + "wotbc" : Region("Wasteland of the Buried Churches", player, world), + "wotw" : Region("Where Olive Trees Wither", player, world), + "dungeon" : Region("Dungeons", player, world) + } + + for rname, reg in region_table.items(): + world.regions.append(reg) + + for ename, exits in region_exit_table.items(): + if ename == rname: + for i in exits: + ent = Entrance(player, i, reg) + reg.exits.append(ent) + + for e, r in exit_lookup_table.items(): + if i == e: + ent.connect(region_table[r]) + + for loc in location_table: + id = base_id + location_table.index(loc) + region_table[loc["region"]].locations\ + .append(BlasphemousLocation(self.player, loc["name"], id, region_table[loc["region"]])) + + victory = Location(self.player, "His Holiness Escribar", None, self.multiworld.get_region("Deambulatory of His Holiness", self.player)) + victory.place_locked_item(self.create_event("Victory")) + self.multiworld.get_region("Deambulatory of His Holiness", self.player).locations.append(victory) + + if self.multiworld.ending[self.player].value == 1: + set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8)) + elif self.multiworld.ending[self.player].value == 2: + set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8) and \ + state.has("Holy Wound of Abnegation", player)) + + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + + + def fill_slot_data(self) -> Dict[str, Any]: + slot_data: Dict[str, Any] = {} + locations = [] + + for loc in self.multiworld.get_filled_locations(self.player): + if loc.name == "His Holiness Escribar": + continue + else: + data = { + "id": self.location_name_to_game_id[loc.name], + "ap_id": loc.address, + "name": loc.item.name, + "player_name": self.multiworld.player_name[loc.item.player] + } + + if loc.name in shop_set: + data["type"] = loc.item.classification.name + + locations.append(data) + + config = { + "versionCreated": "AP", + "general": { + "teleportationAlwaysUnlocked": bool(self.multiworld.prie_dieu_warp[self.player].value), + "skipCutscenes": bool(self.multiworld.skip_cutscenes[self.player].value), + "enablePenitence": bool(self.multiworld.penitence[self.player].value), + "hardMode": False, + "customSeed": 0, + "allowHints": bool(self.multiworld.corpse_hints[self.player].value) + }, + "items": { + "type": 1, + "lungDamage": False, + "disableNPCDeath": True, + "startWithWheel": bool(self.multiworld.start_wheel[self.player].value), + "shuffleReliquaries": bool(self.multiworld.reliquary_shuffle[self.player].value) + }, + "enemies": { + "type": self.multiworld.enemy_randomizer[self.player].value, + "maintainClass": bool(self.multiworld.enemy_groups[self.player].value), + "areaScaling": bool(self.multiworld.enemy_scaling[self.player].value) + }, + "prayers": { + "type": 0, + "removeMirabis": False + }, + "doors": { + "type": 0 + }, + "debug": { + "type": 0 + } + } + + slot_data = { + "locations": locations, + "cfg": config, + "ending": self.multiworld.ending[self.player].value, + "death_link": bool(self.multiworld.death_link[self.player].value) + } + + return slot_data + + +class BlasphemousItem(Item): + game: str = "Blasphemous" + + +class BlasphemousLocation(Location): + game: str = "Blasphemous" \ No newline at end of file diff --git a/worlds/blasphemous/docs/en_Blasphemous.md b/worlds/blasphemous/docs/en_Blasphemous.md new file mode 100644 index 0000000000..15223213ac --- /dev/null +++ b/worlds/blasphemous/docs/en_Blasphemous.md @@ -0,0 +1,64 @@ +# Blasphemous + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a config file. + +## What does randomization do to this game? + +All items that appear on the ground are randomized, and there are options to randomize more of the game, such as stat upgrades, enemies, skills, and more. + +In addition, there are other changes to the game that make it better optimized for a randomizer: + +- Some items and enemies are never randomized. +- Teleportation between Prie Dieus can be unlocked from the beginning. +- New save files are created in True Torment mode (NG+), but enemies and bosses will use their regular attack & defense values. A penitence can be chosen if the option is enabled. +- Save files can not be ascended - the randomizer is meant to be completed in a single playthrough. +- The Ossuary will give a reward for every four bones collected. +- Side quests have been modified so that the items received from them cannot be missed. +- The Apodictic Heart of Mea Culpa can be unequipped. +- Dying with the Immaculate Bead is unnecessary, it is automatically upgraded to the Weight of True Guilt. +- If the option is enabled, the 34 corpses in game will have their messages changed to give hints about certain items and locations. The Shroud of Dreamt Sins is not required to hear them. + +## What has been changed about the side quests? + +Tirso: +- Tirso's helpers will never die. Herbs can be given to him at any time. + +Gemino: +- Gemino will never freeze. The thimble can be given to him at any time. + +Viridiana: +- Viridiana will never die. The player can ask for her assistance at all 5 boss fights, and she will still appear at the rooftops. + +Redento: +- No changes. + +Cleofas: +- The choice to end Socorro's suffering has been removed. +- Cleofas will not jump off the rooftops, even after talking to him without the Cord of the True Burying. + +Crisanta / Ending C: +- The Incomplete Scapular will not skip the fight with Esdras. Instead, it is required to open the door to the church in Brotherhood of the Silent Sorrow. +- Perpetva's item from The Resting Place of the Sister is always accessible, even after defeating Esdras. +- Crisanta's gift in Brotherhood of the Silent Sorrow will always be the Holy Wound of Abnegation. +- When fighting Crisanta, it is no longer required to have the Apodictic Heart of Mea Culpa equipped to continue with Ending C, it just needs to be in the player's inventory. + +## Which items and enemies are never randomized? + +Items: +- Golden Thimble Filled with Burning Oil - from the fountain of burning oil in Convent of Our Lady of the Charred Visage +- Hatched Egg of Deformity - from the tree in Mountains of the Endless Dusk +- Chalice of Inverted Verses - from the statue in Desecrated Cistern +- Holy Wound of Abnegation - given by Crisanta in Brotherhood of the Silent Sorrow + +Enemies: +- The Charging Knell in Mountains of the Endless Dusk +- The bell ringer in lower east Jondo +- The first Phalaris, Lionheart, and Sleepless Tomb in their respective areas (Chalice of Inverted Verses quest) + +In addition, any enemies that appear in some kind of arena (such as the Confessor Dungeons, or the bridges in Archcathedral Rooftops or Grievance Ascends) will not be randomized to prevent softlocking. + +## What does another world's item look like in Blasphemous? + +Items retain their original appearance. You won't know if an item is for another player until you collect it. The only exception to this is the shops, where items that belong to other players are represented by the Archipelago logo. \ No newline at end of file diff --git a/worlds/blasphemous/docs/setup_en.md b/worlds/blasphemous/docs/setup_en.md new file mode 100644 index 0000000000..35b8670f6d --- /dev/null +++ b/worlds/blasphemous/docs/setup_en.md @@ -0,0 +1,21 @@ +# Blasphemous Multiworld Setup Guide + +## Required Software + +- Blasphemous from: [Steam](https://store.steampowered.com/app/774361/Blasphemous/) +- Blasphemous Modding API from: [GitHub](https://github.com/BrandenEK/Blasphemous-Modding-API) +- Blasphemous Randomizer from: [GitHub](https://github.com/BrandenEK/Blasphemous-Randomizer) +- Blasphemous Multiworld from: [GitHub](https://github.com/BrandenEK/Blasphemous-Multiworld) + +## Instructions (Windows) + +1. Download the [Modding API](https://github.com/BrandenEK/Blasphemous-Modding-API/releases), and follow the [installation instructions](https://github.com/BrandenEK/Blasphemous-Modding-API#installation) on the GitHub page. + +2. After the Modding API has been installed, download the [Randomizer](https://github.com/BrandenEK/Blasphemous-Randomizer/releases) and [Multiworld](https://github.com/BrandenEK/Blasphemous-Multiworld/releases) archives, and extract the contents of both into the `Modding` folder. + +3. Start Blasphemous. To verfy that the mods are working, look for a version number for both the Randomizer and Multiworld on the title screen. + +## Connecting + +To connect to an Archipelago server, open the in-game console by pressing backslash `\` and use the command `multiworld connect [address:port] [name] [password]`. The port and password are both optional - if no port is provided then the default port of 38281 is used. +**Make sure to connect to the server before attempting to start a new save file.** \ No newline at end of file diff --git a/worlds/dark_souls_3/Items.py b/worlds/dark_souls_3/Items.py index e7ba2ecf00..140d3ba613 100644 --- a/worlds/dark_souls_3/Items.py +++ b/worlds/dark_souls_3/Items.py @@ -1,10 +1,18 @@ +import sys + from BaseClasses import Item -from worlds.dark_souls_3.data.items_data import item_tables +from worlds.dark_souls_3.data.items_data import item_tables, dlc_shields_table, dlc_weapons_upgrade_10_table, \ + dlc_weapons_upgrade_5_table, dlc_goods_table, dlc_spells_table, dlc_armor_table, dlc_ring_table, dlc_misc_table, dlc_goods_2_table class DarkSouls3Item(Item): game: str = "Dark Souls III" + dlc_set = {**dlc_shields_table, **dlc_weapons_upgrade_10_table, **dlc_weapons_upgrade_5_table, + **dlc_goods_table, **dlc_spells_table, **dlc_armor_table, **dlc_ring_table, **dlc_misc_table} + + dlc_progressive = {**dlc_goods_2_table} + @staticmethod def get_name_to_id() -> dict: base_id = 100000 @@ -12,6 +20,17 @@ class DarkSouls3Item(Item): output = {} for i, table in enumerate(item_tables): + if len(table) > table_offset: + raise Exception("An item table has {} entries, that is more than {} entries (table #{})".format(len(table), table_offset, i)) output.update({name: id for id, name in enumerate(table, base_id + (table_offset * i))}) return output + + @staticmethod + def is_dlc_item(name) -> bool: + return name in DarkSouls3Item.dlc_set + + @staticmethod + def is_dlc_progressive(name) -> bool: + return name in DarkSouls3Item.dlc_progressive + diff --git a/worlds/dark_souls_3/Locations.py b/worlds/dark_souls_3/Locations.py index 0ba84e365b..a63a951ccb 100644 --- a/worlds/dark_souls_3/Locations.py +++ b/worlds/dark_souls_3/Locations.py @@ -1,5 +1,8 @@ +import sys + from BaseClasses import Location -from worlds.dark_souls_3.data.locations_data import location_tables +from worlds.dark_souls_3.data.locations_data import location_tables, painted_world_table, dreg_heap_table, \ + ringed_city_table class DarkSouls3Location(Location): @@ -12,6 +15,8 @@ class DarkSouls3Location(Location): output = {} for i, table in enumerate(location_tables): + if len(table) > table_offset: + raise Exception("A location table has {} entries, that is more than {} entries (table #{})".format(len(table), table_offset, i)) output.update({name: id for id, name in enumerate(table, base_id + (table_offset * i))}) return output diff --git a/worlds/dark_souls_3/Options.py b/worlds/dark_souls_3/Options.py index 54c43143e0..dc9510a7f3 100644 --- a/worlds/dark_souls_3/Options.py +++ b/worlds/dark_souls_3/Options.py @@ -1,5 +1,5 @@ import typing -from Options import Toggle, Option, DeathLink +from Options import Toggle, Option, Range, Choice, DeathLink class AutoEquipOption(Toggle): @@ -29,10 +29,57 @@ class NoEquipLoadOption(Toggle): display_name = "No Equip load" -class RandomizeWeaponsLevelOption(Toggle): - """Enable this option to upgrade 33% ( based on the probability chance ) of the pool of weapons to a random value - between +1 and +5/+10""" +class RandomizeWeaponsLevelOption(Choice): + """Enable this option to upgrade a percentage of the pool of weapons to a random value between the minimum and + maximum levels defined. + all: All weapons are eligible, both basic and epic + basic: Only weapons that can be upgraded to +10 + epic: Only weapons that can be upgraded to +5""" display_name = "Randomize weapons level" + option_none = 0 + option_all = 1 + option_basic = 2 + option_epic = 3 + + +class RandomizeWeaponsLevelPercentageOption(Range): + """The percentage of weapons in the pool to be upgraded if randomize weapons level is toggled""" + display_name = "Percentage of randomized weapons" + range_start = 1 + range_end = 100 + default = 33 + + +class MinLevelsIn5WeaponPoolOption(Range): + """The minimum upgraded value of a weapon in the pool of weapons that can only reach +5""" + display_name = "Minimum level of +5 weapons" + range_start = 1 + range_end = 5 + default = 1 + + +class MaxLevelsIn5WeaponPoolOption(Range): + """The maximum upgraded value of a weapon in the pool of weapons that can only reach +5""" + display_name = "Maximum level of +5 weapons" + range_start = 1 + range_end = 5 + default = 5 + + +class MinLevelsIn10WeaponPoolOption(Range): + """The minimum upgraded value of a weapon in the pool of weapons that can reach +10""" + display_name = "Minimum level of +10 weapons" + range_start = 1 + range_end = 10 + default = 1 + + +class MaxLevelsIn10WeaponPoolOption(Range): + """The maximum upgraded value of a weapon in the pool of weapons that can reach +10""" + display_name = "Maximum level of +10 weapons" + range_start = 1 + range_end = 10 + default = 10 class LateBasinOfVowsOption(Toggle): @@ -41,14 +88,31 @@ class LateBasinOfVowsOption(Toggle): display_name = "Late Basin of Vows" +class EnableProgressiveLocationsOption(Toggle): + """Randomize upgrade materials such as the titanite shards, the estus shards and the consumables""" + display_name = "Randomize materials, Estus shards and consumables (+196 checks/items)" + + +class EnableDLCOption(Toggle): + """To use this option, you must own both the ASHES OF ARIANDEL and the RINGED CITY DLC""" + display_name = "Add the DLC Items and Locations to the pool (+81 checks/items)" + + dark_souls_options: typing.Dict[str, type(Option)] = { "auto_equip": AutoEquipOption, "lock_equip": LockEquipOption, "no_weapon_requirements": NoWeaponRequirementsOption, "randomize_weapons_level": RandomizeWeaponsLevelOption, + "randomize_weapons_percentage": RandomizeWeaponsLevelPercentageOption, + "min_levels_in_5": MinLevelsIn5WeaponPoolOption, + "max_levels_in_5": MaxLevelsIn5WeaponPoolOption, + "min_levels_in_10": MinLevelsIn10WeaponPoolOption, + "max_levels_in_10": MaxLevelsIn10WeaponPoolOption, "late_basin_of_vows": LateBasinOfVowsOption, "no_spell_requirements": NoSpellRequirementsOption, "no_equip_load": NoEquipLoadOption, "death_link": DeathLink, + "enable_progressive_locations": EnableProgressiveLocationsOption, + "enable_dlc": EnableDLCOption, } diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 6d16e562fe..d08cd3ee3e 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -1,18 +1,18 @@ # world/dark_souls_3/__init__.py -import json -import os from typing import Dict from .Items import DarkSouls3Item from .Locations import DarkSouls3Location from .Options import dark_souls_options -from .data.items_data import weapons_upgrade_5_table, weapons_upgrade_10_table, item_dictionary, key_items_list +from .data.items_data import weapons_upgrade_5_table, weapons_upgrade_10_table, item_dictionary, key_items_list, \ + dlc_weapons_upgrade_5_table, dlc_weapons_upgrade_10_table from .data.locations_data import location_dictionary, fire_link_shrine_table, \ high_wall_of_lothric, \ undead_settlement_table, road_of_sacrifice_table, consumed_king_garden_table, cathedral_of_the_deep_table, \ farron_keep_table, catacombs_of_carthus_table, smouldering_lake_table, irithyll_of_the_boreal_valley_table, \ irithyll_dungeon_table, profaned_capital_table, anor_londo_table, lothric_castle_table, grand_archives_table, \ - untended_graves_table, archdragon_peak_table, firelink_shrine_bell_tower_table, progressive_locations + untended_graves_table, archdragon_peak_table, firelink_shrine_bell_tower_table, progressive_locations, \ + progressive_locations_2, progressive_locations_3, painted_world_table, dreg_heap_table, ringed_city_table, dlc_progressive_locations from ..AutoWorld import World, WebWorld from BaseClasses import MultiWorld, Region, Item, Entrance, Tutorial, ItemClassification from ..generic.Rules import set_rule, add_item_rule @@ -52,9 +52,9 @@ class DarkSouls3World(World): option_definitions = dark_souls_options topology_present: bool = True web = DarkSouls3Web() - data_version = 4 + data_version = 5 base_id = 100000 - required_client_version = (0, 3, 6) + required_client_version = (0, 3, 7) item_name_to_id = DarkSouls3Item.get_name_to_id() location_name_to_id = DarkSouls3Location.get_name_to_id() @@ -77,7 +77,15 @@ class DarkSouls3World(World): return DarkSouls3Item(name, item_classification, data, self.player) def create_regions(self): - menu_region = self.create_region("Menu", progressive_locations) + + if self.multiworld.enable_progressive_locations[self.player].value and self.multiworld.enable_dlc[self.player].value: + menu_region = self.create_region("Menu", {**progressive_locations, **progressive_locations_2, + **progressive_locations_3, **dlc_progressive_locations}) + elif self.multiworld.enable_progressive_locations[self.player].value: + menu_region = self.create_region("Menu", {**progressive_locations, **progressive_locations_2, + **progressive_locations_3}) + else: + menu_region = self.create_region("Menu", None) # Create all Vanilla regions of Dark Souls III firelink_shrine_region = self.create_region("Firelink Shrine", fire_link_shrine_table) @@ -101,6 +109,11 @@ class DarkSouls3World(World): untended_graves_region = self.create_region("Untended Graves", untended_graves_table) archdragon_peak_region = self.create_region("Archdragon Peak", archdragon_peak_table) kiln_of_the_first_flame_region = self.create_region("Kiln Of The First Flame", None) + # DLC Down here + if self.multiworld.enable_dlc[self.player]: + painted_world_of_ariandel_region = self.create_region("Painted World of Ariandel", painted_world_table) + dreg_heap_region = self.create_region("Dreg Heap", dreg_heap_table) + ringed_city_region = self.create_region("Ringed City", ringed_city_table) # Create the entrance to connect those regions menu_region.exits.append(Entrance(self.player, "New Game", menu_region)) @@ -112,7 +125,8 @@ class DarkSouls3World(World): firelink_shrine_region.exits.append(Entrance(self.player, "Goto Bell Tower", firelink_shrine_region)) self.multiworld.get_entrance("Goto High Wall of Lothric", self.player).connect(high_wall_of_lothric_region) - self.multiworld.get_entrance("Goto Kiln Of The First Flame", self.player).connect(kiln_of_the_first_flame_region) + self.multiworld.get_entrance("Goto Kiln Of The First Flame", self.player).connect( + kiln_of_the_first_flame_region) self.multiworld.get_entrance("Goto Bell Tower", self.player).connect(firelink_shrine_bell_tower_region) high_wall_of_lothric_region.exits.append(Entrance(self.player, "Goto Undead Settlement", high_wall_of_lothric_region)) @@ -133,7 +147,7 @@ class DarkSouls3World(World): catacombs_of_carthus_region)) catacombs_of_carthus_region.exits.append(Entrance(self.player, "Goto Smouldering Lake", catacombs_of_carthus_region)) - self.multiworld.get_entrance("Goto Irithyll of the boreal", self.player).\ + self.multiworld.get_entrance("Goto Irithyll of the boreal", self.player). \ connect(irithyll_of_the_boreal_valley_region) self.multiworld.get_entrance("Goto Smouldering Lake", self.player).connect(smouldering_lake_region) irithyll_of_the_boreal_valley_region.exits.append(Entrance(self.player, "Goto Irithyll dungeon", @@ -153,6 +167,16 @@ class DarkSouls3World(World): consumed_king_garden_region.exits.append(Entrance(self.player, "Goto Untended Graves", consumed_king_garden_region)) self.multiworld.get_entrance("Goto Untended Graves", self.player).connect(untended_graves_region) + # DLC Connectors Below + if self.multiworld.enable_dlc[self.player]: + cathedral_of_the_deep_region.exits.append(Entrance(self.player, "Goto Painted World of Ariandel", + cathedral_of_the_deep_region)) + self.multiworld.get_entrance("Goto Painted World of Ariandel", self.player).connect(painted_world_of_ariandel_region) + painted_world_of_ariandel_region.exits.append(Entrance(self.player, "Goto Dreg Heap", + painted_world_of_ariandel_region)) + self.multiworld.get_entrance("Goto Dreg Heap", self.player).connect(dreg_heap_region) + dreg_heap_region.exits.append(Entrance(self.player, "Goto Ringed City", dreg_heap_region)) + self.multiworld.get_entrance("Goto Ringed City", self.player).connect(ringed_city_region) # For each region, add the associated locations retrieved from the corresponding location_table def create_region(self, region_name, location_table) -> Region: @@ -169,8 +193,18 @@ class DarkSouls3World(World): def create_items(self): for name, address in self.item_name_to_id.items(): # Specific items will be included in the item pool under certain conditions. See generate_basic - if name != "Basin of Vows": - self.multiworld.itempool += [self.create_item(name)] + if name == "Basin of Vows": + continue + # Do not add progressive_items ( containing "#" ) to the itempool if the option is disabled + if (not self.multiworld.enable_progressive_locations[self.player]) and "#" in name: + continue + # Do not add DLC items if the option is disabled + if (not self.multiworld.enable_dlc[self.player]) and DarkSouls3Item.is_dlc_item(name): + continue + # Do not add DLC Progressives if both options are disabled + if ((not self.multiworld.enable_progressive_locations[self.player]) or (not self.multiworld.enable_dlc[self.player])) and DarkSouls3Item.is_dlc_progressive(name): + continue + self.multiworld.itempool += [self.create_item(name)] def generate_early(self): pass @@ -194,15 +228,23 @@ class DarkSouls3World(World): lambda state: state.has("Grand Archives Key", self.player)) set_rule(self.multiworld.get_entrance("Goto Kiln Of The First Flame", self.player), lambda state: state.has("Cinders of a Lord - Abyss Watcher", self.player) and - state.has("Cinders of a Lord - Yhorm the Giant", self.player) and - state.has("Cinders of a Lord - Aldrich", self.player) and - state.has("Cinders of a Lord - Lothric Prince", self.player)) + state.has("Cinders of a Lord - Yhorm the Giant", self.player) and + state.has("Cinders of a Lord - Aldrich", self.player) and + state.has("Cinders of a Lord - Lothric Prince", self.player)) + # DLC Access Rules Below + if self.multiworld.enable_dlc[self.player]: + set_rule(self.multiworld.get_entrance("Goto Painted World of Ariandel", self.player), + lambda state: state.has("Contraption Key", self.player)) + set_rule(self.multiworld.get_entrance("Goto Ringed City", self.player), + lambda state: state.has("Small Envoy Banner", self.player)) # Define the access rules to some specific locations set_rule(self.multiworld.get_location("HWL: Soul of the Dancer", self.player), lambda state: state.has("Basin of Vows", self.player)) set_rule(self.multiworld.get_location("HWL: Greirat's Ashes", self.player), lambda state: state.has("Cell Key", self.player)) + set_rule(self.multiworld.get_location("HWL: Blue Tearstone Ring", self.player), + lambda state: state.has("Cell Key", self.player)) set_rule(self.multiworld.get_location("ID: Bellowing Dragoncrest Ring", self.player), lambda state: state.has("Jailbreaker's Key", self.player)) set_rule(self.multiworld.get_location("ID: Prisoner Chief's Ashes", self.player), @@ -242,17 +284,38 @@ class DarkSouls3World(World): # Depending on the specified option, modify items hexadecimal value to add an upgrade level item_dictionary_copy = item_dictionary.copy() - if self.multiworld.randomize_weapons_level[self.player]: - # Randomize some weapons upgrades - for name in weapons_upgrade_5_table.keys(): - if self.multiworld.random.randint(0, 100) < 33: - value = self.multiworld.random.randint(1, 5) - item_dictionary_copy[name] += value + if self.multiworld.randomize_weapons_level[self.player] > 0: + # if the user made an error and set a min higher than the max we default to the max + max_5 = self.multiworld.max_levels_in_5[self.player] + min_5 = min(self.multiworld.min_levels_in_5[self.player], max_5) + max_10 = self.multiworld.max_levels_in_10[self.player] + min_10 = min(self.multiworld.min_levels_in_10[self.player], max_10) + weapons_percentage = self.multiworld.randomize_weapons_percentage[self.player] - for name in weapons_upgrade_10_table.keys(): - if self.multiworld.random.randint(0, 100) < 33: - value = self.multiworld.random.randint(1, 10) - item_dictionary_copy[name] += value + # Randomize some weapons upgrades + if self.multiworld.randomize_weapons_level[self.player] in [1, 3]: # Options are either all or +5 + for name in weapons_upgrade_5_table.keys(): + if self.multiworld.per_slot_randoms[self.player].randint(1, 100) < weapons_percentage: + value = self.multiworld.per_slot_randoms[self.player].randint(min_5, max_5) + item_dictionary_copy[name] += value + + if self.multiworld.randomize_weapons_level[self.player] in [1, 2]: # Options are either all or +10 + for name in weapons_upgrade_10_table.keys(): + if self.multiworld.per_slot_randoms[self.player].randint(1, 100) < weapons_percentage: + value = self.multiworld.per_slot_randoms[self.player].randint(min_10, max_10) + item_dictionary_copy[name] += value + + if self.multiworld.randomize_weapons_level[self.player] in [1, 3]: + for name in dlc_weapons_upgrade_5_table.keys(): + if self.multiworld.per_slot_randoms[self.player].randint(1, 100) < weapons_percentage: + value = self.multiworld.per_slot_randoms[self.player].randint(min_5, max_5) + item_dictionary_copy[name] += value + + if self.multiworld.randomize_weapons_level[self.player] in [1, 2]: + for name in dlc_weapons_upgrade_10_table.keys(): + if self.multiworld.per_slot_randoms[self.player].randint(1, 100) < weapons_percentage: + value = self.multiworld.per_slot_randoms[self.player].randint(min_10, max_10) + item_dictionary_copy[name] += value # Create the mandatory lists to generate the player's output file items_id = [] @@ -281,6 +344,7 @@ class DarkSouls3World(World): "death_link": self.multiworld.death_link[self.player].value, "no_spell_requirements": self.multiworld.no_spell_requirements[self.player].value, "no_equip_load": self.multiworld.no_equip_load[self.player].value, + "enable_dlc": self.multiworld.enable_dlc[self.player].value }, "seed": self.multiworld.seed_name, # to verify the server's multiworld "slot": self.multiworld.player_name[self.player], # to connect to server diff --git a/worlds/dark_souls_3/data/items_data.py b/worlds/dark_souls_3/data/items_data.py index c3f2c682e5..0ecd043440 100644 --- a/worlds/dark_souls_3/data/items_data.py +++ b/worlds/dark_souls_3/data/items_data.py @@ -13,7 +13,6 @@ weapons_upgrade_5_table = { "Izalith Staff": 0x00C96A80, "Fume Ultra Greatsword": 0x0060E4B0, "Black Knight Sword": 0x005F5E10, - "Yorshka's Spear": 0x008C3A70, "Smough's Great Hammer": 0x007E30B0, "Dragonslayer Greatbow": 0x00CF8500, @@ -25,7 +24,6 @@ weapons_upgrade_5_table = { "Dragonslayer Spear": 0x008CAFA0, "Caitha's Chime": 0x00CA06C0, "Sunlight Straight Sword": 0x00203230, - "Firelink Greatsword": 0x0060BDA0, "Hollowslayer Greatsword": 0x00604870, "Arstor's Spear": 0x008BEC50, @@ -37,7 +35,6 @@ weapons_upgrade_5_table = { "Wolnir's Holy Sword": 0x005FFA50, "Demon's Greataxe": 0x006CA480, "Demon's Fist": 0x00A84DF0, - "Old King's Great Hammer": 0x007CF830, "Greatsword of Judgment": 0x005E2590, "Profaned Greatsword": 0x005E4CA0, @@ -55,6 +52,29 @@ weapons_upgrade_5_table = { "Irithyll Rapier": 0x002E8A10 } +dlc_weapons_upgrade_5_table = { + "Friede's Great Scythe": 0x009B55A0, + "Rose of Ariandel": 0x00B82C70, + "Demon's Scar": 0x003F04D0, # Assigned to "RC: Church Guardian Shiv" + "Frayed Blade": 0x004D35A0, # Assigned to "RC: Ritual Spear Fragment" + "Gael's Greatsword": 0x00227C20, # Assigned to "RC: Violet Wrappings" + "Repeating Crossbow": 0x00D885B0, # Assigned to "RC: Blood of the Dark Souls" + "Onyx Blade": 0x00222E00, # VILHELM FIGHT + "Earth Seeker": 0x006D8EE0, + "Quakestone Hammer": 0x007ECCF0, + "Millwood Greatbow": 0x00D85EA0, + "Valorheart": 0x00F646E0, + "Aquamarine Dagger": 0x00116520, + "Ringed Knight Straight Sword": 0x00225510, + "Ledo's Great Hammer": 0x007EF400, # INVADER FIGHT + "Ringed Knight Spear": 0x008CFDC0, + "Crucifix of the Mad King": 0x008D4BE0, + "Sacred Chime of Filianore": 0x00CCECF0, + "Preacher's Right Arm": 0x00CD1400, + "White Birch Bow": 0x00D77440, + "Ringed Knight Paired Greatswords": 0x00F69500 +} + weapons_upgrade_10_table = { "Broken Straight Sword": 0x001EF9B0, "Deep Battle Axe": 0x0006AFA54, @@ -73,7 +93,6 @@ weapons_upgrade_10_table = { "Red Hilted Halberd": 0x009AB960, "Saint's Talisman": 0x00CACA10, "Large Club": 0x007AFC60, - "Brigand Twindaggers": 0x00F50E60, "Butcher Knife": 0x006BE130, "Brigand Axe": 0x006B1DE0, @@ -104,9 +123,24 @@ weapons_upgrade_10_table = { "Drakeblood Greatsword": 0x00609690, "Greatlance": 0x008A8CC0, "Sniper Crossbow": 0x00D83790, - "Claw": 0x00A7D8C0, "Drang Twinspears": 0x00F5AAA0, + "Pyromancy Flame": 0x00CC77C0 #given/dropped by Cornyx +} + +dlc_weapons_upgrade_10_table = { + "Follower Sabre": 0x003EDDC0, + "Millwood Battle Axe": 0x006D67D0, + "Follower Javelin": 0x008CD6B0, + "Crow Talons": 0x00A89C10, + "Pyromancer's Parting Flame": 0x00CC9ED0, + "Crow Quills": 0x00F66DF0, + "Follower Torch": 0x015F1AD0, + "Murky Hand Scythe": 0x00118C30, + "Herald Curved Greatsword": 0x006159E0, + "Lothric War Banner": 0x008D24D0, + "Splitleaf Greatsword": 0x009B2E90, # SHOP ITEM + "Murky Longstaff": 0x00CCC5E0, } shields_table = { @@ -132,7 +166,15 @@ shields_table = { "Golden Wing Crest Shield": 0x0143CAA0, "Ancient Dragon Greatshield": 0x013599D0, "Spirit Tree Crest Shield": 0x014466E0, - "Blessed Red and White Shield": 0x01343FB9, + "Blessed Red and White Shield": 0x01343FB9 +} + +dlc_shields_table = { + "Followers Shield": 0x0135C0E0, + "Ethereal Oak Shield": 0x01450320, + "Giant Door Shield": 0x00F5F8C0, + "Dragonhead Shield": 0x0135E7F0, + "Dragonhead Greatshield": 0x01452A30 } goods_table = { @@ -167,7 +209,55 @@ goods_table = { **{"Soul of a Great Champion #"+str(i): 0x400001A4 for i in range(1, 3)}, **{"Soul of a Champion #"+str(i): 0x400001A3 for i in range(1, 5)}, **{"Soul of a Venerable Old Hand #"+str(i): 0x400001A2 for i in range(1, 5)}, - **{"Soul of a Crestfallen Knight #"+str(i): 0x40000199 for i in range(1, 11)}, + **{"Soul of a Crestfallen Knight #"+str(i): 0x40000199 for i in range(1, 11)} +} + +goods_2_table = { # Added by Br00ty + "HWL: Gold Pine Resin #": 0x4000014B, + "US: Charcoal Pine Resin #": 0x4000014A, + "FK: Gold Pine Bundle #": 0x40000155, + "CC: Carthus Rouge #": 0x4000014F, + "ID: Pale Pine Resin #": 0x40000150, + **{"Ember #"+str(i): 0x400001F4 for i in range(1, 45)}, + **{"Titanite Shard #"+str(i): 0x400003E8 for i in range(11, 16)}, + **{"Large Titanite Shard #"+str(i): 0x400003E9 for i in range(11, 16)}, + **{"Titanite Scale #" + str(i): 0x400003FC for i in range(1, 25)} +} + +goods_3_table = { # Added by Br00ty + **{"Fading Soul #" + str(i): 0x40000190 for i in range(1, 4)}, + **{"Ring of Sacrifice #"+str(i): 0x20004EF2 for i in range(1, 5)}, + **{"Homeward Bone #"+str(i): 0x4000015E for i in range(1, 17)}, + **{"Green Blossom #"+str(i): 0x40000104 for i in range(1, 7)}, + **{"Human Pine Resin #"+str(i): 0x4000014E for i in range(1, 3)}, + **{"Charcoal Pine Bundle #"+str(i): 0x40000154 for i in range(1, 3)}, + **{"Rotten Pine Resin #"+str(i): 0x40000157 for i in range(1, 3)}, + **{"Alluring Skull #"+str(i): 0x40000126 for i in range(1, 9)}, + **{"Rusted Coin #"+str(i): 0x400001C7 for i in range(1, 3)}, + **{"Rusted Gold Coin #"+str(i): 0x400001C9 for i in range(1, 3)}, + **{"Titanite Chunk #"+str(i): 0x400003EA for i in range(1, 17)}, + **{"Twinkling Titanite #"+str(i): 0x40000406 for i in range(1, 8)} +} + +dlc_goods_table = { + "Soul of Sister Friede": 0x400002E8, + "Soul of the Demon Prince": 0x400002EA, + "Soul of Darkeater Midir": 0x400002EB, + "Soul of Slave Knight Gael": 0x400002E9 +} + +dlc_goods_2_table = { #71 + **{"Large Soul of an Unknown Traveler $"+str(i): 0x40000194 for i in range(1, 10)}, + **{"Soul of a Weary Warrior $"+str(i): 0x40000197 for i in range(1, 6)}, + **{"Large Soul of a Weary Warrior $"+str(i): 0x40000198 for i in range(1, 7)}, + **{"Soul of a Crestfallen Knight $"+str(i): 0x40000199 for i in range(1, 7)}, + **{"Large Soul of a Crestfallen Knight $"+str(i): 0x4000019A for i in range(1, 4)}, + **{"Homeward Bone $"+str(i): 0x4000015E for i in range(1, 7)}, + **{"Large Titanite Shard $"+str(i): 0x400003E9 for i in range(1, 4)}, + **{"Titanite Chunk $"+str(i): 0x400003EA for i in range(1, 16)}, + **{"Twinkling Titanite $"+str(i): 0x40000406 for i in range(1, 6)}, + **{"Rusted Coin $"+str(i): 0x400001C7 for i in range(1, 4)}, + **{"Ember $"+str(i): 0x400001F4 for i in range(1, 11)} } armor_table = { @@ -265,6 +355,69 @@ armor_table = { "Outrider Knight Armor": 0x1328BB28, "Outrider Knight Gauntlets": 0x1328BF10, "Outrider Knight Leggings": 0x1328C2F8, + + "Cornyx's Wrap": 0x11946370, + "Cornyx's Garb": 0x11945F88, + "Cornyx's Skirt": 0x11946758 +} + +dlc_armor_table = { + "Slave Knight Hood": 0x134EDCE0, + "Slave Knight Armor": 0x134EE0C8, + "Slave Knight Gauntlets": 0x134EE4B0, + "Slave Knight Leggings": 0x134EE898, + "Vilhelm's Helm": 0x11312D00, + "Vilhelm's Armor": 0x113130E8, + "Vilhelm's Gauntlets": 0x113134D0, + "Vilhelm's Leggings": 0x113138B8, + #"Millwood Knight Helm": 0x139B2820, # SHOP ITEM + #"Millwood Knight Armor": 0x139B2C08, # SHOP ITEM + #"Millwood Knight Gauntlets": 0x139B2FF0, # SHOP ITEM + #"Millwood Knight Leggings": 0x139B33D8, # SHOP ITEM + + "Shira's Crown": 0x11C22260, + "Shira's Armor": 0x11C22648, + "Shira's Gloves": 0x11C22A30, + "Shira's Trousers": 0x11C22E18, + #"Lapp's Helm": 0x11E84800, # SHOP ITEM + #"Lapp's Armor": 0x11E84BE8, # SHOP ITEM + #"Lapp's Gauntlets": 0x11E84FD0, # SHOP ITEM + #"Lapp's Leggings": 0x11E853B8, # SHOP ITEM + #"Ringed Knight Hood": 0x13C8EEE0, # RANDOM ENEMY DROP + #"Ringed Knight Armor": 0x13C8F2C8, # RANDOM ENEMY DROP + #"Ringed Knight Gauntlets": 0x13C8F6B0, # RANDOM ENEMY DROP + #"Ringed Knight Leggings": 0x13C8FA98, # RANDOM ENEMY DROP + #"Harald Legion Armor": 0x13D83508, # RANDOM ENEMY DROP + #"Harald Legion Gauntlets": 0x13D838F0, # RANDOM ENEMY DROP + #"Harald Legion Leggings": 0x13D83CD8, # RANDOM ENEMY DROP + "Iron Dragonslayer Helm": 0x1405F7E0, + "Iron Dragonslayer Armor": 0x1405FBC8, + "Iron Dragonslayer Gauntlets": 0x1405FFB0, + "Iron Dragonslayer Leggings": 0x14060398, + + "Ruin Sentinel Helm": 0x14CC5520, + "Ruin Sentinel Armor": 0x14CC5908, + "Ruin Sentinel Gauntlets": 0x14CC5CF0, + "Ruin Sentinel Leggings": 0x14CC60D8, + "Desert Pyromancer Hood": 0x14DB9760, + "Desert Pyromancer Garb": 0x14DB9B48, + "Desert Pyromancer Gloves": 0x14DB9F30, + "Desert Pyromancer Skirt": 0x14DBA318, + + #"Follower Helm": 0x137CA3A0, # RANDOM ENEMY DROP + #"Follower Armor": 0x137CA788, # RANDOM ENEMY DROP + #"Follower Gloves": 0x137CAB70, # RANDOM ENEMY DROP + #"Follower Boots": 0x137CAF58, # RANDOM ENEMY DROP + #"Ordained Hood": 0x135E1F20, # SHOP ITEM + #"Ordained Dress": 0x135E2308, # SHOP ITEM + #"Ordained Trousers": 0x135E2AD8, # SHOP ITEM + "Black Witch Veil": 0x14FA1BE0, + "Black Witch Hat": 0x14EAD9A0, + "Black Witch Garb": 0x14EADD88, + "Black Witch Wrappings": 0x14EAE170, + "Black Witch Trousers": 0x14EAE558, + "White Preacher Head": 0x14153A20, + "Antiquated Plain Garb": 0x11B2E408 } rings_table = { @@ -314,6 +467,12 @@ rings_table = { "Dragonscale Ring": 0x2000515E, "Knight Slayer's Ring": 0x20005000, "Magic Stoneplate Ring": 0x20004E66, + "Blue Tearstone Ring": 0x20004ED4 #given/dropped by Greirat +} + +dlc_ring_table = { + "Havel's Ring": 0x20004E34, + "Chillbite Ring": 0x20005208 } spells_table = { @@ -335,7 +494,21 @@ spells_table = { "Divine Pillars of Light": 0x4038C340, "Great Magic Barrier": 0x40365628, "Great Magic Shield": 0x40144F38, - "Crystal Scroll": 0x40000856, + "Crystal Scroll": 0x40000856 +} + +dlc_spells_table = { + #"Boulder Heave": 0x40282170, # KILN STRAY DEMON + #"Seething Chaos": 0x402896A0, # KILN DEMON PRINCES + #"Old Moonlight": 0x4014FF00, # KILN MIDIR + "Frozen Weapon": 0x401408E8, + "Snap Freeze": 0x401A90C8, + "Great Soul Dregs": 0x401879A0, + "Flame Fan": 0x40258190, + "Lightning Arrow": 0x40358B08, + "Way of White Corona": 0x403642A0, + "Projected Heal": 0x40364688, + "Floating Chaos": 0x40257DA8 } misc_items_table = { @@ -347,7 +520,7 @@ misc_items_table = { "Braille Divine Tome of Carim": 0x40000847, # Shop "Great Swamp Pyromancy Tome": 0x4000084F, # Shop "Farron Coal ": 0x40000837, # Shop - "Paladin's Ashes": 0x4000083D, #Shop + "Paladin's Ashes": 0x4000083D, # Shop "Deep Braille Divine Tome": 0x40000860, # Shop "Small Doll": 0x400007D5, "Golden Scroll": 0x4000085C, @@ -388,6 +561,12 @@ misc_items_table = { "Orbeck's Ashes": 0x40000840 } +dlc_misc_table = { + "Captains Ashes": 0x4000086A, + "Contraption Key": 0x4000086B, # Needed for Painted World + "Small Envoy Banner": 0x4000086C # Needed to get to Ringed City from Dreg Heap +} + key_items_list = { "Small Lothric Banner", "Basin of Vows", @@ -405,8 +584,17 @@ key_items_list = { "Prisoner Chief's Ashes", "Old Cell Key", "Jailer's Key Ring", + "Contraption Key", + "Small Envoy Banner" } -item_tables = [weapons_upgrade_5_table, weapons_upgrade_10_table, shields_table, armor_table, rings_table, spells_table, misc_items_table, goods_table] +item_tables = [weapons_upgrade_5_table, weapons_upgrade_10_table, shields_table, + armor_table, rings_table, spells_table, misc_items_table, goods_table, goods_2_table, goods_3_table, + dlc_weapons_upgrade_5_table, dlc_weapons_upgrade_10_table, dlc_shields_table, dlc_goods_table, + dlc_armor_table, dlc_spells_table, dlc_ring_table, dlc_misc_table, dlc_goods_2_table] + +item_dictionary = {**weapons_upgrade_5_table, **weapons_upgrade_10_table, **shields_table, + **armor_table, **rings_table, **spells_table, **misc_items_table, **goods_table, **goods_2_table, + **goods_3_table, **dlc_weapons_upgrade_5_table, **dlc_weapons_upgrade_10_table, **dlc_shields_table, + **dlc_goods_table, **dlc_armor_table, **dlc_spells_table, **dlc_ring_table, **dlc_misc_table, **dlc_goods_2_table} -item_dictionary = {**weapons_upgrade_5_table, **weapons_upgrade_10_table, **shields_table, **armor_table, **rings_table, **spells_table, **misc_items_table, **goods_table} diff --git a/worlds/dark_souls_3/data/locations_data.py b/worlds/dark_souls_3/data/locations_data.py index 654c7f0930..5a85916973 100644 --- a/worlds/dark_souls_3/data/locations_data.py +++ b/worlds/dark_souls_3/data/locations_data.py @@ -42,6 +42,7 @@ high_wall_of_lothric = { "HWL: Soul of the Dancer": 0x400002CA, "HWL: Way of Blue Covenant": 0x2000274C, "HWL: Greirat's Ashes": 0x4000083F, + "HWL: Blue Tearstone Ring": 0x20004ED4 #given/dropped by Greirat } undead_settlement_table = { @@ -91,7 +92,11 @@ undead_settlement_table = { "US: Warrior of Sunlight Covenant": 0x20002738, "US: Blessed Red and White Shield": 0x01343FB9, "US: Irina's Ashes": 0x40000843, - "US: Cornyx's Ashes": 0x40000841 + "US: Cornyx's Ashes": 0x40000841, + "US: Cornyx's Wrap": 0x11946370, + "US: Cornyx's Garb": 0x11945F88, + "US: Cornyx's Skirt": 0x11946758, + "US: Pyromancy Flame": 0x00CC77C0 #given/dropped by Cornyx } road_of_sacrifice_table = { @@ -437,6 +442,101 @@ archdragon_peak_table = { "AP: Havel's Greatshield": 0x013376F0, } +painted_world_table = { # DLC + "PW: Follower Javelin": 0x008CD6B0, + "PW: Frozen Weapon": 0x401408E8, + "PW: Millwood Greatbow": 0x00D85EA0, + "PW: Captains Ashes": 0x4000086A, + "PW: Millwood Battle Axe": 0x006D67D0, + "PW: Ethereal Oak Shield": 0x01450320, + "PW: Crow Quills": 0x00F66DF0, + "PW: Slave Knight Hood": 0x134EDCE0, + "PW: Slave Knight Armor": 0x134EE0C8, + "PW: Slave Knight Gauntlets": 0x134EE4B0, + "PW: Slave Knight Leggings": 0x134EE898, + "PW: Way of White Corona": 0x403642A0, + "PW: Crow Talons": 0x00A89C10, + "PW: Quakestone Hammer": 0x007ECCF0, + "PW: Earth Seeker": 0x006D8EE0, + "PW: Follower Torch": 0x015F1AD0, + "PW: Follower Shield": 0x0135C0E0, + "PW: Follower Sabre": 0x003EDDC0, + "PW: Snap Freeze": 0x401A90C8, + "PW: Floating Chaos": 0x40257DA8, + "PW: Pyromancer's Parting Flame": 0x00CC9ED0, + "PW: Vilhelm's Helm": 0x11312D00, + "PW: Vilhelm's Armor": 0x113130E8, + "PW: Vilhelm's Gauntlets": 0x113134D0, + "PW: Vilhelm's Leggings": 0x113138B8, + "PW: Vilhelm's Leggings": 0x113138B8, + "PW: Valorheart": 0x00F646E0, # GRAVETENDER FIGHT + "PW: Champions Bones": 0x40000869, # GRAVETENDER FIGHT + "PW: Onyx Blade": 0x00222E00, # VILHELM FIGHT + "PW: Soul of Sister Friede": 0x400002E8, + "PW: Titanite Slab": 0x400003EB, + "PW: Chillbite Ring": 0x20005208, + "PW: Contraption Key": 0x4000086B # VILHELM FIGHT/NEEDED TO PROGRESS THROUGH PW +} + +dreg_heap_table = { # DLC + "DH: Loincloth": 0x11B2EBD8, + "DH: Aquamarine Dagger": 0x00116520, + "DH: Murky Hand Scythe": 0x00118C30, + "DH: Murky Longstaff": 0x00CCC5E0, + "DH: Great Soul Dregs": 0x401879A0, + "DH: Lothric War Banner": 0x00CCC5E0, + "DH: Projected Heal": 0x40364688, + "DH: Desert Pyromancer Hood": 0x14DB9760, + "DH: Desert Pyromancer Garb": 0x14DB9B48, + "DH: Desert Pyromancer Gloves": 0x14DB9F30, + "DH: Desert Pyromancer Skirt": 0x14DBA318, + "DH: Giant Door Shield": 0x00F5F8C0, + "DH: Herald Curved Greatsword": 0x006159E0, + "DH: Flame Fan": 0x40258190, + "DH: Soul of the Demon Prince": 0x400002EA, + "DH: Small Envoy Banner": 0x4000086C # NEEDED TO TRAVEL TO RINGED CITY +} + +ringed_city_table = { # DLC + "RC: Ruin Sentinel Helm": 0x14CC5520, + "RC: Ruin Sentinel Armor": 0x14CC5908, + "RC: Ruin Sentinel Gauntlets": 0x14CC5CF0, + "RC: Ruin Sentinel Leggings": 0x14CC60D8, + "RC: Black Witch Veil": 0x14FA1BE0, + "RC: Black Witch Hat": 0x14EAD9A0, + "RC: Black Witch Garb": 0x14EADD88, + "RC: Black Witch Wrappings": 0x14EAE170, + "RC: Black Witch Trousers": 0x14EAE558, + "RC: White Preacher Head": 0x14153A20, + "RC: Havel's Ring": 0x20004E34, + "RC: Ringed Knight Spear": 0x008CFDC0, + "RC: Dragonhead Shield": 0x0135E7F0, + "RC: Ringed Knight Straight Sword": 0x00225510, + "RC: Preacher's Right Arm": 0x00CD1400, + "RC: White Birch Bow": 0x00D77440, + "RC: Church Guardian Shiv": 0x4000013B, # Assigned to "Demon's Scar" + "RC: Dragonhead Greatshield": 0x01452A30, + "RC: Ringed Knight Paired Greatswords": 0x00F69500, + "RC: Shira's Crown": 0x11C22260, + "RC: Shira's Armor": 0x11C22648, + "RC: Shira's Gloves": 0x11C22A30, + "RC: Shira's Trousers": 0x11C22E18, + "RC: Titanite Slab": 0x400003EB, # SHIRA DROP + "RC: Crucifix of the Mad King": 0x008D4BE0, # SHIRA DROP + "RC: Sacred Chime of Filianore": 0x00CCECF0, # SHIRA DROP + "RC: Iron Dragonslayer Helm": 0x1405F7E0, + "RC: Iron Dragonslayer Armor": 0x1405FBC8, + "RC: Iron Dragonslayer Gauntlets": 0x1405FFB0, + "RC: Iron Dragonslayer Leggings": 0x14060398, + "RC: Lightning Arrow": 0x40358B08, + "RC: Ritual Spear Fragment": 0x4000028A, # Assigned to "Frayed Blade" + "RC: Antiquated Plain Garb": 0x11B2E408, + "RC: Violet Wrappings": 0x11B2E7F0, # Assigned to "Gael's Greatsword" + "RC: Soul of Darkeater Midir": 0x400002EB, + "RC: Soul of Slave Knight Gael": 0x400002E9, + "RC: Blood of the Dark Souls": 0x4000086E, # Assigned to "Repeating Crossbow" +} + progressive_locations = { # Upgrade materials **{"Titanite Shard #"+str(i): 0x400003E8 for i in range(1, 11)}, @@ -456,15 +556,60 @@ progressive_locations = { **{"Soul of a Deserted Corpse #" + str(i): 0x40000191 for i in range(1, 6)}, **{"Large Soul of a Deserted Corpse #" + str(i): 0x40000192 for i in range(1, 6)}, **{"Soul of an Unknown Traveler #" + str(i): 0x40000193 for i in range(1, 6)}, - **{"Large Soul of an Unknown Traveler #" + str(i): 0x40000194 for i in range(1, 6)}, + **{"Large Soul of an Unknown Traveler #" + str(i): 0x40000194 for i in range(1, 6)} +} + +progressive_locations_2 = { + ##Added by Br00ty + "HWL: Gold Pine Resin #": 0x4000014B, + "US: Charcoal Pine Resin #": 0x4000014A, + "FK: Gold Pine Bundle #": 0x40000155, + "CC: Carthus Rouge #": 0x4000014F, + "ID: Pale Pine Resin #": 0x40000150, + **{"Titanite Scale #" + str(i): 0x400003FC for i in range(1, 27)}, + **{"Fading Soul #" + str(i): 0x40000190 for i in range(1, 4)}, + **{"Ring of Sacrifice #"+str(i): 0x20004EF2 for i in range(1, 5)}, + **{"Homeward Bone #"+str(i): 0x4000015E for i in range(1, 17)}, + **{"Ember #"+str(i): 0x400001F4 for i in range(1, 46)}, +} + +progressive_locations_3 = { + **{"Green Blossom #" + str(i): 0x40000104 for i in range(1, 7)}, + **{"Human Pine Resin #" + str(i): 0x4000014E for i in range(1, 3)}, + **{"Charcoal Pine Bundle #" + str(i): 0x40000154 for i in range(1, 3)}, + **{"Rotten Pine Resin #" + str(i): 0x40000157 for i in range(1, 3)}, + **{"Pale Tongue #" + str(i): 0x40000175 for i in range(1, 3)}, + **{"Alluring Skull #" + str(i): 0x40000126 for i in range(1, 3)}, + **{"Undead Hunter Charm #" + str(i): 0x40000128 for i in range(1, 3)}, + **{"Duel Charm #" + str(i): 0x40000130 for i in range(1, 3)}, + **{"Rusted Coin #" + str(i): 0x400001C7 for i in range(1, 3)}, + **{"Rusted Gold Coin #" + str(i): 0x400001C9 for i in range(1, 4)}, + **{"Titanite Chunk #"+str(i): 0x400003EA for i in range(1, 17)}, + **{"Twinkling Titanite #"+str(i): 0x40000406 for i in range(1, 8)} +} + +dlc_progressive_locations = { #71 + **{"Large Soul of an Unknown Traveler $"+str(i): 0x40000194 for i in range(1, 10)}, + **{"Soul of a Weary Warrior $"+str(i): 0x40000197 for i in range(1, 6)}, + **{"Large Soul of a Weary Warrior $"+str(i): 0x40000198 for i in range(1, 7)}, + **{"Soul of a Crestfallen Knight $"+str(i): 0x40000199 for i in range(1, 7)}, + **{"Large Soul of a Crestfallen Knight $"+str(i): 0x4000019A for i in range(1, 4)}, + **{"Homeward Bone $"+str(i): 0x4000015E for i in range(1, 7)}, + **{"Large Titanite Shard $"+str(i): 0x400003E9 for i in range(1, 4)}, + **{"Titanite Chunk $"+str(i): 0x400003EA for i in range(1, 16)}, + **{"Twinkling Titanite $"+str(i): 0x40000406 for i in range(1, 6)}, + **{"Rusted Coin $"+str(i): 0x400001C7 for i in range(1, 4)}, + **{"Ember $"+str(i): 0x400001F4 for i in range(1, 11)} } location_tables = [fire_link_shrine_table, firelink_shrine_bell_tower_table, high_wall_of_lothric, undead_settlement_table, road_of_sacrifice_table, cathedral_of_the_deep_table, farron_keep_table, catacombs_of_carthus_table, smouldering_lake_table, irithyll_of_the_boreal_valley_table, irithyll_dungeon_table, profaned_capital_table, anor_londo_table, lothric_castle_table, consumed_king_garden_table, - grand_archives_table, untended_graves_table, archdragon_peak_table, progressive_locations] + grand_archives_table, untended_graves_table, archdragon_peak_table, progressive_locations, progressive_locations_2, progressive_locations_3, + painted_world_table, dreg_heap_table, ringed_city_table, dlc_progressive_locations] location_dictionary = {**fire_link_shrine_table, **firelink_shrine_bell_tower_table, **high_wall_of_lothric, **undead_settlement_table, **road_of_sacrifice_table, **cathedral_of_the_deep_table, **farron_keep_table, **catacombs_of_carthus_table, **smouldering_lake_table, **irithyll_of_the_boreal_valley_table, **irithyll_dungeon_table, **profaned_capital_table, **anor_londo_table, **lothric_castle_table, **consumed_king_garden_table, - **grand_archives_table, **untended_graves_table, **archdragon_peak_table, **progressive_locations} + **grand_archives_table, **untended_graves_table, **archdragon_peak_table, **progressive_locations, **progressive_locations_2, **progressive_locations_3, + **painted_world_table, **dreg_heap_table, **ringed_city_table, **dlc_progressive_locations} diff --git a/worlds/dark_souls_3/docs/en_Dark Souls III.md b/worlds/dark_souls_3/docs/en_Dark Souls III.md index 2effa5f124..3ad8236ccf 100644 --- a/worlds/dark_souls_3/docs/en_Dark Souls III.md +++ b/worlds/dark_souls_3/docs/en_Dark Souls III.md @@ -7,19 +7,20 @@ config file. ## What does randomization do to this game? -In Dark Souls III, all unique items you can earn from a static corpse, a chest or the death of a Boss/NPC are randomized. -This exclude the upgrade materials such as the titanite shards, the estus shards and the consumables which remain at -the same location. I also added an option available from the settings page to randomize the level of the generated -weapons(from +0 to +10/+5) +In Dark Souls III, all unique items you can earn from a static corpse, a chest or the death of a Boss/NPC are +randomized. +An option is available from the settings page to also randomize the upgrade materials, the Estus shards and the +consumables. +Another option is available to randomize the level of the generated weapons(from +0 to +10/+5) -To beat the game you need to collect the 4 "Cinders of a Lord" randomized in the multiworld +To beat the game you need to collect the 4 "Cinders of a Lord" randomized in the multiworld and kill the final boss "Soul of Cinder" ## What Dark Souls III items can appear in other players' worlds? -Every unique items from Dark Souls III can appear in other player's worlds, such as a piece of armor, an upgraded weapon +Every unique item from Dark Souls III can appear in other player's worlds, such as a piece of armor, an upgraded weapon, or a key item. ## What does another world's item look like in Dark Souls III? -In Dark Souls III, items which need to be sent to other worlds appear as a Prism Stone. \ No newline at end of file +In Dark Souls III, items which need to be sent to other worlds appear as a Prism Stone. diff --git a/worlds/dark_souls_3/docs/setup_en.md b/worlds/dark_souls_3/docs/setup_en.md index b4705ad5d5..8e1af8e92d 100644 --- a/worlds/dark_souls_3/docs/setup_en.md +++ b/worlds/dark_souls_3/docs/setup_en.md @@ -5,6 +5,10 @@ - [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/) - [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) +## Optional Software + +- [Dark Souls III Maptracker Pack](https://github.com/Br00ty/DS3_AP_Maptracker/releases/latest), for use with [Poptracker](https://github.com/black-sliver/PopTracker/releases) + ## General Concept The Dark Souls III AP Client is a dinput8.dll triggered when launching Dark Souls III. This .dll file will launch a command diff --git a/worlds/dkc3/Rom.py b/worlds/dkc3/Rom.py index 37b8ecf04c..c193e909eb 100644 --- a/worlds/dkc3/Rom.py +++ b/worlds/dkc3/Rom.py @@ -740,5 +740,5 @@ def get_base_rom_path(file_name: str = "") -> str: if not file_name: file_name = options["dkc3_options"]["rom_file"] if not os.path.exists(file_name): - file_name = Utils.local_path(file_name) + file_name = Utils.user_path(file_name) return file_name diff --git a/worlds/factorio/Technologies.py b/worlds/factorio/Technologies.py index 2db7f23dea..1c1939ee24 100644 --- a/worlds/factorio/Technologies.py +++ b/worlds/factorio/Technologies.py @@ -422,7 +422,7 @@ for root in sorted_rows: progressive = progressive_rows[root] assert all(tech in tech_table for tech in progressive), "declared a progressive technology without base technology" factorio_id += 1 - progressive_technology = Technology(root, technology_table[progressive_rows[root][0]].ingredients, factorio_id, + progressive_technology = Technology(root, technology_table[progressive[0]].ingredients, factorio_id, progressive, has_modifier=any(technology_table[tech].has_modifier for tech in progressive), unlocks=any(technology_table[tech].unlocks for tech in progressive)) diff --git a/worlds/ff1/Options.py b/worlds/ff1/Options.py index 2ab4b33622..0993d103d5 100644 --- a/worlds/ff1/Options.py +++ b/worlds/ff1/Options.py @@ -4,14 +4,17 @@ from Options import OptionDict class Locations(OptionDict): + """to roll settings go to https://finalfantasyrandomizer.com/""" display_name = "locations" class Items(OptionDict): + """to roll settings go to https://finalfantasyrandomizer.com/""" display_name = "items" class Rules(OptionDict): + """to roll settings go to https://finalfantasyrandomizer.com/""" display_name = "rules" diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index 98fb560aee..fb783edb67 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -1,7 +1,7 @@ import collections import typing -from BaseClasses import LocationProgressType, MultiWorld +from BaseClasses import LocationProgressType, MultiWorld, Location, Region, Entrance if typing.TYPE_CHECKING: import BaseClasses @@ -143,14 +143,41 @@ def add_item_rule(location: "BaseClasses.Location", rule: ItemRule, combine: str def item_in_locations(state: "BaseClasses.CollectionState", item: str, player: int, locations: typing.Sequence["BaseClasses.Location"]) -> bool: for location in locations: - if item_name(state, location[0], location[1]) == (item, player): + if location_item_name(state, location[0], location[1]) == (item, player): return True return False -def item_name(state: "BaseClasses.CollectionState", location: str, player: int) -> \ +def location_item_name(state: "BaseClasses.CollectionState", location: str, player: int) -> \ typing.Optional[typing.Tuple[str, int]]: location = state.multiworld.get_location(location, player) if location.item is None: return None return location.item.name, location.item.player + + +def allow_self_locking_items(spot: typing.Union[Location, Region], *item_names: str) -> None: + """ + This function sets rules on the supplied spot, such that the supplied item_name(s) can possibly be placed there. + + spot: Location or Region that the item(s) are allowed to be placed in + item_names: item name or names that are allowed to be placed in the Location or Region + """ + player = spot.player + + def add_allowed_rules(area: typing.Union[Location, Entrance], location: Location) -> None: + def set_always_allow(location: Location, rule: typing.Callable) -> None: + location.always_allow = rule + + for item_name in item_names: + add_rule(area, lambda state, item_name=item_name: + location_item_name(state, location.name, player) == (item_name, player), "or") + set_always_allow(location, lambda state, item: + item.player == player and item.name in [item_name for item_name in item_names]) + + if isinstance(spot, Region): + for entrance in spot.entrances: + for location in spot.locations: + add_allowed_rules(entrance, location) + else: + add_allowed_rules(spot, spot) diff --git a/worlds/messenger/Constants.py b/worlds/messenger/Constants.py new file mode 100644 index 0000000000..d57081edac --- /dev/null +++ b/worlds/messenger/Constants.py @@ -0,0 +1,153 @@ +# items +# listing individual groups first for easy lookup +NOTES = [ + "Key of Hope", + "Key of Chaos", + "Key of Courage", + "Key of Love", + "Key of Strength", + "Key of Symbiosis" +] + +PROG_ITEMS = [ + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + # "Astral Seed", + # "Astral Tea Leaves" +] + +PHOBEKINS = [ + "Necro", + "Pyro", + "Claustro", + "Acro" +] + +USEFUL_ITEMS = [ + "Windmill Shuriken" +] + +# item_name_to_id needs to be deterministic and match upstream +ALL_ITEMS = [ + *NOTES, + "Windmill Shuriken", + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + # "Astral Seed", + # "Astral Tea Leaves", + "Candle", + "Seashell", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + *PHOBEKINS, + "Power Seal", + "Time Shard" # there's 45 separate instances of this in the client lookup, but hopefully we don't care? +] + +# locations +# the names of these don't actually matter, but using the upstream's names for now +# order must be exactly the same as upstream +ALWAYS_LOCATIONS = [ + # notes + "Key of Love", + "Key of Courage", + "Key of Chaos", + "Key of Symbiosis", + "Key of Strength", + "Key of Hope", + # upgrades + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + "Climbing Claws", + # quest items + "Astral Seed", + "Astral Tea Leaves", + "Candle", + "Seashell", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + # phobekins + "Necro", + "Pyro", + "Claustro", + "Acro" +] + +SEALS = [ + "Ninja Village Seal - Tree House", + + "Autumn Hills Seal - Trip Saws", + "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", + + "Catacombs Seal - Triple Spike Crushers", + "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", + + "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", + "Bamboo Creek Seal - Spike Crushers and Doors v2", + + "Howling Grotto Seal - Windy Saws and Balls", + "Howling Grotto Seal - Crushing Pits", + "Howling Grotto Seal - Breezy Crushers", + + "Quillshroom Marsh Seal - Spikey Window", + "Quillshroom Marsh Seal - Sand Trap", + "Quillshroom Marsh Seal - Do the Spike Wave", + + "Searing Crags Seal - Triple Ball Spinner", + "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", + + "Glacial Peak Seal - Ice Climbers", + "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", + + "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + + "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", + "Cloud Ruins Seal - Money Farm Room", + + "Underworld Seal - Sharp and Windy Climb", + "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", + "Underworld Seal - Rising Fanta", + + "Forlorn Temple Seal - Rocket Maze", + "Forlorn Temple Seal - Rocket Sunset", + + "Sunken Shrine Seal - Ultra Lifeguard", + "Sunken Shrine Seal - Waterfall Paradise", + "Sunken Shrine Seal - Tabi Gauntlet", + + "Riviere Turquoise Seal - Bounces and Balls", + "Riviere Turquoise Seal - Launch of Faith", + "Riviere Turquoise Seal - Flower Power", + + "Elemental Skylands Seal - Air", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire" +] diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py new file mode 100644 index 0000000000..1baca12e3a --- /dev/null +++ b/worlds/messenger/Options.py @@ -0,0 +1,66 @@ +from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice + + +class MessengerAccessibility(Accessibility): + default = Accessibility.option_locations + # defaulting to locations accessibility since items makes certain items self-locking + __doc__ = Accessibility.__doc__.replace(f"default {Accessibility.default}", f"default {default}") + + +class Logic(DefaultOnToggle): + """Whether the seed should be guaranteed completable.""" + display_name = "Use Logic" + + +class PowerSeals(DefaultOnToggle): + """Whether power seal locations should be randomized.""" + display_name = "Shuffle Seals" + + +class Goal(Choice): + """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" + display_name = "Goal" + option_open_music_box = 0 + option_power_seal_hunt = 1 + + +class MusicBox(DefaultOnToggle): + """Whether the music box gauntlet needs to be done.""" + display_name = "Music Box Gauntlet" + + +class NotesNeeded(Range): + """How many notes are needed to access the Music Box.""" + display_name = "Notes Needed" + range_start = 1 + range_end = 6 + default = range_end + + +class AmountSeals(Range): + """Number of power seals that exist in the item pool when power seal hunt is the goal.""" + display_name = "Total Power Seals" + range_start = 1 + range_end = 45 + default = range_end + + +class RequiredSeals(Range): + """Percentage of total seals required to open the shop chest.""" + display_name = "Percent Seals Required" + range_start = 10 + range_end = 100 + default = range_end + + +messenger_options = { + "accessibility": MessengerAccessibility, + "enable_logic": Logic, + "shuffle_seals": PowerSeals, + "goal": Goal, + "music_box": MusicBox, + "notes_needed": NotesNeeded, + "total_seals": AmountSeals, + "percent_seals_required": RequiredSeals, + "death_link": DeathLink, +} diff --git a/worlds/messenger/Regions.py b/worlds/messenger/Regions.py new file mode 100644 index 0000000000..468c69cfd8 --- /dev/null +++ b/worlds/messenger/Regions.py @@ -0,0 +1,52 @@ +from typing import Dict, Set, List + +REGIONS: Dict[str, List[str]] = { + "Menu": [], + "Tower HQ": [], + "The Shop": [], + "Tower of Time": [], + "Ninja Village": ["Candle", "Astral Seed"], + "Autumn Hills": ["Climbing Claws", "Key of Hope"], + "Forlorn Temple": ["Demon King Crown"], + "Catacombs": ["Necro", "Ruxxtin's Amulet"], + "Bamboo Creek": ["Claustro"], + "Howling Grotto": ["Wingsuit"], + "Quillshroom Marsh": ["Seashell"], + "Searing Crags": ["Rope Dart"], + "Searing Crags Upper": ["Power Thistle", "Key of Strength", "Astral Tea Leaves"], + "Glacial Peak": [], + "Cloud Ruins": ["Acro"], + "Underworld": ["Pyro", "Key of Chaos"], + "Dark Cave": [], + "Riviere Turquoise": ["Fairy Bottle"], + "Sunken Shrine": ["Ninja Tabi", "Sun Crest", "Moon Crest", "Key of Love"], + "Elemental Skylands": ["Key of Symbiosis"], + "Corrupted Future": ["Key of Courage"], + "Music Box": ["Rescue Phantom"] +} +"""seal locations have the region in their name and may not need to be created so skip them here""" + + +REGION_CONNECTIONS: Dict[str, Set[str]] = { + "Menu": {"Tower HQ"}, + "Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time", "Riviere Turquoise", + "Sunken Shrine", "Corrupted Future", "The Shop", "Music Box"}, + "Tower of Time": set(), + "Ninja Village": set(), + "Autumn Hills": {"Ninja Village", "Forlorn Temple", "Catacombs"}, + "Forlorn Temple": {"Catacombs", "Bamboo Creek"}, + "Catacombs": {"Autumn Hills", "Bamboo Creek", "Dark Cave"}, + "Bamboo Creek": {"Catacombs", "Howling Grotto"}, + "Howling Grotto": {"Bamboo Creek", "Quillshroom Marsh", "Sunken Shrine"}, + "Quillshroom Marsh": {"Howling Grotto", "Searing Crags"}, + "Searing Crags": {"Searing Crags Upper", "Quillshroom Marsh", "Underworld"}, + "Searing Crags Upper": {"Searing Crags", "Glacial Peak"}, + "Glacial Peak": {"Searing Crags Upper", "Tower HQ", "Cloud Ruins", "Elemental Skylands"}, + "Cloud Ruins": {"Underworld"}, + "Underworld": set(), + "Dark Cave": {"Catacombs", "Riviere Turquoise"}, + "Riviere Turquoise": set(), + "Sunken Shrine": {"Howling Grotto"}, + "Elemental Skylands": set() +} +"""Vanilla layout mapping with all Tower HQ portals open. from -> to""" diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py new file mode 100644 index 0000000000..a7e0a1a76b --- /dev/null +++ b/worlds/messenger/Rules.py @@ -0,0 +1,125 @@ +from typing import Dict, Callable, TYPE_CHECKING + +from BaseClasses import CollectionState, MultiWorld +from worlds.generic.Rules import set_rule, allow_self_locking_items +from .Options import MessengerAccessibility, Goal +from .Constants import NOTES, PHOBEKINS + +if TYPE_CHECKING: + from . import MessengerWorld +else: + MessengerWorld = object + + +class MessengerRules: + player: int + world: MessengerWorld + + def __init__(self, world: MessengerWorld): + self.player = world.player + self.world = world + + self.region_rules: Dict[str, Callable[[CollectionState], bool]] = { + "Ninja Village": self.has_wingsuit, + "Autumn Hills": self.has_wingsuit, + "Catacombs": self.has_wingsuit, + "Bamboo Creek": self.has_wingsuit, + "Searing Crags Upper": self.has_vertical, + "Cloud Ruins": lambda state: self.has_wingsuit(state) and state.has("Ruxxtin's Amulet", self.player), + "Underworld": self.has_tabi, + "Forlorn Temple": lambda state: state.has_all(PHOBEKINS, self.player) and self.has_wingsuit(state), + "Glacial Peak": self.has_vertical, + "Elemental Skylands": lambda state: state.has("Fairy Bottle", self.player), + "Music Box": lambda state: state.has_all(NOTES, self.player) + } + + self.location_rules: Dict[str, Callable[[CollectionState], bool]] = { + # ninja village + "Ninja Village Seal - Tree House": self.has_dart, + # autumn hills + "Key of Hope": self.has_dart, + # howling grotto + "Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit, + "Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state), + # searing crags + "Key of Strength": lambda state: state.has("Power Thistle", self.player), + # glacial peak + "Glacial Peak Seal - Ice Climbers": self.has_dart, + "Glacial Peak Seal - Projectile Spike Pit": self.has_vertical, + "Glacial Peak Seal - Glacial Air Swag": self.has_vertical, + # tower of time + "Tower of Time Seal - Time Waster Seal": self.has_dart, + "Tower of Time Seal - Lantern Climb": self.has_wingsuit, + "Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state), + # underworld + "Underworld Seal - Sharp and Windy Climb": self.has_wingsuit, + "Underworld Seal - Fireball Wave": self.has_wingsuit, + "Underworld Seal - Rising Fanta": self.has_dart, + # sunken shrine + "Sun Crest": self.has_tabi, + "Moon Crest": self.has_tabi, + "Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), + "Sunken Shrine Seal - Waterfall Paradise": self.has_tabi, + "Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi, + # riviere turquoise + "Fairy Bottle": self.has_vertical, + "Riviere Turquoise Seal - Flower Power": self.has_vertical, + # elemental skylands + "Key of Symbiosis": self.has_dart, + "Elemental Skylands Seal - Air": self.has_wingsuit, + "Elemental Skylands Seal - Water": self.has_dart, + "Elemental Skylands Seal - Fire": self.has_dart, + # corrupted future + "Key of Courage": lambda state: state.has_all({"Demon King Crown", "Fairy Bottle"}, self.player), + # the shop + "Shop Chest": self.has_enough_seals + } + + def has_wingsuit(self, state: CollectionState) -> bool: + return state.has("Wingsuit", self.player) + + def has_dart(self, state: CollectionState) -> bool: + return state.has("Rope Dart", self.player) + + def has_tabi(self, state: CollectionState) -> bool: + return state.has("Ninja Tabi", self.player) + + def has_vertical(self, state: CollectionState) -> bool: + return self.has_wingsuit(state) or self.has_dart(state) + + def has_enough_seals(self, state: CollectionState) -> bool: + required_seals = state.multiworld.worlds[self.player].required_seals + return state.has("Power Seal", self.player, required_seals) + + def set_messenger_rules(self) -> None: + multiworld = self.world.multiworld + + for region in multiworld.get_regions(self.player): + if region.name in self.region_rules: + for entrance in region.entrances: + entrance.access_rule = self.region_rules[region.name] + for loc in region.locations: + if loc.name in self.location_rules: + loc.access_rule = self.location_rules[loc.name] + if multiworld.goal[self.player] == Goal.option_power_seal_hunt: + set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), + lambda state: state.has("Shop Chest", self.player)) + + if multiworld.enable_logic[self.player]: + multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) + else: + multiworld.accessibility[self.player].value = MessengerAccessibility.option_minimal + if multiworld.accessibility[self.player] > MessengerAccessibility.option_locations: + set_self_locking_items(multiworld, self.player) + + +def set_self_locking_items(multiworld: MultiWorld, player: int) -> None: + # do the ones for seal shuffle on and off first + allow_self_locking_items(multiworld.get_location("Key of Strength", player), "Power Thistle") + allow_self_locking_items(multiworld.get_location("Key of Love", player), "Sun Crest", "Moon Crest") + allow_self_locking_items(multiworld.get_location("Key of Courage", player), "Demon King Crown") + + # add these locations when seals aren't shuffled + if not multiworld.shuffle_seals[player]: + allow_self_locking_items(multiworld.get_region("Cloud Ruins", player), "Ruxxtin's Amulet") + allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py new file mode 100644 index 0000000000..32803f5e0d --- /dev/null +++ b/worlds/messenger/SubClasses.py @@ -0,0 +1,58 @@ +from typing import Set, TYPE_CHECKING, Optional, Dict + +from BaseClasses import Region, Location, Item, ItemClassification, Entrance +from .Constants import SEALS, NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS +from .Options import Goal +from .Regions import REGIONS + +if TYPE_CHECKING: + from . import MessengerWorld +else: + MessengerWorld = object + + +class MessengerRegion(Region): + def __init__(self, name: str, world: MessengerWorld): + super().__init__(name, world.player, world.multiworld) + self.add_locations(self.multiworld.worlds[self.player].location_name_to_id) + world.multiworld.regions.append(self) + + def add_locations(self, name_to_id: Dict[str, int]) -> None: + for loc in REGIONS[self.name]: + self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None))) + if self.name == "The Shop" and self.multiworld.goal[self.player] > Goal.option_open_music_box: + self.locations.append(MessengerLocation("Shop Chest", self, name_to_id.get("Shop Chest", None))) + # putting some dumb special case for searing crags and ToT so i can split them into 2 regions + if self.multiworld.shuffle_seals[self.player] and self.name not in {"Searing Crags", "Tower HQ"}: + for seal_loc in SEALS: + if seal_loc.startswith(self.name.split(" ")[0]): + self.locations.append(MessengerLocation(seal_loc, self, name_to_id.get(seal_loc, None))) + + def add_exits(self, exits: Set[str]) -> None: + for exit in exits: + ret = Entrance(self.player, f"{self.name} -> {exit}", self) + self.exits.append(ret) + ret.connect(self.multiworld.get_region(exit, self.player)) + + +class MessengerLocation(Location): + game = "The Messenger" + + def __init__(self, name: str, parent: MessengerRegion, loc_id: Optional[int]): + super().__init__(parent.player, name, loc_id, parent) + if loc_id is None: + self.place_locked_item(MessengerItem(name, parent.player, None)) + + +class MessengerItem(Item): + game = "The Messenger" + + def __init__(self, name: str, player: int, item_id: Optional[int] = None): + if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS} or item_id is None: + item_class = ItemClassification.progression + elif name in USEFUL_ITEMS: + item_class = ItemClassification.useful + else: + item_class = ItemClassification.filler + super().__init__(name, item_class, item_id, player) + diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py new file mode 100644 index 0000000000..1c42b30494 --- /dev/null +++ b/worlds/messenger/__init__.py @@ -0,0 +1,125 @@ +from typing import Dict, Any, List, Optional + +from BaseClasses import Tutorial, ItemClassification +from worlds.AutoWorld import World, WebWorld +from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS, ALWAYS_LOCATIONS, SEALS, ALL_ITEMS +from .Options import messenger_options, NotesNeeded, Goal, PowerSeals +from .Regions import REGIONS, REGION_CONNECTIONS +from .Rules import MessengerRules +from .SubClasses import MessengerRegion, MessengerItem + + +class MessengerWeb(WebWorld): + theme = "ocean" + + bug_report_page = "https://github.com/minous27/TheMessengerRandomizerMod/issues" + + tut_en = Tutorial( + "Multiworld Setup Tutorial", + "A guide to setting up The Messenger randomizer on your computer.", + "English", + "setup_en.md", + "setup/en", + ["alwaysintreble"] + ) + + tutorials = [tut_en] + + +class MessengerWorld(World): + """ + As a demon army besieges his village, a young ninja ventures through a cursed world, to deliver a scroll paramount + to his clan’s survival. What begins as a classic action platformer soon unravels into an expansive time-traveling + adventure full of thrills, surprises, and humor. + """ + game = "The Messenger" + + item_name_groups = { + "Notes": set(NOTES), + "Keys": set(NOTES), + "Crest": {"Sun Crest", "Moon Crest"}, + "Phobe": set(PHOBEKINS), + "Phobekin": set(PHOBEKINS), + "Shuriken": {"Windmill Shuriken"}, + } + + option_definitions = messenger_options + + base_offset = 0xADD_000 + item_name_to_id = {item: item_id + for item_id, item in enumerate(ALL_ITEMS, base_offset)} + location_name_to_id = {location: location_id + for location_id, location in enumerate([*ALWAYS_LOCATIONS, *SEALS], base_offset)} + + data_version = 1 + + web = MessengerWeb() + + total_seals: Optional[int] = None + required_seals: Optional[int] = None + + def generate_early(self) -> None: + if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + self.multiworld.shuffle_seals[self.player].value = PowerSeals.option_true + self.total_seals = self.multiworld.total_seals[self.player].value + self.required_seals = int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals) + + def create_regions(self) -> None: + for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: + if region.name in REGION_CONNECTIONS: + region.add_exits(REGION_CONNECTIONS[region.name]) + + def create_items(self) -> None: + itempool: List[MessengerItem] = [] + if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] + for i in range(self.required_seals): + seals[i].classification = ItemClassification.progression_skip_balancing + itempool += seals + else: + notes = self.multiworld.random.sample(NOTES, k=len(NOTES)) + precollected_notes_amount = NotesNeeded.range_end - self.multiworld.notes_needed[self.player] + if precollected_notes_amount: + for note in notes[:precollected_notes_amount]: + self.multiworld.push_precollected(self.create_item(note)) + itempool += [self.create_item(note) for note in notes[precollected_notes_amount:]] + + itempool += [self.create_item(item) + for item in self.item_name_to_id + if item not in + { + "Power Seal", "Time Shard", *NOTES, + *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]} + # this is a set and currently won't create items for anything that appears in here at all + # if we get in a position where this can have duplicates of items that aren't Power Seals + # or Time shards, this will need to be redone. + }] + itempool += [self.create_filler() + for _ in range(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool))] + + self.multiworld.itempool += itempool + + def set_rules(self) -> None: + MessengerRules(self).set_messenger_rules() + + def fill_slot_data(self) -> Dict[str, Any]: + locations: Dict[int, List[str]] = {} + for loc in self.multiworld.get_filled_locations(self.player): + if loc.item.code: + locations[loc.address] = [loc.item.name, self.multiworld.player_name[loc.item.player]] + + return { + "deathlink": self.multiworld.death_link[self.player].value, + "goal": self.multiworld.goal[self.player].current_key, + "music_box": self.multiworld.music_box[self.player].value, + "required_seals": self.required_seals, + "locations": locations, + "settings": {"Difficulty": "Basic" if not self.multiworld.shuffle_seals[self.player] else "Advanced"} + } + + def get_filler_item_name(self) -> str: + return "Time Shard" + + def create_item(self, name: str) -> MessengerItem: + item_id: Optional[int] = self.item_name_to_id.get(name, None) + return MessengerItem(name, self.player, item_id) diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md new file mode 100644 index 0000000000..16faa97cd9 --- /dev/null +++ b/worlds/messenger/docs/en_The Messenger.md @@ -0,0 +1,73 @@ +# The Messenger + +## Quick Links +- [Setup](../../../../tutorial/The%20Messenger/setup/en) +- [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Courier Github](https://github.com/Brokemia/Courier) +- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod) +- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) +- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack) + +## What does randomization do in this game? + +All items and upgrades that can be picked up by the player in the game are randomized. The player starts in the Tower of +Time HQ with the past section finished, all area portals open, and with the cloud step, and climbing claws already +obtained. You'll be forced to do sections of the game in different ways with your current abilities. Currently, logic +assumes you already have all shop upgrades. + +## What items can appear in other players' worlds? + +* The player's movement items +* Quest and pedestal items +* Music Box notes +* The Phobekins +* Time shards +* Power Seals + +## Where can I find items? + +You can find items wherever items can be picked up in the original game. This includes: +* Shopkeeper dialog where the player originally gains movement items +* Quest Item pickups +* Music Box notes +* Phobekins +* Power seals + +## What are the item name groups? + +When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a +group of items. Hinting for a group will choose a random item from the group that you do not currently have and hint +for it. The groups you can use for The Messenger are: +* Notes - This covers the music notes +* Keys - An alternative name for the music notes +* Crest - The Sun and Moon Crests +* Phobekin - Any of the Phobekins +* Phobe - An alternative name for the Phobekins +* Shuriken - The windmill shuriken + +## Other changes + +* The player can return to the Tower of Time HQ at any point by selecting the button from the options menu + * This can cause issues if used at specific times. Current known: + * During Boss fights + * After Courage Note collection (Corrupted Future chase) + * This is currently an expected action in logic. If you do need to teleport during this chase sequence, it + is recommended to quit to title and reload the save +* After reaching ninja village a teleport option is added to the menu to reach it quickly +* Toggle Windmill Shuriken button is added to option menu once the item is received + +## Currently known issues +* Necro cutscene will sometimes not play correctly, but will still reward the item +* Ruxxtin Coffin cutscene will sometimes not play correctly, but will still reward the item +* If you receive the Fairy Bottle while in Quillshroom Marsh, The Decurse Queen cutscene will not play. You can exit + to Searing Crags and re-enter to get it to play correctly. +* If you defeat Barma'thazël, the cutscene afterward will not play correctly since that is what normally transitions + you to 2nd quest. The game will not kill you if you fall here, so you can teleport to HQ at any point after defeating him. +* Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the + player. +* Text entry menus don't accept controller input + +## What do I do if I have a problem? + +If you believe something happened that isn't intended, please get the `log.txt`from the folder of your game installation +and send a bug report either on github or the [Archipelago Discord Server](http://archipelago.gg/discord) diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md new file mode 100644 index 0000000000..3b88503362 --- /dev/null +++ b/worlds/messenger/docs/setup_en.md @@ -0,0 +1,50 @@ +# The Messenger Randomizer Setup Guide + +## Quick Links +- [Game Info](../../../../games/The%20Messenger/info/en) +- [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Courier Github](https://github.com/Brokemia/Courier) +- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod) +- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) +- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack) + +## Installation + +1. Download and install Courier Mod Loader using the instructions on the release page + * [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases) +2. Download and install the randomizer mod + 1. Download the latest TheMessengerRandomizer.zip from the [The Messenger Randomizer Mod releases page](https://github.com/minous27/TheMessengerRandomizerMod/releases) + 2. Extract the zip file to `TheMessenger/Mods/` of your game's install location + 3. Optionally, Backup your save game + * On Windows + 1. Press `Windows Key + R` to open run + 2. Type `%appdata%` to access AppData + 3. Navigate to `AppData/locallow/SabotageStudios/The Messenger` + 4. Rename `SaveGame.txt` to any name of your choice + * On Linux + 1. Navigate to `steamapps/compatdata/764790/pfx/drive_c/users/steamuser/AppData/LocalLow/Sabotage Studio/The Messenger` + 2. Rename `SaveGame.txt` to any name of your choice + +## Joining a MultiWorld Game + +1. Launch the game +2. Navigate to `Options > Third Party Mod Options` +3. Select `Reset Randomizer File Slots` + * This will set up all of your save slots with new randomizer save files. You can have up to 3 randomizer files at a + time, but must do this step again to start new runs afterwards. +4. Enter connection info using the relevant option buttons + * **The game is limited to alphanumerical characters and `-` so when entering the host name replace `.` with ` ` and + ensure that your player name when generating a settings file follows these constrictions** + * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the + website. +5. Select the `Connect to Archipelago` button +6. Navigate to save file selection +7. Select a new valid randomizer save + +## Troubleshooting + +If you launch the game, and it hangs on the splash screen for more than 30 seconds try these steps: +1. Close the game and remove `TheMessengerRandomizer` from the `Mods` folder. +2. Launch The Messenger +3. Delete any save slot +4. Reinstall the randomizer mod following step 2 of the installation. \ No newline at end of file diff --git a/worlds/messenger/test/TestAccess.py b/worlds/messenger/test/TestAccess.py new file mode 100644 index 0000000000..eba4ad9bbf --- /dev/null +++ b/worlds/messenger/test/TestAccess.py @@ -0,0 +1,149 @@ +from . import MessengerTestBase +from ..Constants import NOTES, PHOBEKINS +from ..Options import MessengerAccessibility + + +class AccessTest(MessengerTestBase): + + def testTabi(self) -> None: + """locations that hard require the Ninja Tabi""" + locations = ["Pyro", "Key of Chaos", "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta", "Sun Crest", "Moon Crest", + "Sunken Shrine Seal - Waterfall Paradise", "Sunken Shrine Seal - Tabi Gauntlet"] + items = [["Ninja Tabi"]] + self.assertAccessDependency(locations, items) + + def testDart(self) -> None: + """locations that hard require the Rope Dart""" + locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Arcane Orbs", "Underworld Seal - Rising Fanta", "Key of Symbiosis", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire"] + items = [["Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testWingsuit(self) -> None: + """locations that hard require the Wingsuit""" + locations = ["Candle", "Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope", + "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro", + "Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls", + "Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", + "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave", + "Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", + "Forlorn Temple Seal - Rocket Sunset", "Astral Seed"] + items = [["Wingsuit"]] + self.assertAccessDependency(locations, items) + + def testVertical(self) -> None: + """locations that require either the Rope Dart or the Wingsuit""" + locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal", + "Underworld Seal - Rising Fanta", "Key of Symbiosis", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire", "Candle", + "Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope", + "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro", + "Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls", + "Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", + "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave", + "Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + "Power Thistle", "Key of Strength", "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", "Fairy Bottle", "Riviere Turquoise Seal - Flower Power", + "Searing Crags Seal - Triple Ball Spinner", "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", "Astral Seed", "Astral Tea Leaves"] + items = [["Wingsuit", "Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testAmulet(self) -> None: + """Locations that require Ruxxtin's Amulet""" + locations = ["Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"] + # Cloud Ruins requires Ruxxtin's Amulet + items = [["Ruxxtin's Amulet"]] + self.assertAccessDependency(locations, items) + + def testBottle(self) -> None: + """Elemental Skylands and Corrupted Future require the Fairy Bottle""" + locations = ["Key of Symbiosis", "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Fire", + "Elemental Skylands Seal - Water", "Key of Courage"] + items = [["Fairy Bottle"]] + self.assertAccessDependency(locations, items) + + def testCrests(self) -> None: + """Test Key of Love nonsense""" + locations = ["Key of Love"] + items = [["Sun Crest", "Moon Crest"]] + self.assertAccessDependency(locations, items) + self.collect_all_but("Sun Crest") + self.assertEqual(self.can_reach_location("Key of Love"), False) + self.remove(self.get_item_by_name("Moon Crest")) + self.collect_by_name("Sun Crest") + self.assertEqual(self.can_reach_location("Key of Love"), False) + + def testThistle(self) -> None: + """I'm a chuckster!""" + locations = ["Key of Strength"] + items = [["Power Thistle"]] + self.assertAccessDependency(locations, items) + + def testCrown(self) -> None: + """Crocomire but not""" + locations = ["Key of Courage"] + items = [["Demon King Crown"]] + self.assertAccessDependency(locations, items) + + def testGoal(self) -> None: + """Test some different states to verify goal requires the correct items""" + self.collect_all_but([*NOTES, "Rescue Phantom"]) + self.assertEqual(self.can_reach_location("Rescue Phantom"), False) + self.collect_all_but(["Key of Love", "Rescue Phantom"]) + self.assertBeatable(False) + self.collect_by_name(["Key of Love"]) + self.assertEqual(self.can_reach_location("Rescue Phantom"), True) + self.assertBeatable(True) + + +class ItemsAccessTest(MessengerTestBase): + options = { + "shuffle_seals": False, + "accessibility": MessengerAccessibility.option_items + } + + def testSelfLockingItems(self) -> None: + """Force items that can be self locked to ensure it's valid placement.""" + location_lock_pairs = { + "Key of Strength": ["Power Thistle"], + "Key of Love": ["Sun Crest", "Moon Crest"], + "Key of Courage": ["Demon King Crown"], + "Acro": ["Ruxxtin's Amulet"], + "Demon King Crown": PHOBEKINS + } + + for loc in location_lock_pairs: + for item_name in location_lock_pairs[loc]: + item = self.get_item_by_name(item_name) + with self.subTest("Fulfills Accessibility", location=loc, item=item_name): + self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, True)) + + +class NoLogicTest(MessengerTestBase): + options = { + "enable_logic": "false" + } + + def testNoLogic(self) -> None: + """Test some funny locations to make sure they aren't reachable but we can still win""" + self.assertEqual(self.can_reach_location("Pyro"), False) + self.assertEqual(self.can_reach_location("Rescue Phantom"), False) + self.assertBeatable(True) diff --git a/worlds/messenger/test/TestNotes.py b/worlds/messenger/test/TestNotes.py new file mode 100644 index 0000000000..07745e3397 --- /dev/null +++ b/worlds/messenger/test/TestNotes.py @@ -0,0 +1,30 @@ +from . import MessengerTestBase +from ..Constants import NOTES + + +class TwoNoteGoalTest(MessengerTestBase): + options = { + "notes_needed": 2, + } + + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4) + + +class FourNoteGoalTest(MessengerTestBase): + options = { + "notes_needed": 4, + } + + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2) + + +class DefaultGoalTest(MessengerTestBase): + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0) + + def testGoal(self) -> None: + self.assertBeatable(False) + self.collect_by_name(NOTES) + self.assertBeatable(True) diff --git a/worlds/messenger/test/TestShopChest.py b/worlds/messenger/test/TestShopChest.py new file mode 100644 index 0000000000..c3f2c4dd55 --- /dev/null +++ b/worlds/messenger/test/TestShopChest.py @@ -0,0 +1,79 @@ +from BaseClasses import ItemClassification, CollectionState +from . import MessengerTestBase + + +class NoLogicTest(MessengerTestBase): + options = { + "enable_logic": "false", + "goal": "power_seal_hunt", + } + + def testChestAccess(self): + """Test to make sure we can win even though we can't reach the chest.""" + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(True) + + +class AllSealsRequired(MessengerTestBase): + options = { + "shuffle_seals": "false", + "goal": "power_seal_hunt", + } + + def testSealsShuffled(self) -> None: + """Shuffle seals should be forced on when shop chest is the goal so test it.""" + self.assertTrue(self.multiworld.shuffle_seals[self.player]) + + def testChestAccess(self) -> None: + """Defaults to a total of 45 power seals in the pool and required.""" + with self.subTest("Access Dependency"): + self.assertEqual(len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]), + self.multiworld.total_seals[self.player]) + locations = ["Shop Chest"] + items = [["Power Seal"]] + self.assertAccessDependency(locations, items) + self.multiworld.state = CollectionState(self.multiworld) + + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(False) + self.collect_all_but(["Power Seal", "Shop Chest", "Rescue Phantom"]) + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(False) + self.collect_by_name("Power Seal") + self.assertEqual(self.can_reach_location("Shop Chest"), True) + self.assertBeatable(True) + + +class HalfSealsRequired(MessengerTestBase): + options = { + "goal": "power_seal_hunt", + "percent_seals_required": 50, + } + + def testSealsAmount(self) -> None: + """Should have 45 power seals in the item pool and half that required""" + self.assertEqual(self.multiworld.total_seals[self.player], 45) + self.assertEqual(self.multiworld.worlds[self.player].total_seals, 45) + self.assertEqual(self.multiworld.worlds[self.player].required_seals, 22) + total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] + required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + self.assertEqual(len(total_seals), 45) + self.assertEqual(len(required_seals), 22) + + +class ThirtyThirtySeals(MessengerTestBase): + options = { + "goal": "power_seal_hunt", + "total_seals": 30, + "percent_seals_required": 34, + } + + def testSealsAmount(self) -> None: + """Should have 30 power seals in the pool and 33 percent of that required.""" + self.assertEqual(self.multiworld.total_seals[self.player], 30) + self.assertEqual(self.multiworld.worlds[self.player].total_seals, 30) + self.assertEqual(self.multiworld.worlds[self.player].required_seals, 10) + total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] + required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + self.assertEqual(len(total_seals), 30) + self.assertEqual(len(required_seals), 10) diff --git a/worlds/messenger/test/__init__.py b/worlds/messenger/test/__init__.py new file mode 100644 index 0000000000..7ab1e11781 --- /dev/null +++ b/worlds/messenger/test/__init__.py @@ -0,0 +1,6 @@ +from test.TestBase import WorldTestBase + + +class MessengerTestBase(WorldTestBase): + game = "The Messenger" + player: int = 1 diff --git a/worlds/minecraft/Constants.py b/worlds/minecraft/Constants.py new file mode 100644 index 0000000000..0d1101e802 --- /dev/null +++ b/worlds/minecraft/Constants.py @@ -0,0 +1,26 @@ +import os +import json +import pkgutil + +def load_data_file(*args) -> dict: + fname = os.path.join("data", *args) + return json.loads(pkgutil.get_data(__name__, fname).decode()) + +# For historical reasons, these values are different. +# They remain different to ensure datapackage consistency. +# Do not separate other games' location and item IDs like this. +item_id_offset: int = 45000 +location_id_offset: int = 42000 + +item_info = load_data_file("items.json") +item_name_to_id = {name: item_id_offset + index \ + for index, name in enumerate(item_info["all_items"])} +item_name_to_id["Bee Trap"] = item_id_offset + 100 # historical reasons + +location_info = load_data_file("locations.json") +location_name_to_id = {name: location_id_offset + index \ + for index, name in enumerate(location_info["all_locations"])} + +exclusion_info = load_data_file("excluded_locations.json") + +region_info = load_data_file("regions.json") diff --git a/worlds/minecraft/ItemPool.py b/worlds/minecraft/ItemPool.py new file mode 100644 index 0000000000..78eeffca80 --- /dev/null +++ b/worlds/minecraft/ItemPool.py @@ -0,0 +1,52 @@ +from math import ceil +from typing import List + +from BaseClasses import MultiWorld, Item +from worlds.AutoWorld import World + +from . import Constants + +def get_junk_item_names(rand, k: int) -> str: + junk_weights = Constants.item_info["junk_weights"] + junk = rand.choices( + list(junk_weights.keys()), + weights=list(junk_weights.values()), + k=k) + return junk + +def build_item_pool(mc_world: World) -> List[Item]: + multiworld = mc_world.multiworld + player = mc_world.player + + itempool = [] + total_location_count = len(multiworld.get_unfilled_locations(player)) + + required_pool = Constants.item_info["required_pool"] + junk_weights = Constants.item_info["junk_weights"] + + # Add required progression items + for item_name, num in required_pool.items(): + itempool += [mc_world.create_item(item_name) for _ in range(num)] + + # Add structure compasses + if multiworld.structure_compasses[player]: + compasses = [name for name in mc_world.item_name_to_id if "Structure Compass" in name] + for item_name in compasses: + itempool.append(mc_world.create_item(item_name)) + + # Dragon egg shards + if multiworld.egg_shards_required[player] > 0: + num = multiworld.egg_shards_available[player] + itempool += [mc_world.create_item("Dragon Egg Shard") for _ in range(num)] + + # Bee traps + bee_trap_percentage = multiworld.bee_traps[player] * 0.01 + if bee_trap_percentage > 0: + bee_trap_qty = ceil(bee_trap_percentage * (total_location_count - len(itempool))) + itempool += [mc_world.create_item("Bee Trap") for _ in range(bee_trap_qty)] + + # Fill remaining itempool with randomly generated junk + junk = get_junk_item_names(multiworld.random, total_location_count - len(itempool)) + itempool += [mc_world.create_item(name) for name in junk] + + return itempool diff --git a/worlds/minecraft/Items.py b/worlds/minecraft/Items.py deleted file mode 100644 index 6cf8447c8f..0000000000 --- a/worlds/minecraft/Items.py +++ /dev/null @@ -1,108 +0,0 @@ -from BaseClasses import Item -import typing - - -class ItemData(typing.NamedTuple): - code: typing.Optional[int] - progression: bool - - -class MinecraftItem(Item): - game: str = "Minecraft" - - -item_table = { - "Archery": ItemData(45000, True), - "Progressive Resource Crafting": ItemData(45001, True), - # "Resource Blocks": ItemData(45002, True), - "Brewing": ItemData(45003, True), - "Enchanting": ItemData(45004, True), - "Bucket": ItemData(45005, True), - "Flint and Steel": ItemData(45006, True), - "Bed": ItemData(45007, True), - "Bottles": ItemData(45008, True), - "Shield": ItemData(45009, True), - "Fishing Rod": ItemData(45010, True), - "Campfire": ItemData(45011, True), - "Progressive Weapons": ItemData(45012, True), - "Progressive Tools": ItemData(45013, True), - "Progressive Armor": ItemData(45014, True), - "8 Netherite Scrap": ItemData(45015, True), - "8 Emeralds": ItemData(45016, False), - "4 Emeralds": ItemData(45017, False), - "Channeling Book": ItemData(45018, True), - "Silk Touch Book": ItemData(45019, True), - "Sharpness III Book": ItemData(45020, False), - "Piercing IV Book": ItemData(45021, True), - "Looting III Book": ItemData(45022, False), - "Infinity Book": ItemData(45023, False), - "4 Diamond Ore": ItemData(45024, False), - "16 Iron Ore": ItemData(45025, False), - "500 XP": ItemData(45026, False), - "100 XP": ItemData(45027, False), - "50 XP": ItemData(45028, False), - "3 Ender Pearls": ItemData(45029, True), - "4 Lapis Lazuli": ItemData(45030, False), - "16 Porkchops": ItemData(45031, False), - "8 Gold Ore": ItemData(45032, False), - "Rotten Flesh": ItemData(45033, False), - "Single Arrow": ItemData(45034, False), - "32 Arrows": ItemData(45035, False), - "Saddle": ItemData(45036, True), - "Structure Compass (Village)": ItemData(45037, True), - "Structure Compass (Pillager Outpost)": ItemData(45038, True), - "Structure Compass (Nether Fortress)": ItemData(45039, True), - "Structure Compass (Bastion Remnant)": ItemData(45040, True), - "Structure Compass (End City)": ItemData(45041, True), - "Shulker Box": ItemData(45042, False), - "Dragon Egg Shard": ItemData(45043, True), - "Spyglass": ItemData(45044, True), - "Lead": ItemData(45045, True), - - "Bee Trap": ItemData(45100, False), - "Blaze Rods": ItemData(None, True), - "Defeat Ender Dragon": ItemData(None, True), - "Defeat Wither": ItemData(None, True), -} - -# 33 required items -required_items = { - "Archery": 1, - "Progressive Resource Crafting": 2, - "Brewing": 1, - "Enchanting": 1, - "Bucket": 1, - "Flint and Steel": 1, - "Bed": 1, - "Bottles": 1, - "Shield": 1, - "Fishing Rod": 1, - "Campfire": 1, - "Progressive Weapons": 3, - "Progressive Tools": 3, - "Progressive Armor": 2, - "8 Netherite Scrap": 2, - "Channeling Book": 1, - "Silk Touch Book": 1, - "Sharpness III Book": 1, - "Piercing IV Book": 1, - "Looting III Book": 1, - "Infinity Book": 1, - "3 Ender Pearls": 4, - "Saddle": 1, - "Spyglass": 1, - "Lead": 1, -} - -junk_weights = { - "4 Emeralds": 2, - "4 Diamond Ore": 1, - "16 Iron Ore": 1, - "50 XP": 4, - "16 Porkchops": 2, - "8 Gold Ore": 1, - "Rotten Flesh": 1, - "32 Arrows": 1, -} - -lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code} diff --git a/worlds/minecraft/Locations.py b/worlds/minecraft/Locations.py deleted file mode 100644 index 46398ab11e..0000000000 --- a/worlds/minecraft/Locations.py +++ /dev/null @@ -1,192 +0,0 @@ -from BaseClasses import Location -import typing - - -class AdvData(typing.NamedTuple): - id: typing.Optional[int] - region: str - - -class MinecraftAdvancement(Location): - game: str = "Minecraft" - - def __init__(self, player: int, name: str, address: typing.Optional[int], parent): - super().__init__(player, name, address, parent) - self.event = not address - - -advancement_table = { - "Who is Cutting Onions?": AdvData(42000, 'Overworld'), - "Oh Shiny": AdvData(42001, 'Overworld'), - "Suit Up": AdvData(42002, 'Overworld'), - "Very Very Frightening": AdvData(42003, 'Overworld'), - "Hot Stuff": AdvData(42004, 'Overworld'), - "Free the End": AdvData(42005, 'The End'), - "A Furious Cocktail": AdvData(42006, 'Nether Fortress'), - "Best Friends Forever": AdvData(42007, 'Overworld'), - "Bring Home the Beacon": AdvData(42008, 'Nether Fortress'), - "Not Today, Thank You": AdvData(42009, 'Overworld'), - "Isn't It Iron Pick": AdvData(42010, 'Overworld'), - "Local Brewery": AdvData(42011, 'Nether Fortress'), - "The Next Generation": AdvData(42012, 'The End'), - "Fishy Business": AdvData(42013, 'Overworld'), - "Hot Tourist Destinations": AdvData(42014, 'The Nether'), - "This Boat Has Legs": AdvData(42015, 'The Nether'), - "Sniper Duel": AdvData(42016, 'Overworld'), - "Nether": AdvData(42017, 'The Nether'), - "Great View From Up Here": AdvData(42018, 'End City'), - "How Did We Get Here?": AdvData(42019, 'Nether Fortress'), - "Bullseye": AdvData(42020, 'Overworld'), - "Spooky Scary Skeleton": AdvData(42021, 'Nether Fortress'), - "Two by Two": AdvData(42022, 'The Nether'), - "Stone Age": AdvData(42023, 'Overworld'), - "Two Birds, One Arrow": AdvData(42024, 'Overworld'), - "We Need to Go Deeper": AdvData(42025, 'The Nether'), - "Who's the Pillager Now?": AdvData(42026, 'Pillager Outpost'), - "Getting an Upgrade": AdvData(42027, 'Overworld'), - "Tactical Fishing": AdvData(42028, 'Overworld'), - "Zombie Doctor": AdvData(42029, 'Overworld'), - "The City at the End of the Game": AdvData(42030, 'End City'), - "Ice Bucket Challenge": AdvData(42031, 'Overworld'), - "Remote Getaway": AdvData(42032, 'The End'), - "Into Fire": AdvData(42033, 'Nether Fortress'), - "War Pigs": AdvData(42034, 'Bastion Remnant'), - "Take Aim": AdvData(42035, 'Overworld'), - "Total Beelocation": AdvData(42036, 'Overworld'), - "Arbalistic": AdvData(42037, 'Overworld'), - "The End... Again...": AdvData(42038, 'The End'), - "Acquire Hardware": AdvData(42039, 'Overworld'), - "Not Quite \"Nine\" Lives": AdvData(42040, 'The Nether'), - "Cover Me With Diamonds": AdvData(42041, 'Overworld'), - "Sky's the Limit": AdvData(42042, 'End City'), - "Hired Help": AdvData(42043, 'Overworld'), - "Return to Sender": AdvData(42044, 'The Nether'), - "Sweet Dreams": AdvData(42045, 'Overworld'), - "You Need a Mint": AdvData(42046, 'The End'), - "Adventure": AdvData(42047, 'Overworld'), - "Monsters Hunted": AdvData(42048, 'Overworld'), - "Enchanter": AdvData(42049, 'Overworld'), - "Voluntary Exile": AdvData(42050, 'Pillager Outpost'), - "Eye Spy": AdvData(42051, 'Overworld'), - "The End": AdvData(42052, 'The End'), - "Serious Dedication": AdvData(42053, 'The Nether'), - "Postmortal": AdvData(42054, 'Village'), - "Monster Hunter": AdvData(42055, 'Overworld'), - "Adventuring Time": AdvData(42056, 'Overworld'), - "A Seedy Place": AdvData(42057, 'Overworld'), - "Those Were the Days": AdvData(42058, 'Bastion Remnant'), - "Hero of the Village": AdvData(42059, 'Village'), - "Hidden in the Depths": AdvData(42060, 'The Nether'), - "Beaconator": AdvData(42061, 'Nether Fortress'), - "Withering Heights": AdvData(42062, 'Nether Fortress'), - "A Balanced Diet": AdvData(42063, 'Village'), - "Subspace Bubble": AdvData(42064, 'The Nether'), - "Husbandry": AdvData(42065, 'Overworld'), - "Country Lode, Take Me Home": AdvData(42066, 'The Nether'), - "Bee Our Guest": AdvData(42067, 'Overworld'), - "What a Deal!": AdvData(42068, 'Village'), - "Uneasy Alliance": AdvData(42069, 'The Nether'), - "Diamonds!": AdvData(42070, 'Overworld'), - "A Terrible Fortress": AdvData(42071, 'Nether Fortress'), - "A Throwaway Joke": AdvData(42072, 'Overworld'), - "Minecraft": AdvData(42073, 'Overworld'), - "Sticky Situation": AdvData(42074, 'Overworld'), - "Ol' Betsy": AdvData(42075, 'Overworld'), - "Cover Me in Debris": AdvData(42076, 'The Nether'), - "The End?": AdvData(42077, 'The End'), - "The Parrots and the Bats": AdvData(42078, 'Overworld'), - "A Complete Catalogue": AdvData(42079, 'Village'), - "Getting Wood": AdvData(42080, 'Overworld'), - "Time to Mine!": AdvData(42081, 'Overworld'), - "Hot Topic": AdvData(42082, 'Overworld'), - "Bake Bread": AdvData(42083, 'Overworld'), - "The Lie": AdvData(42084, 'Overworld'), - "On a Rail": AdvData(42085, 'Overworld'), - "Time to Strike!": AdvData(42086, 'Overworld'), - "Cow Tipper": AdvData(42087, 'Overworld'), - "When Pigs Fly": AdvData(42088, 'Overworld'), - "Overkill": AdvData(42089, 'Nether Fortress'), - "Librarian": AdvData(42090, 'Overworld'), - "Overpowered": AdvData(42091, 'Bastion Remnant'), - "Wax On": AdvData(42092, 'Overworld'), - "Wax Off": AdvData(42093, 'Overworld'), - "The Cutest Predator": AdvData(42094, 'Overworld'), - "The Healing Power of Friendship": AdvData(42095, 'Overworld'), - "Is It a Bird?": AdvData(42096, 'Overworld'), - "Is It a Balloon?": AdvData(42097, 'The Nether'), - "Is It a Plane?": AdvData(42098, 'The End'), - "Surge Protector": AdvData(42099, 'Overworld'), - "Light as a Rabbit": AdvData(42100, 'Overworld'), - "Glow and Behold!": AdvData(42101, 'Overworld'), - "Whatever Floats Your Goat!": AdvData(42102, 'Overworld'), - "Caves & Cliffs": AdvData(42103, 'Overworld'), - "Feels like home": AdvData(42104, 'The Nether'), - "Sound of Music": AdvData(42105, 'Overworld'), - "Star Trader": AdvData(42106, 'Village'), - - # 1.19 advancements - "Birthday Song": AdvData(42107, 'Pillager Outpost'), - "Bukkit Bukkit": AdvData(42108, 'Overworld'), - "It Spreads": AdvData(42109, 'Overworld'), - "Sneak 100": AdvData(42110, 'Overworld'), - "When the Squad Hops into Town": AdvData(42111, 'Overworld'), - "With Our Powers Combined!": AdvData(42112, 'The Nether'), - "You've Got a Friend in Me": AdvData(42113, 'Pillager Outpost'), - - "Blaze Spawner": AdvData(None, 'Nether Fortress'), - "Ender Dragon": AdvData(None, 'The End'), - "Wither": AdvData(None, 'Nether Fortress'), -} - -exclusion_table = { - "hard": { - "Very Very Frightening", - "A Furious Cocktail", - "Two by Two", - "Two Birds, One Arrow", - "Arbalistic", - "Monsters Hunted", - "Beaconator", - "A Balanced Diet", - "Uneasy Alliance", - "Cover Me in Debris", - "A Complete Catalogue", - "Surge Protector", - "Sound of Music", - "Star Trader", - "When the Squad Hops into Town", - "With Our Powers Combined!", - }, - "unreasonable": { - "How Did We Get Here?", - "Adventuring Time", - }, -} - -def get_postgame_advancements(required_bosses): - - postgame_advancements = { - "ender_dragon": { - "Free the End", - "The Next Generation", - "The End... Again...", - "You Need a Mint", - "Monsters Hunted", - "Is It a Plane?", - }, - "wither": { - "Withering Heights", - "Bring Home the Beacon", - "Beaconator", - "A Furious Cocktail", - "How Did We Get Here?", - "Monsters Hunted", - } - } - - advancements = set() - if required_bosses in {"ender_dragon", "both"}: - advancements.update(postgame_advancements["ender_dragon"]) - if required_bosses in {"wither", "both"}: - advancements.update(postgame_advancements["wither"]) - return advancements diff --git a/worlds/minecraft/Options.py b/worlds/minecraft/Options.py index 161d44d9b8..084a611e44 100644 --- a/worlds/minecraft/Options.py +++ b/worlds/minecraft/Options.py @@ -6,7 +6,7 @@ class AdvancementGoal(Range): """Number of advancements required to spawn bosses.""" display_name = "Advancement Goal" range_start = 0 - range_end = 95 + range_end = 114 default = 40 @@ -14,7 +14,7 @@ class EggShardsRequired(Range): """Number of dragon egg shards to collect to spawn bosses.""" display_name = "Egg Shards Required" range_start = 0 - range_end = 40 + range_end = 74 default = 0 @@ -22,7 +22,7 @@ class EggShardsAvailable(Range): """Number of dragon egg shards available to collect.""" display_name = "Egg Shards Available" range_start = 0 - range_end = 40 + range_end = 74 default = 0 @@ -35,6 +35,14 @@ class BossGoal(Choice): option_both = 3 default = 1 + @property + def dragon(self): + return self.value % 2 == 1 + + @property + def wither(self): + return self.value > 1 + class ShuffleStructures(DefaultOnToggle): """Enables shuffling of villages, outposts, fortresses, bastions, and end cities.""" @@ -94,14 +102,16 @@ minecraft_options: typing.Dict[str, type(Option)] = { "egg_shards_required": EggShardsRequired, "egg_shards_available": EggShardsAvailable, "required_bosses": BossGoal, + "shuffle_structures": ShuffleStructures, "structure_compasses": StructureCompasses, - "bee_traps": BeeTraps, + "combat_difficulty": CombatDifficulty, "include_hard_advancements": HardAdvancements, "include_unreasonable_advancements": UnreasonableAdvancements, "include_postgame_advancements": PostgameAdvancements, + "bee_traps": BeeTraps, "send_defeated_mobs": SendDefeatedMobs, - "starting_items": StartingItems, "death_link": DeathLink, + "starting_items": StartingItems, } diff --git a/worlds/minecraft/Regions.py b/worlds/minecraft/Regions.py deleted file mode 100644 index d9f3f1b59e..0000000000 --- a/worlds/minecraft/Regions.py +++ /dev/null @@ -1,93 +0,0 @@ - -def link_minecraft_structures(world, player): - - # Link mandatory connections first - for (exit, region) in mandatory_connections: - world.get_entrance(exit, player).connect(world.get_region(region, player)) - - # Get all unpaired exits and all regions without entrances (except the Menu) - # This function is destructive on these lists. - exits = [exit.name for r in world.regions if r.player == player for exit in r.exits if exit.connected_region == None] - structs = [r.name for r in world.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] - exits_spoiler = exits[:] # copy the original order for the spoiler log - try: - assert len(exits) == len(structs) - except AssertionError as e: # this should never happen - raise Exception(f"Could not obtain equal numbers of Minecraft exits and structures for player {player} ({world.player_name[player]})") - - pairs = {} - - def set_pair(exit, struct): - if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])): - pairs[exit] = struct - exits.remove(exit) - structs.remove(struct) - else: - raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({world.player_name[player]})") - - # Connect plando structures first - if world.plando_connections[player]: - for conn in world.plando_connections[player]: - set_pair(conn.entrance, conn.exit) - - # The algorithm tries to place the most restrictive structures first. This algorithm always works on the - # relatively small set of restrictions here, but does not work on all possible inputs with valid configurations. - if world.shuffle_structures[player]: - structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, []))) - for struct in structs[:]: - try: - exit = world.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) - except IndexError: - raise Exception(f"No valid structure placements remaining for player {player} ({world.player_name[player]})") - set_pair(exit, struct) - else: # write remaining default connections - for (exit, struct) in default_connections: - if exit in exits: - set_pair(exit, struct) - - # Make sure we actually paired everything; might fail if plando - try: - assert len(exits) == len(structs) == 0 - except AssertionError: - raise Exception(f"Failed to connect all Minecraft structures for player {player} ({world.player_name[player]})") - - for exit in exits_spoiler: - world.get_entrance(exit, player).connect(world.get_region(pairs[exit], player)) - if world.shuffle_structures[player] or world.plando_connections[player]: - world.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) - - - -# (Region name, list of exits) -mc_regions = [ - ('Menu', ['New World']), - ('Overworld', ['Nether Portal', 'End Portal', 'Overworld Structure 1', 'Overworld Structure 2']), - ('The Nether', ['Nether Structure 1', 'Nether Structure 2']), - ('The End', ['The End Structure']), - ('Village', []), - ('Pillager Outpost', []), - ('Nether Fortress', []), - ('Bastion Remnant', []), - ('End City', []) -] - -# (Entrance, region pointed to) -mandatory_connections = [ - ('New World', 'Overworld'), - ('Nether Portal', 'The Nether'), - ('End Portal', 'The End') -] - -default_connections = [ - ('Overworld Structure 1', 'Village'), - ('Overworld Structure 2', 'Pillager Outpost'), - ('Nether Structure 1', 'Nether Fortress'), - ('Nether Structure 2', 'Bastion Remnant'), - ('The End Structure', 'End City') -] - -# Structure: illegal locations -illegal_connections = { - 'Nether Fortress': ['The End Structure'] -} - diff --git a/worlds/minecraft/Rules.py b/worlds/minecraft/Rules.py index 2ec9523762..dae4241b99 100644 --- a/worlds/minecraft/Rules.py +++ b/worlds/minecraft/Rules.py @@ -1,317 +1,313 @@ -from ..generic.Rules import set_rule, add_rule -from .Locations import exclusion_table, get_postgame_advancements -from BaseClasses import MultiWorld -from ..AutoWorld import LogicMixin +import typing +from collections.abc import Callable + +from BaseClasses import CollectionState +from worlds.generic.Rules import exclusion_rules +from worlds.AutoWorld import World + +from . import Constants + +# Helper functions +# moved from logicmixin + +def has_iron_ingots(state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) + +def has_copper_ingots(state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) + +def has_gold_ingots(state: CollectionState, player: int) -> bool: + return state.has('Progressive Resource Crafting', player) and (state.has('Progressive Tools', player, 2) or state.can_reach('The Nether', 'Region', player)) + +def has_diamond_pickaxe(state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player, 3) and has_iron_ingots(state, player) + +def craft_crossbow(state: CollectionState, player: int) -> bool: + return state.has('Archery', player) and has_iron_ingots(state, player) + +def has_bottle(state: CollectionState, player: int) -> bool: + return state.has('Bottles', player) and state.has('Progressive Resource Crafting', player) + +def has_spyglass(state: CollectionState, player: int) -> bool: + return has_copper_ingots(state, player) and state.has('Spyglass', player) and can_adventure(state, player) + +def can_enchant(state: CollectionState, player: int) -> bool: + return state.has('Enchanting', player) and has_diamond_pickaxe(state, player) # mine obsidian and lapis + +def can_use_anvil(state: CollectionState, player: int) -> bool: + return state.has('Enchanting', player) and state.has('Progressive Resource Crafting', player, 2) and has_iron_ingots(state, player) + +def fortress_loot(state: CollectionState, player: int) -> bool: # saddles, blaze rods, wither skulls + return state.can_reach('Nether Fortress', 'Region', player) and basic_combat(state, player) + +def can_brew_potions(state: CollectionState, player: int) -> bool: + return state.has('Blaze Rods', player) and state.has('Brewing', player) and has_bottle(state, player) + +def can_piglin_trade(state: CollectionState, player: int) -> bool: + return has_gold_ingots(state, player) and ( + state.can_reach('The Nether', 'Region', player) or + state.can_reach('Bastion Remnant', 'Region', player)) + +def overworld_villager(state: CollectionState, player: int) -> bool: + village_region = state.multiworld.get_region('Village', player).entrances[0].parent_region.name + if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village + return (state.can_reach('Zombie Doctor', 'Location', player) or + (has_diamond_pickaxe(state, player) and state.can_reach('Village', 'Region', player))) + elif village_region == 'The End': + return state.can_reach('Zombie Doctor', 'Location', player) + return state.can_reach('Village', 'Region', player) + +def enter_stronghold(state: CollectionState, player: int) -> bool: + return state.has('Blaze Rods', player) and state.has('Brewing', player) and state.has('3 Ender Pearls', player) + +# Difficulty-dependent functions +def combat_difficulty(state: CollectionState, player: int) -> bool: + return state.multiworld.combat_difficulty[player].current_key + +def can_adventure(state: CollectionState, player: int) -> bool: + death_link_check = not state.multiworld.death_link[player] or state.has('Bed', player) + if combat_difficulty(state, player) == 'easy': + return state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and death_link_check + elif combat_difficulty(state, player) == 'hard': + return True + return (state.has('Progressive Weapons', player) and death_link_check and + (state.has('Progressive Resource Crafting', player) or state.has('Campfire', player))) + +def basic_combat(state: CollectionState, player: int) -> bool: + if combat_difficulty(state, player) == 'easy': + return state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player) and \ + state.has('Shield', player) and has_iron_ingots(state, player) + elif combat_difficulty(state, player) == 'hard': + return True + return state.has('Progressive Weapons', player) and (state.has('Progressive Armor', player) or state.has('Shield', player)) and has_iron_ingots(state, player) + +def complete_raid(state: CollectionState, player: int) -> bool: + reach_regions = state.can_reach('Village', 'Region', player) and state.can_reach('Pillager Outpost', 'Region', player) + if combat_difficulty(state, player) == 'easy': + return reach_regions and \ + state.has('Progressive Weapons', player, 3) and state.has('Progressive Armor', player, 2) and \ + state.has('Shield', player) and state.has('Archery', player) and \ + state.has('Progressive Tools', player, 2) and has_iron_ingots(state, player) + elif combat_difficulty(state, player) == 'hard': # might be too hard? + return reach_regions and state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and \ + (state.has('Progressive Armor', player) or state.has('Shield', player)) + return reach_regions and state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and \ + state.has('Progressive Armor', player) and state.has('Shield', player) + +def can_kill_wither(state: CollectionState, player: int) -> bool: + normal_kill = state.has("Progressive Weapons", player, 3) and state.has("Progressive Armor", player, 2) and can_brew_potions(state, player) and can_enchant(state, player) + if combat_difficulty(state, player) == 'easy': + return fortress_loot(state, player) and normal_kill and state.has('Archery', player) + elif combat_difficulty(state, player) == 'hard': # cheese kill using bedrock ceilings + return fortress_loot(state, player) and (normal_kill or state.can_reach('The Nether', 'Region', player) or state.can_reach('The End', 'Region', player)) + return fortress_loot(state, player) and normal_kill + +def can_respawn_ender_dragon(state: CollectionState, player: int) -> bool: + return state.can_reach('The Nether', 'Region', player) and state.can_reach('The End', 'Region', player) and \ + state.has('Progressive Resource Crafting', player) # smelt sand into glass + +def can_kill_ender_dragon(state: CollectionState, player: int) -> bool: + if combat_difficulty(state, player) == 'easy': + return state.has("Progressive Weapons", player, 3) and state.has("Progressive Armor", player, 2) and \ + state.has('Archery', player) and can_brew_potions(state, player) and can_enchant(state, player) + if combat_difficulty(state, player) == 'hard': + return (state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player)) or \ + (state.has('Progressive Weapons', player, 1) and state.has('Bed', player)) + return state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player) and state.has('Archery', player) + +def has_structure_compass(state: CollectionState, entrance_name: str, player: int) -> bool: + if not state.multiworld.structure_compasses[player]: + return True + return state.has(f"Structure Compass ({state.multiworld.get_entrance(entrance_name, player).connected_region.name})", player) -class MinecraftLogic(LogicMixin): +def get_rules_lookup(player: int): + rules_lookup: typing.Dict[str, typing.List[Callable[[CollectionState], bool]]] = { + "entrances": { + "Nether Portal": lambda state: (state.has('Flint and Steel', player) and + (state.has('Bucket', player) or state.has('Progressive Tools', player, 3)) and + has_iron_ingots(state, player)), + "End Portal": lambda state: enter_stronghold(state, player) and state.has('3 Ender Pearls', player, 4), + "Overworld Structure 1": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Overworld Structure 1", player)), + "Overworld Structure 2": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Overworld Structure 2", player)), + "Nether Structure 1": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Nether Structure 1", player)), + "Nether Structure 2": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Nether Structure 2", player)), + "The End Structure": lambda state: (can_adventure(state, player) and has_structure_compass(state, "The End Structure", player)), + }, + "locations": { + "Ender Dragon": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Wither": lambda state: can_kill_wither(state, player), + "Blaze Rods": lambda state: fortress_loot(state, player), - def _mc_has_iron_ingots(self, player: int): - return self.has('Progressive Tools', player) and self.has('Progressive Resource Crafting', player) - - def _mc_has_copper_ingots(self, player: int): - return self.has('Progressive Tools', player) and self.has('Progressive Resource Crafting', player) - - def _mc_has_gold_ingots(self, player: int): - return self.has('Progressive Resource Crafting', player) and (self.has('Progressive Tools', player, 2) or self.can_reach('The Nether', 'Region', player)) - - def _mc_has_diamond_pickaxe(self, player: int): - return self.has('Progressive Tools', player, 3) and self._mc_has_iron_ingots(player) - - def _mc_craft_crossbow(self, player: int): - return self.has('Archery', player) and self._mc_has_iron_ingots(player) - - def _mc_has_bottle(self, player: int): - return self.has('Bottles', player) and self.has('Progressive Resource Crafting', player) - - def _mc_has_spyglass(self, player: int): - return self._mc_has_copper_ingots(player) and self.has('Spyglass', player) and self._mc_can_adventure(player) - - def _mc_can_enchant(self, player: int): - return self.has('Enchanting', player) and self._mc_has_diamond_pickaxe(player) # mine obsidian and lapis - - def _mc_can_use_anvil(self, player: int): - return self.has('Enchanting', player) and self.has('Progressive Resource Crafting', player, 2) and self._mc_has_iron_ingots(player) - - def _mc_fortress_loot(self, player: int): # saddles, blaze rods, wither skulls - return self.can_reach('Nether Fortress', 'Region', player) and self._mc_basic_combat(player) - - def _mc_can_brew_potions(self, player: int): - return self.has('Blaze Rods', player) and self.has('Brewing', player) and self._mc_has_bottle(player) - - def _mc_can_piglin_trade(self, player: int): - return self._mc_has_gold_ingots(player) and ( - self.can_reach('The Nether', 'Region', player) or - self.can_reach('Bastion Remnant', 'Region', player)) - - def _mc_overworld_villager(self, player: int): - village_region = self.multiworld.get_region('Village', player).entrances[0].parent_region.name - if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village - return (self.can_reach('Zombie Doctor', 'Location', player) or - (self._mc_has_diamond_pickaxe(player) and self.can_reach('Village', 'Region', player))) - elif village_region == 'The End': - return self.can_reach('Zombie Doctor', 'Location', player) - return self.can_reach('Village', 'Region', player) - - def _mc_enter_stronghold(self, player: int): - return self.has('Blaze Rods', player) and self.has('Brewing', player) and self.has('3 Ender Pearls', player) - - # Difficulty-dependent functions - def _mc_combat_difficulty(self, player: int): - return self.multiworld.combat_difficulty[player].current_key - - def _mc_can_adventure(self, player: int): - death_link_check = not self.multiworld.death_link[player] or self.has('Bed', player) - if self._mc_combat_difficulty(player) == 'easy': - return self.has('Progressive Weapons', player, 2) and self._mc_has_iron_ingots(player) and death_link_check - elif self._mc_combat_difficulty(player) == 'hard': - return True - return (self.has('Progressive Weapons', player) and death_link_check and - (self.has('Progressive Resource Crafting', player) or self.has('Campfire', player))) - - def _mc_basic_combat(self, player: int): - if self._mc_combat_difficulty(player) == 'easy': - return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and \ - self.has('Shield', player) and self._mc_has_iron_ingots(player) - elif self._mc_combat_difficulty(player) == 'hard': - return True - return self.has('Progressive Weapons', player) and (self.has('Progressive Armor', player) or self.has('Shield', player)) and self._mc_has_iron_ingots(player) - - def _mc_complete_raid(self, player: int): - reach_regions = self.can_reach('Village', 'Region', player) and self.can_reach('Pillager Outpost', 'Region', player) - if self._mc_combat_difficulty(player) == 'easy': - return reach_regions and \ - self.has('Progressive Weapons', player, 3) and self.has('Progressive Armor', player, 2) and \ - self.has('Shield', player) and self.has('Archery', player) and \ - self.has('Progressive Tools', player, 2) and self._mc_has_iron_ingots(player) - elif self._mc_combat_difficulty(player) == 'hard': # might be too hard? - return reach_regions and self.has('Progressive Weapons', player, 2) and self._mc_has_iron_ingots(player) and \ - (self.has('Progressive Armor', player) or self.has('Shield', player)) - return reach_regions and self.has('Progressive Weapons', player, 2) and self._mc_has_iron_ingots(player) and \ - self.has('Progressive Armor', player) and self.has('Shield', player) - - def _mc_can_kill_wither(self, player: int): - normal_kill = self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self._mc_can_brew_potions(player) and self._mc_can_enchant(player) - if self._mc_combat_difficulty(player) == 'easy': - return self._mc_fortress_loot(player) and normal_kill and self.has('Archery', player) - elif self._mc_combat_difficulty(player) == 'hard': # cheese kill using bedrock ceilings - return self._mc_fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player)) - return self._mc_fortress_loot(player) and normal_kill - - def _mc_can_respawn_ender_dragon(self, player: int): - return self.can_reach('The Nether', 'Region', player) and self.can_reach('The End', 'Region', player) and \ - self.has('Progressive Resource Crafting', player) # smelt sand into glass - - def _mc_can_kill_ender_dragon(self, player: int): - if self._mc_combat_difficulty(player) == 'easy': - return self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and \ - self.has('Archery', player) and self._mc_can_brew_potions(player) and self._mc_can_enchant(player) - if self._mc_combat_difficulty(player) == 'hard': - return (self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player)) or \ - (self.has('Progressive Weapons', player, 1) and self.has('Bed', player)) - return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and self.has('Archery', player) - - def _mc_has_structure_compass(self, entrance_name: str, player: int): - if not self.multiworld.structure_compasses[player]: - return True - return self.has(f"Structure Compass ({self.multiworld.get_entrance(entrance_name, player).connected_region.name})", player) - -# Sets rules on entrances and advancements that are always applied -def set_advancement_rules(world: MultiWorld, player: int): - - # Retrieves the appropriate structure compass for the given entrance - def get_struct_compass(entrance_name): - struct = world.get_entrance(entrance_name, player).connected_region.name - return f"Structure Compass ({struct})" - - set_rule(world.get_entrance("Nether Portal", player), lambda state: state.has('Flint and Steel', player) and - (state.has('Bucket', player) or state.has('Progressive Tools', player, 3)) and - state._mc_has_iron_ingots(player)) - set_rule(world.get_entrance("End Portal", player), lambda state: state._mc_enter_stronghold(player) and state.has('3 Ender Pearls', player, 4)) - set_rule(world.get_entrance("Overworld Structure 1", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Overworld Structure 1", player)) - set_rule(world.get_entrance("Overworld Structure 2", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Overworld Structure 2", player)) - set_rule(world.get_entrance("Nether Structure 1", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Nether Structure 1", player)) - set_rule(world.get_entrance("Nether Structure 2", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Nether Structure 2", player)) - set_rule(world.get_entrance("The End Structure", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("The End Structure", player)) - - set_rule(world.get_location("Ender Dragon", player), lambda state: state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("Wither", player), lambda state: state._mc_can_kill_wither(player)) - set_rule(world.get_location("Blaze Spawner", player), lambda state: state._mc_fortress_loot(player)) - - set_rule(world.get_location("Who is Cutting Onions?", player), lambda state: state._mc_can_piglin_trade(player)) - set_rule(world.get_location("Oh Shiny", player), lambda state: state._mc_can_piglin_trade(player)) - set_rule(world.get_location("Suit Up", player), lambda state: state.has("Progressive Armor", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Very Very Frightening", player), lambda state: state.has("Channeling Book", player) and - state._mc_can_use_anvil(player) and state._mc_can_enchant(player) and state._mc_overworld_villager(player)) - set_rule(world.get_location("Hot Stuff", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Free the End", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("A Furious Cocktail", player), lambda state: state._mc_can_brew_potions(player) and - state.has("Fishing Rod", player) and # Water Breathing - state.can_reach('The Nether', 'Region', player) and # Regeneration, Fire Resistance, gold nuggets - state.can_reach('Village', 'Region', player) and # Night Vision, Invisibility - state.can_reach('Bring Home the Beacon', 'Location', player)) # Resistance - # set_rule(world.get_location("Best Friends Forever", player), lambda state: True) - set_rule(world.get_location("Bring Home the Beacon", player), lambda state: state._mc_can_kill_wither(player) and - state._mc_has_diamond_pickaxe(player) and state.has("Progressive Resource Crafting", player, 2)) - set_rule(world.get_location("Not Today, Thank You", player), lambda state: state.has("Shield", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Isn't It Iron Pick", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Local Brewery", player), lambda state: state._mc_can_brew_potions(player)) - set_rule(world.get_location("The Next Generation", player), lambda state: state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("Fishy Business", player), lambda state: state.has("Fishing Rod", player)) - # set_rule(world.get_location("Hot Tourist Destinations", player), lambda state: True) - set_rule(world.get_location("This Boat Has Legs", player), lambda state: (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and - state.has("Saddle", player) and state.has("Fishing Rod", player)) - set_rule(world.get_location("Sniper Duel", player), lambda state: state.has("Archery", player)) - # set_rule(world.get_location("Nether", player), lambda state: True) - set_rule(world.get_location("Great View From Up Here", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("How Did We Get Here?", player), lambda state: state._mc_can_brew_potions(player) and - state._mc_has_gold_ingots(player) and # Absorption - state.can_reach('End City', 'Region', player) and # Levitation - state.can_reach('The Nether', 'Region', player) and # potion ingredients - state.has("Fishing Rod", player) and state.has("Archery",player) and # Pufferfish, Nautilus Shells; spectral arrows - state.can_reach("Bring Home the Beacon", "Location", player) and # Haste - state.can_reach("Hero of the Village", "Location", player)) # Bad Omen, Hero of the Village - set_rule(world.get_location("Bullseye", player), lambda state: state.has("Archery", player) and state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Spooky Scary Skeleton", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Two by Two", player), lambda state: state._mc_has_iron_ingots(player) and state.has("Bucket", player) and state._mc_can_adventure(player)) # shears > seagrass > turtles; buckets of tropical fish > axolotls; nether > striders; gold carrots > horses skips ingots - # set_rule(world.get_location("Stone Age", player), lambda state: True) - set_rule(world.get_location("Two Birds, One Arrow", player), lambda state: state._mc_craft_crossbow(player) and state._mc_can_enchant(player)) - # set_rule(world.get_location("We Need to Go Deeper", player), lambda state: True) - set_rule(world.get_location("Who's the Pillager Now?", player), lambda state: state._mc_craft_crossbow(player)) - set_rule(world.get_location("Getting an Upgrade", player), lambda state: state.has("Progressive Tools", player)) - set_rule(world.get_location("Tactical Fishing", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Zombie Doctor", player), lambda state: state._mc_can_brew_potions(player) and state._mc_has_gold_ingots(player)) - # set_rule(world.get_location("The City at the End of the Game", player), lambda state: True) - set_rule(world.get_location("Ice Bucket Challenge", player), lambda state: state._mc_has_diamond_pickaxe(player)) - # set_rule(world.get_location("Remote Getaway", player), lambda state: True) - set_rule(world.get_location("Into Fire", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("War Pigs", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Take Aim", player), lambda state: state.has("Archery", player)) - set_rule(world.get_location("Total Beelocation", player), lambda state: state.has("Silk Touch Book", player) and state._mc_can_use_anvil(player) and state._mc_can_enchant(player)) - set_rule(world.get_location("Arbalistic", player), lambda state: state._mc_craft_crossbow(player) and state.has("Piercing IV Book", player) and - state._mc_can_use_anvil(player) and state._mc_can_enchant(player)) - set_rule(world.get_location("The End... Again...", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("Acquire Hardware", player), lambda state: state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Not Quite \"Nine\" Lives", player), lambda state: state._mc_can_piglin_trade(player) and state.has("Progressive Resource Crafting", player, 2)) - set_rule(world.get_location("Cover Me With Diamonds", player), lambda state: state.has("Progressive Armor", player, 2) and state.can_reach("Diamonds!", "Location", player)) - set_rule(world.get_location("Sky's the Limit", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Hired Help", player), lambda state: state.has("Progressive Resource Crafting", player, 2) and state._mc_has_iron_ingots(player)) - # set_rule(world.get_location("Return to Sender", player), lambda state: True) - set_rule(world.get_location("Sweet Dreams", player), lambda state: state.has("Bed", player) or state.can_reach('Village', 'Region', player)) - set_rule(world.get_location("You Need a Mint", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_has_bottle(player)) - # set_rule(world.get_location("Adventure", player), lambda state: True) - set_rule(world.get_location("Monsters Hunted", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player) and - state._mc_can_kill_wither(player) and state.has("Fishing Rod", player)) # pufferfish for Water Breathing - set_rule(world.get_location("Enchanter", player), lambda state: state._mc_can_enchant(player)) - set_rule(world.get_location("Voluntary Exile", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Eye Spy", player), lambda state: state._mc_enter_stronghold(player)) - # set_rule(world.get_location("The End", player), lambda state: True) - set_rule(world.get_location("Serious Dedication", player), lambda state: state.can_reach("Hidden in the Depths", "Location", player) and state._mc_has_gold_ingots(player)) - set_rule(world.get_location("Postmortal", player), lambda state: state._mc_complete_raid(player)) - # set_rule(world.get_location("Monster Hunter", player), lambda state: True) - set_rule(world.get_location("Adventuring Time", player), lambda state: state._mc_can_adventure(player)) - # set_rule(world.get_location("A Seedy Place", player), lambda state: True) - # set_rule(world.get_location("Those Were the Days", player), lambda state: True) - set_rule(world.get_location("Hero of the Village", player), lambda state: state._mc_complete_raid(player)) - set_rule(world.get_location("Hidden in the Depths", player), lambda state: state._mc_can_brew_potions(player) and state.has("Bed", player) and state._mc_has_diamond_pickaxe(player)) # bed mining :) - set_rule(world.get_location("Beaconator", player), lambda state: state._mc_can_kill_wither(player) and state._mc_has_diamond_pickaxe(player) and - state.has("Progressive Resource Crafting", player, 2)) - set_rule(world.get_location("Withering Heights", player), lambda state: state._mc_can_kill_wither(player)) - set_rule(world.get_location("A Balanced Diet", player), lambda state: state._mc_has_bottle(player) and state._mc_has_gold_ingots(player) and # honey bottle; gapple - state.has("Progressive Resource Crafting", player, 2) and state.can_reach('The End', 'Region', player)) # notch apple, chorus fruit - set_rule(world.get_location("Subspace Bubble", player), lambda state: state._mc_has_diamond_pickaxe(player)) - # set_rule(world.get_location("Husbandry", player), lambda state: True) - set_rule(world.get_location("Country Lode, Take Me Home", player), lambda state: state.can_reach("Hidden in the Depths", "Location", player) and state._mc_has_gold_ingots(player)) - set_rule(world.get_location("Bee Our Guest", player), lambda state: state.has("Campfire", player) and state._mc_has_bottle(player)) - # set_rule(world.get_location("What a Deal!", player), lambda state: True) - set_rule(world.get_location("Uneasy Alliance", player), lambda state: state._mc_has_diamond_pickaxe(player) and state.has('Fishing Rod', player)) - set_rule(world.get_location("Diamonds!", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - # set_rule(world.get_location("A Terrible Fortress", player), lambda state: True) # since you don't have to fight anything - set_rule(world.get_location("A Throwaway Joke", player), lambda state: state._mc_can_adventure(player)) # kill drowned - # set_rule(world.get_location("Minecraft", player), lambda state: True) - set_rule(world.get_location("Sticky Situation", player), lambda state: state.has("Campfire", player) and state._mc_has_bottle(player)) - set_rule(world.get_location("Ol' Betsy", player), lambda state: state._mc_craft_crossbow(player)) - set_rule(world.get_location("Cover Me in Debris", player), lambda state: state.has("Progressive Armor", player, 2) and - state.has("8 Netherite Scrap", player, 2) and state.has("Progressive Resource Crafting", player) and - state.can_reach("Diamonds!", "Location", player) and state.can_reach("Hidden in the Depths", "Location", player)) - # set_rule(world.get_location("The End?", player), lambda state: True) - # set_rule(world.get_location("The Parrots and the Bats", player), lambda state: True) - # set_rule(world.get_location("A Complete Catalogue", player), lambda state: True) # kill fish for raw - # set_rule(world.get_location("Getting Wood", player), lambda state: True) - # set_rule(world.get_location("Time to Mine!", player), lambda state: True) - set_rule(world.get_location("Hot Topic", player), lambda state: state.has("Progressive Resource Crafting", player)) - # set_rule(world.get_location("Bake Bread", player), lambda state: True) - set_rule(world.get_location("The Lie", player), lambda state: state._mc_has_iron_ingots(player) and state.has("Bucket", player)) - set_rule(world.get_location("On a Rail", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Progressive Tools', player, 2)) # powered rails - # set_rule(world.get_location("Time to Strike!", player), lambda state: True) - # set_rule(world.get_location("Cow Tipper", player), lambda state: True) - set_rule(world.get_location("When Pigs Fly", player), lambda state: (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and - state.has("Saddle", player) and state.has("Fishing Rod", player) and state._mc_can_adventure(player)) - set_rule(world.get_location("Overkill", player), lambda state: state._mc_can_brew_potions(player) and - (state.has("Progressive Weapons", player) or state.can_reach('The Nether', 'Region', player))) # strength 1 + stone axe crit OR strength 2 + wood axe crit - set_rule(world.get_location("Librarian", player), lambda state: state.has("Enchanting", player)) - set_rule(world.get_location("Overpowered", player), lambda state: state._mc_has_iron_ingots(player) and - state.has('Progressive Tools', player, 2) and state._mc_basic_combat(player)) # mine gold blocks w/ iron pick - set_rule(world.get_location("Wax On", player), lambda state: state._mc_has_copper_ingots(player) and state.has('Campfire', player) and - state.has('Progressive Resource Crafting', player, 2)) - set_rule(world.get_location("Wax Off", player), lambda state: state._mc_has_copper_ingots(player) and state.has('Campfire', player) and - state.has('Progressive Resource Crafting', player, 2)) - set_rule(world.get_location("The Cutest Predator", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player)) - set_rule(world.get_location("The Healing Power of Friendship", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player)) - set_rule(world.get_location("Is It a Bird?", player), lambda state: state._mc_has_spyglass(player) and state._mc_can_adventure(player)) - set_rule(world.get_location("Is It a Balloon?", player), lambda state: state._mc_has_spyglass(player)) - set_rule(world.get_location("Is It a Plane?", player), lambda state: state._mc_has_spyglass(player) and state._mc_can_respawn_ender_dragon(player)) - set_rule(world.get_location("Surge Protector", player), lambda state: state.has("Channeling Book", player) and - state._mc_can_use_anvil(player) and state._mc_can_enchant(player) and state._mc_overworld_villager(player)) - set_rule(world.get_location("Light as a Rabbit", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has('Bucket', player)) - set_rule(world.get_location("Glow and Behold!", player), lambda state: state._mc_can_adventure(player)) - set_rule(world.get_location("Whatever Floats Your Goat!", player), lambda state: state._mc_can_adventure(player)) - set_rule(world.get_location("Caves & Cliffs", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player) and state.has('Progressive Tools', player, 2)) - set_rule(world.get_location("Feels like home", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player) and state.has('Fishing Rod', player) and - (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and state.has("Saddle", player)) - set_rule(world.get_location("Sound of Music", player), lambda state: state.can_reach("Diamonds!", "Location", player) and state._mc_basic_combat(player)) - set_rule(world.get_location("Star Trader", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player) and - (state.can_reach("The Nether", 'Region', player) or state.can_reach("Nether Fortress", 'Region', player) or state._mc_can_piglin_trade(player)) and # soul sand for water elevator - state._mc_overworld_villager(player)) - - # 1.19 advancements - - # can make a cake, and a noteblock, and can reach a pillager outposts for allays - set_rule(world.get_location("Birthday Song", player), lambda state: state.can_reach("The Lie", "Location", player) and state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - # can get to outposts. - # set_rule(world.get_location("You've Got a Friend in Me", player), lambda state: True) - # craft bucket and adventure to find frog spawning biome - set_rule(world.get_location("Bukkit Bukkit", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player) and state._mc_can_adventure(player)) - # I don't like this one its way to easy to get. just a pain to find. - set_rule(world.get_location("It Spreads", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has("Progressive Tools", player, 2)) - # literally just a duplicate of It spreads. - set_rule(world.get_location("Sneak 100", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has("Progressive Tools", player, 2)) - set_rule(world.get_location("When the Squad Hops into Town", player), lambda state: state._mc_can_adventure(player) and state.has("Lead", player)) - # lead frogs to the nether and a basalt delta's biomes to find magma cubes. - set_rule(world.get_location("With Our Powers Combined!", player), lambda state: state._mc_can_adventure(player) and state.has("Lead", player)) + "Who is Cutting Onions?": lambda state: can_piglin_trade(state, player), + "Oh Shiny": lambda state: can_piglin_trade(state, player), + "Suit Up": lambda state: state.has("Progressive Armor", player) and has_iron_ingots(state, player), + "Very Very Frightening": lambda state: (state.has("Channeling Book", player) and + can_use_anvil(state, player) and can_enchant(state, player) and overworld_villager(state, player)), + "Hot Stuff": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player), + "Free the End": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "A Furious Cocktail": lambda state: (can_brew_potions(state, player) and + state.has("Fishing Rod", player) and # Water Breathing + state.can_reach("The Nether", "Region", player) and # Regeneration, Fire Resistance, gold nuggets + state.can_reach("Village", "Region", player) and # Night Vision, Invisibility + state.can_reach("Bring Home the Beacon", "Location", player)), # Resistance + "Bring Home the Beacon": lambda state: (can_kill_wither(state, player) and + has_diamond_pickaxe(state, player) and state.has("Progressive Resource Crafting", player, 2)), + "Not Today, Thank You": lambda state: state.has("Shield", player) and has_iron_ingots(state, player), + "Isn't It Iron Pick": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), + "Local Brewery": lambda state: can_brew_potions(state, player), + "The Next Generation": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Fishy Business": lambda state: state.has("Fishing Rod", player), + "This Boat Has Legs": lambda state: ((fortress_loot(state, player) or complete_raid(state, player)) and + state.has("Saddle", player) and state.has("Fishing Rod", player)), + "Sniper Duel": lambda state: state.has("Archery", player), + "Great View From Up Here": lambda state: basic_combat(state, player), + "How Did We Get Here?": lambda state: (can_brew_potions(state, player) and + has_gold_ingots(state, player) and # Absorption + state.can_reach('End City', 'Region', player) and # Levitation + state.can_reach('The Nether', 'Region', player) and # potion ingredients + state.has("Fishing Rod", player) and state.has("Archery",player) and # Pufferfish, Nautilus Shells; spectral arrows + state.can_reach("Bring Home the Beacon", "Location", player) and # Haste + state.can_reach("Hero of the Village", "Location", player)), # Bad Omen, Hero of the Village + "Bullseye": lambda state: (state.has("Archery", player) and state.has("Progressive Tools", player, 2) and + has_iron_ingots(state, player)), + "Spooky Scary Skeleton": lambda state: basic_combat(state, player), + "Two by Two": lambda state: has_iron_ingots(state, player) and state.has("Bucket", player) and can_adventure(state, player), + "Two Birds, One Arrow": lambda state: craft_crossbow(state, player) and can_enchant(state, player), + "Who's the Pillager Now?": lambda state: craft_crossbow(state, player), + "Getting an Upgrade": lambda state: state.has("Progressive Tools", player), + "Tactical Fishing": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player), + "Zombie Doctor": lambda state: can_brew_potions(state, player) and has_gold_ingots(state, player), + "Ice Bucket Challenge": lambda state: has_diamond_pickaxe(state, player), + "Into Fire": lambda state: basic_combat(state, player), + "War Pigs": lambda state: basic_combat(state, player), + "Take Aim": lambda state: state.has("Archery", player), + "Total Beelocation": lambda state: state.has("Silk Touch Book", player) and can_use_anvil(state, player) and can_enchant(state, player), + "Arbalistic": lambda state: (craft_crossbow(state, player) and state.has("Piercing IV Book", player) and + can_use_anvil(state, player) and can_enchant(state, player)), + "The End... Again...": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Acquire Hardware": lambda state: has_iron_ingots(state, player), + "Not Quite \"Nine\" Lives": lambda state: can_piglin_trade(state, player) and state.has("Progressive Resource Crafting", player, 2), + "Cover Me With Diamonds": lambda state: (state.has("Progressive Armor", player, 2) and + state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player)), + "Sky's the Limit": lambda state: basic_combat(state, player), + "Hired Help": lambda state: state.has("Progressive Resource Crafting", player, 2) and has_iron_ingots(state, player), + "Sweet Dreams": lambda state: state.has("Bed", player) or state.can_reach('Village', 'Region', player), + "You Need a Mint": lambda state: can_respawn_ender_dragon(state, player) and has_bottle(state, player), + "Monsters Hunted": lambda state: (can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player) and + can_kill_wither(state, player) and state.has("Fishing Rod", player)), + "Enchanter": lambda state: can_enchant(state, player), + "Voluntary Exile": lambda state: basic_combat(state, player), + "Eye Spy": lambda state: enter_stronghold(state, player), + "Serious Dedication": lambda state: (can_brew_potions(state, player) and state.has("Bed", player) and + has_diamond_pickaxe(state, player) and has_gold_ingots(state, player)), + "Postmortal": lambda state: complete_raid(state, player), + "Adventuring Time": lambda state: can_adventure(state, player), + "Hero of the Village": lambda state: complete_raid(state, player), + "Hidden in the Depths": lambda state: can_brew_potions(state, player) and state.has("Bed", player) and has_diamond_pickaxe(state, player), + "Beaconator": lambda state: (can_kill_wither(state, player) and has_diamond_pickaxe(state, player) and + state.has("Progressive Resource Crafting", player, 2)), + "Withering Heights": lambda state: can_kill_wither(state, player), + "A Balanced Diet": lambda state: (has_bottle(state, player) and has_gold_ingots(state, player) and # honey bottle; gapple + state.has("Progressive Resource Crafting", player, 2) and state.can_reach('The End', 'Region', player)), # notch apple, chorus fruit + "Subspace Bubble": lambda state: has_diamond_pickaxe(state, player), + "Country Lode, Take Me Home": lambda state: state.can_reach("Hidden in the Depths", "Location", player) and has_gold_ingots(state, player), + "Bee Our Guest": lambda state: state.has("Campfire", player) and has_bottle(state, player), + "Uneasy Alliance": lambda state: has_diamond_pickaxe(state, player) and state.has('Fishing Rod', player), + "Diamonds!": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), + "A Throwaway Joke": lambda state: can_adventure(state, player), + "Sticky Situation": lambda state: state.has("Campfire", player) and has_bottle(state, player), + "Ol' Betsy": lambda state: craft_crossbow(state, player), + "Cover Me in Debris": lambda state: (state.has("Progressive Armor", player, 2) and + state.has("8 Netherite Scrap", player, 2) and state.has("Progressive Resource Crafting", player) and + has_diamond_pickaxe(state, player) and has_iron_ingots(state, player) and + can_brew_potions(state, player) and state.has("Bed", player)), + "Hot Topic": lambda state: state.has("Progressive Resource Crafting", player), + "The Lie": lambda state: has_iron_ingots(state, player) and state.has("Bucket", player), + "On a Rail": lambda state: has_iron_ingots(state, player) and state.has('Progressive Tools', player, 2), + "When Pigs Fly": lambda state: ((fortress_loot(state, player) or complete_raid(state, player)) and + state.has("Saddle", player) and state.has("Fishing Rod", player) and can_adventure(state, player)), + "Overkill": lambda state: (can_brew_potions(state, player) and + (state.has("Progressive Weapons", player) or state.can_reach('The Nether', 'Region', player))), + "Librarian": lambda state: state.has("Enchanting", player), + "Overpowered": lambda state: (has_iron_ingots(state, player) and + state.has('Progressive Tools', player, 2) and basic_combat(state, player)), + "Wax On": lambda state: (has_copper_ingots(state, player) and state.has('Campfire', player) and + state.has('Progressive Resource Crafting', player, 2)), + "Wax Off": lambda state: (has_copper_ingots(state, player) and state.has('Campfire', player) and + state.has('Progressive Resource Crafting', player, 2)), + "The Cutest Predator": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player), + "The Healing Power of Friendship": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player), + "Is It a Bird?": lambda state: has_spyglass(state, player) and can_adventure(state, player), + "Is It a Balloon?": lambda state: has_spyglass(state, player), + "Is It a Plane?": lambda state: has_spyglass(state, player) and can_respawn_ender_dragon(state, player), + "Surge Protector": lambda state: (state.has("Channeling Book", player) and + can_use_anvil(state, player) and can_enchant(state, player) and overworld_villager(state, player)), + "Light as a Rabbit": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has('Bucket', player), + "Glow and Behold!": lambda state: can_adventure(state, player), + "Whatever Floats Your Goat!": lambda state: can_adventure(state, player), + "Caves & Cliffs": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player) and state.has('Progressive Tools', player, 2), + "Feels like home": lambda state: (has_iron_ingots(state, player) and state.has('Bucket', player) and state.has('Fishing Rod', player) and + (fortress_loot(state, player) or complete_raid(state, player)) and state.has("Saddle", player)), + "Sound of Music": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player) and basic_combat(state, player), + "Star Trader": lambda state: (has_iron_ingots(state, player) and state.has('Bucket', player) and + (state.can_reach("The Nether", 'Region', player) or + state.can_reach("Nether Fortress", 'Region', player) or # soul sand for water elevator + can_piglin_trade(state, player)) and + overworld_villager(state, player)), + "Birthday Song": lambda state: state.can_reach("The Lie", "Location", player) and state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), + "Bukkit Bukkit": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player) and can_adventure(state, player), + "It Spreads": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has("Progressive Tools", player, 2), + "Sneak 100": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has("Progressive Tools", player, 2), + "When the Squad Hops into Town": lambda state: can_adventure(state, player) and state.has("Lead", player), + "With Our Powers Combined!": lambda state: can_adventure(state, player) and state.has("Lead", player), + } + } + return rules_lookup -# Sets rules on completion condition and postgame advancements -def set_completion_rules(world: MultiWorld, player: int): - def reachable_locations(state): - postgame_advancements = get_postgame_advancements(world.required_bosses[player].current_key) - return [location for location in world.get_locations() if - location.player == player and - location.name not in postgame_advancements and - location.address != None and - location.can_reach(state)] +def set_rules(mc_world: World) -> None: + multiworld = mc_world.multiworld + player = mc_world.player - def defeated_required_bosses(state): - return (world.required_bosses[player].current_key not in {"ender_dragon", "both"} or state.has("Defeat Ender Dragon", player)) and \ - (world.required_bosses[player].current_key not in {"wither", "both"} or state.has("Defeat Wither", player)) + rules_lookup = get_rules_lookup(player) - # 103 total advancements. Goal is to complete X advancements and then defeat the dragon. - # There are 11 possible postgame advancements; 5 for dragon, 5 for wither, 1 shared between them - # Hence the max for completion is 92 - egg_shards = min(world.egg_shards_required[player], world.egg_shards_available[player]) - completion_requirements = lambda state: len(reachable_locations(state)) >= world.advancement_goal[player] and \ - state.has("Dragon Egg Shard", player, egg_shards) - world.completion_condition[player] = lambda state: completion_requirements(state) and defeated_required_bosses(state) - # Set rules on postgame advancements - for adv_name in get_postgame_advancements(world.required_bosses[player].current_key): - add_rule(world.get_location(adv_name, player), completion_requirements) + # Set entrance rules + for entrance_name, rule in rules_lookup["entrances"].items(): + multiworld.get_entrance(entrance_name, player).access_rule = rule + + # Set location rules + for location_name, rule in rules_lookup["locations"].items(): + multiworld.get_location(location_name, player).access_rule = rule + + # Set rules surrounding completion + bosses = multiworld.required_bosses[player] + postgame_advancements = set() + if bosses.dragon: + postgame_advancements.update(Constants.exclusion_info["ender_dragon"]) + if bosses.wither: + postgame_advancements.update(Constants.exclusion_info["wither"]) + + def location_count(state: CollectionState) -> bool: + return len([location for location in multiworld.get_locations(player) if + location.address != None and + location.can_reach(state)]) + + def defeated_bosses(state: CollectionState) -> bool: + return ((not bosses.dragon or state.has("Ender Dragon", player)) + and (not bosses.wither or state.has("Wither", player))) + + egg_shards = min(multiworld.egg_shards_required[player], multiworld.egg_shards_available[player]) + completion_requirements = lambda state: (location_count(state) >= multiworld.advancement_goal[player] + and state.has("Dragon Egg Shard", player, egg_shards)) + multiworld.completion_condition[player] = lambda state: completion_requirements(state) and defeated_bosses(state) + + # Set exclusions on hard/unreasonable/postgame + excluded_advancements = set() + if not multiworld.include_hard_advancements[player]: + excluded_advancements.update(Constants.exclusion_info["hard"]) + if not multiworld.include_unreasonable_advancements[player]: + excluded_advancements.update(Constants.exclusion_info["unreasonable"]) + if not multiworld.include_postgame_advancements[player]: + excluded_advancements.update(postgame_advancements) + exclusion_rules(multiworld, player, excluded_advancements) diff --git a/worlds/minecraft/Structures.py b/worlds/minecraft/Structures.py new file mode 100644 index 0000000000..95bafc9efb --- /dev/null +++ b/worlds/minecraft/Structures.py @@ -0,0 +1,57 @@ +from worlds.AutoWorld import World + +from . import Constants + +def shuffle_structures(mc_world: World) -> None: + multiworld = mc_world.multiworld + player = mc_world.player + + default_connections = Constants.region_info["default_connections"] + illegal_connections = Constants.region_info["illegal_connections"] + + # Get all unpaired exits and all regions without entrances (except the Menu) + # This function is destructive on these lists. + exits = [exit.name for r in multiworld.regions if r.player == player for exit in r.exits if exit.connected_region == None] + structs = [r.name for r in multiworld.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] + exits_spoiler = exits[:] # copy the original order for the spoiler log + + pairs = {} + + def set_pair(exit, struct): + if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])): + pairs[exit] = struct + exits.remove(exit) + structs.remove(struct) + else: + raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({multiworld.player_name[player]})") + + # Connect plando structures first + if multiworld.plando_connections[player]: + for conn in multiworld.plando_connections[player]: + set_pair(conn.entrance, conn.exit) + + # The algorithm tries to place the most restrictive structures first. This algorithm always works on the + # relatively small set of restrictions here, but does not work on all possible inputs with valid configurations. + if multiworld.shuffle_structures[player]: + structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, []))) + for struct in structs[:]: + try: + exit = multiworld.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) + except IndexError: + raise Exception(f"No valid structure placements remaining for player {player} ({multiworld.player_name[player]})") + set_pair(exit, struct) + else: # write remaining default connections + for (exit, struct) in default_connections: + if exit in exits: + set_pair(exit, struct) + + # Make sure we actually paired everything; might fail if plando + try: + assert len(exits) == len(structs) == 0 + except AssertionError: + raise Exception(f"Failed to connect all Minecraft structures for player {player} ({multiworld.player_name[player]})") + + for exit in exits_spoiler: + multiworld.get_entrance(exit, player).connect(multiworld.get_region(pairs[exit], player)) + if multiworld.shuffle_structures[player] or multiworld.plando_connections[player]: + multiworld.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) diff --git a/worlds/minecraft/__init__.py b/worlds/minecraft/__init__.py index cbd274ba84..a685d1ab4b 100644 --- a/worlds/minecraft/__init__.py +++ b/worlds/minecraft/__init__.py @@ -1,17 +1,16 @@ import os import json from base64 import b64encode, b64decode -from math import ceil +from typing import Dict, Any -from .Items import MinecraftItem, item_table, required_items, junk_weights -from .Locations import MinecraftAdvancement, advancement_table, exclusion_table, get_postgame_advancements -from .Regions import mc_regions, link_minecraft_structures, default_connections -from .Rules import set_advancement_rules, set_completion_rules -from worlds.generic.Rules import exclusion_rules +from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification, Location +from worlds.AutoWorld import World, WebWorld -from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification +from . import Constants from .Options import minecraft_options -from ..AutoWorld import World, WebWorld +from .Structures import shuffle_structures +from .ItemPool import build_item_pool, get_junk_item_names +from .Rules import set_rules client_version = 9 @@ -47,7 +46,16 @@ class MinecraftWebWorld(WebWorld): ["Albinum"] ) - tutorials = [setup, setup_es, setup_sv] + setup_fr = Tutorial( + setup.tutorial_name, + setup.description, + "Français", + "minecraft_fr.md", + "minecraft/fr", + ["TheLynk"] + ) + + tutorials = [setup, setup_es, setup_sv, setup_fr] class MinecraftWorld(World): @@ -62,13 +70,13 @@ class MinecraftWorld(World): topology_present = True web = MinecraftWebWorld() - item_name_to_id = {name: data.code for name, data in item_table.items()} - location_name_to_id = {name: data.id for name, data in advancement_table.items()} + item_name_to_id = Constants.item_name_to_id + location_name_to_id = Constants.location_name_to_id data_version = 7 - def _get_mc_data(self): - exits = [connection[0] for connection in default_connections] + def _get_mc_data(self) -> Dict[str, Any]: + exits = [connection[0] for connection in Constants.region_info["default_connections"]] return { 'world_seed': self.multiworld.per_slot_randoms[self.player].getrandbits(32), 'seed_name': self.multiworld.seed_name, @@ -87,74 +95,70 @@ class MinecraftWorld(World): 'race': self.multiworld.is_race, } - def generate_basic(self): + def create_item(self, name: str) -> Item: + item_class = ItemClassification.filler + if name in Constants.item_info["progression_items"]: + item_class = ItemClassification.progression + elif name in Constants.item_info["useful_items"]: + item_class = ItemClassification.useful + elif name in Constants.item_info["trap_items"]: + item_class = ItemClassification.trap - # Generate item pool - itempool = [] - junk_pool = junk_weights.copy() - # Add all required progression items - for (name, num) in required_items.items(): - itempool += [name] * num - # Add structure compasses if desired - if self.multiworld.structure_compasses[self.player]: - structures = [connection[1] for connection in default_connections] - for struct_name in structures: - itempool.append(f"Structure Compass ({struct_name})") - # Add dragon egg shards - if self.multiworld.egg_shards_required[self.player] > 0: - itempool += ["Dragon Egg Shard"] * self.multiworld.egg_shards_available[self.player] - # Add bee traps if desired - bee_trap_quantity = ceil(self.multiworld.bee_traps[self.player] * (len(self.location_names) - len(itempool)) * 0.01) - itempool += ["Bee Trap"] * bee_trap_quantity - # Fill remaining items with randomly generated junk - itempool += self.multiworld.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), k=len(self.location_names) - len(itempool)) - # Convert itempool into real items - itempool = [item for item in map(lambda name: self.create_item(name), itempool)] + return MinecraftItem(name, item_class, self.item_name_to_id.get(name, None), self.player) - # Choose locations to automatically exclude based on settings - exclusion_pool = set() - exclusion_types = ['hard', 'unreasonable'] - for key in exclusion_types: - if not getattr(self.multiworld, f"include_{key}_advancements")[self.player]: - exclusion_pool.update(exclusion_table[key]) - # For postgame advancements, check with the boss goal - exclusion_pool.update(get_postgame_advancements(self.multiworld.required_bosses[self.player].current_key)) - exclusion_rules(self.multiworld, self.player, exclusion_pool) + def create_event(self, region_name: str, event_name: str) -> None: + region = self.multiworld.get_region(region_name, self.player) + loc = MinecraftLocation(self.player, event_name, None, region) + loc.place_locked_item(self.create_event_item(event_name)) + region.locations.append(loc) - # Prefill event locations with their events - self.multiworld.get_location("Blaze Spawner", self.player).place_locked_item(self.create_item("Blaze Rods")) - self.multiworld.get_location("Ender Dragon", self.player).place_locked_item(self.create_item("Defeat Ender Dragon")) - self.multiworld.get_location("Wither", self.player).place_locked_item(self.create_item("Defeat Wither")) + def create_event_item(self, name: str) -> None: + item = self.create_item(name) + item.classification = ItemClassification.progression + return item - self.multiworld.itempool += itempool + def create_regions(self) -> None: + # Create regions + for region_name, exits in Constants.region_info["regions"]: + r = Region(region_name, self.player, self.multiworld) + for exit_name in exits: + r.exits.append(Entrance(self.player, exit_name, r)) + self.multiworld.regions.append(r) - def get_filler_item_name(self) -> str: - return self.multiworld.random.choices(list(junk_weights.keys()), weights=list(junk_weights.values()))[0] + # Bind mandatory connections + for entr_name, region_name in Constants.region_info["mandatory_connections"]: + e = self.multiworld.get_entrance(entr_name, self.player) + r = self.multiworld.get_region(region_name, self.player) + e.connect(r) - def set_rules(self): - set_advancement_rules(self.multiworld, self.player) - set_completion_rules(self.multiworld, self.player) + # Add locations + for region_name, locations in Constants.location_info["locations_by_region"].items(): + region = self.multiworld.get_region(region_name, self.player) + for loc_name in locations: + loc = MinecraftLocation(self.player, loc_name, + self.location_name_to_id.get(loc_name, None), region) + region.locations.append(loc) - def create_regions(self): - def MCRegion(region_name: str, exits=[]): - ret = Region(region_name, self.player, self.multiworld) - ret.locations = [MinecraftAdvancement(self.player, loc_name, loc_data.id, ret) - for loc_name, loc_data in advancement_table.items() - if loc_data.region == region_name] - for exit in exits: - ret.exits.append(Entrance(self.player, exit, ret)) - return ret + # Add events + self.create_event("Nether Fortress", "Blaze Rods") + self.create_event("The End", "Ender Dragon") + self.create_event("Nether Fortress", "Wither") - self.multiworld.regions += [MCRegion(*r) for r in mc_regions] - link_minecraft_structures(self.multiworld, self.player) + # Shuffle the connections + shuffle_structures(self) - def generate_output(self, output_directory: str): + def create_items(self) -> None: + self.multiworld.itempool += build_item_pool(self) + + set_rules = set_rules + + def generate_output(self, output_directory: str) -> None: data = self._get_mc_data() filename = f"AP_{self.multiworld.get_out_file_name_base(self.player)}.apmc" with open(os.path.join(output_directory, filename), 'wb') as f: f.write(b64encode(bytes(json.dumps(data), 'utf-8'))) - def fill_slot_data(self): + def fill_slot_data(self) -> dict: slot_data = self._get_mc_data() for option_name in minecraft_options: option = getattr(self.multiworld, option_name)[self.player] @@ -162,20 +166,16 @@ class MinecraftWorld(World): slot_data[option_name] = int(option.value) return slot_data - def create_item(self, name: str) -> Item: - item_data = item_table[name] - if name == "Bee Trap": - classification = ItemClassification.trap - # prevent books from going on excluded locations - elif name in ("Sharpness III Book", "Infinity Book", "Looting III Book"): - classification = ItemClassification.useful - elif item_data.progression: - classification = ItemClassification.progression - else: - classification = ItemClassification.filler - item = MinecraftItem(name, classification, item_data.code, self.player) + def get_filler_item_name(self) -> str: + return get_junk_item_names(self.multiworld.random, 1)[0] + + +class MinecraftLocation(Location): + game = "Minecraft" + +class MinecraftItem(Item): + game = "Minecraft" - return item def mc_update_output(raw_data, server, port): data = json.loads(b64decode(raw_data)) diff --git a/worlds/minecraft/data/excluded_locations.json b/worlds/minecraft/data/excluded_locations.json new file mode 100644 index 0000000000..2f6fbbba6d --- /dev/null +++ b/worlds/minecraft/data/excluded_locations.json @@ -0,0 +1,40 @@ +{ + "hard": [ + "Very Very Frightening", + "A Furious Cocktail", + "Two by Two", + "Two Birds, One Arrow", + "Arbalistic", + "Monsters Hunted", + "Beaconator", + "A Balanced Diet", + "Uneasy Alliance", + "Cover Me in Debris", + "A Complete Catalogue", + "Surge Protector", + "Sound of Music", + "Star Trader", + "When the Squad Hops into Town", + "With Our Powers Combined!" + ], + "unreasonable": [ + "How Did We Get Here?", + "Adventuring Time" + ], + "ender_dragon": [ + "Free the End", + "The Next Generation", + "The End... Again...", + "You Need a Mint", + "Monsters Hunted", + "Is It a Plane?" + ], + "wither": [ + "Withering Heights", + "Bring Home the Beacon", + "Beaconator", + "A Furious Cocktail", + "How Did We Get Here?", + "Monsters Hunted" + ] +} \ No newline at end of file diff --git a/worlds/minecraft/data/items.json b/worlds/minecraft/data/items.json new file mode 100644 index 0000000000..7d35d18aeb --- /dev/null +++ b/worlds/minecraft/data/items.json @@ -0,0 +1,128 @@ +{ + "all_items": [ + "Archery", + "Progressive Resource Crafting", + "Resource Blocks", + "Brewing", + "Enchanting", + "Bucket", + "Flint and Steel", + "Bed", + "Bottles", + "Shield", + "Fishing Rod", + "Campfire", + "Progressive Weapons", + "Progressive Tools", + "Progressive Armor", + "8 Netherite Scrap", + "8 Emeralds", + "4 Emeralds", + "Channeling Book", + "Silk Touch Book", + "Sharpness III Book", + "Piercing IV Book", + "Looting III Book", + "Infinity Book", + "4 Diamond Ore", + "16 Iron Ore", + "500 XP", + "100 XP", + "50 XP", + "3 Ender Pearls", + "4 Lapis Lazuli", + "16 Porkchops", + "8 Gold Ore", + "Rotten Flesh", + "Single Arrow", + "32 Arrows", + "Saddle", + "Structure Compass (Village)", + "Structure Compass (Pillager Outpost)", + "Structure Compass (Nether Fortress)", + "Structure Compass (Bastion Remnant)", + "Structure Compass (End City)", + "Shulker Box", + "Dragon Egg Shard", + "Spyglass", + "Lead", + "Bee Trap" + ], + "progression_items": [ + "Archery", + "Progressive Resource Crafting", + "Resource Blocks", + "Brewing", + "Enchanting", + "Bucket", + "Flint and Steel", + "Bed", + "Bottles", + "Shield", + "Fishing Rod", + "Campfire", + "Progressive Weapons", + "Progressive Tools", + "Progressive Armor", + "8 Netherite Scrap", + "Channeling Book", + "Silk Touch Book", + "Piercing IV Book", + "3 Ender Pearls", + "Saddle", + "Structure Compass (Village)", + "Structure Compass (Pillager Outpost)", + "Structure Compass (Nether Fortress)", + "Structure Compass (Bastion Remnant)", + "Structure Compass (End City)", + "Dragon Egg Shard", + "Spyglass", + "Lead" + ], + "useful_items": [ + "Sharpness III Book", + "Looting III Book", + "Infinity Book" + ], + "trap_items": [ + "Bee Trap" + ], + + "required_pool": { + "Archery": 1, + "Progressive Resource Crafting": 2, + "Brewing": 1, + "Enchanting": 1, + "Bucket": 1, + "Flint and Steel": 1, + "Bed": 1, + "Bottles": 1, + "Shield": 1, + "Fishing Rod": 1, + "Campfire": 1, + "Progressive Weapons": 3, + "Progressive Tools": 3, + "Progressive Armor": 2, + "8 Netherite Scrap": 2, + "Channeling Book": 1, + "Silk Touch Book": 1, + "Sharpness III Book": 1, + "Piercing IV Book": 1, + "Looting III Book": 1, + "Infinity Book": 1, + "3 Ender Pearls": 4, + "Saddle": 1, + "Spyglass": 1, + "Lead": 1 + }, + "junk_weights": { + "4 Emeralds": 2, + "4 Diamond Ore": 1, + "16 Iron Ore": 1, + "50 XP": 4, + "16 Porkchops": 2, + "8 Gold Ore": 1, + "Rotten Flesh": 1, + "32 Arrows": 1 + } +} \ No newline at end of file diff --git a/worlds/minecraft/data/locations.json b/worlds/minecraft/data/locations.json new file mode 100644 index 0000000000..7cd00e5851 --- /dev/null +++ b/worlds/minecraft/data/locations.json @@ -0,0 +1,250 @@ +{ + "all_locations": [ + "Who is Cutting Onions?", + "Oh Shiny", + "Suit Up", + "Very Very Frightening", + "Hot Stuff", + "Free the End", + "A Furious Cocktail", + "Best Friends Forever", + "Bring Home the Beacon", + "Not Today, Thank You", + "Isn't It Iron Pick", + "Local Brewery", + "The Next Generation", + "Fishy Business", + "Hot Tourist Destinations", + "This Boat Has Legs", + "Sniper Duel", + "Nether", + "Great View From Up Here", + "How Did We Get Here?", + "Bullseye", + "Spooky Scary Skeleton", + "Two by Two", + "Stone Age", + "Two Birds, One Arrow", + "We Need to Go Deeper", + "Who's the Pillager Now?", + "Getting an Upgrade", + "Tactical Fishing", + "Zombie Doctor", + "The City at the End of the Game", + "Ice Bucket Challenge", + "Remote Getaway", + "Into Fire", + "War Pigs", + "Take Aim", + "Total Beelocation", + "Arbalistic", + "The End... Again...", + "Acquire Hardware", + "Not Quite \"Nine\" Lives", + "Cover Me With Diamonds", + "Sky's the Limit", + "Hired Help", + "Return to Sender", + "Sweet Dreams", + "You Need a Mint", + "Adventure", + "Monsters Hunted", + "Enchanter", + "Voluntary Exile", + "Eye Spy", + "The End", + "Serious Dedication", + "Postmortal", + "Monster Hunter", + "Adventuring Time", + "A Seedy Place", + "Those Were the Days", + "Hero of the Village", + "Hidden in the Depths", + "Beaconator", + "Withering Heights", + "A Balanced Diet", + "Subspace Bubble", + "Husbandry", + "Country Lode, Take Me Home", + "Bee Our Guest", + "What a Deal!", + "Uneasy Alliance", + "Diamonds!", + "A Terrible Fortress", + "A Throwaway Joke", + "Minecraft", + "Sticky Situation", + "Ol' Betsy", + "Cover Me in Debris", + "The End?", + "The Parrots and the Bats", + "A Complete Catalogue", + "Getting Wood", + "Time to Mine!", + "Hot Topic", + "Bake Bread", + "The Lie", + "On a Rail", + "Time to Strike!", + "Cow Tipper", + "When Pigs Fly", + "Overkill", + "Librarian", + "Overpowered", + "Wax On", + "Wax Off", + "The Cutest Predator", + "The Healing Power of Friendship", + "Is It a Bird?", + "Is It a Balloon?", + "Is It a Plane?", + "Surge Protector", + "Light as a Rabbit", + "Glow and Behold!", + "Whatever Floats Your Goat!", + "Caves & Cliffs", + "Feels like home", + "Sound of Music", + "Star Trader", + "Birthday Song", + "Bukkit Bukkit", + "It Spreads", + "Sneak 100", + "When the Squad Hops into Town", + "With Our Powers Combined!", + "You've Got a Friend in Me" + ], + "locations_by_region": { + "Overworld": [ + "Who is Cutting Onions?", + "Oh Shiny", + "Suit Up", + "Very Very Frightening", + "Hot Stuff", + "Best Friends Forever", + "Not Today, Thank You", + "Isn't It Iron Pick", + "Fishy Business", + "Sniper Duel", + "Bullseye", + "Stone Age", + "Two Birds, One Arrow", + "Getting an Upgrade", + "Tactical Fishing", + "Zombie Doctor", + "Ice Bucket Challenge", + "Take Aim", + "Total Beelocation", + "Arbalistic", + "Acquire Hardware", + "Cover Me With Diamonds", + "Hired Help", + "Sweet Dreams", + "Adventure", + "Monsters Hunted", + "Enchanter", + "Eye Spy", + "Monster Hunter", + "Adventuring Time", + "A Seedy Place", + "Husbandry", + "Bee Our Guest", + "Diamonds!", + "A Throwaway Joke", + "Minecraft", + "Sticky Situation", + "Ol' Betsy", + "The Parrots and the Bats", + "Getting Wood", + "Time to Mine!", + "Hot Topic", + "Bake Bread", + "The Lie", + "On a Rail", + "Time to Strike!", + "Cow Tipper", + "When Pigs Fly", + "Librarian", + "Wax On", + "Wax Off", + "The Cutest Predator", + "The Healing Power of Friendship", + "Is It a Bird?", + "Surge Protector", + "Light as a Rabbit", + "Glow and Behold!", + "Whatever Floats Your Goat!", + "Caves & Cliffs", + "Sound of Music", + "Bukkit Bukkit", + "It Spreads", + "Sneak 100", + "When the Squad Hops into Town" + ], + "The Nether": [ + "Hot Tourist Destinations", + "This Boat Has Legs", + "Nether", + "Two by Two", + "We Need to Go Deeper", + "Not Quite \"Nine\" Lives", + "Return to Sender", + "Serious Dedication", + "Hidden in the Depths", + "Subspace Bubble", + "Country Lode, Take Me Home", + "Uneasy Alliance", + "Cover Me in Debris", + "Is It a Balloon?", + "Feels like home", + "With Our Powers Combined!" + ], + "The End": [ + "Free the End", + "The Next Generation", + "Remote Getaway", + "The End... Again...", + "You Need a Mint", + "The End", + "The End?", + "Is It a Plane?" + ], + "Village": [ + "Postmortal", + "Hero of the Village", + "A Balanced Diet", + "What a Deal!", + "A Complete Catalogue", + "Star Trader" + ], + "Nether Fortress": [ + "A Furious Cocktail", + "Bring Home the Beacon", + "Local Brewery", + "How Did We Get Here?", + "Spooky Scary Skeleton", + "Into Fire", + "Beaconator", + "Withering Heights", + "A Terrible Fortress", + "Overkill" + ], + "Pillager Outpost": [ + "Who's the Pillager Now?", + "Voluntary Exile", + "Birthday Song", + "You've Got a Friend in Me" + ], + "Bastion Remnant": [ + "War Pigs", + "Those Were the Days", + "Overpowered" + ], + "End City": [ + "Great View From Up Here", + "The City at the End of the Game", + "Sky's the Limit" + ] + } +} \ No newline at end of file diff --git a/worlds/minecraft/data/regions.json b/worlds/minecraft/data/regions.json new file mode 100644 index 0000000000..c9e51e4829 --- /dev/null +++ b/worlds/minecraft/data/regions.json @@ -0,0 +1,28 @@ +{ + "regions": [ + ["Menu", ["New World"]], + ["Overworld", ["Nether Portal", "End Portal", "Overworld Structure 1", "Overworld Structure 2"]], + ["The Nether", ["Nether Structure 1", "Nether Structure 2"]], + ["The End", ["The End Structure"]], + ["Village", []], + ["Pillager Outpost", []], + ["Nether Fortress", []], + ["Bastion Remnant", []], + ["End City", []] + ], + "mandatory_connections": [ + ["New World", "Overworld"], + ["Nether Portal", "The Nether"], + ["End Portal", "The End"] + ], + "default_connections": [ + ["Overworld Structure 1", "Village"], + ["Overworld Structure 2", "Pillager Outpost"], + ["Nether Structure 1", "Nether Fortress"], + ["Nether Structure 2", "Bastion Remnant"], + ["The End Structure", "End City"] + ], + "illegal_connections": { + "Nether Fortress": ["The End Structure"] + } +} \ No newline at end of file diff --git a/worlds/minecraft/docs/minecraft_fr.md b/worlds/minecraft/docs/minecraft_fr.md new file mode 100644 index 0000000000..e25febba42 --- /dev/null +++ b/worlds/minecraft/docs/minecraft_fr.md @@ -0,0 +1,74 @@ +# Guide de configuration du randomiseur Minecraft + +## Logiciel requis + +- Minecraft Java Edition à partir de + la [page de la boutique Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) +- Archipelago depuis la [page des versions d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) + - (sélectionnez `Minecraft Client` lors de l'installation.) + +## Configuration de votre fichier YAML + +### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ? + +Voir le guide sur la configuration d'un YAML de base lors de la configuration d'Archipelago +guide : [Guide de configuration de base de Multiworld](/tutorial/Archipelago/setup/en) + +### Où puis-je obtenir un fichier YAML ? + +Vous pouvez personnaliser vos paramètres Minecraft en allant sur la [page des paramètres de joueur](/games/Minecraft/player-settings) + +## Rejoindre une partie MultiWorld + +### Obtenez votre fichier de données Minecraft + +**Un seul fichier yaml doit être soumis par monde minecraft, quel que soit le nombre de joueurs qui y jouent.** + +Lorsque vous rejoignez un jeu multimonde, il vous sera demandé de fournir votre fichier YAML à l'hébergeur. Une fois cela fait, +l'hébergeur vous fournira soit un lien pour télécharger votre fichier de données, soit un fichier zip contenant les données de chacun +des dossiers. Votre fichier de données doit avoir une extension `.apmc`. + +Double-cliquez sur votre fichier `.apmc` pour que le client Minecraft lance automatiquement le serveur forge installé. Assurez-vous de +laissez cette fenêtre ouverte car il s'agit de votre console serveur. + +### Connectez-vous au multiserveur + +Ouvrez Minecraft, accédez à "Multijoueur> Connexion directe" et rejoignez l'adresse du serveur "localhost". + +Si vous utilisez le site Web pour héberger le jeu, il devrait se connecter automatiquement au serveur AP sans avoir besoin de `/connect` + +sinon, une fois que vous êtes dans le jeu, tapez `/connect (Port) (Password)` où `` est l'adresse du +Serveur Archipelago. `(Port)` n'est requis que si le serveur Archipelago n'utilise pas le port par défaut 38281. Notez qu'il n'y a pas de deux-points entre `` et `(Port)` mais un espace. +`(Mot de passe)` n'est requis que si le serveur Archipelago que vous utilisez a un mot de passe défini. + +### Jouer le jeu + +Lorsque la console vous indique que vous avez rejoint la salle, vous êtes prêt. Félicitations pour avoir rejoint avec succès un +jeu multimonde ! À ce stade, tous les joueurs minecraft supplémentaires peuvent se connecter à votre serveur forge. Pour commencer le jeu une fois +que tout le monde est prêt utilisez la commande `/start`. + +## Installation non Windows + +Le client Minecraft installera forge et le mod pour d'autres systèmes d'exploitation, mais Java doit être fourni par l' +utilisateur. Rendez-vous sur [minecraft_versions.json sur le MC AP GitHub](https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json) +pour voir quelle version de Java est requise. Les nouvelles installations utiliseront par défaut la version "release" la plus élevée. +- Installez le JDK Amazon Corretto correspondant + - voir les [Liens d'installation manuelle du logiciel](#manual-installation-software-links) + - ou gestionnaire de paquets fourni par votre OS/distribution +- Ouvrez votre `host.yaml` et ajoutez le chemin vers votre Java sous la clé `minecraft_options` + - ` java : "chemin/vers/java-xx-amazon-corretto/bin/java"` +- Exécutez le client Minecraft et sélectionnez votre fichier .apmc + +## Installation manuelle complète + +Il est fortement recommandé d'utiliser le programme d'installation d'Archipelago pour gérer l'installation du serveur forge pour vous. +Le support ne sera pas fourni pour ceux qui souhaitent installer manuellement forge. Pour ceux d'entre vous qui savent comment faire et qui souhaitent le faire, +les liens suivants sont les versions des logiciels que nous utilisons. + +### Liens d'installation manuelle du logiciel + +- [Page de téléchargement de Minecraft Forge] (https://files.minecraftforge.net/net/minecraftforge/forge/) +- [Page des versions du mod Minecraft Archipelago Randomizer] (https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases) + - **NE PAS INSTALLER CECI SUR VOTRE CLIENT** +- [Amazon Corretto](https://docs.aws.amazon.com/corretto/) + - choisissez la version correspondante et sélectionnez "Téléchargements" sur la gauche \ No newline at end of file diff --git a/worlds/minecraft/test/TestAdvancements.py b/worlds/minecraft/test/TestAdvancements.py index 5fc64f76bf..321aef1af9 100644 --- a/worlds/minecraft/test/TestAdvancements.py +++ b/worlds/minecraft/test/TestAdvancements.py @@ -1,10 +1,14 @@ -from .TestMinecraft import TestMinecraft +from . import MCTestBase # Format: # [location, expected_result, given_items, [excluded_items]] # Every advancement has its own test, named by its internal ID number. -class TestAdvancements(TestMinecraft): +class TestAdvancements(MCTestBase): + options = { + "shuffle_structures": False, + "structure_compasses": False + } def test_42000(self): self.run_location_tests([ @@ -1278,3 +1282,129 @@ class TestAdvancements(TestMinecraft): ["Whatever Floats Your Goat!", True, ["Progressive Weapons", "Progressive Resource Crafting"]], ["Whatever Floats Your Goat!", True, ["Progressive Weapons", "Campfire"]], ]) + + # bucket, iron pick + def test_42103(self): + self.run_location_tests([ + ["Caves & Cliffs", False, []], + ["Caves & Cliffs", False, [], ["Bucket"]], + ["Caves & Cliffs", False, [], ["Progressive Tools"]], + ["Caves & Cliffs", False, [], ["Progressive Resource Crafting"]], + ["Caves & Cliffs", True, ["Progressive Resource Crafting", "Progressive Tools", "Progressive Tools", "Bucket"]], + ]) + + # bucket, fishing rod, saddle, combat + def test_42104(self): + self.run_location_tests([ + ["Feels like home", False, []], + ["Feels like home", False, [], ['Progressive Resource Crafting']], + ["Feels like home", False, [], ['Progressive Tools']], + ["Feels like home", False, [], ['Progressive Weapons']], + ["Feels like home", False, [], ['Progressive Armor', 'Shield']], + ["Feels like home", False, [], ['Fishing Rod']], + ["Feels like home", False, [], ['Saddle']], + ["Feels like home", False, [], ['Bucket']], + ["Feels like home", False, [], ['Flint and Steel']], + ["Feels like home", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor', 'Fishing Rod']], + ["Feels like home", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield', 'Fishing Rod']], + ]) + + # iron pick, combat + def test_42105(self): + self.run_location_tests([ + ["Sound of Music", False, []], + ["Sound of Music", False, [], ["Progressive Tools"]], + ["Sound of Music", False, [], ["Progressive Resource Crafting"]], + ["Sound of Music", False, [], ["Progressive Weapons"]], + ["Sound of Music", False, [], ["Progressive Armor", "Shield"]], + ["Sound of Music", True, ["Progressive Tools", "Progressive Tools", "Progressive Resource Crafting", "Progressive Weapons", "Progressive Armor"]], + ["Sound of Music", True, ["Progressive Tools", "Progressive Tools", "Progressive Resource Crafting", "Progressive Weapons", "Shield"]], + ]) + + # bucket, nether, villager + def test_42106(self): + self.run_location_tests([ + ["Star Trader", False, []], + ["Star Trader", False, [], ["Bucket"]], + ["Star Trader", False, [], ["Flint and Steel"]], + ["Star Trader", False, [], ["Progressive Tools"]], + ["Star Trader", False, [], ["Progressive Resource Crafting"]], + ["Star Trader", False, [], ["Progressive Weapons"]], + ["Star Trader", True, ["Bucket", "Flint and Steel", "Progressive Tools", "Progressive Resource Crafting", "Progressive Weapons"]], + ]) + + # bucket, redstone -> iron pick, pillager outpost -> adventure + def test_42107(self): + self.run_location_tests([ + ["Birthday Song", False, []], + ["Birthday Song", False, [], ["Bucket"]], + ["Birthday Song", False, [], ["Progressive Tools"]], + ["Birthday Song", False, [], ["Progressive Weapons"]], + ["Birthday Song", False, [], ["Progressive Resource Crafting"]], + ["Birthday Song", True, ["Progressive Resource Crafting", "Progressive Tools", "Progressive Tools", "Progressive Weapons", "Bucket"]], + ]) + + # bucket, adventure + def test_42108(self): + self.run_location_tests([ + ["Bukkit Bukkit", False, []], + ["Bukkit Bukkit", False, [], ["Bucket"]], + ["Bukkit Bukkit", False, [], ["Progressive Tools"]], + ["Bukkit Bukkit", False, [], ["Progressive Weapons"]], + ["Bukkit Bukkit", False, [], ["Progressive Resource Crafting"]], + ["Bukkit Bukkit", True, ["Bucket", "Progressive Tools", "Progressive Weapons", "Progressive Resource Crafting"]], + ]) + + # iron pick, adventure + def test_42109(self): + self.run_location_tests([ + ["It Spreads", False, []], + ["It Spreads", False, [], ["Progressive Tools"]], + ["It Spreads", False, [], ["Progressive Weapons"]], + ["It Spreads", False, [], ["Progressive Resource Crafting"]], + ["It Spreads", True, ["Progressive Tools", "Progressive Tools", "Progressive Weapons", "Progressive Resource Crafting"]], + ]) + + # iron pick, adventure + def test_42110(self): + self.run_location_tests([ + ["Sneak 100", False, []], + ["Sneak 100", False, [], ["Progressive Tools"]], + ["Sneak 100", False, [], ["Progressive Weapons"]], + ["Sneak 100", False, [], ["Progressive Resource Crafting"]], + ["Sneak 100", True, ["Progressive Tools", "Progressive Tools", "Progressive Weapons", "Progressive Resource Crafting"]], + ]) + + # adventure, lead + def test_42111(self): + self.run_location_tests([ + ["When the Squad Hops into Town", False, []], + ["When the Squad Hops into Town", False, [], ["Progressive Weapons"]], + ["When the Squad Hops into Town", False, [], ["Campfire", "Progressive Resource Crafting"]], + ["When the Squad Hops into Town", False, [], ["Lead"]], + ["When the Squad Hops into Town", True, ["Progressive Weapons", "Lead", "Campfire"]], + ["When the Squad Hops into Town", True, ["Progressive Weapons", "Lead", "Progressive Resource Crafting"]], + ]) + + # adventure, lead, nether + def test_42112(self): + self.run_location_tests([ + ["With Our Powers Combined!", False, []], + ["With Our Powers Combined!", False, [], ["Lead"]], + ["With Our Powers Combined!", False, [], ["Bucket", "Progressive Tools"]], + ["With Our Powers Combined!", False, [], ["Flint and Steel"]], + ["With Our Powers Combined!", False, [], ["Progressive Weapons"]], + ["With Our Powers Combined!", False, [], ["Progressive Resource Crafting"]], + ["With Our Powers Combined!", True, ["Lead", "Progressive Weapons", "Progressive Resource Crafting", "Flint and Steel", "Progressive Tools", "Bucket"]], + ["With Our Powers Combined!", True, ["Lead", "Progressive Weapons", "Progressive Resource Crafting", "Flint and Steel", "Progressive Tools", "Progressive Tools", "Progressive Tools"]], + ]) + + # pillager outpost -> adventure + def test_42113(self): + self.run_location_tests([ + ["You've Got a Friend in Me", False, []], + ["You've Got a Friend in Me", False, [], ["Progressive Weapons"]], + ["You've Got a Friend in Me", False, [], ["Campfire", "Progressive Resource Crafting"]], + ["You've Got a Friend in Me", True, ["Progressive Weapons", "Campfire"]], + ["You've Got a Friend in Me", True, ["Progressive Weapons", "Progressive Resource Crafting"]], + ]) diff --git a/worlds/minecraft/test/TestDataLoad.py b/worlds/minecraft/test/TestDataLoad.py new file mode 100644 index 0000000000..c14eef071b --- /dev/null +++ b/worlds/minecraft/test/TestDataLoad.py @@ -0,0 +1,60 @@ +import unittest + +from .. import Constants + +class TestDataLoad(unittest.TestCase): + + def test_item_data(self): + item_info = Constants.item_info + + # All items in sub-tables are in all_items + all_items: set = set(item_info['all_items']) + assert set(item_info['progression_items']) <= all_items + assert set(item_info['useful_items']) <= all_items + assert set(item_info['trap_items']) <= all_items + assert set(item_info['required_pool'].keys()) <= all_items + assert set(item_info['junk_weights'].keys()) <= all_items + + # No overlapping ids (because of bee trap stuff) + all_ids: set = set(Constants.item_name_to_id.values()) + assert len(all_items) == len(all_ids) + + def test_location_data(self): + location_info = Constants.location_info + exclusion_info = Constants.exclusion_info + + # Every location has a region and every region's locations are in all_locations + all_locations: set = set(location_info['all_locations']) + all_locs_2: set = set() + for v in location_info['locations_by_region'].values(): + all_locs_2.update(v) + assert all_locations == all_locs_2 + + # All exclusions are locations + for v in exclusion_info.values(): + assert set(v) <= all_locations + + def test_region_data(self): + region_info = Constants.region_info + + # Every entrance and region in mandatory/default/illegal connections is a real entrance and region + all_regions = set() + all_entrances = set() + for v in region_info['regions']: + assert isinstance(v[0], str) + assert isinstance(v[1], list) + all_regions.add(v[0]) + all_entrances.update(v[1]) + + for v in region_info['mandatory_connections']: + assert v[0] in all_entrances + assert v[1] in all_regions + + for v in region_info['default_connections']: + assert v[0] in all_entrances + assert v[1] in all_regions + + for k, v in region_info['illegal_connections'].items(): + assert k in all_regions + assert set(v) <= all_entrances + diff --git a/worlds/minecraft/test/TestEntrances.py b/worlds/minecraft/test/TestEntrances.py index 8e80a1353a..946eb23d63 100644 --- a/worlds/minecraft/test/TestEntrances.py +++ b/worlds/minecraft/test/TestEntrances.py @@ -1,7 +1,11 @@ -from .TestMinecraft import TestMinecraft +from . import MCTestBase -class TestEntrances(TestMinecraft): +class TestEntrances(MCTestBase): + options = { + "shuffle_structures": False, + "structure_compasses": False + } def testPortals(self): self.run_entrance_tests([ diff --git a/worlds/minecraft/test/TestMinecraft.py b/worlds/minecraft/test/TestMinecraft.py deleted file mode 100644 index dc5c81c031..0000000000 --- a/worlds/minecraft/test/TestMinecraft.py +++ /dev/null @@ -1,68 +0,0 @@ -from test.TestBase import TestBase -from BaseClasses import MultiWorld, ItemClassification -from worlds import AutoWorld -from worlds.minecraft import MinecraftWorld -from worlds.minecraft.Items import MinecraftItem, item_table -from Options import Toggle -from worlds.minecraft.Options import AdvancementGoal, EggShardsRequired, EggShardsAvailable, BossGoal, BeeTraps, \ - ShuffleStructures, CombatDifficulty - - -# Converts the name of an item into an item object -def MCItemFactory(items, player: int): - ret = [] - singleton = False - if isinstance(items, str): - items = [items] - singleton = True - for item in items: - if item in item_table: - ret.append(MinecraftItem( - item, ItemClassification.progression if item_table[item].progression else ItemClassification.filler, - item_table[item].code, player - )) - else: - raise Exception(f"Unknown item {item}") - - if singleton: - return ret[0] - return ret - - -class TestMinecraft(TestBase): - - def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.game[1] = "Minecraft" - self.multiworld.worlds[1] = MinecraftWorld(self.multiworld, 1) - exclusion_pools = ['hard', 'unreasonable', 'postgame'] - for pool in exclusion_pools: - setattr(self.multiworld, f"include_{pool}_advancements", {1: False}) - setattr(self.multiworld, "advancement_goal", {1: AdvancementGoal(30)}) - setattr(self.multiworld, "egg_shards_required", {1: EggShardsRequired(0)}) - setattr(self.multiworld, "egg_shards_available", {1: EggShardsAvailable(0)}) - setattr(self.multiworld, "required_bosses", {1: BossGoal(1)}) # ender dragon - setattr(self.multiworld, "shuffle_structures", {1: ShuffleStructures(False)}) - setattr(self.multiworld, "bee_traps", {1: BeeTraps(0)}) - setattr(self.multiworld, "combat_difficulty", {1: CombatDifficulty(1)}) # normal - setattr(self.multiworld, "structure_compasses", {1: Toggle(False)}) - setattr(self.multiworld, "death_link", {1: Toggle(False)}) - AutoWorld.call_single(self.multiworld, "create_regions", 1) - AutoWorld.call_single(self.multiworld, "generate_basic", 1) - AutoWorld.call_single(self.multiworld, "set_rules", 1) - - def _get_items(self, item_pool, all_except): - if all_except and len(all_except) > 0: - items = self.multiworld.itempool[:] - items = [item for item in items if - item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)] - items.extend(MCItemFactory(item_pool[0], 1)) - else: - items = MCItemFactory(item_pool[0], 1) - return self.get_state(items) - - def _get_items_partial(self, item_pool, missing_item): - new_items = item_pool[0].copy() - new_items.remove(missing_item) - items = MCItemFactory(new_items, 1) - return self.get_state(items) diff --git a/worlds/minecraft/test/TestOptions.py b/worlds/minecraft/test/TestOptions.py new file mode 100644 index 0000000000..668ed500e8 --- /dev/null +++ b/worlds/minecraft/test/TestOptions.py @@ -0,0 +1,49 @@ +from . import MCTestBase +from ..Constants import region_info +from ..Options import minecraft_options + +from BaseClasses import ItemClassification + +class AdvancementTestBase(MCTestBase): + options = { + "advancement_goal": minecraft_options["advancement_goal"].range_end + } + # beatability test implicit + +class ShardTestBase(MCTestBase): + options = { + "egg_shards_required": minecraft_options["egg_shards_required"].range_end, + "egg_shards_available": minecraft_options["egg_shards_available"].range_end + } + + # check that itempool is not overfilled with shards + def test_itempool(self): + assert len(self.multiworld.get_unfilled_locations()) == len(self.multiworld.itempool) + +class CompassTestBase(MCTestBase): + def test_compasses_in_pool(self): + structures = [x[1] for x in region_info["default_connections"]] + itempool_str = {item.name for item in self.multiworld.itempool} + for struct in structures: + assert f"Structure Compass ({struct})" in itempool_str + +class NoBeeTestBase(MCTestBase): + options = { + "bee_traps": 0 + } + + # With no bees, there are no traps in the pool + def test_bees(self): + for item in self.multiworld.itempool: + assert item.classification != ItemClassification.trap + + +class AllBeeTestBase(MCTestBase): + options = { + "bee_traps": 100 + } + + # With max bees, there are no filler items, only bee traps + def test_bees(self): + for item in self.multiworld.itempool: + assert item.classification != ItemClassification.filler diff --git a/worlds/minecraft/test/__init__.py b/worlds/minecraft/test/__init__.py index e69de29bb2..acf9b79491 100644 --- a/worlds/minecraft/test/__init__.py +++ b/worlds/minecraft/test/__init__.py @@ -0,0 +1,33 @@ +from test.TestBase import TestBase, WorldTestBase +from .. import MinecraftWorld + + +class MCTestBase(WorldTestBase, TestBase): + game = "Minecraft" + player: int = 1 + + def _create_items(self, items, player): + singleton = False + if isinstance(items, str): + items = [items] + singleton = True + ret = [self.multiworld.worlds[player].create_item(item) for item in items] + if singleton: + return ret[0] + return ret + + def _get_items(self, item_pool, all_except): + if all_except and len(all_except) > 0: + items = self.multiworld.itempool[:] + items = [item for item in items if item.name not in all_except] + items.extend(self._create_items(item_pool[0], 1)) + else: + items = self._create_items(item_pool[0], 1) + return self.get_state(items) + + def _get_items_partial(self, item_pool, missing_item): + new_items = item_pool[0].copy() + new_items.remove(missing_item) + items = self._create_items(new_items, 1) + return self.get_state(items) + diff --git a/worlds/oot/HintList.py b/worlds/oot/HintList.py index 556e165184..b0f20858e7 100644 --- a/worlds/oot/HintList.py +++ b/worlds/oot/HintList.py @@ -50,7 +50,7 @@ def getHint(item, clearer_hint=False): return Hint(item, clearText, hintType) else: return Hint(item, textOptions, hintType) - elif type(item) is str: + elif isinstance(item, str): return Hint(item, item, 'generic') else: # is an Item return Hint(item.name, item.hint_text, 'item') diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 20b3ccb02d..cae67e1e65 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -85,7 +85,16 @@ class OOTWeb(WebWorld): setup.authors ) - tutorials = [setup, setup_es] + setup_fr = Tutorial( + setup.tutorial_name, + setup.description, + "Français", + "setup_fr.md", + "setup/fr", + ["TheLynk"] + ) + + tutorials = [setup, setup_es, setup_fr] class OOTWorld(World): diff --git a/worlds/oot/docs/setup_fr.md b/worlds/oot/docs/setup_fr.md new file mode 100644 index 0000000000..6248f8c44b --- /dev/null +++ b/worlds/oot/docs/setup_fr.md @@ -0,0 +1,422 @@ +# Guide d'installation Archipelago pour Ocarina of Time + +## Important + +Comme nous utilisons Bizhawk, ce guide ne s'applique qu'aux systèmes Windows et Linux. + +## Logiciel requis + +- Bizhawk : [Bizhawk sort de TASVideos] (https://tasvideos.org/BizHawk/ReleaseHistory) + - Les versions 2.3.1 et ultérieures sont prises en charge. La version 2.7 est recommandée pour la stabilité. + - Des instructions d'installation détaillées pour Bizhawk peuvent être trouvées sur le lien ci-dessus. + - Les utilisateurs Windows doivent d'abord exécuter le programme d'installation prereq, qui peut également être trouvé sur le lien ci-dessus. +- Le client Archipelago intégré, qui peut être installé [ici](https://github.com/ArchipelagoMW/Archipelago/releases) + (sélectionnez `Ocarina of Time Client` lors de l'installation). +- Une ROM Ocarina of Time v1.0. + +## Configuration de Bizhawk + +Une fois Bizhawk installé, ouvrez Bizhawk et modifiez les paramètres suivants : + +- Allez dans Config > Personnaliser. Basculez vers l'onglet Avancé, puis basculez le Lua Core de "NLua+KopiLua" vers + "Interface Lua+Lua". Redémarrez ensuite Bizhawk. Ceci est nécessaire pour que le script Lua fonctionne correctement. + **REMARQUE : Même si "Lua+LuaInterface" est déjà sélectionné, basculez entre les deux options et resélectionnez-le. Nouvelles installations** + ** des versions plus récentes de Bizhawk ont tendance à afficher "Lua+LuaInterface" comme option sélectionnée par défaut mais se chargent toujours ** + **"NLua+KopiLua" jusqu'à ce que cette étape soit terminée.** +- Sous Config > Personnaliser > Avancé, assurez-vous que la case pour AutoSaveRAM est cochée et cliquez sur le bouton 5s. + Cela réduit la possibilité de perdre des données de sauvegarde en cas de plantage de l'émulateur. +- Sous Config > Personnaliser, cochez les cases "Exécuter en arrière-plan" et "Accepter la saisie en arrière-plan". Cela vous permettra de + continuer à jouer en arrière-plan, même si une autre fenêtre est sélectionnée. +- Sous Config> Raccourcis clavier, de nombreux raccourcis clavier sont répertoriés, dont beaucoup sont liés aux touches communes du clavier. Vous voudrez probablement + désactiver la plupart d'entre eux, ce que vous pouvez faire rapidement en utilisant `Esc`. +- Si vous jouez avec une manette, lorsque vous liez les commandes, désactivez "P1 A Up", "P1 A Down", "P1 A Left" et "P1 A Right" + car ceux-ci interfèrent avec la visée s'ils sont liés. Définissez l'entrée directionnelle à l'aide de l'onglet Analogique à la place. +- Sous N64, activez "Utiliser l'emplacement d'extension". Ceci est nécessaire pour que les sauvegardes fonctionnent. + (Le menu N64 n'apparaît qu'après le chargement d'une ROM.) + +Il est fortement recommandé d'associer les extensions de rom N64 (\*.n64, \*.z64) au Bizhawk que nous venons d'installer. +Pour ce faire, nous devons simplement rechercher n'importe quelle rom N64 que nous possédons, faire un clic droit et sélectionner "Ouvrir avec ...", dépliez +la liste qui apparaît et sélectionnez l'option du bas "Rechercher une autre application", puis naviguez jusqu'au dossier Bizhawk +et sélectionnez EmuHawk.exe. + +Un guide de configuration Bizhawk alternatif ainsi que divers conseils de dépannage peuvent être trouvés +[ici](https://wiki.ootrandomizer.com/index.php?title=Bizhawk). + +## Configuration de votre fichier YAML + +### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ? + +Votre fichier YAML contient un ensemble d'options de configuration qui fournissent au générateur des informations sur la façon dont il doit +générer votre jeu. Chaque joueur d'un multimonde fournira son propre fichier YAML. Cette configuration permet à chaque joueur de profiter +d'une expérience personnalisée à leur goût, et différents joueurs dans le même multimonde peuvent tous avoir des options différentes. + +### Où puis-je obtenir un fichier YAML ? + +Un yaml OoT de base ressemblera à ceci. Il y a beaucoup d'options cosmétiques qui ont été supprimées pour le plaisir de ce +tutoriel, si vous voulez voir une liste complète, téléchargez Archipelago depuis +la [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) et recherchez l'exemple de fichier dans +le dossier "Lecteurs". + +``` yaml +description: Modèle par défaut d'Ocarina of Time # Utilisé pour décrire votre yaml. Utile si vous avez plusieurs fichiers +# Votre nom dans le jeu. Les espaces seront remplacés par des underscores et il y a une limite de 16 caractères +name: VotreNom +game: + Ocarina of Time: 1 +requires: + version: 0.1.7 # Version d'Archipelago requise pour que ce yaml fonctionne comme prévu. +# Options partagées prises en charge par tous les jeux : +accessibility: + items: 0 # Garantit que vous pourrez acquérir tous les articles, mais vous ne pourrez peut-être pas accéder à tous les emplacements + locations: 50 # Garantit que vous pourrez accéder à tous les emplacements, et donc à tous les articles + none: 0 # Garantit seulement que le jeu est battable. Vous ne pourrez peut-être pas accéder à tous les emplacements ou acquérir tous les objets +progression_balancing: # Un système pour réduire le BK, comme dans les périodes où vous ne pouvez rien faire, en déplaçant vos éléments dans une sphère d'accès antérieure + 0: 0 # Choisissez un nombre inférieur si cela ne vous dérange pas d'avoir un multimonde plus long, ou si vous pouvez glitch / faire du hors logique. + 25: 0 + 50: 50 # Faites en sorte que vous ayez probablement des choses à faire. + 99: 0 # Obtenez les éléments importants tôt et restez en tête de la progression. +Ocarina of Time: + logic_rules: # définit la logique utilisée pour le générateur. + glitchless: 50 + glitched: 0 + no_logic: 0 + logic_no_night_tokens_without_suns_song: # Les skulltulas nocturnes nécessiteront logiquement le Chant du soleil. + false: 50 + true: 0 + open_forest: # Définissez l'état de la forêt de Kokiri et du chemin vers l'arbre Mojo. + open: 50 + closed_deku: 0 + closed: 0 + open_kakariko: # Définit l'état de la porte du village de Kakariko. + open: 50 + zelda: 0 + closed: 0 + open_door_of_time: # Ouvre la Porte du Temps par défaut, sans le Chant du Temps. + false: 0 + true: 50 + zora_fountain: # Définit l'état du roi Zora, bloquant le chemin vers la fontaine de Zora. + open: 0 + adult: 0 + closed: 50 + gerudo_fortress: # Définit les conditions d'accès à la forteresse Gerudo. + normal: 0 + fast: 50 + open: 0 + bridge: # Définit les exigences pour le pont arc-en-ciel. + open: 0 + vanilla: 0 + stones: 0 + medallions: 50 + dungeons: 0 + tokens: 0 + trials: # Définit le nombre d'épreuves requises dans le Château de Ganon. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 50 # valeur minimale + 6: 0 # valeur maximale + random: 0 + random-low: 0 + random-higt: 0 + starting_age: # Choisissez l'âge auquel Link commencera. + child: 50 + adult: 0 + triforce_hunt: # Rassemblez des morceaux de la Triforce dispersés dans le monde entier pour terminer le jeu. + false: 50 + true: 0 + triforce_goal: # Nombre de pièces Triforce nécessaires pour terminer le jeu. Nombre total placé déterminé par le paramètre Item Pool. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 1: 0 # valeur minimale + 50: 0 # valeur maximale + random: 0 + random-low: 0 + random-higt: 0 + 20: 50 + bombchus_in_logic: # Les Bombchus sont correctement pris en compte dans la logique. Le premier pack trouvé aura 20 chus ; Kokiri Shop et Bazaar vendent des recharges ; bombchus ouvre Bombchu Bowling. + false: 50 + true: 0 + bridge_stones: # Définissez le nombre de pierres spirituelles requises pour le pont arc-en-ciel. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 3: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + bridge_medallions: # Définissez le nombre de médaillons requis pour le pont arc-en-ciel. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 6: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + bridge_rewards: # Définissez le nombre de récompenses de donjon requises pour le pont arc-en-ciel. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 9: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + bridge_tokens: # Définissez le nombre de jetons Gold Skulltula requis pour le pont arc-en-ciel. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 100: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + shuffle_mapcompass: # Contrôle où mélanger les cartes et boussoles des donjons. + remove: 0 + startwith: 50 + vanilla: 0 + dungeon: 0 + overworld: 0 + any_dungeon: 0 + keysanity: 0 + shuffle_smallkeys: # Contrôle où mélanger les petites clés de donjon. + remove: 0 + vanilla: 0 + dungeon: 50 + overworld: 0 + any_dungeon: 0 + keysanity: 0 + shuffle_hideoutkeys: # Contrôle où mélanger les petites clés de la Forteresse Gerudo. + vanilla: 50 + overworld: 0 + any_dungeon: 0 + keysanity: 0 + shuffle_bosskeys: # Contrôle où mélanger les clés du boss, à l'exception de la clé du boss du château de Ganon. + remove: 0 + vanilla: 0 + dungeon: 50 + overworld: 0 + any_dungeon: 0 + keysanity: 0 + shuffle_ganon_bosskey: # Contrôle où mélanger la clé du patron du château de Ganon. + remove: 50 + vanilla: 0 + dungeon: 0 + overworld: 0 + any_dungeon: 0 + keysanity: 0 + on_lacs: 0 + enhance_map_compass: # La carte indique si un donjon est vanille ou MQ. La boussole indique quelle est la récompense du donjon. + false: 50 + true: 0 + lacs_condition: # Définissez les exigences pour la cinématique de la Flèche lumineuse dans le Temple du temps. + vanilla: 50 + stones: 0 + medallions: 0 + dungeons: 0 + tokens: 0 + lacs_stones: # Définissez le nombre de pierres spirituelles requises pour le LACS. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 3: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + lacs_medallions: # Définissez le nombre de médaillons requis pour LACS. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 6: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + lacs_rewards: # Définissez le nombre de récompenses de donjon requises pour LACS. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 9: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + lacs_tokens: # Définissez le nombre de jetons Gold Skulltula requis pour le LACS. + # vous pouvez ajouter des valeurs supplémentaires entre minimum et maximum + 0: 0 # valeur minimale + 100: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + shuffle_song_items: # Définit où les chansons peuvent apparaître. + song: 50 + dungeon: 0 + any: 0 + shopsanity: # Randomise le contenu de la boutique. Réglez sur "off" pour ne pas mélanger les magasins ; "0" mélange les magasins mais ne n'autorise pas les articles multimonde dans les magasins. + 0: 0 + 1: 0 + 2: 0 + 3: 0 + 4: 0 + random_value: 0 + off: 50 + tokensanity : # les récompenses en jetons des Skulltulas dorées sont mélangées dans la réserve. + off: 50 + dungeons: 0 + overworld: 0 + all: 0 + shuffle_scrubs: # Mélangez les articles vendus par Business Scrubs et fixez les prix. + off: 50 + low: 0 + regular: 0 + random_prices: 0 + shuffle_cows: # les vaches donnent des objets lorsque la chanson d'Epona est jouée. + false: 50 + true: 0 + shuffle_kokiri_sword: # Mélangez l'épée Kokiri dans la réserve d'objets. + false: 50 + true: 0 + shuffle_ocarinas: # Mélangez l'Ocarina des fées et l'Ocarina du temps dans la réserve d'objets. + false: 50 + true: 0 + shuffle_weird_egg: # Mélangez l'œuf bizarre de Malon au château d'Hyrule. + false: 50 + true: 0 + shuffle_gerudo_card: # Mélangez la carte de membre Gerudo dans la réserve d'objets. + false: 50 + true: 0 + shuffle_beans: # Ajoute un paquet de 10 haricots au pool d'objets et change le vendeur de haricots pour qu'il vende un objet pour 60 roupies. + false: 50 + true: 0 + shuffle_medigoron_carpet_salesman: # Mélangez les objets vendus par Medigoron et le vendeur de tapis Haunted Wasteland. + false: 50 + true: 0 + skip_child_zelda: # le jeu commence avec la lettre de Zelda, l'objet de la berceuse de Zelda et les événements pertinents déjà terminés. + false: 50 + true: 0 + no_escape_sequence: # Ignore la séquence d'effondrement de la tour entre les combats de Ganondorf et de Ganon. + false: 50 + true: 0 + no_guard_stealth: # Le vide sanitaire du château d'Hyrule passe directement à Zelda. + false: 50 + true: 0 + no_epona_race: # Epona peut toujours être invoquée avec Epona's Song. + false: 50 + true: 0 + skip_some_minigame_phases: # Dampe Race et Horseback Archery donnent les deux récompenses si la deuxième condition est remplie lors de la première tentative. + false: 50 + true: 0 + complete_mask_quest: # Tous les masques sont immédiatement disponibles à l'emprunt dans la boutique Happy Mask. + false: 50 + true: 0 + useful_cutscenes: # Réactive la cinématique Poe dans le Temple de la forêt, Darunia dans le Temple du feu et l'introduction de Twinrova. Surtout utile pour les pépins. + false: 50 + true: 0 + fast_chests: # Toutes les animations des coffres sont rapides. Si désactivé, les éléments principaux ont une animation lente. + false: 50 + true: 0 + free_scarecrow: # Sortir l'ocarina près d'un point d'épouvantail fait apparaître Pierre sans avoir besoin de la chanson. + false: 50 + true: 0 + fast_bunny_hood: # Bunny Hood vous permet de vous déplacer 1,5 fois plus vite comme dans Majora's Mask. + false: 50 + true: 0 + chicken_count: # Contrôle le nombre de Cuccos pour qu'Anju donne un objet en tant qu'enfant. + \# vous pouvez ajouter des valeurs supplémentaires entre le minimum et le maximum + 0: 0 # valeur minimale + 7: 50 # valeur maximale + random: 0 + random-low: 0 + random-high: 0 + hints: # les pierres à potins peuvent donner des indices sur l'emplacement des objets. + none: 0 + mask: 0 + agony: 0 + always: 50 + hint_dist: # Choisissez la distribution d'astuces à utiliser. Affecte la fréquence des indices forts, quels éléments sont toujours indiqués, etc. + balanced: 50 + ddr: 0 + league: 0 + mw2: 0 + scrubs: 0 + strong: 0 + tournament: 0 + useless: 0 + very_strong: 0 + text_shuffle: # Randomise le texte dans le jeu pour un effet comique. + none: 50 + except_hints: 0 + complete: 0 + damage_multiplier: # contrôle la quantité de dégâts subis par Link. + half: 0 + normal: 50 + double: 0 + quadruple: 0 + ohko: 0 + no_collectible_hearts: # les cœurs ne tomberont pas des ennemis ou des objets. + false: 50 + true: 0 + starting_tod: # Changer l'heure de début de la journée. + default: 50 + sunrise: 0 + morning: 0 + noon: 0 + afternoon: 0 + sunset: 0 + evening: 0 + midnight: 0 + witching_hour: 0 + start_with_consumables: # Démarrez le jeu avec des Deku Sticks et des Deku Nuts pleins. + false: 50 + true: 0 + start_with_rupees: # Commencez avec un portefeuille plein. Les mises à niveau de portefeuille rempliront également votre portefeuille. + false: 50 + true: 0 + item_pool_value: # modifie le nombre d'objets disponibles dans le jeu. + plentiful: 0 + balanced: 50 + scarce: 0 + minimal: 0 + junk_ice_traps: # Ajoute des pièges à glace au pool d'objets. + off: 0 + normal: 50 + on: 0 + mayhem: 0 + onslaught: 0 + ice_trap_appearance: # modifie l'apparence des pièges à glace en tant qu'éléments autonomes. + major_only: 50 + junk_only: 0 + anything: 0 + logic_earliest_adult_trade: # premier élément pouvant apparaître dans la séquence d'échange pour adultes. + pocket_egg: 0 + pocket_cucco: 0 + cojiro: 0 + odd_mushroom: 0 + poachers_saw: 0 + broken_sword: 0 + prescription: 50 + eyeball_frog: 0 + eyedrops: 0 + claim_check: 0 + logic_latest_adult_trade: # Dernier élément pouvant apparaître dans la séquence d'échange pour adultes. + pocket_egg: 0 + pocket_cucco: 0 + cojiro: 0 + odd_mushroom: 0 + poachers_saw: 0 + broken_sword: 0 + prescription: 0 + eyeball_frog: 0 + eyedrops: 0 + claim_check: 50 + +``` + +## Rejoindre une partie MultiWorld + +### Obtenez votre fichier de correctif OOT + +Lorsque vous rejoignez un jeu multimonde, il vous sera demandé de fournir votre fichier YAML à l'hébergeur. Une fois que c'est Fini, +l'hébergeur vous fournira soit un lien pour télécharger votre fichier de données, soit un fichier zip contenant les données de chacun +des dossiers. Votre fichier de données doit avoir une extension `.apz5`. + +Double-cliquez sur votre fichier `.apz5` pour démarrer votre client et démarrer le processus de patch ROM. Une fois le processus terminé +(cela peut prendre un certain temps), le client et l'émulateur seront lancés automatiquement (si vous avez associé l'extension +à l'émulateur comme recommandé). + +### Connectez-vous au multiserveur + +Une fois le client et l'émulateur démarrés, vous devez les connecter. Dans l'émulateur, cliquez sur "Outils" +menu et sélectionnez "Console Lua". Cliquez sur le bouton du dossier ou appuyez sur Ctrl+O pour ouvrir un script Lua. + +Accédez à votre dossier d'installation Archipelago et ouvrez `data/lua/OOT/oot_connector.lua`. + +Pour connecter le client au multiserveur, mettez simplement `:` dans le champ de texte en haut et appuyez sur Entrée (si le +le serveur utilise un mot de passe, saisissez dans le champ de texte inférieur `/connect : [mot de passe]`) + +Vous êtes maintenant prêt à commencer votre aventure à Hyrule. \ No newline at end of file diff --git a/worlds/overcooked2/Options.py b/worlds/overcooked2/Options.py index d0de7f4c8a..400796af59 100644 --- a/worlds/overcooked2/Options.py +++ b/worlds/overcooked2/Options.py @@ -1,14 +1,20 @@ -from enum import Enum +from enum import IntEnum from typing import TypedDict from Options import DefaultOnToggle, Range, Choice -class LocationBalancingMode(Enum): +class LocationBalancingMode(IntEnum): disabled = 0 compromise = 1 full = 2 +class DeathLinkMode(IntEnum): + disabled = 0 + death_only = 1 + death_and_overcook = 2 + + class OC2OnToggle(DefaultOnToggle): @property def result(self) -> bool: @@ -31,6 +37,23 @@ class LocationBalancing(Choice): default = LocationBalancingMode.compromise.value +class DeathLink(Choice): + """DeathLink is an opt-in feature for Multiworlds where individual death events are propogated to all games with DeathLink enabled. + + - Disabled: Death will behave as it does in the original game. + + - Death Only: A DeathLink broadcast will be sent every time a chef falls into a stage hazard. All local chefs will be killed when any one perishes. + + - Death and Overcook: Same as above, but an additional broadcast will be sent whenever the kitchen catches on fire from burnt food. + """ + auto_display_name = True + display_name = "DeathLink" + option_disabled = DeathLinkMode.disabled.value + option_death_only = DeathLinkMode.death_only.value + option_death_and_overcook = DeathLinkMode.death_and_overcook.value + default = DeathLinkMode.disabled.value + + class AlwaysServeOldestOrder(OC2OnToggle): """Modifies the game so that serving an expired order doesn't target the ticket with the highest tip. This helps players dig out of a broken tip combo faster.""" @@ -131,6 +154,9 @@ overcooked_options = { # generator options "location_balancing": LocationBalancing, + # deathlink + "deathlink": DeathLink, + # randomization options "shuffle_level_order": ShuffleLevelOrder, "include_horde_levels": IncludeHordeLevels, diff --git a/worlds/overcooked2/Overcooked2Levels.py b/worlds/overcooked2/Overcooked2Levels.py index 624e274196..007be13c9e 100644 --- a/worlds/overcooked2/Overcooked2Levels.py +++ b/worlds/overcooked2/Overcooked2Levels.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import Enum, IntEnum from typing import List @@ -113,7 +113,7 @@ ITEMS_TO_EXCLUDE_IF_NO_DLC = [ "Calmer Unbread", ] -class Overcooked2GameWorld(Enum): +class Overcooked2GameWorld(IntEnum): ONE = 1 TWO = 2 THREE = 3 @@ -127,7 +127,7 @@ class Overcooked2GameWorld(Enum): if self == Overcooked2GameWorld.KEVIN: return "Kevin" - return str(int(self.value)) + return str(self.value) @property def sublevel_count(self) -> int: @@ -141,7 +141,7 @@ class Overcooked2GameWorld(Enum): if self == Overcooked2GameWorld.ONE: return 1 - prev = Overcooked2GameWorld(self.value - 1) + prev = Overcooked2GameWorld(self - 1) return prev.base_id + prev.sublevel_count @property @@ -195,7 +195,7 @@ class Overcooked2Level: if self.sublevel > self.world.sublevel_count: if self.world == Overcooked2GameWorld.KEVIN: raise StopIteration - self.world = Overcooked2GameWorld(self.world.value + 1) + self.world = Overcooked2GameWorld(self.world + 1) self.sublevel = 1 return self diff --git a/worlds/overcooked2/__init__.py b/worlds/overcooked2/__init__.py index b973ebe485..63d87648e1 100644 --- a/worlds/overcooked2/__init__.py +++ b/worlds/overcooked2/__init__.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import IntEnum from typing import Callable, Dict, Any, List, Optional from BaseClasses import ItemClassification, CollectionState, Region, Entrance, Location, Tutorial, LocationProgressType @@ -6,7 +6,7 @@ from worlds.AutoWorld import World, WebWorld from .Overcooked2Levels import Overcooked2Level, Overcooked2GenericLevel, ITEMS_TO_EXCLUDE_IF_NO_DLC from .Locations import Overcooked2Location, oc2_location_name_to_id, oc2_location_id_to_name -from .Options import overcooked_options, OC2Options, OC2OnToggle, LocationBalancingMode +from .Options import overcooked_options, OC2Options, OC2OnToggle, LocationBalancingMode, DeathLinkMode from .Items import item_table, Overcooked2Item, item_name_to_id, item_id_to_name, item_to_unlock_event, item_frequencies from .Logic import has_requirements_for_level_star, has_requirements_for_level_access, level_shuffle_factory, is_item_progression, is_useful @@ -27,7 +27,7 @@ class Overcooked2Web(WebWorld): tutorials = [setup_en] -class PrepLevelMode(Enum): +class PrepLevelMode(IntEnum): original = 0 excluded = 1 ayce = 2 @@ -179,7 +179,7 @@ class Overcooked2World(World): balancing_mode = self.get_options()["LocationBalancing"] - if balancing_mode == LocationBalancingMode.disabled.value: + if balancing_mode == LocationBalancingMode.disabled: # Location balancing is disabled, progression density is purely determined by filler return list() @@ -191,12 +191,12 @@ class Overcooked2World(World): game_progression_count += 1 game_progression_density = game_progression_count/game_item_count - if balancing_mode == LocationBalancingMode.full.value: + if balancing_mode == LocationBalancingMode.full: # Location balancing will be employed in an attempt to keep the number of # progression locations and proression items as close to equal as possible return self.get_n_random_locations(game_progression_count) - assert balancing_mode == LocationBalancingMode.compromise.value + assert balancing_mode == LocationBalancingMode.compromise # Count how many progression items are shuffled between all games total_item_count = len(self.multiworld.itempool) @@ -242,7 +242,7 @@ class Overcooked2World(World): self.level_mapping = \ level_shuffle_factory( self.multiworld.random, - self.options["PrepLevels"] != PrepLevelMode.excluded.value, + self.options["PrepLevels"] != PrepLevelMode.excluded, self.options["IncludeHordeLevels"], ) else: @@ -508,6 +508,8 @@ class Overcooked2World(World): "SaveFolderName": mod_name, "CustomOrderTimeoutPenalty": 10, "LevelForceHide": [37, 38, 39, 40, 41, 42, 43, 44], + "LocalDeathLink": self.options["DeathLink"] != DeathLinkMode.disabled, + "BurnTriggersDeath": self.options["DeathLink"] == DeathLinkMode.death_and_overcook, # Game Modifications "LevelPurchaseRequirements": level_purchase_requirements, @@ -560,7 +562,7 @@ class Overcooked2World(World): for bug in bugs: self.options[bug] = self.options["FixBugs"] self.options["PreserveCookingProgress"] = self.options["AlwaysPreserveCookingProgress"] - self.options["TimerAlwaysStarts"] = self.options["PrepLevels"] == PrepLevelMode.ayce.value + self.options["TimerAlwaysStarts"] = self.options["PrepLevels"] == PrepLevelMode.ayce self.options["LevelTimerScale"] = 0.666 if self.options["ShorterLevelDuration"] else 1.0 self.options["LeaderboardScoreScale"] = { "FourStars": 1.0, diff --git a/worlds/pokemon_rb/__init__.py b/worlds/pokemon_rb/__init__.py index abe309222e..344b96f2b9 100644 --- a/worlds/pokemon_rb/__init__.py +++ b/worlds/pokemon_rb/__init__.py @@ -15,7 +15,7 @@ from .options import pokemon_rb_options from .rom_addresses import rom_addresses from .text import encode_text from .rom import generate_output, get_base_rom_bytes, get_base_rom_path, process_pokemon_data, process_wild_pokemon,\ - process_static_pokemon + process_static_pokemon, process_move_data from .rules import set_rules import worlds.pokemon_rb.poke_data as poke_data @@ -40,13 +40,14 @@ class PokemonRedBlueWorld(World): game = "Pokemon Red and Blue" option_definitions = pokemon_rb_options - data_version = 5 - required_client_version = (0, 3, 7) + data_version = 7 + required_client_version = (0, 3, 9) topology_present = False item_name_to_id = {name: data.id for name, data in item_table.items()} - location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"} + location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item" + and location.address is not None} item_name_groups = item_groups web = PokemonWebWorld() @@ -58,11 +59,14 @@ class PokemonRedBlueWorld(World): self.extra_badges = {} self.type_chart = None self.local_poke_data = None + self.local_move_data = None + self.local_tms = None self.learnsets = None self.trainer_name = None self.rival_name = None self.type_chart = None self.traps = None + self.trade_mons = {} @classmethod def stage_assert_generate(cls, multiworld: MultiWorld): @@ -94,6 +98,12 @@ class PokemonRedBlueWorld(World): if len(self.multiworld.player_name[self.player].encode()) > 16: raise Exception(f"Player name too long for {self.multiworld.get_player_name(self.player)}. Player name cannot exceed 16 bytes for Pokémon Red and Blue.") + if (self.multiworld.dexsanity[self.player] and self.multiworld.accessibility[self.player] == "locations" + and (self.multiworld.catch_em_all[self.player] != "all_pokemon" + or self.multiworld.randomize_wild_pokemon[self.player] == "vanilla" + or self.multiworld.randomize_legendary_pokemon[self.player] != "any")): + self.multiworld.accessibility[self.player] = self.multiworld.accessibility[self.player].from_text("items") + if self.multiworld.badges_needed_for_hm_moves[self.player].value >= 2: badges_to_add = ["Marsh Badge", "Volcano Badge", "Earth Badge"] if self.multiworld.badges_needed_for_hm_moves[self.player].value == 3: @@ -107,6 +117,7 @@ class PokemonRedBlueWorld(World): for badge in badges_to_add: self.extra_badges[hm_moves.pop()] = badge + process_move_data(self) process_pokemon_data(self) if self.multiworld.randomize_type_chart[self.player] == "vanilla": @@ -171,14 +182,20 @@ class PokemonRedBlueWorld(World): # damage being reduced by 1 which leads to a "not very effective" message appearing due to my changes # to the way effectiveness messages are generated. self.type_chart = sorted(chart, key=lambda matchup: -matchup[2]) + self.multiworld.early_items[self.player]["Exp. All"] = 1 def create_items(self) -> None: start_inventory = self.multiworld.start_inventory[self.player].value.copy() if self.multiworld.randomize_pokedex[self.player] == "start_with": start_inventory["Pokedex"] = 1 self.multiworld.push_precollected(self.create_item("Pokedex")) + locations = [location for location in location_data if location.type == "Item"] item_pool = [] + combined_traps = (self.multiworld.poison_trap_weight[self.player].value + + self.multiworld.fire_trap_weight[self.player].value + + self.multiworld.paralyze_trap_weight[self.player].value + + self.multiworld.ice_trap_weight[self.player].value) for location in locations: if not location.inclusion(self.multiworld, self.player): continue @@ -188,9 +205,18 @@ class PokemonRedBlueWorld(World): item = self.create_filler() elif location.original_item is None: item = self.create_filler() + elif location.original_item == "Pokedex": + if self.multiworld.randomize_pokedex[self.player] == "vanilla": + self.multiworld.get_location(location.name, self.player).event = True + location.event = True + item = self.create_item("Pokedex") + elif location.original_item.startswith("TM"): + if self.multiworld.randomize_tm_moves[self.player]: + item = self.create_item(location.original_item.split(" ")[0]) + else: + item = self.create_item(location.original_item) else: item = self.create_item(location.original_item) - combined_traps = self.multiworld.poison_trap_weight[self.player].value + self.multiworld.fire_trap_weight[self.player].value + self.multiworld.paralyze_trap_weight[self.player].value + self.multiworld.ice_trap_weight[self.player].value if (item.classification == ItemClassification.filler and self.multiworld.random.randint(1, 100) <= self.multiworld.trap_percentage[self.player].value and combined_traps != 0): item = self.create_item(self.select_trap()) @@ -204,12 +230,69 @@ class PokemonRedBlueWorld(World): self.multiworld.itempool += item_pool def pre_fill(self) -> None: - process_wild_pokemon(self) process_static_pokemon(self) + pokemon_locs = [location.name for location in location_data if location.type != "Item"] + for location in self.multiworld.get_locations(self.player): + if location.name in pokemon_locs: + location.show_in_spoiler = False - if self.multiworld.old_man[self.player].value == 1: + def intervene(move): + accessible_slots = [loc for loc in self.multiworld.get_reachable_locations(test_state, self.player) if loc.type == "Wild Encounter"] + move_bit = pow(2, poke_data.hm_moves.index(move) + 2) + viable_mons = [mon for mon in self.local_poke_data if self.local_poke_data[mon]["tms"][6] & move_bit] + placed_mons = [slot.item.name for slot in accessible_slots] + # this sort method doesn't seem to work if you reference the same list being sorted in the lambda + placed_mons_copy = placed_mons.copy() + placed_mons.sort(key=lambda i: placed_mons_copy.count(i)) + placed_mon = placed_mons.pop() + if self.multiworld.area_1_to_1_mapping[self.player]: + zone = " - ".join(placed_mon.split(" - ")[:-1]) + replace_slots = [slot for slot in accessible_slots if slot.name.startswith(zone) and slot.item.name == + placed_mon] + else: + replace_slots = [self.multiworld.random.choice([slot for slot in accessible_slots if slot.item.name == + placed_mon])] + replace_mon = self.multiworld.random.choice(viable_mons) + for replace_slot in replace_slots: + replace_slot.item = self.create_item(replace_mon) + last_intervene = None + while True: + intervene_move = None + test_state = self.multiworld.get_all_state(False) + if not self.multiworld.badgesanity[self.player]: + for badge in ["Boulder Badge", "Cascade Badge", "Thunder Badge", "Rainbow Badge", "Soul Badge", + "Marsh Badge", "Volcano Badge", "Earth Badge"]: + test_state.collect(self.create_item(badge)) + if not test_state.pokemon_rb_can_surf(self.player): + intervene_move = "Surf" + if not test_state.pokemon_rb_can_strength(self.player): + intervene_move = "Strength" + # cut may not be needed if accessibility is minimal, unless you need all 8 badges and badgesanity is off, + # as you will require cut to access celadon gyn + if (self.multiworld.accessibility[self.player] != "minimal" or ((not + self.multiworld.badgesanity[self.player]) and max(self.multiworld.elite_four_condition[self.player], + self.multiworld.victory_road_condition[self.player]) > 7)): + if not test_state.pokemon_rb_can_cut(self.player): + intervene_move = "Cut" + if (self.multiworld.accessibility[self.player].current_key != "minimal" and + (self.multiworld.trainersanity[self.player] or self.multiworld.extra_key_items[self.player])): + if not test_state.pokemon_rb_can_flash(self.player): + intervene_move = "Flash" + if intervene_move: + if intervene_move == last_intervene: + raise Exception(f"Caught in infinite loop attempting to ensure {intervene_move} is available to player {self.player}") + intervene(intervene_move) + last_intervene = intervene_move + else: + break + + if self.multiworld.old_man[self.player] == "early_parcel": self.multiworld.local_early_items[self.player]["Oak's Parcel"] = 1 + if self.multiworld.dexsanity[self.player]: + for location in [self.multiworld.get_location(f"Pokedex - {mon}", self.player) + for mon in poke_data.pokemon_data.keys()]: + add_item_rule(location, lambda item: item.name != "Oak's Parcel" or item.player != self.player) if not self.multiworld.badgesanity[self.player].value: self.multiworld.non_local_items[self.player].value -= self.item_name_groups["Badges"] @@ -236,17 +319,26 @@ class PokemonRedBlueWorld(World): else: raise FillError(f"Failed to place badges for player {self.player}") - locs = [self.multiworld.get_location("Fossil - Choice A", self.player), - self.multiworld.get_location("Fossil - Choice B", self.player)] - for loc in locs: - add_item_rule(loc, lambda i: i.advancement or i.name in self.item_name_groups["Unique"] - or i.name == "Master Ball") + # Place local items in some locations to prevent save-scumming. Also Oak's PC to prevent an "AP Item" from + # entering the player's inventory. + + locs = {self.multiworld.get_location("Fossil - Choice A", self.player), + self.multiworld.get_location("Fossil - Choice B", self.player)} + + if self.multiworld.dexsanity[self.player]: + for mon in ([" ".join(self.multiworld.get_location( + f"Pallet Town - Starter {i}", self.player).item.name.split(" ")[1:]) for i in range(1, 4)] + + [" ".join(self.multiworld.get_location( + f"Fighting Dojo - Gift {i}", self.player).item.name.split(" ")[1:]) for i in range(1, 3)]): + loc = self.multiworld.get_location(f"Pokedex - {mon}", self.player) + if loc.item is None: + locs.add(loc) loc = self.multiworld.get_location("Pallet Town - Player's PC", self.player) if loc.item is None: - locs.append(loc) + locs.add(loc) - for loc in locs: + for loc in sorted(locs): unplaced_items = [] if loc.name in self.multiworld.priority_locations[self.player].value: add_item_rule(loc, lambda i: i.advancement) @@ -261,21 +353,6 @@ class PokemonRedBlueWorld(World): unplaced_items.append(item) self.multiworld.itempool += unplaced_items - intervene = False - test_state = self.multiworld.get_all_state(False) - if not test_state.pokemon_rb_can_surf(self.player) or not test_state.pokemon_rb_can_strength(self.player): - intervene = True - elif self.multiworld.accessibility[self.player].current_key != "minimal": - if not test_state.pokemon_rb_can_cut(self.player) or not test_state.pokemon_rb_can_flash(self.player): - intervene = True - if intervene: - # the way this is handled will be improved significantly in the future when I add options to - # let you choose the exact weights for HM compatibility - logging.warning( - f"HM-compatible Pokémon possibly missing, placing Mew on Route 1 for player {self.player}") - loc = self.multiworld.get_location("Route 1 - Wild Pokemon - 1", self.player) - loc.item = self.create_item("Mew") - def create_regions(self): if self.multiworld.free_fly_location[self.player].value: if self.multiworld.old_man[self.player].value == 0: @@ -316,6 +393,12 @@ class PokemonRedBlueWorld(World): spoiler_handle.write(f"\n\nType matchups ({self.multiworld.player_name[self.player]}):\n\n") for matchup in self.type_chart: spoiler_handle.write(f"{matchup[0]} deals {matchup[2] * 10}% damage to {matchup[1]}\n") + spoiler_handle.write(f"\n\nPokémon locations ({self.multiworld.player_name[self.player]}):\n\n") + pokemon_locs = [location.name for location in location_data if location.type != "Item"] + for location in self.multiworld.get_locations(self.player): + if location.name in pokemon_locs: + spoiler_handle.write(location.name + ": " + location.item.name + "\n") + def get_filler_item_name(self) -> str: combined_traps = self.multiworld.poison_trap_weight[self.player].value + self.multiworld.fire_trap_weight[self.player].value + self.multiworld.paralyze_trap_weight[self.player].value + self.multiworld.ice_trap_weight[self.player].value @@ -335,6 +418,21 @@ class PokemonRedBlueWorld(World): self.traps += ["Ice Trap"] * self.multiworld.ice_trap_weight[self.player].value return self.multiworld.random.choice(self.traps) + def extend_hint_information(self, hint_data): + if self.multiworld.dexsanity[self.player]: + hint_data[self.player] = {} + mon_locations = {mon: set() for mon in poke_data.pokemon_data.keys()} + for loc in location_data: #self.multiworld.get_locations(self.player): + if loc.type in ["Wild Encounter", "Static Pokemon", "Legendary Pokemon", "Missable Pokemon"]: + mon = self.multiworld.get_location(loc.name, self.player).item.name + if mon.startswith("Static ") or mon.startswith("Missable "): + mon = " ".join(mon.split(" ")[1:]) + mon_locations[mon].add(loc.name.split(" -")[0]) + for mon in mon_locations: + if mon_locations[mon]: + hint_data[self.player][self.multiworld.get_location(f"Pokedex - {mon}", self.player).address] = \ + ", ".join(mon_locations[mon]) + def fill_slot_data(self) -> dict: return { "second_fossil_check_condition": self.multiworld.second_fossil_check_condition[self.player].value, @@ -357,7 +455,8 @@ class PokemonRedBlueWorld(World): "type_chart": self.type_chart, "randomize_pokedex": self.multiworld.randomize_pokedex[self.player].value, "trainersanity": self.multiworld.trainersanity[self.player].value, - "death_link": self.multiworld.death_link[self.player].value + "death_link": self.multiworld.death_link[self.player].value, + "prizesanity": self.multiworld.prizesanity[self.player].value } diff --git a/worlds/pokemon_rb/basepatch_blue.bsdiff4 b/worlds/pokemon_rb/basepatch_blue.bsdiff4 index 6932762f4d..6123d71415 100644 Binary files a/worlds/pokemon_rb/basepatch_blue.bsdiff4 and b/worlds/pokemon_rb/basepatch_blue.bsdiff4 differ diff --git a/worlds/pokemon_rb/basepatch_red.bsdiff4 b/worlds/pokemon_rb/basepatch_red.bsdiff4 index cd380e1f4f..efe4de42ea 100644 Binary files a/worlds/pokemon_rb/basepatch_red.bsdiff4 and b/worlds/pokemon_rb/basepatch_red.bsdiff4 differ 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 b857f234b0..556da20309 100644 --- a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md +++ b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md @@ -33,6 +33,8 @@ fossil scientist. This may require reviving a number of fossils, depending on yo * If the Old Man is blocking your way through Viridian City, you do not have Oak's Parcel in your inventory, and you've exhausted your money and Poké Balls, you can get a free Poké Ball from your mom. * HM moves can be overwritten if you have the HM for it in your bag. +* The NPC on the left behind the Celadon Game Corner counter will sell 1500 coins at once instead of giving information +about the Prize Corner ## What items and locations get shuffled? diff --git a/worlds/pokemon_rb/items.py b/worlds/pokemon_rb/items.py index 8afde91957..b30480ed3d 100644 --- a/worlds/pokemon_rb/items.py +++ b/worlds/pokemon_rb/items.py @@ -65,7 +65,7 @@ item_table = { "Super Repel": ItemData(56, ItemClassification.filler, ["Consumables"]), "Max Repel": ItemData(57, ItemClassification.filler, ["Consumables"]), "Dire Hit": ItemData(58, ItemClassification.filler, ["Consumables", "Battle Items"]), - #"Coin": ItemData(59, ItemClassification.filler), + "10 Coins": ItemData(59, ItemClassification.filler, ["Coins"]), "Fresh Water": ItemData(60, ItemClassification.filler, ["Consumables", "Vending Machine Drinks"]), "Soda Pop": ItemData(61, ItemClassification.filler, ["Consumables", "Vending Machine Drinks"]), "Lemonade": ItemData(62, ItemClassification.filler, ["Consumables", "Vending Machine Drinks"]), @@ -103,6 +103,8 @@ item_table = { "Paralyze Trap": ItemData(95, ItemClassification.trap, ["Traps"]), "Ice Trap": ItemData(96, ItemClassification.trap, ["Traps"]), "Fire Trap": ItemData(97, ItemClassification.trap, ["Traps"]), + "20 Coins": ItemData(98, ItemClassification.filler, ["Coins"]), + "100 Coins": ItemData(99, ItemClassification.filler, ["Coins"]), "HM01 Cut": ItemData(196, ItemClassification.progression, ["Unique", "HMs"]), "HM02 Fly": ItemData(197, ItemClassification.progression, ["Unique", "HMs"]), "HM03 Surf": ItemData(198, ItemClassification.progression, ["Unique", "HMs"]), @@ -119,7 +121,7 @@ item_table = { "TM09 Take Down": ItemData(209, ItemClassification.useful, ["Unique", "TMs"]), "TM10 Double Edge": ItemData(210, ItemClassification.useful, ["Unique", "TMs"]), "TM11 Bubble Beam": ItemData(211, ItemClassification.useful, ["Unique", "TMs"]), - "TM12 Water Gun": ItemData(212, ItemClassification.useful, ["Unique", "TMs"]), + "TM12 Water Gun": ItemData(212, ItemClassification.filler, ["Unique", "TMs"]), "TM13 Ice Beam": ItemData(213, ItemClassification.useful, ["Unique", "TMs"]), "TM14 Blizzard": ItemData(214, ItemClassification.useful, ["Unique", "TMs"]), "TM15 Hyper Beam": ItemData(215, ItemClassification.useful, ["Unique", "TMs"]), @@ -163,6 +165,10 @@ item_table = { "Silph Co Liberated": ItemData(None, ItemClassification.progression, []), "Become Champion": ItemData(None, ItemClassification.progression, []) } + +item_table.update({f"TM{str(i).zfill(2)}": ItemData(i + 456, ItemClassification.filler, ["Unique", "TMs"]) + for i in range(1, 51)}) + item_table.update( {pokemon: ItemData(None, ItemClassification.progression, []) for pokemon in pokemon_data.keys()} ) diff --git a/worlds/pokemon_rb/locations.py b/worlds/pokemon_rb/locations.py index 418ce9a9f2..a1b64e12e5 100644 --- a/worlds/pokemon_rb/locations.py +++ b/worlds/pokemon_rb/locations.py @@ -1,6 +1,8 @@ from BaseClasses import Location from .rom_addresses import rom_addresses +from .poke_data import pokemon_data + loc_id_start = 172000000 @@ -8,6 +10,10 @@ def trainersanity(multiworld, player): return multiworld.trainersanity[player] +def dexsanity(multiworld, player): + return multiworld.dexsanity[player] + + def hidden_items(multiworld, player): return multiworld.randomize_hidden_items[player].value > 0 @@ -20,14 +26,13 @@ def extra_key_items(multiworld, player): return multiworld.extra_key_items[player] -def pokedex(multiworld, player): - return multiworld.randomize_pokedex[player].value > 0 - - def always_on(multiworld, player): return True +def prizesanity(multiworld, player): + return multiworld.prizesanity[player] + class LocationData: @@ -72,6 +77,13 @@ class Rod: self.flag = flag +class DexSanityFlag: + def __init__(self, flag): + self.byte = int(flag / 8) + self.bit = flag % 8 + self.flag = flag + + location_data = [ LocationData("Vermilion City", "Fishing Guru", "Old Rod", rom_addresses["Rod_Vermilion_City_Fishing_Guru"], Rod(3)), @@ -119,7 +131,7 @@ location_data = [ LocationData("Celadon City", "Gambling Addict", "Coin Case", rom_addresses["Event_Gambling_Addict"], EventFlag(480)), LocationData("Celadon Gym", "Erika 2", "TM21 Mega Drain", rom_addresses["Event_Celadon_Gym"], EventFlag(424)), - LocationData("Silph Co 11F", "Silph Co President", "Master Ball", rom_addresses["Event_Silph_Co_President"], + LocationData("Silph Co 11F", "Silph Co President (Card Key)", "Master Ball", rom_addresses["Event_Silph_Co_President"], EventFlag(1933)), LocationData("Silph Co 2F", "Woman", "TM36 Self-Destruct", rom_addresses["Event_Scared_Woman"], EventFlag(1791)), @@ -374,7 +386,7 @@ location_data = [ LocationData("Seafoam Islands B4F", "Hidden Item Corner Island", "Ultra Ball", rom_addresses['Hidden_Item_Seafoam_Islands_B4F'], Hidden(26), inclusion=hidden_items), LocationData("Pokemon Mansion 1F", "Hidden Item Block Near Entrance Carpet", "Moon Stone", rom_addresses['Hidden_Item_Pokemon_Mansion_1F'], Hidden(27), inclusion=hidden_items), LocationData("Pokemon Mansion 3F", "Hidden Item Behind Burglar", "Max Revive", rom_addresses['Hidden_Item_Pokemon_Mansion_3F'], Hidden(28), inclusion=hidden_items), - LocationData("Route 23", "Hidden Item Rocks Before Final Guard", "Full Restore", rom_addresses['Hidden_Item_Route_23_1'], Hidden(29), inclusion=hidden_items), + LocationData("Route 23", "Hidden Item Rocks Before Victory Road", "Full Restore", rom_addresses['Hidden_Item_Route_23_1'], Hidden(29), inclusion=hidden_items), LocationData("Route 23", "Hidden Item East Bush After Water", "Ultra Ball", rom_addresses['Hidden_Item_Route_23_2'], Hidden(30), inclusion=hidden_items), LocationData("Route 23", "Hidden Item On Island", "Max Ether", rom_addresses['Hidden_Item_Route_23_3'], Hidden(31), inclusion=hidden_items), LocationData("Victory Road 2F", "Hidden Item Rock Before Moltres", "Ultra Ball", rom_addresses['Hidden_Item_Victory_Road_2F_1'], Hidden(32), inclusion=hidden_items), @@ -400,7 +412,8 @@ location_data = [ LocationData("Cerulean City", "Hidden Item Gym Badge Guy's Backyard", "Rare Candy", rom_addresses['Hidden_Item_Cerulean_City'], Hidden(52), inclusion=hidden_items), LocationData("Route 4", "Hidden Item Plateau East Of Mt Moon", "Great Ball", rom_addresses['Hidden_Item_Route_4'], Hidden(53), inclusion=hidden_items), - LocationData("Pallet Town", "Oak's Parcel Reward", "Pokedex", rom_addresses["Event_Pokedex"], EventFlag(0x38), inclusion=pokedex), + + LocationData("Pallet Town", "Oak's Parcel Reward", "Pokedex", rom_addresses["Event_Pokedex"], EventFlag(0x38)), LocationData("Pokemon Mansion 1F", "Scientist", None, rom_addresses["Trainersanity_EVENT_BEAT_MANSION_1_TRAINER_0_ITEM"], EventFlag(376), inclusion=trainersanity), LocationData("Pokemon Mansion 2F", "Burglar", None, rom_addresses["Trainersanity_EVENT_BEAT_MANSION_2_TRAINER_0_ITEM"], EventFlag(43), inclusion=trainersanity), @@ -712,6 +725,37 @@ location_data = [ LocationData("Indigo Plateau", "Bruno", None, rom_addresses["Trainersanity_EVENT_BEAT_BRUNOS_ROOM_TRAINER_0_ITEM"], EventFlag(20), inclusion=trainersanity), LocationData("Indigo Plateau", "Agatha", None, rom_addresses["Trainersanity_EVENT_BEAT_AGATHAS_ROOM_TRAINER_0_ITEM"], EventFlag(19), inclusion=trainersanity), LocationData("Indigo Plateau", "Lance", None, rom_addresses["Trainersanity_EVENT_BEAT_LANCES_ROOM_TRAINER_0_ITEM"], EventFlag(18), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Burglar 1", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_A_ITEM"], EventFlag(374), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Super Nerd 1", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_B_ITEM"], EventFlag(373), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Super Nerd 2", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_2_ITEM"], EventFlag(372), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Burglar 2", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_3_ITEM"], EventFlag(371), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Super Nerd 3", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_4_ITEM"], EventFlag(370), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Super Nerd 4", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_5_ITEM"], EventFlag(369), inclusion=trainersanity), + LocationData("Cinnabar Gym", "Super Nerd 5", None, rom_addresses["Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_6_ITEM"], EventFlag(368), inclusion=trainersanity), + + LocationData("Celadon Prize Corner", "Item Prize 1", "TM23 Dragon Rage", rom_addresses["Prize_Item_A"], EventFlag(0x69a), inclusion=prizesanity), + LocationData("Celadon Prize Corner", "Item Prize 2", "TM15 Hyper Beam", rom_addresses["Prize_Item_B"], EventFlag(0x69B), inclusion=prizesanity), + LocationData("Celadon Prize Corner", "Item Prize 3", "TM50 Substitute", rom_addresses["Prize_Item_C"], EventFlag(0x69C), inclusion=prizesanity), + + LocationData("Celadon Game Corner", "West Gambler's Gift (Coin Case)", "10 Coins", rom_addresses["Event_Game_Corner_Gift_A"], EventFlag(0x1ba)), + LocationData("Celadon Game Corner", "Center Gambler's Gift (Coin Case)", "20 Coins", rom_addresses["Event_Game_Corner_Gift_C"], EventFlag(0x1bc)), + LocationData("Celadon Game Corner", "East Gambler's Gift (Coin Case)", "20 Coins", rom_addresses["Event_Game_Corner_Gift_B"], EventFlag(0x1bb)), + + LocationData("Celadon Game Corner", "Hidden Item Northwest By Counter (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_1"], Hidden(54), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Southwest Corner (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_2"], Hidden(55), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near Rumor Man (Coin Case)", "20 Coins", rom_addresses["Hidden_Item_Game_Corner_3"], Hidden(56), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near Speculating Woman (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_4"], Hidden(57), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near West Gifting Gambler (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_5"], Hidden(58), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near Wonderful Time Woman (Coin Case)", "20 Coins", rom_addresses["Hidden_Item_Game_Corner_6"], Hidden(59), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near Failing Gym Information Guy (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_7"], Hidden(60), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near East Gifting Gambler (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_8"], Hidden(61), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item Near Hooked Guy (Coin Case)", "10 Coins", rom_addresses["Hidden_Item_Game_Corner_9"], Hidden(62), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item at End of Horizontal Machine Row (Coin Case)", "20 Coins", rom_addresses["Hidden_Item_Game_Corner_10"], Hidden(63), inclusion=hidden_items), + LocationData("Celadon Game Corner", "Hidden Item in Front of Horizontal Machine Row (Coin Case)", "100 Coins", rom_addresses["Hidden_Item_Game_Corner_11"], Hidden(64), inclusion=hidden_items), + + *[LocationData("Pokedex", mon, ball, rom_addresses["Dexsanity_Items"] + i, DexSanityFlag(i), type="Item", + inclusion=dexsanity) for (mon, i, ball) in zip(pokemon_data.keys(), range(0, 152), + ["Poke Ball", "Great Ball", "Ultra Ball"]* 51)], LocationData("Indigo Plateau", "Become Champion", "Become Champion", event=True), LocationData("Pokemon Tower 7F", "Fuji Saved", "Fuji Saved", event=True), @@ -1965,6 +2009,25 @@ location_data = [ LocationData("Cinnabar Island", "Dome Fossil Pokemon", "Kabuto", rom_addresses["Gift_Kabuto"], None, event=True, type="Static Pokemon"), + LocationData("Route 2 East", "Marcel Trade", "Mr Mime", rom_addresses["Trade_Marcel"] + 1, None, event=True, + type="Static Pokemon"), + LocationData("Underground Tunnel North-South", "Spot Trade", "Nidoran F", rom_addresses["Trade_Spot"] + 1, None, + event=True, type="Static Pokemon"), + LocationData("Route 11", "Terry Trade", "Nidorina", rom_addresses["Trade_Terry"] + 1, None, event=True, + type="Static Pokemon"), + LocationData("Route 18", "Marc Trade", "Lickitung", rom_addresses["Trade_Marc"] + 1, None, event=True, + type="Static Pokemon"), + LocationData("Cinnabar Island", "Sailor Trade", "Seel", rom_addresses["Trade_Sailor"] + 1, None, event=True, + type="Static Pokemon"), + LocationData("Cinnabar Island", "Crinkles Trade", "Tangela", rom_addresses["Trade_Crinkles"] + 1, None, + event=True, type="Static Pokemon"), + LocationData("Cinnabar Island", "Doris Trade", "Electrode", rom_addresses["Trade_Doris"] + 1, None, + event=True, type="Static Pokemon"), + LocationData("Vermilion City", "Dux Trade", "Farfetchd", rom_addresses["Trade_Dux"] + 1, None, event=True, + type="Static Pokemon"), + LocationData("Cerulean City", "Lola Trade", "Jynx", rom_addresses["Trade_Lola"] + 1, None, event=True, + type="Static Pokemon"), + # not counted for logic currently. Could perhaps make static encounters resettable in the future? LocationData("Power Plant", "Fake Pokeball Battle 1", "Voltorb", rom_addresses["Static_Encounter_Voltorb_A"], None, event=True, type="Missable Pokemon"), @@ -2043,20 +2106,24 @@ location_data = [ ] -for i, location in enumerate(location_data): + + +i = 0 +for location in location_data: if location.event or location.rom_address is None: location.address = None else: location.address = loc_id_start + i - + i += 1 class PokemonRBLocation(Location): game = "Pokemon Red and Blue" - def __init__(self, player, name, address, rom_address): + def __init__(self, player, name, address, rom_address, type): super(PokemonRBLocation, self).__init__( player, name, address ) - self.rom_address = rom_address \ No newline at end of file + self.rom_address = rom_address + self.type = type diff --git a/worlds/pokemon_rb/logic.py b/worlds/pokemon_rb/logic.py index 70e825c2b5..8425bcdb4b 100644 --- a/worlds/pokemon_rb/logic.py +++ b/worlds/pokemon_rb/logic.py @@ -45,14 +45,13 @@ class PokemonLogic(LogicMixin): ["Boulder Badge", "Cascade Badge", "Thunder Badge", "Rainbow Badge", "Soul Badge", "Marsh Badge", "Volcano Badge", "Earth Badge", "Bicycle", "Silph Scope", "Item Finder", "Super Rod", "Good Rod", "Old Rod", "Lift Key", "Card Key", "Town Map", "Coin Case", "S.S. Ticket", "Secret Key", - "Mansion Key", "Safari Pass", "Plant Key", "Hideout Key", "HM01 Cut", "HM02 Fly", "HM03 Surf", - "HM04 Strength", "HM05 Flash"] if self.has(item, player)]) >= count + "Poke Flute", "Mansion Key", "Safari Pass", "Plant Key", "Hideout Key", "HM01 Cut", "HM02 Fly", + "HM03 Surf", "HM04 Strength", "HM05 Flash"] if self.has(item, player)]) >= count def pokemon_rb_can_pass_guards(self, player): if self.multiworld.tea[player].value: return self.has("Tea", player) else: - # this could just be "True", but you never know what weird options I might introduce later ;) return self.can_reach("Celadon City - Counter Man", "Location", player) def pokemon_rb_has_badges(self, count, player): @@ -60,13 +59,8 @@ class PokemonLogic(LogicMixin): "Soul Badge", "Volcano Badge", "Earth Badge"] if self.has(item, player)]) >= count def pokemon_rb_oaks_aide(self, count, player): - if self.multiworld.randomize_pokedex[player].value > 0: - if not self.has("Pokedex", player): - return False - else: - if not self.has("Oak's Parcel", player): - return False - return self.pokemon_rb_has_pokemon(count, player) + return ((not self.multiworld.require_pokedex[player] or self.has("Pokedex", player)) + and self.pokemon_rb_has_pokemon(count, player)) def pokemon_rb_has_pokemon(self, count, player): obtained_pokemon = set() diff --git a/worlds/pokemon_rb/options.py b/worlds/pokemon_rb/options.py index ae51c47b32..b705e6f2f9 100644 --- a/worlds/pokemon_rb/options.py +++ b/worlds/pokemon_rb/options.py @@ -65,7 +65,7 @@ class CeruleanCaveCondition(Range): If extra_key_items is turned on, the number chosen will be increased by 4.""" display_name = "Cerulean Cave Condition" range_start = 0 - range_end = 25 + range_end = 26 default = 20 @@ -155,6 +155,12 @@ class RandomizeHiddenItems(Choice): default = 0 +class PrizeSanity(Toggle): + """Shuffles the TM prizes at the Celadon Prize Corner into the item pool.""" + display_name = "Prizesanity" + default = 0 + + class TrainerSanity(Toggle): """Add a location check to every trainer in the game, which can be obtained by talking to a trainer after defeating them. Does not affect gym leaders and some scripted event battles (including all Rival, Giovanni, and @@ -163,12 +169,44 @@ class TrainerSanity(Toggle): default = 0 +class RequirePokedex(Toggle): + """Require the Pokedex to obtain items from Oak's Aides or from Dexsanity checks.""" + display_name = "Require Pokedex" + default = 1 + + +class AllPokemonSeen(Toggle): + """Start with all Pokemon "seen" in your Pokedex. This allows you to see where Pokemon can be encountered in the + wild. Pokemon found by fishing or in the Cerulean Cave are not displayed.""" + default = 0 + + +class DexSanity(Toggle): + """Adds a location check for each Pokemon flagged "Owned" on your Pokedex. If accessibility is set to `locations` + and randomize_wild_pokemon is off, catch_em_all is not `all_pokemon` or randomize_legendary_pokemon is not `any`, + accessibility will be forced to `items` instead, as not all Dexsanity locations can be guaranteed to be considered + reachable in logic. + If Pokedex is required, the items for Pokemon acquired before acquiring the Pokedex can be found by talking to + Professor Oak or evaluating the Pokedex via Oak's PC.""" + display_name = "Dexsanity" + default = 0 + + class FreeFlyLocation(Toggle): """One random fly destination will be unlocked by default.""" display_name = "Free Fly Location" default = 1 +class RandomizeRockTunnel(Toggle): + """Randomize the layout of Rock Tunnel. This is highly experimental, if you encounter any issues (items or trainers + unreachable, trainers walking over walls, inability to reach end of tunnel, anything looking strange) to + Alchav#8826 in the Archipelago Discord (directly or in #pkmn-red-blue) along with the seed number found on the + signs outside the tunnel.""" + display_name = "Randomize Rock Tunnel" + default = 0 + + class OaksAidRt2(Range): """Number of Pokemon registered in the Pokedex required to receive the item from Oak's Aide on Route 2. Vanilla is 10.""" @@ -229,6 +267,12 @@ class RandomizeWildPokemon(Choice): option_completely_random = 4 +class Area1To1Mapping(Toggle): + """When randomizing wild Pokemon, for each zone, all instances of a particular Pokemon will be replaced with the + same Pokemon, resulting in fewer Pokemon in each area.""" + default = 1 + + class RandomizeStarterPokemon(Choice): """Randomize the starter Pokemon choices.""" display_name = "Randomize Starter Pokemon" @@ -334,6 +378,13 @@ class MinimumCatchRate(Range): default = 3 +class MoveBalancing(Toggle): + """All one-hit-KO moves and fixed-damage moves become normal damaging moves. + Blizzard, and moves that cause sleep have their accuracy reduced.""" + display_name = "Move Balancing" + default = 0 + + class RandomizePokemonMovesets(Choice): """Randomize the moves learned by Pokemon. prefer_types will prefer moves that match the type of the Pokemon.""" display_name = "Randomize Pokemon Movesets" @@ -343,6 +394,12 @@ class RandomizePokemonMovesets(Choice): default = 0 +class ConfineTranstormToDitto(Toggle): + """Regardless of moveset randomization, will keep Ditto's first move as Transform no others will learn it. + If an enemy Pokemon uses transform before you catch it, it will permanently change to Ditto after capture.""" + display_name = "Confine Transform to Ditto" + default = 1 + class StartWithFourMoves(Toggle): """If movesets are randomized, this will give all Pokemon 4 starting moves.""" display_name = "Start With Four Moves" @@ -356,30 +413,62 @@ class SameTypeAttackBonus(Toggle): default = 1 -class TMCompatibility(Choice): - """Randomize which Pokemon can learn each TM. prefer_types: 90% chance if Pokemon's type matches the move, - 50% chance if move is Normal type and the Pokemon is not, and 25% chance otherwise. Pokemon will retain the same - TM compatibility when they evolve if the evolved form has the same type(s). Mew will always be able to learn - every TM.""" - display_name = "TM Compatibility" - default = 0 - option_vanilla = 0 - option_prefer_types = 1 - option_completely_random = 2 - option_full_compatibility = 3 +class RandomizeTMMoves(Toggle): + """Randomize the moves taught by TMs. + All TM items will be flagged as 'filler' items regardless of how good the move they teach are.""" + display_name = "Randomize TM Moves" -class HMCompatibility(Choice): - """Randomize which Pokemon can learn each HM. prefer_types: 100% chance if Pokemon's type matches the move, - 75% chance if move is Normal type and the Pokemon is not, and 25% chance otherwise. Pokemon will retain the same - HM compatibility when they evolve if the evolved form has the same type(s). Mew will always be able to learn - every HM.""" - display_name = "HM Compatibility" - default = 0 - option_vanilla = 0 - option_prefer_types = 1 - option_completely_random = 2 - option_full_compatibility = 3 +class TMHMCompatibility(SpecialRange): + range_start = -1 + range_end = 100 + special_range_names = { + "vanilla": -1, + "none": 0, + "full": 100 + } + default = -1 + + +class TMSameTypeCompatibility(TMHMCompatibility): + """Chance of each TM being usable on each Pokemon whose type matches the move.""" + display_name = "TM Same-Type Compatibility" + + +class TMNormalTypeCompatibility(TMHMCompatibility): + """Chance of each TM being usable on each Pokemon if the move is Normal type and the Pokemon is not.""" + display_name = "TM Normal-Type Compatibility" + + +class TMOtherTypeCompatibility(TMHMCompatibility): + """Chance of each TM being usable on each Pokemon if the move a type other than Normal or one of the Pokemon's types.""" + display_name = "TM Other-Type Compatibility" + + +class HMSameTypeCompatibility(TMHMCompatibility): + """Chance of each HM being usable on each Pokemon whose type matches the move. + At least one Pokemon will always be able to learn the moves needed to meet your accessibility requirements.""" + display_name = "HM Same-Type Compatibility" + + +class HMNormalTypeCompatibility(TMHMCompatibility): + """Chance of each HM being usable on each Pokemon if the move is Normal type and the Pokemon is not. + At least one Pokemon will always be able to learn the moves needed to meet your accessibility requirements.""" + display_name = "HM Normal-Type Compatibility" + + +class HMOtherTypeCompatibility(TMHMCompatibility): + """Chance of each HM being usable on each Pokemon if the move a type other than Normal or one of the Pokemon's types. + At least one Pokemon will always be able to learn the moves needed to meet your accessibility requirements.""" + display_name = "HM Other-Type Compatibility" + + +class InheritTMHMCompatibility(Toggle): + """If on, evolved Pokemon will inherit their pre-evolved form's TM and HM compatibilities. + They will then roll the above set chances again at a 50% lower rate for all TMs and HMs their predecessor could not + learn, unless the evolved form has additional or different types, then moves of those new types will be rolled + at the full set chance.""" + display_name = "Inherit TM/HM Compatibility" class RandomizePokemonTypes(Choice): @@ -543,6 +632,17 @@ class IceTrapWeight(TrapWeight): default = 0 +class RandomizePokemonPalettes(Choice): + """Modify palettes of Pokemon. Primary Type will set Pokemons' palettes based on their primary type, Follow + Evolutions will randomize palettes but palettes will remain the same through evolutions (except Eeveelutions), + Completely Random will randomize all Pokemons' palettes individually""" + display_name = "Randomize Pokemon Palettes" + option_vanilla = 0 + option_primary_type = 1 + option_follow_evolutions = 2 + option_completely_random = 3 + + pokemon_rb_options = { "game_version": GameVersion, "trainer_name": TrainerName, @@ -561,16 +661,22 @@ pokemon_rb_options = { "extra_strength_boulders": ExtraStrengthBoulders, "require_item_finder": RequireItemFinder, "randomize_hidden_items": RandomizeHiddenItems, + "prizesanity": PrizeSanity, "trainersanity": TrainerSanity, - "badges_needed_for_hm_moves": BadgesNeededForHMMoves, - "free_fly_location": FreeFlyLocation, + "require_pokedex": RequirePokedex, + "all_pokemon_seen": AllPokemonSeen, + "dexsanity": DexSanity, "oaks_aide_rt_2": OaksAidRt2, "oaks_aide_rt_11": OaksAidRt11, "oaks_aide_rt_15": OaksAidRt15, + "badges_needed_for_hm_moves": BadgesNeededForHMMoves, + "free_fly_location": FreeFlyLocation, + "randomize_rock_tunnel": RandomizeRockTunnel, "blind_trainers": BlindTrainers, "minimum_steps_between_encounters": MinimumStepsBetweenEncounters, "exp_modifier": ExpModifier, "randomize_wild_pokemon": RandomizeWildPokemon, + "area_1_to_1_mapping": Area1To1Mapping, "randomize_starter_pokemon": RandomizeStarterPokemon, "randomize_static_pokemon": RandomizeStaticPokemon, "randomize_legendary_pokemon": RandomizeLegendaryPokemon, @@ -580,11 +686,19 @@ pokemon_rb_options = { "minimum_catch_rate": MinimumCatchRate, "randomize_trainer_parties": RandomizeTrainerParties, "trainer_legendaries": TrainerLegendaries, + "move_balancing": MoveBalancing, "randomize_pokemon_movesets": RandomizePokemonMovesets, + "confine_transform_to_ditto": ConfineTranstormToDitto, "start_with_four_moves": StartWithFourMoves, "same_type_attack_bonus": SameTypeAttackBonus, - "tm_compatibility": TMCompatibility, - "hm_compatibility": HMCompatibility, + "randomize_tm_moves": RandomizeTMMoves, + "tm_same_type_compatibility": TMSameTypeCompatibility, + "tm_normal_type_compatibility": TMNormalTypeCompatibility, + "tm_other_type_compatibility": TMOtherTypeCompatibility, + "hm_same_type_compatibility": HMSameTypeCompatibility, + "hm_normal_type_compatibility": HMNormalTypeCompatibility, + "hm_other_type_compatibility": HMOtherTypeCompatibility, + "inherit_tm_hm_compatibility": InheritTMHMCompatibility, "randomize_pokemon_types": RandomizePokemonTypes, "secondary_type_chance": SecondaryTypeChance, "randomize_type_chart": RandomizeTypeChart, @@ -604,5 +718,6 @@ pokemon_rb_options = { "fire_trap_weight": FireTrapWeight, "paralyze_trap_weight": ParalyzeTrapWeight, "ice_trap_weight": IceTrapWeight, + "randomize_pokemon_palettes": RandomizePokemonPalettes, "death_link": DeathLink } diff --git a/worlds/pokemon_rb/poke_data.py b/worlds/pokemon_rb/poke_data.py index 691db1c46e..6218c70aa6 100644 --- a/worlds/pokemon_rb/poke_data.py +++ b/worlds/pokemon_rb/poke_data.py @@ -1006,172 +1006,172 @@ learnsets = { } moves = { - 'No Move': {'id': 0, 'power': 0, 'type': 'Typeless', 'accuracy': 0, 'pp': 0}, - 'Pound': {'id': 1, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 35}, - 'Karate Chop': {'id': 2, 'power': 50, 'type': 'Normal', 'accuracy': 100, 'pp': 25}, - 'Doubleslap': {'id': 3, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 10}, - 'Comet Punch': {'id': 4, 'power': 18, 'type': 'Normal', 'accuracy': 85, 'pp': 15}, - 'Mega Punch': {'id': 5, 'power': 80, 'type': 'Normal', 'accuracy': 85, 'pp': 20}, - 'Pay Day': {'id': 6, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Fire Punch': {'id': 7, 'power': 75, 'type': 'Fire', 'accuracy': 100, 'pp': 15}, - 'Ice Punch': {'id': 8, 'power': 75, 'type': 'Ice', 'accuracy': 100, 'pp': 15}, - 'Thunderpunch': {'id': 9, 'power': 75, 'type': 'Electric', 'accuracy': 100, 'pp': 15}, - 'Scratch': {'id': 10, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 35}, - 'Vicegrip': {'id': 11, 'power': 55, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Guillotine': {'id': 12, 'power': 1, 'type': 'Normal', 'accuracy': 30, 'pp': 5}, - 'Razor Wind': {'id': 13, 'power': 80, 'type': 'Normal', 'accuracy': 75, 'pp': 10}, - 'Swords Dance': {'id': 14, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Cut': {'id': 15, 'power': 50, 'type': 'Normal', 'accuracy': 95, 'pp': 30}, - 'Gust': {'id': 16, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 35}, - 'Wing Attack': {'id': 17, 'power': 35, 'type': 'Flying', 'accuracy': 100, 'pp': 35}, - 'Whirlwind': {'id': 18, 'power': 0, 'type': 'Normal', 'accuracy': 85, 'pp': 20}, - 'Fly': {'id': 19, 'power': 70, 'type': 'Flying', 'accuracy': 95, 'pp': 15}, - 'Bind': {'id': 20, 'power': 15, 'type': 'Normal', 'accuracy': 75, 'pp': 20}, - 'Slam': {'id': 21, 'power': 80, 'type': 'Normal', 'accuracy': 75, 'pp': 20}, - 'Vine Whip': {'id': 22, 'power': 35, 'type': 'Grass', 'accuracy': 100, 'pp': 10}, - 'Stomp': {'id': 23, 'power': 65, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Double Kick': {'id': 24, 'power': 30, 'type': 'Fighting', 'accuracy': 100, 'pp': 30}, - 'Mega Kick': {'id': 25, 'power': 120, 'type': 'Normal', 'accuracy': 75, 'pp': 5}, - 'Jump Kick': {'id': 26, 'power': 70, 'type': 'Fighting', 'accuracy': 95, 'pp': 25}, - 'Rolling Kick': {'id': 27, 'power': 60, 'type': 'Fighting', 'accuracy': 85, 'pp': 15}, - 'Sand Attack': {'id': 28, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Headbutt': {'id': 29, 'power': 70, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Horn Attack': {'id': 30, 'power': 65, 'type': 'Normal', 'accuracy': 100, 'pp': 25}, - 'Fury Attack': {'id': 31, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 20}, - 'Horn Drill': {'id': 32, 'power': 1, 'type': 'Normal', 'accuracy': 30, 'pp': 5}, - 'Tackle': {'id': 33, 'power': 35, 'type': 'Normal', 'accuracy': 95, 'pp': 35}, - 'Body Slam': {'id': 34, 'power': 85, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Wrap': {'id': 35, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 20}, - 'Take Down': {'id': 36, 'power': 90, 'type': 'Normal', 'accuracy': 85, 'pp': 20}, - 'Thrash': {'id': 37, 'power': 90, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Double Edge': {'id': 38, 'power': 100, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Tail Whip': {'id': 39, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Poison Sting': {'id': 40, 'power': 15, 'type': 'Poison', 'accuracy': 100, 'pp': 35}, - 'Twineedle': {'id': 41, 'power': 25, 'type': 'Bug', 'accuracy': 100, 'pp': 20}, - 'Pin Missile': {'id': 42, 'power': 14, 'type': 'Bug', 'accuracy': 85, 'pp': 20}, - 'Leer': {'id': 43, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Bite': {'id': 44, 'power': 60, 'type': 'Normal', 'accuracy': 100, 'pp': 25}, - 'Growl': {'id': 45, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40}, - 'Roar': {'id': 46, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Sing': {'id': 47, 'power': 0, 'type': 'Normal', 'accuracy': 55, 'pp': 15}, - 'Supersonic': {'id': 48, 'power': 0, 'type': 'Normal', 'accuracy': 55, 'pp': 20}, - 'Sonicboom': {'id': 49, 'power': 1, 'type': 'Normal', 'accuracy': 90, 'pp': 20}, - 'Disable': {'id': 50, 'power': 0, 'type': 'Normal', 'accuracy': 55, 'pp': 20}, - 'Acid': {'id': 51, 'power': 40, 'type': 'Poison', 'accuracy': 100, 'pp': 30}, - 'Ember': {'id': 52, 'power': 40, 'type': 'Fire', 'accuracy': 100, 'pp': 25}, - 'Flamethrower': {'id': 53, 'power': 95, 'type': 'Fire', 'accuracy': 100, 'pp': 15}, - 'Mist': {'id': 54, 'power': 0, 'type': 'Ice', 'accuracy': 100, 'pp': 30}, - 'Water Gun': {'id': 55, 'power': 40, 'type': 'Water', 'accuracy': 100, 'pp': 25}, - 'Hydro Pump': {'id': 56, 'power': 120, 'type': 'Water', 'accuracy': 80, 'pp': 5}, - 'Surf': {'id': 57, 'power': 95, 'type': 'Water', 'accuracy': 100, 'pp': 15}, - 'Ice Beam': {'id': 58, 'power': 95, 'type': 'Ice', 'accuracy': 100, 'pp': 10}, - 'Blizzard': {'id': 59, 'power': 120, 'type': 'Ice', 'accuracy': 90, 'pp': 5}, - 'Psybeam': {'id': 60, 'power': 65, 'type': 'Psychic', 'accuracy': 100, 'pp': 20}, - 'Bubblebeam': {'id': 61, 'power': 65, 'type': 'Water', 'accuracy': 100, 'pp': 20}, - 'Aurora Beam': {'id': 62, 'power': 65, 'type': 'Ice', 'accuracy': 100, 'pp': 20}, - 'Hyper Beam': {'id': 63, 'power': 150, 'type': 'Normal', 'accuracy': 90, 'pp': 5}, - 'Peck': {'id': 64, 'power': 35, 'type': 'Flying', 'accuracy': 100, 'pp': 35}, - 'Drill Peck': {'id': 65, 'power': 80, 'type': 'Flying', 'accuracy': 100, 'pp': 20}, - 'Submission': {'id': 66, 'power': 80, 'type': 'Fighting', 'accuracy': 80, 'pp': 25}, - 'Low Kick': {'id': 67, 'power': 50, 'type': 'Fighting', 'accuracy': 90, 'pp': 20}, - 'Counter': {'id': 68, 'power': 1, 'type': 'Fighting', 'accuracy': 100, 'pp': 20}, - 'Seismic Toss': {'id': 69, 'power': 1, 'type': 'Fighting', 'accuracy': 100, 'pp': 20}, - 'Strength': {'id': 70, 'power': 80, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Absorb': {'id': 71, 'power': 20, 'type': 'Grass', 'accuracy': 100, 'pp': 20}, - 'Mega Drain': {'id': 72, 'power': 40, 'type': 'Grass', 'accuracy': 100, 'pp': 10}, - 'Leech Seed': {'id': 73, 'power': 0, 'type': 'Grass', 'accuracy': 90, 'pp': 10}, - 'Growth': {'id': 74, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40}, - 'Razor Leaf': {'id': 75, 'power': 55, 'type': 'Grass', 'accuracy': 95, 'pp': 25}, - 'Solarbeam': {'id': 76, 'power': 120, 'type': 'Grass', 'accuracy': 100, 'pp': 10}, - 'Poisonpowder': {'id': 77, 'power': 0, 'type': 'Poison', 'accuracy': 75, 'pp': 35}, - 'Stun Spore': {'id': 78, 'power': 0, 'type': 'Grass', 'accuracy': 75, 'pp': 30}, - 'Sleep Powder': {'id': 79, 'power': 0, 'type': 'Grass', 'accuracy': 75, 'pp': 15}, - 'Petal Dance': {'id': 80, 'power': 70, 'type': 'Grass', 'accuracy': 100, 'pp': 20}, - 'String Shot': {'id': 81, 'power': 0, 'type': 'Bug', 'accuracy': 95, 'pp': 40}, - 'Dragon Rage': {'id': 82, 'power': 1, 'type': 'Dragon', 'accuracy': 100, 'pp': 10}, - 'Fire Spin': {'id': 83, 'power': 15, 'type': 'Fire', 'accuracy': 70, 'pp': 15}, - 'Thundershock': {'id': 84, 'power': 40, 'type': 'Electric', 'accuracy': 100, 'pp': 30}, - 'Thunderbolt': {'id': 85, 'power': 95, 'type': 'Electric', 'accuracy': 100, 'pp': 15}, - 'Thunder Wave': {'id': 86, 'power': 0, 'type': 'Electric', 'accuracy': 100, 'pp': 20}, - 'Thunder': {'id': 87, 'power': 120, 'type': 'Electric', 'accuracy': 70, 'pp': 10}, - 'Rock Throw': {'id': 88, 'power': 50, 'type': 'Rock', 'accuracy': 65, 'pp': 15}, - 'Earthquake': {'id': 89, 'power': 100, 'type': 'Ground', 'accuracy': 100, 'pp': 10}, - 'Fissure': {'id': 90, 'power': 1, 'type': 'Ground', 'accuracy': 30, 'pp': 5}, - 'Dig': {'id': 91, 'power': 100, 'type': 'Ground', 'accuracy': 100, 'pp': 10}, - 'Toxic': {'id': 92, 'power': 0, 'type': 'Poison', 'accuracy': 85, 'pp': 10}, - 'Confusion': {'id': 93, 'power': 50, 'type': 'Psychic', 'accuracy': 100, 'pp': 25}, - 'Psychic': {'id': 94, 'power': 90, 'type': 'Psychic', 'accuracy': 100, 'pp': 10}, - 'Hypnosis': {'id': 95, 'power': 0, 'type': 'Psychic', 'accuracy': 60, 'pp': 20}, - 'Meditate': {'id': 96, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 40}, - 'Agility': {'id': 97, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 30}, - 'Quick Attack': {'id': 98, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Rage': {'id': 99, 'power': 20, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Teleport': {'id': 100, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 20}, - 'Night Shade': {'id': 101, 'power': 0, 'type': 'Ghost', 'accuracy': 100, 'pp': 15}, - 'Mimic': {'id': 102, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Screech': {'id': 103, 'power': 0, 'type': 'Normal', 'accuracy': 85, 'pp': 40}, - 'Double Team': {'id': 104, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Recover': {'id': 105, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Harden': {'id': 106, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Minimize': {'id': 107, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Smokescreen': {'id': 108, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Confuse Ray': {'id': 109, 'power': 0, 'type': 'Ghost', 'accuracy': 100, 'pp': 10}, - 'Withdraw': {'id': 110, 'power': 0, 'type': 'Water', 'accuracy': 100, 'pp': 40}, - 'Defense Curl': {'id': 111, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40}, - 'Barrier': {'id': 112, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 30}, - 'Light Screen': {'id': 113, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 30}, - 'Haze': {'id': 114, 'power': 0, 'type': 'Ice', 'accuracy': 100, 'pp': 30}, - 'Reflect': {'id': 115, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 20}, - 'Focus Energy': {'id': 116, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Bide': {'id': 117, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Metronome': {'id': 118, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Mirror Move': {'id': 119, 'power': 0, 'type': 'Flying', 'accuracy': 100, 'pp': 20}, - 'Selfdestruct': {'id': 120, 'power': 130, 'type': 'Normal', 'accuracy': 100, 'pp': 5}, - 'Egg Bomb': {'id': 121, 'power': 100, 'type': 'Normal', 'accuracy': 75, 'pp': 10}, - 'Lick': {'id': 122, 'power': 20, 'type': 'Ghost', 'accuracy': 100, 'pp': 30}, - 'Smog': {'id': 123, 'power': 20, 'type': 'Poison', 'accuracy': 70, 'pp': 20}, - 'Sludge': {'id': 124, 'power': 65, 'type': 'Poison', 'accuracy': 100, 'pp': 20}, - 'Bone Club': {'id': 125, 'power': 65, 'type': 'Ground', 'accuracy': 85, 'pp': 20}, - 'Fire Blast': {'id': 126, 'power': 120, 'type': 'Fire', 'accuracy': 85, 'pp': 5}, - 'Waterfall': {'id': 127, 'power': 80, 'type': 'Water', 'accuracy': 100, 'pp': 15}, - 'Clamp': {'id': 128, 'power': 35, 'type': 'Water', 'accuracy': 75, 'pp': 10}, - 'Swift': {'id': 129, 'power': 60, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Skull Bash': {'id': 130, 'power': 100, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Spike Cannon': {'id': 131, 'power': 20, 'type': 'Normal', 'accuracy': 100, 'pp': 15}, - 'Constrict': {'id': 132, 'power': 10, 'type': 'Normal', 'accuracy': 100, 'pp': 35}, - 'Amnesia': {'id': 133, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 20}, - 'Kinesis': {'id': 134, 'power': 0, 'type': 'Psychic', 'accuracy': 80, 'pp': 15}, - 'Softboiled': {'id': 135, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Hi Jump Kick': {'id': 136, 'power': 85, 'type': 'Fighting', 'accuracy': 90, 'pp': 20}, - 'Glare': {'id': 137, 'power': 0, 'type': 'Normal', 'accuracy': 75, 'pp': 30}, - 'Dream Eater': {'id': 138, 'power': 100, 'type': 'Psychic', 'accuracy': 100, 'pp': 15}, - 'Poison Gas': {'id': 139, 'power': 0, 'type': 'Poison', 'accuracy': 55, 'pp': 40}, - 'Barrage': {'id': 140, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 20}, - 'Leech Life': {'id': 141, 'power': 20, 'type': 'Bug', 'accuracy': 100, 'pp': 15}, - 'Lovely Kiss': {'id': 142, 'power': 0, 'type': 'Normal', 'accuracy': 75, 'pp': 10}, - 'Sky Attack': {'id': 143, 'power': 140, 'type': 'Flying', 'accuracy': 90, 'pp': 5}, - 'Transform': {'id': 144, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Bubble': {'id': 145, 'power': 20, 'type': 'Water', 'accuracy': 100, 'pp': 30}, - 'Dizzy Punch': {'id': 146, 'power': 70, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Spore': {'id': 147, 'power': 0, 'type': 'Grass', 'accuracy': 100, 'pp': 15}, - 'Flash': {'id': 148, 'power': 0, 'type': 'Normal', 'accuracy': 70, 'pp': 20}, - 'Psywave': {'id': 149, 'power': 1, 'type': 'Psychic', 'accuracy': 80, 'pp': 15}, - 'Splash': {'id': 150, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40}, - 'Acid Armor': {'id': 151, 'power': 0, 'type': 'Poison', 'accuracy': 100, 'pp': 40}, - 'Crabhammer': {'id': 152, 'power': 90, 'type': 'Water', 'accuracy': 85, 'pp': 10}, - 'Explosion': {'id': 153, 'power': 170, 'type': 'Normal', 'accuracy': 100, 'pp': 5}, - 'Fury Swipes': {'id': 154, 'power': 18, 'type': 'Normal', 'accuracy': 80, 'pp': 15}, - 'Bonemerang': {'id': 155, 'power': 50, 'type': 'Ground', 'accuracy': 90, 'pp': 10}, - 'Rest': {'id': 156, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 10}, - 'Rock Slide': {'id': 157, 'power': 75, 'type': 'Rock', 'accuracy': 90, 'pp': 10}, - 'Hyper Fang': {'id': 158, 'power': 80, 'type': 'Normal', 'accuracy': 90, 'pp': 15}, - 'Sharpen': {'id': 159, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Conversion': {'id': 160, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30}, - 'Tri Attack': {'id': 161, 'power': 80, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - 'Super Fang': {'id': 162, 'power': 1, 'type': 'Normal', 'accuracy': 90, 'pp': 10}, - 'Slash': {'id': 163, 'power': 70, 'type': 'Normal', 'accuracy': 100, 'pp': 20}, - 'Substitute': {'id': 164, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10}, - #'Struggle': {'id': 165, 'power': 50, 'type': 'Struggle_Type', 'accuracy': 100, 'pp': 10} + 'No Move': {'id': 0, 'power': 0, 'type': 'Typeless', 'accuracy': 0, 'pp': 0, 'effect': 0}, + 'Pound': {'id': 1, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 35, 'effect': 0}, + 'Karate Chop': {'id': 2, 'power': 50, 'type': 'Normal', 'accuracy': 100, 'pp': 25, 'effect': 0}, + 'Doubleslap': {'id': 3, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 10, 'effect': 29}, + 'Comet Punch': {'id': 4, 'power': 18, 'type': 'Normal', 'accuracy': 85, 'pp': 15, 'effect': 29}, + 'Mega Punch': {'id': 5, 'power': 80, 'type': 'Normal', 'accuracy': 85, 'pp': 20, 'effect': 0}, + 'Pay Day': {'id': 6, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 16}, + 'Fire Punch': {'id': 7, 'power': 75, 'type': 'Fire', 'accuracy': 100, 'pp': 15, 'effect': 4}, + 'Ice Punch': {'id': 8, 'power': 75, 'type': 'Ice', 'accuracy': 100, 'pp': 15, 'effect': 5}, + 'Thunderpunch': {'id': 9, 'power': 75, 'type': 'Electric', 'accuracy': 100, 'pp': 15, 'effect': 6}, + 'Scratch': {'id': 10, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 35, 'effect': 0}, + 'Vicegrip': {'id': 11, 'power': 55, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 0}, + 'Guillotine': {'id': 12, 'power': 1, 'type': 'Normal', 'accuracy': 30, 'pp': 5, 'effect': 38}, + 'Razor Wind': {'id': 13, 'power': 80, 'type': 'Normal', 'accuracy': 75, 'pp': 10, 'effect': 39}, + 'Swords Dance': {'id': 14, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 50}, + 'Cut': {'id': 15, 'power': 50, 'type': 'Normal', 'accuracy': 95, 'pp': 30, 'effect': 0}, + 'Gust': {'id': 16, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 35, 'effect': 0}, + 'Wing Attack': {'id': 17, 'power': 35, 'type': 'Flying', 'accuracy': 100, 'pp': 35, 'effect': 0}, + 'Whirlwind': {'id': 18, 'power': 0, 'type': 'Normal', 'accuracy': 85, 'pp': 20, 'effect': 28}, + 'Fly': {'id': 19, 'power': 70, 'type': 'Flying', 'accuracy': 95, 'pp': 15, 'effect': 43}, + 'Bind': {'id': 20, 'power': 15, 'type': 'Normal', 'accuracy': 75, 'pp': 20, 'effect': 42}, + 'Slam': {'id': 21, 'power': 80, 'type': 'Normal', 'accuracy': 75, 'pp': 20, 'effect': 0}, + 'Vine Whip': {'id': 22, 'power': 35, 'type': 'Grass', 'accuracy': 100, 'pp': 10, 'effect': 0}, + 'Stomp': {'id': 23, 'power': 65, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 37}, + 'Double Kick': {'id': 24, 'power': 30, 'type': 'Fighting', 'accuracy': 100, 'pp': 30, 'effect': 44}, + 'Mega Kick': {'id': 25, 'power': 120, 'type': 'Normal', 'accuracy': 75, 'pp': 5, 'effect': 0}, + 'Jump Kick': {'id': 26, 'power': 70, 'type': 'Fighting', 'accuracy': 95, 'pp': 25, 'effect': 45}, + 'Rolling Kick': {'id': 27, 'power': 60, 'type': 'Fighting', 'accuracy': 85, 'pp': 15, 'effect': 37}, + 'Sand Attack': {'id': 28, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 22}, + 'Headbutt': {'id': 29, 'power': 70, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 37}, + 'Horn Attack': {'id': 30, 'power': 65, 'type': 'Normal', 'accuracy': 100, 'pp': 25, 'effect': 0}, + 'Fury Attack': {'id': 31, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 20, 'effect': 29}, + 'Horn Drill': {'id': 32, 'power': 1, 'type': 'Normal', 'accuracy': 30, 'pp': 5, 'effect': 38}, + 'Tackle': {'id': 33, 'power': 35, 'type': 'Normal', 'accuracy': 95, 'pp': 35, 'effect': 0}, + 'Body Slam': {'id': 34, 'power': 85, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 36}, + 'Wrap': {'id': 35, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 20, 'effect': 42}, + 'Take Down': {'id': 36, 'power': 90, 'type': 'Normal', 'accuracy': 85, 'pp': 20, 'effect': 48}, + 'Thrash': {'id': 37, 'power': 90, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 27}, + 'Double Edge': {'id': 38, 'power': 100, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 48}, + 'Tail Whip': {'id': 39, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 19}, + 'Poison Sting': {'id': 40, 'power': 15, 'type': 'Poison', 'accuracy': 100, 'pp': 35, 'effect': 2}, + 'Twineedle': {'id': 41, 'power': 25, 'type': 'Bug', 'accuracy': 100, 'pp': 20, 'effect': 77}, + 'Pin Missile': {'id': 42, 'power': 14, 'type': 'Bug', 'accuracy': 85, 'pp': 20, 'effect': 29}, + 'Leer': {'id': 43, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 19}, + 'Bite': {'id': 44, 'power': 60, 'type': 'Normal', 'accuracy': 100, 'pp': 25, 'effect': 31}, + 'Growl': {'id': 45, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40, 'effect': 18}, + 'Roar': {'id': 46, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 28}, + 'Sing': {'id': 47, 'power': 0, 'type': 'Normal', 'accuracy': 55, 'pp': 15, 'effect': 32}, + 'Supersonic': {'id': 48, 'power': 0, 'type': 'Normal', 'accuracy': 55, 'pp': 20, 'effect': 49}, + 'Sonicboom': {'id': 49, 'power': 1, 'type': 'Normal', 'accuracy': 90, 'pp': 20, 'effect': 41}, + 'Disable': {'id': 50, 'power': 0, 'type': 'Normal', 'accuracy': 55, 'pp': 20, 'effect': 86}, + 'Acid': {'id': 51, 'power': 40, 'type': 'Poison', 'accuracy': 100, 'pp': 30, 'effect': 69}, + 'Ember': {'id': 52, 'power': 40, 'type': 'Fire', 'accuracy': 100, 'pp': 25, 'effect': 4}, + 'Flamethrower': {'id': 53, 'power': 95, 'type': 'Fire', 'accuracy': 100, 'pp': 15, 'effect': 4}, + 'Mist': {'id': 54, 'power': 0, 'type': 'Ice', 'accuracy': 100, 'pp': 30, 'effect': 46}, + 'Water Gun': {'id': 55, 'power': 40, 'type': 'Water', 'accuracy': 100, 'pp': 25, 'effect': 0}, + 'Hydro Pump': {'id': 56, 'power': 120, 'type': 'Water', 'accuracy': 80, 'pp': 5, 'effect': 0}, + 'Surf': {'id': 57, 'power': 95, 'type': 'Water', 'accuracy': 100, 'pp': 15, 'effect': 0}, + 'Ice Beam': {'id': 58, 'power': 95, 'type': 'Ice', 'accuracy': 100, 'pp': 10, 'effect': 5}, + 'Blizzard': {'id': 59, 'power': 120, 'type': 'Ice', 'accuracy': 90, 'pp': 5, 'effect': 5}, + 'Psybeam': {'id': 60, 'power': 65, 'type': 'Psychic', 'accuracy': 100, 'pp': 20, 'effect': 76}, + 'Bubblebeam': {'id': 61, 'power': 65, 'type': 'Water', 'accuracy': 100, 'pp': 20, 'effect': 70}, + 'Aurora Beam': {'id': 62, 'power': 65, 'type': 'Ice', 'accuracy': 100, 'pp': 20, 'effect': 68}, + 'Hyper Beam': {'id': 63, 'power': 150, 'type': 'Normal', 'accuracy': 90, 'pp': 5, 'effect': 80}, + 'Peck': {'id': 64, 'power': 35, 'type': 'Flying', 'accuracy': 100, 'pp': 35, 'effect': 0}, + 'Drill Peck': {'id': 65, 'power': 80, 'type': 'Flying', 'accuracy': 100, 'pp': 20, 'effect': 0}, + 'Submission': {'id': 66, 'power': 80, 'type': 'Fighting', 'accuracy': 80, 'pp': 25, 'effect': 48}, + 'Low Kick': {'id': 67, 'power': 50, 'type': 'Fighting', 'accuracy': 90, 'pp': 20, 'effect': 37}, + 'Counter': {'id': 68, 'power': 1, 'type': 'Fighting', 'accuracy': 100, 'pp': 20, 'effect': 0}, + 'Seismic Toss': {'id': 69, 'power': 1, 'type': 'Fighting', 'accuracy': 100, 'pp': 20, 'effect': 41}, + 'Strength': {'id': 70, 'power': 80, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 0}, + 'Absorb': {'id': 71, 'power': 20, 'type': 'Grass', 'accuracy': 100, 'pp': 20, 'effect': 3}, + 'Mega Drain': {'id': 72, 'power': 40, 'type': 'Grass', 'accuracy': 100, 'pp': 10, 'effect': 3}, + 'Leech Seed': {'id': 73, 'power': 0, 'type': 'Grass', 'accuracy': 90, 'pp': 10, 'effect': 84}, + 'Growth': {'id': 74, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40, 'effect': 13}, + 'Razor Leaf': {'id': 75, 'power': 55, 'type': 'Grass', 'accuracy': 95, 'pp': 25, 'effect': 0}, + 'Solarbeam': {'id': 76, 'power': 120, 'type': 'Grass', 'accuracy': 100, 'pp': 10, 'effect': 39}, + 'Poisonpowder': {'id': 77, 'power': 0, 'type': 'Poison', 'accuracy': 75, 'pp': 35, 'effect': 66}, + 'Stun Spore': {'id': 78, 'power': 0, 'type': 'Grass', 'accuracy': 75, 'pp': 30, 'effect': 67}, + 'Sleep Powder': {'id': 79, 'power': 0, 'type': 'Grass', 'accuracy': 75, 'pp': 15, 'effect': 32}, + 'Petal Dance': {'id': 80, 'power': 70, 'type': 'Grass', 'accuracy': 100, 'pp': 20, 'effect': 27}, + 'String Shot': {'id': 81, 'power': 0, 'type': 'Bug', 'accuracy': 95, 'pp': 40, 'effect': 20}, + 'Dragon Rage': {'id': 82, 'power': 1, 'type': 'Dragon', 'accuracy': 100, 'pp': 10, 'effect': 41}, + 'Fire Spin': {'id': 83, 'power': 15, 'type': 'Fire', 'accuracy': 70, 'pp': 15, 'effect': 42}, + 'Thundershock': {'id': 84, 'power': 40, 'type': 'Electric', 'accuracy': 100, 'pp': 30, 'effect': 6}, + 'Thunderbolt': {'id': 85, 'power': 95, 'type': 'Electric', 'accuracy': 100, 'pp': 15, 'effect': 6}, + 'Thunder Wave': {'id': 86, 'power': 0, 'type': 'Electric', 'accuracy': 100, 'pp': 20, 'effect': 67}, + 'Thunder': {'id': 87, 'power': 120, 'type': 'Electric', 'accuracy': 70, 'pp': 10, 'effect': 6}, + 'Rock Throw': {'id': 88, 'power': 50, 'type': 'Rock', 'accuracy': 65, 'pp': 15, 'effect': 0}, + 'Earthquake': {'id': 89, 'power': 100, 'type': 'Ground', 'accuracy': 100, 'pp': 10, 'effect': 0}, + 'Fissure': {'id': 90, 'power': 1, 'type': 'Ground', 'accuracy': 30, 'pp': 5, 'effect': 38}, + 'Dig': {'id': 91, 'power': 100, 'type': 'Ground', 'accuracy': 100, 'pp': 10, 'effect': 39}, + 'Toxic': {'id': 92, 'power': 0, 'type': 'Poison', 'accuracy': 85, 'pp': 10, 'effect': 66}, + 'Confusion': {'id': 93, 'power': 50, 'type': 'Psychic', 'accuracy': 100, 'pp': 25, 'effect': 76}, + 'Psychic': {'id': 94, 'power': 90, 'type': 'Psychic', 'accuracy': 100, 'pp': 10, 'effect': 71}, + 'Hypnosis': {'id': 95, 'power': 0, 'type': 'Psychic', 'accuracy': 60, 'pp': 20, 'effect': 32}, + 'Meditate': {'id': 96, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 40, 'effect': 10}, + 'Agility': {'id': 97, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 30, 'effect': 52}, + 'Quick Attack': {'id': 98, 'power': 40, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 0}, + 'Rage': {'id': 99, 'power': 20, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 81}, + 'Teleport': {'id': 100, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 20, 'effect': 28}, + 'Night Shade': {'id': 101, 'power': 0, 'type': 'Ghost', 'accuracy': 100, 'pp': 15, 'effect': 41}, + 'Mimic': {'id': 102, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 82}, + 'Screech': {'id': 103, 'power': 0, 'type': 'Normal', 'accuracy': 85, 'pp': 40, 'effect': 59}, + 'Double Team': {'id': 104, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 15}, + 'Recover': {'id': 105, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 56}, + 'Harden': {'id': 106, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 11}, + 'Minimize': {'id': 107, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 15}, + 'Smokescreen': {'id': 108, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 22}, + 'Confuse Ray': {'id': 109, 'power': 0, 'type': 'Ghost', 'accuracy': 100, 'pp': 10, 'effect': 49}, + 'Withdraw': {'id': 110, 'power': 0, 'type': 'Water', 'accuracy': 100, 'pp': 40, 'effect': 11}, + 'Defense Curl': {'id': 111, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40, 'effect': 11}, + 'Barrier': {'id': 112, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 30, 'effect': 51}, + 'Light Screen': {'id': 113, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 30, 'effect': 64}, + 'Haze': {'id': 114, 'power': 0, 'type': 'Ice', 'accuracy': 100, 'pp': 30, 'effect': 25}, + 'Reflect': {'id': 115, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 20, 'effect': 65}, + 'Focus Energy': {'id': 116, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 47}, + 'Bide': {'id': 117, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 26}, + 'Metronome': {'id': 118, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 83}, + 'Mirror Move': {'id': 119, 'power': 0, 'type': 'Flying', 'accuracy': 100, 'pp': 20, 'effect': 9}, + 'Selfdestruct': {'id': 120, 'power': 130, 'type': 'Normal', 'accuracy': 100, 'pp': 5, 'effect': 7}, + 'Egg Bomb': {'id': 121, 'power': 100, 'type': 'Normal', 'accuracy': 75, 'pp': 10, 'effect': 0}, + 'Lick': {'id': 122, 'power': 20, 'type': 'Ghost', 'accuracy': 100, 'pp': 30, 'effect': 36}, + 'Smog': {'id': 123, 'power': 20, 'type': 'Poison', 'accuracy': 70, 'pp': 20, 'effect': 33}, + 'Sludge': {'id': 124, 'power': 65, 'type': 'Poison', 'accuracy': 100, 'pp': 20, 'effect': 33}, + 'Bone Club': {'id': 125, 'power': 65, 'type': 'Ground', 'accuracy': 85, 'pp': 20, 'effect': 31}, + 'Fire Blast': {'id': 126, 'power': 120, 'type': 'Fire', 'accuracy': 85, 'pp': 5, 'effect': 34}, + 'Waterfall': {'id': 127, 'power': 80, 'type': 'Water', 'accuracy': 100, 'pp': 15, 'effect': 0}, + 'Clamp': {'id': 128, 'power': 35, 'type': 'Water', 'accuracy': 75, 'pp': 10, 'effect': 42}, + 'Swift': {'id': 129, 'power': 60, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 17}, + 'Skull Bash': {'id': 130, 'power': 100, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 39}, + 'Spike Cannon': {'id': 131, 'power': 20, 'type': 'Normal', 'accuracy': 100, 'pp': 15, 'effect': 29}, + 'Constrict': {'id': 132, 'power': 10, 'type': 'Normal', 'accuracy': 100, 'pp': 35, 'effect': 70}, + 'Amnesia': {'id': 133, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 20, 'effect': 53}, + 'Kinesis': {'id': 134, 'power': 0, 'type': 'Psychic', 'accuracy': 80, 'pp': 15, 'effect': 22}, + 'Softboiled': {'id': 135, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 56}, + 'Hi Jump Kick': {'id': 136, 'power': 85, 'type': 'Fighting', 'accuracy': 90, 'pp': 20, 'effect': 45}, + 'Glare': {'id': 137, 'power': 0, 'type': 'Normal', 'accuracy': 75, 'pp': 30, 'effect': 67}, + 'Dream Eater': {'id': 138, 'power': 100, 'type': 'Psychic', 'accuracy': 100, 'pp': 15, 'effect': 8}, + 'Poison Gas': {'id': 139, 'power': 0, 'type': 'Poison', 'accuracy': 55, 'pp': 40, 'effect': 66}, + 'Barrage': {'id': 140, 'power': 15, 'type': 'Normal', 'accuracy': 85, 'pp': 20, 'effect': 29}, + 'Leech Life': {'id': 141, 'power': 20, 'type': 'Bug', 'accuracy': 100, 'pp': 15, 'effect': 3}, + 'Lovely Kiss': {'id': 142, 'power': 0, 'type': 'Normal', 'accuracy': 75, 'pp': 10, 'effect': 32}, + 'Sky Attack': {'id': 143, 'power': 140, 'type': 'Flying', 'accuracy': 90, 'pp': 5, 'effect': 39}, + 'Transform': {'id': 144, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 57}, + 'Bubble': {'id': 145, 'power': 20, 'type': 'Water', 'accuracy': 100, 'pp': 30, 'effect': 70}, + 'Dizzy Punch': {'id': 146, 'power': 70, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 0}, + 'Spore': {'id': 147, 'power': 0, 'type': 'Grass', 'accuracy': 100, 'pp': 15, 'effect': 32}, + 'Flash': {'id': 148, 'power': 0, 'type': 'Normal', 'accuracy': 70, 'pp': 20, 'effect': 22}, + 'Psywave': {'id': 149, 'power': 1, 'type': 'Psychic', 'accuracy': 80, 'pp': 15, 'effect': 41}, + 'Splash': {'id': 150, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 40, 'effect': 85}, + 'Acid Armor': {'id': 151, 'power': 0, 'type': 'Poison', 'accuracy': 100, 'pp': 40, 'effect': 51}, + 'Crabhammer': {'id': 152, 'power': 90, 'type': 'Water', 'accuracy': 85, 'pp': 10, 'effect': 0}, + 'Explosion': {'id': 153, 'power': 170, 'type': 'Normal', 'accuracy': 100, 'pp': 5, 'effect': 7}, + 'Fury Swipes': {'id': 154, 'power': 18, 'type': 'Normal', 'accuracy': 80, 'pp': 15, 'effect': 29}, + 'Bonemerang': {'id': 155, 'power': 50, 'type': 'Ground', 'accuracy': 90, 'pp': 10, 'effect': 44}, + 'Rest': {'id': 156, 'power': 0, 'type': 'Psychic', 'accuracy': 100, 'pp': 10, 'effect': 56}, + 'Rock Slide': {'id': 157, 'power': 75, 'type': 'Rock', 'accuracy': 90, 'pp': 10, 'effect': 0}, + 'Hyper Fang': {'id': 158, 'power': 80, 'type': 'Normal', 'accuracy': 90, 'pp': 15, 'effect': 31}, + 'Sharpen': {'id': 159, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 10}, + 'Conversion': {'id': 160, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 30, 'effect': 24}, + 'Tri Attack': {'id': 161, 'power': 80, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 0}, + 'Super Fang': {'id': 162, 'power': 1, 'type': 'Normal', 'accuracy': 90, 'pp': 10, 'effect': 40}, + 'Slash': {'id': 163, 'power': 70, 'type': 'Normal', 'accuracy': 100, 'pp': 20, 'effect': 0}, + 'Substitute': {'id': 164, 'power': 0, 'type': 'Normal', 'accuracy': 100, 'pp': 10, 'effect': 79} + #'Struggle': {'id': 165, 'power': 50, 'type': 'Struggle_Type', 'accuracy': 100, 'pp': 10, 'effect': 48} } encounter_tables = {'Wild_Super_Rod_A': 2, 'Wild_Super_Rod_B': 2, 'Wild_Super_Rod_C': 3, 'Wild_Super_Rod_D': 2, @@ -1204,6 +1204,29 @@ tm_moves = [ 'Selfdestruct', 'Egg Bomb', 'Fire Blast', 'Swift', 'Skull Bash', 'Softboiled', 'Dream Eater', 'Sky Attack', 'Rest', 'Thunder Wave', 'Psywave', 'Explosion', 'Rock Slide', 'Tri Attack', 'Substitute' ] +#['No Move', 'Pound', 'Karate Chop', 'Doubleslap', 'Comet Punch', 'Fire Punch', 'Ice Punch', 'Thunderpunch', 'Scratch', +# 'Vicegrip', 'Guillotine', 'Cut', 'Gust', 'Wing Attack', 'Fly', 'Bind', 'Slam', 'Vine Whip', 'Stomp', 'Double Kick', 'Jump Kick', +# 'Rolling Kick', 'Sand Attack', 'Headbutt', 'Horn Attack', 'Fury Attack', 'Tackle', 'Wrap', 'Thrash', 'Tail Whip', 'Poison Sting', +# 'Twineedle', 'Pin Missile', 'Leer', 'Bite', 'Growl', 'Roar', 'Sing', 'Supersonic', 'Sonicboom', 'Disable', 'Acid', 'Ember', 'Flamethrower', +# 'Mist', 'Hydro Pump', 'Surf', 'Psybeam', 'Aurora Beam', 'Peck', 'Drill Peck', 'Low Kick', 'Strength', 'Absorb', 'Leech Seed', 'Growth', +# 'Razor Leaf', 'Poisonpowder', 'Stun Spore', 'Sleep Powder', 'Petal Dance', 'String Shot', 'Fire Spin', 'Thundershock', 'Rock Throw', 'Confusion', +# 'Hypnosis', 'Meditate', 'Agility', 'Quick Attack', 'Night Shade', 'Screech', 'Recover', 'Harden', 'Minimize', 'Smokescreen', 'Confuse Ray', 'Withdraw', +# 'Defense Curl', 'Barrier', 'Light Screen', 'Haze', 'Focus Energy', 'Mirror Move', 'Lick', 'Smog', 'Sludge', 'Bone Club', 'Waterfall', 'Clamp', 'Spike Cannon', +# 'Constrict', 'Amnesia', 'Kinesis', 'Hi Jump Kick', 'Glare', 'Poison Gas', 'Barrage', 'Leech Life', 'Lovely Kiss', 'Transform', 'Bubble', 'Dizzy Punch', 'Spore', 'Flash', +# 'Splash', 'Acid Armor', 'Crabhammer', 'Fury Swipes', 'Bonemerang', 'Hyper Fang', 'Sharpen', 'Conversion', 'Super Fang', 'Slash'] + +# print([i for i in list(moves.keys()) if i not in tm_moves]) +# filler_moves = [ +# "Razor Wind", "Whirlwind", "Counter", "Teleport", "Bide", "Skull Bash", "Sky Attack", "Psywave", +# "Pound", "Karate Chop", "Doubleslap", "Comet Punch", "Scratch", "Vicegrip", "Gust", "Wing Attack", "Bind", +# "Vine Whip", "Sand Attack", "Fury Attack", "Tackle", "Wrap", "Tail Whip", "Poison Sting", "Twineedle", +# "Leer", "Growl", "Roar", "Sing", "Supersonic", "Sonicboom", "Disable", "Acid", "Ember", "Mist", "Peck", "Absorb", +# "Growth", "Poisonpowder", "String Shot", "Meditate", "Agility", "Screech", "Double Team", "Harden", "Minimize", +# "Smokescreen", "Confuse Ray", "Withdraw", "Defense Curl", "Barrier", "Light Screen", "Haze", "Reflect", +# "Focus Energy", "Lick", "Smog", "Clamp", "Spike Cannon", "Constrict" +# +# ] + first_stage_pokemon = [pokemon for pokemon in pokemon_data.keys() if pokemon not in evolves_from] legendary_pokemon = ["Articuno", "Zapdos", "Moltres", "Mewtwo", "Mew"] diff --git a/worlds/pokemon_rb/regions.py b/worlds/pokemon_rb/regions.py index 674d24d148..98dbb3af8f 100644 --- a/worlds/pokemon_rb/regions.py +++ b/worlds/pokemon_rb/regions.py @@ -23,11 +23,12 @@ def create_regions(multiworld: MultiWorld, player: int): locations_per_region.setdefault(location.region, []) if location.inclusion(multiworld, player): locations_per_region[location.region].append(PokemonRBLocation(player, location.name, location.address, - location.rom_address)) + location.rom_address, location.type)) regions = [ create_region(multiworld, player, "Menu", locations_per_region), create_region(multiworld, player, "Anywhere", locations_per_region), create_region(multiworld, player, "Fossil", locations_per_region), + create_region(multiworld, player, "Pokedex", locations_per_region), create_region(multiworld, player, "Pallet Town", locations_per_region), create_region(multiworld, player, "Route 1", locations_per_region), create_region(multiworld, player, "Viridian City", locations_per_region), @@ -88,6 +89,7 @@ def create_regions(multiworld: MultiWorld, player: int): create_region(multiworld, player, "Route 8", locations_per_region), create_region(multiworld, player, "Route 8 Grass", locations_per_region), create_region(multiworld, player, "Celadon City", locations_per_region), + create_region(multiworld, player, "Celadon Game Corner", locations_per_region), create_region(multiworld, player, "Celadon Prize Corner", locations_per_region), create_region(multiworld, player, "Celadon Gym", locations_per_region), create_region(multiworld, player, "Route 16", locations_per_region), @@ -148,6 +150,7 @@ def create_regions(multiworld: MultiWorld, player: int): multiworld.regions += regions connect(multiworld, player, "Menu", "Anywhere", one_way=True) connect(multiworld, player, "Menu", "Pallet Town", one_way=True) + connect(multiworld, player, "Menu", "Pokedex", one_way=True) connect(multiworld, player, "Menu", "Fossil", lambda state: state.pokemon_rb_fossil_checks( state.multiworld.second_fossil_check_condition[player].value, player), one_way=True) connect(multiworld, player, "Pallet Town", "Route 1") @@ -220,6 +223,7 @@ def create_regions(multiworld: MultiWorld, player: int): connect(multiworld, player, "Route 8", "Route 8 Grass", lambda state: state.pokemon_rb_can_cut(player), one_way=True) connect(multiworld, player, "Route 7", "Celadon City") connect(multiworld, player, "Celadon City", "Celadon Gym", lambda state: state.pokemon_rb_can_cut(player), one_way=True) + connect(multiworld, player, "Celadon City", "Celadon Game Corner") connect(multiworld, player, "Celadon City", "Celadon Prize Corner") connect(multiworld, player, "Celadon City", "Route 16") connect(multiworld, player, "Route 16", "Route 16 North", lambda state: state.pokemon_rb_can_cut(player), one_way=True) diff --git a/worlds/pokemon_rb/rock_tunnel.py b/worlds/pokemon_rb/rock_tunnel.py new file mode 100644 index 0000000000..3a70709eb0 --- /dev/null +++ b/worlds/pokemon_rb/rock_tunnel.py @@ -0,0 +1,294 @@ +from .rom_addresses import rom_addresses + +disallowed1F = [[2, 2], [3, 2], [1, 8], [2, 8], [7, 7], [8, 7], [10, 4], [11, 4], [11, 12], + [11, 13], [16, 10], [17, 10], [18, 10], [16, 12], [17, 12], [18, 12]] +disallowed2F = [[16, 2], [17, 2], [18, 2], [15, 5], [15, 6], [10, 10], [11, 10], [12, 10], [7, 14], [8, 14], [1, 15], + [13, 15], [13, 16], [1, 12], [1, 10], [3, 5], [3, 6], [5, 6], [5, 7], [5, 8], [1, 2], [1, 3], [1, 4], + [11, 1]] + + +def randomize_rock_tunnel(data, random): + + seed = random.randint(0, 999999999999999999) + random.seed(seed) + + map1f = [] + map2f = [] + + address = rom_addresses["Map_Rock_Tunnel1F"] + for y in range(0, 18): + row = [] + for x in range(0, 20): + row.append(data[address]) + address += 1 + map1f.append(row) + + address = rom_addresses["Map_Rock_TunnelB1F"] + for y in range(0, 18): + row = [] + for x in range(0, 20): + row.append(data[address]) + address += 1 + map2f.append(row) + + current_map = map1f + + def floor(x, y): + current_map[y][x] = 1 + + def wide(x, y): + current_map[y][x] = 32 + current_map[y][x + 1] = 34 + + def tall(x, y): + current_map[y][x] = 23 + current_map[y + 1][x] = 31 + + def single(x, y): + current_map[y][x] = 2 + + # 0 = top left, 1 = middle, 2 = top right, 3 = bottom right + entrance_c = random.choice([0, 1, 2]) + exit_c = [0, 1, 3] + if entrance_c == 2: + exit_c.remove(1) + else: + exit_c.remove(entrance_c) + exit_c = random.choice(exit_c) + remaining = [i for i in [0, 1, 2, 3] if i not in [entrance_c, exit_c]] + + if entrance_c == 0: + floor(6, 3) + floor(6, 4) + tall(random.randint(8, 10), 2) + wide(4, random.randint(5, 7)) + wide(1, random.choice([5, 6, 7, 9])) + elif entrance_c == 1: + if remaining == [0, 2] or random.randint(0, 1): + tall(random.randint(8, 10), 2) + floor(7, 4) + floor(8, 4) + else: + tall(random.randint(11, 12), 5) + floor(9, 5) + floor(9, 6) + elif entrance_c == 2: + floor(16, 2) + floor(16, 3) + if remaining == [1, 3]: + wide(17, 4) + else: + tall(random.randint(11, 17), random.choice([2, 5])) + + if exit_c == 0: + r = random.sample([0, 1, 2], 2) + if 0 in r: + floor(1, 11) + floor(2, 11) + if 1 in r: + floor(3, 11) + floor(4, 11) + if 2 in r: + floor(5, 11) + floor(6, 11) + elif exit_c == 1 or (exit_c == 3 and entrance_c == 0): + r = random.sample([1, 3, 5, 7], random.randint(1, 2)) + for i in r: + floor(i, 11) + floor(i + 1, 11) + if exit_c != 3: + tall(random.choice([9, 10, 12]), 12) + + # 0 = top left, 1 = middle, 2 = top right, 3 = bottom right + # [0, 1] [0, 2] [1, 2] [1, 3], [2, 3] + if remaining[0] == 1: + floor(9, 5) + floor(9, 6) + + if remaining == [0, 2]: + if random.randint(0, 1): + tall(9, 4) + floor(9, 6) + floor(9, 7) + else: + floor(10, 7) + floor(11, 7) + + if remaining == [1, 2]: + floor(16, 2) + floor(16, 3) + tall(random.randint(11, 17), random.choice([2, 5])) + if remaining in [[1, 3], [2, 3]]: + r = round(random.triangular(0, 3, 0)) + floor(12 + (r * 2), 7) + if r < 3: + floor(13 + (r * 2), 7) + if remaining == [1, 3]: + wide(10, random.choice([3, 5])) + + if remaining != [0, 1] and exit_c != 1: + wide(7, 6) + + if entrance_c != 0: + if random.randint(0, 1): + wide(4, random.randint(4, 7)) + else: + wide(1, random.choice([5, 6, 7, 9])) + + current_map = map2f + + if 3 in remaining: + c = random.choice([entrance_c, exit_c]) + else: + c = random.choice(remaining) + + # 0 = top right, 1 = middle, 2 = bottom right, 3 = top left + if c in [0, 1]: + if random.randint(0, 2): + tall(random.choice([2, 4]), 5) + r = random.choice([1, 3, 7, 9, 11]) + floor(3 if r < 11 else random.randint(1, 2), r) + floor(3 if r < 11 else random.randint(1, 2), r + 1) + if random.randint(0, 2): + tall(random.randint(6, 7), 7) + r = random.choice([1, 3, 5, 9]) + floor(6, r) + floor(6, r + 1) + if random.randint(0, 2): + wide(7, 15) + r = random.randint(0, 4) + if r == 0: + floor(9, 14) + floor(10, 14) + elif r == 1: + floor(11, 14) + floor(12, 14) + elif r == 2: + floor(13, 13) + floor(13, 14) + elif r == 3: + floor(13, 11) + floor(13, 12) + elif r == 4: + floor(13, 10) + floor(14, 10) + if c == 0: + tall(random.randint(9, 10), 5) + if random.randint(0, 1): + floor(10, 7) + floor(11, 7) + tall(random.randint(12, 17), 8) + else: + floor(12, 5) + floor(12, 6) + wide(13, random.randint(4, 5)) + wide(17, random.randint(3, 5)) + r = random.choice([1, 3]) + floor(12, r) + floor(12, + 1) + + elif c == 2: + r = random.randint(0, 6) + if r == 0: + floor(12, 1) + floor(12, 2) + elif r == 1: + floor(12, 3) + floor(12, 4) + elif r == 2: + floor(12, 5) + floor(12, 6) + elif r == 3: + floor(10, 7) + floor(11, 7) + elif r == 4: + floor(9, 7) + floor(9, 8) + elif r == 5: + floor(9, 9) + floor(9, 10) + elif r == 6: + floor(8, 11) + floor(9, 11) + if r < 2 or (r in [2, 3] and random.randint(0, 1)): + wide(7, random.randint(6, 7)) + elif r in [2, 3]: + tall(random.randint(9, 10), 5) + else: + tall(random.randint(6, 7), 7) + r = random.randint(r, 6) + if r == 0: + #early block + wide(13, random.randint(2, 5)) + tall(random.randint(14, 15), 1) + elif r == 1: + if random.randint(0, 1): + tall(16, 5) + tall(random.choice([14, 15, 17]), 1) + else: + wide(16, random.randint(6,8)) + single(18, 7) + elif r == 2: + tall(random.randint(12, 16), 8) + elif r == 3: + wide(10, 9) + single(12, 9) + elif r == 4: + wide(10, random.randint(11, 12)) + single(12, random.randint(11, 12)) + elif r == 5: + tall(random.randint(8, 10), 12) + elif r == 6: + wide(7, 15) + r = random.randint(r, 6) + if r == 6: + #late open + r2 = random.randint(0, 2) + floor(1 + (r2 * 2), 14) + floor(2 + (r2 * 2), 14) + elif r == 5: + floor(6, 12) + floor(6, 13) + elif r == 4: + if random.randint(0, 1): + floor(6, 11) + floor(7, 11) + else: + floor(8, 11) + floor(9, 11) + elif r == 3: + floor(9, 9) + floor(9, 10) + elif r < 3: + single(9, 7) + floor(9, 8) + + def check_addable_block(check_map, disallowed): + if check_map[y][x] == 1 and [x, y] not in disallowed: + i = 0 + for xx in range(x-1, x+2): + for yy in range(y-1, y+2): + if check_map[yy][xx] == 1: + i += 1 + if i >= 8: + single(x, y) + + for _ in range(100): + y = random.randint(1, 16) + x = random.randint(1, 18) + current_map = map1f + check_addable_block(map1f, disallowed1F) + current_map = map2f + check_addable_block(map2f, disallowed2F) + + address = rom_addresses["Map_Rock_Tunnel1F"] + for y in map1f: + for x in y: + data[address] = x + address += 1 + address = rom_addresses["Map_Rock_TunnelB1F"] + for y in map2f: + for x in y: + data[address] = x + address += 1 + return seed \ No newline at end of file diff --git a/worlds/pokemon_rb/rom.py b/worlds/pokemon_rb/rom.py index 9dbc3a8b83..1a5f3250aa 100644 --- a/worlds/pokemon_rb/rom.py +++ b/worlds/pokemon_rb/rom.py @@ -8,6 +8,7 @@ from .text import encode_text from .rom_addresses import rom_addresses from .locations import location_data from .items import item_table +from .rock_tunnel import randomize_rock_tunnel import worlds.pokemon_rb.poke_data as poke_data @@ -28,15 +29,15 @@ def filter_moves(moves, type, random): return ret -def get_move(moves, chances, random, starting_move=False): +def get_move(local_move_data, moves, chances, random, starting_move=False): type = choose_forced_type(chances, random) filtered_moves = filter_moves(moves, type, random) for move in filtered_moves: - if poke_data.moves[move]["accuracy"] > 80 and poke_data.moves[move]["power"] > 0 or not starting_move: + if local_move_data[move]["accuracy"] > 80 and local_move_data[move]["power"] > 0 or not starting_move: moves.remove(move) return move else: - return get_move(moves, [], random, starting_move) + return get_move(local_move_data, moves, [], random, starting_move) def get_encounter_slots(self): @@ -75,6 +76,42 @@ def randomize_pokemon(self, mon, mons_list, randomize_type, random): return mon +def set_mon_palettes(self, random, data): + if self.multiworld.randomize_pokemon_palettes[self.player] == "vanilla": + return + pallet_map = { + "Poison": 0x0F, + "Normal": 0x10, + "Ice": 0x11, + "Fire": 0x12, + "Water": 0x13, + "Ghost": 0x14, + "Ground": 0x15, + "Grass": 0x16, + "Psychic": 0x17, + "Electric": 0x18, + "Rock": 0x19, + "Dragon": 0x1F, + "Flying": 0x20, + "Fighting": 0x21, + "Bug": 0x22 + } + palettes = [] + for mon in poke_data.pokemon_data: + if self.multiworld.randomize_pokemon_palettes[self.player] == "primary_type": + pallet = pallet_map[self.local_poke_data[mon]["type1"]] + elif (self.multiworld.randomize_pokemon_palettes[self.player] == "follow_evolutions" and mon in + poke_data.evolves_from and poke_data.evolves_from[mon] != "Eevee"): + pallet = palettes[-1] + else: # completely_random or follow_evolutions and it is not an evolved form (except eeveelutions) + pallet = random.choice(list(pallet_map.values())) + palettes.append(pallet) + address = rom_addresses["Mon_Palettes"] + for pallet in palettes: + data[address] = pallet + address += 1 + + def process_trainer_data(self, data, random): mons_list = [pokemon for pokemon in poke_data.pokemon_data.keys() if pokemon not in poke_data.legendary_pokemon or self.multiworld.trainer_legendaries[self.player].value] @@ -163,6 +200,7 @@ def process_static_pokemon(self): randomize_type, self.multiworld.random)) location.place_locked_item(mon) + chosen_mons = set() for slot in starter_slots: location = self.multiworld.get_location(slot.name, self.player) randomize_type = self.multiworld.randomize_starter_pokemon[self.player].value @@ -170,9 +208,13 @@ def process_static_pokemon(self): if not randomize_type: location.place_locked_item(self.create_item(slot_type + " " + slot.original_item)) else: - location.place_locked_item(self.create_item(slot_type + " " + - randomize_pokemon(self, slot.original_item, mons_list, randomize_type, - self.multiworld.random))) + mon = self.create_item(slot_type + " " + randomize_pokemon(self, slot.original_item, mons_list, + randomize_type, self.multiworld.random)) + while mon.name in chosen_mons: + mon = self.create_item(slot_type + " " + randomize_pokemon(self, slot.original_item, mons_list, + randomize_type, self.multiworld.random)) + chosen_mons.add(mon.name) + location.place_locked_item(mon) def process_wild_pokemon(self): @@ -180,27 +222,36 @@ def process_wild_pokemon(self): encounter_slots = get_encounter_slots(self) placed_mons = {pokemon: 0 for pokemon in poke_data.pokemon_data.keys()} + zone_mapping = {} if self.multiworld.randomize_wild_pokemon[self.player].value: mons_list = [pokemon for pokemon in poke_data.pokemon_data.keys() if pokemon not in poke_data.legendary_pokemon or self.multiworld.randomize_legendary_pokemon[self.player].value == 3] self.multiworld.random.shuffle(encounter_slots) locations = [] for slot in encounter_slots: - mon = randomize_pokemon(self, slot.original_item, mons_list, - self.multiworld.randomize_wild_pokemon[self.player].value, self.multiworld.random) + location = self.multiworld.get_location(slot.name, self.player) + zone = " - ".join(location.name.split(" - ")[:-1]) + if zone not in zone_mapping: + zone_mapping[zone] = {} + original_mon = slot.original_item + if self.multiworld.area_1_to_1_mapping[self.player] and original_mon in zone_mapping[zone]: + mon = zone_mapping[zone][original_mon] + else: + mon = randomize_pokemon(self, original_mon, mons_list, + self.multiworld.randomize_wild_pokemon[self.player].value, self.multiworld.random) # if static Pokemon are not randomized, we make sure nothing on Pokemon Tower 6F is a Marowak # if static Pokemon are randomized we deal with that during static encounter randomization while (self.multiworld.randomize_static_pokemon[self.player].value == 0 and mon == "Marowak" and "Pokemon Tower 6F" in slot.name): # to account for the possibility that only one ground type Pokemon exists, match only stats for this fix - mon = randomize_pokemon(self, slot.original_item, mons_list, 2, self.multiworld.random) + mon = randomize_pokemon(self, original_mon, mons_list, 2, self.multiworld.random) placed_mons[mon] += 1 - location = self.multiworld.get_location(slot.name, self.player) location.item = self.create_item(mon) location.event = True location.locked = True location.item.location = location locations.append(location) + zone_mapping[zone][original_mon] = mon mons_to_add = [] remaining_pokemon = [pokemon for pokemon in poke_data.pokemon_data.keys() if placed_mons[pokemon] == 0 and @@ -223,22 +274,46 @@ def process_wild_pokemon(self): for mon in mons_to_add: stat_base = get_base_stat_total(mon) candidate_locations = get_encounter_slots(self) - if self.multiworld.randomize_wild_pokemon[self.player].value in [1, 3]: - candidate_locations = [slot for slot in candidate_locations if any([poke_data.pokemon_data[slot.original_item][ - "type1"] in [self.local_poke_data[mon]["type1"], self.local_poke_data[mon]["type2"]], - poke_data.pokemon_data[slot.original_item]["type2"] in [self.local_poke_data[mon]["type1"], - self.local_poke_data[mon]["type2"]]])] - if not candidate_locations: - candidate_locations = get_encounter_slots(self) candidate_locations = [self.multiworld.get_location(location.name, self.player) for location in candidate_locations] - candidate_locations.sort(key=lambda slot: abs(get_base_stat_total(slot.item.name) - stat_base)) + if self.multiworld.randomize_wild_pokemon[self.player].current_key in ["match_base_stats", "match_types_and_base_stats"]: + candidate_locations.sort(key=lambda slot: abs(get_base_stat_total(slot.item.name) - stat_base)) + if self.multiworld.randomize_wild_pokemon[self.player].current_key in ["match_types", "match_types_and_base_stats"]: + candidate_locations.sort(key=lambda slot: not any([poke_data.pokemon_data[slot.original_item]["type1"] in + [self.local_poke_data[mon]["type1"], self.local_poke_data[mon]["type2"]], + poke_data.pokemon_data[slot.original_item]["type2"] in + [self.local_poke_data[mon]["type1"], self.local_poke_data[mon]["type2"]]])) for location in candidate_locations: - if placed_mons[location.item.name] > 1 or location.item.name not in poke_data.first_stage_pokemon: - placed_mons[location.item.name] -= 1 - location.item = self.create_item(mon) - location.item.location = location + zone = " - ".join(location.name.split(" - ")[:-1]) + if self.multiworld.catch_em_all[self.player] == "all_pokemon" and self.multiworld.area_1_to_1_mapping[self.player]: + if not [self.multiworld.get_location(l.name, self.player) for l in get_encounter_slots(self) + if (not l.name.startswith(zone)) and + self.multiworld.get_location(l.name, self.player).item.name == location.item.name]: + continue + if self.multiworld.catch_em_all[self.player] == "first_stage" and self.multiworld.area_1_to_1_mapping[self.player]: + if not [self.multiworld.get_location(l.name, self.player) for l in get_encounter_slots(self) + if (not l.name.startswith(zone)) and + self.multiworld.get_location(l.name, self.player).item.name == location.item.name and l.name + not in poke_data.evolves_from]: + continue + + if placed_mons[location.item.name] < 2 and (location.item.name in poke_data.first_stage_pokemon + or self.multiworld.catch_em_all[self.player]): + continue + + if self.multiworld.area_1_to_1_mapping[self.player]: + place_locations = [place_location for place_location in candidate_locations if + place_location.name.startswith(zone) and + place_location.item.name == location.item.name] + else: + place_locations = [location] + for place_location in place_locations: + placed_mons[place_location.item.name] -= 1 + place_location.item = self.create_item(mon) + place_location.item.location = place_location placed_mons[mon] += 1 - break + break + else: + raise Exception else: for slot in encounter_slots: @@ -250,10 +325,41 @@ def process_wild_pokemon(self): placed_mons[location.item.name] += 1 +def process_move_data(self): + self.local_move_data = deepcopy(poke_data.moves) + if self.multiworld.move_balancing[self.player]: + self.local_move_data["Sing"]["accuracy"] = 30 + self.local_move_data["Sleep Powder"]["accuracy"] = 40 + self.local_move_data["Spore"]["accuracy"] = 50 + self.local_move_data["Sonicboom"]["effect"] = 0 + self.local_move_data["Sonicboom"]["power"] = 50 + self.local_move_data["Dragon Rage"]["effect"] = 0 + self.local_move_data["Dragon Rage"]["power"] = 80 + self.local_move_data["Horn Drill"]["effect"] = 0 + self.local_move_data["Horn Drill"]["power"] = 70 + self.local_move_data["Horn Drill"]["accuracy"] = 90 + self.local_move_data["Guillotine"]["effect"] = 0 + self.local_move_data["Guillotine"]["power"] = 70 + self.local_move_data["Guillotine"]["accuracy"] = 90 + self.local_move_data["Fissure"]["effect"] = 0 + self.local_move_data["Fissure"]["power"] = 70 + self.local_move_data["Fissure"]["accuracy"] = 90 + self.local_move_data["Blizzard"]["accuracy"] = 70 + if self.multiworld.randomize_tm_moves[self.player]: + self.local_tms = self.multiworld.random.sample([move for move in poke_data.moves.keys() if move not in + ["No Move"] + poke_data.hm_moves], 50) + else: + self.local_tms = poke_data.tm_moves.copy() + + def process_pokemon_data(self): local_poke_data = deepcopy(poke_data.pokemon_data) learnsets = deepcopy(poke_data.learnsets) + tms_hms = self.local_tms + poke_data.hm_moves + + + compat_hms = set() for mon, mon_data in local_poke_data.items(): if self.multiworld.randomize_pokemon_stats[self.player].value == 1: @@ -265,18 +371,21 @@ def process_pokemon_data(self): mon_data["spd"] = stats[3] mon_data["spc"] = stats[4] elif self.multiworld.randomize_pokemon_stats[self.player].value == 2: - old_stats = mon_data["hp"] + mon_data["atk"] + mon_data["def"] + mon_data["spd"] + mon_data["spc"] - 5 - stats = [1, 1, 1, 1, 1] - while old_stats > 0: - stat = self.multiworld.random.randint(0, 4) - if stats[stat] < 255: - old_stats -= 1 - stats[stat] += 1 - mon_data["hp"] = stats[0] - mon_data["atk"] = stats[1] - mon_data["def"] = stats[2] - mon_data["spd"] = stats[3] - mon_data["spc"] = stats[4] + first_run = True + while (mon_data["hp"] > 255 or mon_data["atk"] > 255 or mon_data["def"] > 255 or mon_data["spd"] > 255 + or mon_data["spc"] > 255 or first_run): + first_run = False + total_stats = mon_data["hp"] + mon_data["atk"] + mon_data["def"] + mon_data["spd"] + mon_data["spc"] - 60 + dist = [self.multiworld.random.randint(1, 101) / 100, self.multiworld.random.randint(1, 101) / 100, + self.multiworld.random.randint(1, 101) / 100, self.multiworld.random.randint(1, 101) / 100, + self.multiworld.random.randint(1, 101) / 100] + total_dist = sum(dist) + + mon_data["hp"] = int(round(dist[0] / total_dist * total_stats) + 20) + mon_data["atk"] = int(round(dist[1] / total_dist * total_stats) + 10) + mon_data["def"] = int(round(dist[2] / total_dist * total_stats) + 10) + mon_data["spd"] = int(round(dist[3] / total_dist * total_stats) + 10) + mon_data["spc"] = int(round(dist[4] / total_dist * total_stats) + 10) if self.multiworld.randomize_pokemon_types[self.player].value: if self.multiworld.randomize_pokemon_types[self.player].value == 1 and mon in poke_data.evolves_from: type1 = local_poke_data[poke_data.evolves_from[mon]]["type1"] @@ -318,46 +427,237 @@ def process_pokemon_data(self): moves = list(poke_data.moves.keys()) for move in ["No Move"] + poke_data.hm_moves: moves.remove(move) - mon_data["start move 1"] = get_move(moves, chances, self.multiworld.random, True) - for i in range(2, 5): - if mon_data[f"start move {i}"] != "No Move" or self.multiworld.start_with_four_moves[ - self.player].value == 1: - mon_data[f"start move {i}"] = get_move(moves, chances, self.multiworld.random) + if self.multiworld.confine_transform_to_ditto[self.player]: + moves.remove("Transform") + if self.multiworld.start_with_four_moves[self.player]: + num_moves = 4 + else: + num_moves = len([i for i in [mon_data["start move 1"], mon_data["start move 2"], + mon_data["start move 3"], mon_data["start move 4"]] if i != "No Move"]) if mon in learnsets: - for move_num in range(0, len(learnsets[mon])): - learnsets[mon][move_num] = get_move(moves, chances, self.multiworld.random) + num_moves += len(learnsets[mon]) + non_power_moves = [] + learnsets[mon] = [] + for i in range(num_moves): + if i == 0 and mon == "Ditto" and self.multiworld.confine_transform_to_ditto[self.player]: + move = "Transform" + else: + move = get_move(self.local_move_data, moves, chances, self.multiworld.random) + while move == "Transform" and self.multiworld.confine_transform_to_ditto[self.player]: + move = get_move(self.local_move_data, moves, chances, self.multiworld.random) + if self.local_move_data[move]["power"] < 5: + non_power_moves.append(move) + else: + learnsets[mon].append(move) + learnsets[mon].sort(key=lambda move: self.local_move_data[move]["power"]) + if learnsets[mon]: + for move in non_power_moves: + learnsets[mon].insert(self.multiworld.random.randint(1, len(learnsets[mon])), move) + else: + learnsets[mon] = non_power_moves + for i in range(1, 5): + if mon_data[f"start move {i}"] != "No Move" or self.multiworld.start_with_four_moves[self.player]: + mon_data[f"start move {i}"] = learnsets[mon].pop(0) + if self.multiworld.randomize_pokemon_catch_rates[self.player].value: mon_data["catch rate"] = self.multiworld.random.randint(self.multiworld.minimum_catch_rate[self.player], 255) else: mon_data["catch rate"] = max(self.multiworld.minimum_catch_rate[self.player], mon_data["catch rate"]) - if mon != "Mew": - tms_hms = poke_data.tm_moves + poke_data.hm_moves - for flag, tm_move in enumerate(tms_hms): - if ((mon in poke_data.evolves_from.keys() and mon_data["type1"] == - local_poke_data[poke_data.evolves_from[mon]]["type1"] and mon_data["type2"] == - local_poke_data[poke_data.evolves_from[mon]]["type2"]) and ( - (flag < 50 and self.multiworld.tm_compatibility[self.player].value in [1, 2]) or ( - flag >= 51 and self.multiworld.hm_compatibility[self.player].value in [1, 2]))): - bit = 1 if local_poke_data[poke_data.evolves_from[mon]]["tms"][int(flag / 8)] & 1 << (flag % 8) else 0 - elif (flag < 50 and self.multiworld.tm_compatibility[self.player].value == 1) or (flag >= 50 and self.multiworld.hm_compatibility[self.player].value == 1): - type_match = poke_data.moves[tm_move]["type"] in [mon_data["type1"], mon_data["type2"]] - bit = int(self.multiworld.random.randint(1, 100) < [[90, 50, 25], [100, 75, 25]][flag >= 50][0 if type_match else 1 if poke_data.moves[tm_move]["type"] == "Normal" else 2]) - elif (flag < 50 and self.multiworld.tm_compatibility[self.player].value == 2) or (flag >= 50 and self.multiworld.hm_compatibility[self.player].value == 2): - bit = self.multiworld.random.randint(0, 1) - elif (flag < 50 and self.multiworld.tm_compatibility[self.player].value == 3) or (flag >= 50 and self.multiworld.hm_compatibility[self.player].value == 3): + def roll_tm_compat(roll_move): + if self.local_move_data[roll_move]["type"] in [mon_data["type1"], mon_data["type2"]]: + if roll_move in poke_data.hm_moves: + if self.multiworld.hm_same_type_compatibility[self.player].value == -1: + return mon_data["tms"][int(flag / 8)] + r = self.multiworld.random.randint(1, 100) <= self.multiworld.hm_same_type_compatibility[self.player].value + if r and mon not in poke_data.legendary_pokemon: + compat_hms.add(roll_move) + return r + else: + if self.multiworld.tm_same_type_compatibility[self.player].value == -1: + return mon_data["tms"][int(flag / 8)] + return self.multiworld.random.randint(1, 100) <= self.multiworld.tm_same_type_compatibility[self.player].value + elif self.local_move_data[roll_move]["type"] == "Normal" and "Normal" not in [mon_data["type1"], mon_data["type2"]]: + if roll_move in poke_data.hm_moves: + if self.multiworld.hm_normal_type_compatibility[self.player].value == -1: + return mon_data["tms"][int(flag / 8)] + r = self.multiworld.random.randint(1, 100) <= self.multiworld.hm_normal_type_compatibility[self.player].value + if r and mon not in poke_data.legendary_pokemon: + compat_hms.add(roll_move) + return r + else: + if self.multiworld.tm_normal_type_compatibility[self.player].value == -1: + return mon_data["tms"][int(flag / 8)] + return self.multiworld.random.randint(1, 100) <= self.multiworld.tm_normal_type_compatibility[self.player].value + else: + if roll_move in poke_data.hm_moves: + if self.multiworld.hm_other_type_compatibility[self.player].value == -1: + return mon_data["tms"][int(flag / 8)] + r = self.multiworld.random.randint(1, 100) <= self.multiworld.hm_other_type_compatibility[self.player].value + if r and mon not in poke_data.legendary_pokemon: + compat_hms.add(roll_move) + return r + else: + if self.multiworld.tm_other_type_compatibility[self.player].value == -1: + return mon_data["tms"][int(flag / 8)] + return self.multiworld.random.randint(1, 100) <= self.multiworld.tm_other_type_compatibility[self.player].value + + + for flag, tm_move in enumerate(tms_hms): + if mon in poke_data.evolves_from.keys() and self.multiworld.inherit_tm_hm_compatibility[self.player]: + + if local_poke_data[poke_data.evolves_from[mon]]["tms"][int(flag / 8)] & 1 << (flag % 8): + # always inherit learnable tms/hms bit = 1 else: - continue - if bit: - mon_data["tms"][int(flag / 8)] |= 1 << (flag % 8) - else: - mon_data["tms"][int(flag / 8)] &= ~(1 << (flag % 8)) + if self.local_move_data[tm_move]["type"] in [mon_data["type1"], mon_data["type2"]] and \ + self.local_move_data[tm_move]["type"] not in [ + local_poke_data[poke_data.evolves_from[mon]]["type1"], + local_poke_data[poke_data.evolves_from[mon]]["type2"]]: + # the tm/hm is for a move whose type matches current mon, but not pre-evolved form + # so this gets full chance roll + bit = roll_tm_compat(tm_move) + # otherwise 50% reduced chance to add compatibility over pre-evolved form + elif self.multiworld.random.randint(1, 100) > 50 and roll_tm_compat(tm_move): + bit = 1 + else: + bit = 0 + else: + bit = roll_tm_compat(tm_move) + if bit: + mon_data["tms"][int(flag / 8)] |= 1 << (flag % 8) + else: + mon_data["tms"][int(flag / 8)] &= ~(1 << (flag % 8)) + + hm_verify = ["Surf", "Strength"] + if self.multiworld.accessibility[self.player] != "minimal" or ((not + self.multiworld.badgesanity[self.player]) and max(self.multiworld.elite_four_condition[self.player], + self.multiworld.victory_road_condition[self.player]) > 7): + hm_verify += ["Cut"] + if self.multiworld.accessibility[self.player] != "minimal" and (self.multiworld.trainersanity[self.player] or + self.multiworld.extra_key_items[self.player]): + hm_verify += ["Flash"] + + for hm_move in hm_verify: + if hm_move not in compat_hms: + mon = self.multiworld.random.choice([mon for mon in poke_data.pokemon_data if mon not in + poke_data.legendary_pokemon]) + flag = tms_hms.index(hm_move) + local_poke_data[mon]["tms"][int(flag / 8)] |= 1 << (flag % 8) self.local_poke_data = local_poke_data self.learnsets = learnsets +def write_quizzes(self, data, random): + + def get_quiz(q, a): + if q == 0: + r = random.randint(0, 3) + if r == 0: + mon = self.trade_mons["Trade_Dux"] + text = "A woman inVermilion City" + elif r == 1: + mon = self.trade_mons["Trade_Lola"] + text = "A man inCerulean City" + elif r == 2: + mon = self.trade_mons["Trade_Marcel"] + text = "Someone on Route 2" + elif r == 3: + mon = self.trade_mons["Trade_Spot"] + text = "Someone on Route 5" + if not a: + answers.append(0) + old_mon = mon + while old_mon == mon: + mon = random.choice(list(poke_data.pokemon_data.keys())) + + return encode_text(f"{text}was looking for{mon}?") + elif q == 1: + for location in self.multiworld.get_filled_locations(): + if location.item.name == "Secret Key" and location.item.player == self.player: + break + if location.player == self.player: + player_name = "yourself" + else: + player_name = self.multiworld.player_names[location.player] + if not a: + if len(self.multiworld.player_name) > 1: + old_name = player_name + while old_name == player_name: + player_name = random.choice(list(self.multiworld.player_name.values())) + else: + return encode_text("You're playingin a multiworldwith otherplayers?") + return encode_text(f"The Secret Key wasfound by{player_name[:17]}?") + elif q == 2: + if a: + return encode_text(f"#mon ispronouncedPo-kay-mon?") + else: + if random.randint(0, 1): + return encode_text(f"#mon ispronouncedPo-key-mon?") + else: + return encode_text(f"#mon ispronouncedPo-kuh-mon?") + elif q == 3: + starters = [" ".join(self.multiworld.get_location( + f"Pallet Town - Starter {i}", self.player).item.name.split(" ")[1:]) for i in range(1, 4)] + mon = random.choice(starters) + nots = random.choice(range(8, 16, 2)) + if random.randint(0, 1): + while mon in starters: + mon = random.choice(list(poke_data.pokemon_data.keys())) + if a: + nots += 1 + elif not a: + nots += 1 + text = f"{mon} was" + while nots > 0: + i = random.randint(1, min(4, nots)) + text += ("not " * i) + "" + nots -= i + text += "a starter choice?" + return encode_text(text) + elif q == 4: + if a: + tm_text = self.local_tms[27] + else: + if self.multiworld.randomize_tm_moves[self.player]: + wrong_tms = self.local_tms.copy() + wrong_tms.pop(27) + tm_text = random.choice(wrong_tms) + else: + tm_text = "TOMBSTONER" + return encode_text(f"TM28 contains{tm_text.upper()}?") + elif q == 5: + i = 8 + while not a and i in [1, 8]: + i = random.randint(0, 99999999) + return encode_text(f"There are {i}certified #MONLEAGUE BADGEs?") + elif q == 6: + i = 2 + while not a and i in [1, 2]: + i = random.randint(0, 99) + return encode_text(f"POLIWAG evolves {i}times?") + elif q == 7: + entity = "Motor Carrier" + if not a: + entity = random.choice(["Driver", "Shipper"]) + return encode_text("Title 49 of theU.S. Code ofFederalRegulations part397.67 states" + f"that the{entity}is responsiblefor planningroutes when" + "hazardousmaterials aretransported?") + + answers = [random.randint(0, 1), random.randint(0, 1), random.randint(0, 1), + random.randint(0, 1), random.randint(0, 1), random.randint(0, 1)] + + questions = random.sample((range(0, 8)), 6) + question_texts = [] + for i, question in enumerate(questions): + question_texts.append(get_quiz(question, answers[i])) + + for i, quiz in enumerate(["A", "B", "C", "D", "E", "F"]): + data[rom_addresses[f"Quiz_Answer_{quiz}"]] = int(not answers[i]) << 4 | (i + 1) + write_bytes(data, question_texts[i], rom_addresses[f"Text_Quiz_{quiz}"]) + + def generate_output(self, output_directory: str): random = self.multiworld.per_slot_randoms[self.player] game_version = self.multiworld.game_version[self.player].current_key @@ -384,10 +684,33 @@ def generate_output(self, output_directory: str): elif " ".join(location.item.name.split()[1:]) in poke_data.pokemon_data.keys(): data[address] = poke_data.pokemon_data[" ".join(location.item.name.split()[1:])]["id"] else: - data[address] = self.item_name_to_id[location.item.name] - 172000000 + item_id = self.item_name_to_id[location.item.name] - 172000000 + if item_id > 255: + item_id -= 256 + data[address] = item_id else: data[location.rom_address] = 0x2C # AP Item + + def set_trade_mon(address, loc): + mon = self.multiworld.get_location(loc, self.player).item.name + data[rom_addresses[address]] = poke_data.pokemon_data[mon]["id"] + self.trade_mons[address] = mon + + if game_version == "red": + set_trade_mon("Trade_Terry", "Safari Zone Center - Wild Pokemon - 5") + set_trade_mon("Trade_Spot", "Safari Zone East - Wild Pokemon - 1") + else: + set_trade_mon("Trade_Terry", "Safari Zone Center - Wild Pokemon - 7") + set_trade_mon("Trade_Spot", "Safari Zone East - Wild Pokemon - 7") + set_trade_mon("Trade_Marcel", "Route 24 - Wild Pokemon - 6") + set_trade_mon("Trade_Sailor", "Pokemon Mansion 1F - Wild Pokemon - 3") + set_trade_mon("Trade_Dux", "Route 3 - Wild Pokemon - 2") + set_trade_mon("Trade_Marc", "Route 23 - Super Rod Pokemon - 1") + set_trade_mon("Trade_Lola", "Route 10 - Super Rod Pokemon - 1") + set_trade_mon("Trade_Doris", "Cerulean Cave 1F - Wild Pokemon - 9") + set_trade_mon("Trade_Crinkles", "Route 12 - Wild Pokemon - 4") + data[rom_addresses['Fly_Location']] = self.fly_map_code if self.multiworld.tea[self.player].value: @@ -421,6 +744,14 @@ def generate_output(self, output_directory: str): if self.multiworld.old_man[self.player].value == 2: data[rom_addresses['Option_Old_Man']] = 0x11 data[rom_addresses['Option_Old_Man_Lying']] = 0x15 + if self.multiworld.require_pokedex[self.player]: + data[rom_addresses["Require_Pokedex_A"]] = 1 + data[rom_addresses["Require_Pokedex_B"]] = 1 + if self.multiworld.dexsanity[self.player]: + data[rom_addresses["Option_Dexsanity_A"]] = 1 + data[rom_addresses["Option_Dexsanity_B"]] = 1 + if self.multiworld.all_pokemon_seen[self.player]: + data[rom_addresses["Option_Pokedex_Seen"]] = 1 money = str(self.multiworld.starting_money[self.player].value).zfill(6) data[rom_addresses["Starting_Money_High"]] = int(money[:2], 16) data[rom_addresses["Starting_Money_Middle"]] = int(money[2:4], 16) @@ -433,6 +764,7 @@ def generate_output(self, output_directory: str): write_bytes(data, encode_text( " ".join(self.multiworld.get_location("Route 3 - Pokemon For Sale", self.player).item.name.upper().split()[1:])), rom_addresses["Text_Magikarp_Salesman"]) + write_quizzes(self, data, random) if self.multiworld.badges_needed_for_hm_moves[self.player].value == 0: for hm_move in poke_data.hm_moves: @@ -492,10 +824,10 @@ def generate_output(self, output_directory: str): data[address + 17] = poke_data.moves[self.local_poke_data[mon]["start move 3"]]["id"] data[address + 18] = poke_data.moves[self.local_poke_data[mon]["start move 4"]]["id"] write_bytes(data, self.local_poke_data[mon]["tms"], address + 20) - if mon in self.learnsets: - address = rom_addresses["Learnset_" + mon.replace(" ", "")] - for i, move in enumerate(self.learnsets[mon]): - data[(address + 1) + i * 2] = poke_data.moves[move]["id"] + if mon in self.learnsets and self.learnsets[mon]: + address = rom_addresses["Learnset_" + mon.replace(" ", "")] + for i, move in enumerate(self.learnsets[mon]): + data[(address + 1) + i * 2] = poke_data.moves[move]["id"] data[rom_addresses["Option_Aide_Rt2"]] = self.multiworld.oaks_aide_rt_2[self.player].value data[rom_addresses["Option_Aide_Rt11"]] = self.multiworld.oaks_aide_rt_11[self.player].value @@ -507,8 +839,8 @@ def generate_output(self, output_directory: str): if self.multiworld.reusable_tms[self.player].value: data[rom_addresses["Option_Reusable_TMs"]] = 0xC9 - data[rom_addresses["Option_Trainersanity"]] = self.multiworld.trainersanity[self.player].value - data[rom_addresses["Option_Trainersanity2"]] = self.multiworld.trainersanity[self.player].value + for i in range(1, 10): + data[rom_addresses[f"Option_Trainersanity{i}"]] = self.multiworld.trainersanity[self.player].value data[rom_addresses["Option_Always_Half_STAB"]] = int(not self.multiworld.same_type_attack_bonus[self.player].value) @@ -532,8 +864,23 @@ def generate_output(self, output_directory: str): if data[rom_addresses["Start_Inventory"] + item.code - 172000000] < 255: data[rom_addresses["Start_Inventory"] + item.code - 172000000] += 1 + set_mon_palettes(self, random, data) process_trainer_data(self, data, random) + for move_data in self.local_move_data.values(): + if move_data["id"] == 0: + continue + address = rom_addresses["Move_Data"] + ((move_data["id"] - 1) * 6) + write_bytes(data, bytearray([move_data["id"], move_data["effect"], move_data["power"], + poke_data.type_ids[move_data["type"]], round(move_data["accuracy"] * 2.55), move_data["pp"]]), address) + + TM_IDs = bytearray([poke_data.moves[move]["id"] for move in self.local_tms]) + write_bytes(data, TM_IDs, rom_addresses["TM_Moves"]) + + if self.multiworld.randomize_rock_tunnel[self.player]: + seed = randomize_rock_tunnel(data, random) + write_bytes(data, encode_text(f"SEED: {seed}"), rom_addresses["Text_Rock_Tunnel_Sign"]) + mons = [mon["id"] for mon in poke_data.pokemon_data.values()] random.shuffle(mons) data[rom_addresses['Title_Mon_First']] = mons.pop() @@ -564,7 +911,7 @@ def generate_output(self, output_directory: str): else: write_bytes(data, self.rival_name, rom_addresses['Rival_Name']) - data[0xFF00] = 1 # client compatibility version + data[0xFF00] = 2 # client compatibility version write_bytes(data, self.multiworld.seed_name.encode(), 0xFFDB) write_bytes(data, self.multiworld.player_name[self.player].encode(), 0xFFF0) diff --git a/worlds/pokemon_rb/rom_addresses.py b/worlds/pokemon_rb/rom_addresses.py index 30c38fa240..11b6e1463d 100644 --- a/worlds/pokemon_rb/rom_addresses.py +++ b/worlds/pokemon_rb/rom_addresses.py @@ -1,7 +1,7 @@ rom_addresses = { "Option_Encounter_Minimum_Steps": 0x3c3, - "Option_Blind_Trainers": 0x30fc, - "Option_Trainersanity": 0x318c, + "Option_Blind_Trainers": 0x30e2, + "Option_Trainersanity1": 0x3172, "Option_Lose_Money": 0x40d4, "Base_Stats_Mew": 0x4260, "Title_Mon_First": 0x4373, @@ -9,94 +9,95 @@ rom_addresses = { "Player_Name": 0x456e, "Rival_Name": 0x4576, "Price_Master_Ball": 0x45d0, - "Title_Seed": 0x5e3a, - "Title_Slot_Name": 0x5e5a, - "PC_Item": 0x6228, - "PC_Item_Quantity": 0x622d, - "Options": 0x623d, - "Fly_Location": 0x6242, - "Skip_Player_Name": 0x625b, - "Skip_Rival_Name": 0x6269, - "Option_Old_Man": 0xcafc, - "Option_Old_Man_Lying": 0xcaff, - "Option_Boulders": 0xcda5, - "Option_Rock_Tunnel_Extra_Items": 0xcdae, - "Wild_Route1": 0xd108, - "Wild_Route2": 0xd11e, - "Wild_Route22": 0xd134, - "Wild_ViridianForest": 0xd14a, - "Wild_Route3": 0xd160, - "Wild_MtMoon1F": 0xd176, - "Wild_MtMoonB1F": 0xd18c, - "Wild_MtMoonB2F": 0xd1a2, - "Wild_Route4": 0xd1b8, - "Wild_Route24": 0xd1ce, - "Wild_Route25": 0xd1e4, - "Wild_Route9": 0xd1fa, - "Wild_Route5": 0xd210, - "Wild_Route6": 0xd226, - "Wild_Route11": 0xd23c, - "Wild_RockTunnel1F": 0xd252, - "Wild_RockTunnelB1F": 0xd268, - "Wild_Route10": 0xd27e, - "Wild_Route12": 0xd294, - "Wild_Route8": 0xd2aa, - "Wild_Route7": 0xd2c0, - "Wild_PokemonTower3F": 0xd2da, - "Wild_PokemonTower4F": 0xd2f0, - "Wild_PokemonTower5F": 0xd306, - "Wild_PokemonTower6F": 0xd31c, - "Wild_PokemonTower7F": 0xd332, - "Wild_Route13": 0xd348, - "Wild_Route14": 0xd35e, - "Wild_Route15": 0xd374, - "Wild_Route16": 0xd38a, - "Wild_Route17": 0xd3a0, - "Wild_Route18": 0xd3b6, - "Wild_SafariZoneCenter": 0xd3cc, - "Wild_SafariZoneEast": 0xd3e2, - "Wild_SafariZoneNorth": 0xd3f8, - "Wild_SafariZoneWest": 0xd40e, - "Wild_SeaRoutes": 0xd425, - "Wild_SeafoamIslands1F": 0xd43a, - "Wild_SeafoamIslandsB1F": 0xd450, - "Wild_SeafoamIslandsB2F": 0xd466, - "Wild_SeafoamIslandsB3F": 0xd47c, - "Wild_SeafoamIslandsB4F": 0xd492, - "Wild_PokemonMansion1F": 0xd4a8, - "Wild_PokemonMansion2F": 0xd4be, - "Wild_PokemonMansion3F": 0xd4d4, - "Wild_PokemonMansionB1F": 0xd4ea, - "Wild_Route21": 0xd500, - "Wild_Surf_Route21": 0xd515, - "Wild_CeruleanCave1F": 0xd52a, - "Wild_CeruleanCave2F": 0xd540, - "Wild_CeruleanCaveB1F": 0xd556, - "Wild_PowerPlant": 0xd56c, - "Wild_Route23": 0xd582, - "Wild_VictoryRoad2F": 0xd598, - "Wild_VictoryRoad3F": 0xd5ae, - "Wild_VictoryRoad1F": 0xd5c4, - "Wild_DiglettsCave": 0xd5da, - "Ghost_Battle5": 0xd730, - "HM_Surf_Badge_a": 0xda1e, - "HM_Surf_Badge_b": 0xda23, - "Wild_Old_Rod": 0xe320, - "Wild_Good_Rod": 0xe34d, - "Option_Reusable_TMs": 0xe619, - "Wild_Super_Rod_A": 0xea4e, - "Wild_Super_Rod_B": 0xea53, - "Wild_Super_Rod_C": 0xea58, - "Wild_Super_Rod_D": 0xea5f, - "Wild_Super_Rod_E": 0xea64, - "Wild_Super_Rod_F": 0xea69, - "Wild_Super_Rod_G": 0xea72, - "Wild_Super_Rod_H": 0xea7b, - "Wild_Super_Rod_I": 0xea84, - "Wild_Super_Rod_J": 0xea8d, - "Starting_Money_High": 0xf957, - "Starting_Money_Middle": 0xf95a, - "Starting_Money_Low": 0xf95d, + "Title_Seed": 0x5e57, + "Title_Slot_Name": 0x5e77, + "PC_Item": 0x6245, + "PC_Item_Quantity": 0x624a, + "Options": 0x625a, + "Fly_Location": 0x625f, + "Skip_Player_Name": 0x6278, + "Skip_Rival_Name": 0x6286, + "Option_Old_Man": 0xcb05, + "Option_Old_Man_Lying": 0xcb08, + "Option_Boulders": 0xcdae, + "Option_Rock_Tunnel_Extra_Items": 0xcdb7, + "Wild_Route1": 0xd111, + "Wild_Route2": 0xd127, + "Wild_Route22": 0xd13d, + "Wild_ViridianForest": 0xd153, + "Wild_Route3": 0xd169, + "Wild_MtMoon1F": 0xd17f, + "Wild_MtMoonB1F": 0xd195, + "Wild_MtMoonB2F": 0xd1ab, + "Wild_Route4": 0xd1c1, + "Wild_Route24": 0xd1d7, + "Wild_Route25": 0xd1ed, + "Wild_Route9": 0xd203, + "Wild_Route5": 0xd219, + "Wild_Route6": 0xd22f, + "Wild_Route11": 0xd245, + "Wild_RockTunnel1F": 0xd25b, + "Wild_RockTunnelB1F": 0xd271, + "Wild_Route10": 0xd287, + "Wild_Route12": 0xd29d, + "Wild_Route8": 0xd2b3, + "Wild_Route7": 0xd2c9, + "Wild_PokemonTower3F": 0xd2e3, + "Wild_PokemonTower4F": 0xd2f9, + "Wild_PokemonTower5F": 0xd30f, + "Wild_PokemonTower6F": 0xd325, + "Wild_PokemonTower7F": 0xd33b, + "Wild_Route13": 0xd351, + "Wild_Route14": 0xd367, + "Wild_Route15": 0xd37d, + "Wild_Route16": 0xd393, + "Wild_Route17": 0xd3a9, + "Wild_Route18": 0xd3bf, + "Wild_SafariZoneCenter": 0xd3d5, + "Wild_SafariZoneEast": 0xd3eb, + "Wild_SafariZoneNorth": 0xd401, + "Wild_SafariZoneWest": 0xd417, + "Wild_SeaRoutes": 0xd42e, + "Wild_SeafoamIslands1F": 0xd443, + "Wild_SeafoamIslandsB1F": 0xd459, + "Wild_SeafoamIslandsB2F": 0xd46f, + "Wild_SeafoamIslandsB3F": 0xd485, + "Wild_SeafoamIslandsB4F": 0xd49b, + "Wild_PokemonMansion1F": 0xd4b1, + "Wild_PokemonMansion2F": 0xd4c7, + "Wild_PokemonMansion3F": 0xd4dd, + "Wild_PokemonMansionB1F": 0xd4f3, + "Wild_Route21": 0xd509, + "Wild_Surf_Route21": 0xd51e, + "Wild_CeruleanCave1F": 0xd533, + "Wild_CeruleanCave2F": 0xd549, + "Wild_CeruleanCaveB1F": 0xd55f, + "Wild_PowerPlant": 0xd575, + "Wild_Route23": 0xd58b, + "Wild_VictoryRoad2F": 0xd5a1, + "Wild_VictoryRoad3F": 0xd5b7, + "Wild_VictoryRoad1F": 0xd5cd, + "Wild_DiglettsCave": 0xd5e3, + "Ghost_Battle5": 0xd739, + "HM_Surf_Badge_a": 0xda2f, + "HM_Surf_Badge_b": 0xda34, + "Wild_Old_Rod": 0xe331, + "Wild_Good_Rod": 0xe35e, + "Option_Reusable_TMs": 0xe62a, + "Wild_Super_Rod_A": 0xea5f, + "Wild_Super_Rod_B": 0xea64, + "Wild_Super_Rod_C": 0xea69, + "Wild_Super_Rod_D": 0xea70, + "Wild_Super_Rod_E": 0xea75, + "Wild_Super_Rod_F": 0xea7a, + "Wild_Super_Rod_G": 0xea83, + "Wild_Super_Rod_H": 0xea8c, + "Wild_Super_Rod_I": 0xea95, + "Wild_Super_Rod_J": 0xea9e, + "Starting_Money_High": 0xf968, + "Starting_Money_Middle": 0xf96b, + "Starting_Money_Low": 0xf96e, + "Option_Pokedex_Seen": 0xf989, "HM_Fly_Badge_a": 0x1318e, "HM_Fly_Badge_b": 0x13193, "HM_Cut_Badge_a": 0x131c4, @@ -105,35 +106,36 @@ rom_addresses = { "HM_Strength_Badge_b": 0x131f9, "HM_Flash_Badge_a": 0x13208, "HM_Flash_Badge_b": 0x1320d, + "TM_Moves": 0x1376c, "Encounter_Chances": 0x13911, "Option_Viridian_Gym_Badges": 0x1901d, "Event_Sleepy_Guy": 0x191bc, "Starter2_K": 0x195a8, "Starter3_K": 0x195b0, "Event_Rocket_Thief": 0x196cc, - "Option_Cerulean_Cave_Condition": 0x1986c, - "Event_Stranded_Man": 0x19b1f, - "Event_Rivals_Sister": 0x19cf2, - "Option_Pokemon_League_Badges": 0x19e0f, - "Shop10": 0x19ee6, - "Trainersanity_EVENT_BEAT_SILPH_CO_4F_TRAINER_0_ITEM": 0x1a03a, - "Trainersanity_EVENT_BEAT_SILPH_CO_4F_TRAINER_1_ITEM": 0x1a048, - "Trainersanity_EVENT_BEAT_SILPH_CO_4F_TRAINER_2_ITEM": 0x1a056, - "Missable_Silph_Co_4F_Item_1": 0x1a0fe, - "Missable_Silph_Co_4F_Item_2": 0x1a105, - "Missable_Silph_Co_4F_Item_3": 0x1a10c, - "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_0_ITEM": 0x1a264, - "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_1_ITEM": 0x1a272, - "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_2_ITEM": 0x1a280, - "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_3_ITEM": 0x1a28e, - "Missable_Silph_Co_5F_Item_1": 0x1a366, - "Missable_Silph_Co_5F_Item_2": 0x1a36d, - "Missable_Silph_Co_5F_Item_3": 0x1a374, - "Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_0_ITEM": 0x1a4a4, - "Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_1_ITEM": 0x1a4b2, - "Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_2_ITEM": 0x1a4c0, - "Missable_Silph_Co_6F_Item_1": 0x1a5e2, - "Missable_Silph_Co_6F_Item_2": 0x1a5e9, + "Option_Cerulean_Cave_Condition": 0x19875, + "Event_Stranded_Man": 0x19b28, + "Event_Rivals_Sister": 0x19cfb, + "Option_Pokemon_League_Badges": 0x19e18, + "Shop10": 0x19eef, + "Trainersanity_EVENT_BEAT_SILPH_CO_4F_TRAINER_0_ITEM": 0x1a043, + "Trainersanity_EVENT_BEAT_SILPH_CO_4F_TRAINER_1_ITEM": 0x1a051, + "Trainersanity_EVENT_BEAT_SILPH_CO_4F_TRAINER_2_ITEM": 0x1a05f, + "Missable_Silph_Co_4F_Item_1": 0x1a107, + "Missable_Silph_Co_4F_Item_2": 0x1a10e, + "Missable_Silph_Co_4F_Item_3": 0x1a115, + "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_0_ITEM": 0x1a26d, + "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_1_ITEM": 0x1a27b, + "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_2_ITEM": 0x1a289, + "Trainersanity_EVENT_BEAT_SILPH_CO_5F_TRAINER_3_ITEM": 0x1a297, + "Missable_Silph_Co_5F_Item_1": 0x1a36f, + "Missable_Silph_Co_5F_Item_2": 0x1a376, + "Missable_Silph_Co_5F_Item_3": 0x1a37d, + "Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_0_ITEM": 0x1a4ad, + "Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_1_ITEM": 0x1a4bb, + "Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_2_ITEM": 0x1a4c9, + "Missable_Silph_Co_6F_Item_1": 0x1a5eb, + "Missable_Silph_Co_6F_Item_2": 0x1a5f2, "Event_Free_Sample": 0x1cad6, "Starter1_F": 0x1cca2, "Starter2_F": 0x1cca6, @@ -145,49 +147,50 @@ rom_addresses = { "Starter2_I": 0x1d0fa, "Starter1_D": 0x1d101, "Starter3_D": 0x1d10b, - "Starter2_E": 0x1d2e5, - "Starter3_E": 0x1d2ed, - "Event_Pokedex": 0x1d351, - "Event_Oaks_Gift": 0x1d381, - "Event_Pokemart_Quest": 0x1d579, - "Shop1": 0x1d5a3, - "Event_Bicycle_Shop": 0x1d83d, - "Text_Bicycle": 0x1d8d0, - "Event_Fuji": 0x1da05, - "Trainersanity_EVENT_BEAT_MEW_ITEM": 0x1dc58, - "Static_Encounter_Mew": 0x1dc88, - "Gift_Eevee": 0x1dd01, - "Shop7": 0x1dd53, - "Event_Mr_Psychic": 0x1de30, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_0_ITEM": 0x1e32b, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_1_ITEM": 0x1e339, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_2_ITEM": 0x1e347, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_3_ITEM": 0x1e355, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_4_ITEM": 0x1e363, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_5_ITEM": 0x1e371, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_6_ITEM": 0x1e37f, - "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_7_ITEM": 0x1e38d, - "Trainersanity_EVENT_BEAT_ZAPDOS_ITEM": 0x1e39b, - "Static_Encounter_Voltorb_A": 0x1e40a, - "Static_Encounter_Voltorb_B": 0x1e412, - "Static_Encounter_Voltorb_C": 0x1e41a, - "Static_Encounter_Electrode_A": 0x1e422, - "Static_Encounter_Voltorb_D": 0x1e42a, - "Static_Encounter_Voltorb_E": 0x1e432, - "Static_Encounter_Electrode_B": 0x1e43a, - "Static_Encounter_Voltorb_F": 0x1e442, - "Static_Encounter_Zapdos": 0x1e44a, - "Missable_Power_Plant_Item_1": 0x1e452, - "Missable_Power_Plant_Item_2": 0x1e459, - "Missable_Power_Plant_Item_3": 0x1e460, - "Missable_Power_Plant_Item_4": 0x1e467, - "Missable_Power_Plant_Item_5": 0x1e46e, - "Event_Rt16_House_Woman": 0x1e647, - "Option_Victory_Road_Badges": 0x1e718, - "Event_Bill": 0x1e949, + "Starter2_E": 0x1d300, + "Starter3_E": 0x1d308, + "Event_Pokedex": 0x1d36c, + "Event_Oaks_Gift": 0x1d39c, + "Event_Pokemart_Quest": 0x1d594, + "Shop1": 0x1d5be, + "Event_Bicycle_Shop": 0x1d858, + "Text_Bicycle": 0x1d8eb, + "Event_Fuji": 0x1da20, + "Trainersanity_EVENT_BEAT_MEW_ITEM": 0x1dc73, + "Static_Encounter_Mew": 0x1dca3, + "Gift_Eevee": 0x1dd1c, + "Shop7": 0x1dd6e, + "Event_Mr_Psychic": 0x1de4b, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_0_ITEM": 0x1e346, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_1_ITEM": 0x1e354, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_2_ITEM": 0x1e362, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_3_ITEM": 0x1e370, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_4_ITEM": 0x1e37e, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_5_ITEM": 0x1e38c, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_6_ITEM": 0x1e39a, + "Trainersanity_EVENT_BEAT_POWER_PLANT_VOLTORB_7_ITEM": 0x1e3a8, + "Trainersanity_EVENT_BEAT_ZAPDOS_ITEM": 0x1e3b6, + "Static_Encounter_Voltorb_A": 0x1e425, + "Static_Encounter_Voltorb_B": 0x1e42d, + "Static_Encounter_Voltorb_C": 0x1e435, + "Static_Encounter_Electrode_A": 0x1e43d, + "Static_Encounter_Voltorb_D": 0x1e445, + "Static_Encounter_Voltorb_E": 0x1e44d, + "Static_Encounter_Electrode_B": 0x1e455, + "Static_Encounter_Voltorb_F": 0x1e45d, + "Static_Encounter_Zapdos": 0x1e465, + "Missable_Power_Plant_Item_1": 0x1e46d, + "Missable_Power_Plant_Item_2": 0x1e474, + "Missable_Power_Plant_Item_3": 0x1e47b, + "Missable_Power_Plant_Item_4": 0x1e482, + "Missable_Power_Plant_Item_5": 0x1e489, + "Event_Rt16_House_Woman": 0x1e662, + "Option_Victory_Road_Badges": 0x1e733, + "Event_Bill": 0x1e964, "Starter1_O": 0x372b0, "Starter2_O": 0x372b4, "Starter3_O": 0x372b8, + "Move_Data": 0x38000, "Base_Stats": 0x383de, "Starter3_C": 0x39cf2, "Starter1_C": 0x39cf8, @@ -217,320 +220,345 @@ rom_addresses = { "Rival_Starter3_H": 0x3a4ab, "Rival_Starter1_H": 0x3a4b9, "Trainer_Data_End": 0x3a52e, - "Learnset_Rhydon": 0x3b1d9, - "Learnset_Kangaskhan": 0x3b1e7, - "Learnset_NidoranM": 0x3b1f6, - "Learnset_Clefairy": 0x3b208, - "Learnset_Spearow": 0x3b219, - "Learnset_Voltorb": 0x3b228, - "Learnset_Nidoking": 0x3b234, - "Learnset_Slowbro": 0x3b23c, - "Learnset_Ivysaur": 0x3b24f, - "Learnset_Exeggutor": 0x3b25f, - "Learnset_Lickitung": 0x3b263, - "Learnset_Exeggcute": 0x3b273, - "Learnset_Grimer": 0x3b284, - "Learnset_Gengar": 0x3b292, - "Learnset_NidoranF": 0x3b29b, - "Learnset_Nidoqueen": 0x3b2a9, - "Learnset_Cubone": 0x3b2b4, - "Learnset_Rhyhorn": 0x3b2c3, - "Learnset_Lapras": 0x3b2d1, - "Learnset_Mew": 0x3b2e1, - "Learnset_Gyarados": 0x3b2eb, - "Learnset_Shellder": 0x3b2fb, - "Learnset_Tentacool": 0x3b30a, - "Learnset_Gastly": 0x3b31f, - "Learnset_Scyther": 0x3b325, - "Learnset_Staryu": 0x3b337, - "Learnset_Blastoise": 0x3b347, - "Learnset_Pinsir": 0x3b355, - "Learnset_Tangela": 0x3b363, - "Learnset_Growlithe": 0x3b379, - "Learnset_Onix": 0x3b385, - "Learnset_Fearow": 0x3b391, - "Learnset_Pidgey": 0x3b3a0, - "Learnset_Slowpoke": 0x3b3b1, - "Learnset_Kadabra": 0x3b3c9, - "Learnset_Graveler": 0x3b3e1, - "Learnset_Chansey": 0x3b3ef, - "Learnset_Machoke": 0x3b407, - "Learnset_MrMime": 0x3b413, - "Learnset_Hitmonlee": 0x3b41f, - "Learnset_Hitmonchan": 0x3b42b, - "Learnset_Arbok": 0x3b437, - "Learnset_Parasect": 0x3b443, - "Learnset_Psyduck": 0x3b452, - "Learnset_Drowzee": 0x3b461, - "Learnset_Golem": 0x3b46f, - "Learnset_Magmar": 0x3b47f, - "Learnset_Electabuzz": 0x3b48f, - "Learnset_Magneton": 0x3b49b, - "Learnset_Koffing": 0x3b4ac, - "Learnset_Mankey": 0x3b4bd, - "Learnset_Seel": 0x3b4cc, - "Learnset_Diglett": 0x3b4db, - "Learnset_Tauros": 0x3b4e7, - "Learnset_Farfetchd": 0x3b4f9, - "Learnset_Venonat": 0x3b508, - "Learnset_Dragonite": 0x3b516, - "Learnset_Doduo": 0x3b52b, - "Learnset_Poliwag": 0x3b53c, - "Learnset_Jynx": 0x3b54a, - "Learnset_Moltres": 0x3b558, - "Learnset_Articuno": 0x3b560, - "Learnset_Zapdos": 0x3b568, - "Learnset_Meowth": 0x3b575, - "Learnset_Krabby": 0x3b584, - "Learnset_Vulpix": 0x3b59a, - "Learnset_Pikachu": 0x3b5ac, - "Learnset_Dratini": 0x3b5c1, - "Learnset_Dragonair": 0x3b5d0, - "Learnset_Kabuto": 0x3b5df, - "Learnset_Kabutops": 0x3b5e9, - "Learnset_Horsea": 0x3b5f6, - "Learnset_Seadra": 0x3b602, - "Learnset_Sandshrew": 0x3b615, - "Learnset_Sandslash": 0x3b621, - "Learnset_Omanyte": 0x3b630, - "Learnset_Omastar": 0x3b63a, - "Learnset_Jigglypuff": 0x3b648, - "Learnset_Eevee": 0x3b666, - "Learnset_Flareon": 0x3b670, - "Learnset_Jolteon": 0x3b682, - "Learnset_Vaporeon": 0x3b694, - "Learnset_Machop": 0x3b6a9, - "Learnset_Zubat": 0x3b6b8, - "Learnset_Ekans": 0x3b6c7, - "Learnset_Paras": 0x3b6d6, - "Learnset_Poliwhirl": 0x3b6e6, - "Learnset_Poliwrath": 0x3b6f4, - "Learnset_Beedrill": 0x3b704, - "Learnset_Dodrio": 0x3b714, - "Learnset_Primeape": 0x3b722, - "Learnset_Dugtrio": 0x3b72e, - "Learnset_Venomoth": 0x3b73a, - "Learnset_Dewgong": 0x3b748, - "Learnset_Butterfree": 0x3b762, - "Learnset_Machamp": 0x3b772, - "Learnset_Golduck": 0x3b780, - "Learnset_Hypno": 0x3b78c, - "Learnset_Golbat": 0x3b79a, - "Learnset_Mewtwo": 0x3b7a6, - "Learnset_Snorlax": 0x3b7b2, - "Learnset_Magikarp": 0x3b7bf, - "Learnset_Muk": 0x3b7c7, - "Learnset_Kingler": 0x3b7d7, - "Learnset_Cloyster": 0x3b7e3, - "Learnset_Electrode": 0x3b7e9, - "Learnset_Weezing": 0x3b7f7, - "Learnset_Persian": 0x3b803, - "Learnset_Marowak": 0x3b80f, - "Learnset_Haunter": 0x3b827, - "Learnset_Alakazam": 0x3b832, - "Learnset_Pidgeotto": 0x3b843, - "Learnset_Pidgeot": 0x3b851, - "Learnset_Bulbasaur": 0x3b864, - "Learnset_Venusaur": 0x3b874, - "Learnset_Tentacruel": 0x3b884, - "Learnset_Goldeen": 0x3b89b, - "Learnset_Seaking": 0x3b8a9, - "Learnset_Ponyta": 0x3b8c2, - "Learnset_Rapidash": 0x3b8d0, - "Learnset_Rattata": 0x3b8e1, - "Learnset_Raticate": 0x3b8eb, - "Learnset_Nidorino": 0x3b8f9, - "Learnset_Nidorina": 0x3b90b, - "Learnset_Geodude": 0x3b91c, - "Learnset_Porygon": 0x3b92a, - "Learnset_Aerodactyl": 0x3b934, - "Learnset_Magnemite": 0x3b942, - "Learnset_Charmander": 0x3b957, - "Learnset_Squirtle": 0x3b968, - "Learnset_Charmeleon": 0x3b979, - "Learnset_Wartortle": 0x3b98a, - "Learnset_Charizard": 0x3b998, - "Learnset_Oddish": 0x3b9b1, - "Learnset_Gloom": 0x3b9c3, - "Learnset_Vileplume": 0x3b9d1, - "Learnset_Bellsprout": 0x3b9dc, - "Learnset_Weepinbell": 0x3b9f0, - "Learnset_Victreebel": 0x3ba00, + "Learnset_Rhydon": 0x3b1e1, + "Learnset_Kangaskhan": 0x3b1ef, + "Learnset_NidoranM": 0x3b1fe, + "Learnset_Clefairy": 0x3b210, + "Learnset_Spearow": 0x3b221, + "Learnset_Voltorb": 0x3b230, + "Learnset_Nidoking": 0x3b23c, + "Learnset_Slowbro": 0x3b244, + "Learnset_Ivysaur": 0x3b257, + "Learnset_Exeggutor": 0x3b267, + "Learnset_Lickitung": 0x3b26b, + "Learnset_Exeggcute": 0x3b27b, + "Learnset_Grimer": 0x3b28c, + "Learnset_Gengar": 0x3b29a, + "Learnset_NidoranF": 0x3b2a3, + "Learnset_Nidoqueen": 0x3b2b1, + "Learnset_Cubone": 0x3b2bc, + "Learnset_Rhyhorn": 0x3b2cb, + "Learnset_Lapras": 0x3b2d9, + "Learnset_Mew": 0x3b2e9, + "Learnset_Gyarados": 0x3b2f3, + "Learnset_Shellder": 0x3b303, + "Learnset_Tentacool": 0x3b312, + "Learnset_Gastly": 0x3b327, + "Learnset_Scyther": 0x3b32d, + "Learnset_Staryu": 0x3b33f, + "Learnset_Blastoise": 0x3b34f, + "Learnset_Pinsir": 0x3b35d, + "Learnset_Tangela": 0x3b36b, + "Learnset_Growlithe": 0x3b381, + "Learnset_Onix": 0x3b38d, + "Learnset_Fearow": 0x3b399, + "Learnset_Pidgey": 0x3b3a8, + "Learnset_Slowpoke": 0x3b3b9, + "Learnset_Kadabra": 0x3b3d1, + "Learnset_Graveler": 0x3b3e9, + "Learnset_Chansey": 0x3b3f7, + "Learnset_Machoke": 0x3b40f, + "Learnset_MrMime": 0x3b41b, + "Learnset_Hitmonlee": 0x3b427, + "Learnset_Hitmonchan": 0x3b433, + "Learnset_Arbok": 0x3b43f, + "Learnset_Parasect": 0x3b44b, + "Learnset_Psyduck": 0x3b45a, + "Learnset_Drowzee": 0x3b469, + "Learnset_Golem": 0x3b477, + "Learnset_Magmar": 0x3b487, + "Learnset_Electabuzz": 0x3b497, + "Learnset_Magneton": 0x3b4a3, + "Learnset_Koffing": 0x3b4b4, + "Learnset_Mankey": 0x3b4c5, + "Learnset_Seel": 0x3b4d4, + "Learnset_Diglett": 0x3b4e3, + "Learnset_Tauros": 0x3b4ef, + "Learnset_Farfetchd": 0x3b501, + "Learnset_Venonat": 0x3b510, + "Learnset_Dragonite": 0x3b51e, + "Learnset_Doduo": 0x3b533, + "Learnset_Poliwag": 0x3b544, + "Learnset_Jynx": 0x3b552, + "Learnset_Moltres": 0x3b560, + "Learnset_Articuno": 0x3b568, + "Learnset_Zapdos": 0x3b570, + "Learnset_Meowth": 0x3b57d, + "Learnset_Krabby": 0x3b58c, + "Learnset_Vulpix": 0x3b5a2, + "Learnset_Pikachu": 0x3b5b4, + "Learnset_Dratini": 0x3b5c9, + "Learnset_Dragonair": 0x3b5d8, + "Learnset_Kabuto": 0x3b5e7, + "Learnset_Kabutops": 0x3b5f1, + "Learnset_Horsea": 0x3b5fe, + "Learnset_Seadra": 0x3b60a, + "Learnset_Sandshrew": 0x3b61d, + "Learnset_Sandslash": 0x3b629, + "Learnset_Omanyte": 0x3b638, + "Learnset_Omastar": 0x3b642, + "Learnset_Jigglypuff": 0x3b650, + "Learnset_Eevee": 0x3b66e, + "Learnset_Flareon": 0x3b678, + "Learnset_Jolteon": 0x3b68a, + "Learnset_Vaporeon": 0x3b69c, + "Learnset_Machop": 0x3b6b1, + "Learnset_Zubat": 0x3b6c0, + "Learnset_Ekans": 0x3b6cf, + "Learnset_Paras": 0x3b6de, + "Learnset_Poliwhirl": 0x3b6ee, + "Learnset_Poliwrath": 0x3b6fc, + "Learnset_Beedrill": 0x3b70c, + "Learnset_Dodrio": 0x3b71c, + "Learnset_Primeape": 0x3b72a, + "Learnset_Dugtrio": 0x3b736, + "Learnset_Venomoth": 0x3b742, + "Learnset_Dewgong": 0x3b750, + "Learnset_Butterfree": 0x3b76a, + "Learnset_Machamp": 0x3b77a, + "Learnset_Golduck": 0x3b788, + "Learnset_Hypno": 0x3b794, + "Learnset_Golbat": 0x3b7a2, + "Learnset_Mewtwo": 0x3b7ae, + "Learnset_Snorlax": 0x3b7ba, + "Learnset_Magikarp": 0x3b7c7, + "Learnset_Muk": 0x3b7cf, + "Learnset_Kingler": 0x3b7df, + "Learnset_Cloyster": 0x3b7eb, + "Learnset_Electrode": 0x3b7f1, + "Learnset_Weezing": 0x3b7ff, + "Learnset_Persian": 0x3b80b, + "Learnset_Marowak": 0x3b817, + "Learnset_Haunter": 0x3b82f, + "Learnset_Alakazam": 0x3b83a, + "Learnset_Pidgeotto": 0x3b84b, + "Learnset_Pidgeot": 0x3b859, + "Learnset_Bulbasaur": 0x3b86c, + "Learnset_Venusaur": 0x3b87c, + "Learnset_Tentacruel": 0x3b88c, + "Learnset_Goldeen": 0x3b8a3, + "Learnset_Seaking": 0x3b8b1, + "Learnset_Ponyta": 0x3b8ca, + "Learnset_Rapidash": 0x3b8d8, + "Learnset_Rattata": 0x3b8e9, + "Learnset_Raticate": 0x3b8f3, + "Learnset_Nidorino": 0x3b901, + "Learnset_Nidorina": 0x3b913, + "Learnset_Geodude": 0x3b924, + "Learnset_Porygon": 0x3b932, + "Learnset_Aerodactyl": 0x3b93c, + "Learnset_Magnemite": 0x3b94b, + "Learnset_Charmander": 0x3b960, + "Learnset_Squirtle": 0x3b971, + "Learnset_Charmeleon": 0x3b982, + "Learnset_Wartortle": 0x3b993, + "Learnset_Charizard": 0x3b9a1, + "Learnset_Oddish": 0x3b9ba, + "Learnset_Gloom": 0x3b9cc, + "Learnset_Vileplume": 0x3b9da, + "Learnset_Bellsprout": 0x3b9e5, + "Learnset_Weepinbell": 0x3b9f9, + "Learnset_Victreebel": 0x3ba09, "Option_Always_Half_STAB": 0x3e3fb, "Type_Chart": 0x3e4ee, "Ghost_Battle3": 0x3f1be, - "Trainersanity_EVENT_BEAT_MANSION_1_TRAINER_0_ITEM": 0x44341, - "Missable_Pokemon_Mansion_1F_Item_1": 0x443d8, - "Missable_Pokemon_Mansion_1F_Item_2": 0x443df, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0_ITEM": 0x44514, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1_ITEM": 0x44522, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2_ITEM": 0x44530, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3_ITEM": 0x4453e, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4_ITEM": 0x4454c, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5_ITEM": 0x4455a, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6_ITEM": 0x44568, - "Map_Rock_TunnelF": 0x44686, - "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0_ITEM": 0x44a55, - "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1_ITEM": 0x44a63, - "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2_ITEM": 0x44a71, - "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3_ITEM": 0x44a7f, - "Missable_Victory_Road_3F_Item_1": 0x44b1f, - "Missable_Victory_Road_3F_Item_2": 0x44b26, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0_ITEM": 0x44c47, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1_ITEM": 0x44c55, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2_ITEM": 0x44c63, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3_ITEM": 0x44c71, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4_ITEM": 0x44c7f, - "Missable_Rocket_Hideout_B1F_Item_1": 0x44d4f, - "Missable_Rocket_Hideout_B1F_Item_2": 0x44d56, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0_ITEM": 0x45100, - "Missable_Rocket_Hideout_B2F_Item_1": 0x45141, - "Missable_Rocket_Hideout_B2F_Item_2": 0x45148, - "Missable_Rocket_Hideout_B2F_Item_3": 0x4514f, - "Missable_Rocket_Hideout_B2F_Item_4": 0x45156, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0_ITEM": 0x45333, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1_ITEM": 0x45341, - "Missable_Rocket_Hideout_B3F_Item_1": 0x45397, - "Missable_Rocket_Hideout_B3F_Item_2": 0x4539e, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0_ITEM": 0x4554a, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1_ITEM": 0x45558, - "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2_ITEM": 0x45566, - "Missable_Rocket_Hideout_B4F_Item_1": 0x45655, - "Missable_Rocket_Hideout_B4F_Item_2": 0x4565c, - "Missable_Rocket_Hideout_B4F_Item_3": 0x45663, - "Missable_Rocket_Hideout_B4F_Item_4": 0x4566a, - "Missable_Rocket_Hideout_B4F_Item_5": 0x45671, - "Missable_Safari_Zone_East_Item_1": 0x458e0, - "Missable_Safari_Zone_East_Item_2": 0x458e7, - "Missable_Safari_Zone_East_Item_3": 0x458ee, - "Missable_Safari_Zone_East_Item_4": 0x458f5, - "Missable_Safari_Zone_North_Item_1": 0x45a40, - "Missable_Safari_Zone_North_Item_2": 0x45a47, - "Missable_Safari_Zone_Center_Item": 0x45c27, - "Missable_Cerulean_Cave_2F_Item_1": 0x45e64, - "Missable_Cerulean_Cave_2F_Item_2": 0x45e6b, - "Missable_Cerulean_Cave_2F_Item_3": 0x45e72, - "Trainersanity_EVENT_BEAT_MEWTWO_ITEM": 0x45f4a, - "Static_Encounter_Mewtwo": 0x45f74, - "Missable_Cerulean_Cave_B1F_Item_1": 0x45f7c, - "Missable_Cerulean_Cave_B1F_Item_2": 0x45f83, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0_ITEM": 0x46059, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1_ITEM": 0x46067, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2_ITEM": 0x46075, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3_ITEM": 0x46083, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4_ITEM": 0x46091, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5_ITEM": 0x4609f, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6_ITEM": 0x460ad, - "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7_ITEM": 0x460bb, - "Missable_Rock_Tunnel_B1F_Item_1": 0x461df, - "Missable_Rock_Tunnel_B1F_Item_2": 0x461e6, - "Missable_Rock_Tunnel_B1F_Item_3": 0x461ed, - "Missable_Rock_Tunnel_B1F_Item_4": 0x461f4, - "Trainersanity_EVENT_BEAT_ARTICUNO_ITEM": 0x468f7, - "Static_Encounter_Articuno": 0x4694e, - "Hidden_Item_Viridian_Forest_1": 0x46eaf, - "Hidden_Item_Viridian_Forest_2": 0x46eb5, - "Hidden_Item_MtMoonB2F_1": 0x46ebc, - "Hidden_Item_MtMoonB2F_2": 0x46ec2, - "Hidden_Item_Route_25_1": 0x46ed6, - "Hidden_Item_Route_25_2": 0x46edc, - "Hidden_Item_Route_9": 0x46ee3, - "Hidden_Item_SS_Anne_Kitchen": 0x46ef6, - "Hidden_Item_SS_Anne_B1F": 0x46efd, - "Hidden_Item_Route_10_1": 0x46f04, - "Hidden_Item_Route_10_2": 0x46f0a, - "Hidden_Item_Rocket_Hideout_B1F": 0x46f11, - "Hidden_Item_Rocket_Hideout_B3F": 0x46f18, - "Hidden_Item_Rocket_Hideout_B4F": 0x46f1f, - "Hidden_Item_Pokemon_Tower_5F": 0x46f33, - "Hidden_Item_Route_13_1": 0x46f3a, - "Hidden_Item_Route_13_2": 0x46f40, - "Hidden_Item_Safari_Zone_West": 0x46f4e, - "Hidden_Item_Silph_Co_5F": 0x46f55, - "Hidden_Item_Silph_Co_9F": 0x46f5c, - "Hidden_Item_Copycats_House": 0x46f63, - "Hidden_Item_Cerulean_Cave_1F": 0x46f6a, - "Hidden_Item_Cerulean_Cave_B1F": 0x46f71, - "Hidden_Item_Power_Plant_1": 0x46f78, - "Hidden_Item_Power_Plant_2": 0x46f7e, - "Hidden_Item_Seafoam_Islands_B2F": 0x46f85, - "Hidden_Item_Seafoam_Islands_B4F": 0x46f8c, - "Hidden_Item_Pokemon_Mansion_1F": 0x46f93, - "Hidden_Item_Pokemon_Mansion_3F": 0x46fa7, - "Hidden_Item_Pokemon_Mansion_B1F": 0x46fb4, - "Hidden_Item_Route_23_1": 0x46fc7, - "Hidden_Item_Route_23_2": 0x46fcd, - "Hidden_Item_Route_23_3": 0x46fd3, - "Hidden_Item_Victory_Road_2F_1": 0x46fda, - "Hidden_Item_Victory_Road_2F_2": 0x46fe0, - "Hidden_Item_Unused_6F": 0x46fe7, - "Hidden_Item_Viridian_City": 0x46ff5, - "Hidden_Item_Route_11": 0x470a2, - "Hidden_Item_Route_12": 0x470a9, - "Hidden_Item_Route_17_1": 0x470b7, - "Hidden_Item_Route_17_2": 0x470bd, - "Hidden_Item_Route_17_3": 0x470c3, - "Hidden_Item_Route_17_4": 0x470c9, - "Hidden_Item_Route_17_5": 0x470cf, - "Hidden_Item_Underground_Path_NS_1": 0x470d6, - "Hidden_Item_Underground_Path_NS_2": 0x470dc, - "Hidden_Item_Underground_Path_WE_1": 0x470e3, - "Hidden_Item_Underground_Path_WE_2": 0x470e9, - "Hidden_Item_Celadon_City": 0x470f0, - "Hidden_Item_Seafoam_Islands_B3F": 0x470f7, - "Hidden_Item_Vermilion_City": 0x470fe, - "Hidden_Item_Cerulean_City": 0x47105, - "Hidden_Item_Route_4": 0x4710c, + "Dexsanity_Items": 0x44254, + "Option_Dexsanity_A": 0x44301, + "Require_Pokedex_B": 0x44305, + "Option_Dexsanity_B": 0x44362, + "Trainersanity_EVENT_BEAT_MANSION_1_TRAINER_0_ITEM": 0x44540, + "Missable_Pokemon_Mansion_1F_Item_1": 0x445d7, + "Missable_Pokemon_Mansion_1F_Item_2": 0x445de, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0_ITEM": 0x44713, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1_ITEM": 0x44721, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2_ITEM": 0x4472f, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3_ITEM": 0x4473d, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4_ITEM": 0x4474b, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5_ITEM": 0x44759, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6_ITEM": 0x44767, + "Map_Rock_Tunnel1F": 0x44884, + "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0_ITEM": 0x44c54, + "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1_ITEM": 0x44c62, + "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2_ITEM": 0x44c70, + "Trainersanity_EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3_ITEM": 0x44c7e, + "Missable_Victory_Road_3F_Item_1": 0x44d1e, + "Missable_Victory_Road_3F_Item_2": 0x44d25, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0_ITEM": 0x44e46, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1_ITEM": 0x44e54, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2_ITEM": 0x44e62, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3_ITEM": 0x44e70, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4_ITEM": 0x44e7e, + "Missable_Rocket_Hideout_B1F_Item_1": 0x44f4e, + "Missable_Rocket_Hideout_B1F_Item_2": 0x44f55, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0_ITEM": 0x452ff, + "Missable_Rocket_Hideout_B2F_Item_1": 0x45340, + "Missable_Rocket_Hideout_B2F_Item_2": 0x45347, + "Missable_Rocket_Hideout_B2F_Item_3": 0x4534e, + "Missable_Rocket_Hideout_B2F_Item_4": 0x45355, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0_ITEM": 0x45532, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1_ITEM": 0x45540, + "Missable_Rocket_Hideout_B3F_Item_1": 0x45596, + "Missable_Rocket_Hideout_B3F_Item_2": 0x4559d, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0_ITEM": 0x45749, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1_ITEM": 0x45757, + "Trainersanity_EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2_ITEM": 0x45765, + "Missable_Rocket_Hideout_B4F_Item_1": 0x45854, + "Missable_Rocket_Hideout_B4F_Item_2": 0x4585b, + "Missable_Rocket_Hideout_B4F_Item_3": 0x45862, + "Missable_Rocket_Hideout_B4F_Item_4": 0x45869, + "Missable_Rocket_Hideout_B4F_Item_5": 0x45870, + "Missable_Safari_Zone_East_Item_1": 0x45adf, + "Missable_Safari_Zone_East_Item_2": 0x45ae6, + "Missable_Safari_Zone_East_Item_3": 0x45aed, + "Missable_Safari_Zone_East_Item_4": 0x45af4, + "Missable_Safari_Zone_North_Item_1": 0x45c3f, + "Missable_Safari_Zone_North_Item_2": 0x45c46, + "Missable_Safari_Zone_Center_Item": 0x45e26, + "Missable_Cerulean_Cave_2F_Item_1": 0x46063, + "Missable_Cerulean_Cave_2F_Item_2": 0x4606a, + "Missable_Cerulean_Cave_2F_Item_3": 0x46071, + "Trainersanity_EVENT_BEAT_MEWTWO_ITEM": 0x46149, + "Static_Encounter_Mewtwo": 0x46173, + "Missable_Cerulean_Cave_B1F_Item_1": 0x4617b, + "Missable_Cerulean_Cave_B1F_Item_2": 0x46182, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0_ITEM": 0x46258, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1_ITEM": 0x46266, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2_ITEM": 0x46274, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3_ITEM": 0x46282, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4_ITEM": 0x46290, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5_ITEM": 0x4629e, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6_ITEM": 0x462ac, + "Trainersanity_EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7_ITEM": 0x462ba, + "Missable_Rock_Tunnel_B1F_Item_1": 0x463de, + "Missable_Rock_Tunnel_B1F_Item_2": 0x463e5, + "Missable_Rock_Tunnel_B1F_Item_3": 0x463ec, + "Missable_Rock_Tunnel_B1F_Item_4": 0x463f3, + "Map_Rock_TunnelB1F": 0x46404, + "Trainersanity_EVENT_BEAT_ARTICUNO_ITEM": 0x46af6, + "Static_Encounter_Articuno": 0x46b4d, + "Hidden_Item_Game_Corner_1": 0x46fe5, + "Hidden_Item_Game_Corner_2": 0x46feb, + "Hidden_Item_Game_Corner_3": 0x46ff1, + "Hidden_Item_Game_Corner_4": 0x46ff7, + "Hidden_Item_Game_Corner_5": 0x46ffd, + "Hidden_Item_Game_Corner_6": 0x47003, + "Hidden_Item_Game_Corner_7": 0x47009, + "Hidden_Item_Game_Corner_8": 0x4700f, + "Hidden_Item_Game_Corner_9": 0x47015, + "Hidden_Item_Game_Corner_10": 0x4701b, + "Hidden_Item_Game_Corner_11": 0x47021, + "Quiz_Answer_A": 0x47055, + "Quiz_Answer_B": 0x4705b, + "Quiz_Answer_C": 0x47061, + "Quiz_Answer_D": 0x47067, + "Quiz_Answer_E": 0x4706d, + "Quiz_Answer_F": 0x47073, + "Hidden_Item_Viridian_Forest_1": 0x470a8, + "Hidden_Item_Viridian_Forest_2": 0x470ae, + "Hidden_Item_MtMoonB2F_1": 0x470b5, + "Hidden_Item_MtMoonB2F_2": 0x470bb, + "Hidden_Item_Route_25_1": 0x470cf, + "Hidden_Item_Route_25_2": 0x470d5, + "Hidden_Item_Route_9": 0x470dc, + "Hidden_Item_SS_Anne_Kitchen": 0x470ef, + "Hidden_Item_SS_Anne_B1F": 0x470f6, + "Hidden_Item_Route_10_1": 0x470fd, + "Hidden_Item_Route_10_2": 0x47103, + "Hidden_Item_Rocket_Hideout_B1F": 0x4710a, + "Hidden_Item_Rocket_Hideout_B3F": 0x47111, + "Hidden_Item_Rocket_Hideout_B4F": 0x47118, + "Hidden_Item_Pokemon_Tower_5F": 0x4712c, + "Hidden_Item_Route_13_1": 0x47133, + "Hidden_Item_Route_13_2": 0x47139, + "Hidden_Item_Safari_Zone_West": 0x47147, + "Hidden_Item_Silph_Co_5F": 0x4714e, + "Hidden_Item_Silph_Co_9F": 0x47155, + "Hidden_Item_Copycats_House": 0x4715c, + "Hidden_Item_Cerulean_Cave_1F": 0x47163, + "Hidden_Item_Cerulean_Cave_B1F": 0x4716a, + "Hidden_Item_Power_Plant_1": 0x47171, + "Hidden_Item_Power_Plant_2": 0x47177, + "Hidden_Item_Seafoam_Islands_B2F": 0x4717e, + "Hidden_Item_Seafoam_Islands_B4F": 0x47185, + "Hidden_Item_Pokemon_Mansion_1F": 0x4718c, + "Hidden_Item_Pokemon_Mansion_3F": 0x471a0, + "Hidden_Item_Pokemon_Mansion_B1F": 0x471ad, + "Hidden_Item_Route_23_1": 0x471c0, + "Hidden_Item_Route_23_2": 0x471c6, + "Hidden_Item_Route_23_3": 0x471cc, + "Hidden_Item_Victory_Road_2F_1": 0x471d3, + "Hidden_Item_Victory_Road_2F_2": 0x471d9, + "Hidden_Item_Unused_6F": 0x471e0, + "Hidden_Item_Viridian_City": 0x471ee, + "Hidden_Item_Route_11": 0x4729b, + "Hidden_Item_Route_12": 0x472a2, + "Hidden_Item_Route_17_1": 0x472b0, + "Hidden_Item_Route_17_2": 0x472b6, + "Hidden_Item_Route_17_3": 0x472bc, + "Hidden_Item_Route_17_4": 0x472c2, + "Hidden_Item_Route_17_5": 0x472c8, + "Hidden_Item_Underground_Path_NS_1": 0x472cf, + "Hidden_Item_Underground_Path_NS_2": 0x472d5, + "Hidden_Item_Underground_Path_WE_1": 0x472dc, + "Hidden_Item_Underground_Path_WE_2": 0x472e2, + "Hidden_Item_Celadon_City": 0x472e9, + "Hidden_Item_Seafoam_Islands_B3F": 0x472f0, + "Hidden_Item_Vermilion_City": 0x472f7, + "Hidden_Item_Cerulean_City": 0x472fe, + "Hidden_Item_Route_4": 0x47305, "Event_Counter": 0x482d3, - "Event_Thirsty_Girl_Lemonade": 0x484f9, - "Event_Thirsty_Girl_Soda": 0x4851d, - "Event_Thirsty_Girl_Water": 0x48541, - "Option_Tea": 0x4871d, - "Event_Mansion_Lady": 0x4872a, - "Badge_Celadon_Gym": 0x48a1b, - "Event_Celadon_Gym": 0x48a2f, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_0_ITEM": 0x48a75, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_1_ITEM": 0x48a83, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_2_ITEM": 0x48a91, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_3_ITEM": 0x48a9f, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_4_ITEM": 0x48aad, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_5_ITEM": 0x48abb, - "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_6_ITEM": 0x48ac9, - "Event_Gambling_Addict": 0x492a1, - "Gift_Magikarp": 0x4943e, - "Option_Aide_Rt11": 0x4959b, - "Event_Rt11_Oaks_Aide": 0x4959f, - "Event_Mourning_Girl": 0x49699, - "Option_Aide_Rt15": 0x49784, - "Event_Rt_15_Oaks_Aide": 0x49788, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_0_ITEM": 0x49b2e, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_1_ITEM": 0x49b3c, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_2_ITEM": 0x49b4a, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_3_ITEM": 0x49b58, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_4_ITEM": 0x49b66, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_5_ITEM": 0x49b74, - "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_6_ITEM": 0x49b82, - "Missable_Mt_Moon_1F_Item_1": 0x49c91, - "Missable_Mt_Moon_1F_Item_2": 0x49c98, - "Missable_Mt_Moon_1F_Item_3": 0x49c9f, - "Missable_Mt_Moon_1F_Item_4": 0x49ca6, - "Missable_Mt_Moon_1F_Item_5": 0x49cad, - "Missable_Mt_Moon_1F_Item_6": 0x49cb4, - "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_0_ITEM": 0x49f87, - "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_1_ITEM": 0x49f95, - "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_2_ITEM": 0x49fa3, - "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_3_ITEM": 0x49fb1, - "Dome_Fossil_Text": 0x4a025, - "Event_Dome_Fossil": 0x4a045, - "Helix_Fossil_Text": 0x4a081, - "Event_Helix_Fossil": 0x4a0a1, - "Missable_Mt_Moon_B2F_Item_1": 0x4a18a, - "Missable_Mt_Moon_B2F_Item_2": 0x4a191, - "Missable_Safari_Zone_West_Item_1": 0x4a373, - "Missable_Safari_Zone_West_Item_2": 0x4a37a, - "Missable_Safari_Zone_West_Item_3": 0x4a381, - "Missable_Safari_Zone_West_Item_4": 0x4a388, - "Event_Safari_Zone_Secret_House": 0x4a48d, + "Event_Thirsty_Girl_Lemonade": 0x48501, + "Event_Thirsty_Girl_Soda": 0x48525, + "Event_Thirsty_Girl_Water": 0x48549, + "Option_Tea": 0x48725, + "Event_Mansion_Lady": 0x48732, + "Badge_Celadon_Gym": 0x48a23, + "Event_Celadon_Gym": 0x48a37, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_0_ITEM": 0x48a7d, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_1_ITEM": 0x48a8b, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_2_ITEM": 0x48a99, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_3_ITEM": 0x48aa7, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_4_ITEM": 0x48ab5, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_5_ITEM": 0x48ac3, + "Trainersanity_EVENT_BEAT_CELADON_GYM_TRAINER_6_ITEM": 0x48ad1, + "Event_Game_Corner_Gift_A": 0x48e98, + "Event_Game_Corner_Gift_C": 0x48f14, + "Event_Game_Corner_Gift_B": 0x48f63, + "Event_Gambling_Addict": 0x49306, + "Gift_Magikarp": 0x494a3, + "Option_Aide_Rt11": 0x49600, + "Event_Rt11_Oaks_Aide": 0x49604, + "Event_Mourning_Girl": 0x496fe, + "Option_Aide_Rt15": 0x497e9, + "Event_Rt_15_Oaks_Aide": 0x497ed, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_0_ITEM": 0x49b93, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_1_ITEM": 0x49ba1, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_2_ITEM": 0x49baf, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_3_ITEM": 0x49bbd, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_4_ITEM": 0x49bcb, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_5_ITEM": 0x49bd9, + "Trainersanity_EVENT_BEAT_MT_MOON_1_TRAINER_6_ITEM": 0x49be7, + "Missable_Mt_Moon_1F_Item_1": 0x49cf6, + "Missable_Mt_Moon_1F_Item_2": 0x49cfd, + "Missable_Mt_Moon_1F_Item_3": 0x49d04, + "Missable_Mt_Moon_1F_Item_4": 0x49d0b, + "Missable_Mt_Moon_1F_Item_5": 0x49d12, + "Missable_Mt_Moon_1F_Item_6": 0x49d19, + "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_0_ITEM": 0x49fec, + "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_1_ITEM": 0x49ffa, + "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_2_ITEM": 0x4a008, + "Trainersanity_EVENT_BEAT_MT_MOON_3_TRAINER_3_ITEM": 0x4a016, + "Dome_Fossil_Text": 0x4a08a, + "Event_Dome_Fossil": 0x4a0aa, + "Helix_Fossil_Text": 0x4a0e6, + "Event_Helix_Fossil": 0x4a106, + "Missable_Mt_Moon_B2F_Item_1": 0x4a1ef, + "Missable_Mt_Moon_B2F_Item_2": 0x4a1f6, + "Missable_Safari_Zone_West_Item_1": 0x4a3d8, + "Missable_Safari_Zone_West_Item_2": 0x4a3df, + "Missable_Safari_Zone_West_Item_3": 0x4a3e6, + "Missable_Safari_Zone_West_Item_4": 0x4a3ed, + "Event_Safari_Zone_Secret_House": 0x4a4f2, "Missable_Route_24_Item": 0x506e6, "Missable_Route_25_Item": 0x5080b, "Trainersanity_EVENT_BEAT_ROUTE_20_TRAINER_0_ITEM": 0x50d47, @@ -606,13 +634,16 @@ rom_addresses = { "Prize_Mon_D2": 0x5288a, "Prize_Mon_E2": 0x5288b, "Prize_Mon_F2": 0x5288c, - "Prize_Mon_A": 0x529b0, - "Prize_Mon_B": 0x529b2, - "Prize_Mon_C": 0x529b4, - "Prize_Mon_D": 0x529b6, - "Prize_Mon_E": 0x529b8, - "Prize_Mon_F": 0x529ba, - "Start_Inventory": 0x52add, + "Prize_Item_A": 0x52895, + "Prize_Item_B": 0x52896, + "Prize_Item_C": 0x52897, + "Prize_Mon_A": 0x529cc, + "Prize_Mon_B": 0x529ce, + "Prize_Mon_C": 0x529d0, + "Prize_Mon_D": 0x529d2, + "Prize_Mon_E": 0x529d4, + "Prize_Mon_F": 0x529d6, + "Start_Inventory": 0x52af9, "Missable_Route_2_Item_1": 0x5404a, "Missable_Route_2_Item_2": 0x54051, "Missable_Route_4_Item": 0x543df, @@ -696,81 +727,82 @@ rom_addresses = { "Missable_Route_12_Item_2": 0x5870b, "Missable_Route_15_Item": 0x589c7, "Ghost_Battle6": 0x58df0, - "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_0_ITEM": 0x59106, - "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_1_ITEM": 0x59114, - "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_2_ITEM": 0x59122, - "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_3_ITEM": 0x59130, - "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_4_ITEM": 0x5913e, - "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_5_ITEM": 0x5914c, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_0_ITEM": 0x5921e, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_1_ITEM": 0x5922c, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_2_ITEM": 0x5923a, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_3_ITEM": 0x59248, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_4_ITEM": 0x59256, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_5_ITEM": 0x59264, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_6_ITEM": 0x59272, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_7_ITEM": 0x59280, - "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_8_ITEM": 0x5928e, - "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_0_ITEM": 0x59406, - "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_1_ITEM": 0x59414, - "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_2_ITEM": 0x59422, - "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_3_ITEM": 0x59430, - "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_4_ITEM": 0x5943e, - "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_5_ITEM": 0x5944c, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_0_ITEM": 0x59533, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_1_ITEM": 0x59541, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_2_ITEM": 0x5954f, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_3_ITEM": 0x5955d, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_4_ITEM": 0x5956b, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_5_ITEM": 0x59579, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_6_ITEM": 0x59587, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_7_ITEM": 0x59595, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_8_ITEM": 0x595a3, - "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_9_ITEM": 0x595b1, - "Static_Encounter_Snorlax_A": 0x596ef, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_0_ITEM": 0x5975d, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_1_ITEM": 0x5976b, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_2_ITEM": 0x59779, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_3_ITEM": 0x59787, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_4_ITEM": 0x59795, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_5_ITEM": 0x597a3, - "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_6_ITEM": 0x597b1, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_0_ITEM": 0x598b9, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_1_ITEM": 0x598c7, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_2_ITEM": 0x598d5, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_3_ITEM": 0x598e3, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_4_ITEM": 0x598f1, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_5_ITEM": 0x598ff, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_6_ITEM": 0x5990d, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_7_ITEM": 0x5991b, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_8_ITEM": 0x59929, - "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_9_ITEM": 0x59937, - "Static_Encounter_Snorlax_B": 0x59a51, - "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_0_ITEM": 0x59abd, - "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_1_ITEM": 0x59acb, - "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_2_ITEM": 0x59ad9, - "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_3_ITEM": 0x59ae7, - "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_4_ITEM": 0x59af5, - "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_5_ITEM": 0x59b03, - "Trainersanity_EVENT_BEAT_ROUTE_18_TRAINER_0_ITEM": 0x59be4, - "Trainersanity_EVENT_BEAT_ROUTE_18_TRAINER_1_ITEM": 0x59bf2, - "Trainersanity_EVENT_BEAT_ROUTE_18_TRAINER_2_ITEM": 0x59c00, - "Event_Pokemon_Fan_Club": 0x59d13, - "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_0_ITEM": 0x59e73, - "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_1_ITEM": 0x59e81, - "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_2_ITEM": 0x59e8f, - "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_3_ITEM": 0x59e9d, - "Event_Scared_Woman": 0x59eaf, - "Trainersanity_EVENT_BEAT_SILPH_CO_3F_TRAINER_0_ITEM": 0x5a0b7, - "Trainersanity_EVENT_BEAT_SILPH_CO_3F_TRAINER_1_ITEM": 0x5a0c5, - "Missable_Silph_Co_3F_Item": 0x5a15f, - "Trainersanity_EVENT_BEAT_SILPH_CO_10F_TRAINER_0_ITEM": 0x5a281, - "Trainersanity_EVENT_BEAT_SILPH_CO_10F_TRAINER_1_ITEM": 0x5a28f, - "Missable_Silph_Co_10F_Item_1": 0x5a319, - "Missable_Silph_Co_10F_Item_2": 0x5a320, - "Missable_Silph_Co_10F_Item_3": 0x5a327, - "Trainersanity_EVENT_BEAT_LANCES_ROOM_TRAINER_0_ITEM": 0x5a48a, - "Guard_Drink_List": 0x5a69f, + "Require_Pokedex_A": 0x59051, + "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_0_ITEM": 0x5910b, + "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_1_ITEM": 0x59119, + "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_2_ITEM": 0x59127, + "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_3_ITEM": 0x59135, + "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_4_ITEM": 0x59143, + "Trainersanity_EVENT_BEAT_ROUTE_6_TRAINER_5_ITEM": 0x59151, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_0_ITEM": 0x59223, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_1_ITEM": 0x59231, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_2_ITEM": 0x5923f, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_3_ITEM": 0x5924d, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_4_ITEM": 0x5925b, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_5_ITEM": 0x59269, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_6_ITEM": 0x59277, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_7_ITEM": 0x59285, + "Trainersanity_EVENT_BEAT_ROUTE_8_TRAINER_8_ITEM": 0x59293, + "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_0_ITEM": 0x5940d, + "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_1_ITEM": 0x5941b, + "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_2_ITEM": 0x59429, + "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_3_ITEM": 0x59437, + "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_4_ITEM": 0x59445, + "Trainersanity_EVENT_BEAT_ROUTE_10_TRAINER_5_ITEM": 0x59453, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_0_ITEM": 0x5953a, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_1_ITEM": 0x59548, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_2_ITEM": 0x59556, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_3_ITEM": 0x59564, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_4_ITEM": 0x59572, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_5_ITEM": 0x59580, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_6_ITEM": 0x5958e, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_7_ITEM": 0x5959c, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_8_ITEM": 0x595aa, + "Trainersanity_EVENT_BEAT_ROUTE_11_TRAINER_9_ITEM": 0x595b8, + "Static_Encounter_Snorlax_A": 0x596f6, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_0_ITEM": 0x59764, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_1_ITEM": 0x59772, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_2_ITEM": 0x59780, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_3_ITEM": 0x5978e, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_4_ITEM": 0x5979c, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_5_ITEM": 0x597aa, + "Trainersanity_EVENT_BEAT_ROUTE_12_TRAINER_6_ITEM": 0x597b8, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_0_ITEM": 0x598c0, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_1_ITEM": 0x598ce, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_2_ITEM": 0x598dc, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_3_ITEM": 0x598ea, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_4_ITEM": 0x598f8, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_5_ITEM": 0x59906, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_6_ITEM": 0x59914, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_7_ITEM": 0x59922, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_8_ITEM": 0x59930, + "Trainersanity_EVENT_BEAT_ROUTE_15_TRAINER_9_ITEM": 0x5993e, + "Static_Encounter_Snorlax_B": 0x59a58, + "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_0_ITEM": 0x59ac4, + "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_1_ITEM": 0x59ad2, + "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_2_ITEM": 0x59ae0, + "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_3_ITEM": 0x59aee, + "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_4_ITEM": 0x59afc, + "Trainersanity_EVENT_BEAT_ROUTE_16_TRAINER_5_ITEM": 0x59b0a, + "Trainersanity_EVENT_BEAT_ROUTE_18_TRAINER_0_ITEM": 0x59beb, + "Trainersanity_EVENT_BEAT_ROUTE_18_TRAINER_1_ITEM": 0x59bf9, + "Trainersanity_EVENT_BEAT_ROUTE_18_TRAINER_2_ITEM": 0x59c07, + "Event_Pokemon_Fan_Club": 0x59d1a, + "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_0_ITEM": 0x59e7a, + "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_1_ITEM": 0x59e88, + "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_2_ITEM": 0x59e96, + "Trainersanity_EVENT_BEAT_SILPH_CO_2F_TRAINER_3_ITEM": 0x59ea4, + "Event_Scared_Woman": 0x59eb6, + "Trainersanity_EVENT_BEAT_SILPH_CO_3F_TRAINER_0_ITEM": 0x5a0be, + "Trainersanity_EVENT_BEAT_SILPH_CO_3F_TRAINER_1_ITEM": 0x5a0cc, + "Missable_Silph_Co_3F_Item": 0x5a166, + "Trainersanity_EVENT_BEAT_SILPH_CO_10F_TRAINER_0_ITEM": 0x5a288, + "Trainersanity_EVENT_BEAT_SILPH_CO_10F_TRAINER_1_ITEM": 0x5a296, + "Missable_Silph_Co_10F_Item_1": 0x5a320, + "Missable_Silph_Co_10F_Item_2": 0x5a327, + "Missable_Silph_Co_10F_Item_3": 0x5a32e, + "Trainersanity_EVENT_BEAT_LANCES_ROOM_TRAINER_0_ITEM": 0x5a491, + "Guard_Drink_List": 0x5a6a6, "Event_Museum": 0x5c266, "Badge_Pewter_Gym": 0x5c3ed, "Event_Pewter_Gym": 0x5c401, @@ -879,6 +911,16 @@ rom_addresses = { "Trainersanity_EVENT_BEAT_SILPH_CO_11F_TRAINER_1_ITEM": 0x6234a, "Event_Silph_Co_President": 0x6235d, "Ghost_Battle4": 0x708e1, + "Trade_Terry": 0x71b7b, + "Trade_Marcel": 0x71b89, + "Trade_Sailor": 0x71ba5, + "Trade_Dux": 0x71bb3, + "Trade_Marc": 0x71bc1, + "Trade_Lola": 0x71bcf, + "Trade_Doris": 0x71bdd, + "Trade_Crinkles": 0x71beb, + "Trade_Spot": 0x71bf9, + "Mon_Palettes": 0x725dd, "Badge_Viridian_Gym": 0x749f7, "Event_Viridian_Gym": 0x74a0b, "Trainersanity_EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0_ITEM": 0x74a66, @@ -906,18 +948,39 @@ rom_addresses = { "Trainersanity_EVENT_BEAT_FUCHSIA_GYM_TRAINER_5_ITEM": 0x756d2, "Badge_Cinnabar_Gym": 0x75a06, "Event_Cinnabar_Gym": 0x75a1a, - "Event_Lab_Scientist": 0x75e43, - "Fossils_Needed_For_Second_Item": 0x75f10, - "Event_Dome_Fossil_B": 0x75f8d, - "Event_Helix_Fossil_B": 0x75fad, - "Shop8": 0x760cb, - "Starter2_N": 0x761fe, - "Starter3_N": 0x76206, - "Trainersanity_EVENT_BEAT_LORELEIS_ROOM_TRAINER_0_ITEM": 0x764ce, - "Trainersanity_EVENT_BEAT_BRUNOS_ROOM_TRAINER_0_ITEM": 0x76627, - "Trainersanity_EVENT_BEAT_AGATHAS_ROOM_TRAINER_0_ITEM": 0x76786, - "Option_Itemfinder": 0x768ff, - "Text_Magikarp_Salesman": 0x8a7fe, + "Option_Trainersanity4": 0x75af6, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_B_ITEM": 0x75b02, + "Option_Trainersanity3": 0x75b46, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_A_ITEM": 0x75b52, + "Option_Trainersanity5": 0x75bad, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_2_ITEM": 0x75bb9, + "Option_Trainersanity6": 0x75bfd, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_3_ITEM": 0x75c09, + "Option_Trainersanity7": 0x75c4d, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_4_ITEM": 0x75c59, + "Option_Trainersanity8": 0x75c9d, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_5_ITEM": 0x75ca9, + "Option_Trainersanity9": 0x75ced, + "Trainersanity_EVENT_BEAT_CINNABAR_GYM_TRAINER_6_ITEM": 0x75cf9, + "Event_Lab_Scientist": 0x75f17, + "Fossils_Needed_For_Second_Item": 0x75fe4, + "Event_Dome_Fossil_B": 0x76061, + "Event_Helix_Fossil_B": 0x76081, + "Shop8": 0x7619f, + "Starter2_N": 0x762d2, + "Starter3_N": 0x762da, + "Trainersanity_EVENT_BEAT_LORELEIS_ROOM_TRAINER_0_ITEM": 0x7663d, + "Trainersanity_EVENT_BEAT_BRUNOS_ROOM_TRAINER_0_ITEM": 0x76796, + "Trainersanity_EVENT_BEAT_AGATHAS_ROOM_TRAINER_0_ITEM": 0x768f5, + "Option_Itemfinder": 0x76a6e, + "Text_Quiz_A": 0x88806, + "Text_Quiz_B": 0x8893a, + "Text_Quiz_C": 0x88a6e, + "Text_Quiz_D": 0x88ba2, + "Text_Quiz_E": 0x88cd6, + "Text_Quiz_F": 0x88e0a, + "Text_Magikarp_Salesman": 0x8ae3f, + "Text_Rock_Tunnel_Sign": 0x8e82a, "Text_Badges_Needed": 0x92304, "Badge_Text_Boulder_Badge": 0x99010, "Badge_Text_Cascade_Badge": 0x99028, diff --git a/worlds/pokemon_rb/rules.py b/worlds/pokemon_rb/rules.py index 493a58e594..704446d66d 100644 --- a/worlds/pokemon_rb/rules.py +++ b/worlds/pokemon_rb/rules.py @@ -1,26 +1,45 @@ -from ..generic.Rules import add_item_rule, add_rule +from ..generic.Rules import add_item_rule, add_rule, location_item_name +from .items import item_groups + def set_rules(world, player): - add_item_rule(world.get_location("Pallet Town - Player's PC", player), - lambda i: i.player == player and "Badge" not in i.name and "Trap" not in i.name and - i.name != "Pokedex") + item_rules = { + "Pallet Town - Player's PC": (lambda i: i.player == player and "Badge" not in i.name and "Trap" not in i.name + and i.name != "Pokedex" and "Coins" not in i.name) + } + + if world.prizesanity[player]: + def prize_rule(i): + return i.player != player or i.name in item_groups["Unique"] + item_rules["Celadon Prize Corner - Item Prize 1"] = prize_rule + item_rules["Celadon Prize Corner - Item Prize 2"] = prize_rule + item_rules["Celadon Prize Corner - Item Prize 3"] = prize_rule + + if world.accessibility[player] != "locations": + world.get_location("Cerulean City - Bicycle Shop", player).always_allow = (lambda state, item: + item.name == "Bike Voucher" + and item.player == player) + world.get_location("Fuchsia City - Safari Zone Warden", player).always_allow = (lambda state, item: + item.name == "Gold Teeth" and + item.player == player) access_rules = { - "Pallet Town - Rival's Sister": lambda state: state.has("Oak's Parcel", player), "Pallet Town - Oak's Post-Route-22-Rival Gift": lambda state: state.has("Oak's Parcel", player), "Viridian City - Sleepy Guy": lambda state: state.pokemon_rb_can_cut(player) or state.pokemon_rb_can_surf(player), "Route 2 - Oak's Aide": lambda state: state.pokemon_rb_oaks_aide(state.multiworld.oaks_aide_rt_2[player].value + 5, player), "Pewter City - Museum": lambda state: state.pokemon_rb_can_cut(player), - "Cerulean City - Bicycle Shop": lambda state: state.has("Bike Voucher", player), + "Cerulean City - Bicycle Shop": lambda state: state.has("Bike Voucher", player) + or location_item_name(state, "Cerulean City - Bicycle Shop", player) == ("Bike Voucher", player), "Lavender Town - Mr. Fuji": lambda state: state.has("Fuji Saved", player), "Vermilion Gym - Lt. Surge 1": lambda state: state.pokemon_rb_can_cut(player or state.pokemon_rb_can_surf(player)), "Vermilion Gym - Lt. Surge 2": lambda state: state.pokemon_rb_can_cut(player or state.pokemon_rb_can_surf(player)), "Route 11 - Oak's Aide": lambda state: state.pokemon_rb_oaks_aide(state.multiworld.oaks_aide_rt_11[player].value + 5, player), "Celadon City - Stranded Man": lambda state: state.pokemon_rb_can_surf(player), - "Silph Co 11F - Silph Co President": lambda state: state.has("Card Key", player), - "Fuchsia City - Safari Zone Warden": lambda state: state.has("Gold Teeth", player), + "Silph Co 11F - Silph Co President (Card Key)": lambda state: state.has("Card Key", player), + "Fuchsia City - Safari Zone Warden": lambda state: state.has("Gold Teeth", player) + or location_item_name(state, "Fuchsia City - Safari Zone Warden", player) == ("Gold Teeth", player), "Route 12 - Island Item": lambda state: state.pokemon_rb_can_surf(player), "Route 12 - Item Behind Cuttable Tree": lambda state: state.pokemon_rb_can_cut(player), "Route 15 - Oak's Aide": lambda state: state.pokemon_rb_oaks_aide(state.multiworld.oaks_aide_rt_15[player].value + 5, player), @@ -38,6 +57,23 @@ def set_rules(world, player): "Silph Co 6F - Southwest Item (Card Key)": lambda state: state.has("Card Key", player), "Silph Co 7F - East Item (Card Key)": lambda state: state.has("Card Key", player), "Safari Zone Center - Island Item": lambda state: state.pokemon_rb_can_surf(player), + "Celadon Prize Corner - Item Prize 1": lambda state: state.has("Coin Case", player), + "Celadon Prize Corner - Item Prize 2": lambda state: state.has("Coin Case", player), + "Celadon Prize Corner - Item Prize 3": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - West Gambler's Gift (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Center Gambler's Gift (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - East Gambler's Gift (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Northwest By Counter (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Southwest Corner (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Near Rumor Man (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Near Speculating Woman (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Near West Gifting Gambler (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Near Wonderful Time Woman (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Near Failing Gym Information Guy (Coin Case)": lambda state: state.has( "Coin Case", player), + "Celadon Game Corner - Hidden Item Near East Gifting Gambler (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item Near Hooked Guy (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item at End of Horizontal Machine Row (Coin Case)": lambda state: state.has("Coin Case", player), + "Celadon Game Corner - Hidden Item in Front of Horizontal Machine Row (Coin Case)": lambda state: state.has("Coin Case", player), "Silph Co 11F - Silph Co Liberated": lambda state: state.has("Card Key", player), @@ -89,6 +125,16 @@ def set_rules(world, player): "Seafoam Islands B4F - Legendary Pokemon": lambda state: state.pokemon_rb_can_strength(player), "Vermilion City - Legendary Pokemon": lambda state: state.pokemon_rb_can_surf(player) and state.has("S.S. Ticket", player), + "Route 2 - Marcel Trade": lambda state: state.can_reach("Route 24 - Wild Pokemon - 6", "Location", player), + "Underground Tunnel West-East - Spot Trade": lambda state: state.can_reach("Route 24 - Wild Pokemon - 6", "Location", player), + "Route 11 - Terry Trade": lambda state: state.can_reach("Safari Zone Center - Wild Pokemon - 5", "Location", player), + "Route 18 - Marc Trade": lambda state: state.can_reach("Route 23 - Super Rod Pokemon - 1", "Location", player), + "Cinnabar Island - Sailor Trade": lambda state: state.can_reach("Pokemon Mansion 1F - Wild Pokemon - 3", "Location", player), + "Cinnabar Island - Crinkles Trade": lambda state: state.can_reach("Route 12 - Wild Pokemon - 4", "Location", player), + "Cinnabar Island - Doris Trade": lambda state: state.can_reach("Cerulean Cave 1F - Wild Pokemon - 9", "Location", player), + "Vermilion City - Dux Trade": lambda state: state.can_reach("Route 3 - Wild Pokemon - 2", "Location", player), + "Cerulean City - Lola Trade": lambda state: state.can_reach("Route 10 - Super Rod Pokemon - 1", "Location", player), + # Pokédex check "Pallet Town - Oak's Parcel Reward": lambda state: state.has("Oak's Parcel", player), @@ -142,7 +188,7 @@ def set_rules(world, player): "Pokemon Mansion 1F - Hidden Item Block Near Entrance Carpet": lambda state: state.pokemon_rb_can_get_hidden_items(player), "Pokemon Mansion 3F - Hidden Item Behind Burglar": lambda state: state.pokemon_rb_can_get_hidden_items(player), - "Route 23 - Hidden Item Rocks Before Final Guard": lambda state: state.pokemon_rb_can_get_hidden_items( + "Route 23 - Hidden Item Rocks Before Victory Road": lambda state: state.pokemon_rb_can_get_hidden_items( player), "Route 23 - Hidden Item East Bush After Water": lambda state: state.pokemon_rb_can_get_hidden_items( player), @@ -178,3 +224,11 @@ def set_rules(world, player): for loc in world.get_locations(player): if loc.name in access_rules: add_rule(loc, access_rules[loc.name]) + if loc.name in item_rules: + add_item_rule(loc, item_rules[loc.name]) + if loc.name.startswith("Pokedex"): + mon = loc.name.split(" - ")[1] + add_rule(loc, lambda state, i=mon: (state.has("Pokedex", player) or not + state.multiworld.require_pokedex[player]) and (state.has(i, player) + or state.has(f"Static {i}", player))) + diff --git a/worlds/pokemon_rb/text.py b/worlds/pokemon_rb/text.py index e15623d4b8..feb54e656a 100644 --- a/worlds/pokemon_rb/text.py +++ b/worlds/pokemon_rb/text.py @@ -1,5 +1,9 @@ special_chars = { "PKMN": 0x4A, + "LINE": 0x4F, + "CONT": 0x55, + "DONE": 0x57, + "PROMPT": 0x58, "'d": 0xBB, "'l": 0xBC, "'t": 0xBE, @@ -105,7 +109,7 @@ char_map = { "9": 0xFF, } -unsafe_chars = ["@", "#", "PKMN"] +unsafe_chars = ["@", "#", "PKMN", "LINE", "DONE", "CONT", "PROMPT"] def encode_text(text: str, length: int=0, whitespace=False, force=False, safety=False): diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index aae83f5031..9776e4fed1 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -182,9 +182,11 @@ filler_items: typing.Tuple[str, ...] = ( '+15 Starting Vespene' ) +# Defense rating table +# Commented defense ratings are handled in LogicMixin defense_ratings = { "Siege Tank": 5, - "Maelstrom Rounds": 2, + # "Maelstrom Rounds": 2, "Planetary Fortress": 3, # Bunker w/ Marine/Marauder: 3, "Perdition Turret": 2, @@ -193,7 +195,7 @@ defense_ratings = { } zerg_defense_ratings = { "Perdition Turret": 2, - # Bunker w/ Firebat: 2 + # Bunker w/ Firebat: 2, "Hive Mind Emulator": 3, "Psi Disruptor": 3 } diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index dac9d856e7..c803835f63 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -17,10 +17,12 @@ class SC2WoLLogic(LogicMixin): or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Wraith', player) def _sc2wol_has_competent_anti_air(self, multiworld: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Goliath'}, player) or self._sc2wol_has_air_anti_air(multiworld, player) + return self.has('Goliath', player) \ + or self.has('Marine', player) and self.has_any({'Medic', 'Medivac'}, player) \ + or self._sc2wol_has_air_anti_air(multiworld, player) def _sc2wol_has_anti_air(self, multiworld: MultiWorld, player: int) -> bool: - return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Wraith'}, player) \ + return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Marine', 'Wraith'}, player) \ or self._sc2wol_has_competent_anti_air(multiworld, player) \ or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) @@ -28,6 +30,8 @@ class SC2WoLLogic(LogicMixin): defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) if self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player): defense_score += 3 + if self.has_all({'Siege Tank', 'Maelstrom Rounds'}, player): + defense_score += 2 if zerg_enemy: defense_score += sum((zerg_defense_ratings[item] for item in zerg_defense_ratings if self.has(item, player))) if self.has('Firebat', player) and self.has('Bunker', player): diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index d926ea6251..6db9354768 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -1,7 +1,5 @@ -from typing import NamedTuple, Dict, List, Set - -from BaseClasses import MultiWorld -from .Options import get_option_value +from typing import NamedTuple, Dict, List +from enum import IntEnum no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom", "Belly of the Beast"] @@ -13,6 +11,14 @@ hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkn "Shatter the Sky"] +class MissionPools(IntEnum): + STARTER = 0 + EASY = 1 + MEDIUM = 2 + HARD = 3 + FINAL = 4 + + class MissionInfo(NamedTuple): id: int required_world: List[int] @@ -23,119 +29,119 @@ class MissionInfo(NamedTuple): class FillMission(NamedTuple): - type: str + type: int connect_to: List[int] # -1 connects to Menu category: str number: int = 0 # number of worlds need beaten completion_critical: bool = False # missions needed to beat game or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed - relegate: bool = False # true if this is a slot no build missions should be relegated to. + removal_priority: int = 0 # how many missions missing from the pool required to remove this mission vanilla_shuffle_order = [ - FillMission("no_build", [-1], "Mar Sara", completion_critical=True), - FillMission("easy", [0], "Mar Sara", completion_critical=True), - FillMission("easy", [1], "Mar Sara", completion_critical=True), - FillMission("easy", [2], "Colonist"), - FillMission("medium", [3], "Colonist"), - FillMission("hard", [4], "Colonist", number=7), - FillMission("hard", [4], "Colonist", number=7, relegate=True), - FillMission("easy", [2], "Artifact", completion_critical=True), - FillMission("medium", [7], "Artifact", number=8, completion_critical=True), - FillMission("hard", [8], "Artifact", number=11, completion_critical=True), - FillMission("hard", [9], "Artifact", number=14, completion_critical=True), - FillMission("hard", [10], "Artifact", completion_critical=True), - FillMission("medium", [2], "Covert", number=4), - FillMission("medium", [12], "Covert"), - FillMission("hard", [13], "Covert", number=8, relegate=True), - FillMission("hard", [13], "Covert", number=8, relegate=True), - FillMission("medium", [2], "Rebellion", number=6), - FillMission("hard", [16], "Rebellion"), - FillMission("hard", [17], "Rebellion"), - FillMission("hard", [18], "Rebellion"), - FillMission("hard", [19], "Rebellion", relegate=True), - FillMission("medium", [8], "Prophecy"), - FillMission("hard", [21], "Prophecy"), - FillMission("hard", [22], "Prophecy"), - FillMission("hard", [23], "Prophecy", relegate=True), - FillMission("hard", [11], "Char", completion_critical=True), - FillMission("hard", [25], "Char", completion_critical=True), - FillMission("hard", [25], "Char", completion_critical=True), - FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [0], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [1], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [2], "Colonist"), + FillMission(MissionPools.MEDIUM, [3], "Colonist"), + FillMission(MissionPools.HARD, [4], "Colonist", number=7), + FillMission(MissionPools.HARD, [4], "Colonist", number=7, removal_priority=1), + FillMission(MissionPools.EASY, [2], "Artifact", completion_critical=True), + FillMission(MissionPools.MEDIUM, [7], "Artifact", number=8, completion_critical=True), + FillMission(MissionPools.HARD, [8], "Artifact", number=11, completion_critical=True), + FillMission(MissionPools.HARD, [9], "Artifact", number=14, completion_critical=True), + FillMission(MissionPools.HARD, [10], "Artifact", completion_critical=True), + FillMission(MissionPools.MEDIUM, [2], "Covert", number=4), + FillMission(MissionPools.MEDIUM, [12], "Covert"), + FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=3), + FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=2), + FillMission(MissionPools.MEDIUM, [2], "Rebellion", number=6), + FillMission(MissionPools.HARD, [16], "Rebellion"), + FillMission(MissionPools.HARD, [17], "Rebellion"), + FillMission(MissionPools.HARD, [18], "Rebellion"), + FillMission(MissionPools.HARD, [19], "Rebellion", removal_priority=5), + FillMission(MissionPools.MEDIUM, [8], "Prophecy", removal_priority=9), + FillMission(MissionPools.HARD, [21], "Prophecy", removal_priority=8), + FillMission(MissionPools.HARD, [22], "Prophecy", removal_priority=7), + FillMission(MissionPools.HARD, [23], "Prophecy", removal_priority=6), + FillMission(MissionPools.HARD, [11], "Char", completion_critical=True), + FillMission(MissionPools.HARD, [25], "Char", completion_critical=True, removal_priority=4), + FillMission(MissionPools.HARD, [25], "Char", completion_critical=True), + FillMission(MissionPools.FINAL, [26, 27], "Char", completion_critical=True, or_requirements=True) ] mini_campaign_order = [ - FillMission("no_build", [-1], "Mar Sara", completion_critical=True), - FillMission("easy", [0], "Colonist"), - FillMission("medium", [1], "Colonist"), - FillMission("medium", [0], "Artifact", completion_critical=True), - FillMission("medium", [3], "Artifact", number=4, completion_critical=True), - FillMission("hard", [4], "Artifact", number=8, completion_critical=True), - FillMission("medium", [0], "Covert", number=2), - FillMission("hard", [6], "Covert"), - FillMission("medium", [0], "Rebellion", number=3), - FillMission("hard", [8], "Rebellion"), - FillMission("medium", [4], "Prophecy"), - FillMission("hard", [10], "Prophecy"), - FillMission("hard", [5], "Char", completion_critical=True), - FillMission("hard", [5], "Char", completion_critical=True), - FillMission("all_in", [12, 13], "Char", completion_critical=True, or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [0], "Colonist"), + FillMission(MissionPools.MEDIUM, [1], "Colonist"), + FillMission(MissionPools.EASY, [0], "Artifact", completion_critical=True), + FillMission(MissionPools.MEDIUM, [3], "Artifact", number=4, completion_critical=True), + FillMission(MissionPools.HARD, [4], "Artifact", number=8, completion_critical=True), + FillMission(MissionPools.MEDIUM, [0], "Covert", number=2), + FillMission(MissionPools.HARD, [6], "Covert"), + FillMission(MissionPools.MEDIUM, [0], "Rebellion", number=3), + FillMission(MissionPools.HARD, [8], "Rebellion"), + FillMission(MissionPools.MEDIUM, [4], "Prophecy"), + FillMission(MissionPools.HARD, [10], "Prophecy"), + FillMission(MissionPools.HARD, [5], "Char", completion_critical=True), + FillMission(MissionPools.HARD, [5], "Char", completion_critical=True), + FillMission(MissionPools.FINAL, [12, 13], "Char", completion_critical=True, or_requirements=True) ] gauntlet_order = [ - FillMission("no_build", [-1], "I", completion_critical=True), - FillMission("easy", [0], "II", completion_critical=True), - FillMission("medium", [1], "III", completion_critical=True), - FillMission("medium", [2], "IV", completion_critical=True), - FillMission("hard", [3], "V", completion_critical=True), - FillMission("hard", [4], "VI", completion_critical=True), - FillMission("all_in", [5], "Final", completion_critical=True) + FillMission(MissionPools.STARTER, [-1], "I", completion_critical=True), + FillMission(MissionPools.EASY, [0], "II", completion_critical=True), + FillMission(MissionPools.EASY, [1], "III", completion_critical=True), + FillMission(MissionPools.MEDIUM, [2], "IV", completion_critical=True), + FillMission(MissionPools.MEDIUM, [3], "V", completion_critical=True), + FillMission(MissionPools.HARD, [4], "VI", completion_critical=True), + FillMission(MissionPools.FINAL, [5], "Final", completion_critical=True) ] grid_order = [ - FillMission("no_build", [-1], "_1"), - FillMission("medium", [0], "_1"), - FillMission("medium", [1, 6, 3], "_1", or_requirements=True), - FillMission("hard", [2, 7], "_1", or_requirements=True), - FillMission("easy", [0], "_2"), - FillMission("medium", [1, 4], "_2", or_requirements=True), - FillMission("hard", [2, 5, 10, 7], "_2", or_requirements=True), - FillMission("hard", [3, 6, 11], "_2", or_requirements=True), - FillMission("medium", [4, 9, 12], "_3", or_requirements=True), - FillMission("hard", [5, 8, 10, 13], "_3", or_requirements=True), - FillMission("hard", [6, 9, 11, 14], "_3", or_requirements=True), - FillMission("hard", [7, 10], "_3", or_requirements=True), - FillMission("hard", [8, 13], "_4", or_requirements=True), - FillMission("hard", [9, 12, 14], "_4", or_requirements=True), - FillMission("hard", [10, 13], "_4", or_requirements=True), - FillMission("all_in", [11, 14], "_4", or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "_1"), + FillMission(MissionPools.EASY, [0], "_1"), + FillMission(MissionPools.MEDIUM, [1, 6, 3], "_1", or_requirements=True), + FillMission(MissionPools.HARD, [2, 7], "_1", or_requirements=True), + FillMission(MissionPools.EASY, [0], "_2"), + FillMission(MissionPools.MEDIUM, [1, 4], "_2", or_requirements=True), + FillMission(MissionPools.HARD, [2, 5, 10, 7], "_2", or_requirements=True), + FillMission(MissionPools.HARD, [3, 6, 11], "_2", or_requirements=True), + FillMission(MissionPools.MEDIUM, [4, 9, 12], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [5, 8, 10, 13], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [6, 9, 11, 14], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [7, 10], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [8, 13], "_4", or_requirements=True), + FillMission(MissionPools.HARD, [9, 12, 14], "_4", or_requirements=True), + FillMission(MissionPools.HARD, [10, 13], "_4", or_requirements=True), + FillMission(MissionPools.FINAL, [11, 14], "_4", or_requirements=True) ] mini_grid_order = [ - FillMission("no_build", [-1], "_1"), - FillMission("medium", [0], "_1"), - FillMission("medium", [1, 5], "_1", or_requirements=True), - FillMission("easy", [0], "_2"), - FillMission("medium", [1, 3], "_2", or_requirements=True), - FillMission("hard", [2, 4], "_2", or_requirements=True), - FillMission("medium", [3, 7], "_3", or_requirements=True), - FillMission("hard", [4, 6], "_3", or_requirements=True), - FillMission("all_in", [5, 7], "_3", or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "_1"), + FillMission(MissionPools.EASY, [0], "_1"), + FillMission(MissionPools.MEDIUM, [1, 5], "_1", or_requirements=True), + FillMission(MissionPools.EASY, [0], "_2"), + FillMission(MissionPools.MEDIUM, [1, 3], "_2", or_requirements=True), + FillMission(MissionPools.HARD, [2, 4], "_2", or_requirements=True), + FillMission(MissionPools.MEDIUM, [3, 7], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [4, 6], "_3", or_requirements=True), + FillMission(MissionPools.FINAL, [5, 7], "_3", or_requirements=True) ] blitz_order = [ - FillMission("no_build", [-1], "I"), - FillMission("easy", [-1], "I"), - FillMission("medium", [0, 1], "II", number=1, or_requirements=True), - FillMission("medium", [0, 1], "II", number=1, or_requirements=True), - FillMission("medium", [0, 1], "III", number=2, or_requirements=True), - FillMission("medium", [0, 1], "III", number=2, or_requirements=True), - FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), - FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), - FillMission("hard", [0, 1], "V", number=4, or_requirements=True), - FillMission("hard", [0, 1], "V", number=4, or_requirements=True), - FillMission("hard", [0, 1], "Final", number=5, or_requirements=True), - FillMission("all_in", [0, 1], "Final", number=5, or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "I"), + FillMission(MissionPools.EASY, [-1], "I"), + FillMission(MissionPools.MEDIUM, [0, 1], "II", number=1, or_requirements=True), + FillMission(MissionPools.MEDIUM, [0, 1], "II", number=1, or_requirements=True), + FillMission(MissionPools.MEDIUM, [0, 1], "III", number=2, or_requirements=True), + FillMission(MissionPools.MEDIUM, [0, 1], "III", number=2, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "IV", number=3, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "IV", number=3, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "V", number=4, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "V", number=4, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "Final", number=5, or_requirements=True), + FillMission(MissionPools.FINAL, [0, 1], "Final", number=5, or_requirements=True) ] mission_orders = [vanilla_shuffle_order, vanilla_shuffle_order, mini_campaign_order, grid_order, mini_grid_order, blitz_order, gauntlet_order] @@ -176,40 +182,21 @@ vanilla_mission_req_table = { lookup_id_to_mission: Dict[int, str] = { data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} -no_build_starting_mission_locations = { +starting_mission_locations = { "Liberation Day": "Liberation Day: Victory", "Breakout": "Breakout: Victory", "Ghost of a Chance": "Ghost of a Chance: Victory", "Piercing the Shroud": "Piercing the Shroud: Victory", "Whispers of Doom": "Whispers of Doom: Victory", "Belly of the Beast": "Belly of the Beast: Victory", -} - -build_starting_mission_locations = { "Zero Hour": "Zero Hour: First Group Rescued", "Evacuation": "Evacuation: First Chysalis", - "Devil's Playground": "Devil's Playground: Tosh's Miners" -} - -advanced_starting_mission_locations = { + "Devil's Playground": "Devil's Playground: Tosh's Miners", "Smash and Grab": "Smash and Grab: First Relic", "The Great Train Robbery": "The Great Train Robbery: North Defiler" } -def get_starting_mission_locations(multiworld: MultiWorld, player: int) -> Set[str]: - if get_option_value(multiworld, player, 'shuffle_no_build') or get_option_value(multiworld, player, 'mission_order') < 2: - # Always start with a no-build mission unless explicitly relegating them - # Vanilla and Vanilla Shuffled always start with a no-build even when relegated - return no_build_starting_mission_locations - elif get_option_value(multiworld, player, 'required_tactics') > 0: - # Advanced Tactics/No Logic add more starting missions to the pool - return {**build_starting_mission_locations, **advanced_starting_mission_locations} - else: - # Standard starting missions when relegate is on - return build_starting_mission_locations - - alt_final_mission_locations = { "Maw of the Void": "Maw of the Void: Victory", "Engine of Destruction": "Engine of Destruction: Victory", diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index 4526328f53..4f2032d662 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -1,6 +1,7 @@ -from typing import Dict +from typing import Dict, FrozenSet, Union from BaseClasses import MultiWorld from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range +from .MissionTables import vanilla_mission_req_table class GameDifficulty(Choice): @@ -110,6 +111,7 @@ class ExcludedMissions(OptionSet): Only applies on shortened mission orders. It may be impossible to build a valid campaign if too many missions are excluded.""" display_name = "Excluded Missions" + valid_keys = {mission_name for mission_name in vanilla_mission_req_table.keys() if mission_name != 'All-In'} # noinspection PyTypeChecker @@ -130,19 +132,10 @@ sc2wol_options: Dict[str, Option] = { } -def get_option_value(multiworld: MultiWorld, player: int, name: str) -> int: - option = getattr(multiworld, name, None) +def get_option_value(multiworld: MultiWorld, player: int, name: str) -> Union[int, FrozenSet]: + if multiworld is None: + return sc2wol_options[name].default - if option is None: - return 0 + player_option = getattr(multiworld, name)[player] - return int(option[player].value) - - -def get_option_set_value(multiworld: MultiWorld, player: int, name: str) -> set: - option = getattr(multiworld, name, None) - - if option is None: - return set() - - return option[player].value + return player_option.value diff --git a/worlds/sc2wol/PoolFilter.py b/worlds/sc2wol/PoolFilter.py index c4aa1098bb..16cc51f243 100644 --- a/worlds/sc2wol/PoolFilter.py +++ b/worlds/sc2wol/PoolFilter.py @@ -2,8 +2,8 @@ from typing import Callable, Dict, List, Set from BaseClasses import MultiWorld, ItemClassification, Item, Location from .Items import item_table from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ - mission_orders, get_starting_mission_locations, MissionInfo, vanilla_mission_req_table, alt_final_mission_locations -from .Options import get_option_value, get_option_set_value + mission_orders, MissionInfo, alt_final_mission_locations, MissionPools +from .Options import get_option_value from .LogicMixin import SC2WoLLogic # Items with associated upgrades @@ -21,34 +21,33 @@ STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "He PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} -def filter_missions(multiworld: MultiWorld, player: int) -> Dict[str, List[str]]: +def filter_missions(multiworld: MultiWorld, player: int) -> Dict[int, List[str]]: """ Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets """ mission_order_type = get_option_value(multiworld, player, "mission_order") + shuffle_no_build = get_option_value(multiworld, player, "shuffle_no_build") shuffle_protoss = get_option_value(multiworld, player, "shuffle_protoss") - excluded_missions = set(get_option_set_value(multiworld, player, "excluded_missions")) - invalid_mission_names = excluded_missions.difference(vanilla_mission_req_table.keys()) - if invalid_mission_names: - raise Exception("Error in locked_missions - the following are not valid mission names: " + ", ".join(invalid_mission_names)) + excluded_missions = get_option_value(multiworld, player, "excluded_missions") mission_count = len(mission_orders[mission_order_type]) - 1 - # Vanilla and Vanilla Shuffled use the entire mission pool - if mission_count == 28: - return { - "no_build": no_build_regions_list[:], - "easy": easy_regions_list[:], - "medium": medium_regions_list[:], - "hard": hard_regions_list[:], - "all_in": ["All-In"] - } - - mission_pools = [ - [], - easy_regions_list, - medium_regions_list, - hard_regions_list - ] + mission_pools = { + MissionPools.STARTER: no_build_regions_list[:], + MissionPools.EASY: easy_regions_list[:], + MissionPools.MEDIUM: medium_regions_list[:], + MissionPools.HARD: hard_regions_list[:], + MissionPools.FINAL: [] + } + if mission_order_type == 0: + # Vanilla uses the entire mission pool + mission_pools[MissionPools.FINAL] = ['All-In'] + return mission_pools + elif mission_order_type == 1: + # Vanilla Shuffled ignores the player-provided excluded missions + excluded_missions = set() + # Omitting No-Build missions if not shuffling no-build + if not shuffle_no_build: + excluded_missions = excluded_missions.union(no_build_regions_list) # Omitting Protoss missions if not shuffling protoss if not shuffle_protoss: excluded_missions = excluded_missions.union(PROTOSS_REGIONS) @@ -58,46 +57,35 @@ def filter_missions(multiworld: MultiWorld, player: int) -> Dict[str, List[str]] excluded_missions.add(final_mission) else: final_mission = 'All-In' - # Yaml settings determine which missions can be placed in the first slot - mission_pools[0] = [mission for mission in get_starting_mission_locations(multiworld, player).keys() if mission not in excluded_missions] - # Removing the new no-build missions from their original sets - for i in range(1, len(mission_pools)): - mission_pools[i] = [mission for mission in mission_pools[i] if mission not in excluded_missions.union(mission_pools[0])] - # If the first mission is a build mission, there may not be enough locations to reach Outbreak as a second mission + # Excluding missions + for difficulty, mission_pool in mission_pools.items(): + mission_pools[difficulty] = [mission for mission in mission_pool if mission not in excluded_missions] + mission_pools[MissionPools.FINAL].append(final_mission) + # Mission pool changes on Build-Only if not get_option_value(multiworld, player, 'shuffle_no_build'): - # Swapping Outbreak and The Great Train Robbery - if "Outbreak" in mission_pools[1]: - mission_pools[1].remove("Outbreak") - mission_pools[2].append("Outbreak") - if "The Great Train Robbery" in mission_pools[2]: - mission_pools[2].remove("The Great Train Robbery") - mission_pools[1].append("The Great Train Robbery") - # Removing random missions from each difficulty set in a cycle - set_cycle = 0 - current_count = sum(len(mission_pool) for mission_pool in mission_pools) + def move_mission(mission_name, current_pool, new_pool): + if mission_name in mission_pools[current_pool]: + mission_pools[current_pool].remove(mission_name) + mission_pools[new_pool].append(mission_name) + # Replacing No Build missions with Easy missions + move_mission("Zero Hour", MissionPools.EASY, MissionPools.STARTER) + move_mission("Evacuation", MissionPools.EASY, MissionPools.STARTER) + move_mission("Devil's Playground", MissionPools.EASY, MissionPools.STARTER) + # Pushing Outbreak to Normal, as it cannot be placed as the second mission on Build-Only + move_mission("Outbreak", MissionPools.EASY, MissionPools.MEDIUM) + # Pushing extra Normal missions to Easy + move_mission("The Great Train Robbery", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Echoes of the Future", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Cutthroat", MissionPools.MEDIUM, MissionPools.EASY) + # Additional changes on Advanced Tactics + if get_option_value(multiworld, player, "required_tactics") > 0: + move_mission("The Great Train Robbery", MissionPools.EASY, MissionPools.STARTER) + move_mission("Smash and Grab", MissionPools.EASY, MissionPools.STARTER) + move_mission("Moebius Factor", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Welcome to the Jungle", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Engine of Destruction", MissionPools.HARD, MissionPools.MEDIUM) - if current_count < mission_count: - raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") - while current_count > mission_count: - if set_cycle == 4: - set_cycle = 0 - # Must contain at least one mission per set - mission_pool = mission_pools[set_cycle] - if len(mission_pool) <= 1: - if all(len(mission_pool) <= 1 for mission_pool in mission_pools): - raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") - else: - mission_pool.remove(multiworld.random.choice(mission_pool)) - current_count -= 1 - set_cycle += 1 - - return { - "no_build": mission_pools[0], - "easy": mission_pools[1], - "medium": mission_pools[2], - "hard": mission_pools[3], - "all_in": [final_mission] - } + return mission_pools def get_item_upgrades(inventory: List[Item], parent_item: Item or str): @@ -135,7 +123,21 @@ class ValidInventory: requirements = mission_requirements cascade_keys = self.cascade_removal_map.keys() units_always_have_upgrades = get_option_value(self.multiworld, self.player, "units_always_have_upgrades") - if self.min_units_per_structure > 0: + + # Locking associated items for items that have already been placed when units_always_have_upgrades is on + if units_always_have_upgrades: + existing_items = self.existing_items[:] + while existing_items: + existing_item = existing_items.pop() + items_to_lock = self.cascade_removal_map.get(existing_item, [existing_item]) + for item in items_to_lock: + if item in inventory: + inventory.remove(item) + locked_items.append(item) + if item in existing_items: + existing_items.remove(item) + + if self.min_units_per_structure > 0 and self.has_units_per_structure(): requirements.append(lambda state: state.has_units_per_structure()) def attempt_removal(item: Item) -> bool: @@ -151,6 +153,10 @@ class ValidInventory: return False return True + # Determining if the full-size inventory can complete campaign + if not all(requirement(self) for requirement in requirements): + raise Exception("Too many items excluded - campaign is impossible to complete.") + while len(inventory) + len(locked_items) > inventory_size: if len(inventory) == 0: raise Exception("Reduced item pool generation failed - not enough locations available to place items.") diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index bcf6434aa5..033636662b 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -2,7 +2,7 @@ from typing import List, Set, Dict, Tuple, Optional, Callable from BaseClasses import MultiWorld, Region, Entrance, Location from .Locations import LocationData from .Options import get_option_value -from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations +from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations, MissionPools from .PoolFilter import filter_missions @@ -14,34 +14,18 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio mission_order = mission_orders[mission_order_type] mission_pools = filter_missions(multiworld, player) - final_mission = mission_pools['all_in'][0] - used_regions = [mission for mission_pool in mission_pools.values() for mission in mission_pool] regions = [create_region(multiworld, player, locations_per_region, location_cache, "Menu")] - for region_name in used_regions: - regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name)) - # Changing the completion condition for alternate final missions into an event - if final_mission != 'All-In': - final_location = alt_final_mission_locations[final_mission] - # Final location should be near the end of the cache - for i in range(len(location_cache) - 1, -1, -1): - if location_cache[i].name == final_location: - location_cache[i].locked = True - location_cache[i].event = True - location_cache[i].address = None - break - else: - final_location = 'All-In: Victory' - - if __debug__: - if mission_order_type in (0, 1): - throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) - - multiworld.regions += regions names: Dict[str, int] = {} if mission_order_type == 0: + + # Generating all regions and locations + for region_name in vanilla_mission_req_table.keys(): + regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name)) + multiworld.regions += regions + connect(multiworld, player, names, 'Menu', 'Liberation Day'), connect(multiworld, player, names, 'Liberation Day', 'The Outlaws', lambda state: state.has("Beat Liberation Day", player)), @@ -110,31 +94,32 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio lambda state: state.has('Beat Gates of Hell', player) and ( state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) - return vanilla_mission_req_table, 29, final_location + return vanilla_mission_req_table, 29, 'All-In: Victory' else: missions = [] + remove_prophecy = mission_order_type == 1 and not get_option_value(multiworld, player, "shuffle_protoss") + + final_mission = mission_pools[MissionPools.FINAL][0] + + # Determining if missions must be removed + mission_pool_size = sum(len(mission_pool) for mission_pool in mission_pools.values()) + removals = len(mission_order) - mission_pool_size + # Removing entire Prophecy chain on vanilla shuffled when not shuffling protoss + if remove_prophecy: + removals -= 4 + # Initial fill out of mission list and marking all-in mission for mission in mission_order: - if mission is None: + # Removing extra missions if mission pool is too small + if 0 < mission.removal_priority <= removals or mission.category == 'Prophecy' and remove_prophecy: missions.append(None) - elif mission.type == "all_in": + elif mission.type == MissionPools.FINAL: missions.append(final_mission) - elif mission.relegate and not get_option_value(multiworld, player, "shuffle_no_build"): - missions.append("no_build") else: missions.append(mission.type) - # Place Protoss Missions if we are not using ShuffleProtoss and are in Vanilla Shuffled - if get_option_value(multiworld, player, "shuffle_protoss") == 0 and mission_order_type == 1: - missions[22] = "A Sinister Turn" - mission_pools['medium'].remove("A Sinister Turn") - missions[23] = "Echoes of the Future" - mission_pools['medium'].remove("Echoes of the Future") - missions[24] = "In Utter Darkness" - mission_pools['hard'].remove("In Utter Darkness") - no_build_slots = [] easy_slots = [] medium_slots = [] @@ -144,79 +129,108 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio for i in range(len(missions)): if missions[i] is None: continue - if missions[i] == "no_build": + if missions[i] == MissionPools.STARTER: no_build_slots.append(i) - elif missions[i] == "easy": + elif missions[i] == MissionPools.EASY: easy_slots.append(i) - elif missions[i] == "medium": + elif missions[i] == MissionPools.MEDIUM: medium_slots.append(i) - elif missions[i] == "hard": + elif missions[i] == MissionPools.HARD: hard_slots.append(i) # Add no_build missions to the pool and fill in no_build slots - missions_to_add = mission_pools['no_build'] + missions_to_add = mission_pools[MissionPools.STARTER] + if len(no_build_slots) > len(missions_to_add): + raise Exception("There are no valid No-Build missions. Please exclude fewer missions.") for slot in no_build_slots: filler = multiworld.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add easy missions into pool and fill in easy slots - missions_to_add = missions_to_add + mission_pools['easy'] + missions_to_add = missions_to_add + mission_pools[MissionPools.EASY] + if len(easy_slots) > len(missions_to_add): + raise Exception("There are not enough Easy missions to fill the campaign. Please exclude fewer missions.") for slot in easy_slots: filler = multiworld.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add medium missions into pool and fill in medium slots - missions_to_add = missions_to_add + mission_pools['medium'] + missions_to_add = missions_to_add + mission_pools[MissionPools.MEDIUM] + if len(medium_slots) > len(missions_to_add): + raise Exception("There are not enough Easy and Medium missions to fill the campaign. Please exclude fewer missions.") for slot in medium_slots: filler = multiworld.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add hard missions into pool and fill in hard slots - missions_to_add = missions_to_add + mission_pools['hard'] + missions_to_add = missions_to_add + mission_pools[MissionPools.HARD] + if len(hard_slots) > len(missions_to_add): + raise Exception("There are not enough missions to fill the campaign. Please exclude fewer missions.") for slot in hard_slots: filler = multiworld.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) + # Generating regions and locations from selected missions + for region_name in missions: + regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name)) + multiworld.regions += regions + + # Mapping original mission slots to shifted mission slots when missions are removed + slot_map = [] + slot_offset = 0 + for position, mission in enumerate(missions): + slot_map.append(position - slot_offset + 1) + if mission is None: + slot_offset += 1 + # Loop through missions to create requirements table and connect regions # TODO: Handle 'and' connections mission_req_table = {} - for i in range(len(missions)): + + for i, mission in enumerate(missions): + if mission is None: + continue connections = [] for connection in mission_order[i].connect_to: + required_mission = missions[connection] if connection == -1: - connect(multiworld, player, names, "Menu", missions[i]) + connect(multiworld, player, names, "Menu", mission) + elif required_mission is None: + continue else: - connect(multiworld, player, names, missions[connection], missions[i], + connect(multiworld, player, names, required_mission, mission, (lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and state._sc2wol_cleared_missions(multiworld, player, missions_req))) (missions[connection], mission_order[i].number)) - connections.append(connection + 1) + connections.append(slot_map[connection]) - mission_req_table.update({missions[i]: MissionInfo( - vanilla_mission_req_table[missions[i]].id, connections, mission_order[i].category, + mission_req_table.update({mission: MissionInfo( + vanilla_mission_req_table[mission].id, connections, mission_order[i].category, number=mission_order[i].number, completion_critical=mission_order[i].completion_critical, or_requirements=mission_order[i].or_requirements)}) final_mission_id = vanilla_mission_req_table[final_mission].id - return mission_req_table, final_mission_id, final_mission + ': Victory' + # Changing the completion condition for alternate final missions into an event + if final_mission != 'All-In': + final_location = alt_final_mission_locations[final_mission] + # Final location should be near the end of the cache + for i in range(len(location_cache) - 1, -1, -1): + if location_cache[i].name == final_location: + location_cache[i].locked = True + location_cache[i].event = True + location_cache[i].address = None + break + else: + final_location = 'All-In: Victory' -def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): - existingRegions = set() - - for region in regions: - existingRegions.add(region.name) - - if (regionNames - existingRegions): - raise Exception("Starcraft: the following regions are used in locations: {}, but no such region exists".format( - regionNames - existingRegions)) - + return mission_req_table, final_mission_id, final_location def create_location(player: int, location_data: LocationData, region: Region, location_cache: List[Location]) -> Location: diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 878f3882dc..60de200804 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -7,10 +7,10 @@ from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_basic_units from .Locations import get_locations from .Regions import create_regions -from .Options import sc2wol_options, get_option_value, get_option_set_value +from .Options import sc2wol_options, get_option_value from .LogicMixin import SC2WoLLogic from .PoolFilter import filter_missions, filter_items, get_item_upgrades -from .MissionTables import get_starting_mission_locations, MissionInfo +from .MissionTables import starting_mission_locations, MissionInfo class Starcraft2WoLWebWorld(WebWorld): @@ -137,7 +137,6 @@ def assign_starter_items(multiworld: MultiWorld, player: int, excluded_items: Se # The first world should also be the starting world first_mission = list(multiworld.worlds[player].mission_req_table)[0] - starting_mission_locations = get_starting_mission_locations(multiworld, player) if first_mission in starting_mission_locations: first_location = starting_mission_locations[first_mission] elif first_mission == "In Utter Darkness": @@ -174,7 +173,7 @@ def get_item_pool(multiworld: MultiWorld, player: int, mission_req_table: Dict[s locked_items = [] # YAML items - yaml_locked_items = get_option_set_value(multiworld, player, 'locked_items') + yaml_locked_items = get_option_value(multiworld, player, 'locked_items') for name, data in item_table.items(): if name not in excluded_items: diff --git a/worlds/sm64ex/docs/setup_en.md b/worlds/sm64ex/docs/setup_en.md index acf9432fe5..1ff8b5e938 100644 --- a/worlds/sm64ex/docs/setup_en.md +++ b/worlds/sm64ex/docs/setup_en.md @@ -67,6 +67,62 @@ Failing to use a new file may make some locations unavailable. However, this can To play offline, first generate a seed on the game's settings page. Create a room and download the `.apsm64ex` file, and start the game with the `--sm64ap_file "path/to/FileName"` launch argument. +# Optional: Using Batch Files to play offline and MultiWorld games + +As an alternative to launching the game with sm64pclauncher, it is also possible to launch the completed build with the use of Windows batch files. This has the added benefit of streamlining the join process so that manual editing of connection info is not needed for each new game. However, you'll need to be somewhat comfortable with creating and using batch files. + +IMPORTANT NOTE: The remainder of this section uses copy-and-paste code that assumes you're using the US version. If you instead use the Japanese version, you'll need to edit the EXE name accordingly by changing "sm64.us.f3dex2e.exe" to "sm64.jp.f3dex2e.exe". + +### Making an offline.bat for launching offline patch files + +Open Notepad. Paste in the following text: `start sm64.us.f3dex2e.exe --sm64ap_file %1` + +Go to File > Save As... + +Navigate to the folder you selected for your SM64 build when you followed the Build guide for SM64PCLauncher earlier. Once there, navigate further into `build` and then `us_pc`. This folder should be the same folder that `sm64.us.f3dex2e.exe` resides in. + +Make the file name `"offline.bat"` . THE QUOTE MARKS ARE IMPORTANT! Otherwise, it will create a text file instead ("offline.bat.txt"), which won't work as a batch file. + +Now you should have a file called `offline.bat` with a gear icon in the same folder as your "sm64.us.f3dex2e.exe". Right click `offline.bat` and choose `Send To > Desktop (Create Shortcut)`. +- If the icon for this file is a notepad rather than a gear, you saved it as a .txt file on accident. To fix this, change the file extension to .bat. + +From now on, whenever you start an offline, single-player game, just download the `.apsm64ex` patch file from the Generator, then drag-and-drop that onto `offline.bat` to open the game and start playing. + +NOTE: When playing offline patch files, a `.save` file is created in the same directory as your patch file, which contains your save data for that seed. Don't delete it until you're done with that seed. + +### Making an online.bat for launching online Multiworld games + +These steps are very similar. You will be making a batch file in the same location as before. However, the text you put into this batch file is different, and you will not drag patch files onto it. + +Use the same steps as before to open Notepad and paste in the following: + +`set /p port="Enter port number of room - "` + +`set /p slot="Enter slot name - "` + +`start sm64.us.f3dex2e.exe --sm64ap_name "%slot%" --sm64ap_ip archipelago.gg:%port%` + +Save this file as `"online.bat"`, then create a shortcut by following the same steps as before. + +To use this batch file, double-click it. A window will open. Type the five-digit port number of the room you wish to join, then type your slot name. +- The port number is provided on the room page. The game host should share this page with all players. +- The slot name is whatever you typed in the "Name" field when creating a config file. All slot names are visible on the room page. + +Once you provide those two bits of information, the game will open. If the info is correct, when the game starts, you will see "Connected to Archipelago" on the bottom of your screen, and you will be able to enter the castle. +- If you don't see this text and crash upon entering the castle, try again. Double-check the port number and slot name; even a single typo will cause your connection to fail. + +### Addendum - Deleting old saves + +Loading an old Mario save alongside a new seed is a bad idea, as it can cause locked doors and castle secret stars to already be unlocked / obtained. You should avoid opening a save that says "Stars x 0" as opposed to one that simply says "New". + +You can manually delete these old saves in-game before starting a new game, but that can be tedious. With a small edit to the batch files, you can delete these old saves automatically. Just add the line `del %AppData%\sm64ex\*.bin` to the batch file, above the `start` command. For example, here is `offline.bat` with the additional line: + +`del %AppData%\sm64ex\*.bin` + +`start sm64.us.f3dex2e.exe --sm64ap_file %1` + +This extra line deletes any previous save data before opening the game. Don't worry about lost stars or checks - the AP server (or in the case of offline, the `.save` file) keeps track of your star count, unlocked keys/caps/cannons, and which locations have already been checked, so you won't have to redo them. At worst you'll have to rewatch the door unlocking animations, and catch the rabbit Mips twice for his first star again if you haven't yet collected the second one. + ## Installation Troubleshooting Start the game from the command line to view helpful messages regarding SM64EX. diff --git a/worlds/smw/Aesthetics.py b/worlds/smw/Aesthetics.py index fb53295de1..73ca616508 100644 --- a/worlds/smw/Aesthetics.py +++ b/worlds/smw/Aesthetics.py @@ -149,9 +149,9 @@ def generate_shuffled_level_music(world, player): shuffled_level_music = level_music_value_data.copy() if world.music_shuffle[player] == "consistent": - world.random.shuffle(shuffled_level_music) + world.per_slot_randoms[player].shuffle(shuffled_level_music) elif world.music_shuffle[player] == "singularity": - single_song = world.random.choice(shuffled_level_music) + single_song = world.per_slot_randoms[player].choice(shuffled_level_music) shuffled_level_music = [single_song for i in range(len(shuffled_level_music))] return shuffled_level_music @@ -160,9 +160,9 @@ def generate_shuffled_ow_music(world, player): shuffled_ow_music = ow_music_value_data.copy() if world.music_shuffle[player] == "consistent" or world.music_shuffle[player] == "full": - world.random.shuffle(shuffled_ow_music) + world.per_slot_randoms[player].shuffle(shuffled_ow_music) elif world.music_shuffle[player] == "singularity": - single_song = world.random.choice(shuffled_ow_music) + single_song = world.per_slot_randoms[player].choice(shuffled_ow_music) shuffled_ow_music = [single_song for i in range(len(shuffled_ow_music))] return shuffled_ow_music @@ -170,7 +170,7 @@ def generate_shuffled_ow_music(world, player): def generate_shuffled_ow_palettes(rom, world, player): if world.overworld_palette_shuffle[player]: for address, valid_palettes in valid_ow_palettes.items(): - chosen_palette = world.random.choice(valid_palettes) + chosen_palette = world.per_slot_randoms[player].choice(valid_palettes) rom.write_byte(address, chosen_palette) def generate_shuffled_header_data(rom, world, player): @@ -196,11 +196,11 @@ def generate_shuffled_header_data(rom, world, player): if world.music_shuffle[player] == "full": level_header[2] &= 0x8F - level_header[2] |= (world.random.randint(0, 7) << 5) + level_header[2] |= (world.per_slot_randoms[player].randint(0, 7) << 5) if (world.foreground_palette_shuffle[player] and tileset in valid_foreground_palettes): level_header[3] &= 0xF8 - level_header[3] |= world.random.choice(valid_foreground_palettes[tileset]) + level_header[3] |= world.per_slot_randoms[player].choice(valid_foreground_palettes[tileset]) if world.background_palette_shuffle[player]: layer2_ptr_list = list(rom.read_bytes(0x2E600 + level_id * 3, 3)) @@ -208,10 +208,10 @@ def generate_shuffled_header_data(rom, world, player): if layer2_ptr in valid_background_palettes: level_header[0] &= 0x1F - level_header[0] |= (world.random.choice(valid_background_palettes[layer2_ptr]) << 5) + level_header[0] |= (world.per_slot_randoms[player].choice(valid_background_palettes[layer2_ptr]) << 5) if layer2_ptr in valid_background_colors: level_header[1] &= 0x1F - level_header[1] |= (world.random.choice(valid_background_colors[layer2_ptr]) << 5) + level_header[1] |= (world.per_slot_randoms[player].choice(valid_background_colors[layer2_ptr]) << 5) rom.write_bytes(layer1_ptr, bytes(level_header)) diff --git a/worlds/smw/Rom.py b/worlds/smw/Rom.py index 3a982fce26..ffd8923786 100644 --- a/worlds/smw/Rom.py +++ b/worlds/smw/Rom.py @@ -725,7 +725,7 @@ def handle_swap_donut_gh_exits(rom): def handle_bowser_rooms(rom, world, player: int): if world.bowser_castle_rooms[player] == "random_two_room": - chosen_rooms = world.random.sample(standard_bowser_rooms, 2) + chosen_rooms = world.per_slot_randoms[player].sample(standard_bowser_rooms, 2) rom.write_byte(0x3A680, chosen_rooms[0].roomID) rom.write_byte(0x3A684, chosen_rooms[0].roomID) @@ -738,7 +738,7 @@ def handle_bowser_rooms(rom, world, player: int): rom.write_byte(chosen_rooms[len(chosen_rooms)-1].exitAddress, 0xBD) elif world.bowser_castle_rooms[player] == "random_five_room": - chosen_rooms = world.random.sample(standard_bowser_rooms, 5) + chosen_rooms = world.per_slot_randoms[player].sample(standard_bowser_rooms, 5) rom.write_byte(0x3A680, chosen_rooms[0].roomID) rom.write_byte(0x3A684, chosen_rooms[0].roomID) @@ -752,7 +752,7 @@ def handle_bowser_rooms(rom, world, player: int): elif world.bowser_castle_rooms[player] == "gauntlet": chosen_rooms = standard_bowser_rooms.copy() - world.random.shuffle(chosen_rooms) + world.per_slot_randoms[player].shuffle(chosen_rooms) rom.write_byte(0x3A680, chosen_rooms[0].roomID) rom.write_byte(0x3A684, chosen_rooms[0].roomID) @@ -768,7 +768,7 @@ def handle_bowser_rooms(rom, world, player: int): entrance_point = bowser_rooms_copy.pop(0) - world.random.shuffle(bowser_rooms_copy) + world.per_slot_randoms[player].shuffle(bowser_rooms_copy) rom.write_byte(entrance_point.exitAddress, bowser_rooms_copy[0].roomID) for i in range(0, len(bowser_rooms_copy) - 1): @@ -782,8 +782,8 @@ def handle_boss_shuffle(rom, world, player): submap_boss_rooms_copy = submap_boss_rooms.copy() ow_boss_rooms_copy = ow_boss_rooms.copy() - world.random.shuffle(submap_boss_rooms_copy) - world.random.shuffle(ow_boss_rooms_copy) + world.per_slot_randoms[player].shuffle(submap_boss_rooms_copy) + world.per_slot_randoms[player].shuffle(ow_boss_rooms_copy) for i in range(len(submap_boss_rooms_copy)): rom.write_byte(submap_boss_rooms[i].exitAddress, submap_boss_rooms_copy[i].roomID) @@ -796,19 +796,19 @@ def handle_boss_shuffle(rom, world, player): elif world.boss_shuffle[player] == "full": for i in range(len(submap_boss_rooms)): - chosen_boss = world.random.choice(submap_boss_rooms) + chosen_boss = world.per_slot_randoms[player].choice(submap_boss_rooms) rom.write_byte(submap_boss_rooms[i].exitAddress, chosen_boss.roomID) for i in range(len(ow_boss_rooms)): - chosen_boss = world.random.choice(ow_boss_rooms) + chosen_boss = world.per_slot_randoms[player].choice(ow_boss_rooms) rom.write_byte(ow_boss_rooms[i].exitAddress, chosen_boss.roomID) if ow_boss_rooms[i].exitAddressAlt is not None: rom.write_byte(ow_boss_rooms[i].exitAddressAlt, chosen_boss.roomID) elif world.boss_shuffle[player] == "singularity": - chosen_submap_boss = world.random.choice(submap_boss_rooms) - chosen_ow_boss = world.random.choice(ow_boss_rooms) + chosen_submap_boss = world.per_slot_randoms[player].choice(submap_boss_rooms) + chosen_ow_boss = world.per_slot_randoms[player].choice(ow_boss_rooms) for i in range(len(submap_boss_rooms)): rom.write_byte(submap_boss_rooms[i].exitAddress, chosen_submap_boss.roomID) @@ -821,8 +821,6 @@ def handle_boss_shuffle(rom, world, player): def patch_rom(world, rom, player, active_level_dict): - local_random = world.per_slot_randoms[player] - goal_text = generate_goal_text(world, player) rom.write_bytes(0x2A6E2, goal_text) @@ -973,5 +971,5 @@ def get_base_rom_path(file_name: str = "") -> str: if not file_name: file_name = options["smw_options"]["rom_file"] if not os.path.exists(file_name): - file_name = Utils.local_path(file_name) + file_name = Utils.user_path(file_name) return file_name diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 93ac7fbddf..5e73f5db0c 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -509,7 +509,7 @@ class SMZ3World(World): return self.smz3DungeonItems else: return [] - + def post_fill(self): # some small or big keys (those always_allow) can be unreachable in-game # while logic still collects some of them (probably to simulate the player collecting pot keys in the logic), some others don't @@ -524,7 +524,7 @@ class SMZ3World(World): loc.item.classification = ItemClassification.filler loc.item.item.Progression = False loc.item.location.event = False - self.unreachable.append(loc) + self.unreachable.append(loc) def get_filler_item_name(self) -> str: return self.multiworld.random.choice(self.junkItemsNames) diff --git a/worlds/smz3/data/zsm.ips b/worlds/smz3/data/zsm.ips index f2ecadf8ac..fff36d95d1 100644 Binary files a/worlds/smz3/data/zsm.ips and b/worlds/smz3/data/zsm.ips differ diff --git a/worlds/spire/Options.py b/worlds/spire/Options.py index 1711e12deb..76cbc4cf37 100644 --- a/worlds/spire/Options.py +++ b/worlds/spire/Options.py @@ -1,15 +1,34 @@ import typing -from Options import Choice, Option, Range, Toggle +from Options import TextChoice, Option, Range, Toggle -class Character(Choice): - """Pick What Character you wish to play with.""" +class Character(TextChoice): + """Enter the internal ID of the character to use. + + if you don't know the exact ID to enter with the mod installed go to + `Mods -> Archipelago Multi-world -> config` to view a list of installed modded character IDs. + + the downfall characters will only work if you have downfall installed. + + Spire Take the Wheel will have your client pick a random character from the list of all your installed characters + including custom ones. + + if the chosen character mod is not installed it will default back to 'The Ironclad' + """ display_name = "Character" - option_ironclad = 0 - option_silent = 1 - option_defect = 2 - option_watcher = 3 - default = 0 + option_The_Ironclad = 0 + option_The_Silent = 1 + option_The_Defect = 2 + option_The_Watcher = 3 + option_The_Hermit = 4 + option_The_Slime_Boss = 5 + option_The_Guardian = 6 + option_The_Hexaghost = 7 + option_The_Champ = 8 + option_The_Gremlins = 9 + option_The_Automaton = 10 + option_The_Snecko = 11 + option_spire_take_the_wheel = 12 class Ascension(Range): @@ -20,10 +39,17 @@ class Ascension(Range): default = 0 -class HeartRun(Toggle): - """Whether or not you will need to collect the 3 keys and enter the final act to - complete the game. The Heart does not need to be defeated.""" - display_name = "Heart Run" +class FinalAct(Toggle): + """Whether you will need to collect the 3 keys and beat the final act to complete the game.""" + display_name = "Final Act" + option_true = 1 + option_false = 0 + default = 0 + + +class Downfall(Toggle): + """When Downfall is Installed this will switch the played mode to Downfall""" + display_name = "Downfall" option_true = 1 option_false = 0 default = 0 @@ -32,5 +58,6 @@ class HeartRun(Toggle): spire_options: typing.Dict[str, type(Option)] = { "character": Character, "ascension": Ascension, - "heart_run": HeartRun + "final_act": FinalAct, + "downfall": Downfall, } diff --git a/worlds/spire/__init__.py b/worlds/spire/__init__.py index 5a7ed19ecf..a9f4d46d70 100644 --- a/worlds/spire/__init__.py +++ b/worlds/spire/__init__.py @@ -32,18 +32,11 @@ class SpireWorld(World): topology_present = False data_version = 1 web = SpireWeb() + required_client_version = (0, 3, 7) item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = location_table - def _get_slot_data(self): - return { - 'seed': "".join(self.multiworld.per_slot_randoms[self.player].choice(string.ascii_letters) for i in range(16)), - 'character': self.multiworld.character[self.player], - 'ascension': self.multiworld.ascension[self.player], - 'heart_run': self.multiworld.heart_run[self.player] - } - def generate_basic(self): # Fill out our pool with our items from item_pool, assuming 1 item if not present in item_pool pool = [] @@ -63,7 +56,6 @@ class SpireWorld(World): if self.multiworld.logic[self.player] != 'no logic': self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) - def set_rules(self): set_rules(self.multiworld, self.player) @@ -74,10 +66,12 @@ class SpireWorld(World): create_regions(self.multiworld, self.player) def fill_slot_data(self) -> dict: - slot_data = self._get_slot_data() + slot_data = { + 'seed': "".join(self.multiworld.slot_seeds[self.player].choice(string.ascii_letters) for i in range(16)) + } for option_name in spire_options: option = getattr(self.multiworld, option_name)[self.player] - slot_data[option_name] = int(option.value) + slot_data[option_name] = option.value return slot_data def get_filler_item_name(self) -> str: diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py new file mode 100644 index 0000000000..306a3ec7e0 --- /dev/null +++ b/worlds/stardew_valley/__init__.py @@ -0,0 +1,188 @@ +from typing import Dict, Any, Iterable, Optional, Union + +from BaseClasses import Region, Entrance, Location, Item, Tutorial +from worlds.AutoWorld import World, WebWorld +from . import rules, logic, options +from .bundles import get_all_bundles, Bundle +from .items import item_table, create_items, ItemData, Group +from .locations import location_table, create_locations, LocationData +from .logic import StardewLogic, StardewRule, _True, _And +from .options import stardew_valley_options, StardewOptions, fetch_options +from .regions import create_regions +from .rules import set_rules + +client_version = 0 + + +class StardewLocation(Location): + game: str = "Stardew Valley" + + def __init__(self, player: int, name: str, address: Optional[int], parent=None): + super().__init__(player, name, address, parent) + self.event = not address + + +class StardewItem(Item): + game: str = "Stardew Valley" + + +class StardewWebWorld(WebWorld): + theme = "dirt" + bug_report_page = "https://github.com/agilbert1412/StardewArchipelago/issues/new?labels=bug&title=%5BBug%5D%3A+Brief+Description+of+bug+here" + + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to playing Stardew Valley with Archipelago.", + "English", + "setup_en.md", + "setup/en", + ["KaitoKid", "Jouramie"] + )] + + +class StardewValleyWorld(World): + """ + Stardew Valley farming simulator game where the objective is basically to spend the least possible time on your farm. + """ + game = "Stardew Valley" + option_definitions = stardew_valley_options + topology_present = False + + item_name_to_id = {name: data.code for name, data in item_table.items()} + location_name_to_id = {name: data.code for name, data in location_table.items()} + + data_version = 1 + required_client_version = (0, 3, 9) + + options: StardewOptions + logic: StardewLogic + + web = StardewWebWorld() + modified_bundles: Dict[str, Bundle] + randomized_entrances: Dict[str, str] + + def generate_early(self): + self.options = fetch_options(self.multiworld, self.player) + self.logic = StardewLogic(self.player, self.options) + self.modified_bundles = get_all_bundles(self.multiworld.random, + self.logic, + self.options[options.BundleRandomization], + self.options[options.BundlePrice]) + + def create_regions(self): + def create_region(name: str, exits: Iterable[str]) -> Region: + region = Region(name, self.player, self.multiworld) + region.exits = [Entrance(self.player, exit_name, region) for exit_name in exits] + return region + + world_regions, self.randomized_entrances = create_regions(create_region, self.multiworld.random, self.options) + self.multiworld.regions.extend(world_regions) + + def add_location(name: str, code: Optional[int], region: str): + region = self.multiworld.get_region(region, self.player) + location = StardewLocation(self.player, name, code, region) + location.access_rule = lambda _: True + region.locations.append(location) + + create_locations(add_location, self.options, self.multiworld.random) + + def create_items(self): + locations_count = len([location + for location in self.multiworld.get_locations(self.player) + if not location.event]) + items_to_exclude = [excluded_items + for excluded_items in self.multiworld.precollected_items[self.player] + if not item_table[excluded_items.name].has_any_group(Group.RESOURCE_PACK, + Group.FRIENDSHIP_PACK)] + created_items = create_items(self.create_item, locations_count + len(items_to_exclude), self.options, + self.multiworld.random) + self.multiworld.itempool += created_items + + for item in items_to_exclude: + self.multiworld.itempool.remove(item) + + self.setup_season_events() + self.setup_victory() + + def set_rules(self): + set_rules(self.multiworld, self.player, self.options, self.logic, self.modified_bundles) + + def create_item(self, item: Union[str, ItemData]) -> StardewItem: + if isinstance(item, str): + item = item_table[item] + + return StardewItem(item.name, item.classification, item.code, self.player) + + def setup_season_events(self): + self.multiworld.push_precollected(self.create_item("Spring")) + self.create_event_location(location_table["Summer"], self.logic.received("Spring"), "Summer") + self.create_event_location(location_table["Fall"], self.logic.received("Summer"), "Fall") + self.create_event_location(location_table["Winter"], self.logic.received("Fall"), "Winter") + self.create_event_location(location_table["Year Two"], self.logic.received("Winter"), "Year Two") + + def setup_victory(self): + if self.options[options.Goal] == options.Goal.option_community_center: + self.create_event_location(location_table["Complete Community Center"], + self.logic.can_complete_community_center().simplify(), + "Victory") + elif self.options[options.Goal] == options.Goal.option_grandpa_evaluation: + self.create_event_location(location_table["Succeed Grandpa's Evaluation"], + self.logic.can_finish_grandpa_evaluation().simplify(), + "Victory") + elif self.options[options.Goal] == options.Goal.option_bottom_of_the_mines: + self.create_event_location(location_table["Reach the Bottom of The Mines"], + self.logic.can_mine_to_floor(120).simplify(), + "Victory") + elif self.options[options.Goal] == options.Goal.option_cryptic_note: + self.create_event_location(location_table["Complete Quest Cryptic Note"], + self.logic.can_complete_quest("Cryptic Note").simplify(), + "Victory") + elif self.options[options.Goal] == options.Goal.option_master_angler: + self.create_event_location(location_table["Catch Every Fish"], + self.logic.can_catch_every_fish().simplify(), + "Victory") + + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + + def create_event_location(self, location_data: LocationData, rule: StardewRule, item: str): + region = self.multiworld.get_region(location_data.region, self.player) + location = StardewLocation(self.player, location_data.name, None, region) + location.access_rule = rule + region.locations.append(location) + location.place_locked_item(self.create_item(item)) + + def get_filler_item_name(self) -> str: + return "Joja Cola" + + def fill_slot_data(self) -> Dict[str, Any]: + + modified_bundles = {} + for bundle_key in self.modified_bundles: + key, value = self.modified_bundles[bundle_key].to_pair() + modified_bundles[key] = value + + return { + "starting_money": self.options[options.StartingMoney], + "entrance_randomization": self.options[options.EntranceRandomization], + "backpack_progression": self.options[options.BackpackProgression], + "tool_progression": self.options[options.ToolProgression], + "elevator_progression": self.options[options.TheMinesElevatorsProgression], + "skill_progression": self.options[options.SkillProgression], + "building_progression": self.options[options.BuildingProgression], + "arcade_machine_progression": self.options[options.ArcadeMachineLocations], + "help_wanted_locations": self.options[options.HelpWantedLocations], + "fishsanity": self.options[options.Fishsanity], + "death_link": self.options["death_link"], + "goal": self.options[options.Goal], + "seed": self.multiworld.per_slot_randoms[self.player].randrange(1000000000), # Seed should be max 9 digits + "multiple_day_sleep_enabled": self.options[options.MultipleDaySleepEnabled], + "multiple_day_sleep_cost": self.options[options.MultipleDaySleepCost], + "experience_multiplier": self.options[options.ExperienceMultiplier], + "debris_multiplier": self.options[options.DebrisMultiplier], + "quick_start": self.options[options.QuickStart], + "gifting": self.options[options.Gifting], + "gift_tax": self.options[options.GiftTax], + "modified_bundles": modified_bundles, + "randomized_entrances": self.randomized_entrances, + "client_version": "2.2.2", + } diff --git a/worlds/stardew_valley/bundle_data.py b/worlds/stardew_valley/bundle_data.py new file mode 100644 index 0000000000..cfc5d482ad --- /dev/null +++ b/worlds/stardew_valley/bundle_data.py @@ -0,0 +1,414 @@ +from dataclasses import dataclass + +from . import fish_data +from .game_item import GameItem + +quality_dict = { + 0: "", + 1: "Silver", + 2: "Gold", + 3: "Iridium" +} + + +@dataclass(frozen=True) +class BundleItem: + item: GameItem + amount: int + quality: int + + @staticmethod + def item_bundle(name: str, item_id: int, amount: int, quality: int): + return BundleItem(GameItem(name, item_id), amount, quality) + + @staticmethod + def money_bundle(amount: int): + return BundleItem.item_bundle("Money", -1, amount, amount) + + def as_amount(self, amount: int): + return BundleItem.item_bundle(self.item.name, self.item.item_id, amount, self.quality) + + def as_quality(self, quality: int): + return BundleItem.item_bundle(self.item.name, self.item.item_id, self.amount, quality) + + def __repr__(self): + return f"{self.amount} {quality_dict[self.quality]} {self.item.name}" + + def __lt__(self, other): + return self.item < other.item + + +wild_horseradish = BundleItem.item_bundle("Wild Horseradish", 16, 1, 0) +daffodil = BundleItem.item_bundle("Daffodil", 18, 1, 0) +leek = BundleItem.item_bundle("Leek", 20, 1, 0) +dandelion = BundleItem.item_bundle("Dandelion", 22, 1, 0) +morel = BundleItem.item_bundle("Morel", 257, 1, 0) +common_mushroom = BundleItem.item_bundle("Common Mushroom", 404, 1, 0) +salmonberry = BundleItem.item_bundle("Salmonberry", 296, 1, 0) +spring_onion = BundleItem.item_bundle("Spring Onion", 399, 1, 0) + +grape = BundleItem.item_bundle("Grape", 398, 1, 0) +spice_berry = BundleItem.item_bundle("Spice Berry", 396, 1, 0) +sweet_pea = BundleItem.item_bundle("Sweet Pea", 402, 1, 0) +red_mushroom = BundleItem.item_bundle("Red Mushroom", 420, 1, 0) +fiddlehead_fern = BundleItem.item_bundle("Fiddlehead Fern", 259, 1, 0) + +wild_plum = BundleItem.item_bundle("Wild Plum", 406, 1, 0) +hazelnut = BundleItem.item_bundle("Hazelnut", 408, 1, 0) +blackberry = BundleItem.item_bundle("Blackberry", 410, 1, 0) +chanterelle = BundleItem.item_bundle("Chanterelle", 281, 1, 0) + +winter_root = BundleItem.item_bundle("Winter Root", 412, 1, 0) +crystal_fruit = BundleItem.item_bundle("Crystal Fruit", 414, 1, 0) +snow_yam = BundleItem.item_bundle("Snow Yam", 416, 1, 0) +crocus = BundleItem.item_bundle("Crocus", 418, 1, 0) +holly = BundleItem.item_bundle("Holly", 283, 1, 0) + +coconut = BundleItem.item_bundle("Coconut", 88, 1, 0) +cactus_fruit = BundleItem.item_bundle("Cactus Fruit", 90, 1, 0) +cave_carrot = BundleItem.item_bundle("Cave Carrot", 78, 1, 0) +purple_mushroom = BundleItem.item_bundle("Purple Mushroom", 422, 1, 0) +maple_syrup = BundleItem.item_bundle("Maple Syrup", 724, 1, 0) +oak_resin = BundleItem.item_bundle("Oak Resin", 725, 1, 0) +pine_tar = BundleItem.item_bundle("Pine Tar", 726, 1, 0) +nautilus_shell = BundleItem.item_bundle("Nautilus Shell", 392, 1, 0) +coral = BundleItem.item_bundle("Coral", 393, 1, 0) +sea_urchin = BundleItem.item_bundle("Sea Urchin", 397, 1, 0) +rainbow_shell = BundleItem.item_bundle("Rainbow Shell", 394, 1, 0) +clam = BundleItem(fish_data.clam, 1, 0) +cockle = BundleItem(fish_data.cockle, 1, 0) +mussel = BundleItem(fish_data.mussel, 1, 0) +oyster = BundleItem(fish_data.oyster, 1, 0) +seaweed = BundleItem.item_bundle("Seaweed", 152, 1, 0) + +wood = BundleItem.item_bundle("Wood", 388, 99, 0) +stone = BundleItem.item_bundle("Stone", 390, 99, 0) +hardwood = BundleItem.item_bundle("Hardwood", 709, 10, 0) +clay = BundleItem.item_bundle("Clay", 330, 10, 0) +fiber = BundleItem.item_bundle("Fiber", 771, 99, 0) + +blue_jazz = BundleItem.item_bundle("Blue Jazz", 597, 1, 0) +cauliflower = BundleItem.item_bundle("Cauliflower", 190, 1, 0) +green_bean = BundleItem.item_bundle("Green Bean", 188, 1, 0) +kale = BundleItem.item_bundle("Kale", 250, 1, 0) +parsnip = BundleItem.item_bundle("Parsnip", 24, 1, 0) +potato = BundleItem.item_bundle("Potato", 192, 1, 0) +strawberry = BundleItem.item_bundle("Strawberry", 400, 1, 0) +tulip = BundleItem.item_bundle("Tulip", 591, 1, 0) +unmilled_rice = BundleItem.item_bundle("Unmilled Rice", 271, 1, 0) +blueberry = BundleItem.item_bundle("Blueberry", 258, 1, 0) +corn = BundleItem.item_bundle("Corn", 270, 1, 0) +hops = BundleItem.item_bundle("Hops", 304, 1, 0) +hot_pepper = BundleItem.item_bundle("Hot Pepper", 260, 1, 0) +melon = BundleItem.item_bundle("Melon", 254, 1, 0) +poppy = BundleItem.item_bundle("Poppy", 376, 1, 0) +radish = BundleItem.item_bundle("Radish", 264, 1, 0) +summer_spangle = BundleItem.item_bundle("Summer Spangle", 593, 1, 0) +sunflower = BundleItem.item_bundle("Sunflower", 421, 1, 0) +tomato = BundleItem.item_bundle("Tomato", 256, 1, 0) +wheat = BundleItem.item_bundle("Wheat", 262, 1, 0) +hay = BundleItem.item_bundle("Hay", 178, 1, 0) +amaranth = BundleItem.item_bundle("Amaranth", 300, 1, 0) +bok_choy = BundleItem.item_bundle("Bok Choy", 278, 1, 0) +cranberries = BundleItem.item_bundle("Cranberries", 282, 1, 0) +eggplant = BundleItem.item_bundle("Eggplant", 272, 1, 0) +fairy_rose = BundleItem.item_bundle("Fairy Rose", 595, 1, 0) +pumpkin = BundleItem.item_bundle("Pumpkin", 276, 1, 0) +yam = BundleItem.item_bundle("Yam", 280, 1, 0) +sweet_gem_berry = BundleItem.item_bundle("Sweet Gem Berry", 417, 1, 0) +rhubarb = BundleItem.item_bundle("Rhubarb", 252, 1, 0) +beet = BundleItem.item_bundle("Beet", 284, 1, 0) +red_cabbage = BundleItem.item_bundle("Red Cabbage", 266, 1, 0) +artichoke = BundleItem.item_bundle("Artichoke", 274, 1, 0) + +egg = BundleItem.item_bundle("Egg", 176, 1, 0) +large_egg = BundleItem.item_bundle("Large Egg", 174, 1, 0) +brown_egg = BundleItem.item_bundle("Egg (Brown)", 180, 1, 0) +large_brown_egg = BundleItem.item_bundle("Large Egg (Brown)", 182, 1, 0) +wool = BundleItem.item_bundle("Wool", 440, 1, 0) +milk = BundleItem.item_bundle("Milk", 184, 1, 0) +large_milk = BundleItem.item_bundle("Large Milk", 186, 1, 0) +goat_milk = BundleItem.item_bundle("Goat Milk", 436, 1, 0) +large_goat_milk = BundleItem.item_bundle("Large Goat Milk", 438, 1, 0) +truffle = BundleItem.item_bundle("Truffle", 430, 1, 0) +duck_feather = BundleItem.item_bundle("Duck Feather", 444, 1, 0) +duck_egg = BundleItem.item_bundle("Duck Egg", 442, 1, 0) +rabbit_foot = BundleItem.item_bundle("Rabbit's Foot", 446, 1, 0) + +truffle_oil = BundleItem.item_bundle("Truffle Oil", 432, 1, 0) +cloth = BundleItem.item_bundle("Cloth", 428, 1, 0) +goat_cheese = BundleItem.item_bundle("Goat Cheese", 426, 1, 0) +cheese = BundleItem.item_bundle("Cheese", 424, 1, 0) +honey = BundleItem.item_bundle("Honey", 340, 1, 0) +beer = BundleItem.item_bundle("Beer", 346, 1, 0) +juice = BundleItem.item_bundle("Juice", 350, 1, 0) +mead = BundleItem.item_bundle("Mead", 459, 1, 0) +pale_ale = BundleItem.item_bundle("Pale Ale", 303, 1, 0) +wine = BundleItem.item_bundle("Wine", 348, 1, 0) +jelly = BundleItem.item_bundle("Jelly", 344, 1, 0) +pickles = BundleItem.item_bundle("Pickles", 342, 1, 0) +caviar = BundleItem.item_bundle("Caviar", 445, 1, 0) +aged_roe = BundleItem.item_bundle("Aged Roe", 447, 1, 0) +apple = BundleItem.item_bundle("Apple", 613, 1, 0) +apricot = BundleItem.item_bundle("Apricot", 634, 1, 0) +orange = BundleItem.item_bundle("Orange", 635, 1, 0) +peach = BundleItem.item_bundle("Peach", 636, 1, 0) +pomegranate = BundleItem.item_bundle("Pomegranate", 637, 1, 0) +cherry = BundleItem.item_bundle("Cherry", 638, 1, 0) +lobster = BundleItem(fish_data.lobster, 1, 0) +crab = BundleItem(fish_data.crab, 1, 0) +shrimp = BundleItem(fish_data.shrimp, 1, 0) +crayfish = BundleItem(fish_data.crayfish, 1, 0) +snail = BundleItem(fish_data.snail, 1, 0) +periwinkle = BundleItem(fish_data.periwinkle, 1, 0) +trash = BundleItem.item_bundle("Trash", 168, 1, 0) +driftwood = BundleItem.item_bundle("Driftwood", 169, 1, 0) +soggy_newspaper = BundleItem.item_bundle("Soggy Newspaper", 172, 1, 0) +broken_cd = BundleItem.item_bundle("Broken CD", 171, 1, 0) +broken_glasses = BundleItem.item_bundle("Broken Glasses", 170, 1, 0) + +chub = BundleItem(fish_data.chub, 1, 0) +catfish = BundleItem(fish_data.catfish, 1, 0) +rainbow_trout = BundleItem(fish_data.rainbow_trout, 1, 0) +lingcod = BundleItem(fish_data.lingcod, 1, 0) +walleye = BundleItem(fish_data.walleye, 1, 0) +perch = BundleItem(fish_data.perch, 1, 0) +pike = BundleItem(fish_data.pike, 1, 0) +bream = BundleItem(fish_data.bream, 1, 0) +salmon = BundleItem(fish_data.salmon, 1, 0) +sunfish = BundleItem(fish_data.sunfish, 1, 0) +tiger_trout = BundleItem(fish_data.tiger_trout, 1, 0) +shad = BundleItem(fish_data.shad, 1, 0) +smallmouth_bass = BundleItem(fish_data.smallmouth_bass, 1, 0) +dorado = BundleItem(fish_data.dorado, 1, 0) +carp = BundleItem(fish_data.carp, 1, 0) +midnight_carp = BundleItem(fish_data.midnight_carp, 1, 0) +largemouth_bass = BundleItem(fish_data.largemouth_bass, 1, 0) +sturgeon = BundleItem(fish_data.sturgeon, 1, 0) +bullhead = BundleItem(fish_data.bullhead, 1, 0) +tilapia = BundleItem(fish_data.tilapia, 1, 0) +pufferfish = BundleItem(fish_data.pufferfish, 1, 0) +tuna = BundleItem(fish_data.tuna, 1, 0) +super_cucumber = BundleItem(fish_data.super_cucumber, 1, 0) +flounder = BundleItem(fish_data.flounder, 1, 0) +anchovy = BundleItem(fish_data.anchovy, 1, 0) +sardine = BundleItem(fish_data.sardine, 1, 0) +red_mullet = BundleItem(fish_data.red_mullet, 1, 0) +herring = BundleItem(fish_data.herring, 1, 0) +eel = BundleItem(fish_data.eel, 1, 0) +octopus = BundleItem(fish_data.octopus, 1, 0) +red_snapper = BundleItem(fish_data.red_snapper, 1, 0) +squid = BundleItem(fish_data.squid, 1, 0) +sea_cucumber = BundleItem(fish_data.sea_cucumber, 1, 0) +albacore = BundleItem(fish_data.albacore, 1, 0) +halibut = BundleItem(fish_data.halibut, 1, 0) +scorpion_carp = BundleItem(fish_data.scorpion_carp, 1, 0) +sandfish = BundleItem(fish_data.sandfish, 1, 0) +woodskip = BundleItem(fish_data.woodskip, 1, 0) +lava_eel = BundleItem(fish_data.lava_eel, 1, 0) +ice_pip = BundleItem(fish_data.ice_pip, 1, 0) +stonefish = BundleItem(fish_data.stonefish, 1, 0) +ghostfish = BundleItem(fish_data.ghostfish, 1, 0) + +wilted_bouquet = BundleItem.item_bundle("Wilted Bouquet", 277, 1, 0) +copper_bar = BundleItem.item_bundle("Copper Bar", 334, 2, 0) +iron_Bar = BundleItem.item_bundle("Iron Bar", 335, 2, 0) +gold_bar = BundleItem.item_bundle("Gold Bar", 336, 1, 0) +iridium_bar = BundleItem.item_bundle("Iridium Bar", 337, 1, 0) +refined_quartz = BundleItem.item_bundle("Refined Quartz", 338, 2, 0) +coal = BundleItem.item_bundle("Coal", 382, 5, 0) + +quartz = BundleItem.item_bundle("Quartz", 80, 1, 0) +fire_quartz = BundleItem.item_bundle("Fire Quartz", 82, 1, 0) +frozen_tear = BundleItem.item_bundle("Frozen Tear", 84, 1, 0) +earth_crystal = BundleItem.item_bundle("Earth Crystal", 86, 1, 0) +emerald = BundleItem.item_bundle("Emerald", 60, 1, 0) +aquamarine = BundleItem.item_bundle("Aquamarine", 62, 1, 0) +ruby = BundleItem.item_bundle("Ruby", 64, 1, 0) +amethyst = BundleItem.item_bundle("Amethyst", 66, 1, 0) +topaz = BundleItem.item_bundle("Topaz", 68, 1, 0) +jade = BundleItem.item_bundle("Jade", 70, 1, 0) + +slime = BundleItem.item_bundle("Slime", 766, 99, 0) +bug_meat = BundleItem.item_bundle("Bug Meat", 684, 10, 0) +bat_wing = BundleItem.item_bundle("Bat Wing", 767, 10, 0) +solar_essence = BundleItem.item_bundle("Solar Essence", 768, 1, 0) +void_essence = BundleItem.item_bundle("Void Essence", 769, 1, 0) + +maki_roll = BundleItem.item_bundle("Maki Roll", 228, 1, 0) +fried_egg = BundleItem.item_bundle("Fried Egg", 194, 1, 0) +omelet = BundleItem.item_bundle("Omelet", 195, 1, 0) +pizza = BundleItem.item_bundle("Pizza", 206, 1, 0) +hashbrowns = BundleItem.item_bundle("Hashbrowns", 210, 1, 0) +pancakes = BundleItem.item_bundle("Pancakes", 211, 1, 0) +bread = BundleItem.item_bundle("Bread", 216, 1, 0) +tortilla = BundleItem.item_bundle("Tortilla", 229, 1, 0) +triple_shot_espresso = BundleItem.item_bundle("Triple Shot Espresso", 253, 1, 0) +farmer_s_lunch = BundleItem.item_bundle("Farmer's Lunch", 240, 1, 0) +survival_burger = BundleItem.item_bundle("Survival Burger", 241, 1, 0) +dish_o_the_sea = BundleItem.item_bundle("Dish O' The Sea", 242, 1, 0) +miner_s_treat = BundleItem.item_bundle("Miner's Treat", 243, 1, 0) +roots_platter = BundleItem.item_bundle("Roots Platter", 244, 1, 0) +salad = BundleItem.item_bundle("Salad", 196, 1, 0) +cheese_cauliflower = BundleItem.item_bundle("Cheese Cauliflower", 197, 1, 0) +parsnip_soup = BundleItem.item_bundle("Parsnip Soup", 199, 1, 0) +fried_mushroom = BundleItem.item_bundle("Fried Mushroom", 205, 1, 0) +salmon_dinner = BundleItem.item_bundle("Salmon Dinner", 212, 1, 0) +pepper_poppers = BundleItem.item_bundle("Pepper Poppers", 215, 1, 0) +spaghetti = BundleItem.item_bundle("Spaghetti", 224, 1, 0) +sashimi = BundleItem.item_bundle("Sashimi", 227, 1, 0) +blueberry_tart = BundleItem.item_bundle("Blueberry Tart", 234, 1, 0) +algae_soup = BundleItem.item_bundle("Algae Soup", 456, 1, 0) +pale_broth = BundleItem.item_bundle("Pale Broth", 457, 1, 0) +chowder = BundleItem.item_bundle("Chowder", 727, 1, 0) +green_algae = BundleItem.item_bundle("Green Algae", 153, 1, 0) +white_algae = BundleItem.item_bundle("White Algae", 157, 1, 0) +geode = BundleItem.item_bundle("Geode", 535, 1, 0) +frozen_geode = BundleItem.item_bundle("Frozen Geode", 536, 1, 0) +magma_geode = BundleItem.item_bundle("Magma Geode", 537, 1, 0) +omni_geode = BundleItem.item_bundle("Omni Geode", 749, 1, 0) + +spring_foraging_items = [wild_horseradish, daffodil, leek, dandelion, salmonberry, spring_onion] +summer_foraging_items = [grape, spice_berry, sweet_pea, fiddlehead_fern, rainbow_shell] +fall_foraging_items = [common_mushroom, wild_plum, hazelnut, blackberry] +winter_foraging_items = [winter_root, crystal_fruit, snow_yam, crocus, holly, nautilus_shell] +exotic_foraging_items = [coconut, cactus_fruit, cave_carrot, red_mushroom, purple_mushroom, + maple_syrup, oak_resin, pine_tar, morel, coral, + sea_urchin, clam, cockle, mussel, oyster, seaweed] +construction_items = [wood, stone, hardwood, clay, fiber] + +# TODO coffee_bean, garlic, rhubarb, tea_leaves +spring_crop_items = [blue_jazz, cauliflower, green_bean, kale, parsnip, potato, strawberry, tulip, unmilled_rice] +# TODO red_cabbage, starfruit, ancient_fruit, pineapple, taro_root +summer_crops_items = [blueberry, corn, hops, hot_pepper, melon, poppy, + radish, summer_spangle, sunflower, tomato, wheat] +# TODO artichoke, beet +fall_crops_items = [corn, sunflower, wheat, amaranth, bok_choy, cranberries, + eggplant, fairy_rose, grape, pumpkin, yam, sweet_gem_berry] +all_crops_items = sorted({*spring_crop_items, *summer_crops_items, *fall_crops_items}) +quality_crops_items = [item.as_quality(2).as_amount(5) for item in all_crops_items] +# TODO void_egg, dinosaur_egg, ostrich_egg, golden_egg +animal_product_items = [egg, large_egg, brown_egg, large_brown_egg, wool, milk, large_milk, + goat_milk, large_goat_milk, truffle, duck_feather, duck_egg, rabbit_foot] +# TODO coffee, green_tea +artisan_goods_items = [truffle_oil, cloth, goat_cheese, cheese, honey, beer, juice, mead, pale_ale, wine, jelly, + pickles, caviar, aged_roe, apple, apricot, orange, peach, pomegranate, cherry] + +river_fish_items = [chub, catfish, rainbow_trout, lingcod, walleye, perch, pike, bream, + salmon, sunfish, tiger_trout, shad, smallmouth_bass, dorado] +lake_fish_items = [chub, rainbow_trout, lingcod, walleye, perch, carp, midnight_carp, + largemouth_bass, sturgeon, bullhead, midnight_carp] +ocean_fish_items = [tilapia, pufferfish, tuna, super_cucumber, flounder, anchovy, sardine, red_mullet, + herring, eel, octopus, red_snapper, squid, sea_cucumber, albacore, halibut] +night_fish_items = [walleye, bream, super_cucumber, eel, squid, midnight_carp] +# TODO void_salmon +specialty_fish_items = [scorpion_carp, sandfish, woodskip, pufferfish, eel, octopus, + squid, lava_eel, ice_pip, stonefish, ghostfish, dorado] +crab_pot_items = [lobster, clam, crab, cockle, mussel, shrimp, oyster, crayfish, snail, + periwinkle, trash, driftwood, soggy_newspaper, broken_cd, broken_glasses] + +# TODO radioactive_bar +blacksmith_items = [wilted_bouquet, copper_bar, iron_Bar, gold_bar, iridium_bar, refined_quartz, coal] +geologist_items = [quartz, earth_crystal, frozen_tear, fire_quartz, emerald, aquamarine, ruby, amethyst, topaz, jade] +adventurer_items = [slime, bug_meat, bat_wing, solar_essence, void_essence, coal] + +chef_items = [maki_roll, fried_egg, omelet, pizza, hashbrowns, pancakes, bread, tortilla, triple_shot_espresso, + farmer_s_lunch, survival_burger, dish_o_the_sea, miner_s_treat, roots_platter, salad, + cheese_cauliflower, parsnip_soup, fried_mushroom, salmon_dinner, pepper_poppers, spaghetti, + sashimi, blueberry_tart, algae_soup, pale_broth, chowder] + +dwarf_scroll_1 = BundleItem.item_bundle("Dwarf Scroll I", 96, 1, 0) +dwarf_scroll_2 = BundleItem.item_bundle("Dwarf Scroll II", 97, 1, 0) +dwarf_scroll_3 = BundleItem.item_bundle("Dwarf Scroll III", 98, 1, 0) +dwarf_scroll_4 = BundleItem.item_bundle("Dwarf Scroll IV", 99, 1, 0) +elvish_jewelry = BundleItem.item_bundle("Elvish Jewelry", 104, 1, 0) +ancient_drum = BundleItem.item_bundle("Ancient Drum", 123, 1, 0) +dried_starfish = BundleItem.item_bundle("Dried Starfish", 116, 1, 0) + +# TODO Dye Bundle +dye_red_items = [cranberries, dwarf_scroll_1, hot_pepper, radish, rhubarb, spaghetti, strawberry, tomato, tulip] +dye_orange_items = [poppy, pumpkin, apricot, orange, spice_berry, winter_root] +dye_yellow_items = [dried_starfish, dwarf_scroll_4, elvish_jewelry, corn, parsnip, summer_spangle, sunflower] +dye_green_items = [dwarf_scroll_2, fiddlehead_fern, kale, artichoke, bok_choy, green_bean] +dye_blue_items = [blueberry, dwarf_scroll_3, blue_jazz, blackberry, crystal_fruit] +dye_purple_items = [ancient_drum, beet, crocus, eggplant, red_cabbage, sweet_pea] +dye_items = [dye_red_items, dye_orange_items, dye_yellow_items, dye_green_items, dye_blue_items, dye_purple_items] +field_research_items = [purple_mushroom, nautilus_shell, chub, geode, frozen_geode, magma_geode, omni_geode, + rainbow_shell, amethyst, bream, carp] +fodder_items = [wheat.as_amount(10), hay.as_amount(10), apple.as_amount(3), kale.as_amount(3), corn.as_amount(3), + green_bean.as_amount(3), potato.as_amount(3), green_algae.as_amount(5), white_algae.as_amount(3)] +enchanter_items = [oak_resin, wine, rabbit_foot, pomegranate, purple_mushroom, solar_essence, + super_cucumber, void_essence, fire_quartz, frozen_tear, jade] + +vault_2500_items = [BundleItem.money_bundle(2500)] +vault_5000_items = [BundleItem.money_bundle(5000)] +vault_10000_items = [BundleItem.money_bundle(10000)] +vault_25000_items = [BundleItem.money_bundle(25000)] + +crafts_room_bundle_items = [ + *spring_foraging_items, + *summer_foraging_items, + *fall_foraging_items, + *winter_foraging_items, + *exotic_foraging_items, + *construction_items, +] + +pantry_bundle_items = sorted({ + *spring_crop_items, + *summer_crops_items, + *fall_crops_items, + *quality_crops_items, + *animal_product_items, + *artisan_goods_items, +}) + +fish_tank_bundle_items = sorted({ + *river_fish_items, + *lake_fish_items, + *ocean_fish_items, + *night_fish_items, + *crab_pot_items, + *specialty_fish_items, +}) + +boiler_room_bundle_items = sorted({ + *blacksmith_items, + *geologist_items, + *adventurer_items, +}) + +bulletin_board_bundle_items = sorted({ + *chef_items, + *[item for dye_color_items in dye_items for item in dye_color_items], + *field_research_items, + *fodder_items, + *enchanter_items +}) + +vault_bundle_items = [ + *vault_2500_items, + *vault_5000_items, + *vault_10000_items, + *vault_25000_items, +] + +all_bundle_items_except_money = sorted({ + *crafts_room_bundle_items, + *pantry_bundle_items, + *fish_tank_bundle_items, + *boiler_room_bundle_items, + *bulletin_board_bundle_items, +}, key=lambda x: x.item.name) + +all_bundle_items = sorted({ + *crafts_room_bundle_items, + *pantry_bundle_items, + *fish_tank_bundle_items, + *boiler_room_bundle_items, + *bulletin_board_bundle_items, + *vault_bundle_items, +}, key=lambda x: x.item.name) + +all_bundle_items_by_name = {item.item.name: item for item in all_bundle_items} +all_bundle_items_by_id = {item.item.item_id: item for item in all_bundle_items} diff --git a/worlds/stardew_valley/bundles.py b/worlds/stardew_valley/bundles.py new file mode 100644 index 0000000000..f87e3d6730 --- /dev/null +++ b/worlds/stardew_valley/bundles.py @@ -0,0 +1,254 @@ +from random import Random +from typing import List, Dict, Union + +from .bundle_data import * +from .logic import StardewLogic +from .options import BundleRandomization, BundlePrice + +vanilla_bundles = { + "Pantry/0": "Spring Crops/O 465 20/24 1 0 188 1 0 190 1 0 192 1 0/0", + "Pantry/1": "Summer Crops/O 621 1/256 1 0 260 1 0 258 1 0 254 1 0/3", + "Pantry/2": "Fall Crops/BO 10 1/270 1 0 272 1 0 276 1 0 280 1 0/2", + "Pantry/3": "Quality Crops/BO 15 1/24 5 2 254 5 2 276 5 2 270 5 2/6/3", + "Pantry/4": "Animal/BO 16 1/186 1 0 182 1 0 174 1 0 438 1 0 440 1 0 442 1 0/4/5", + # 639 1 0 640 1 0 641 1 0 642 1 0 643 1 0 + "Pantry/5": "Artisan/BO 12 1/432 1 0 428 1 0 426 1 0 424 1 0 340 1 0 344 1 0 613 1 0 634 1 0 635 1 0 636 1 0 637 1 0 638 1 0/1/6", + "Crafts Room/13": "Spring Foraging/O 495 30/16 1 0 18 1 0 20 1 0 22 1 0/0", + "Crafts Room/14": "Summer Foraging/O 496 30/396 1 0 398 1 0 402 1 0/3", + "Crafts Room/15": "Fall Foraging/O 497 30/404 1 0 406 1 0 408 1 0 410 1 0/2", + "Crafts Room/16": "Winter Foraging/O 498 30/412 1 0 414 1 0 416 1 0 418 1 0/6", + "Crafts Room/17": "Construction/BO 114 1/388 99 0 388 99 0 390 99 0 709 10 0/4", + "Crafts Room/19": "Exotic Foraging/O 235 5/88 1 0 90 1 0 78 1 0 420 1 0 422 1 0 724 1 0 725 1 0 726 1 0 257 1 0/1/5", + "Fish Tank/6": "River Fish/O 685 30/145 1 0 143 1 0 706 1 0 699 1 0/6", + "Fish Tank/7": "Lake Fish/O 687 1/136 1 0 142 1 0 700 1 0 698 1 0/0", + "Fish Tank/8": "Ocean Fish/O 690 5/131 1 0 130 1 0 150 1 0 701 1 0/5", + "Fish Tank/9": "Night Fishing/R 516 1/140 1 0 132 1 0 148 1 0/1", + "Fish Tank/10": "Specialty Fish/O 242 5/128 1 0 156 1 0 164 1 0 734 1 0/4", + "Fish Tank/11": "Crab Pot/O 710 3/715 1 0 716 1 0 717 1 0 718 1 0 719 1 0 720 1 0 721 1 0 722 1 0 723 1 0 372 1 0/1/5", + "Boiler Room/20": "Blacksmith's/BO 13 1/334 1 0 335 1 0 336 1 0/2", + "Boiler Room/21": "Geologist's/O 749 5/80 1 0 86 1 0 84 1 0 82 1 0/1", + "Boiler Room/22": "Adventurer's/R 518 1/766 99 0 767 10 0 768 1 0 769 1 0/1/2", + "Vault/23": "2,500g/O 220 3/-1 2500 2500/4", + "Vault/24": "5,000g/O 369 30/-1 5000 5000/2", + "Vault/25": "10,000g/BO 9 1/-1 10000 10000/3", + "Vault/26": "25,000g/BO 21 1/-1 25000 25000/1", + "Bulletin Board/31": "Chef's/O 221 3/724 1 0 259 1 0 430 1 0 376 1 0 228 1 0 194 1 0/4", + "Bulletin Board/32": "Field Research/BO 20 1/422 1 0 392 1 0 702 1 0 536 1 0/5", + "Bulletin Board/33": "Enchanter's/O 336 5/725 1 0 348 1 0 446 1 0 637 1 0/1", + "Bulletin Board/34": "Dye/BO 25 1/420 1 0 397 1 0 421 1 0 444 1 0 62 1 0 266 1 0/6", + "Bulletin Board/35": "Fodder/BO 104 1/262 10 0 178 10 0 613 3 0/3", + # "Abandoned Joja Mart/36": "The Missing//348 1 1 807 1 0 74 1 0 454 5 2 795 1 2 445 1 0/1/5" +} + + +class Bundle: + room: str + sprite: str + original_name: str + name: str + rewards: List[str] + requirements: List[BundleItem] + color: str + number_required: int + + def __init__(self, key: str, value: str): + key_parts = key.split("/") + self.room = key_parts[0] + self.sprite = key_parts[1] + + value_parts = value.split("/") + self.original_name = value_parts[0] + self.name = value_parts[0] + self.rewards = self.parse_stardew_objects(value_parts[1]) + self.requirements = self.parse_stardew_bundle_items(value_parts[2]) + self.color = value_parts[3] + if len(value_parts) > 4: + self.number_required = int(value_parts[4]) + else: + self.number_required = len(self.requirements) + + def __repr__(self): + return f"{self.original_name} -> {repr(self.requirements)}" + + def get_name_with_bundle(self) -> str: + return f"{self.original_name} Bundle" + + def to_pair(self) -> (str, str): + key = f"{self.room}/{self.sprite}" + str_rewards = "" + for reward in self.rewards: + str_rewards += f" {reward}" + str_rewards = str_rewards.strip() + str_requirements = "" + for requirement in self.requirements: + str_requirements += f" {requirement.item.item_id} {requirement.amount} {requirement.quality}" + str_requirements = str_requirements.strip() + value = f"{self.name}/{str_rewards}/{str_requirements}/{self.color}/{self.number_required}" + return key, value + + def remove_rewards(self): + self.rewards = [] + + def change_number_required(self, difference: int): + self.number_required = min(len(self.requirements), max(1, self.number_required + difference)) + if len(self.requirements) == 1 and self.requirements[0].item.item_id == -1: + one_fifth = self.requirements[0].amount / 5 + new_amount = int(self.requirements[0].amount + (difference * one_fifth)) + self.requirements[0] = BundleItem.money_bundle(new_amount) + thousand_amount = int(new_amount / 1000) + dollar_amount = str(new_amount % 1000) + while len(dollar_amount) < 3: + dollar_amount = f"0{dollar_amount}" + self.name = f"{thousand_amount},{dollar_amount}g" + + def randomize_requirements(self, random: Random, + potential_requirements: Union[List[BundleItem], List[List[BundleItem]]]): + if not potential_requirements: + return + + number_to_generate = len(self.requirements) + self.requirements.clear() + if number_to_generate > len(potential_requirements): + choices: Union[BundleItem, List[BundleItem]] = random.choices(potential_requirements, k=number_to_generate) + else: + choices: Union[BundleItem, List[BundleItem]] = random.sample(potential_requirements, number_to_generate) + for choice in choices: + if isinstance(choice, BundleItem): + self.requirements.append(choice) + else: + self.requirements.append(random.choice(choice)) + + def assign_requirements(self, new_requirements: List[BundleItem]) -> List[BundleItem]: + number_to_generate = len(self.requirements) + self.requirements.clear() + for requirement in new_requirements: + self.requirements.append(requirement) + if len(self.requirements) >= number_to_generate: + return new_requirements[number_to_generate:] + + @staticmethod + def parse_stardew_objects(string_objects: str) -> List[str]: + objects = [] + if len(string_objects) < 5: + return objects + rewards_parts = string_objects.split(" ") + for index in range(0, len(rewards_parts), 3): + objects.append(f"{rewards_parts[index]} {rewards_parts[index + 1]} {rewards_parts[index + 2]}") + return objects + + @staticmethod + def parse_stardew_bundle_items(string_objects: str) -> List[BundleItem]: + bundle_items = [] + parts = string_objects.split(" ") + for index in range(0, len(parts), 3): + item_id = int(parts[index]) + bundle_item = BundleItem(all_bundle_items_by_id[item_id].item, + int(parts[index + 1]), + int(parts[index + 2])) + bundle_items.append(bundle_item) + return bundle_items + + # Shuffling the Vault doesn't really work with the stardew system in place + # shuffle_vault_amongst_themselves(random, bundles) + + +def get_all_bundles(random: Random, logic: StardewLogic, randomization: int, price: int) -> Dict[str, Bundle]: + bundles = {} + for bundle_key in vanilla_bundles: + bundle_value = vanilla_bundles[bundle_key] + bundle = Bundle(bundle_key, bundle_value) + bundles[bundle.get_name_with_bundle()] = bundle + + if randomization == BundleRandomization.option_thematic: + shuffle_bundles_thematically(random, bundles) + elif randomization == BundleRandomization.option_shuffled: + shuffle_bundles_completely(random, logic, bundles) + + price_difference = 0 + if price == BundlePrice.option_very_cheap: + price_difference = -2 + elif price == BundlePrice.option_cheap: + price_difference = -1 + elif price == BundlePrice.option_expensive: + price_difference = 1 + + for bundle_key in bundles: + bundles[bundle_key].remove_rewards() + bundles[bundle_key].change_number_required(price_difference) + + return bundles + + +def shuffle_bundles_completely(random: Random, logic: StardewLogic, bundles: Dict[str, Bundle]): + total_required_item_number = sum(len(bundle.requirements) for bundle in bundles.values()) + quality_crops_items_set = set(quality_crops_items) + all_bundle_items_without_quality_and_money = [item + for item in all_bundle_items_except_money + if item not in quality_crops_items_set] + \ + random.sample(quality_crops_items, 10) + choices = random.sample(all_bundle_items_without_quality_and_money, total_required_item_number - 4) + + items_sorted = sorted(choices, key=lambda x: logic.item_rules[x.item.name].get_difficulty()) + + keys = sorted(bundles.keys()) + random.shuffle(keys) + + for key in keys: + if not bundles[key].original_name.endswith("00g"): + items_sorted = bundles[key].assign_requirements(items_sorted) + + +def shuffle_bundles_thematically(random: Random, bundles: Dict[str, Bundle]): + shuffle_crafts_room_bundle_thematically(random, bundles) + shuffle_pantry_bundle_thematically(random, bundles) + shuffle_fish_tank_thematically(random, bundles) + shuffle_boiler_room_thematically(random, bundles) + shuffle_bulletin_board_thematically(random, bundles) + + +def shuffle_crafts_room_bundle_thematically(random: Random, bundles: Dict[str, Bundle]): + bundles["Spring Foraging Bundle"].randomize_requirements(random, spring_foraging_items) + bundles["Summer Foraging Bundle"].randomize_requirements(random, summer_foraging_items) + bundles["Fall Foraging Bundle"].randomize_requirements(random, fall_foraging_items) + bundles["Winter Foraging Bundle"].randomize_requirements(random, winter_foraging_items) + bundles["Exotic Foraging Bundle"].randomize_requirements(random, exotic_foraging_items) + bundles["Construction Bundle"].randomize_requirements(random, construction_items) + + +def shuffle_pantry_bundle_thematically(random: Random, bundles: Dict[str, Bundle]): + bundles["Spring Crops Bundle"].randomize_requirements(random, spring_crop_items) + bundles["Summer Crops Bundle"].randomize_requirements(random, summer_crops_items) + bundles["Fall Crops Bundle"].randomize_requirements(random, fall_crops_items) + bundles["Quality Crops Bundle"].randomize_requirements(random, quality_crops_items) + bundles["Animal Bundle"].randomize_requirements(random, animal_product_items) + bundles["Artisan Bundle"].randomize_requirements(random, artisan_goods_items) + + +def shuffle_fish_tank_thematically(random: Random, bundles: Dict[str, Bundle]): + bundles["River Fish Bundle"].randomize_requirements(random, river_fish_items) + bundles["Lake Fish Bundle"].randomize_requirements(random, lake_fish_items) + bundles["Ocean Fish Bundle"].randomize_requirements(random, ocean_fish_items) + bundles["Night Fishing Bundle"].randomize_requirements(random, night_fish_items) + bundles["Crab Pot Bundle"].randomize_requirements(random, crab_pot_items) + bundles["Specialty Fish Bundle"].randomize_requirements(random, specialty_fish_items) + + +def shuffle_boiler_room_thematically(random: Random, bundles: Dict[str, Bundle]): + bundles["Blacksmith's Bundle"].randomize_requirements(random, blacksmith_items) + bundles["Geologist's Bundle"].randomize_requirements(random, geologist_items) + bundles["Adventurer's Bundle"].randomize_requirements(random, adventurer_items) + + +def shuffle_bulletin_board_thematically(random: Random, bundles: Dict[str, Bundle]): + bundles["Chef's Bundle"].randomize_requirements(random, chef_items) + bundles["Dye Bundle"].randomize_requirements(random, dye_items) + bundles["Field Research Bundle"].randomize_requirements(random, field_research_items) + bundles["Fodder Bundle"].randomize_requirements(random, fodder_items) + bundles["Enchanter's Bundle"].randomize_requirements(random, enchanter_items) + + +def shuffle_vault_amongst_themselves(random: Random, bundles: Dict[str, Bundle]): + bundles["2,500g Bundle"].randomize_requirements(random, vault_bundle_items) + bundles["5,000g Bundle"].randomize_requirements(random, vault_bundle_items) + bundles["10,000g Bundle"].randomize_requirements(random, vault_bundle_items) + bundles["25,000g Bundle"].randomize_requirements(random, vault_bundle_items) diff --git a/worlds/stardew_valley/data/__init__.py b/worlds/stardew_valley/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/stardew_valley/data/items.csv b/worlds/stardew_valley/data/items.csv new file mode 100644 index 0000000000..425186ed4f --- /dev/null +++ b/worlds/stardew_valley/data/items.csv @@ -0,0 +1,312 @@ +id,name,classification,groups +0,Joja Cola,filler,TRASH +15,Rusty Key,progression, +16,Dwarvish Translation Guide,progression, +17,Bridge Repair,progression,COMMUNITY_REWARD +18,Greenhouse,progression,COMMUNITY_REWARD +19,Glittering Boulder Removed,progression,COMMUNITY_REWARD +20,Minecarts Repair,useful,COMMUNITY_REWARD +21,Bus Repair,progression,COMMUNITY_REWARD +22,Movie Theater,useful, +23,Stardrop,useful, +24,Progressive Backpack,progression, +25,Rusty Sword,progression,WEAPON +26,Leather Boots,progression,"FOOTWEAR,MINES_FLOOR_10" +27,Work Boots,useful,"FOOTWEAR,MINES_FLOOR_10" +28,Wooden Blade,progression,"MINES_FLOOR_10,WEAPON" +29,Iron Dirk,progression,"MINES_FLOOR_10,WEAPON" +30,Wind Spire,progression,"MINES_FLOOR_10,WEAPON" +31,Femur,progression,"MINES_FLOOR_10,WEAPON" +32,Steel Smallsword,progression,"MINES_FLOOR_20,WEAPON" +33,Wood Club,progression,"MINES_FLOOR_20,WEAPON" +34,Elf Blade,progression,"MINES_FLOOR_20,WEAPON" +35,Glow Ring,useful,"MINES_FLOOR_20,RING" +36,Magnet Ring,useful,"MINES_FLOOR_20,RING" +37,Slingshot,progression,WEAPON +38,Tundra Boots,useful,"FOOTWEAR,MINES_FLOOR_50" +39,Thermal Boots,useful,"FOOTWEAR,MINES_FLOOR_50" +40,Combat Boots,useful,"FOOTWEAR,MINES_FLOOR_50" +41,Silver Saber,progression,"MINES_FLOOR_50,WEAPON" +42,Pirate's Sword,progression,"MINES_FLOOR_50,WEAPON" +43,Crystal Dagger,progression,"MINES_FLOOR_60,WEAPON" +44,Cutlass,progression,"MINES_FLOOR_60,WEAPON" +45,Iron Edge,progression,"MINES_FLOOR_60,WEAPON" +46,Burglar's Shank,progression,"MINES_FLOOR_60,WEAPON" +47,Wood Mallet,progression,"MINES_FLOOR_60,WEAPON" +48,Master Slingshot,progression,WEAPON +49,Firewalker Boots,useful,"FOOTWEAR,MINES_FLOOR_80" +50,Dark Boots,useful,"FOOTWEAR,MINES_FLOOR_80" +51,Claymore,progression,"MINES_FLOOR_80,WEAPON" +52,Templar's Blade,progression,"MINES_FLOOR_80,WEAPON" +53,Kudgel,progression,"MINES_FLOOR_80,WEAPON" +54,Shadow Dagger,progression,"MINES_FLOOR_80,WEAPON" +55,Obsidian Edge,progression,"MINES_FLOOR_90,WEAPON" +56,Tempered Broadsword,progression,"MINES_FLOOR_90,WEAPON" +57,Wicked Kris,progression,"MINES_FLOOR_90,WEAPON" +58,Bone Sword,progression,"MINES_FLOOR_90,WEAPON" +59,Ossified Blade,progression,"MINES_FLOOR_90,WEAPON" +60,Space Boots,useful,"FOOTWEAR,MINES_FLOOR_110" +61,Crystal Shoes,useful,"FOOTWEAR,MINES_FLOOR_110" +62,Steel Falchion,progression,"MINES_FLOOR_110,WEAPON" +63,The Slammer,progression,"MINES_FLOOR_110,WEAPON" +64,Skull Key,progression, +65,Progressive Hoe,progression,PROGRESSIVE_TOOLS +66,Progressive Pickaxe,progression,PROGRESSIVE_TOOLS +67,Progressive Axe,progression,PROGRESSIVE_TOOLS +68,Progressive Watering Can,progression,PROGRESSIVE_TOOLS +69,Progressive Trash Can,progression,PROGRESSIVE_TOOLS +70,Progressive Fishing Rod,progression,PROGRESSIVE_TOOLS +71,Golden Scythe,useful, +72,Progressive Mine Elevator,progression, +73,Farming Level,progression,SKILL_LEVEL_UP +74,Fishing Level,progression,SKILL_LEVEL_UP +75,Foraging Level,progression,SKILL_LEVEL_UP +76,Mining Level,progression,SKILL_LEVEL_UP +77,Combat Level,progression,SKILL_LEVEL_UP +78,Earth Obelisk,useful, +79,Water Obelisk,useful, +80,Desert Obelisk,progression, +81,Island Obelisk,progression, +82,Junimo Hut,useful, +83,Gold Clock,useful, +84,Progressive Coop,progression, +85,Progressive Barn,progression, +86,Well,useful, +87,Silo,progression, +88,Mill,progression, +89,Progressive Shed,progression, +90,Fish Pond,progression, +91,Stable,useful, +92,Slime Hutch,useful, +93,Shipping Bin,progression, +94,Beach Bridge,progression, +95,Adventurer's Guild,progression, +96,Club Card,progression, +97,Magnifying Glass,progression, +98,Bear's Knowledge,progression, +99,Iridium Snake Milk,progression, +100,JotPK: Progressive Boots,progression,ARCADE_MACHINE_BUFFS +101,JotPK: Progressive Gun,progression,ARCADE_MACHINE_BUFFS +102,JotPK: Progressive Ammo,progression,ARCADE_MACHINE_BUFFS +103,JotPK: Extra Life,progression,ARCADE_MACHINE_BUFFS +104,JotPK: Increased Drop Rate,progression,ARCADE_MACHINE_BUFFS +105,Junimo Kart: Extra Life,progression,ARCADE_MACHINE_BUFFS +106,Galaxy Sword,progression,"GALAXY_WEAPONS,WEAPON" +107,Galaxy Dagger,progression,"GALAXY_WEAPONS,WEAPON" +108,Galaxy Hammer,progression,"GALAXY_WEAPONS,WEAPON" +109,Movement Speed Bonus,useful, +110,Luck Bonus,useful, +111,Lava Katana,progression,"MINES_FLOOR_110,WEAPON" +112,Progressive House,progression, +113,Traveling Merchant: Sunday,progression, +114,Traveling Merchant: Monday,progression, +115,Traveling Merchant: Tuesday,progression, +116,Traveling Merchant: Wednesday,progression, +117,Traveling Merchant: Thursday,progression, +118,Traveling Merchant: Friday,progression, +119,Traveling Merchant: Saturday,progression, +120,Traveling Merchant Stock Size,progression, +121,Traveling Merchant Discount,progression, +122,Return Scepter,useful, +5000,Resource Pack: 500 Money,useful,"BASE_RESOURCE,RESOURCE_PACK" +5001,Resource Pack: 1000 Money,useful,"BASE_RESOURCE,RESOURCE_PACK" +5002,Resource Pack: 1500 Money,useful,"BASE_RESOURCE,RESOURCE_PACK" +5003,Resource Pack: 2000 Money,useful,"BASE_RESOURCE,RESOURCE_PACK" +5004,Resource Pack: 25 Stone,filler,"BASE_RESOURCE,RESOURCE_PACK" +5005,Resource Pack: 50 Stone,filler,"BASE_RESOURCE,RESOURCE_PACK" +5006,Resource Pack: 75 Stone,filler,"BASE_RESOURCE,RESOURCE_PACK" +5007,Resource Pack: 100 Stone,filler,"BASE_RESOURCE,RESOURCE_PACK" +5008,Resource Pack: 25 Wood,filler,"BASE_RESOURCE,RESOURCE_PACK" +5009,Resource Pack: 50 Wood,filler,"BASE_RESOURCE,RESOURCE_PACK" +5010,Resource Pack: 75 Wood,filler,"BASE_RESOURCE,RESOURCE_PACK" +5011,Resource Pack: 100 Wood,filler,"BASE_RESOURCE,RESOURCE_PACK" +5012,Resource Pack: 5 Hardwood,useful,"BASE_RESOURCE,RESOURCE_PACK" +5013,Resource Pack: 10 Hardwood,useful,"BASE_RESOURCE,RESOURCE_PACK" +5014,Resource Pack: 15 Hardwood,useful,"BASE_RESOURCE,RESOURCE_PACK" +5015,Resource Pack: 20 Hardwood,useful,"BASE_RESOURCE,RESOURCE_PACK" +5016,Resource Pack: 15 Fiber,filler,"BASE_RESOURCE,RESOURCE_PACK" +5017,Resource Pack: 30 Fiber,filler,"BASE_RESOURCE,RESOURCE_PACK" +5018,Resource Pack: 45 Fiber,filler,"BASE_RESOURCE,RESOURCE_PACK" +5019,Resource Pack: 60 Fiber,filler,"BASE_RESOURCE,RESOURCE_PACK" +5020,Resource Pack: 5 Coal,filler,"BASE_RESOURCE,RESOURCE_PACK" +5021,Resource Pack: 10 Coal,filler,"BASE_RESOURCE,RESOURCE_PACK" +5022,Resource Pack: 15 Coal,filler,"BASE_RESOURCE,RESOURCE_PACK" +5023,Resource Pack: 20 Coal,filler,"BASE_RESOURCE,RESOURCE_PACK" +5024,Resource Pack: 5 Clay,filler,"BASE_RESOURCE,RESOURCE_PACK" +5025,Resource Pack: 10 Clay,filler,"BASE_RESOURCE,RESOURCE_PACK" +5026,Resource Pack: 15 Clay,filler,"BASE_RESOURCE,RESOURCE_PACK" +5027,Resource Pack: 20 Clay,filler,"BASE_RESOURCE,RESOURCE_PACK" +5028,Resource Pack: 1 Warp Totem: Beach,filler,"RESOURCE_PACK,WARP_TOTEM" +5029,Resource Pack: 3 Warp Totem: Beach,filler,"RESOURCE_PACK,WARP_TOTEM" +5030,Resource Pack: 5 Warp Totem: Beach,filler,"RESOURCE_PACK,WARP_TOTEM" +5031,Resource Pack: 7 Warp Totem: Beach,filler,"RESOURCE_PACK,WARP_TOTEM" +5032,Resource Pack: 9 Warp Totem: Beach,filler,"RESOURCE_PACK,WARP_TOTEM" +5033,Resource Pack: 10 Warp Totem: Beach,filler,"RESOURCE_PACK,WARP_TOTEM" +5034,Resource Pack: 1 Warp Totem: Desert,filler,"RESOURCE_PACK,WARP_TOTEM" +5035,Resource Pack: 3 Warp Totem: Desert,filler,"RESOURCE_PACK,WARP_TOTEM" +5036,Resource Pack: 5 Warp Totem: Desert,filler,"RESOURCE_PACK,WARP_TOTEM" +5037,Resource Pack: 7 Warp Totem: Desert,filler,"RESOURCE_PACK,WARP_TOTEM" +5038,Resource Pack: 9 Warp Totem: Desert,filler,"RESOURCE_PACK,WARP_TOTEM" +5039,Resource Pack: 10 Warp Totem: Desert,filler,"RESOURCE_PACK,WARP_TOTEM" +5040,Resource Pack: 1 Warp Totem: Farm,filler,"RESOURCE_PACK,WARP_TOTEM" +5041,Resource Pack: 3 Warp Totem: Farm,filler,"RESOURCE_PACK,WARP_TOTEM" +5042,Resource Pack: 5 Warp Totem: Farm,filler,"RESOURCE_PACK,WARP_TOTEM" +5043,Resource Pack: 7 Warp Totem: Farm,filler,"RESOURCE_PACK,WARP_TOTEM" +5044,Resource Pack: 9 Warp Totem: Farm,filler,"RESOURCE_PACK,WARP_TOTEM" +5045,Resource Pack: 10 Warp Totem: Farm,filler,"RESOURCE_PACK,WARP_TOTEM" +5046,Resource Pack: 1 Warp Totem: Island,filler,"RESOURCE_PACK,WARP_TOTEM" +5047,Resource Pack: 3 Warp Totem: Island,filler,"RESOURCE_PACK,WARP_TOTEM" +5048,Resource Pack: 5 Warp Totem: Island,filler,"RESOURCE_PACK,WARP_TOTEM" +5049,Resource Pack: 7 Warp Totem: Island,filler,"RESOURCE_PACK,WARP_TOTEM" +5050,Resource Pack: 9 Warp Totem: Island,filler,"RESOURCE_PACK,WARP_TOTEM" +5051,Resource Pack: 10 Warp Totem: Island,filler,"RESOURCE_PACK,WARP_TOTEM" +5052,Resource Pack: 1 Warp Totem: Mountains,filler,"RESOURCE_PACK,WARP_TOTEM" +5053,Resource Pack: 3 Warp Totem: Mountains,filler,"RESOURCE_PACK,WARP_TOTEM" +5054,Resource Pack: 5 Warp Totem: Mountains,filler,"RESOURCE_PACK,WARP_TOTEM" +5055,Resource Pack: 7 Warp Totem: Mountains,filler,"RESOURCE_PACK,WARP_TOTEM" +5056,Resource Pack: 9 Warp Totem: Mountains,filler,"RESOURCE_PACK,WARP_TOTEM" +5057,Resource Pack: 10 Warp Totem: Mountains,filler,"RESOURCE_PACK,WARP_TOTEM" +5058,Resource Pack: 6 Geode,filler,"GEODE,RESOURCE_PACK" +5059,Resource Pack: 12 Geode,filler,"GEODE,RESOURCE_PACK" +5060,Resource Pack: 18 Geode,filler,"GEODE,RESOURCE_PACK" +5061,Resource Pack: 24 Geode,filler,"GEODE,RESOURCE_PACK" +5062,Resource Pack: 4 Frozen Geode,filler,"GEODE,RESOURCE_PACK" +5063,Resource Pack: 8 Frozen Geode,filler,"GEODE,RESOURCE_PACK" +5064,Resource Pack: 12 Frozen Geode,filler,"GEODE,RESOURCE_PACK" +5065,Resource Pack: 16 Frozen Geode,filler,"GEODE,RESOURCE_PACK" +5066,Resource Pack: 3 Magma Geode,filler,"GEODE,RESOURCE_PACK" +5067,Resource Pack: 6 Magma Geode,filler,"GEODE,RESOURCE_PACK" +5068,Resource Pack: 9 Magma Geode,filler,"GEODE,RESOURCE_PACK" +5069,Resource Pack: 12 Magma Geode,filler,"GEODE,RESOURCE_PACK" +5070,Resource Pack: 2 Omni Geode,useful,"GEODE,RESOURCE_PACK" +5071,Resource Pack: 4 Omni Geode,useful,"GEODE,RESOURCE_PACK" +5072,Resource Pack: 6 Omni Geode,useful,"GEODE,RESOURCE_PACK" +5073,Resource Pack: 8 Omni Geode,useful,"GEODE,RESOURCE_PACK" +5074,Resource Pack: 25 Copper Ore,filler,"ORE,RESOURCE_PACK" +5075,Resource Pack: 50 Copper Ore,filler,"ORE,RESOURCE_PACK" +5076,Resource Pack: 75 Copper Ore,filler,"ORE,RESOURCE_PACK" +5077,Resource Pack: 100 Copper Ore,filler,"ORE,RESOURCE_PACK" +5078,Resource Pack: 125 Copper Ore,filler,"ORE,RESOURCE_PACK" +5079,Resource Pack: 150 Copper Ore,filler,"ORE,RESOURCE_PACK" +5080,Resource Pack: 25 Iron Ore,filler,"ORE,RESOURCE_PACK" +5081,Resource Pack: 50 Iron Ore,filler,"ORE,RESOURCE_PACK" +5082,Resource Pack: 75 Iron Ore,filler,"ORE,RESOURCE_PACK" +5083,Resource Pack: 100 Iron Ore,filler,"ORE,RESOURCE_PACK" +5084,Resource Pack: 12 Gold Ore,useful,"ORE,RESOURCE_PACK" +5085,Resource Pack: 25 Gold Ore,useful,"ORE,RESOURCE_PACK" +5086,Resource Pack: 38 Gold Ore,useful,"ORE,RESOURCE_PACK" +5087,Resource Pack: 50 Gold Ore,useful,"ORE,RESOURCE_PACK" +5088,Resource Pack: 5 Iridium Ore,useful,"ORE,RESOURCE_PACK" +5089,Resource Pack: 10 Iridium Ore,useful,"ORE,RESOURCE_PACK" +5090,Resource Pack: 15 Iridium Ore,useful,"ORE,RESOURCE_PACK" +5091,Resource Pack: 20 Iridium Ore,useful,"ORE,RESOURCE_PACK" +5092,Resource Pack: 5 Quartz,filler,"ORE,RESOURCE_PACK" +5093,Resource Pack: 10 Quartz,filler,"ORE,RESOURCE_PACK" +5094,Resource Pack: 15 Quartz,filler,"ORE,RESOURCE_PACK" +5095,Resource Pack: 20 Quartz,filler,"ORE,RESOURCE_PACK" +5096,Resource Pack: 10 Basic Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5097,Resource Pack: 20 Basic Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5098,Resource Pack: 30 Basic Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5099,Resource Pack: 40 Basic Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5100,Resource Pack: 50 Basic Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5101,Resource Pack: 60 Basic Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5102,Resource Pack: 10 Basic Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5103,Resource Pack: 20 Basic Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5104,Resource Pack: 30 Basic Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5105,Resource Pack: 40 Basic Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5106,Resource Pack: 50 Basic Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5107,Resource Pack: 60 Basic Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5108,Resource Pack: 10 Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5109,Resource Pack: 20 Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5110,Resource Pack: 30 Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5111,Resource Pack: 40 Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5112,Resource Pack: 50 Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5113,Resource Pack: 60 Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5114,Resource Pack: 4 Quality Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5115,Resource Pack: 12 Quality Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5116,Resource Pack: 20 Quality Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5117,Resource Pack: 28 Quality Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5118,Resource Pack: 36 Quality Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5119,Resource Pack: 40 Quality Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5120,Resource Pack: 4 Quality Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5121,Resource Pack: 12 Quality Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5122,Resource Pack: 20 Quality Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5123,Resource Pack: 28 Quality Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5124,Resource Pack: 36 Quality Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5125,Resource Pack: 40 Quality Retaining Soil,filler,"FERTILIZER,RESOURCE_PACK" +5126,Resource Pack: 4 Deluxe Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5127,Resource Pack: 12 Deluxe Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5128,Resource Pack: 20 Deluxe Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5129,Resource Pack: 28 Deluxe Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5130,Resource Pack: 36 Deluxe Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5131,Resource Pack: 40 Deluxe Speed-Gro,filler,"FERTILIZER,RESOURCE_PACK" +5132,Resource Pack: 2 Deluxe Fertilizer,useful,"FERTILIZER,RESOURCE_PACK" +5133,Resource Pack: 6 Deluxe Fertilizer,useful,"FERTILIZER,RESOURCE_PACK" +5134,Resource Pack: 10 Deluxe Fertilizer,useful,"FERTILIZER,RESOURCE_PACK" +5135,Resource Pack: 14 Deluxe Fertilizer,useful,"FERTILIZER,RESOURCE_PACK" +5136,Resource Pack: 18 Deluxe Fertilizer,useful,"FERTILIZER,RESOURCE_PACK" +5137,Resource Pack: 20 Deluxe Fertilizer,useful,"FERTILIZER,RESOURCE_PACK" +5138,Resource Pack: 2 Deluxe Retaining Soil,useful,"FERTILIZER,RESOURCE_PACK" +5139,Resource Pack: 6 Deluxe Retaining Soil,useful,"FERTILIZER,RESOURCE_PACK" +5140,Resource Pack: 10 Deluxe Retaining Soil,useful,"FERTILIZER,RESOURCE_PACK" +5141,Resource Pack: 14 Deluxe Retaining Soil,useful,"FERTILIZER,RESOURCE_PACK" +5142,Resource Pack: 18 Deluxe Retaining Soil,useful,"FERTILIZER,RESOURCE_PACK" +5143,Resource Pack: 20 Deluxe Retaining Soil,useful,"FERTILIZER,RESOURCE_PACK" +5144,Resource Pack: 2 Hyper Speed-Gro,useful,"FERTILIZER,RESOURCE_PACK" +5145,Resource Pack: 6 Hyper Speed-Gro,useful,"FERTILIZER,RESOURCE_PACK" +5146,Resource Pack: 10 Hyper Speed-Gro,useful,"FERTILIZER,RESOURCE_PACK" +5147,Resource Pack: 14 Hyper Speed-Gro,useful,"FERTILIZER,RESOURCE_PACK" +5148,Resource Pack: 18 Hyper Speed-Gro,useful,"FERTILIZER,RESOURCE_PACK" +5149,Resource Pack: 20 Hyper Speed-Gro,useful,"FERTILIZER,RESOURCE_PACK" +5150,Resource Pack: 2 Tree Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5151,Resource Pack: 6 Tree Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5152,Resource Pack: 10 Tree Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5153,Resource Pack: 14 Tree Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5154,Resource Pack: 18 Tree Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5155,Resource Pack: 20 Tree Fertilizer,filler,"FERTILIZER,RESOURCE_PACK" +5156,Resource Pack: 10 Spring Seeds,filler,"RESOURCE_PACK,SEED" +5157,Resource Pack: 20 Spring Seeds,filler,"RESOURCE_PACK,SEED" +5158,Resource Pack: 30 Spring Seeds,filler,"RESOURCE_PACK,SEED" +5159,Resource Pack: 40 Spring Seeds,filler,"RESOURCE_PACK,SEED" +5160,Resource Pack: 50 Spring Seeds,filler,"RESOURCE_PACK,SEED" +5161,Resource Pack: 60 Spring Seeds,filler,"RESOURCE_PACK,SEED" +5162,Resource Pack: 10 Summer Seeds,filler,"RESOURCE_PACK,SEED" +5163,Resource Pack: 20 Summer Seeds,filler,"RESOURCE_PACK,SEED" +5164,Resource Pack: 30 Summer Seeds,filler,"RESOURCE_PACK,SEED" +5165,Resource Pack: 40 Summer Seeds,filler,"RESOURCE_PACK,SEED" +5166,Resource Pack: 50 Summer Seeds,filler,"RESOURCE_PACK,SEED" +5167,Resource Pack: 60 Summer Seeds,filler,"RESOURCE_PACK,SEED" +5168,Resource Pack: 10 Fall Seeds,filler,"RESOURCE_PACK,SEED" +5169,Resource Pack: 20 Fall Seeds,filler,"RESOURCE_PACK,SEED" +5170,Resource Pack: 30 Fall Seeds,filler,"RESOURCE_PACK,SEED" +5171,Resource Pack: 40 Fall Seeds,filler,"RESOURCE_PACK,SEED" +5172,Resource Pack: 50 Fall Seeds,filler,"RESOURCE_PACK,SEED" +5173,Resource Pack: 60 Fall Seeds,filler,"RESOURCE_PACK,SEED" +5174,Resource Pack: 10 Winter Seeds,filler,"RESOURCE_PACK,SEED" +5175,Resource Pack: 20 Winter Seeds,filler,"RESOURCE_PACK,SEED" +5176,Resource Pack: 30 Winter Seeds,filler,"RESOURCE_PACK,SEED" +5177,Resource Pack: 40 Winter Seeds,filler,"RESOURCE_PACK,SEED" +5178,Resource Pack: 50 Winter Seeds,filler,"RESOURCE_PACK,SEED" +5179,Resource Pack: 60 Winter Seeds,filler,"RESOURCE_PACK,SEED" +5180,Resource Pack: 1 Mahogany Seed,filler,"RESOURCE_PACK,SEED" +5181,Resource Pack: 3 Mahogany Seed,filler,"RESOURCE_PACK,SEED" +5182,Resource Pack: 5 Mahogany Seed,filler,"RESOURCE_PACK,SEED" +5183,Resource Pack: 7 Mahogany Seed,filler,"RESOURCE_PACK,SEED" +5184,Resource Pack: 9 Mahogany Seed,filler,"RESOURCE_PACK,SEED" +5185,Resource Pack: 10 Mahogany Seed,filler,"RESOURCE_PACK,SEED" +5186,Resource Pack: 10 Bait,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5187,Resource Pack: 20 Bait,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5188,Resource Pack: 30 Bait,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5189,Resource Pack: 40 Bait,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5190,Resource Pack: 50 Bait,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5191,Resource Pack: 60 Bait,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5192,Resource Pack: 1 Crab Pot,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5193,Resource Pack: 2 Crab Pot,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5194,Resource Pack: 3 Crab Pot,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5195,Resource Pack: 4 Crab Pot,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5196,Resource Pack: 5 Crab Pot,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5197,Resource Pack: 6 Crab Pot,filler,"FISHING_RESOURCE,RESOURCE_PACK" +5198,Friendship Bonus (1 <3),useful,FRIENDSHIP_PACK +5199,Friendship Bonus (2 <3),useful,FRIENDSHIP_PACK +5200,Friendship Bonus (3 <3),useful,FRIENDSHIP_PACK +5201,Friendship Bonus (4 <3),useful,FRIENDSHIP_PACK diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv new file mode 100644 index 0000000000..abad3c042d --- /dev/null +++ b/worlds/stardew_valley/data/locations.csv @@ -0,0 +1,379 @@ +id,region,name,tags +1,Crafts Room,Spring Foraging Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,CRAFTS_ROOM_BUNDLE,MANDATORY" +2,Crafts Room,Summer Foraging Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,CRAFTS_ROOM_BUNDLE,MANDATORY" +3,Crafts Room,Fall Foraging Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,CRAFTS_ROOM_BUNDLE,MANDATORY" +4,Crafts Room,Winter Foraging Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,CRAFTS_ROOM_BUNDLE,MANDATORY" +5,Crafts Room,Construction Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,CRAFTS_ROOM_BUNDLE,MANDATORY" +6,Crafts Room,Exotic Foraging Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,CRAFTS_ROOM_BUNDLE,MANDATORY" +7,Pantry,Spring Crops Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,PANTRY_BUNDLE" +8,Pantry,Summer Crops Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,PANTRY_BUNDLE" +9,Pantry,Fall Crops Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,PANTRY_BUNDLE" +10,Pantry,Quality Crops Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,PANTRY_BUNDLE" +11,Pantry,Animal Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,PANTRY_BUNDLE" +12,Pantry,Artisan Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,PANTRY_BUNDLE" +13,Fish Tank,River Fish Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,FISH_TANK_BUNDLE,MANDATORY" +14,Fish Tank,Lake Fish Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,FISH_TANK_BUNDLE,MANDATORY" +15,Fish Tank,Ocean Fish Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,FISH_TANK_BUNDLE,MANDATORY" +16,Fish Tank,Night Fishing Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,FISH_TANK_BUNDLE,MANDATORY" +17,Fish Tank,Crab Pot Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,FISH_TANK_BUNDLE,MANDATORY" +18,Fish Tank,Specialty Fish Bundle,"BUNDLE,COMMUNITY_CENTER_BUNDLE,FISH_TANK_BUNDLE,MANDATORY" +19,Boiler Room,Blacksmith's Bundle,"BOILER_ROOM_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +20,Boiler Room,Geologist's Bundle,"BOILER_ROOM_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +21,Boiler Room,Adventurer's Bundle,"BOILER_ROOM_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +22,Bulletin Board,Chef's Bundle,"BULLETIN_BOARD_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +23,Bulletin Board,Dye Bundle,"BULLETIN_BOARD_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +24,Bulletin Board,Field Research Bundle,"BULLETIN_BOARD_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +25,Bulletin Board,Fodder Bundle,"BULLETIN_BOARD_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +26,Bulletin Board,Enchanter's Bundle,"BULLETIN_BOARD_BUNDLE,BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY" +27,Vault,"2,500g Bundle","BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,VAULT_BUNDLE" +28,Vault,"5,000g Bundle","BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,VAULT_BUNDLE" +29,Vault,"10,000g Bundle","BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,VAULT_BUNDLE" +30,Vault,"25,000g Bundle","BUNDLE,COMMUNITY_CENTER_BUNDLE,MANDATORY,VAULT_BUNDLE" +31,Abandoned JojaMart,The Missing Bundle,BUNDLE +32,Crafts Room,Complete Crafts Room,"COMMUNITY_CENTER_ROOM,MANDATORY" +33,Pantry,Complete Pantry,"COMMUNITY_CENTER_ROOM,MANDATORY" +34,Fish Tank,Complete Fish Tank,"COMMUNITY_CENTER_ROOM,MANDATORY" +35,Boiler Room,Complete Boiler Room,"COMMUNITY_CENTER_ROOM,MANDATORY" +36,Bulletin Board,Complete Bulletin Board,"COMMUNITY_CENTER_ROOM,MANDATORY" +37,Vault,Complete Vault,"COMMUNITY_CENTER_ROOM,MANDATORY" +101,Pierre's General Store,Large Pack,BACKPACK +102,Pierre's General Store,Deluxe Pack,BACKPACK +103,Clint's Blacksmith,Copper Hoe Upgrade,"HOE_UPGRADE,TOOL_UPGRADE" +104,Clint's Blacksmith,Iron Hoe Upgrade,"HOE_UPGRADE,TOOL_UPGRADE" +105,Clint's Blacksmith,Gold Hoe Upgrade,"HOE_UPGRADE,TOOL_UPGRADE" +106,Clint's Blacksmith,Iridium Hoe Upgrade,"HOE_UPGRADE,TOOL_UPGRADE" +107,Clint's Blacksmith,Copper Pickaxe Upgrade,"PICKAXE_UPGRADE,TOOL_UPGRADE" +108,Clint's Blacksmith,Iron Pickaxe Upgrade,"PICKAXE_UPGRADE,TOOL_UPGRADE" +109,Clint's Blacksmith,Gold Pickaxe Upgrade,"PICKAXE_UPGRADE,TOOL_UPGRADE" +110,Clint's Blacksmith,Iridium Pickaxe Upgrade,"PICKAXE_UPGRADE,TOOL_UPGRADE" +111,Clint's Blacksmith,Copper Axe Upgrade,"AXE_UPGRADE,TOOL_UPGRADE" +112,Clint's Blacksmith,Iron Axe Upgrade,"AXE_UPGRADE,TOOL_UPGRADE" +113,Clint's Blacksmith,Gold Axe Upgrade,"AXE_UPGRADE,TOOL_UPGRADE" +114,Clint's Blacksmith,Iridium Axe Upgrade,"AXE_UPGRADE,TOOL_UPGRADE" +115,Clint's Blacksmith,Copper Watering Can Upgrade,"TOOL_UPGRADE,WATERING_CAN_UPGRADE" +116,Clint's Blacksmith,Iron Watering Can Upgrade,"TOOL_UPGRADE,WATERING_CAN_UPGRADE" +117,Clint's Blacksmith,Gold Watering Can Upgrade,"TOOL_UPGRADE,WATERING_CAN_UPGRADE" +118,Clint's Blacksmith,Iridium Watering Can Upgrade,"TOOL_UPGRADE,WATERING_CAN_UPGRADE" +119,Clint's Blacksmith,Copper Trash Can Upgrade,"TOOL_UPGRADE,TRASH_CAN_UPGRADE" +120,Clint's Blacksmith,Iron Trash Can Upgrade,"TOOL_UPGRADE,TRASH_CAN_UPGRADE" +121,Clint's Blacksmith,Gold Trash Can Upgrade,"TOOL_UPGRADE,TRASH_CAN_UPGRADE" +122,Clint's Blacksmith,Iridium Trash Can Upgrade,"TOOL_UPGRADE,TRASH_CAN_UPGRADE" +123,Willy's Fish Shop,Purchase Training Rod,"FISHING_ROD_UPGRADE,TOOL_UPGRADE" +124,Stardew Valley,Bamboo Pole Cutscene,"FISHING_ROD_UPGRADE,TOOL_UPGRADE" +125,Willy's Fish Shop,Purchase Fiberglass Rod,"FISHING_ROD_UPGRADE,TOOL_UPGRADE" +126,Willy's Fish Shop,Purchase Iridium Rod,"FISHING_ROD_UPGRADE,TOOL_UPGRADE" +201,The Mines - Floor 10,The Mines Floor 10 Treasure,"MANDATORY,THE_MINES_TREASURE" +202,The Mines - Floor 20,The Mines Floor 20 Treasure,"MANDATORY,THE_MINES_TREASURE" +203,The Mines - Floor 40,The Mines Floor 40 Treasure,"MANDATORY,THE_MINES_TREASURE" +204,The Mines - Floor 50,The Mines Floor 50 Treasure,"MANDATORY,THE_MINES_TREASURE" +205,The Mines - Floor 60,The Mines Floor 60 Treasure,"MANDATORY,THE_MINES_TREASURE" +206,The Mines - Floor 70,The Mines Floor 70 Treasure,"MANDATORY,THE_MINES_TREASURE" +207,The Mines - Floor 80,The Mines Floor 80 Treasure,"MANDATORY,THE_MINES_TREASURE" +208,The Mines - Floor 90,The Mines Floor 90 Treasure,"MANDATORY,THE_MINES_TREASURE" +209,The Mines - Floor 100,The Mines Floor 100 Treasure,"MANDATORY,THE_MINES_TREASURE" +210,The Mines - Floor 110,The Mines Floor 110 Treasure,"MANDATORY,THE_MINES_TREASURE" +211,The Mines - Floor 120,The Mines Floor 120 Treasure,"MANDATORY,THE_MINES_TREASURE" +212,Quarry Mine,Grim Reaper statue,MANDATORY +213,The Mines,The Mines Entrance Cutscene,MANDATORY +214,The Mines - Floor 5,Floor 5 Elevator,THE_MINES_ELEVATOR +215,The Mines - Floor 10,Floor 10 Elevator,THE_MINES_ELEVATOR +216,The Mines - Floor 15,Floor 15 Elevator,THE_MINES_ELEVATOR +217,The Mines - Floor 20,Floor 20 Elevator,THE_MINES_ELEVATOR +218,The Mines - Floor 25,Floor 25 Elevator,THE_MINES_ELEVATOR +219,The Mines - Floor 30,Floor 30 Elevator,THE_MINES_ELEVATOR +220,The Mines - Floor 35,Floor 35 Elevator,THE_MINES_ELEVATOR +221,The Mines - Floor 40,Floor 40 Elevator,THE_MINES_ELEVATOR +222,The Mines - Floor 45,Floor 45 Elevator,THE_MINES_ELEVATOR +223,The Mines - Floor 50,Floor 50 Elevator,THE_MINES_ELEVATOR +224,The Mines - Floor 55,Floor 55 Elevator,THE_MINES_ELEVATOR +225,The Mines - Floor 60,Floor 60 Elevator,THE_MINES_ELEVATOR +226,The Mines - Floor 65,Floor 65 Elevator,THE_MINES_ELEVATOR +227,The Mines - Floor 70,Floor 70 Elevator,THE_MINES_ELEVATOR +228,The Mines - Floor 75,Floor 75 Elevator,THE_MINES_ELEVATOR +229,The Mines - Floor 80,Floor 80 Elevator,THE_MINES_ELEVATOR +230,The Mines - Floor 85,Floor 85 Elevator,THE_MINES_ELEVATOR +231,The Mines - Floor 90,Floor 90 Elevator,THE_MINES_ELEVATOR +232,The Mines - Floor 95,Floor 95 Elevator,THE_MINES_ELEVATOR +233,The Mines - Floor 100,Floor 100 Elevator,THE_MINES_ELEVATOR +234,The Mines - Floor 105,Floor 105 Elevator,THE_MINES_ELEVATOR +235,The Mines - Floor 110,Floor 110 Elevator,THE_MINES_ELEVATOR +236,The Mines - Floor 115,Floor 115 Elevator,THE_MINES_ELEVATOR +237,The Mines - Floor 120,Floor 120 Elevator,THE_MINES_ELEVATOR +301,Stardew Valley,Level 1 Farming,"FARMING_LEVEL,SKILL_LEVEL" +302,Stardew Valley,Level 2 Farming,"FARMING_LEVEL,SKILL_LEVEL" +303,Stardew Valley,Level 3 Farming,"FARMING_LEVEL,SKILL_LEVEL" +304,Stardew Valley,Level 4 Farming,"FARMING_LEVEL,SKILL_LEVEL" +305,Stardew Valley,Level 5 Farming,"FARMING_LEVEL,SKILL_LEVEL" +306,Stardew Valley,Level 6 Farming,"FARMING_LEVEL,SKILL_LEVEL" +307,Stardew Valley,Level 7 Farming,"FARMING_LEVEL,SKILL_LEVEL" +308,Stardew Valley,Level 8 Farming,"FARMING_LEVEL,SKILL_LEVEL" +309,Stardew Valley,Level 9 Farming,"FARMING_LEVEL,SKILL_LEVEL" +310,Stardew Valley,Level 10 Farming,"FARMING_LEVEL,SKILL_LEVEL" +311,Stardew Valley,Level 1 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +312,Stardew Valley,Level 2 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +313,Stardew Valley,Level 3 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +314,Stardew Valley,Level 4 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +315,Stardew Valley,Level 5 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +316,Stardew Valley,Level 6 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +317,Stardew Valley,Level 7 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +318,Stardew Valley,Level 8 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +319,Stardew Valley,Level 9 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +320,Stardew Valley,Level 10 Fishing,"FISHING_LEVEL,SKILL_LEVEL" +321,Stardew Valley,Level 1 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +322,Stardew Valley,Level 2 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +323,Stardew Valley,Level 3 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +324,Stardew Valley,Level 4 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +325,Stardew Valley,Level 5 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +326,Stardew Valley,Level 6 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +327,Stardew Valley,Level 7 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +328,Stardew Valley,Level 8 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +329,Stardew Valley,Level 9 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +330,Stardew Valley,Level 10 Foraging,"FORAGING_LEVEL,SKILL_LEVEL" +331,Stardew Valley,Level 1 Mining,"MINING_LEVEL,SKILL_LEVEL" +332,Stardew Valley,Level 2 Mining,"MINING_LEVEL,SKILL_LEVEL" +333,Stardew Valley,Level 3 Mining,"MINING_LEVEL,SKILL_LEVEL" +334,Stardew Valley,Level 4 Mining,"MINING_LEVEL,SKILL_LEVEL" +335,Stardew Valley,Level 5 Mining,"MINING_LEVEL,SKILL_LEVEL" +336,Stardew Valley,Level 6 Mining,"MINING_LEVEL,SKILL_LEVEL" +337,Stardew Valley,Level 7 Mining,"MINING_LEVEL,SKILL_LEVEL" +338,Stardew Valley,Level 8 Mining,"MINING_LEVEL,SKILL_LEVEL" +339,Stardew Valley,Level 9 Mining,"MINING_LEVEL,SKILL_LEVEL" +340,Stardew Valley,Level 10 Mining,"MINING_LEVEL,SKILL_LEVEL" +341,Stardew Valley,Level 1 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +342,Stardew Valley,Level 2 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +343,Stardew Valley,Level 3 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +344,Stardew Valley,Level 4 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +345,Stardew Valley,Level 5 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +346,Stardew Valley,Level 6 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +347,Stardew Valley,Level 7 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +348,Stardew Valley,Level 8 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +349,Stardew Valley,Level 9 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +350,Stardew Valley,Level 10 Combat,"COMBAT_LEVEL,SKILL_LEVEL" +401,Carpenter Shop,Coop Blueprint,BUILDING_BLUEPRINT +402,Carpenter Shop,Big Coop Blueprint,BUILDING_BLUEPRINT +403,Carpenter Shop,Deluxe Coop Blueprint,BUILDING_BLUEPRINT +404,Carpenter Shop,Barn Blueprint,BUILDING_BLUEPRINT +405,Carpenter Shop,Big Barn Blueprint,BUILDING_BLUEPRINT +406,Carpenter Shop,Deluxe Barn Blueprint,BUILDING_BLUEPRINT +407,Carpenter Shop,Well Blueprint,BUILDING_BLUEPRINT +408,Carpenter Shop,Silo Blueprint,BUILDING_BLUEPRINT +409,Carpenter Shop,Mill Blueprint,BUILDING_BLUEPRINT +410,Carpenter Shop,Shed Blueprint,BUILDING_BLUEPRINT +411,Carpenter Shop,Big Shed Blueprint,BUILDING_BLUEPRINT +412,Carpenter Shop,Fish Pond Blueprint,BUILDING_BLUEPRINT +413,Carpenter Shop,Stable Blueprint,BUILDING_BLUEPRINT +414,Carpenter Shop,Slime Hutch Blueprint,BUILDING_BLUEPRINT +415,Carpenter Shop,Shipping Bin Blueprint,BUILDING_BLUEPRINT +416,Carpenter Shop,Kitchen Blueprint,BUILDING_BLUEPRINT +417,Carpenter Shop,Kids Room Blueprint,BUILDING_BLUEPRINT +418,Carpenter Shop,Cellar Blueprint,BUILDING_BLUEPRINT +501,Town,Introductions,"MANDATORY,QUEST" +502,Town,How To Win Friends,"MANDATORY,QUEST" +503,Farm,Getting Started,"MANDATORY,QUEST" +504,Farm,Raising Animals,"MANDATORY,QUEST" +505,Farm,Advancement,"MANDATORY,QUEST" +506,Museum,Archaeology,"MANDATORY,QUEST" +507,Wizard Tower,Meet The Wizard,"MANDATORY,QUEST" +508,Farm,Forging Ahead,"MANDATORY,QUEST" +509,Farm,Smelting,"MANDATORY,QUEST" +510,The Mines - Floor 5,Initiation,"MANDATORY,QUEST" +511,Forest,Robin's Lost Axe,"MANDATORY,QUEST" +512,Sam's House,Jodi's Request,"MANDATORY,QUEST" +513,Marnie's Ranch,"Mayor's ""Shorts""","MANDATORY,QUEST" +514,Tunnel Entrance,Blackberry Basket,"MANDATORY,QUEST" +515,Marnie's Ranch,Marnie's Request,"MANDATORY,QUEST" +516,Town,Pam Is Thirsty,"MANDATORY,QUEST" +517,Wizard Tower,A Dark Reagent,"MANDATORY,QUEST" +518,Marnie's Ranch,Cow's Delight,"MANDATORY,QUEST" +519,Skull Cavern Entrance,The Skull Key,"MANDATORY,QUEST" +520,Town,Crop Research,"MANDATORY,QUEST" +521,Town,Knee Therapy,"MANDATORY,QUEST" +522,Town,Robin's Request,"MANDATORY,QUEST" +523,Skull Cavern,Qi's Challenge,"MANDATORY,QUEST" +524,The Desert,The Mysterious Qi,"MANDATORY,QUEST" +525,Town,Carving Pumpkins,"MANDATORY,QUEST" +526,Town,A Winter Mystery,"MANDATORY,QUEST" +527,Secret Woods,Strange Note,"MANDATORY,QUEST" +528,Skull Cavern,Cryptic Note,"MANDATORY,QUEST" +529,Town,Fresh Fruit,"MANDATORY,QUEST" +530,Town,Aquatic Research,"MANDATORY,QUEST" +531,Town,A Soldier's Star,"MANDATORY,QUEST" +532,Town,Mayor's Need,"MANDATORY,QUEST" +533,Saloon,Wanted: Lobster,"MANDATORY,QUEST" +534,Town,Pam Needs Juice,"MANDATORY,QUEST" +535,Sam's House,Fish Casserole,"MANDATORY,QUEST" +536,Beach,Catch A Squid,"MANDATORY,QUEST" +537,Saloon,Fish Stew,"MANDATORY,QUEST" +538,Town,Pierre's Notice,"MANDATORY,QUEST" +539,Town,Clint's Attempt,"MANDATORY,QUEST" +540,Town,A Favor For Clint,"MANDATORY,QUEST" +541,Wizard Tower,Staff Of Power,"MANDATORY,QUEST" +542,Town,Granny's Gift,"MANDATORY,QUEST" +543,Saloon,Exotic Spirits,"MANDATORY,QUEST" +544,Town,Catch a Lingcod,"MANDATORY,QUEST" +601,JotPK World 1,JotPK: Boots 1,"ARCADE_MACHINE,JOTPK" +602,JotPK World 1,JotPK: Boots 2,"ARCADE_MACHINE,JOTPK" +603,JotPK World 1,JotPK: Gun 1,"ARCADE_MACHINE,JOTPK" +604,JotPK World 2,JotPK: Gun 2,"ARCADE_MACHINE,JOTPK" +605,JotPK World 2,JotPK: Gun 3,"ARCADE_MACHINE,JOTPK" +606,JotPK World 3,JotPK: Super Gun,"ARCADE_MACHINE,JOTPK" +607,JotPK World 1,JotPK: Ammo 1,"ARCADE_MACHINE,JOTPK" +608,JotPK World 2,JotPK: Ammo 2,"ARCADE_MACHINE,JOTPK" +609,JotPK World 3,JotPK: Ammo 3,"ARCADE_MACHINE,JOTPK" +610,JotPK World 1,JotPK: Cowboy 1,"ARCADE_MACHINE,JOTPK" +611,JotPK World 2,JotPK: Cowboy 2,"ARCADE_MACHINE,JOTPK" +612,Junimo Kart 1,Junimo Kart: Crumble Cavern,"ARCADE_MACHINE,JUNIMO_KART" +613,Junimo Kart 1,Junimo Kart: Slippery Slopes,"ARCADE_MACHINE,JUNIMO_KART" +614,Junimo Kart 2,Junimo Kart: Secret Level,"ARCADE_MACHINE,JUNIMO_KART" +615,Junimo Kart 2,Junimo Kart: The Gem Sea Giant,"ARCADE_MACHINE,JUNIMO_KART" +616,Junimo Kart 2,Junimo Kart: Slomp's Stomp,"ARCADE_MACHINE,JUNIMO_KART" +617,Junimo Kart 2,Junimo Kart: Ghastly Galleon,"ARCADE_MACHINE,JUNIMO_KART" +618,Junimo Kart 3,Junimo Kart: Glowshroom Grotto,"ARCADE_MACHINE,JUNIMO_KART" +619,Junimo Kart 3,Junimo Kart: Red Hot Rollercoaster,"ARCADE_MACHINE,JUNIMO_KART" +620,JotPK World 3,Journey of the Prairie King Victory,"ARCADE_MACHINE_VICTORY,JUNIMO_KART" +621,Junimo Kart 3,Junimo Kart: Sunset Speedway (Victory),"ARCADE_MACHINE_VICTORY,JUNIMO_KART" +701,Secret Woods,Old Master Cannoli,MANDATORY +702,Beach,Beach Bridge Repair,MANDATORY +703,The Desert,Galaxy Sword Shrine,MANDATORY +801,Town,Help Wanted: Gathering 1,HELP_WANTED +802,Town,Help Wanted: Gathering 2,HELP_WANTED +803,Town,Help Wanted: Gathering 3,HELP_WANTED +804,Town,Help Wanted: Gathering 4,HELP_WANTED +805,Town,Help Wanted: Gathering 5,HELP_WANTED +806,Town,Help Wanted: Gathering 6,HELP_WANTED +807,Town,Help Wanted: Gathering 7,HELP_WANTED +808,Town,Help Wanted: Gathering 8,HELP_WANTED +811,Town,Help Wanted: Slay Monsters 1,HELP_WANTED +812,Town,Help Wanted: Slay Monsters 2,HELP_WANTED +813,Town,Help Wanted: Slay Monsters 3,HELP_WANTED +814,Town,Help Wanted: Slay Monsters 4,HELP_WANTED +815,Town,Help Wanted: Slay Monsters 5,HELP_WANTED +816,Town,Help Wanted: Slay Monsters 6,HELP_WANTED +817,Town,Help Wanted: Slay Monsters 7,HELP_WANTED +818,Town,Help Wanted: Slay Monsters 8,HELP_WANTED +821,Town,Help Wanted: Fishing 1,HELP_WANTED +822,Town,Help Wanted: Fishing 2,HELP_WANTED +823,Town,Help Wanted: Fishing 3,HELP_WANTED +824,Town,Help Wanted: Fishing 4,HELP_WANTED +825,Town,Help Wanted: Fishing 5,HELP_WANTED +826,Town,Help Wanted: Fishing 6,HELP_WANTED +827,Town,Help Wanted: Fishing 7,HELP_WANTED +828,Town,Help Wanted: Fishing 8,HELP_WANTED +841,Town,Help Wanted: Item Delivery 1,HELP_WANTED +842,Town,Help Wanted: Item Delivery 2,HELP_WANTED +843,Town,Help Wanted: Item Delivery 3,HELP_WANTED +844,Town,Help Wanted: Item Delivery 4,HELP_WANTED +845,Town,Help Wanted: Item Delivery 5,HELP_WANTED +846,Town,Help Wanted: Item Delivery 6,HELP_WANTED +847,Town,Help Wanted: Item Delivery 7,HELP_WANTED +848,Town,Help Wanted: Item Delivery 8,HELP_WANTED +849,Town,Help Wanted: Item Delivery 9,HELP_WANTED +850,Town,Help Wanted: Item Delivery 10,HELP_WANTED +851,Town,Help Wanted: Item Delivery 11,HELP_WANTED +852,Town,Help Wanted: Item Delivery 12,HELP_WANTED +853,Town,Help Wanted: Item Delivery 13,HELP_WANTED +854,Town,Help Wanted: Item Delivery 14,HELP_WANTED +855,Town,Help Wanted: Item Delivery 15,HELP_WANTED +856,Town,Help Wanted: Item Delivery 16,HELP_WANTED +857,Town,Help Wanted: Item Delivery 17,HELP_WANTED +858,Town,Help Wanted: Item Delivery 18,HELP_WANTED +859,Town,Help Wanted: Item Delivery 19,HELP_WANTED +860,Town,Help Wanted: Item Delivery 20,HELP_WANTED +861,Town,Help Wanted: Item Delivery 21,HELP_WANTED +862,Town,Help Wanted: Item Delivery 22,HELP_WANTED +863,Town,Help Wanted: Item Delivery 23,HELP_WANTED +864,Town,Help Wanted: Item Delivery 24,HELP_WANTED +865,Town,Help Wanted: Item Delivery 25,HELP_WANTED +866,Town,Help Wanted: Item Delivery 26,HELP_WANTED +867,Town,Help Wanted: Item Delivery 27,HELP_WANTED +868,Town,Help Wanted: Item Delivery 28,HELP_WANTED +869,Town,Help Wanted: Item Delivery 29,HELP_WANTED +870,Town,Help Wanted: Item Delivery 30,HELP_WANTED +871,Town,Help Wanted: Item Delivery 31,HELP_WANTED +872,Town,Help Wanted: Item Delivery 32,HELP_WANTED +901,Forest,Traveling Merchant Sunday Item 1,"MANDATORY,TRAVELING_MERCHANT" +902,Forest,Traveling Merchant Sunday Item 2,"MANDATORY,TRAVELING_MERCHANT" +903,Forest,Traveling Merchant Sunday Item 3,"MANDATORY,TRAVELING_MERCHANT" +911,Forest,Traveling Merchant Monday Item 1,"MANDATORY,TRAVELING_MERCHANT" +912,Forest,Traveling Merchant Monday Item 2,"MANDATORY,TRAVELING_MERCHANT" +913,Forest,Traveling Merchant Monday Item 3,"MANDATORY,TRAVELING_MERCHANT" +921,Forest,Traveling Merchant Tuesday Item 1,"MANDATORY,TRAVELING_MERCHANT" +922,Forest,Traveling Merchant Tuesday Item 2,"MANDATORY,TRAVELING_MERCHANT" +923,Forest,Traveling Merchant Tuesday Item 3,"MANDATORY,TRAVELING_MERCHANT" +931,Forest,Traveling Merchant Wednesday Item 1,"MANDATORY,TRAVELING_MERCHANT" +932,Forest,Traveling Merchant Wednesday Item 2,"MANDATORY,TRAVELING_MERCHANT" +933,Forest,Traveling Merchant Wednesday Item 3,"MANDATORY,TRAVELING_MERCHANT" +941,Forest,Traveling Merchant Thursday Item 1,"MANDATORY,TRAVELING_MERCHANT" +942,Forest,Traveling Merchant Thursday Item 2,"MANDATORY,TRAVELING_MERCHANT" +943,Forest,Traveling Merchant Thursday Item 3,"MANDATORY,TRAVELING_MERCHANT" +951,Forest,Traveling Merchant Friday Item 1,"MANDATORY,TRAVELING_MERCHANT" +952,Forest,Traveling Merchant Friday Item 2,"MANDATORY,TRAVELING_MERCHANT" +953,Forest,Traveling Merchant Friday Item 3,"MANDATORY,TRAVELING_MERCHANT" +961,Forest,Traveling Merchant Saturday Item 1,"MANDATORY,TRAVELING_MERCHANT" +962,Forest,Traveling Merchant Saturday Item 2,"MANDATORY,TRAVELING_MERCHANT" +963,Forest,Traveling Merchant Saturday Item 3,"MANDATORY,TRAVELING_MERCHANT" +1001,Mountain,Fishsanity: Carp,FISHSANITY +1002,Beach,Fishsanity: Herring,FISHSANITY +1003,Forest,Fishsanity: Smallmouth Bass,FISHSANITY +1004,Beach,Fishsanity: Anchovy,FISHSANITY +1005,Beach,Fishsanity: Sardine,FISHSANITY +1006,Forest,Fishsanity: Sunfish,FISHSANITY +1007,Forest,Fishsanity: Perch,FISHSANITY +1008,Forest,Fishsanity: Chub,FISHSANITY +1009,Forest,Fishsanity: Bream,FISHSANITY +1010,Beach,Fishsanity: Red Snapper,FISHSANITY +1011,Beach,Fishsanity: Sea Cucumber,FISHSANITY +1012,Forest,Fishsanity: Rainbow Trout,FISHSANITY +1013,Forest,Fishsanity: Walleye,FISHSANITY +1014,Forest,Fishsanity: Shad,FISHSANITY +1015,Mountain,Fishsanity: Bullhead,FISHSANITY +1016,Mountain,Fishsanity: Largemouth Bass,FISHSANITY +1017,Forest,Fishsanity: Salmon,FISHSANITY +1018,The Mines - Floor 20,Fishsanity: Ghostfish,FISHSANITY +1019,Beach,Fishsanity: Tilapia,FISHSANITY +1020,Secret Woods,Fishsanity: Woodskip,FISHSANITY +1021,Beach,Fishsanity: Flounder,FISHSANITY +1022,Beach,Fishsanity: Halibut,FISHSANITY +1023,Ginger Island,Fishsanity: Lionfish,FISHSANITY +1024,Mutant Bug Lair,Fishsanity: Slimejack,FISHSANITY +1025,Forest,Fishsanity: Midnight Carp,FISHSANITY +1026,Beach,Fishsanity: Red Mullet,FISHSANITY +1027,Forest,Fishsanity: Pike,FISHSANITY +1028,Forest,Fishsanity: Tiger Trout,FISHSANITY +1029,Ginger Island,Fishsanity: Blue Discus,FISHSANITY +1030,Beach,Fishsanity: Albacore,FISHSANITY +1031,The Desert,Fishsanity: Sandfish,FISHSANITY +1032,The Mines - Floor 20,Fishsanity: Stonefish,FISHSANITY +1033,Beach,Fishsanity: Tuna,FISHSANITY +1034,Beach,Fishsanity: Eel,FISHSANITY +1035,Forest,Fishsanity: Catfish,FISHSANITY +1036,Beach,Fishsanity: Squid,FISHSANITY +1037,Mountain,Fishsanity: Sturgeon,FISHSANITY +1038,Forest,Fishsanity: Dorado,FISHSANITY +1039,Beach,Fishsanity: Pufferfish,FISHSANITY +1040,Witch's Swamp,Fishsanity: Void Salmon,FISHSANITY +1041,Beach,Fishsanity: Super Cucumber,FISHSANITY +1042,Ginger Island,Fishsanity: Stingray,FISHSANITY +1043,The Mines - Floor 60,Fishsanity: Ice Pip,FISHSANITY +1044,Forest,Fishsanity: Lingcod,FISHSANITY +1045,The Desert,Fishsanity: Scorpion Carp,FISHSANITY +1046,The Mines - Floor 100,Fishsanity: Lava Eel,FISHSANITY +1047,Beach,Fishsanity: Octopus,FISHSANITY +1048,Beach,Fishsanity: Midnight Squid,FISHSANITY +1049,Beach,Fishsanity: Spook Fish,FISHSANITY +1050,Beach,Fishsanity: Blobfish,FISHSANITY +1051,Beach,Fishsanity: Crimsonfish,FISHSANITY +1052,Town,Fishsanity: Angler,FISHSANITY +1053,Mountain,Fishsanity: Legend,FISHSANITY +1054,Forest,Fishsanity: Glacierfish,FISHSANITY +1055,Sewers,Fishsanity: Mutant Carp,FISHSANITY +1056,Town,Fishsanity: Crayfish,FISHSANITY +1057,Town,Fishsanity: Snail,FISHSANITY +1058,Town,Fishsanity: Periwinkle,FISHSANITY +1059,Beach,Fishsanity: Lobster,FISHSANITY +1060,Beach,Fishsanity: Clam,FISHSANITY +1061,Beach,Fishsanity: Crab,FISHSANITY +1062,Beach,Fishsanity: Cockle,FISHSANITY +1063,Beach,Fishsanity: Mussel,FISHSANITY +1064,Beach,Fishsanity: Shrimp,FISHSANITY +1065,Beach,Fishsanity: Oyster,FISHSANITY diff --git a/worlds/stardew_valley/data/resource_packs.csv b/worlds/stardew_valley/data/resource_packs.csv new file mode 100644 index 0000000000..0508ee35de --- /dev/null +++ b/worlds/stardew_valley/data/resource_packs.csv @@ -0,0 +1,39 @@ +name,default_amount,scaling_factor,classification,groups +Money,1000,500,useful,BASE_RESOURCE +Stone,50,25,filler,BASE_RESOURCE +Wood,50,25,filler,BASE_RESOURCE +Hardwood,10,5,useful,BASE_RESOURCE +Fiber,30,15,filler,BASE_RESOURCE +Coal,10,5,filler,BASE_RESOURCE +Clay,10,5,filler,BASE_RESOURCE +Warp Totem: Beach,5,2,filler,WARP_TOTEM +Warp Totem: Desert,5,2,filler,WARP_TOTEM +Warp Totem: Farm,5,2,filler,WARP_TOTEM +Warp Totem: Island,5,2,filler,WARP_TOTEM +Warp Totem: Mountains,5,2,filler,WARP_TOTEM +Geode,12,6,filler,GEODE +Frozen Geode,8,4,filler,GEODE +Magma Geode,6,3,filler,GEODE +Omni Geode,4,2,useful,GEODE +Copper Ore,75,25,filler,ORE +Iron Ore,50,25,filler,ORE +Gold Ore,25,13,useful,ORE +Iridium Ore,10,5,useful,ORE +Quartz,10,5,filler,ORE +Basic Fertilizer,30,10,filler,FERTILIZER +Basic Retaining Soil,30,10,filler,FERTILIZER +Speed-Gro,30,10,filler,FERTILIZER +Quality Fertilizer,20,8,filler,FERTILIZER +Quality Retaining Soil,20,8,filler,FERTILIZER +Deluxe Speed-Gro,20,8,filler,FERTILIZER +Deluxe Fertilizer,10,4,useful,FERTILIZER +Deluxe Retaining Soil,10,4,useful,FERTILIZER +Hyper Speed-Gro,10,4,useful,FERTILIZER +Tree Fertilizer,10,4,filler,FERTILIZER +Spring Seeds,30,10,filler,SEED +Summer Seeds,30,10,filler,SEED +Fall Seeds,30,10,filler,SEED +Winter Seeds,30,10,filler,SEED +Mahogany Seed,5,2,filler,SEED +Bait,30,10,filler,FISHING_RESOURCE +Crab Pot,3,1,filler,FISHING_RESOURCE \ No newline at end of file diff --git a/worlds/stardew_valley/docs/en_Stardew Valley.md b/worlds/stardew_valley/docs/en_Stardew Valley.md new file mode 100644 index 0000000000..aef280864b --- /dev/null +++ b/worlds/stardew_valley/docs/en_Stardew Valley.md @@ -0,0 +1,71 @@ +# Stardew Valley + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What does randomization do to this game? + +A vast number of optional objectives in stardew valley can be shuffled around the multiworld. Most of these are optional, and the player can customize their experience in their YAML file. + +For these objectives, if they have a vanilla reward, this reward will instead be an item in the multiworld. For the remaining number of such objectives, there are a number of "Resource Pack" items, which are simply a stack of an item that may be useful to the player. + +## What is the goal of Stardew Valley? + +The player can choose from a number of goals, using their YAML settings. +- Complete the Community Center +- Succeed Grandpa's Evaluation with 4 lit candles +- Reach the bottom of the Pelican Town Mineshaft +- Complete the "Cryptic Note" quest, by meeting Mr Qi on floor 100 of the Skull Cavern +- Get the achievement "Master Angler", which requires catching every fish in the game + +## What are location check in Stardew Valley? + +Location checks in Stardew Valley always include: +- Community Center Bundles +- Mineshaft chest rewards +- Story Quests +- Traveling Merchant items +- Isolated objectives such as the beach bridge, Old Master Cannoli, Grim Reaper Statue, etc + +There also are a number of location checks that are optional, and individual players choose to include them or not in their shuffling: +- Tools and Fishing Rod Upgrades +- Carpenter Buildings +- Backpack Upgrades +- Mine elevator levels +- Skill Levels +- Arcade Machines +- Help Wanted quests +- Fishsanity: Catching individual fish + +## Which items can be in another player's world? + +Every normal reward from the above locations can be in another player's world. +For the locations which do not include a normal reward, Resource Packs are instead added to the pool. These can contain ores, seeds, fertilizers, warp totems, etc. +There are a few extra items, which are added to the pool but do not have a matching location. These include +- Wizard Buildings +- Return Scepter + +And lastly, some Archipelago-exclusive items exist in the pool, which are designed around game balance and QoL. These include: +- Arcade Machine buffs (Only if the arcade machines are randomized) + - Journey of the Prairie King has drop rate increases, extra lives, and equipment + - Junimo Kart has extra lives. +- Permanent Movement Speed Bonuses (customizable) +- Permanent Luck Bonuses (customizable) +- Traveling Merchant buffs + +## When the player receives an item, what happens? + +Since Pelican Town is a remote area, it takes one business day for every item to reach the player. If an item is received while online, it will appear in the player's mailbox the next morning, with a message from the sender telling them where it was found. +If an item is received while offline, it will be in the mailbox as soon as the player logs in. + +Some items will be directly attached to the letter, while some others will instead be a world-wide unlock, and the letter only serves to tell the player about it. + +In some cases, like receiving Carpenter and Wizard buildings, the player will still need to go ask Robin to construct the building that they have received, so they can choose its position. This construction will be completely free. + +## Multiplayer + +You cannot play an Archipelago Slot in multiplayer at the moment. There is no short-terms plans to support that feature. + +You can, however, send Stardew Valley objects as gifts from one Stardew Player to another Stardew player, using in-game Joja Prime delivery, for a fee. This exclusive feature can be turned off if you don't want to send and receive gifts. diff --git a/worlds/stardew_valley/docs/setup_en.md b/worlds/stardew_valley/docs/setup_en.md new file mode 100644 index 0000000000..7ac9c8a814 --- /dev/null +++ b/worlds/stardew_valley/docs/setup_en.md @@ -0,0 +1,74 @@ +# Stardew Valley Randomizer Setup Guide + +## Required Software + +- Stardew Valley on PC (Recommended: [Steam version](https://store.steampowered.com/app/413150/Stardew_Valley/)) +- SMAPI ([Mod loader for Stardew Valley](https://smapi.io/)) +- [StardewArchipelago Mod Release 2.x.x](https://github.com/agilbert1412/StardewArchipelago/releases) + - It is important to use a mod release of version 2.x.x to play seeds that have been generated here. Later releases can only be used with later releases of the world generator, that are not hosted on archipelago.gg yet. + +## Optional Software +- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) + - (Only for the TextClient) +- Other Stardew Valley Mods [Nexus Mods](https://www.nexusmods.com/stardewvalley) + - It is **not** recommended to further mod Stardew Valley, altough it is possible to do so. Mod interactions can be unpredictable, and no support will be offered for related bugs. + - The more mods you have, and the bigger they are, the more likely things are to break. + +## Configuring your YAML file + +### What is a YAML file and why do I need one? + +See the guide on setting up a basic YAML at the Archipelago setup +guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en) + +### Where do I get a YAML file? + +You can customize your settings by visiting the [Stardew Valley Player Settings Page](/games/Stardew Valley/player-settings) + +## Joining a MultiWorld Game + +### Installing the mod + +- Install [SMAPI](https://smapi.io/) by following the instructions on their website +- Download and extract the [StardewArchipelago](https://github.com/agilbert1412/StardewArchipelago/releases) mod into your Stardew Valley "Mods" folder +- *OPTIONAL*: If you want to launch your game through Steam, add the following to your Stardew Valley launch options: + - "[PATH TO STARDEW VALLEY]\Stardew Valley\StardewModdingAPI.exe" %command% +- Otherwise just launch "StardewModdingAPI.exe" in your installation folder directly +- Stardew Valley should launch itself alongside a console which allows you to read mod information and interact with some of them. + +### Connect to the MultiServer + +Launch Stardew Valley with SMAPI. Once you have reached the Stardew Valley title screen, create a new farm. + +On the new character creation page, you will see 3 new fields, used to link your new character to an archipelago multiworld + +![image](https://i.imgur.com/b8KZy2F.png) + +You can customize your farm and character as much as desired. + +The Server text box needs to have both the address and the port, and your slotname is the name specified in your yaml + +`archipelago.gg:38281` + +`StardewPlayer` + +The password is optional. + +Your game will connect automatically to Archipelago, and reconnect automatically when loading the save, later. + +You will never need to enter this information again for this character. + +### Interacting with the MultiWorld from in-game + +When you connect, you should see a message in the chat informing you of the `!!help` command. This command will list other Stardew-exclusive chat commands you can use. + +Furthermore, you can use the in-game chat box to talk to other players in the multiworld, assuming they are using a game that supports chatting. + +Lastly, you can also run Archipelago commands `!help` from the in game chat box, allowing you to request hints on certain items, or check missing locations. + +It is important to note that the Stardew Valley chat is fairly limited in its capabilities. For example, it doesn't allow scrolling up to see history that has been pushed off screen. The SMAPI console running alonside your game will have the full history as well and may be better suited to read older messages. +For a better chat experience, you can also use the official Archipelago Text Client, altough it will not allow you to run Stardew-exclusive commands. + +### Multiplayer + +You cannot play an Archipelago Slot in multiplayer at the moment. There is no short-terms plans to support that feature. \ No newline at end of file diff --git a/worlds/stardew_valley/fish_data.py b/worlds/stardew_valley/fish_data.py new file mode 100644 index 0000000000..270accb478 --- /dev/null +++ b/worlds/stardew_valley/fish_data.py @@ -0,0 +1,127 @@ +from typing import List, Tuple + +from .game_item import FishItem + +spring = ("Spring",) +summer = ("Summer",) +fall = ("Fall",) +winter = ("Winter",) +spring_summer = (*spring, *summer) +spring_fall = (*spring, *fall) +spring_winter = (*spring, *winter) +summer_fall = (*summer, *fall) +summer_winter = (*summer, *winter) +fall_winter = (*fall, *winter) +spring_summer_fall = (*spring, *summer, *fall) +spring_summer_winter = (*spring, *summer, *winter) +spring_fall_winter = (*spring, *fall, *winter) +all_seasons = (*spring, *summer, *fall, *winter) + +town = ("Town",) +beach = ("Beach",) +mountain = ("Mountain",) +forest = ("Forest",) +secret_woods = ("Secret Woods",) +desert = ("The Desert",) +mines_20 = ("The Mines - Floor 20",) +mines_60 = ("The Mines - Floor 60",) +mines_100 = ("The Mines - Floor 100",) +sewers = ("Sewers",) +mutant_bug_lair = ("Mutant Bug Lair",) +witch_swamp = ("Witch's Swamp",) +ginger_island = ("Ginger Island",) +ginger_island_ocean = ginger_island +ginger_island_river = ginger_island +pirate_cove = ginger_island +night_market = beach +lakes = (*mountain, *secret_woods, *sewers) +ocean = beach +rivers = (*town, *forest) +rivers_secret_woods = (*rivers, *secret_woods) +forest_mountain = (*forest, *mountain) +rivers_mountain_lake = (*town, *forest, *mountain) +mines_20_60 = (*mines_20, *mines_60) + +all_fish_items: List[FishItem] = [] + + +def fish(name: str, item_id: int, locations: Tuple[str, ...], seasons: Tuple[str, ...], difficulty: int) -> FishItem: + fish_item = FishItem(name, item_id, locations, seasons, difficulty) + all_fish_items.append(fish_item) + return fish_item + + +carp = fish("Carp", 142, lakes, all_seasons, 15) +herring = fish("Herring", 147, ocean, spring_winter, 25) +smallmouth_bass = fish("Smallmouth Bass", 137, rivers, spring_fall, 28) +anchovy = fish("Anchovy", 129, ocean, spring_fall, 30) +sardine = fish("Sardine", 131, ocean, spring_fall_winter, 30) +sunfish = fish("Sunfish", 145, rivers, spring_summer, 30) +perch = fish("Perch", 141, rivers_mountain_lake, winter, 35) +chub = fish("Chub", 702, forest_mountain, all_seasons, 35) +bream = fish("Bream", 132, rivers, all_seasons, 35) +red_snapper = fish("Red Snapper", 150, ocean, summer_fall, 40) +sea_cucumber = fish("Sea Cucumber", 154, ocean, fall_winter, 40) +rainbow_trout = fish("Rainbow Trout", 138, rivers_mountain_lake, summer, 45) +walleye = fish("Walleye", 140, rivers_mountain_lake, fall, 45) +shad = fish("Shad", 706, rivers, spring_summer_fall, 45) +bullhead = fish("Bullhead", 700, mountain, all_seasons, 46) +largemouth_bass = fish("Largemouth Bass", 136, mountain, all_seasons, 50) +salmon = fish("Salmon", 139, rivers, fall, 50) +ghostfish = fish("Ghostfish", 156, mines_20_60, all_seasons, 50) +tilapia = fish("Tilapia", 701, ocean, summer_fall, 50) +woodskip = fish("Woodskip", 734, secret_woods, all_seasons, 50) +flounder = fish("Flounder", 267, ocean, spring_summer, 50) +halibut = fish("Halibut", 708, ocean, spring_summer_winter, 50) +lionfish = fish("Lionfish", 837, ginger_island_ocean, all_seasons, 50) +slimejack = fish("Slimejack", 796, mutant_bug_lair, all_seasons, 55) +midnight_carp = fish("Midnight Carp", 269, forest_mountain, fall_winter, 55) +red_mullet = fish("Red Mullet", 146, ocean, summer_winter, 55) +pike = fish("Pike", 144, rivers, summer_winter, 60) +tiger_trout = fish("Tiger Trout", 699, rivers, fall_winter, 60) +blue_discus = fish("Blue Discus", 838, ginger_island_river, all_seasons, 60) +albacore = fish("Albacore", 705, ocean, fall_winter, 60) +sandfish = fish("Sandfish", 164, desert, all_seasons, 65) +stonefish = fish("Stonefish", 158, mines_20, all_seasons, 65) +tuna = fish("Tuna", 130, ocean, summer_winter, 70) +eel = fish("Eel", 148, ocean, spring_fall, 70) +catfish = fish("Catfish", 143, rivers_secret_woods, spring_fall, 75) +squid = fish("Squid", 151, ocean, winter, 75) +sturgeon = fish("Sturgeon", 698, mountain, summer_winter, 78) +dorado = fish("Dorado", 704, forest, summer, 78) +pufferfish = fish("Pufferfish", 128, ocean, summer, 80) +void_salmon = fish("Void Salmon", 795, witch_swamp, all_seasons, 80) +super_cucumber = fish("Super Cucumber", 155, ocean, summer_fall, 80) +stingray = fish("Stingray", 836, pirate_cove, all_seasons, 80) +ice_pip = fish("Ice Pip", 161, mines_60, all_seasons, 85) +lingcod = fish("Lingcod", 707, rivers_mountain_lake, winter, 85) +scorpion_carp = fish("Scorpion Carp", 165, desert, all_seasons, 90) +lava_eel = fish("Lava Eel", 162, mines_100, all_seasons, 90) +octopus = fish("Octopus", 149, ocean, summer, 95) + +midnight_squid = fish("Midnight Squid", 798, night_market, winter, 55) +spook_fish = fish("Spook Fish", 799, night_market, winter, 60) +blob_fish = fish("Blobfish", 800, night_market, winter, 75) + +crimsonfish = fish("Crimsonfish", 159, ocean, summer, 95) +angler = fish("Angler", 160, town, fall, 85) +legend = fish("Legend", 163, mountain, spring, 110) +glacierfish = fish("Glacierfish", 775, forest, winter, 100) +mutant_carp = fish("Mutant Carp", 682, sewers, all_seasons, 80) + +crayfish = fish("Crayfish", 716, rivers, all_seasons, -1) +snail = fish("Snail", 721, rivers, all_seasons, -1) +periwinkle = fish("Periwinkle", 722, rivers, all_seasons, -1) +lobster = fish("Lobster", 715, ocean, all_seasons, -1) +clam = fish("Clam", 372, ocean, all_seasons, -1) +crab = fish("Crab", 717, ocean, all_seasons, -1) +cockle = fish("Cockle", 718, ocean, all_seasons, -1) +mussel = fish("Mussel", 719, ocean, all_seasons, -1) +shrimp = fish("Shrimp", 720, ocean, all_seasons, -1) +oyster = fish("Oyster", 723, ocean, all_seasons, -1) + +legendary_fish = [crimsonfish, angler, legend, glacierfish, mutant_carp] +special_fish = [*legendary_fish, blob_fish, lava_eel, octopus, scorpion_carp, ice_pip, super_cucumber, dorado] + +all_fish_items_by_name = {fish.name: fish for fish in all_fish_items} +all_fish_items_by_id = {fish.item_id: fish for fish in all_fish_items} diff --git a/worlds/stardew_valley/game_item.py b/worlds/stardew_valley/game_item.py new file mode 100644 index 0000000000..6b8eb6c6aa --- /dev/null +++ b/worlds/stardew_valley/game_item.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Tuple + + +@dataclass(frozen=True) +class GameItem: + name: str + item_id: int + + def __repr__(self): + return f"{self.name} [{self.item_id}]" + + def __lt__(self, other): + return self.name < other.name + + +@dataclass(frozen=True) +class FishItem(GameItem): + locations: Tuple[str] + seasons: Tuple[str] + difficulty: int + + def __repr__(self): + return f"{self.name} [{self.item_id}] (Locations: {self.locations} |" \ + f" Seasons: {self.seasons} |" \ + f" Difficulty: {self.difficulty}) " diff --git a/worlds/stardew_valley/items.py b/worlds/stardew_valley/items.py new file mode 100644 index 0000000000..03419a1610 --- /dev/null +++ b/worlds/stardew_valley/items.py @@ -0,0 +1,376 @@ +import bisect +import csv +import enum +import itertools +import logging +import math +import typing +from collections import OrderedDict +from dataclasses import dataclass, field +from functools import cached_property +from pathlib import Path +from random import Random +from typing import Dict, List, Protocol, Union, Set, Optional, FrozenSet + +from BaseClasses import Item, ItemClassification +from . import options, data + +ITEM_CODE_OFFSET = 717000 + +logger = logging.getLogger(__name__) +world_folder = Path(__file__).parent + + +class Group(enum.Enum): + RESOURCE_PACK = enum.auto() + FRIENDSHIP_PACK = enum.auto() + COMMUNITY_REWARD = enum.auto() + TRASH = enum.auto() + MINES_FLOOR_10 = enum.auto() + MINES_FLOOR_20 = enum.auto() + MINES_FLOOR_50 = enum.auto() + MINES_FLOOR_60 = enum.auto() + MINES_FLOOR_80 = enum.auto() + MINES_FLOOR_90 = enum.auto() + MINES_FLOOR_110 = enum.auto() + FOOTWEAR = enum.auto() + HATS = enum.auto() + RING = enum.auto() + WEAPON = enum.auto() + PROGRESSIVE_TOOLS = enum.auto() + SKILL_LEVEL_UP = enum.auto() + ARCADE_MACHINE_BUFFS = enum.auto() + GALAXY_WEAPONS = enum.auto() + BASE_RESOURCE = enum.auto() + WARP_TOTEM = enum.auto() + GEODE = enum.auto() + ORE = enum.auto() + FERTILIZER = enum.auto() + SEED = enum.auto() + FISHING_RESOURCE = enum.auto() + + +@dataclass(frozen=True) +class ItemData: + code_without_offset: Optional[int] + name: str + classification: ItemClassification + groups: Set[Group] = field(default_factory=frozenset) + + def __post_init__(self): + if not isinstance(self.groups, frozenset): + super().__setattr__("groups", frozenset(self.groups)) + + @property + def code(self): + return ITEM_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None + + def has_any_group(self, *group: Group) -> bool: + groups = set(group) + return bool(groups.intersection(self.groups)) + + +@dataclass(frozen=True) +class ResourcePackData: + name: str + default_amount: int = 1 + scaling_factor: int = 1 + classification: ItemClassification = ItemClassification.filler + groups: FrozenSet[Group] = frozenset() + + def as_item_data(self, counter: itertools.count) -> [ItemData]: + return [ItemData(next(counter), self.create_item_name(quantity), self.classification, + {Group.RESOURCE_PACK} | self.groups) + for quantity in self.scale_quantity.values()] + + def create_item_name(self, quantity: int) -> str: + return f"Resource Pack: {quantity} {self.name}" + + @cached_property + def scale_quantity(self) -> typing.OrderedDict[int, int]: + """Discrete scaling of the resource pack quantities. + 100 is default, 200 is double, 50 is half (if the scaling_factor allows it). + """ + levels = math.ceil(self.default_amount / self.scaling_factor) * 2 + first_level = self.default_amount % self.scaling_factor + if first_level == 0: + first_level = self.scaling_factor + quantities = sorted(set(range(first_level, self.scaling_factor * levels, self.scaling_factor)) + | {self.default_amount * 2}) + + return OrderedDict({round(quantity / self.default_amount * 100): quantity + for quantity in quantities + if quantity <= self.default_amount * 2}) + + def calculate_quantity(self, multiplier: int) -> int: + scales = list(self.scale_quantity) + left_scale = bisect.bisect_left(scales, multiplier) + closest_scale = min([scales[left_scale], scales[left_scale - 1]], + key=lambda x: abs(multiplier - x)) + return self.scale_quantity[closest_scale] + + def create_name_from_multiplier(self, multiplier: int) -> str: + return self.create_item_name(self.calculate_quantity(multiplier)) + + +class FriendshipPackData(ResourcePackData): + def create_item_name(self, quantity: int) -> str: + return f"Friendship Bonus ({quantity} <3)" + + def as_item_data(self, counter: itertools.count) -> [ItemData]: + item_datas = super().as_item_data(counter) + return [ItemData(item.code_without_offset, item.name, item.classification, {Group.FRIENDSHIP_PACK}) + for item in item_datas] + + +class StardewItemFactory(Protocol): + def __call__(self, name: Union[str, ItemData]) -> Item: + raise NotImplementedError + + +def load_item_csv(): + try: + from importlib.resources import files + except ImportError: + from importlib_resources import files + + items = [] + with files(data).joinpath("items.csv").open() as file: + item_reader = csv.DictReader(file) + for item in item_reader: + id = int(item["id"]) if item["id"] else None + classification = ItemClassification[item["classification"]] + groups = {Group[group] for group in item["groups"].split(",") if group} + items.append(ItemData(id, item["name"], classification, groups)) + return items + + +def load_resource_pack_csv() -> List[ResourcePackData]: + try: + from importlib.resources import files + except ImportError: + from importlib_resources import files + + resource_packs = [] + with files(data).joinpath("resource_packs.csv").open() as file: + resource_pack_reader = csv.DictReader(file) + for resource_pack in resource_pack_reader: + groups = frozenset(Group[group] for group in resource_pack["groups"].split(",") if group) + resource_packs.append(ResourcePackData(resource_pack["name"], + int(resource_pack["default_amount"]), + int(resource_pack["scaling_factor"]), + ItemClassification[resource_pack["classification"]], + groups)) + return resource_packs + + +events = [ + ItemData(None, "Victory", ItemClassification.progression), + ItemData(None, "Spring", ItemClassification.progression), + ItemData(None, "Summer", ItemClassification.progression), + ItemData(None, "Fall", ItemClassification.progression), + ItemData(None, "Winter", ItemClassification.progression), + ItemData(None, "Year Two", ItemClassification.progression), +] + +all_items: List[ItemData] = load_item_csv() + events +item_table: Dict[str, ItemData] = {} +items_by_group: Dict[Group, List[ItemData]] = {} + + +def initialize_groups(): + for item in all_items: + for group in item.groups: + item_group = items_by_group.get(group, list()) + item_group.append(item) + items_by_group[group] = item_group + + +def initialize_item_table(): + item_table.update({item.name: item for item in all_items}) + + +friendship_pack = FriendshipPackData("Friendship Bonus", default_amount=2, classification=ItemClassification.useful) +all_resource_packs = load_resource_pack_csv() + +initialize_item_table() +initialize_groups() + + +def create_items(item_factory: StardewItemFactory, locations_count: int, world_options: options.StardewOptions, + random: Random) \ + -> List[Item]: + items = create_unique_items(item_factory, world_options, random) + assert len(items) <= locations_count, \ + "There should be at least as many locations as there are mandatory items" + logger.debug(f"Created {len(items)} unique items") + + resource_pack_items = fill_with_resource_packs(item_factory, world_options, random, locations_count - len(items)) + items += resource_pack_items + logger.debug(f"Created {len(resource_pack_items)} resource packs") + + return items + + +def create_backpack_items(item_factory: StardewItemFactory, world_options: options.StardewOptions, items: List[Item]): + if (world_options[options.BackpackProgression] == options.BackpackProgression.option_progressive or + world_options[options.BackpackProgression] == options.BackpackProgression.option_early_progressive): + items.extend(item_factory(item) for item in ["Progressive Backpack"] * 2) + + +def create_mine_rewards(item_factory: StardewItemFactory, items: List[Item], random: Random): + items.append(item_factory("Rusty Sword")) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_10]))) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_20]))) + items.append(item_factory("Slingshot")) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_50]))) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_60]))) + items.append(item_factory("Master Slingshot")) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_80]))) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_90]))) + items.append(item_factory(random.choice(items_by_group[Group.MINES_FLOOR_110]))) + items.append(item_factory("Skull Key")) + + +def create_mine_elevators(item_factory: StardewItemFactory, world_options: options.StardewOptions, items: List[Item]): + if (world_options[options.TheMinesElevatorsProgression] == + options.TheMinesElevatorsProgression.option_progressive or + world_options[options.TheMinesElevatorsProgression] == + options.TheMinesElevatorsProgression.option_progressive_from_previous_floor): + items.extend([item_factory(item) for item in ["Progressive Mine Elevator"] * 24]) + + +def create_tools(item_factory: StardewItemFactory, world_options: options.StardewOptions, items: List[Item]): + if world_options[options.ToolProgression] == options.ToolProgression.option_progressive: + items.extend(item_factory(item) for item in items_by_group[Group.PROGRESSIVE_TOOLS] * 4) + items.append(item_factory("Golden Scythe")) + + +def create_skills(item_factory: StardewItemFactory, world_options: options.StardewOptions, items: List[Item]): + if world_options[options.SkillProgression] == options.SkillProgression.option_progressive: + items.extend([item_factory(item) for item in items_by_group[Group.SKILL_LEVEL_UP] * 10]) + + +def create_wizard_buildings(item_factory: StardewItemFactory, items: List[Item]): + items.append(item_factory("Earth Obelisk")) + items.append(item_factory("Water Obelisk")) + items.append(item_factory("Desert Obelisk")) + items.append(item_factory("Island Obelisk")) + items.append(item_factory("Junimo Hut")) + items.append(item_factory("Gold Clock")) + + +def create_carpenter_buildings(item_factory: StardewItemFactory, world_options: options.StardewOptions, + items: List[Item]): + if world_options[options.BuildingProgression] in {options.BuildingProgression.option_progressive, + options.BuildingProgression.option_progressive_early_shipping_bin}: + items.append(item_factory("Progressive Coop")) + items.append(item_factory("Progressive Coop")) + items.append(item_factory("Progressive Coop")) + items.append(item_factory("Progressive Barn")) + items.append(item_factory("Progressive Barn")) + items.append(item_factory("Progressive Barn")) + items.append(item_factory("Well")) + items.append(item_factory("Silo")) + items.append(item_factory("Mill")) + items.append(item_factory("Progressive Shed")) + items.append(item_factory("Progressive Shed")) + items.append(item_factory("Fish Pond")) + items.append(item_factory("Stable")) + items.append(item_factory("Slime Hutch")) + items.append(item_factory("Shipping Bin")) + items.append(item_factory("Progressive House")) + items.append(item_factory("Progressive House")) + items.append(item_factory("Progressive House")) + + +def create_special_quest_rewards(item_factory: StardewItemFactory, items: List[Item]): + items.append(item_factory("Adventurer's Guild")) + items.append(item_factory("Club Card")) + items.append(item_factory("Magnifying Glass")) + items.append(item_factory("Bear's Knowledge")) + items.append(item_factory("Iridium Snake Milk")) + + +def create_stardrops(item_factory: StardewItemFactory, items: List[Item]): + items.append(item_factory("Stardrop")) # The Mines level 100 + items.append(item_factory("Stardrop")) # Old Master Cannoli + + +def create_arcade_machine_items(item_factory: StardewItemFactory, world_options: options.StardewOptions, + items: List[Item]): + if world_options[options.ArcadeMachineLocations] == options.ArcadeMachineLocations.option_full_shuffling: + items.append(item_factory("JotPK: Progressive Boots")) + items.append(item_factory("JotPK: Progressive Boots")) + items.append(item_factory("JotPK: Progressive Gun")) + items.append(item_factory("JotPK: Progressive Gun")) + items.append(item_factory("JotPK: Progressive Gun")) + items.append(item_factory("JotPK: Progressive Gun")) + items.append(item_factory("JotPK: Progressive Ammo")) + items.append(item_factory("JotPK: Progressive Ammo")) + items.append(item_factory("JotPK: Progressive Ammo")) + items.append(item_factory("JotPK: Extra Life")) + items.append(item_factory("JotPK: Extra Life")) + items.append(item_factory("JotPK: Increased Drop Rate")) + items.extend(item_factory(item) for item in ["Junimo Kart: Extra Life"] * 8) + + +def create_player_buffs(item_factory: StardewItemFactory, world_options: options.StardewOptions, items: List[Item]): + number_of_buffs: int = world_options[options.NumberOfPlayerBuffs] + items.extend(item_factory(item) for item in ["Movement Speed Bonus"] * number_of_buffs) + items.extend(item_factory(item) for item in ["Luck Bonus"] * number_of_buffs) + + +def create_traveling_merchant_items(item_factory: StardewItemFactory, items: List[Item]): + items.append(item_factory("Traveling Merchant: Sunday")) + items.append(item_factory("Traveling Merchant: Monday")) + items.append(item_factory("Traveling Merchant: Tuesday")) + items.append(item_factory("Traveling Merchant: Wednesday")) + items.append(item_factory("Traveling Merchant: Thursday")) + items.append(item_factory("Traveling Merchant: Friday")) + items.append(item_factory("Traveling Merchant: Saturday")) + items.extend(item_factory(item) for item in ["Traveling Merchant Stock Size"] * 6) + items.extend(item_factory(item) for item in ["Traveling Merchant Discount"] * 8) + + +def create_unique_items(item_factory: StardewItemFactory, world_options: options.StardewOptions, random: Random) -> \ + List[Item]: + items = [] + + items.extend(item_factory(item) for item in items_by_group[Group.COMMUNITY_REWARD]) + + create_backpack_items(item_factory, world_options, items) + create_mine_rewards(item_factory, items, random) + create_mine_elevators(item_factory, world_options, items) + create_tools(item_factory, world_options, items) + create_skills(item_factory, world_options, items) + create_wizard_buildings(item_factory, items) + create_carpenter_buildings(item_factory, world_options, items) + items.append(item_factory("Beach Bridge")) + create_special_quest_rewards(item_factory, items) + create_stardrops(item_factory, items) + create_arcade_machine_items(item_factory, world_options, items) + items.append(item_factory(random.choice(items_by_group[Group.GALAXY_WEAPONS]))) + items.append( + item_factory(friendship_pack.create_name_from_multiplier(world_options[options.ResourcePackMultiplier]))) + create_player_buffs(item_factory, world_options, items) + create_traveling_merchant_items(item_factory, items) + items.append(item_factory("Return Scepter")) + + return items + + +def fill_with_resource_packs(item_factory: StardewItemFactory, world_options: options.StardewOptions, random: Random, + required_resource_pack: int) -> List[Item]: + resource_pack_multiplier = world_options[options.ResourcePackMultiplier] + + if resource_pack_multiplier == 0: + return [item_factory(cola) for cola in ["Joja Cola"] * required_resource_pack] + + items = [] + + for i in range(required_resource_pack): + resource_pack = random.choice(all_resource_packs) + items.append(item_factory(resource_pack.create_name_from_multiplier(resource_pack_multiplier))) + + return items diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py new file mode 100644 index 0000000000..a7cb70c570 --- /dev/null +++ b/worlds/stardew_valley/locations.py @@ -0,0 +1,175 @@ +import csv +import enum +from dataclasses import dataclass +from random import Random +from typing import Optional, Dict, Protocol, List, FrozenSet + +from . import options, data +from .fish_data import legendary_fish, special_fish, all_fish_items + +LOCATION_CODE_OFFSET = 717000 + + +class LocationTags(enum.Enum): + MANDATORY = enum.auto() + BUNDLE = enum.auto() + COMMUNITY_CENTER_BUNDLE = enum.auto() + CRAFTS_ROOM_BUNDLE = enum.auto() + PANTRY_BUNDLE = enum.auto() + FISH_TANK_BUNDLE = enum.auto() + BOILER_ROOM_BUNDLE = enum.auto() + BULLETIN_BOARD_BUNDLE = enum.auto() + VAULT_BUNDLE = enum.auto() + COMMUNITY_CENTER_ROOM = enum.auto() + BACKPACK = enum.auto() + TOOL_UPGRADE = enum.auto() + HOE_UPGRADE = enum.auto() + PICKAXE_UPGRADE = enum.auto() + AXE_UPGRADE = enum.auto() + WATERING_CAN_UPGRADE = enum.auto() + TRASH_CAN_UPGRADE = enum.auto() + FISHING_ROD_UPGRADE = enum.auto() + THE_MINES_TREASURE = enum.auto() + THE_MINES_ELEVATOR = enum.auto() + SKILL_LEVEL = enum.auto() + FARMING_LEVEL = enum.auto() + FISHING_LEVEL = enum.auto() + FORAGING_LEVEL = enum.auto() + COMBAT_LEVEL = enum.auto() + MINING_LEVEL = enum.auto() + BUILDING_BLUEPRINT = enum.auto() + QUEST = enum.auto() + ARCADE_MACHINE = enum.auto() + ARCADE_MACHINE_VICTORY = enum.auto() + JOTPK = enum.auto() + JUNIMO_KART = enum.auto() + HELP_WANTED = enum.auto() + TRAVELING_MERCHANT = enum.auto() + FISHSANITY = enum.auto() + + +@dataclass(frozen=True) +class LocationData: + code_without_offset: Optional[int] + region: str + name: str + tags: FrozenSet[LocationTags] = frozenset() + + @property + def code(self) -> Optional[int]: + return LOCATION_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None + + +class StardewLocationCollector(Protocol): + def __call__(self, name: str, code: Optional[int], region: str) -> None: + raise NotImplementedError + + +def load_location_csv() -> List[LocationData]: + try: + from importlib.resources import files + except ImportError: + from importlib_resources import files + + with files(data).joinpath("locations.csv").open() as file: + reader = csv.DictReader(file) + return [LocationData(int(location["id"]) if location["id"] else None, + location["region"], + location["name"], + frozenset(LocationTags[group] + for group in location["tags"].split(",") + if group)) + for location in reader] + + +events_locations = [ + LocationData(None, "Stardew Valley", "Succeed Grandpa's Evaluation"), + LocationData(None, "Community Center", "Complete Community Center"), + LocationData(None, "The Mines - Floor 120", "Reach the Bottom of The Mines"), + LocationData(None, "Skull Cavern", "Complete Quest Cryptic Note"), + LocationData(None, "Stardew Valley", "Catch Every Fish"), + LocationData(None, "Stardew Valley", "Summer"), + LocationData(None, "Stardew Valley", "Fall"), + LocationData(None, "Stardew Valley", "Winter"), + LocationData(None, "Stardew Valley", "Year Two"), +] + +all_locations = load_location_csv() + events_locations +location_table: Dict[str, LocationData] = {location.name: location for location in all_locations} +locations_by_tag: Dict[LocationTags, List[LocationData]] = {} + + +def initialize_groups(): + for location in all_locations: + for tag in location.tags: + location_group = locations_by_tag.get(tag, list()) + location_group.append(location) + locations_by_tag[tag] = location_group + + +initialize_groups() + + +def extend_help_wanted_quests(randomized_locations: List[LocationData], desired_number_of_quests: int): + for i in range(0, desired_number_of_quests): + batch = i // 7 + index_this_batch = i % 7 + if index_this_batch < 4: + randomized_locations.append( + location_table[f"Help Wanted: Item Delivery {(batch * 4) + index_this_batch + 1}"]) + elif index_this_batch == 4: + randomized_locations.append(location_table[f"Help Wanted: Fishing {batch + 1}"]) + elif index_this_batch == 5: + randomized_locations.append(location_table[f"Help Wanted: Slay Monsters {batch + 1}"]) + elif index_this_batch == 6: + randomized_locations.append(location_table[f"Help Wanted: Gathering {batch + 1}"]) + + +def extend_fishsanity_locations(randomized_locations: List[LocationData], fishsanity: int, random: Random): + prefix = "Fishsanity: " + if fishsanity == options.Fishsanity.option_none: + return + elif fishsanity == options.Fishsanity.option_legendaries: + randomized_locations.extend(location_table[f"{prefix}{legendary.name}"] for legendary in legendary_fish) + elif fishsanity == options.Fishsanity.option_special: + randomized_locations.extend(location_table[f"{prefix}{special.name}"] for special in special_fish) + elif fishsanity == options.Fishsanity.option_random_selection: + randomized_locations.extend(location_table[f"{prefix}{fish.name}"] + for fish in all_fish_items if random.random() < 0.4) + elif fishsanity == options.Fishsanity.option_all: + randomized_locations.extend(location_table[f"{prefix}{fish.name}"] for fish in all_fish_items) + + +def create_locations(location_collector: StardewLocationCollector, + world_options: options.StardewOptions, + random: Random): + randomized_locations = [] + + randomized_locations.extend(locations_by_tag[LocationTags.MANDATORY]) + + if not world_options[options.BackpackProgression] == options.BackpackProgression.option_vanilla: + randomized_locations.extend(locations_by_tag[LocationTags.BACKPACK]) + + if not world_options[options.ToolProgression] == options.ToolProgression.option_vanilla: + randomized_locations.extend(locations_by_tag[LocationTags.TOOL_UPGRADE]) + + if not world_options[options.TheMinesElevatorsProgression] == options.TheMinesElevatorsProgression.option_vanilla: + randomized_locations.extend(locations_by_tag[LocationTags.THE_MINES_ELEVATOR]) + + if not world_options[options.SkillProgression] == options.SkillProgression.option_vanilla: + randomized_locations.extend(locations_by_tag[LocationTags.SKILL_LEVEL]) + + if not world_options[options.BuildingProgression] == options.BuildingProgression.option_vanilla: + randomized_locations.extend(locations_by_tag[LocationTags.BUILDING_BLUEPRINT]) + + if not world_options[options.ArcadeMachineLocations] == options.ArcadeMachineLocations.option_disabled: + randomized_locations.extend(locations_by_tag[LocationTags.ARCADE_MACHINE_VICTORY]) + + if world_options[options.ArcadeMachineLocations] == options.ArcadeMachineLocations.option_full_shuffling: + randomized_locations.extend(locations_by_tag[LocationTags.ARCADE_MACHINE]) + + extend_help_wanted_quests(randomized_locations, world_options[options.HelpWantedLocations]) + extend_fishsanity_locations(randomized_locations, world_options[options.Fishsanity], random) + + for location_data in randomized_locations: + location_collector(location_data.name, location_data.code, location_data.region) diff --git a/worlds/stardew_valley/logic.py b/worlds/stardew_valley/logic.py new file mode 100644 index 0000000000..85a5bb08d4 --- /dev/null +++ b/worlds/stardew_valley/logic.py @@ -0,0 +1,1148 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Union, Optional, Iterable, Sized, Tuple, List, FrozenSet + +from BaseClasses import CollectionState, ItemClassification +from . import options +from .bundle_data import BundleItem +from .fish_data import all_fish_items +from .game_item import FishItem +from .items import all_items, Group, item_table +from .options import StardewOptions + +MISSING_ITEM = "THIS ITEM IS MISSING" + +tool_materials = { + "Copper": 1, + "Iron": 2, + "Gold": 3, + "Iridium": 4 +} + +tool_prices = { + "Copper": 2000, + "Iron": 5000, + "Gold": 10000, + "Iridium": 25000 +} + +skill_level_per_season = { + "Spring": { + "Farming": 2, + "Fishing": 2, + "Foraging": 2, + "Mining": 2, + "Combat": 2, + }, + "Summer": { + "Farming": 4, + "Fishing": 4, + "Foraging": 4, + "Mining": 4, + "Combat": 3, + }, + "Fall": { + "Farming": 7, + "Fishing": 5, + "Foraging": 5, + "Mining": 5, + "Combat": 4, + }, + "Winter": { + "Farming": 7, + "Fishing": 7, + "Foraging": 6, + "Mining": 7, + "Combat": 5, + }, + "Year Two": { + "Farming": 10, + "Fishing": 10, + "Foraging": 10, + "Mining": 10, + "Combat": 10, + }, +} +season_per_skill_level: Dict[Tuple[str, int], str] = {} +season_per_total_level: Dict[int, str] = {} + + +def initialize_season_per_skill_level(): + current_level = { + "Farming": 0, + "Fishing": 0, + "Foraging": 0, + "Mining": 0, + "Combat": 0, + } + for season, skills in skill_level_per_season.items(): + for skill, expected_level in skills.items(): + for level_up in range(current_level[skill] + 1, expected_level + 1): + skill_level = (skill, level_up) + if skill_level not in season_per_skill_level: + season_per_skill_level[skill_level] = season + level_up = 0 + for level_up in range(level_up + 1, sum(skills.values()) + 1): + if level_up not in season_per_total_level: + season_per_total_level[level_up] = season + + +initialize_season_per_skill_level() +week_days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + + +class StardewRule: + def __call__(self, state: CollectionState) -> bool: + raise NotImplementedError + + def __or__(self, other) -> StardewRule: + if isinstance(other, _Or): + return _Or(self, *other.rules) + + return _Or(self, other) + + def __and__(self, other) -> StardewRule: + if isinstance(other, _And): + return _And(other.rules.union({self})) + + return _And(self, other) + + def get_difficulty(self): + raise NotImplementedError + + def simplify(self) -> StardewRule: + return self + + +class _True(StardewRule): + + def __new__(cls, _cache=[]): # noqa + if not _cache: + _cache.append(super(_True, cls).__new__(cls)) + return _cache[0] + + def __call__(self, state: CollectionState) -> bool: + return True + + def __or__(self, other) -> StardewRule: + return self + + def __and__(self, other) -> StardewRule: + return other + + def __repr__(self): + return "True" + + def get_difficulty(self): + return 0 + + +class _False(StardewRule): + + def __new__(cls, _cache=[]): # noqa + if not _cache: + _cache.append(super(_False, cls).__new__(cls)) + return _cache[0] + + def __call__(self, state: CollectionState) -> bool: + return False + + def __or__(self, other) -> StardewRule: + return other + + def __and__(self, other) -> StardewRule: + return self + + def __repr__(self): + return "False" + + def get_difficulty(self): + return 999999999 + + +class _Or(StardewRule): + rules: FrozenSet[StardewRule] + + def __init__(self, rule: Union[StardewRule, Iterable[StardewRule]], *rules: StardewRule): + rules_list = set() + if isinstance(rule, Iterable): + rules_list.update(rule) + else: + rules_list.add(rule) + + if rules is not None: + rules_list.update(rules) + + assert rules_list, "Can't create a Or conditions without rules" + + new_rules = set() + for rule in rules_list: + if isinstance(rule, _Or): + new_rules.update(rule.rules) + else: + new_rules.add(rule) + rules_list = new_rules + + self.rules = frozenset(rules_list) + + def __call__(self, state: CollectionState) -> bool: + return any(rule(state) for rule in self.rules) + + def __repr__(self): + return f"({' | '.join(repr(rule) for rule in self.rules)})" + + def __or__(self, other): + if isinstance(other, _True): + return other + if isinstance(other, _False): + return self + if isinstance(other, _Or): + return _Or(self.rules.union(other.rules)) + + return _Or(self.rules.union({other})) + + def __eq__(self, other): + return isinstance(other, self.__class__) and other.rules == self.rules + + def __hash__(self): + return hash(self.rules) + + def get_difficulty(self): + return min(rule.get_difficulty() for rule in self.rules) + + def simplify(self) -> StardewRule: + if any(isinstance(rule, _True) for rule in self.rules): + return _True() + + simplified_rules = {rule.simplify() for rule in self.rules} + simplified_rules = {rule for rule in simplified_rules if rule is not _False()} + + if not simplified_rules: + return _False() + + if len(simplified_rules) == 1: + return next(iter(simplified_rules)) + + return _Or(simplified_rules) + + +class _And(StardewRule): + rules: frozenset[StardewRule] + + def __init__(self, rule: Union[StardewRule, Iterable[StardewRule]], *rules: StardewRule): + rules_list = set() + if isinstance(rule, Iterable): + rules_list.update(rule) + else: + rules_list.add(rule) + + if rules is not None: + rules_list.update(rules) + + assert rules_list, "Can't create a And conditions without rules" + + new_rules = set() + for rule in rules_list: + if isinstance(rule, _And): + new_rules.update(rule.rules) + else: + new_rules.add(rule) + rules_list = new_rules + + self.rules = frozenset(rules_list) + + def __call__(self, state: CollectionState) -> bool: + return all(rule(state) for rule in self.rules) + + def __repr__(self): + return f"({' & '.join(repr(rule) for rule in self.rules)})" + + def __and__(self, other): + if isinstance(other, _True): + return self + if isinstance(other, _False): + return other + if isinstance(other, _And): + return _And(self.rules.union(other.rules)) + + return _And(self.rules.union({other})) + + def __eq__(self, other): + return isinstance(other, self.__class__) and other.rules == self.rules + + def __hash__(self): + return hash(self.rules) + + def get_difficulty(self): + return max(rule.get_difficulty() for rule in self.rules) + + def simplify(self) -> StardewRule: + if any(isinstance(rule, _False) for rule in self.rules): + return _False() + + simplified_rules = {rule.simplify() for rule in self.rules} + simplified_rules = {rule for rule in simplified_rules if rule is not _True()} + + if not simplified_rules: + return _True() + + if len(simplified_rules) == 1: + return next(iter(simplified_rules)) + + return _And(simplified_rules) + + +class _Count(StardewRule): + count: int + rules: List[StardewRule] + + def __init__(self, count: int, rule: Union[StardewRule, Iterable[StardewRule]], *rules: StardewRule): + rules_list = [] + if isinstance(rule, Iterable): + rules_list.extend(rule) + else: + rules_list.append(rule) + + if rules is not None: + rules_list.extend(rules) + + assert rules_list, "Can't create a Count conditions without rules" + assert len(rules_list) >= count, "Count need at least as many rules at the count" + + self.rules = rules_list + self.count = count + + def __call__(self, state: CollectionState) -> bool: + c = 0 + for r in self.rules: + if r(state): + c += 1 + if c >= self.count: + return True + return False + + def __repr__(self): + return f"Received {self.count} {repr(self.rules)}" + + def get_difficulty(self): + rules_sorted_by_difficulty = sorted(self.rules, key=lambda x: x.get_difficulty()) + easiest_n_rules = rules_sorted_by_difficulty[0:self.count] + return max(rule.get_difficulty() for rule in easiest_n_rules) + + def simplify(self): + return _Count(self.count, [rule.simplify() for rule in self.rules]) + + +class _TotalReceived(StardewRule): + count: int + items: Iterable[str] + player: int + + def __init__(self, count: int, items: Union[str, Iterable[str]], player: int): + items_list = [] + if isinstance(items, Iterable): + items_list.extend(items) + else: + items_list.append(items) + + assert items_list, "Can't create a Total Received conditions without items" + for item in items_list: + assert item_table[item].classification & ItemClassification.progression, \ + "Item has to be progression to be used in logic" + + self.player = player + self.items = items_list + self.count = count + + def __call__(self, state: CollectionState) -> bool: + c = 0 + for item in self.items: + c += state.count(item, self.player) + if c >= self.count: + return True + return False + + def __repr__(self): + return f"Received {self.count} {self.items}" + + def get_difficulty(self): + return self.count + + +@dataclass(frozen=True) +class _Received(StardewRule): + item: str + player: int + count: int + + def __post_init__(self): + assert item_table[self.item].classification & ItemClassification.progression, \ + "Item has to be progression to be used in logic" + + def __call__(self, state: CollectionState) -> bool: + return state.has(self.item, self.player, self.count) + + def __repr__(self): + if self.count == 1: + return f"Received {self.item}" + return f"Received {self.count} {self.item}" + + def get_difficulty(self): + if self.item == "Spring": + return 0 + if self.item == "Summer": + return 1 + if self.item == "Fall": + return 2 + if self.item == "Winter": + return 3 + if self.item == "Year Two": + return 4 + return self.count + + +@dataclass(frozen=True) +class _Reach(StardewRule): + spot: str + resolution_hint: str + player: int + + def __call__(self, state: CollectionState) -> bool: + return state.can_reach(self.spot, self.resolution_hint, self.player) + + def __repr__(self): + return f"Reach {self.resolution_hint} {self.spot}" + + def get_difficulty(self): + return 1 + + +@dataclass(frozen=True) +class _Has(StardewRule): + item: str + # For sure there is a better way than just passing all the rules everytime + other_rules: Dict[str, StardewRule] + + def __call__(self, state: CollectionState) -> bool: + if isinstance(self.item, str): + return self.other_rules[self.item](state) + + def __repr__(self): + if not self.item in self.other_rules: + return f"Has {self.item} -> {MISSING_ITEM}" + return f"Has {self.item} -> {repr(self.other_rules[self.item])}" + + def get_difficulty(self): + return self.other_rules[self.item].get_difficulty() + 1 + + def __hash__(self): + return hash(self.item) + + def simplify(self) -> StardewRule: + return self.other_rules[self.item].simplify() + + +@dataclass(frozen=True) +class StardewLogic: + player: int + options: StardewOptions + + item_rules: Dict[str, StardewRule] = field(default_factory=dict) + fish_rules: Dict[str, StardewRule] = field(default_factory=dict) + building_rules: Dict[str, StardewRule] = field(default_factory=dict) + quest_rules: Dict[str, StardewRule] = field(default_factory=dict) + + def __post_init__(self): + self.fish_rules.update({fish.name: self.can_catch_fish(fish) for fish in all_fish_items}) + + self.item_rules.update({ + "Aged Roe": self.has("Preserves Jar") & self.has("Roe"), + "Algae Soup": self.can_cook() & self.has("Green Algae") & self.can_have_relationship("Clint", 3), + "Amaranth": self.received("Fall"), + "Amethyst": self.can_mine_in_the_mines_floor_1_40(), + "Ancient Drum": self.has("Frozen Geode"), + "Any Egg": self.has("Chicken Egg") | self.has("Duck Egg"), + "Apple": self.received("Fall"), + "Apricot": self.received("Year Two"), + "Aquamarine": self.can_mine_in_the_mines_floor_41_80() | self.can_mine_in_the_skull_cavern(), + "Artichoke": self.received("Year Two") & self.received("Fall"), + "Bait": self.has_skill_level("Fishing", 2), + "Bat Wing": self.can_mine_in_the_mines_floor_41_80() | self.can_mine_in_the_skull_cavern(), + "Battery Pack": self.has("Lightning Rod"), + "Bee House": self.has_skill_level("Farming", 3) & self.has("Iron Bar") & self.has("Maple Syrup"), + "Beer": (self.has("Keg") & self.has("Wheat")) | self.can_spend_money(400), + "Beet": self.received("Fall") & self.can_reach_region("The Desert"), + "Blackberry": self.received("Fall"), + "Blue Jazz": self.received("Spring"), + "Blueberry": self.received("Summer"), + "Blueberry Tart": self.has("Blueberry") & self.has("Any Egg") & self.can_have_relationship("Pierre", 3), + "Bok Choy": self.received("Fall"), + "Bouquet": self.can_have_relationship("Any", 8), + "Bread": self.can_spend_money(120) | (self.can_spend_money(100) & self.can_cook()), + "Broken CD": self.can_crab_pot(), + "Broken Glasses": self.can_crab_pot(), + "Bug Meat": self.can_mine_in_the_mines_floor_1_40(), + "Cactus Fruit": self.can_reach_region("The Desert"), + "Cauliflower": self.received("Spring"), + "Cave Carrot": self.has_mine_elevator_to_floor(10), + "Caviar": self.has("Preserves Jar") & self.has("Sturgeon Roe"), + "Chanterelle": self.received("Fall") & self.can_reach_region("Secret Woods"), + "Cheese Press": self.has_skill_level("Farming", 6) & self.has("Hardwood") & self.has("Copper Bar"), + "Cheese": (self.has("Cow Milk") & self.has("Cheese Press")) | + (self.can_reach_region("The Desert") & self.has("Emerald")), + "Cheese Cauliflower": self.has(["Cheese", "Cauliflower"]) & self.can_have_relationship("Pam", 3) & + self.can_cook(), + "Cherry": self.received("Year Two"), + "Chicken": self.has_building("Coop"), + "Chicken Egg": self.has(["Egg", "Egg (Brown)", "Large Egg", "Large Egg (Brown)"], 1), + "Chowder": self.can_cook() & self.can_have_relationship("Willy", 3) & self.has(["Clam", "Cow Milk"]), + "Clam": _True(), + "Clay": _True(), + "Cloth": (self.has("Wool") & self.has("Loom")) | + (self.can_reach_region("The Desert") & self.has("Aquamarine")), + "Coal": _True(), + "Cockle": _True(), + "Coconut": self.can_reach_region("The Desert"), + "Coffee": (self.has("Keg") & self.has("Coffee Bean")) | self.has("Coffee Maker") | + self.can_spend_money(300) | self.has("Hot Java Ring"), + "Coffee Bean": (self.received("Spring") | self.received("Summer")) & + (self.can_mine_in_the_mines_floor_41_80() | _True()), # Travelling merchant + "Coffee Maker": _False(), + "Common Mushroom": self.received("Fall") | + (self.received("Spring") & self.can_reach_region("Secret Woods")), + "Copper Bar": self.can_smelt("Copper Ore"), + "Copper Ore": self.can_mine_in_the_mines_floor_1_40() | self.can_mine_in_the_skull_cavern(), + "Coral": self.can_reach_region("Tide Pools") | self.received("Summer"), + "Corn": self.received("Summer") | self.received("Fall"), + "Cow": self.has_building("Barn"), + "Cow Milk": self.has("Milk") | self.has("Large Milk"), + "Crab": self.can_crab_pot(), + "Crab Pot": self.has_skill_level("Fishing", 3), + "Cranberries": self.received("Fall"), + "Crayfish": self.can_crab_pot(), + "Crocus": self.received("Winter"), + "Crystal Fruit": self.received("Winter"), + "Daffodil": self.received("Spring"), + "Dandelion": self.received("Spring"), + "Dish O' The Sea": self.can_cook() & self.has_skill_level("Fishing", 3) & + self.has(["Sardine", "Hashbrowns"]), + "Dorado": self.can_fish(78) & self.received("Summer"), + "Dried Starfish": self.can_fish() & self.can_reach_region("Beach"), + "Driftwood": self.can_crab_pot(), + "Duck Egg": self.has("Duck"), + "Duck Feather": self.has("Duck"), + "Duck": self.has_building("Big Coop"), + "Dwarf Scroll I": self.can_mine_in_the_mines_floor_1_40(), + "Dwarf Scroll II": self.can_mine_in_the_mines_floor_1_40(), + "Dwarf Scroll III": self.can_mine_in_the_mines_floor_1_40(), + "Dwarf Scroll IV": self.can_mine_in_the_mines_floor_81_120(), + "Earth Crystal": self.can_mine_in_the_mines_floor_1_40(), + "Egg": self.has("Chicken"), + "Egg (Brown)": self.has("Chicken"), + "Eggplant": self.received("Fall"), + "Elvish Jewelry": self.can_fish() & self.can_reach_region("Forest"), + "Emerald": self.can_mine_in_the_mines_floor_81_120() | self.can_mine_in_the_skull_cavern(), + "Fairy Rose": self.received("Fall"), + "Farmer's Lunch": self.can_cook() & self.has_skill_level("Farming", 3) & self.has("Omelet") & self.has( + "Parsnip"), + "Fiber": _True(), + "Fiddlehead Fern": self.can_reach_region("Secret Woods") & self.received("Summer"), + "Fire Quartz": self.can_mine_in_the_mines_floor_81_120(), + "Fried Egg": self.can_cook() & self.has("Any Egg"), + "Fried Mushroom": self.can_cook() & self.can_have_relationship("Demetrius", 3) & self.has( + "Morel") & self.has("Common Mushroom"), + "Frozen Geode": self.can_mine_in_the_mines_floor_41_80(), + "Frozen Tear": self.can_mine_in_the_mines_floor_41_80(), + "Furnace": self.has("Stone") & self.has("Copper Ore"), + "Geode": self.can_mine_in_the_mines_floor_1_40(), + "Goat Cheese": self.has("Goat Milk") & self.has("Cheese Press"), + "Goat Milk": self.has("Goat"), + "Goat": self.has_building("Big Barn"), + "Gold Bar": self.can_smelt("Gold Ore"), + "Gold Ore": self.can_mine_in_the_mines_floor_81_120() | self.can_mine_in_the_skull_cavern(), + "Grape": self.received("Summer"), + "Green Algae": self.can_fish(), + "Green Bean": self.received("Spring"), + "Hardwood": self.has_tool("Axe", "Copper"), + "Hashbrowns": self.can_cook() & self.can_spend_money(50) & self.has("Potato"), + "Hazelnut": self.received("Fall"), + "Holly": self.received("Winter"), + "Honey": self.can_reach_region("The Desert") | + (self.has("Bee House") & + (self.received("Spring") | self.received("Summer") | self.received("Fall"))), + "Hops": self.received("Summer"), + "Hot Java Ring": self.can_reach_region("Ginger Island"), + "Hot Pepper": self.received("Summer"), + "Iridium Bar": self.can_smelt("Iridium Ore"), + "Iridium Ore": self.can_mine_in_the_skull_cavern(), + "Iron Bar": self.can_smelt("Iron Ore"), + "Iron Ore": self.can_mine_in_the_mines_floor_41_80() | self.can_mine_in_the_skull_cavern(), + "Jade": self.can_mine_in_the_mines_floor_41_80(), + "Jelly": self.has("Preserves Jar"), + "JotPK Small Buff": self.has_jotpk_power_level(2), + "JotPK Medium Buff": self.has_jotpk_power_level(4), + "JotPK Big Buff": self.has_jotpk_power_level(7), + "JotPK Max Buff": self.has_jotpk_power_level(9), + "Juice": self.has("Keg"), + "Junimo Kart Small Buff": self.has_junimo_kart_power_level(2), + "Junimo Kart Medium Buff": self.has_junimo_kart_power_level(4), + "Junimo Kart Big Buff": self.has_junimo_kart_power_level(6), + "Junimo Kart Max Buff": self.has_junimo_kart_power_level(8), + "Kale": self.received("Spring"), + "Keg": self.has_skill_level("Farming", 8) & self.has("Iron Bar") & self.has("Copper Bar") & self.has( + "Oak Resin"), + "Large Egg": self.has("Chicken"), + "Large Egg (Brown)": self.has("Chicken"), + "Large Goat Milk": self.has("Goat"), + "Large Milk": self.has("Cow"), + "Leek": self.received("Spring"), + "Lightning Rod": self.has_skill_level("Foraging", 6), + "Lobster": self.can_crab_pot(), + "Loom": self.has_skill_level("Farming", 7) & self.has("Pine Tar"), + "Magma Geode": self.can_mine_in_the_mines_floor_81_120() | + (self.has("Lava Eel") & self.has_building("Fish Pond")), + "Maki Roll": self.can_cook() & self.can_fish(), + "Maple Syrup": self.has("Tapper"), + "Mead": self.has("Keg") & self.has("Honey"), + "Melon": self.received("Summer"), + "Milk": self.has("Cow"), + "Miner's Treat": self.can_cook() & self.has_skill_level("Mining", 3) & self.has("Cow Milk") & self.has( + "Cave Carrot"), + "Morel": self.can_reach_region("Secret Woods") & self.received("Year Two"), + "Mussel": _True(), + "Nautilus Shell": self.received("Winter"), + "Oak Resin": self.has("Tapper"), + "Oil Maker": self.has_skill_level("Farming", 8) & self.has("Hardwood") & self.has("Gold Bar"), + "Omelet": self.can_cook() & self.can_spend_money(100) & self.has("Any Egg") & self.has("Cow Milk"), + "Omni Geode": self.can_mine_in_the_mines_floor_41_80() | + self.can_reach_region("The Desert") | + self.can_do_panning() | + self.received("Rusty Key") | + (self.has("Octopus") & self.has_building("Fish Pond")) | + self.can_reach_region("Ginger Island"), + "Orange": self.received("Summer"), + "Ostrich": self.has_building("Barn"), + "Oyster": _True(), + "Pale Ale": self.has("Keg") & self.has("Hops"), + "Pale Broth": self.can_cook() & self.can_have_relationship("Marnie", 3) & self.has("White Algae"), + "Pancakes": self.can_cook() & self.can_spend_money(100) & self.has("Any Egg"), + "Parsnip": self.received("Spring"), + "Parsnip Soup": self.can_cook() & self.can_have_relationship("Caroline", 3) & self.has( + "Parsnip") & self.has("Cow Milk"), + "Peach": self.received("Summer"), + "Pepper Poppers": self.can_cook() & self.has("Cheese") & self.has( + "Hot Pepper") & self.can_have_relationship("Shane", 3), + "Periwinkle": self.can_crab_pot(), + "Pickles": self.has("Preserves Jar"), + "Pig": self.has_building("Deluxe Barn"), + "Pine Tar": self.has("Tapper"), + "Pizza": self.can_spend_money(600), + "Pomegranate": self.received("Fall"), + "Poppy": self.received("Summer"), + "Potato": self.received("Spring"), + "Preserves Jar": self.has_skill_level("Farming", 4), + "Prismatic Shard": self.received("Year Two"), + "Pumpkin": self.received("Fall"), + "Purple Mushroom": self.can_mine_in_the_mines_floor_81_120() | self.can_mine_in_the_skull_cavern(), + "Quartz": self.can_mine_in_the_mines_floor_1_40(), + "Rabbit": self.has_building("Deluxe Coop"), + "Rabbit's Foot": self.has("Rabbit"), + "Radish": self.received("Summer"), + "Rainbow Shell": self.received("Summer"), + "Rain Totem": self.has_skill_level("Foraging", 9), + "Recycling Machine": self.has_skill_level("Fishing", 4) & self.has("Wood") & + self.has("Stone") & self.has("Iron Bar"), + "Red Cabbage": self.received("Year Two"), + "Red Mushroom": self.can_reach_region("Secret Woods") & (self.received("Summer") | self.received("Fall")), + "Refined Quartz": self.has("Quartz") | self.has("Fire Quartz") | + (self.has("Recycling Machine") & (self.has("Broken CD") | self.has("Broken Glasses"))), + "Rhubarb": self.received("Spring") & self.can_reach_region("The Desert"), + "Roe": self.can_fish() & self.has_building("Fish Pond"), + "Roots Platter": self.can_cook() & self.has_skill_level("Combat", 3) & + self.has("Cave Carrot") & self.has("Winter Root"), + "Ruby": self.can_mine_in_the_mines_floor_81_120() | self.can_do_panning(), + "Salad": self.can_spend_money(220) | ( + self.can_cook() & self.can_have_relationship("Emily", 3) & self.has("Leek") & self.has( + "Dandelion")), + "Salmonberry": self.received("Spring"), + "Salmon Dinner": self.can_cook() & self.can_have_relationship("Gus", 3) & self.has("Salmon") & self.has( + "Amaranth") & self.has("Kale"), + "Sashimi": self.can_fish() & self.can_cook() & self.can_have_relationship("Linus", 3), + "Sea Urchin": self.can_reach_region("Tide Pools") | self.received("Summer"), + "Seaweed": self.can_fish() | self.can_reach_region("Tide Pools"), + "Sheep": self.has_building("Deluxe Barn"), + "Shrimp": self.can_crab_pot(), + "Slime": self.can_mine_in_the_mines_floor_1_40(), + "Snail": self.can_crab_pot(), + "Snow Yam": self.received("Winter"), + "Soggy Newspaper": self.can_crab_pot(), + "Solar Essence": self.can_mine_in_the_mines_floor_41_80() | self.can_mine_in_the_skull_cavern(), + "Spaghetti": self.can_spend_money(240), + "Spice Berry": self.received("Summer"), + "Spring Onion": self.received("Spring"), + "Staircase": self.has_skill_level("Mining", 2), + "Starfruit": (self.received("Summer") | self.received("Greenhouse")) & self.can_reach_region("The Desert"), + "Stone": self.has_tool("Pickaxe"), + "Strawberry": self.received("Spring"), + "Sturgeon Roe": self.has("Sturgeon") & self.has_building("Fish Pond"), + "Summer Spangle": self.received("Summer"), + "Sunflower": self.received("Summer") | self.received("Fall"), + "Survival Burger": self.can_cook() & self.has_skill_level("Foraging", 2) & + self.has(["Bread", "Cave Carrot", "Eggplant"]), + "Sweet Gem Berry": (self.received("Fall") | self.received("Greenhouse")) & self.has_traveling_merchant(), + "Sweet Pea": self.received("Summer"), + "Tapper": self.has_skill_level("Foraging", 3), + "Tomato": self.received("Summer"), + "Topaz": self.can_mine_in_the_mines_floor_1_40(), + "Tortilla": self.can_cook() & self.can_spend_money(100) & self.has("Corn"), + "Trash": self.can_crab_pot(), + "Triple Shot Espresso": (self.has("Hot Java Ring") | + (self.can_cook() & self.can_spend_money(5000) & self.has("Coffee"))), + "Truffle Oil": self.has("Truffle") & self.has("Oil Maker"), + "Truffle": self.has("Pig") & self.received("Year Two"), + "Tulip": self.received("Spring"), + "Unmilled Rice": self.received("Year Two"), + "Void Essence": self.can_mine_in_the_mines_floor_81_120() | self.can_mine_in_the_skull_cavern(), + "Wheat": self.received("Summer") | self.received("Fall"), + "White Algae": self.can_fish() & self.can_mine_in_the_mines_floor_1_40(), + "Wild Horseradish": self.received("Spring"), + "Wild Plum": self.received("Fall"), + "Wilted Bouquet": self.has("Furnace") & self.has("Bouquet") & self.has("Coal"), + "Wine": self.has("Keg"), + "Winter Root": self.received("Winter"), + "Wood": self.has_tool("Axe"), + "Wool": self.has("Rabbit") | self.has("Sheep"), + "Yam": self.received("Fall"), + "Hay": self.has_building("Silo"), + }) + self.item_rules.update(self.fish_rules) + + self.building_rules.update({ + "Barn": self.can_spend_money(6000) & self.has(["Wood", "Stone"]), + "Big Barn": self.can_spend_money(12000) & self.has(["Wood", "Stone"]) & self.has_building("Barn"), + "Deluxe Barn": self.can_spend_money(25000) & self.has(["Wood", "Stone"]) & self.has_building("Big Barn"), + "Coop": self.can_spend_money(4000) & self.has(["Wood", "Stone"]), + "Big Coop": self.can_spend_money(10000) & self.has(["Wood", "Stone"]) & self.has_building("Coop"), + "Deluxe Coop": self.can_spend_money(20000) & self.has(["Wood", "Stone"]) & self.has_building("Big Coop"), + "Fish Pond": self.can_spend_money(5000) & self.has(["Stone", "Seaweed", "Green Algae"]), + "Mill": self.can_spend_money(2500) & self.has(["Stone", "Wood", "Cloth"]), + "Shed": self.can_spend_money(15000) & self.has("Wood"), + "Big Shed": self.can_spend_money(20000) & self.has(["Wood", "Stone"]) & self.has_building("Shed"), + "Silo": self.can_spend_money(100) & self.has(["Stone", "Clay", "Copper Bar"]), + "Slime Hutch": self.can_spend_money(10000) & self.has(["Stone", "Refined Quartz", "Iridium Bar"]), + "Stable": self.can_spend_money(10000) & self.has(["Hardwood", "Iron Bar"]), + "Well": self.can_spend_money(1000) & self.has("Stone"), + "Shipping Bin": self.can_spend_money(250) & self.has("Wood"), + "Kitchen": self.can_spend_money(10000) & self.has("Wood") & self.has_house(0), + "Kids Room": self.can_spend_money(50000) & self.has("Hardwood") & self.has_house(1), + "Cellar": self.can_spend_money(100000) & self.has_house(2), + }) + + self.quest_rules.update({ + "Introductions": _True(), + "How To Win Friends": self.can_complete_quest("Introductions"), + "Getting Started": self.received("Spring") & self.has_tool("Hoe") & self.has_tool("Watering Can"), + "To The Beach": self.received("Spring"), + "Raising Animals": self.can_complete_quest("Getting Started") & self.has_building("Coop"), + "Advancement": self.can_complete_quest("Getting Started") & self.has_skill_level("Farming", 1), + "Archaeology": self.has_tool("Hoe") | self.can_mine_in_the_mines_floor_1_40() | self.can_fish(), + "Meet The Wizard": self.received("Spring") & self.can_reach_region("Community Center"), + "Forging Ahead": self.has("Copper Ore") & self.has("Furnace"), + "Smelting": self.has("Copper Bar"), + "Initiation": self.can_mine_in_the_mines_floor_1_40(), + "Robin's Lost Axe": self.received("Spring"), + "Jodi's Request": self.received("Spring") & self.has("Cauliflower"), + "Mayor's \"Shorts\"": self.received("Summer") & self.can_have_relationship("Marnie", 4), + "Blackberry Basket": self.received("Fall"), + "Marnie's Request": self.can_have_relationship("Marnie", 3) & self.has("Cave Carrot"), + "Pam Is Thirsty": self.received("Summer") & self.has("Pale Ale"), + "A Dark Reagent": self.received("Winter") & self.has("Void Essence"), + "Cow's Delight": self.received("Fall") & self.has("Amaranth"), + "The Skull Key": self.received("Skull Key") & self.can_reach_region("The Desert"), + "Crop Research": self.received("Summer") & self.has("Melon"), + "Knee Therapy": self.received("Summer") & self.has("Hot Pepper"), + "Robin's Request": self.received("Winter") & self.has("Hardwood"), + "Qi's Challenge": self.can_mine_in_the_skull_cavern(), + "The Mysterious Qi": self.has("Battery Pack") & self.can_reach_region("The Desert") & self.has( + "Rainbow Shell") & self.has("Beet") & self.has("Solar Essence"), + "Carving Pumpkins": self.received("Fall") & self.has("Pumpkin"), + "A Winter Mystery": self.received("Winter"), + "Strange Note": self.received("Magnifying Glass") & self.can_reach_region("Secret Woods") & self.has( + "Maple Syrup"), + "Cryptic Note": self.received("Magnifying Glass") & self.can_mine_perfectly_in_the_skull_cavern(), + "Fresh Fruit": self.received("Year Two") & self.has("Apricot"), + "Aquatic Research": self.received("Year Two") & self.has("Pufferfish"), + "A Soldier's Star": self.received("Year Two") & self.has("Starfruit"), + "Mayor's Need": self.received("Year Two") & self.has("Truffle Oil"), + "Wanted: Lobster": self.received("Year Two") & self.has("Lobster"), + "Pam Needs Juice": self.received("Year Two") & self.has("Battery Pack"), + "Fish Casserole": self.received("Year Two") & self.can_have_relationship("Jodi", 4) & self.has( + "Largemouth Bass"), + "Catch A Squid": self.received("Year Two") & self.has("Squid"), + "Fish Stew": self.received("Year Two") & self.has("Albacore"), + "Pierre's Notice": self.received("Year Two") & self.has("Sashimi"), + "Clint's Attempt": self.received("Year Two") & self.has("Amethyst"), + "A Favor For Clint": self.received("Year Two") & self.has("Iron Bar"), + "Staff Of Power": self.received("Year Two") & self.has("Iridium Bar"), + "Granny's Gift": self.received("Year Two") & self.has("Leek"), + "Exotic Spirits": self.received("Year Two") & self.has("Coconut"), + "Catch a Lingcod": self.received("Year Two") & self.has("Lingcod"), + }) + + def has(self, items: Union[str, (Iterable[str], Sized)], count: Optional[int] = None) -> StardewRule: + if isinstance(items, str): + return _Has(items, self.item_rules) + + if count is None or count == len(items): + return _And(self.has(item) for item in items) + + if count == 1: + return _Or(self.has(item) for item in items) + + return _Count(count, (self.has(item) for item in items)) + + def received(self, items: Union[str, Iterable[str]], count: Optional[int] = 1) -> StardewRule: + if isinstance(items, str): + return _Received(items, self.player, count) + + if count is None: + return _And(self.received(item) for item in items) + + if count == 1: + return _Or(self.received(item) for item in items) + + return _TotalReceived(count, items, self.player) + + def can_reach_region(self, spot: str) -> StardewRule: + return _Reach(spot, "Region", self.player) + + def can_reach_any_region(self, spots: Iterable[str]) -> StardewRule: + return _Or(self.can_reach_region(spot) for spot in spots) + + def can_reach_location(self, spot: str) -> StardewRule: + return _Reach(spot, "Location", self.player) + + def can_reach_entrance(self, spot: str) -> StardewRule: + return _Reach(spot, "Entrance", self.player) + + def can_have_earned_total_money(self, amount: int) -> StardewRule: + if amount <= 10000: + return self.received("Spring") + elif amount <= 30000: + return self.received("Summer") + elif amount <= 60000: + return self.received("Fall") + elif amount <= 70000: + return self.received("Winter") + return self.received("Year Two") + + def can_spend_money(self, amount: int) -> StardewRule: + if amount <= 2000: + return self.received("Spring") + elif amount <= 8000: + return self.received("Summer") + elif amount <= 15000: + return self.received("Fall") + elif amount <= 18000: + return self.received("Winter") + return self.received("Year Two") + + def has_tool(self, tool: str, material: str = "Basic") -> StardewRule: + if material == "Basic": + return _True() + + if self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + return self.received(f"Progressive {tool}", count=tool_materials[material]) + + return self.has(f"{material} Bar") & self.can_spend_money(tool_prices[material]) + + def has_skill_level(self, skill: str, level: int) -> StardewRule: + if level == 0: + return _True() + + if self.options[options.SkillProgression] == options.SkillProgression.option_progressive: + return self.received(f"{skill} Level", count=level) + + if skill == "Fishing" and self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + return self.can_get_fishing_xp() + + return self.received(season_per_skill_level[(skill, level)]) + + def has_total_skill_level(self, level: int) -> StardewRule: + if level == 0: + return _True() + + if self.options[options.SkillProgression] == options.SkillProgression.option_progressive: + skills_items = ["Farming Level", "Mining Level", "Foraging Level", + "Fishing Level", "Combat Level"] + return self.received(skills_items, count=level) + + if level > 40 and self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + return self.received(season_per_total_level[level]) & self.can_get_fishing_xp() + + return self.received(season_per_total_level[level]) + + def has_building(self, building: str) -> StardewRule: + if not self.options[options.BuildingProgression] == options.BuildingProgression.option_vanilla: + count = 1 + if building in ["Coop", "Barn", "Shed"]: + building = f"Progressive {building}" + elif building.startswith("Big"): + count = 2 + building = " ".join(["Progressive", *building.split(" ")[1:]]) + elif building.startswith("Deluxe"): + count = 3 + building = " ".join(["Progressive", *building.split(" ")[1:]]) + return self.received(f"{building}", count) + + return _Has(building, self.building_rules) + + def has_house(self, upgrade_level: int) -> StardewRule: + if upgrade_level < 1: + return _True() + + if upgrade_level > 3: + return _False() + + if not self.options[options.BuildingProgression] == options.BuildingProgression.option_vanilla: + return self.received(f"Progressive House", upgrade_level) + + if upgrade_level == 1: + return _Has("Kitchen", self.building_rules) + + if upgrade_level == 2: + return _Has("Kids Room", self.building_rules) + + # if upgrade_level == 3: + return _Has("Cellar", self.building_rules) + + def can_complete_quest(self, quest: str) -> StardewRule: + return _Has(quest, self.quest_rules) + + def can_get_fishing_xp(self) -> StardewRule: + if self.options[options.SkillProgression] == options.SkillProgression.option_progressive: + return self.can_fish() | self.can_crab_pot() + + return self.can_fish() + + def can_fish(self, difficulty: int = 0) -> StardewRule: + skill_required = max(0, int((difficulty / 10) - 1)) + if difficulty <= 40: + skill_required = 0 + skill_rule = self.has_skill_level("Fishing", skill_required) + number_fishing_rod_required = 1 if difficulty < 50 else 2 + if self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + return self.received("Progressive Fishing Rod", number_fishing_rod_required) & skill_rule + + return skill_rule + + def can_catch_fish(self, fish: FishItem) -> StardewRule: + region_rule = self.can_reach_any_region(fish.locations) + season_rule = self.received(fish.seasons) + difficulty_rule = self.can_fish(fish.difficulty) + if fish.difficulty == -1: + difficulty_rule = self.can_crab_pot() + return region_rule & season_rule & difficulty_rule + + def can_catch_every_fish(self) -> StardewRule: + rules = [self.has_skill_level("Fishing", 10), self.has_max_fishing_rod()] + for fish in all_fish_items: + rules.append(self.can_catch_fish(fish)) + return _And(rules) + + def has_max_fishing_rod(self) -> StardewRule: + if self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + return self.received("Progressive Fishing Rod", 4) + return self.can_get_fishing_xp() + + def can_cook(self) -> StardewRule: + return self.has_house(1) or self.has_skill_level("Foraging", 9) + + def can_smelt(self, item: str) -> StardewRule: + return self.has("Furnace") & self.has(item) + + def can_crab_pot(self) -> StardewRule: + if self.options[options.SkillProgression] == options.SkillProgression.option_progressive: + return self.has("Crab Pot") + + return _True() + + def can_do_panning(self) -> StardewRule: + return self.received("Glittering Boulder Removed") + + # Regions + def can_mine_in_the_mines_floor_1_40(self) -> StardewRule: + return self.can_reach_region("The Mines - Floor 5") + + def can_mine_in_the_mines_floor_41_80(self) -> StardewRule: + return self.can_reach_region("The Mines - Floor 45") + + def can_mine_in_the_mines_floor_81_120(self) -> StardewRule: + return self.can_reach_region("The Mines - Floor 85") + + def can_mine_in_the_skull_cavern(self) -> StardewRule: + return (self.can_progress_in_the_mines_from_floor(120) & + self.can_reach_region("Skull Cavern")) + + def can_mine_perfectly_in_the_skull_cavern(self) -> StardewRule: + return (self.can_progress_in_the_mines_from_floor(160) & + self.can_reach_region("Skull Cavern")) + + def get_weapon_rule_for_floor_tier(self, tier: int): + if tier >= 4: + return self.has_galaxy_weapon() + if tier >= 3: + return self.has_great_weapon() + if tier >= 2: + return self.has_good_weapon() + if tier >= 1: + return self.has_decent_weapon() + return self.has_any_weapon() + + def can_progress_in_the_mines_from_floor(self, floor: int) -> StardewRule: + tier = int(floor / 40) + rules = [] + weapon_rule = self.get_weapon_rule_for_floor_tier(tier) + rules.append(weapon_rule) + if self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + rules.append(self.received("Progressive Pickaxe", tier)) + if self.options[options.SkillProgression] == options.SkillProgression.option_progressive: + combat_tier = min(10, max(0, tier * 2)) + rules.append(self.has_skill_level("Combat", combat_tier)) + return _And(rules) + + def can_progress_easily_in_the_mines_from_floor(self, floor: int) -> StardewRule: + tier = int(floor / 40) + 1 + rules = [] + weapon_rule = self.get_weapon_rule_for_floor_tier(tier) + rules.append(weapon_rule) + if self.options[options.ToolProgression] == options.ToolProgression.option_progressive: + rules.append(self.received("Progressive Pickaxe", count=tier)) + if self.options[options.SkillProgression] == options.SkillProgression.option_progressive: + combat_tier = min(10, max(0, tier * 2)) + rules.append(self.has_skill_level("Combat", combat_tier)) + return _And(rules) + + def has_mine_elevator_to_floor(self, floor: int) -> StardewRule: + if (self.options[options.TheMinesElevatorsProgression] == + options.TheMinesElevatorsProgression.option_progressive or + self.options[options.TheMinesElevatorsProgression] == + options.TheMinesElevatorsProgression.option_progressive_from_previous_floor): + return self.received("Progressive Mine Elevator", count=int(floor / 5)) + return _True() + + def can_mine_to_floor(self, floor: int) -> StardewRule: + previous_elevator = max(floor - 5, 0) + previous_previous_elevator = max(floor - 10, 0) + return ((self.has_mine_elevator_to_floor(previous_elevator) & + self.can_progress_in_the_mines_from_floor(previous_elevator)) | + (self.has_mine_elevator_to_floor(previous_previous_elevator) & + self.can_progress_easily_in_the_mines_from_floor(previous_previous_elevator))) + + def has_jotpk_power_level(self, power_level: int) -> StardewRule: + if self.options[options.ArcadeMachineLocations] != options.ArcadeMachineLocations.option_full_shuffling: + return _True() + jotpk_buffs = ["JotPK: Progressive Boots", "JotPK: Progressive Gun", + "JotPK: Progressive Ammo", "JotPK: Extra Life", "JotPK: Increased Drop Rate"] + return self.received(jotpk_buffs, power_level) + + def has_junimo_kart_power_level(self, power_level: int) -> StardewRule: + if self.options[options.ArcadeMachineLocations] != options.ArcadeMachineLocations.option_full_shuffling: + return _True() + return self.received("Junimo Kart: Extra Life", power_level) + + def has_traveling_merchant(self, tier: int = 1): + traveling_merchant_days = [f"Traveling Merchant: {day}" for day in week_days] + return self.received(traveling_merchant_days, tier) + + def can_get_married(self) -> StardewRule: + return self.can_reach_region("Tide Pools") & self.can_have_relationship("Bachelor", 10) & self.has_house(1) + + def can_have_relationship(self, npc: str, hearts: int) -> StardewRule: + if npc == "Leo": + return self.can_reach_region("Ginger Island") + + if npc == "Sandy": + return self.can_reach_region("The Desert") + + if npc == "Kent": + return self.received("Year Two") + + if hearts <= 3: + return self.received("Spring") + if hearts <= 6: + return self.received("Summer") + if hearts <= 9: + return self.received("Fall") + return self.received("Winter") + + def can_complete_bundle(self, bundle_requirements: List[BundleItem], number_required: int) -> StardewRule: + item_rules = [] + for bundle_item in bundle_requirements: + if bundle_item.item.item_id == -1: + return self.can_spend_money(bundle_item.amount) + else: + item_rules.append(bundle_item.item.name) + return self.has(item_rules, number_required) + + def can_complete_community_center(self) -> StardewRule: + return (self.can_reach_location("Complete Crafts Room") & + self.can_reach_location("Complete Pantry") & + self.can_reach_location("Complete Fish Tank") & + self.can_reach_location("Complete Bulletin Board") & + self.can_reach_location("Complete Vault") & + self.can_reach_location("Complete Boiler Room")) + + def can_finish_grandpa_evaluation(self) -> StardewRule: + # https://stardewvalleywiki.com/Grandpa + rules_worth_a_point = [self.can_have_earned_total_money(50000), # 50 000g + self.can_have_earned_total_money(100000), # 100 000g + self.can_have_earned_total_money(200000), # 200 000g + self.can_have_earned_total_money(300000), # 300 000g + self.can_have_earned_total_money(500000), # 500 000g + self.can_have_earned_total_money(1000000), # 1 000 000g first point + self.can_have_earned_total_money(1000000), # 1 000 000g second point + self.has_total_skill_level(30), # Total Skills: 30 + self.has_total_skill_level(50), # Total Skills: 50 + # Completing the museum not expected + # Catching every fish not expected + # Shipping every item not expected + self.can_get_married() & self.has_house(2), + self.received("Fall"), # 5 Friends (TODO) + self.received("Winter"), # 10 friends (TODO) + self.received("Fall"), # Max Pet takes 56 days min + self.can_complete_community_center(), # Community Center Completion + self.can_complete_community_center(), # CC Ceremony first point + self.can_complete_community_center(), # CC Ceremony second point + self.received("Skull Key"), # Skull Key obtained + # Rusty key not expected + ] + return _Count(12, rules_worth_a_point) + + def has_any_weapon(self) -> StardewRule: + return self.has_decent_weapon() | self.received(item.name for item in all_items if Group.WEAPON in item.groups) + + def has_decent_weapon(self) -> StardewRule: + return (self.has_good_weapon() | + self.received(item.name for item in all_items + if Group.WEAPON in item.groups and + (Group.MINES_FLOOR_50 in item.groups or Group.MINES_FLOOR_60 in item.groups))) + + def has_good_weapon(self) -> StardewRule: + return ((self.has_great_weapon() | + self.received(item.name for item in all_items + if Group.WEAPON in item.groups and + (Group.MINES_FLOOR_80 in item.groups or Group.MINES_FLOOR_90 in item.groups))) & + self.received("Adventurer's Guild")) + + def has_great_weapon(self) -> StardewRule: + return ((self.has_galaxy_weapon() | + self.received(item.name for item in all_items + if Group.WEAPON in item.groups and Group.MINES_FLOOR_110 in item.groups)) & + self.received("Adventurer's Guild")) + + def has_galaxy_weapon(self) -> StardewRule: + return (self.received(item.name for item in all_items + if Group.WEAPON in item.groups and Group.GALAXY_WEAPONS in item.groups) & + self.received("Adventurer's Guild")) diff --git a/worlds/stardew_valley/options.py b/worlds/stardew_valley/options.py new file mode 100644 index 0000000000..e7478c7dad --- /dev/null +++ b/worlds/stardew_valley/options.py @@ -0,0 +1,409 @@ +from dataclasses import dataclass +from typing import Dict, Union, Protocol, runtime_checkable + +from Options import Option, Range, DeathLink, SpecialRange, Toggle, Choice + + +@runtime_checkable +class StardewOption(Protocol): + internal_name: str + + +@dataclass +class StardewOptions: + options: Dict[str, Union[bool, int]] + + def __getitem__(self, item: Union[str, StardewOption]) -> Union[bool, int]: + if isinstance(item, StardewOption): + item = item.internal_name + + return self.options.get(item, None) + + +class Goal(Choice): + """What's your goal with this play-through? + With Community Center, the world will be completed once you complete the Community Center. + With Grandpa's Evaluation, the world will be completed once 4 candles are lit around Grandpa's Shrine. + With Bottom of the Mines, the world will be completed once you reach level 120 in the local mineshaft. + With Cryptic Note, the world will be completed once you complete the quest "Cryptic Note" where Mr Qi asks you to reach floor 100 in the Skull Cavern + With Master Angler, the world will be completed once you have caught every fish in the game. Pairs well with Fishsanity""" + internal_name = "goal" + display_name = "Goal" + option_community_center = 0 + option_grandpa_evaluation = 1 + option_bottom_of_the_mines = 2 + option_cryptic_note = 3 + option_master_angler = 4 + + @classmethod + def get_option_name(cls, value) -> str: + if value == cls.option_grandpa_evaluation: + return "Grandpa's Evaluation" + + return super().get_option_name(value) + + +class StartingMoney(SpecialRange): + """Amount of gold when arriving at the farm. + Set to -1 or unlimited for infinite money in this playthrough""" + internal_name = "starting_money" + display_name = "Starting Gold" + range_start = -1 + range_end = 50000 + default = 5000 + + special_range_names = { + "unlimited": -1, + "vanilla": 500, + "extra": 2000, + "rich": 5000, + "very rich": 20000, + "filthy rich": 50000, + } + + +class ResourcePackMultiplier(SpecialRange): + """How many items will be in the resource pack. A lower setting mean fewer resources in each pack. + A higher setting means more resources in each pack. Easy (200) doubles the default quantity. + This also include Friendship bonuses that replace the one from the Bulletin Board.""" + internal_name = "resource_pack_multiplier" + default = 100 + range_start = 0 + range_end = 200 + # step = 25 + display_name = "Resource Pack Multiplier" + + special_range_names = { + "resource packs disabled": 0, + "half packs": 50, + "normal packs": 100, + "double packs": 200, + } + + +class BundleRandomization(Choice): + """What items are needed for the community center bundles? + With Vanilla, you get the standard bundles from the game + With Thematic, every bundle will require random items within their original category + With Shuffled, every bundle will require random items without logic""" + internal_name = "bundle_randomization" + display_name = "Bundle Randomization" + default = 1 + option_vanilla = 0 + option_thematic = 1 + option_shuffled = 2 + + +class BundlePrice(Choice): + """How many items are needed for the community center bundles? + With Very Cheap, every bundle will require two items fewer than usual + With Cheap, every bundle will require 1 item fewer than usual + With Normal, every bundle will require the vanilla number of items + With Expensive, every bundle will require 1 extra item""" + internal_name = "bundle_price" + display_name = "Bundle Price" + default = 2 + option_very_cheap = 0 + option_cheap = 1 + option_normal = 2 + option_expensive = 3 + + +class EntranceRandomization(Choice): + """Should area entrances be randomized? + With Disabled, no entrance randomization is done + With Pelican Town, only buildings in the main town area are randomized with each other + With Non Progression, only buildings that are always available are randomized with each other + """ + # With Buildings, All buildings in the world are randomized with each other + # With Everything, All buildings and areas are randomized with each other + # With Chaos, same as everything, but the buildings are shuffled again every in-game day. You can't learn it! + + internal_name = "entrance_randomization" + display_name = "Entrance Randomization" + default = 0 + option_disabled = 0 + option_pelican_town = 1 + option_non_progression = 2 + # option_buildings = 3 + # option_everything = 4 + # option_chaos = 4 + + +class BackpackProgression(Choice): + """How is the backpack progression handled? + With Vanilla, you can buy them at Pierre's. + With Progressive, you will randomly find Progressive Backpack to upgrade. + With Early Progressive, you can expect you first Backpack before the second season, and the third before the forth + season. + """ + internal_name = "backpack_progression" + display_name = "Backpack Progression" + default = 2 + option_vanilla = 0 + option_progressive = 1 + option_early_progressive = 2 + + +class ToolProgression(Choice): + """How is the tool progression handled? + With Vanilla, Clint will upgrade your tools with ore. + With Progressive, you will randomly find Progressive Tool to upgrade. + With World Checks, the tools of different quality will be found in the world.""" + internal_name = "tool_progression" + display_name = "Tool Progression" + default = 1 + option_vanilla = 0 + option_progressive = 1 + + +class TheMinesElevatorsProgression(Choice): + """How is The Mines' Elevator progression handled? + With Vanilla, you will unlock a new elevator floor every 5 floor in the mine. + With Progressive, you will randomly find Progressive Mine Elevator to go deeper. Location are sent for reaching + every level multiple of 5. + With Progressive from previous floor, you will randomly find Progressive Mine Elevator to go deeper. Location are + sent for taking the ladder or stair to every level multiple of 5, taking the elevator does not count.""" + internal_name = "elevator_progression" + display_name = "Elevator Progression" + default = 2 + option_vanilla = 0 + option_progressive = 1 + option_progressive_from_previous_floor = 2 + + +class SkillProgression(Choice): + """How is the skill progression handled? + With Vanilla, you will level up and get the normal reward at each level. + With Progressive, the xp will be counted internally, locations will be sent when you gain a virtual level. Your real + levels will be scattered around the world.""" + internal_name = "skill_progression" + display_name = "Skill Progression" + default = 1 + option_vanilla = 0 + option_progressive = 1 + + +class BuildingProgression(Choice): + """How is the building progression handled? + With Vanilla, you will buy each building and upgrade one at the time. + With Progressive, you will receive the buildings and will be able to build the first one of each building for free, + once it is received. If you want more of the same building, it will cost the vanilla price. + This option INCLUDES the shipping bin as a building you need to receive. + With Progressive early shipping bin, you can expect to receive the shipping bin before the end of the first season. + """ + internal_name = "building_progression" + display_name = "Building Progression" + default = 2 + option_vanilla = 0 + option_progressive = 1 + option_progressive_early_shipping_bin = 2 + + +class ArcadeMachineLocations(Choice): + """How are the Arcade Machines handled? + With Vanilla, the arcade machines are not included in the Archipelago shuffling. + With Victories, each Arcade Machine will contain one check on victory + With Victories Easy, the arcade machines are both made considerably easier to be more accessible for the average + player. + With Full Shuffling, the arcade machines will contain multiple checks each, and different buffs that make the game + easier are in the item pool. Junimo Kart has one check at the end of each level. + Journey of the Prairie King has one check after each boss, plus one check for each vendor equipment. + """ + internal_name = "arcade_machine_locations" + display_name = "Arcade Machine Locations" + default = 3 + option_disabled = 0 + option_victories = 1 + option_victories_easy = 2 + option_full_shuffling = 3 + + +class HelpWantedLocations(SpecialRange): + """How many "Help Wanted" quests need to be completed as ArchipelagoLocations + Out of every 7 quests, 4 will be item deliveries, and then 1 of each for: Fishing, Gathering and Slaying Monsters. + Choosing a multiple of 7 is recommended.""" + internal_name = "help_wanted_locations" + default = 7 + range_start = 0 + range_end = 56 + # step = 7 + display_name = "Number of Help Wanted locations" + + special_range_names = { + "none": 0, + "minimum": 7, + "normal": 14, + "lots": 28, + "maximum": 56, + } + + +class Fishsanity(Choice): + """Locations for catching fish? + With None, there are no locations for catching fish + With Legendaries, each of the 5 legendary fish are locations that contain items + With Special, a curated selection of strong fish are locations that contain items + With Random Selection, a random selection of fish are locations that contain items + With All, every single fish in the game is a location that contains an item. Pairs well with the Master Angler Goal + """ + internal_name = "fishsanity" + display_name = "Fishsanity" + default = 0 + option_none = 0 + option_legendaries = 1 + option_special = 2 + option_random_selection = 3 + option_all = 4 + + +class NumberOfPlayerBuffs(Range): + """Number of buffs to the player of each type that exist as items in the pool. + Buffs include movement speed (+25% multiplier, stacks additively) + and daily luck bonus (0.025 flat value per buff)""" + internal_name = "player_buff_number" + display_name = "Number of Player Buffs" + range_start = 0 + range_end = 12 + default = 4 + # step = 1 + + +class MultipleDaySleepEnabled(Toggle): + """Should you be able to sleep automatically multiple day strait?""" + internal_name = "multiple_day_sleep_enabled" + display_name = "Multiple Day Sleep Enabled" + default = 1 + + +class MultipleDaySleepCost(SpecialRange): + """How must gold it cost to sleep through multiple days? You will have to pay that amount for each day slept.""" + internal_name = "multiple_day_sleep_cost" + display_name = "Multiple Day Sleep Cost" + range_start = 0 + range_end = 200 + # step = 25 + + special_range_names = { + "free": 0, + "cheap": 25, + "medium": 50, + "expensive": 100, + } + + +class ExperienceMultiplier(SpecialRange): + """How fast do you want to level up. A lower setting mean less experience. + A higher setting means more experience.""" + internal_name = "experience_multiplier" + display_name = "Experience Multiplier" + range_start = 25 + range_end = 400 + # step = 25 + default = 200 + + special_range_names = { + "half": 50, + "vanilla": 100, + "double": 200, + "triple": 300, + "quadruple": 400, + } + + +class DebrisMultiplier(Choice): + """How much debris spawn on the player's farm? + With Vanilla, debris spawns normally + With Half, debris will spawn at half the normal rate + With Quarter, debris will spawn at one quarter of the normal rate + With None, No debris will spawn on the farm, ever + With Start Clear, debris will spawn at the normal rate, but the farm will be completely clear when starting the game + """ + internal_name = "debris_multiplier" + display_name = "Debris Multiplier" + default = 1 + option_vanilla = 0 + option_half = 1 + option_quarter = 2 + option_none = 3 + option_start_clear = 4 + + +class QuickStart(Toggle): + """Do you want the quick start package? You will get a few items to help early game automation, + so you can use the multiple day sleep at its maximum.""" + internal_name = "quick_start" + display_name = "Quick Start" + default = 1 + + +class Gifting(Toggle): + """Do you want to enable gifting items to and from other Stardew Valley worlds?""" + internal_name = "gifting" + display_name = "Gifting" + default = 1 + + +class GiftTax(SpecialRange): + """Joja Prime will deliver gifts within one business day, for a price! + Sending a gift will cost a percentage of the item's monetary value as a tax on the sender""" + internal_name = "gift_tax" + display_name = "Gift Tax" + range_start = 0 + range_end = 400 + # step = 20 + default = 20 + + special_range_names = { + "no tax": 0, + "soft tax": 20, + "rough tax": 40, + "full tax": 100, + "oppressive tax": 200, + "nightmare tax": 400, + } + + +stardew_valley_options: Dict[str, type(Option)] = { + option.internal_name: option + for option in [ + StartingMoney, + ResourcePackMultiplier, + BundleRandomization, + BundlePrice, + EntranceRandomization, + BackpackProgression, + ToolProgression, + SkillProgression, + BuildingProgression, + TheMinesElevatorsProgression, + ArcadeMachineLocations, + HelpWantedLocations, + Fishsanity, + NumberOfPlayerBuffs, + Goal, + MultipleDaySleepEnabled, + MultipleDaySleepCost, + ExperienceMultiplier, + DebrisMultiplier, + QuickStart, + Gifting, + GiftTax, + ] +} +default_options = {option.internal_name: option.default for option in stardew_valley_options.values()} +stardew_valley_options["death_link"] = DeathLink + + +def fetch_options(world, player: int) -> StardewOptions: + return StardewOptions({option: get_option_value(world, player, option) for option in stardew_valley_options}) + + +def get_option_value(world, player: int, name: str) -> Union[bool, int]: + assert name in stardew_valley_options, f"{name} is not a valid option for Stardew Valley." + + value = getattr(world, name) + + if issubclass(stardew_valley_options[name], Toggle): + return bool(value[player].value) + return value[player].value diff --git a/worlds/stardew_valley/regions.py b/worlds/stardew_valley/regions.py new file mode 100644 index 0000000000..0979d7f883 --- /dev/null +++ b/worlds/stardew_valley/regions.py @@ -0,0 +1,291 @@ +from dataclasses import dataclass, field +from enum import IntFlag +from random import Random +from typing import Iterable, Dict, Protocol, Optional, List, Tuple + +from BaseClasses import Region, Entrance +from . import options +from .options import StardewOptions + + +class RegionFactory(Protocol): + def __call__(self, name: str, regions: Iterable[str]) -> Region: + raise NotImplementedError + + +class RandomizationFlag(IntFlag): + NOT_RANDOMIZED = 0b0 + PELICAN_TOWN = 0b11111 + NON_PROGRESSION = 0b11110 + BUILDINGS = 0b11100 + EVERYTHING = 0b11000 + CHAOS = 0b10000 + + +@dataclass(frozen=True) +class RegionData: + name: str + exits: List[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class ConnectionData: + name: str + destination: str + reverse: Optional[str] = None + flag: RandomizationFlag = RandomizationFlag.NOT_RANDOMIZED + + def __post_init__(self): + if self.reverse is None and " to " in self.name: + origin, destination = self.name.split(" to ") + super().__setattr__("reverse", f"{destination} to {origin}") + + +stardew_valley_regions = [ + RegionData("Menu", ["To Stardew Valley"]), + RegionData("Stardew Valley", ["To Farmhouse"]), + RegionData("Farmhouse", ["Outside to Farm", "Downstairs to Cellar"]), + RegionData("Cellar"), + RegionData("Farm", ["Farm to Backwoods", "Farm to Bus Stop", "Farm to Forest", "Farm to Farmcave", "Enter Greenhouse", + "Use Desert Obelisk", "Use Island Obelisk"]), + RegionData("Backwoods", ["Backwoods to Mountain"]), + RegionData("Bus Stop", ["Bus Stop to Town", "Take Bus to Desert", "Bus Stop to Tunnel Entrance"]), + RegionData("Forest", ["Forest to Town", "Enter Secret Woods", "Forest to Wizard Tower", "Forest to Marnie's Ranch", + "Forest to Leah's Cottage", "Forest to Sewers"]), + RegionData("Farmcave"), + RegionData("Greenhouse"), + RegionData("Mountain", ["Mountain to Railroad", "Mountain to Tent", "Mountain to Carpenter Shop", "Mountain to The Mines", + "Enter Quarry", "Mountain to Adventurer's Guild", "Mountain to Town"]), + RegionData("Tunnel Entrance", ["Enter Tunnel"]), + RegionData("Tunnel"), + RegionData("Town", ["Town to Community Center", "Town to Beach", "Town to Hospital", + "Town to Pierre's General Store", "Town to Saloon", "Town to Alex's House", "Town to Trailer", "Town to Mayor's Manor", + "Town to Sam's House", "Town to Haley's House", "Town to Sewers", "Town to Clint's Blacksmith", "Town to Museum", + "Town to JojaMart"]), + RegionData("Beach", ["Beach to Willy's Fish Shop", "Enter Elliott's House", "Enter Tide Pools"]), + RegionData("Railroad", ["Enter Bathhouse Entrance", "Enter Witch Warp Cave"]), # "Enter Perfection Cutscene Area" + RegionData("Marnie's Ranch"), + RegionData("Leah's Cottage"), + RegionData("Sewers", ["Enter Mutant Bug Lair"]), + RegionData("Mutant Bug Lair"), + RegionData("Wizard Tower", ["Enter Wizard Basement"]), + RegionData("Wizard Basement"), + RegionData("Tent"), + RegionData("Carpenter Shop", ["Enter Sebastian's Room"]), + RegionData("Sebastian's Room"), + RegionData("Adventurer's Guild"), + RegionData("Community Center", + ["Access Crafts Room", "Access Pantry", "Access Fish Tank", "Access Boiler Room", "Access Bulletin Board", + "Access Vault"]), + RegionData("Crafts Room"), + RegionData("Pantry"), + RegionData("Fish Tank"), + RegionData("Boiler Room"), + RegionData("Bulletin Board"), + RegionData("Vault"), + RegionData("Hospital", ["Enter Harvey's Room"]), + RegionData("Harvey's Room"), + RegionData("Pierre's General Store", ["Enter Sunroom"]), + RegionData("Sunroom"), + RegionData("Saloon", ["Play Journey of the Prairie King", "Play Junimo Kart"]), + RegionData("Alex's House"), + RegionData("Trailer"), + RegionData("Mayor's Manor"), + RegionData("Sam's House"), + RegionData("Haley's House"), + RegionData("Clint's Blacksmith"), + RegionData("Museum"), + RegionData("JojaMart"), + RegionData("Willy's Fish Shop"), + RegionData("Elliott's House"), + RegionData("Tide Pools"), + RegionData("Bathhouse Entrance", ["Enter Locker Room"]), + RegionData("Locker Room", ["Enter Public Bath"]), + RegionData("Public Bath"), + RegionData("Witch Warp Cave", ["Enter Witch's Swamp"]), + RegionData("Witch's Swamp"), + RegionData("Quarry", ["Enter Quarry Mine Entrance"]), + RegionData("Quarry Mine Entrance", ["Enter Quarry Mine"]), + RegionData("Quarry Mine"), + RegionData("Secret Woods"), + RegionData("The Desert", ["Enter Skull Cavern Entrance"]), + RegionData("Skull Cavern Entrance", ["Enter Skull Cavern"]), + RegionData("Skull Cavern"), + RegionData("Ginger Island"), + RegionData("JotPK World 1", ["Reach JotPK World 2"]), + RegionData("JotPK World 2", ["Reach JotPK World 3"]), + RegionData("JotPK World 3"), + RegionData("Junimo Kart 1", ["Reach Junimo Kart 2"]), + RegionData("Junimo Kart 2", ["Reach Junimo Kart 3"]), + RegionData("Junimo Kart 3"), + RegionData("The Mines", ["Dig to The Mines - Floor 5", "Dig to The Mines - Floor 10", "Dig to The Mines - Floor 15", + "Dig to The Mines - Floor 20", "Dig to The Mines - Floor 25", "Dig to The Mines - Floor 30", + "Dig to The Mines - Floor 35", "Dig to The Mines - Floor 40", "Dig to The Mines - Floor 45", + "Dig to The Mines - Floor 50", "Dig to The Mines - Floor 55", "Dig to The Mines - Floor 60", + "Dig to The Mines - Floor 65", "Dig to The Mines - Floor 70", "Dig to The Mines - Floor 75", + "Dig to The Mines - Floor 80", "Dig to The Mines - Floor 85", "Dig to The Mines - Floor 90", + "Dig to The Mines - Floor 95", "Dig to The Mines - Floor 100", "Dig to The Mines - Floor 105", + "Dig to The Mines - Floor 110", "Dig to The Mines - Floor 115", "Dig to The Mines - Floor 120"]), + RegionData("The Mines - Floor 5"), + RegionData("The Mines - Floor 10"), + RegionData("The Mines - Floor 15"), + RegionData("The Mines - Floor 20"), + RegionData("The Mines - Floor 25"), + RegionData("The Mines - Floor 30"), + RegionData("The Mines - Floor 35"), + RegionData("The Mines - Floor 40"), + RegionData("The Mines - Floor 45"), + RegionData("The Mines - Floor 50"), + RegionData("The Mines - Floor 55"), + RegionData("The Mines - Floor 60"), + RegionData("The Mines - Floor 65"), + RegionData("The Mines - Floor 70"), + RegionData("The Mines - Floor 75"), + RegionData("The Mines - Floor 80"), + RegionData("The Mines - Floor 85"), + RegionData("The Mines - Floor 90"), + RegionData("The Mines - Floor 95"), + RegionData("The Mines - Floor 100"), + RegionData("The Mines - Floor 105"), + RegionData("The Mines - Floor 110"), + RegionData("The Mines - Floor 115"), + RegionData("The Mines - Floor 120"), +] + +# Exists and where they lead +mandatory_connections = [ + ConnectionData("To Stardew Valley", "Stardew Valley"), + ConnectionData("To Farmhouse", "Farmhouse"), + ConnectionData("Outside to Farm", "Farm"), + ConnectionData("Downstairs to Cellar", "Cellar"), + ConnectionData("Farm to Backwoods", "Backwoods"), + ConnectionData("Farm to Bus Stop", "Bus Stop"), + ConnectionData("Farm to Forest", "Forest"), + ConnectionData("Farm to Farmcave", "Farmcave", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Enter Greenhouse", "Greenhouse"), + ConnectionData("Use Desert Obelisk", "The Desert"), + ConnectionData("Use Island Obelisk", "Ginger Island"), + ConnectionData("Backwoods to Mountain", "Mountain"), + ConnectionData("Bus Stop to Town", "Town"), + ConnectionData("Bus Stop to Tunnel Entrance", "Tunnel Entrance"), + ConnectionData("Take Bus to Desert", "The Desert"), + ConnectionData("Enter Tunnel", "Tunnel"), + ConnectionData("Forest to Town", "Town"), + ConnectionData("Forest to Wizard Tower", "Wizard Tower", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Enter Wizard Basement", "Wizard Basement"), + ConnectionData("Forest to Marnie's Ranch", "Marnie's Ranch", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Forest to Leah's Cottage", "Leah's Cottage"), + ConnectionData("Enter Secret Woods", "Secret Woods"), + ConnectionData("Forest to Sewers", "Sewers"), + ConnectionData("Town to Sewers", "Sewers"), + ConnectionData("Enter Mutant Bug Lair", "Mutant Bug Lair"), + ConnectionData("Mountain to Railroad", "Railroad"), + ConnectionData("Mountain to Tent", "Tent", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Mountain to Carpenter Shop", "Carpenter Shop", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Enter Sebastian's Room", "Sebastian's Room"), + ConnectionData("Mountain to Adventurer's Guild", "Adventurer's Guild"), + ConnectionData("Enter Quarry", "Quarry"), + ConnectionData("Enter Quarry Mine Entrance", "Quarry Mine Entrance"), + ConnectionData("Enter Quarry Mine", "Quarry Mine"), + ConnectionData("Mountain to Town", "Town"), + ConnectionData("Town to Community Center", "Community Center", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Access Crafts Room", "Crafts Room"), + ConnectionData("Access Pantry", "Pantry"), + ConnectionData("Access Fish Tank", "Fish Tank"), + ConnectionData("Access Boiler Room", "Boiler Room"), + ConnectionData("Access Bulletin Board", "Bulletin Board"), + ConnectionData("Access Vault", "Vault"), + ConnectionData("Town to Hospital", "Hospital", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Enter Harvey's Room", "Harvey's Room"), + ConnectionData("Town to Pierre's General Store", "Pierre's General Store", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Enter Sunroom", "Sunroom"), + ConnectionData("Town to Clint's Blacksmith", "Clint's Blacksmith", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Saloon", "Saloon", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Play Journey of the Prairie King", "JotPK World 1"), + ConnectionData("Reach JotPK World 2", "JotPK World 2"), + ConnectionData("Reach JotPK World 3", "JotPK World 3"), + ConnectionData("Play Junimo Kart", "Junimo Kart 1"), + ConnectionData("Reach Junimo Kart 2", "Junimo Kart 2"), + ConnectionData("Reach Junimo Kart 3", "Junimo Kart 3"), + ConnectionData("Town to Sam's House", "Sam's House", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Haley's House", "Haley's House", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Mayor's Manor", "Mayor's Manor", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Alex's House", "Alex's House", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Trailer", "Trailer", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Museum", "Museum", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to JojaMart", "JojaMart", flag=RandomizationFlag.PELICAN_TOWN), + ConnectionData("Town to Beach", "Beach"), + ConnectionData("Enter Elliott's House", "Elliott's House"), + ConnectionData("Beach to Willy's Fish Shop", "Willy's Fish Shop", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Enter Tide Pools", "Tide Pools"), + ConnectionData("Mountain to The Mines", "The Mines", flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData("Dig to The Mines - Floor 5", "The Mines - Floor 5"), + ConnectionData("Dig to The Mines - Floor 10", "The Mines - Floor 10"), + ConnectionData("Dig to The Mines - Floor 15", "The Mines - Floor 15"), + ConnectionData("Dig to The Mines - Floor 20", "The Mines - Floor 20"), + ConnectionData("Dig to The Mines - Floor 25", "The Mines - Floor 25"), + ConnectionData("Dig to The Mines - Floor 30", "The Mines - Floor 30"), + ConnectionData("Dig to The Mines - Floor 35", "The Mines - Floor 35"), + ConnectionData("Dig to The Mines - Floor 40", "The Mines - Floor 40"), + ConnectionData("Dig to The Mines - Floor 45", "The Mines - Floor 45"), + ConnectionData("Dig to The Mines - Floor 50", "The Mines - Floor 50"), + ConnectionData("Dig to The Mines - Floor 55", "The Mines - Floor 55"), + ConnectionData("Dig to The Mines - Floor 60", "The Mines - Floor 60"), + ConnectionData("Dig to The Mines - Floor 65", "The Mines - Floor 65"), + ConnectionData("Dig to The Mines - Floor 70", "The Mines - Floor 70"), + ConnectionData("Dig to The Mines - Floor 75", "The Mines - Floor 75"), + ConnectionData("Dig to The Mines - Floor 80", "The Mines - Floor 80"), + ConnectionData("Dig to The Mines - Floor 85", "The Mines - Floor 85"), + ConnectionData("Dig to The Mines - Floor 90", "The Mines - Floor 90"), + ConnectionData("Dig to The Mines - Floor 95", "The Mines - Floor 95"), + ConnectionData("Dig to The Mines - Floor 100", "The Mines - Floor 100"), + ConnectionData("Dig to The Mines - Floor 105", "The Mines - Floor 105"), + ConnectionData("Dig to The Mines - Floor 110", "The Mines - Floor 110"), + ConnectionData("Dig to The Mines - Floor 115", "The Mines - Floor 115"), + ConnectionData("Dig to The Mines - Floor 120", "The Mines - Floor 120"), + ConnectionData("Enter Skull Cavern Entrance", "Skull Cavern Entrance"), + ConnectionData("Enter Skull Cavern", "Skull Cavern"), + ConnectionData("Enter Witch Warp Cave", "Witch Warp Cave"), + ConnectionData("Enter Witch's Swamp", "Witch's Swamp"), + ConnectionData("Enter Bathhouse Entrance", "Bathhouse Entrance"), + ConnectionData("Enter Locker Room", "Locker Room"), + ConnectionData("Enter Public Bath", "Public Bath"), +] + + +def create_regions(region_factory: RegionFactory, random: Random, world_options: StardewOptions) -> Tuple[Iterable[Region], Dict[str, str]]: + regions: Dict[str: Region] = {region.name: region_factory(region.name, region.exits) for region in stardew_valley_regions} + entrances: Dict[str: Entrance] = {entrance.name: entrance + for region in regions.values() + for entrance in region.exits} + + connections, randomized_data = randomize_connections(random, world_options) + + for connection in connections: + if connection.name not in entrances: + continue + entrances[connection.name].connect(regions[connection.destination]) + + return regions.values(), randomized_data + + +def randomize_connections(random: Random, world_options: StardewOptions) -> Tuple[List[ConnectionData], Dict[str, str]]: + connections_to_randomize = [] + if world_options[options.EntranceRandomization] == options.EntranceRandomization.option_pelican_town: + connections_to_randomize = [connection for connection in mandatory_connections if RandomizationFlag.PELICAN_TOWN in connection.flag] + elif world_options[options.EntranceRandomization] == options.EntranceRandomization.option_non_progression: + connections_to_randomize = [connection for connection in mandatory_connections if RandomizationFlag.NON_PROGRESSION in connection.flag] + random.shuffle(connections_to_randomize) + + destination_pool = list(connections_to_randomize) + random.shuffle(destination_pool) + + randomized_connections = [] + randomized_data = {} + for connection in connections_to_randomize: + destination = destination_pool.pop() + randomized_connections.append(ConnectionData(connection.name, destination.destination, destination.reverse)) + randomized_data[connection.name] = destination.name + randomized_data[destination.reverse] = connection.reverse + + return mandatory_connections, randomized_data diff --git a/worlds/stardew_valley/requirements.txt b/worlds/stardew_valley/requirements.txt new file mode 100644 index 0000000000..b0922176e4 --- /dev/null +++ b/worlds/stardew_valley/requirements.txt @@ -0,0 +1 @@ +importlib_resources; python_version <= '3.8' diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py new file mode 100644 index 0000000000..f9ba31cc19 --- /dev/null +++ b/worlds/stardew_valley/rules.py @@ -0,0 +1,190 @@ +import itertools +from typing import Dict + +from BaseClasses import MultiWorld +from worlds.generic import Rules as MultiWorldRules +from . import options, locations +from .bundles import Bundle +from .locations import LocationTags +from .logic import StardewLogic, _And, season_per_skill_level, tool_prices, week_days + +help_wanted_per_season = { + 1: "Spring", + 2: "Summer", + 3: "Fall", + 4: "Winter", + 5: "Year Two", + 6: "Year Two", + 7: "Year Two", + 8: "Year Two", + 9: "Year Two", + 10: "Year Two", +} + + +def set_rules(multi_world: MultiWorld, player: int, world_options: options.StardewOptions, logic: StardewLogic, + current_bundles: Dict[str, Bundle]): + summer = multi_world.get_location("Summer", player) + all_location_names = list(location.name for location in multi_world.get_locations(player)) + + for floor in range(5, 120 + 5, 5): + MultiWorldRules.add_rule(multi_world.get_entrance(f"Dig to The Mines - Floor {floor}", player), + logic.can_mine_to_floor(floor).simplify()) + + MultiWorldRules.add_rule(multi_world.get_entrance("Enter Quarry", player), + logic.received("Bridge Repair").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Enter Secret Woods", player), + logic.has_tool("Axe", "Iron").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Take Bus to Desert", player), + logic.received("Bus Repair").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Enter Skull Cavern", player), + logic.received("Skull Key").simplify()) + + MultiWorldRules.add_rule(multi_world.get_entrance("Use Desert Obelisk", player), + logic.received("Desert Obelisk").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Use Island Obelisk", player), + logic.received("Island Obelisk").simplify()) + + # Those checks do not exist if ToolProgression is vanilla + if world_options[options.ToolProgression] != options.ToolProgression.option_vanilla: + MultiWorldRules.add_rule(multi_world.get_location("Purchase Fiberglass Rod", player), + (logic.has_skill_level("Fishing", 2) & logic.can_spend_money(1800)).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Purchase Iridium Rod", player), + (logic.has_skill_level("Fishing", 6) & logic.can_spend_money(7500)).simplify()) + + materials = [None, "Copper", "Iron", "Gold", "Iridium"] + tool = ["Hoe", "Pickaxe", "Axe", "Watering Can", "Trash Can"] + for (previous, material), tool in itertools.product(zip(materials[:4], materials[1:]), tool): + if previous is None: + MultiWorldRules.add_rule(multi_world.get_location(f"{material} {tool} Upgrade", player), + (logic.has(f"{material} Ore") & + logic.can_spend_money(tool_prices[material])).simplify()) + else: + MultiWorldRules.add_rule(multi_world.get_location(f"{material} {tool} Upgrade", player), + (logic.has(f"{material} Ore") & logic.has_tool(tool, previous) & + logic.can_spend_money(tool_prices[material])).simplify()) + + # Skills + if world_options[options.SkillProgression] != options.SkillProgression.option_vanilla: + for i in range(1, 11): + MultiWorldRules.set_rule(multi_world.get_location(f"Level {i} Farming", player), + (logic.received(season_per_skill_level["Farming", i])).simplify()) + MultiWorldRules.set_rule(multi_world.get_location(f"Level {i} Fishing", player), + (logic.can_get_fishing_xp() & + logic.received(season_per_skill_level["Fishing", i])).simplify()) + MultiWorldRules.add_rule(multi_world.get_location(f"Level {i} Foraging", player), + logic.received(season_per_skill_level["Foraging", i]).simplify()) + if i >= 6: + MultiWorldRules.add_rule(multi_world.get_location(f"Level {i} Foraging", player), + logic.has_tool("Axe", "Iron").simplify()) + MultiWorldRules.set_rule(multi_world.get_location(f"Level {i} Mining", player), + logic.received(season_per_skill_level["Mining", i]).simplify()) + MultiWorldRules.set_rule(multi_world.get_location(f"Level {i} Combat", player), + (logic.received(season_per_skill_level["Combat", i]) & + logic.has_any_weapon()).simplify()) + + # Bundles + for bundle in current_bundles.values(): + MultiWorldRules.set_rule(multi_world.get_location(bundle.get_name_with_bundle(), player), + logic.can_complete_bundle(bundle.requirements, bundle.number_required).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Complete Crafts Room", player), + _And(logic.can_reach_location(bundle.name) + for bundle in locations.locations_by_tag[LocationTags.CRAFTS_ROOM_BUNDLE]).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Complete Pantry", player), + _And(logic.can_reach_location(bundle.name) + for bundle in locations.locations_by_tag[LocationTags.PANTRY_BUNDLE]).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Complete Fish Tank", player), + _And(logic.can_reach_location(bundle.name) + for bundle in locations.locations_by_tag[LocationTags.FISH_TANK_BUNDLE]).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Complete Boiler Room", player), + _And(logic.can_reach_location(bundle.name) + for bundle in locations.locations_by_tag[LocationTags.BOILER_ROOM_BUNDLE]).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Complete Bulletin Board", player), + _And(logic.can_reach_location(bundle.name) + for bundle in locations.locations_by_tag[LocationTags.BULLETIN_BOARD_BUNDLE]).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Complete Vault", player), + _And(logic.can_reach_location(bundle.name) + for bundle in locations.locations_by_tag[LocationTags.VAULT_BUNDLE]).simplify()) + + # Buildings + if world_options[options.BuildingProgression] != options.BuildingProgression.option_vanilla: + for building in locations.locations_by_tag[LocationTags.BUILDING_BLUEPRINT]: + MultiWorldRules.set_rule(multi_world.get_location(building.name, player), + logic.building_rules[building.name.replace(" Blueprint", "")].simplify()) + + # Story Quests + for quest in locations.locations_by_tag[LocationTags.QUEST]: + MultiWorldRules.set_rule(multi_world.get_location(quest.name, player), + logic.quest_rules[quest.name].simplify()) + + # Help Wanted Quests + desired_number_help_wanted: int = world_options[options.HelpWantedLocations] // 7 + for i in range(1, desired_number_help_wanted + 1): + prefix = "Help Wanted:" + delivery = "Item Delivery" + rule = logic.received(help_wanted_per_season[min(5, i)]) + fishing_rule = rule & logic.can_fish() + slay_rule = rule & logic.has_any_weapon() + for j in range(i, i + 4): + MultiWorldRules.set_rule(multi_world.get_location(f"{prefix} {delivery} {j}", player), + rule.simplify()) + + MultiWorldRules.set_rule(multi_world.get_location(f"{prefix} Gathering {i}", player), + rule.simplify()) + MultiWorldRules.set_rule(multi_world.get_location(f"{prefix} Fishing {i}", player), + fishing_rule.simplify()) + MultiWorldRules.set_rule(multi_world.get_location(f"{prefix} Slay Monsters {i}", player), + slay_rule.simplify()) + + fish_prefix = "Fishsanity: " + for fish_location in locations.locations_by_tag[LocationTags.FISHSANITY]: + if fish_location.name in all_location_names: + fish_name = fish_location.name[len(fish_prefix):] + MultiWorldRules.set_rule(multi_world.get_location(fish_location.name, player), + logic.has(fish_name).simplify()) + + if world_options[options.BuildingProgression] == options.BuildingProgression.option_progressive_early_shipping_bin: + summer.access_rule = summer.access_rule & logic.received("Shipping Bin") + + # Backpacks + if world_options[options.BackpackProgression] != options.BackpackProgression.option_vanilla: + MultiWorldRules.add_rule(multi_world.get_location("Large Pack", player), + logic.can_spend_money(2000).simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Deluxe Pack", player), + logic.can_spend_money(10000).simplify()) + + if world_options[options.BackpackProgression] == options.BackpackProgression.option_early_progressive: + summer.access_rule = summer.access_rule & logic.received("Progressive Backpack") + MultiWorldRules.add_rule(multi_world.get_location("Winter", player), + logic.received("Progressive Backpack", 2).simplify()) + + MultiWorldRules.add_rule(multi_world.get_location("Old Master Cannoli", player), + logic.has("Sweet Gem Berry").simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Galaxy Sword Shrine", player), + logic.has("Prismatic Shard").simplify()) + + # Traveling Merchant + for day in week_days: + item_for_day = f"Traveling Merchant: {day}" + for i in range(1, 4): + location_name = f"Traveling Merchant {day} Item {i}" + MultiWorldRules.set_rule(multi_world.get_location(location_name, player), + logic.received(item_for_day)) + + if world_options[options.ArcadeMachineLocations] == options.ArcadeMachineLocations.option_full_shuffling: + MultiWorldRules.add_rule(multi_world.get_entrance("Play Junimo Kart", player), + (logic.received("Skull Key") & logic.has("Junimo Kart Small Buff")).simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Reach Junimo Kart 2", player), + logic.has("Junimo Kart Medium Buff").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Reach Junimo Kart 3", player), + logic.has("Junimo Kart Big Buff").simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Junimo Kart: Sunset Speedway (Victory)", player), + logic.has("Junimo Kart Max Buff").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Play Journey of the Prairie King", player), + logic.has("JotPK Small Buff").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Reach JotPK World 2", player), + logic.has("JotPK Medium Buff").simplify()) + MultiWorldRules.add_rule(multi_world.get_entrance("Reach JotPK World 3", player), + logic.has("JotPK Big Buff").simplify()) + MultiWorldRules.add_rule(multi_world.get_location("Journey of the Prairie King Victory", player), + logic.has("JotPK Max Buff").simplify()) diff --git a/worlds/stardew_valley/scripts/__init__.py b/worlds/stardew_valley/scripts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/stardew_valley/scripts/export_items.py b/worlds/stardew_valley/scripts/export_items.py new file mode 100644 index 0000000000..6d929226d9 --- /dev/null +++ b/worlds/stardew_valley/scripts/export_items.py @@ -0,0 +1,26 @@ +"""Items export script +This script can be used to export all the AP items into a json file in the output folder. This file is used by the tests +of the mod to ensure it can handle all possible items. + +To run the script, use `python -m worlds.stardew_valley.scripts.export_items` from the repository root. +""" + +import json +import os.path + +from worlds.stardew_valley import item_table + +if not os.path.isdir("output"): + os.mkdir("output") + +if __name__ == "__main__": + with open("output/stardew_valley_item_table.json", "w+") as f: + items = { + item.name: { + "code": item.code, + "classification": item.classification.name + } + for item in item_table.values() + if item.code is not None + } + json.dump({"items": items}, f) diff --git a/worlds/stardew_valley/scripts/export_locations.py b/worlds/stardew_valley/scripts/export_locations.py new file mode 100644 index 0000000000..1dc60f79b1 --- /dev/null +++ b/worlds/stardew_valley/scripts/export_locations.py @@ -0,0 +1,26 @@ +"""Locations export script +This script can be used to export all the AP locations into a json file in the output folder. This file is used by the +tests of the mod to ensure it can handle all possible locations. + +To run the script, use `python -m worlds.stardew_valley.scripts.export_locations` from the repository root. +""" + +import json +import os + +from worlds.stardew_valley import location_table + +if not os.path.isdir("output"): + os.mkdir("output") + +if __name__ == "__main__": + with open("output/stardew_valley_location_table.json", "w+") as f: + locations = { + location.name: { + "code": location.code, + "region": location.region, + } + for location in location_table.values() + if location.code is not None + } + json.dump({"locations": locations}, f) diff --git a/worlds/stardew_valley/scripts/update_data.py b/worlds/stardew_valley/scripts/update_data.py new file mode 100644 index 0000000000..4b7b6be201 --- /dev/null +++ b/worlds/stardew_valley/scripts/update_data.py @@ -0,0 +1,88 @@ +"""Update data script +This script can be used to assign new ids for the items and locations in the CSV file. It also regenerates the items +based on the resource packs. + +To run the script, use `python -m worlds.stardew_valley.scripts.update_data` from the repository root. +""" + +import csv +import itertools +from pathlib import Path +from typing import List + +from worlds.stardew_valley import LocationData +from worlds.stardew_valley.items import load_item_csv, Group, ItemData, load_resource_pack_csv, friendship_pack +from worlds.stardew_valley.locations import load_location_csv + +RESOURCE_PACK_CODE_OFFSET = 5000 +script_folder = Path(__file__) + + +def write_item_csv(items: List[ItemData]): + with open((script_folder.parent.parent / "data/items.csv").resolve(), "w", newline="") as file: + writer = csv.DictWriter(file, ["id", "name", "classification", "groups"]) + writer.writeheader() + for item in items: + item_dict = { + "id": item.code_without_offset, + "name": item.name, + "classification": item.classification.name, + "groups": ",".join(sorted(group.name for group in item.groups)) + } + writer.writerow(item_dict) + + +def write_location_csv(locations: List[LocationData]): + with open((script_folder.parent.parent / "data/locations.csv").resolve(), "w", newline="") as file: + write = csv.DictWriter(file, ["id", "region", "name", "tags"]) + write.writeheader() + for location in locations: + location_dict = { + "id": location.code_without_offset, + "name": location.name, + "region": location.region, + "tags": ",".join(sorted(group.name for group in location.tags)) + } + write.writerow(location_dict) + + +if __name__ == "__main__": + loaded_items = load_item_csv() + + item_counter = itertools.count(max(item.code_without_offset + for item in loaded_items + if Group.RESOURCE_PACK not in item.groups + and item.code_without_offset is not None) + 1) + items_to_write = [] + for item in loaded_items: + if item.has_any_group(Group.RESOURCE_PACK, Group.FRIENDSHIP_PACK): + continue + + if item.code_without_offset is None: + items_to_write.append(ItemData(next(item_counter), item.name, item.classification, item.groups)) + continue + + items_to_write.append(item) + + all_resource_packs = load_resource_pack_csv() + [friendship_pack] + resource_pack_counter = itertools.count(RESOURCE_PACK_CODE_OFFSET) + items_to_write.extend( + item for resource_pack in all_resource_packs for item in resource_pack.as_item_data(resource_pack_counter)) + + write_item_csv(items_to_write) + + loaded_locations = load_location_csv() + location_counter = itertools.count(max(location.code_without_offset + for location in loaded_locations + if location.code_without_offset is not None) + 1) + + locations_to_write = [] + for location in loaded_locations: + if location.code_without_offset is None: + locations_to_write.append( + LocationData(next(location_counter), location.region, location.name, location.tags)) + continue + + locations_to_write.append(location) + + write_location_csv(locations_to_write) diff --git a/worlds/stardew_valley/test/TestAllLogic.py b/worlds/stardew_valley/test/TestAllLogic.py new file mode 100644 index 0000000000..de1c004913 --- /dev/null +++ b/worlds/stardew_valley/test/TestAllLogic.py @@ -0,0 +1,53 @@ +import unittest + +from test.general import setup_solo_multiworld +from .. import StardewValleyWorld +from ..bundle_data import all_bundle_items_except_money +from ..logic import MISSING_ITEM, _False + + +class TestAllLogicalItem(unittest.TestCase): + multi_world = setup_solo_multiworld(StardewValleyWorld) + world = multi_world.worlds[1] + logic = world.logic + + def setUp(self) -> None: + for item in self.multi_world.get_items(): + self.multi_world.state.collect(item, event=True) + + def test_given_bundle_item_then_is_available_in_logic(self): + for bundle_item in all_bundle_items_except_money: + with self.subTest(bundle_item=bundle_item): + assert bundle_item.item.name in self.logic.item_rules + + def test_given_item_rule_then_can_be_resolved(self): + for item in self.logic.item_rules.keys(): + with self.subTest(item=item): + rule = self.logic.item_rules[item] + + assert MISSING_ITEM not in repr(rule) + assert rule == _False() or rule(self.multi_world.state), f"Could not resolve rule for {item} {rule}" + + def test_given_building_rule_then_can_be_resolved(self): + for item in self.logic.building_rules.keys(): + with self.subTest(item=item): + rule = self.logic.building_rules[item] + + assert MISSING_ITEM not in repr(rule) + assert rule == _False() or rule(self.multi_world.state), f"Could not resolve rule for {item} {rule}" + + def test_given_quest_rule_then_can_be_resolved(self): + for item in self.logic.quest_rules.keys(): + with self.subTest(item=item): + rule = self.logic.quest_rules[item] + + assert MISSING_ITEM not in repr(rule) + assert rule == _False() or rule(self.multi_world.state), f"Could not resolve rule for {item} {rule}" + + def test_given_location_rule_then_can_be_resolved(self): + for location in self.multi_world.get_locations(1): + with self.subTest(location=location): + rule = location.access_rule + + assert MISSING_ITEM not in repr(rule) + assert rule == _False() or rule(self.multi_world.state), f"Could not resolve rule for {location} {rule}" diff --git a/worlds/stardew_valley/test/TestBundles.py b/worlds/stardew_valley/test/TestBundles.py new file mode 100644 index 0000000000..5801737709 --- /dev/null +++ b/worlds/stardew_valley/test/TestBundles.py @@ -0,0 +1,16 @@ +import unittest + +from ..bundle_data import all_bundle_items + + +class TestBundles(unittest.TestCase): + def test_all_bundle_items_have_3_parts(self): + for bundle_item in all_bundle_items: + name = bundle_item.item.name + assert len(name) > 0 + id = bundle_item.item.item_id + assert (id > 0 or id == -1) + amount = bundle_item.amount + assert amount > 0 + quality = bundle_item.quality + assert quality >= 0 diff --git a/worlds/stardew_valley/test/TestData.py b/worlds/stardew_valley/test/TestData.py new file mode 100644 index 0000000000..c08cef0e74 --- /dev/null +++ b/worlds/stardew_valley/test/TestData.py @@ -0,0 +1,20 @@ +import unittest + +from ..items import load_item_csv +from ..locations import load_location_csv + + +class TestCsvIntegrity(unittest.TestCase): + def test_items_integrity(self): + items = load_item_csv() + + for item in items: + assert item.code_without_offset is not None, \ + "Some item do not have an id. Run the script `update_data.py` to generate them." + + def test_locations_integrity(self): + locations = load_location_csv() + + for location in locations: + assert location.code_without_offset is not None, \ + "Some location do not have an id. Run the script `update_data.py` to generate them." diff --git a/worlds/stardew_valley/test/TestGeneration.py b/worlds/stardew_valley/test/TestGeneration.py new file mode 100644 index 0000000000..840052d30b --- /dev/null +++ b/worlds/stardew_valley/test/TestGeneration.py @@ -0,0 +1,127 @@ +from BaseClasses import ItemClassification +from . import SVTestBase +from .. import locations, items, location_table, options +from ..items import items_by_group, Group +from ..locations import LocationTags + + +class TestBaseItemGeneration(SVTestBase): + + def test_all_progression_items_are_added_to_the_pool(self): + for classification in [ItemClassification.progression, ItemClassification.useful]: + with self.subTest(classification=classification): + + all_classified_items = {self.world.create_item(item) + for item in items.items_by_group[items.Group.COMMUNITY_REWARD] + if item.classification is classification} + + for item in all_classified_items: + assert item in self.multiworld.itempool + + def test_creates_as_many_item_as_non_event_locations(self): + non_event_locations = [location for location in self.multiworld.get_locations(self.player) if + not location.event] + + assert len(non_event_locations), len(self.multiworld.itempool) + + +class TestGivenProgressiveBackpack(SVTestBase): + options = {options.BackpackProgression.internal_name: options.BackpackProgression.option_progressive} + + def test_when_generate_world_then_two_progressive_backpack_are_added(self): + assert self.multiworld.itempool.count(self.world.create_item("Progressive Backpack")) == 2 + + def test_when_generate_world_then_backpack_locations_are_added(self): + created_locations = {location.name for location in self.multiworld.get_locations(1)} + assert all(location.name in created_locations for location in locations.locations_by_tag[LocationTags.BACKPACK]) + + +class TestRemixedMineRewards(SVTestBase): + def test_when_generate_world_then_one_reward_is_added_per_chest(self): + # assert self.world.create_item("Rusty Sword") in self.multiworld.itempool + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_10]) + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_20]) + assert self.world.create_item("Slingshot") in self.multiworld.itempool + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_50]) + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_60]) + assert self.world.create_item("Master Slingshot") in self.multiworld.itempool + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_80]) + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_90]) + assert self.world.create_item("Stardrop") in self.multiworld.itempool + assert any(self.world.create_item(item) in self.multiworld.itempool + for item in items_by_group[Group.MINES_FLOOR_110]) + assert self.world.create_item("Skull Key") in self.multiworld.itempool + + # This test as 1 over 90,000 changes to fail... Sorry in advance + def test_when_generate_world_then_rewards_are_not_all_vanilla(self): + assert not all(self.world.create_item(item) in self.multiworld.itempool + for item in + ["Leather Boots", "Steel Smallsword", "Tundra Boots", "Crystal Dagger", "Firewalker Boots", + "Obsidian Edge", "Space Boots"]) + + +class TestProgressiveElevator(SVTestBase): + options = { + options.TheMinesElevatorsProgression.internal_name: options.TheMinesElevatorsProgression.option_progressive, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive, + } + + def test_given_access_to_floor_115_when_find_another_elevator_then_has_access_to_floor_120(self): + self.collect([self.get_item_by_name("Progressive Pickaxe")] * 2) + self.collect([self.get_item_by_name("Progressive Mine Elevator")] * 22) + self.collect(self.multiworld.create_item("Bone Sword", self.player)) + self.collect([self.get_item_by_name("Combat Level")] * 4) + self.collect(self.get_item_by_name("Adventurer's Guild")) + + assert not self.multiworld.get_region("The Mines - Floor 120", self.player).can_reach(self.multiworld.state) + + self.collect(self.get_item_by_name("Progressive Mine Elevator")) + + assert self.multiworld.get_region("The Mines - Floor 120", self.player).can_reach(self.multiworld.state) + + def test_given_access_to_floor_115_when_find_another_pickaxe_and_sword_then_has_access_to_floor_120(self): + self.collect([self.get_item_by_name("Progressive Pickaxe")] * 2) + self.collect([self.get_item_by_name("Progressive Mine Elevator")] * 22) + self.collect(self.multiworld.create_item("Bone Sword", self.player)) + self.collect([self.get_item_by_name("Combat Level")] * 4) + self.collect(self.get_item_by_name("Adventurer's Guild")) + + assert not self.multiworld.get_region("The Mines - Floor 120", self.player).can_reach(self.multiworld.state) + + self.collect(self.get_item_by_name("Progressive Pickaxe")) + self.collect(self.multiworld.create_item("Steel Falchion", self.player)) + self.collect(self.get_item_by_name("Combat Level")) + self.collect(self.get_item_by_name("Combat Level")) + + assert self.multiworld.get_region("The Mines - Floor 120", self.player).can_reach(self.multiworld.state) + + +class TestLocationGeneration(SVTestBase): + + def test_all_location_created_are_in_location_table(self): + for location in self.multiworld.get_locations(self.player): + if not location.event: + assert location.name in location_table + + +class TestLocationAndItemCount(SVTestBase): + options = { + options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, + options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, + options.TheMinesElevatorsProgression.internal_name: options.TheMinesElevatorsProgression.option_vanilla, + options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, + options.HelpWantedLocations.internal_name: 0, + options.NumberOfPlayerBuffs.internal_name: 12, + } + + def test_minimal_location_maximal_items_still_valid(self): + assert len(self.multiworld.get_locations()) >= len(self.multiworld.get_items()) diff --git a/worlds/stardew_valley/test/TestItems.py b/worlds/stardew_valley/test/TestItems.py new file mode 100644 index 0000000000..98d251eb58 --- /dev/null +++ b/worlds/stardew_valley/test/TestItems.py @@ -0,0 +1,26 @@ +import unittest + +from BaseClasses import MultiWorld +from .. import StardewValleyWorld +from ..items import item_table + + +class TestItems(unittest.TestCase): + def test_can_create_item_of_resource_pack(self): + item_name = "Resource Pack: 500 Money" + + multi_world = MultiWorld(1) + multi_world.game[1] = "Stardew Valley" + multi_world.player_name = {1: "Tester"} + world = StardewValleyWorld(multi_world, 1) + item = world.create_item(item_name) + + assert item.name == item_name + + def test_items_table_footprint_is_between_717000_and_727000(self): + item_with_lowest_id = min((item for item in item_table.values() if item.code is not None), key=lambda x: x.code) + item_with_highest_id = max((item for item in item_table.values() if item.code is not None), + key=lambda x: x.code) + + assert item_with_lowest_id.code >= 717000 + assert item_with_highest_id.code < 727000 diff --git a/worlds/stardew_valley/test/TestLogic.py b/worlds/stardew_valley/test/TestLogic.py new file mode 100644 index 0000000000..83129a56b5 --- /dev/null +++ b/worlds/stardew_valley/test/TestLogic.py @@ -0,0 +1,293 @@ +from . import SVTestBase +from .. import options + + +class TestProgressiveToolsLogic(SVTestBase): + options = { + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + } + + def test_sturgeon(self): + assert not self.world.logic.has("Sturgeon")(self.multiworld.state) + + summer = self.get_item_by_name("Summer") + self.multiworld.state.collect(summer, event=True) + assert not self.world.logic.has("Sturgeon")(self.multiworld.state) + + fishing_rod = self.get_item_by_name("Progressive Fishing Rod") + self.multiworld.state.collect(fishing_rod, event=True) + self.multiworld.state.collect(fishing_rod, event=True) + assert not self.world.logic.has("Sturgeon")(self.multiworld.state) + + fishing_level = self.get_item_by_name("Fishing Level") + self.multiworld.state.collect(fishing_level, event=True) + assert not self.world.logic.has("Sturgeon")(self.multiworld.state) + + self.multiworld.state.collect(fishing_level, event=True) + self.multiworld.state.collect(fishing_level, event=True) + self.multiworld.state.collect(fishing_level, event=True) + self.multiworld.state.collect(fishing_level, event=True) + self.multiworld.state.collect(fishing_level, event=True) + assert self.world.logic.has("Sturgeon")(self.multiworld.state) + + self.remove(summer) + assert not self.world.logic.has("Sturgeon")(self.multiworld.state) + + winter = self.get_item_by_name("Winter") + self.multiworld.state.collect(winter, event=True) + assert self.world.logic.has("Sturgeon")(self.multiworld.state) + + self.remove(fishing_rod) + assert not self.world.logic.has("Sturgeon")(self.multiworld.state) + + def test_old_master_cannoli(self): + self.multiworld.state.collect(self.get_item_by_name("Progressive Axe"), event=True) + self.multiworld.state.collect(self.get_item_by_name("Progressive Axe"), event=True) + + assert not self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + + fall = self.get_item_by_name("Fall") + self.multiworld.state.collect(fall, event=True) + assert not self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + + tuesday = self.get_item_by_name("Traveling Merchant: Tuesday") + self.multiworld.state.collect(tuesday, event=True) + assert self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + + self.remove(fall) + assert not self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + self.remove(tuesday) + + green_house = self.get_item_by_name("Greenhouse") + self.multiworld.state.collect(green_house, event=True) + assert not self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + + friday = self.get_item_by_name("Traveling Merchant: Friday") + self.multiworld.state.collect(friday, event=True) + assert self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + + self.remove(green_house) + assert not self.world.logic.can_reach_location("Old Master Cannoli")(self.multiworld.state) + self.remove(friday) + + +class TestBundlesLogic(SVTestBase): + options = { + } + + def test_vault_2500g_bundle(self): + assert not self.world.logic.can_reach_location("2,500g Bundle")(self.multiworld.state) + + summer = self.get_item_by_name("Summer") + self.multiworld.state.collect(summer, event=True) + assert self.world.logic.can_reach_location("2,500g Bundle")(self.multiworld.state) + + +class TestBuildingLogic(SVTestBase): + options = { + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive_early_shipping_bin + } + + def test_coop_blueprint(self): + assert not self.world.logic.can_reach_location("Coop Blueprint")(self.multiworld.state) + + summer = self.get_item_by_name("Summer") + self.multiworld.state.collect(summer, event=True) + assert self.world.logic.can_reach_location("Coop Blueprint")(self.multiworld.state) + + def test_big_coop_blueprint(self): + assert not self.world.logic.can_reach_location("Big Coop Blueprint")(self.multiworld.state), \ + f"Rule is {repr(self.multiworld.get_location('Big Coop Blueprint', self.player).access_rule)}" + + self.multiworld.state.collect(self.get_item_by_name("Fall"), event=True) + assert not self.world.logic.can_reach_location("Big Coop Blueprint")(self.multiworld.state), \ + f"Rule is {repr(self.multiworld.get_location('Big Coop Blueprint', self.player).access_rule)}" + + self.multiworld.state.collect(self.get_item_by_name("Progressive Coop"), event=True) + assert self.world.logic.can_reach_location("Big Coop Blueprint")(self.multiworld.state), \ + f"Rule is {repr(self.multiworld.get_location('Big Coop Blueprint', self.player).access_rule)}" + + def test_deluxe_big_coop_blueprint(self): + assert not self.world.logic.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state) + + self.multiworld.state.collect(self.get_item_by_name("Year Two"), event=True) + assert not self.world.logic.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state) + + self.multiworld.state.collect(self.get_item_by_name("Progressive Coop"), event=True) + assert not self.world.logic.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state) + + self.multiworld.state.collect(self.get_item_by_name("Progressive Coop"), event=True) + assert self.world.logic.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state) + + def test_big_shed_blueprint(self): + assert not self.world.logic.can_reach_location("Big Shed Blueprint")(self.multiworld.state), \ + f"Rule is {repr(self.multiworld.get_location('Big Shed Blueprint', self.player).access_rule)}" + + self.multiworld.state.collect(self.get_item_by_name("Year Two"), event=True) + assert not self.world.logic.can_reach_location("Big Shed Blueprint")(self.multiworld.state), \ + f"Rule is {repr(self.multiworld.get_location('Big Shed Blueprint', self.player).access_rule)}" + + self.multiworld.state.collect(self.get_item_by_name("Progressive Shed"), event=True) + assert self.world.logic.can_reach_location("Big Shed Blueprint")(self.multiworld.state), \ + f"Rule is {repr(self.multiworld.get_location('Big Shed Blueprint', self.player).access_rule)}" + + +class TestArcadeMachinesLogic(SVTestBase): + options = { + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, + } + + def test_prairie_king(self): + assert not self.world.logic.can_reach_region("JotPK World 1")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 2")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 3")(self.multiworld.state) + assert not self.world.logic.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state) + + boots = self.get_item_by_name("JotPK: Progressive Boots") + gun = self.get_item_by_name("JotPK: Progressive Gun") + ammo = self.get_item_by_name("JotPK: Progressive Ammo") + life = self.get_item_by_name("JotPK: Extra Life") + drop = self.get_item_by_name("JotPK: Increased Drop Rate") + + self.multiworld.state.collect(boots, event=True) + self.multiworld.state.collect(gun, event=True) + assert self.world.logic.can_reach_region("JotPK World 1")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 2")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 3")(self.multiworld.state) + assert not self.world.logic.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state) + self.remove(boots) + self.remove(gun) + + self.multiworld.state.collect(boots, event=True) + self.multiworld.state.collect(boots, event=True) + assert self.world.logic.can_reach_region("JotPK World 1")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 2")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 3")(self.multiworld.state) + assert not self.world.logic.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state) + self.remove(boots) + self.remove(boots) + + self.multiworld.state.collect(boots, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(ammo, event=True) + self.multiworld.state.collect(life, event=True) + assert self.world.logic.can_reach_region("JotPK World 1")(self.multiworld.state) + assert self.world.logic.can_reach_region("JotPK World 2")(self.multiworld.state) + assert not self.world.logic.can_reach_region("JotPK World 3")(self.multiworld.state) + assert not self.world.logic.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state) + self.remove(boots) + self.remove(gun) + self.remove(ammo) + self.remove(life) + + self.multiworld.state.collect(boots, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(ammo, event=True) + self.multiworld.state.collect(ammo, event=True) + self.multiworld.state.collect(life, event=True) + self.multiworld.state.collect(drop, event=True) + assert self.world.logic.can_reach_region("JotPK World 1")(self.multiworld.state) + assert self.world.logic.can_reach_region("JotPK World 2")(self.multiworld.state) + assert self.world.logic.can_reach_region("JotPK World 3")(self.multiworld.state) + assert not self.world.logic.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state) + self.remove(boots) + self.remove(gun) + self.remove(gun) + self.remove(ammo) + self.remove(ammo) + self.remove(life) + self.remove(drop) + + self.multiworld.state.collect(boots, event=True) + self.multiworld.state.collect(boots, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(gun, event=True) + self.multiworld.state.collect(ammo, event=True) + self.multiworld.state.collect(ammo, event=True) + self.multiworld.state.collect(ammo, event=True) + self.multiworld.state.collect(life, event=True) + self.multiworld.state.collect(drop, event=True) + assert self.world.logic.can_reach_region("JotPK World 1")(self.multiworld.state) + assert self.world.logic.can_reach_region("JotPK World 2")(self.multiworld.state) + assert self.world.logic.can_reach_region("JotPK World 3")(self.multiworld.state) + assert self.world.logic.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state) + self.remove(boots) + self.remove(boots) + self.remove(gun) + self.remove(gun) + self.remove(gun) + self.remove(gun) + self.remove(ammo) + self.remove(ammo) + self.remove(ammo) + self.remove(life) + self.remove(drop) + + +class TestWeaponsLogic(SVTestBase): + options = { + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive, + } + + def test_mine(self): + self.collect(self.get_item_by_name("Adventurer's Guild")) + self.multiworld.state.collect(self.get_item_by_name("Progressive Pickaxe"), event=True) + self.multiworld.state.collect(self.get_item_by_name("Progressive Pickaxe"), event=True) + self.multiworld.state.collect(self.get_item_by_name("Progressive Pickaxe"), event=True) + self.multiworld.state.collect(self.get_item_by_name("Progressive Pickaxe"), event=True) + self.collect([self.get_item_by_name("Combat Level")] * 10) + self.collect([self.get_item_by_name("Progressive Mine Elevator")] * 24) + self.multiworld.state.collect(self.get_item_by_name("Bus Repair"), event=True) + self.multiworld.state.collect(self.get_item_by_name("Skull Key"), event=True) + + self.GiveItemAndCheckReachableMine("Rusty Sword", 1) + self.GiveItemAndCheckReachableMine("Wooden Blade", 1) + self.GiveItemAndCheckReachableMine("Elf Blade", 1) + + self.GiveItemAndCheckReachableMine("Silver Saber", 2) + self.GiveItemAndCheckReachableMine("Crystal Dagger", 2) + + self.GiveItemAndCheckReachableMine("Claymore", 3) + self.GiveItemAndCheckReachableMine("Obsidian Edge", 3) + self.GiveItemAndCheckReachableMine("Bone Sword", 3) + + self.GiveItemAndCheckReachableMine("The Slammer", 4) + self.GiveItemAndCheckReachableMine("Lava Katana", 4) + + self.GiveItemAndCheckReachableMine("Galaxy Sword", 5) + self.GiveItemAndCheckReachableMine("Galaxy Hammer", 5) + self.GiveItemAndCheckReachableMine("Galaxy Dagger", 5) + + def GiveItemAndCheckReachableMine(self, item_name: str, reachable_level: int): + item = self.multiworld.create_item(item_name, self.player) + self.multiworld.state.collect(item, event=True) + if reachable_level > 0: + assert self.world.logic.can_mine_in_the_mines_floor_1_40()(self.multiworld.state) + else: + assert not self.world.logic.can_mine_in_the_mines_floor_1_40()(self.multiworld.state) + + if reachable_level > 1: + assert self.world.logic.can_mine_in_the_mines_floor_41_80()(self.multiworld.state) + else: + assert not self.world.logic.can_mine_in_the_mines_floor_41_80()(self.multiworld.state) + + if reachable_level > 2: + assert self.world.logic.can_mine_in_the_mines_floor_81_120()(self.multiworld.state) + else: + assert not self.world.logic.can_mine_in_the_mines_floor_81_120()(self.multiworld.state) + + if reachable_level > 3: + assert self.world.logic.can_mine_in_the_skull_cavern()(self.multiworld.state) + else: + assert not self.world.logic.can_mine_in_the_skull_cavern()(self.multiworld.state) + + if reachable_level > 4: + assert self.world.logic.can_mine_perfectly_in_the_skull_cavern()(self.multiworld.state) + else: + assert not self.world.logic.can_mine_perfectly_in_the_skull_cavern()(self.multiworld.state) + + self.remove(item) diff --git a/worlds/stardew_valley/test/TestLogicSimplification.py b/worlds/stardew_valley/test/TestLogicSimplification.py new file mode 100644 index 0000000000..1a3d5a1dca --- /dev/null +++ b/worlds/stardew_valley/test/TestLogicSimplification.py @@ -0,0 +1,52 @@ +import unittest + +from .. import _True +from ..logic import _Received, _Has, _False, _And, _Or + + +class TestLogicSimplification(unittest.TestCase): + def test_simplify_true_in_and(self): + rules = { + "Wood": _True(), + "Rock": _True(), + } + summer = _Received("Summer", 0, 1) + assert (_Has("Wood", rules) & summer & _Has("Rock", rules)).simplify() == summer + + def test_simplify_false_in_or(self): + rules = { + "Wood": _False(), + "Rock": _False(), + } + summer = _Received("Summer", 0, 1) + assert (_Has("Wood", rules) | summer | _Has("Rock", rules)).simplify() == summer + + def test_simplify_and_in_and(self): + rule = _And(_And(_Received("Summer", 0, 1), _Received("Fall", 0, 1)), + _And(_Received("Winter", 0, 1), _Received("Spring", 0, 1))) + assert rule.simplify() == _And(_Received("Summer", 0, 1), _Received("Fall", 0, 1), _Received("Winter", 0, 1), + _Received("Spring", 0, 1)) + + def test_simplify_duplicated_and(self): + rule = _And(_And(_Received("Summer", 0, 1), _Received("Fall", 0, 1)), + _And(_Received("Summer", 0, 1), _Received("Fall", 0, 1))) + assert rule.simplify() == _And(_Received("Summer", 0, 1), _Received("Fall", 0, 1)) + + def test_simplify_or_in_or(self): + rule = _Or(_Or(_Received("Summer", 0, 1), _Received("Fall", 0, 1)), + _Or(_Received("Winter", 0, 1), _Received("Spring", 0, 1))) + assert rule.simplify() == _Or(_Received("Summer", 0, 1), _Received("Fall", 0, 1), _Received("Winter", 0, 1), + _Received("Spring", 0, 1)) + + def test_simplify_duplicated_or(self): + rule = _And(_Or(_Received("Summer", 0, 1), _Received("Fall", 0, 1)), + _Or(_Received("Summer", 0, 1), _Received("Fall", 0, 1))) + assert rule.simplify() == _Or(_Received("Summer", 0, 1), _Received("Fall", 0, 1)) + + def test_simplify_true_in_or(self): + rule = _Or(_True(), _Received("Summer", 0, 1)) + assert rule.simplify() == _True() + + def test_simplify_false_in_and(self): + rule = _And(_False(), _Received("Summer", 0, 1)) + assert rule.simplify() == _False() diff --git a/worlds/stardew_valley/test/TestOptions.py b/worlds/stardew_valley/test/TestOptions.py new file mode 100644 index 0000000000..063d9c2be9 --- /dev/null +++ b/worlds/stardew_valley/test/TestOptions.py @@ -0,0 +1,8 @@ +from worlds.stardew_valley.test import SVTestBase + + +class TestMasterAnglerVanillaTools(SVTestBase): + options = { + "goal": "master_angler", + "tool_progression": "vanilla", + } diff --git a/worlds/stardew_valley/test/TestRegions.py b/worlds/stardew_valley/test/TestRegions.py new file mode 100644 index 0000000000..3aadc7e49b --- /dev/null +++ b/worlds/stardew_valley/test/TestRegions.py @@ -0,0 +1,46 @@ +import random +import sys +import unittest + +from .. import StardewOptions, options +from ..regions import stardew_valley_regions, mandatory_connections, randomize_connections, RandomizationFlag + +connections_by_name = {connection.name for connection in mandatory_connections} +regions_by_name = {region.name for region in stardew_valley_regions} + + +class TestRegions(unittest.TestCase): + def test_region_exits_lead_somewhere(self): + for region in stardew_valley_regions: + with self.subTest(region=region): + for exit in region.exits: + assert exit in connections_by_name, f"{region.name} is leading to {exit} but it does not exist." + + def test_connection_lead_somewhere(self): + for connection in mandatory_connections: + with self.subTest(connection=connection): + assert connection.destination in regions_by_name, \ + f"{connection.name} is leading to {connection.destination} but it does not exist." + + +class TestEntranceRando(unittest.TestCase): + + def test_pelican_town_entrance_randomization(self): + for option, flag in [(options.EntranceRandomization.option_pelican_town, RandomizationFlag.PELICAN_TOWN), + (options.EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION)]: + with self.subTest(option=option, flag=flag): + seed = random.randrange(sys.maxsize) + rand = random.Random(seed) + world_options = StardewOptions({options.EntranceRandomization.internal_name: option}) + + _, randomized_connections = randomize_connections(rand, world_options) + + for connection in mandatory_connections: + if flag in connection.flag: + assert connection.name in randomized_connections, \ + f"Connection {connection.name} should be randomized but it is not in the output. Seed = {seed}" + assert connection.reverse in randomized_connections, \ + f"Connection {connection.reverse} should be randomized but it is not in the output. Seed = {seed}" + + assert len(set(randomized_connections.values())) == len( + randomized_connections.values()), f"Connections are duplicated in randomization. Seed = {seed}" diff --git a/worlds/stardew_valley/test/TestResourcePack.py b/worlds/stardew_valley/test/TestResourcePack.py new file mode 100644 index 0000000000..d25505bbdc --- /dev/null +++ b/worlds/stardew_valley/test/TestResourcePack.py @@ -0,0 +1,76 @@ +import itertools +import math +import unittest + +from BaseClasses import ItemClassification +from .. import ItemData +from ..items import Group, ResourcePackData + + +class TestResourcePack(unittest.TestCase): + + def test_can_transform_resource_pack_data_into_idem_data(self): + resource_pack = ResourcePackData("item name", 1, 1, ItemClassification.filler, frozenset()) + + items = resource_pack.as_item_data(itertools.count()) + + assert ItemData(0, "Resource Pack: 1 item name", ItemClassification.filler, {Group.RESOURCE_PACK}) in items + assert ItemData(1, "Resource Pack: 2 item name", ItemClassification.filler, {Group.RESOURCE_PACK}) in items + assert len(items) == 2 + + def test_when_scale_quantity_then_generate_a_possible_quantity_from_minimal_scaling_to_double(self): + resource_pack = ResourcePackData("item name", default_amount=4, scaling_factor=2) + + quantities = resource_pack.scale_quantity.items() + + assert (50, 2) in quantities + assert (100, 4) in quantities + assert (150, 6) in quantities + assert (200, 8) in quantities + assert len(quantities) == (4 / 2) * 2 + + def test_given_scaling_not_multiple_of_default_amount_when_scale_quantity_then_double_is_added_at_200_scaling(self): + resource_pack = ResourcePackData("item name", default_amount=5, scaling_factor=3) + + quantities = resource_pack.scale_quantity.items() + + assert (40, 2) in quantities + assert (100, 5) in quantities + assert (160, 8) in quantities + assert (200, 10) in quantities + assert len(quantities) == math.ceil(5 / 3) * 2 + + def test_given_large_default_amount_multiple_of_scaling_factor_when_scale_quantity_then_scaled_amount_multiple( + self): + resource_pack = ResourcePackData("item name", default_amount=500, scaling_factor=50) + + quantities = resource_pack.scale_quantity.items() + + assert (10, 50) in quantities + assert (20, 100) in quantities + assert (30, 150) in quantities + assert (40, 200) in quantities + assert (50, 250) in quantities + assert (60, 300) in quantities + assert (70, 350) in quantities + assert (80, 400) in quantities + assert (90, 450) in quantities + assert (100, 500) in quantities + assert (110, 550) in quantities + assert (120, 600) in quantities + assert (130, 650) in quantities + assert (140, 700) in quantities + assert (150, 750) in quantities + assert (160, 800) in quantities + assert (170, 850) in quantities + assert (180, 900) in quantities + assert (190, 950) in quantities + assert (200, 1000) in quantities + assert len(quantities) == math.ceil(500 / 50) * 2 + + def test_given_smallest_multiplier_possible_when_generate_resource_pack_name_then_quantity_is_not_0(self): + resource_pack = ResourcePackData("item name", default_amount=10, scaling_factor=5) + + name = resource_pack.create_name_from_multiplier(1) + + assert name == "Resource Pack: 5 item name" diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py new file mode 100644 index 0000000000..c9a8c74667 --- /dev/null +++ b/worlds/stardew_valley/test/__init__.py @@ -0,0 +1,20 @@ +from typing import ClassVar + +from test.TestBase import WorldTestBase +from .. import StardewValleyWorld + + +class SVTestBase(WorldTestBase): + game = "Stardew Valley" + world: StardewValleyWorld + player: ClassVar[int] = 1 + + def world_setup(self, *args, **kwargs): + super().world_setup(*args, **kwargs) + if self.constructed: + self.world = self.multiworld.worlds[self.player] + + @property + def run_default_tests(self) -> bool: + # world_setup is overridden, so it'd always run default tests when importing SVTestBase + return type(self) is not SVTestBase and super().run_default_tests diff --git a/worlds/subnautica/Items.py b/worlds/subnautica/Items.py index a5ccc1fb59..cf1f8ed24a 100644 --- a/worlds/subnautica/Items.py +++ b/worlds/subnautica/Items.py @@ -223,7 +223,7 @@ item_table: Dict[int, ItemDict] = { 'name': 'Observatory', 'tech_type': 'BaseObservatory'}, 35053: {'classification': ItemClassification.progression, - 'count': 2, + 'count': 1, 'name': 'Multipurpose Room', 'tech_type': 'BaseRoom'}, 35054: {'classification': ItemClassification.useful, @@ -338,12 +338,11 @@ item_table: Dict[int, ItemDict] = { 'count': 1, 'name': 'Ultra High Capacity Tank', 'tech_type': 'HighCapacityTank'}, - # these currently unlock through some special sauce in Subnautica, unlike any established other - # keeping here for later 35082: {'classification': ItemClassification.progression, - 'count': 0, + 'count': 1, 'name': 'Large Room', 'tech_type': 'BaseLargeRoom'}, + # awarded with their rooms, keeping that as-is as they're cosmetic 35083: {'classification': ItemClassification.filler, 'count': 0, 'name': 'Large Room Glass Dome', diff --git a/worlds/subnautica/Locations.py b/worlds/subnautica/Locations.py index 9408094d2b..e0a33966f0 100644 --- a/worlds/subnautica/Locations.py +++ b/worlds/subnautica/Locations.py @@ -140,7 +140,7 @@ location_table: Dict[int, LocationDict] = { 'need_laser_cutter': False, 'position': {'x': -664.4, 'y': -97.8, 'z': -8.0}}, 33029: {'can_slip_through': False, - 'name': 'Grassy Plateaus West Wreck - Databox', + 'name': 'Grassy Plateaus Southwest Wreck - Databox', 'need_laser_cutter': True, 'position': {'x': -421.4, 'y': -107.8, 'z': -266.5}}, 33030: {'can_slip_through': False, @@ -580,9 +580,14 @@ if False: # turn to True to export for Subnautica mod with open("locations.json", "w") as f: json.dump(payload, f) - def radiated(pos: Vector): - aurora_dist = math.sqrt((pos["x"] - 1038.0) ** 2 + (pos["y"] - -3.4) ** 2 + (pos["y"] - -163.1) ** 2) + # copy-paste from Rules + def is_radiated(x: float, y: float, z: float) -> bool: + aurora_dist = math.sqrt((x - 1038.0) ** 2 + y ** 2 + (z - -163.1) ** 2) return aurora_dist < 950 + # end of copy-paste + + def radiated(pos: Vector): + return is_radiated(pos["x"], pos["y"], pos["z"]) def far_away(pos: Vector): return (pos["x"] ** 2 + pos["z"] ** 2) > (800 ** 2) diff --git a/worlds/subnautica/Rules.py b/worlds/subnautica/Rules.py index 48db25a815..793c85be41 100644 --- a/worlds/subnautica/Rules.py +++ b/worlds/subnautica/Rules.py @@ -221,6 +221,11 @@ def get_max_depth(state: "CollectionState", player: int): ) +def is_radiated(x: float, y: float, z: float) -> bool: + aurora_dist = math.sqrt((x - 1038.0) ** 2 + y ** 2 + (z - -163.1) ** 2) + return aurora_dist < 950 + + def can_access_location(state: "CollectionState", player: int, loc: LocationDict) -> bool: need_laser_cutter = loc.get("need_laser_cutter", False) if need_laser_cutter and not has_laser_cutter(state, player): @@ -235,8 +240,7 @@ def can_access_location(state: "CollectionState", player: int, loc: LocationDict pos_y = pos["y"] pos_z = pos["z"] - aurora_dist = math.sqrt((pos_x - 1038.0) ** 2 + (pos_y - -3.4) ** 2 + (pos_z - -163.1) ** 2) - need_radiation_suit = aurora_dist < 950 + need_radiation_suit = is_radiated(pos_x, pos_y, pos_z) if need_radiation_suit and not state.has("Radiation Suit", player): return False diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 341af5a543..b786bcc474 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -41,8 +41,8 @@ class SubnauticaWorld(World): location_name_to_id = all_locations option_definitions = Options.options - data_version = 8 - required_client_version = (0, 3, 8) + data_version = 9 + required_client_version = (0, 3, 9) creatures_to_scan: List[str] diff --git a/worlds/subnautica/test/__init__.py b/worlds/subnautica/test/__init__.py new file mode 100644 index 0000000000..b10ca2c7c5 --- /dev/null +++ b/worlds/subnautica/test/__init__.py @@ -0,0 +1,15 @@ +import unittest +from worlds import subnautica + + +class SubnauticaTest(unittest.TestCase): + # This is an assumption in the mod side + scancutoff: int = 33999 + + def testIDRange(self): + for name, id in subnautica.SubnauticaWorld.location_name_to_id.items(): + with self.subTest(item=name): + if "Scan" in name: + self.assertLess(self.scancutoff, id) + else: + self.assertGreater(self.scancutoff, id) diff --git a/worlds/timespinner/Items.py b/worlds/timespinner/Items.py index add8beabf5..45c67c2547 100644 --- a/worlds/timespinner/Items.py +++ b/worlds/timespinner/Items.py @@ -239,6 +239,22 @@ starter_spells: Tuple[str, ...] = ( 'Corruption' ) +# weighted +starter_progression_items: Tuple[str, ...] = ( + 'Talaria Attachment', + 'Talaria Attachment', + 'Succubus Hairpin', + 'Succubus Hairpin', + 'Timespinner Wheel', + 'Timespinner Wheel', + 'Twin Pyramid Key', + 'Celestial Sash', + 'Lightwall', + 'Modern Warp Beacon', + 'Timeworn Warp Beacon', + 'Mysterious Warp Beacon' +) + filler_items: Tuple[str, ...] = ( 'Potion', 'Ether', diff --git a/worlds/timespinner/Locations.py b/worlds/timespinner/Locations.py index 960444acb8..03f1c025dc 100644 --- a/worlds/timespinner/Locations.py +++ b/worlds/timespinner/Locations.py @@ -14,7 +14,7 @@ class LocationData(NamedTuple): rule: Callable[[CollectionState], bool] = lambda state: True -def get_locations(world: Optional[MultiWorld], player: Optional[int], +def get_location_datas(world: Optional[MultiWorld], player: Optional[int], precalculated_weights: PreCalculatedWeights) -> Tuple[LocationData, ...]: flooded: PreCalculatedWeights = precalculated_weights diff --git a/worlds/timespinner/Options.py b/worlds/timespinner/Options.py index 6f4b7ea876..5f4d230688 100644 --- a/worlds/timespinner/Options.py +++ b/worlds/timespinner/Options.py @@ -1,7 +1,7 @@ from typing import Dict, Union, List from BaseClasses import MultiWorld from Options import Toggle, DefaultOnToggle, DeathLink, Choice, Range, Option, OptionDict, OptionList -from schema import Schema, And, Optional +from schema import Schema, And, Optional, Or class StartWithJewelryBox(Toggle): @@ -308,47 +308,44 @@ class RisingTides(Toggle): display_name = "Rising Tides" +def rising_tide_option(location: str, with_save_point_option: bool = False) -> Dict[Optional, Or]: + if with_save_point_option: + return { + Optional(location): Or( + And({ + Optional("Dry"): And(int, lambda n: n >= 0), + Optional("Flooded"): And(int, lambda n: n >= 0), + Optional("FloodedWithSavePointAvailable"): And(int, lambda n: n >= 0) + }, lambda d: any(v > 0 for v in d.values())), + "Dry", + "Flooded", + "FloodedWithSavePointAvailable") + } + else: + return { + Optional(location): Or( + And({ + Optional("Dry"): And(int, lambda n: n >= 0), + Optional("Flooded"): And(int, lambda n: n >= 0) + }, lambda d: any(v > 0 for v in d.values())), + "Dry", + "Flooded") + } + + class RisingTidesOverrides(OptionDict): """Odds for specific areas to be flooded or drained, only has effect when RisingTides is on. Areas that are not specified will roll with the default 33% chance of getting flooded or drained""" schema = Schema({ - Optional("Xarion"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("Maw"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("AncientPyramidShaft"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("Sandman"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("CastleMoat"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("CastleBasement"): { - "Dry": And(int, lambda n: n >= 0), - "FloodedWithSavePointAvailable": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("CastleCourtyard"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("LakeDesolation"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - }, - Optional("LakeSerene"): { - "Dry": And(int, lambda n: n >= 0), - "Flooded": And(int, lambda n: n >= 0) - } + **rising_tide_option("Xarion"), + **rising_tide_option("Maw"), + **rising_tide_option("AncientPyramidShaft"), + **rising_tide_option("Sandman"), + **rising_tide_option("CastleMoat"), + **rising_tide_option("CastleBasement", with_save_point_option=True), + **rising_tide_option("CastleCourtyard"), + **rising_tide_option("LakeDesolation"), + **rising_tide_option("LakeSerene") }) display_name = "Rising Tides Overrides" default = { @@ -360,7 +357,7 @@ class RisingTidesOverrides(OptionDict): "CastleBasement": { "Dry": 66, "Flooded": 17, "FloodedWithSavePointAvailable": 17 }, "CastleCourtyard": { "Dry": 67, "Flooded": 33 }, "LakeDesolation": { "Dry": 67, "Flooded": 33 }, - "LakeSerene": { "Dry": 67, "Flooded": 33 }, + "LakeSerene": { "Dry": 33, "Flooded": 67 }, } diff --git a/worlds/timespinner/PreCalculatedWeights.py b/worlds/timespinner/PreCalculatedWeights.py index 193bf84dd6..514a17f8ac 100644 --- a/worlds/timespinner/PreCalculatedWeights.py +++ b/worlds/timespinner/PreCalculatedWeights.py @@ -1,7 +1,6 @@ from typing import Tuple, Dict, Union from BaseClasses import MultiWorld -from .Options import is_option_enabled, get_option_value - +from .Options import timespinner_options, is_option_enabled, get_option_value class PreCalculatedWeights: pyramid_keys_unlock: str @@ -21,24 +20,25 @@ class PreCalculatedWeights: dry_lake_serene: bool def __init__(self, world: MultiWorld, player: int): - weights_overrrides: Dict[str, Dict[str, int]] = self.get_flood_weights_overrides(world, player) + weights_overrrides: Dict[str, Union[str, Dict[str, int]]] = self.get_flood_weights_overrides(world, player) self.flood_basement, self.flood_basement_high = \ - self.roll_flood_setting_with_available_save(world, player, weights_overrrides, "CastleBasement") - self.flood_xarion = self.roll_flood_setting(world, player, weights_overrrides, "Xarion") - self.flood_maw = self.roll_flood_setting(world, player, weights_overrrides, "Maw") - self.flood_pyramid_shaft = self.roll_flood_setting(world, player, weights_overrrides, "AncientPyramidShaft") - self.flood_pyramid_back = self.roll_flood_setting(world, player, weights_overrrides, "Sandman") - self.flood_moat = self.roll_flood_setting(world, player, weights_overrrides, "CastleMoat") - self.flood_courtyard = self.roll_flood_setting(world, player, weights_overrrides, "CastleCourtyard") - self.flood_lake_desolation = self.roll_flood_setting(world, player, weights_overrrides, "LakeDesolation") - self.dry_lake_serene = self.roll_flood_setting(world, player, weights_overrrides, "LakeSerene") + self.roll_flood_setting(world, player, weights_overrrides, "CastleBasement") + self.flood_xarion, _ = self.roll_flood_setting(world, player, weights_overrrides, "Xarion") + self.flood_maw, _ = self.roll_flood_setting(world, player, weights_overrrides, "Maw") + self.flood_pyramid_shaft, _ = self.roll_flood_setting(world, player, weights_overrrides, "AncientPyramidShaft") + self.flood_pyramid_back, _ = self.roll_flood_setting(world, player, weights_overrrides, "Sandman") + self.flood_moat, _ = self.roll_flood_setting(world, player, weights_overrrides, "CastleMoat") + self.flood_courtyard, _ = self.roll_flood_setting(world, player, weights_overrrides, "CastleCourtyard") + self.flood_lake_desolation, _ = self.roll_flood_setting(world, player, weights_overrrides, "LakeDesolation") + flood_lake_serene, _ = self.roll_flood_setting(world, player, weights_overrrides, "LakeSerene") + self.dry_lake_serene = not flood_lake_serene self.pyramid_keys_unlock, self.present_key_unlock, self.past_key_unlock, self.time_key_unlock = \ - self.get_pyramid_keys_unlock(world, player, self.flood_maw) + self.get_pyramid_keys_unlocks(world, player, self.flood_maw) - - def get_pyramid_keys_unlock(self, world: MultiWorld, player: int, is_maw_flooded: bool) -> Tuple[str, str, str, str]: + @staticmethod + def get_pyramid_keys_unlocks(world: MultiWorld, player: int, is_maw_flooded: bool) -> Tuple[str, str, str, str]: present_teleportation_gates: Tuple[str, ...] = ( "GateKittyBoss", "GateLeftLibrary", @@ -87,40 +87,38 @@ class PreCalculatedWeights: ) @staticmethod - def get_flood_weights_overrides( world: MultiWorld, player: int) -> Dict[str, int]: - weights_overrides_option: Union[int, Dict[str, Dict[str, int]]] = \ + def get_flood_weights_overrides(world: MultiWorld, player: int) -> Dict[str, Union[str, Dict[str, int]]]: + weights_overrides_option: Union[int, Dict[str, Union[str, Dict[str, int]]]] = \ get_option_value(world, player, "RisingTidesOverrides") - if weights_overrides_option == 0: - return {} + default_weights: Dict[str, Dict[str, int]] = timespinner_options["RisingTidesOverrides"].default + + if not weights_overrides_option: + weights_overrides_option = default_weights else: - return weights_overrides_option + for key, weights in default_weights.items(): + if not key in weights_overrides_option: + weights_overrides_option[key] = weights + + return weights_overrides_option @staticmethod - def roll_flood_setting(world: MultiWorld, player: int, weights: Dict[str, Dict[str, int]], key: str) -> bool: - if not world or not is_option_enabled(world, player, "RisingTides"): - return False - - weights = weights[key] if key in weights else { "Dry": 67, "Flooded": 33 } - - result: str = world.random.choices(list(weights.keys()), weights=list(map(int, weights.values())))[0] - - return result == "Flooded" - - @staticmethod - def roll_flood_setting_with_available_save(world: MultiWorld, player: int, - weights: Dict[str, Dict[str, int]], key: str) -> Tuple[bool, bool]: + def roll_flood_setting(world: MultiWorld, player: int, + all_weights: Dict[str, Union[Dict[str, int], str]], key: str) -> Tuple[bool, bool]: if not world or not is_option_enabled(world, player, "RisingTides"): return False, False - weights = weights[key] if key in weights else {"Dry": 66, "Flooded": 17, "FloodedWithSavePointAvailable": 17} + weights: Union[Dict[str, int], str] = all_weights[key] - result: str = world.random.choices(list(weights.keys()), weights=list(map(int, weights.values())))[0] + if isinstance(weights, dict): + result: str = world.random.choices(list(weights.keys()), weights=list(map(int, weights.values())))[0] + else: + result: str = weights if result == "Dry": return False, False elif result == "Flooded": - return True, False - elif result == "FloodedWithSavePointAvailable": return True, True + elif result == "FloodedWithSavePointAvailable": + return True, False diff --git a/worlds/timespinner/Regions.py b/worlds/timespinner/Regions.py index ab8ee97ac6..002606245d 100644 --- a/worlds/timespinner/Regions.py +++ b/worlds/timespinner/Regions.py @@ -1,64 +1,64 @@ from typing import List, Set, Dict, Tuple, Optional, Callable from BaseClasses import CollectionState, MultiWorld, Region, Entrance, Location from .Options import is_option_enabled -from .Locations import LocationData +from .Locations import LocationData, get_location_datas from .PreCalculatedWeights import PreCalculatedWeights from .LogicExtensions import TimespinnerLogic -def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location], - precalculated_weights: PreCalculatedWeights): +def create_regions_and_locations(world: MultiWorld, player: int, precalculated_weights: PreCalculatedWeights): + locationn_datas: Tuple[LocationData] = get_location_datas(world, player, precalculated_weights) - locations_per_region = get_locations_per_region(locations) + locations_per_region: Dict[str, List[LocationData]] = split_location_datas_per_region(locationn_datas) regions = [ - create_region(world, player, locations_per_region, location_cache, 'Menu'), - create_region(world, player, locations_per_region, location_cache, 'Tutorial'), - create_region(world, player, locations_per_region, location_cache, 'Lake desolation'), - create_region(world, player, locations_per_region, location_cache, 'Upper lake desolation'), - create_region(world, player, locations_per_region, location_cache, 'Lower lake desolation'), - create_region(world, player, locations_per_region, location_cache, 'Eastern lake desolation'), - create_region(world, player, locations_per_region, location_cache, 'Library'), - create_region(world, player, locations_per_region, location_cache, 'Library top'), - create_region(world, player, locations_per_region, location_cache, 'Varndagroth tower left'), - create_region(world, player, locations_per_region, location_cache, 'Varndagroth tower right (upper)'), - create_region(world, player, locations_per_region, location_cache, 'Varndagroth tower right (lower)'), - create_region(world, player, locations_per_region, location_cache, 'Varndagroth tower right (elevator)'), - create_region(world, player, locations_per_region, location_cache, 'Sealed Caves (Sirens)'), - create_region(world, player, locations_per_region, location_cache, 'Military Fortress'), - create_region(world, player, locations_per_region, location_cache, 'Military Fortress (hangar)'), - create_region(world, player, locations_per_region, location_cache, 'The lab'), - create_region(world, player, locations_per_region, location_cache, 'The lab (power off)'), - create_region(world, player, locations_per_region, location_cache, 'The lab (upper)'), - create_region(world, player, locations_per_region, location_cache, 'Emperors tower'), - create_region(world, player, locations_per_region, location_cache, 'Skeleton Shaft'), - create_region(world, player, locations_per_region, location_cache, 'Sealed Caves (upper)'), - create_region(world, player, locations_per_region, location_cache, 'Sealed Caves (Xarion)'), - create_region(world, player, locations_per_region, location_cache, 'Refugee Camp'), - create_region(world, player, locations_per_region, location_cache, 'Forest'), - create_region(world, player, locations_per_region, location_cache, 'Left Side forest Caves'), - create_region(world, player, locations_per_region, location_cache, 'Upper Lake Serene'), - create_region(world, player, locations_per_region, location_cache, 'Lower Lake Serene'), - create_region(world, player, locations_per_region, location_cache, 'Caves of Banishment (upper)'), - create_region(world, player, locations_per_region, location_cache, 'Caves of Banishment (Maw)'), - create_region(world, player, locations_per_region, location_cache, 'Caves of Banishment (Sirens)'), - create_region(world, player, locations_per_region, location_cache, 'Castle Ramparts'), - create_region(world, player, locations_per_region, location_cache, 'Castle Keep'), - create_region(world, player, locations_per_region, location_cache, 'Castle Basement'), - create_region(world, player, locations_per_region, location_cache, 'Royal towers (lower)'), - create_region(world, player, locations_per_region, location_cache, 'Royal towers'), - create_region(world, player, locations_per_region, location_cache, 'Royal towers (upper)'), - create_region(world, player, locations_per_region, location_cache, 'Temporal Gyre'), - create_region(world, player, locations_per_region, location_cache, 'Ancient Pyramid (entrance)'), - create_region(world, player, locations_per_region, location_cache, 'Ancient Pyramid (left)'), - create_region(world, player, locations_per_region, location_cache, 'Ancient Pyramid (right)'), - create_region(world, player, locations_per_region, location_cache, 'Space time continuum') + create_region(world, player, locations_per_region, 'Menu'), + create_region(world, player, locations_per_region, 'Tutorial'), + create_region(world, player, locations_per_region, 'Lake desolation'), + create_region(world, player, locations_per_region, 'Upper lake desolation'), + create_region(world, player, locations_per_region, 'Lower lake desolation'), + create_region(world, player, locations_per_region, 'Eastern lake desolation'), + create_region(world, player, locations_per_region, 'Library'), + create_region(world, player, locations_per_region, 'Library top'), + create_region(world, player, locations_per_region, 'Varndagroth tower left'), + create_region(world, player, locations_per_region, 'Varndagroth tower right (upper)'), + create_region(world, player, locations_per_region, 'Varndagroth tower right (lower)'), + create_region(world, player, locations_per_region, 'Varndagroth tower right (elevator)'), + create_region(world, player, locations_per_region, 'Sealed Caves (Sirens)'), + create_region(world, player, locations_per_region, 'Military Fortress'), + create_region(world, player, locations_per_region, 'Military Fortress (hangar)'), + create_region(world, player, locations_per_region, 'The lab'), + create_region(world, player, locations_per_region, 'The lab (power off)'), + create_region(world, player, locations_per_region, 'The lab (upper)'), + create_region(world, player, locations_per_region, 'Emperors tower'), + create_region(world, player, locations_per_region, 'Skeleton Shaft'), + create_region(world, player, locations_per_region, 'Sealed Caves (upper)'), + create_region(world, player, locations_per_region, 'Sealed Caves (Xarion)'), + create_region(world, player, locations_per_region, 'Refugee Camp'), + create_region(world, player, locations_per_region, 'Forest'), + create_region(world, player, locations_per_region, 'Left Side forest Caves'), + create_region(world, player, locations_per_region, 'Upper Lake Serene'), + create_region(world, player, locations_per_region, 'Lower Lake Serene'), + create_region(world, player, locations_per_region, 'Caves of Banishment (upper)'), + create_region(world, player, locations_per_region, 'Caves of Banishment (Maw)'), + create_region(world, player, locations_per_region, 'Caves of Banishment (Sirens)'), + create_region(world, player, locations_per_region, 'Castle Ramparts'), + create_region(world, player, locations_per_region, 'Castle Keep'), + create_region(world, player, locations_per_region, 'Castle Basement'), + create_region(world, player, locations_per_region, 'Royal towers (lower)'), + create_region(world, player, locations_per_region, 'Royal towers'), + create_region(world, player, locations_per_region, 'Royal towers (upper)'), + create_region(world, player, locations_per_region, 'Temporal Gyre'), + create_region(world, player, locations_per_region, 'Ancient Pyramid (entrance)'), + create_region(world, player, locations_per_region, 'Ancient Pyramid (left)'), + create_region(world, player, locations_per_region, 'Ancient Pyramid (right)'), + create_region(world, player, locations_per_region, 'Space time continuum') ] if is_option_enabled(world, player, "GyreArchives"): regions.extend([ - create_region(world, player, locations_per_region, location_cache, 'Ravenlord\'s Lair'), - create_region(world, player, locations_per_region, location_cache, 'Ifrit\'s Lair'), + create_region(world, player, locations_per_region, 'Ravenlord\'s Lair'), + create_region(world, player, locations_per_region, 'Ifrit\'s Lair'), ]) if __debug__: @@ -70,127 +70,126 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData flooded: PreCalculatedWeights = precalculated_weights logic = TimespinnerLogic(world, player, precalculated_weights) - names: Dict[str, int] = {} - connect(world, player, names, 'Lake desolation', 'Lower lake desolation', lambda state: logic.has_timestop(state) or state.has('Talaria Attachment', player) or flooded.flood_lake_desolation) - connect(world, player, names, 'Lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player)) - connect(world, player, names, 'Lake desolation', 'Skeleton Shaft', lambda state: logic.has_doublejump(state) or flooded.flood_lake_desolation) - connect(world, player, names, 'Lake desolation', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Upper lake desolation', 'Lake desolation') - connect(world, player, names, 'Upper lake desolation', 'Eastern lake desolation') - connect(world, player, names, 'Lower lake desolation', 'Lake desolation') - connect(world, player, names, 'Lower lake desolation', 'Eastern lake desolation') - connect(world, player, names, 'Eastern lake desolation', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Eastern lake desolation', 'Library') - connect(world, player, names, 'Eastern lake desolation', 'Lower lake desolation') - connect(world, player, names, 'Eastern lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player)) - connect(world, player, names, 'Library', 'Eastern lake desolation') - connect(world, player, names, 'Library', 'Library top', lambda state: logic.has_doublejump(state) or state.has('Talaria Attachment', player)) - connect(world, player, names, 'Library', 'Varndagroth tower left', logic.has_keycard_D) - connect(world, player, names, 'Library', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Library top', 'Library') - connect(world, player, names, 'Varndagroth tower left', 'Library') - connect(world, player, names, 'Varndagroth tower left', 'Varndagroth tower right (upper)', logic.has_keycard_C) - connect(world, player, names, 'Varndagroth tower left', 'Varndagroth tower right (lower)', logic.has_keycard_B) - connect(world, player, names, 'Varndagroth tower left', 'Sealed Caves (Sirens)', lambda state: logic.has_keycard_B(state) and state.has('Elevator Keycard', player)) - connect(world, player, names, 'Varndagroth tower left', 'Refugee Camp', lambda state: state.has('Timespinner Wheel', player) and state.has('Timespinner Spindle', player)) - connect(world, player, names, 'Varndagroth tower right (upper)', 'Varndagroth tower left') - connect(world, player, names, 'Varndagroth tower right (upper)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player)) - connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (upper)') - connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (lower)') - connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower left', logic.has_keycard_B) - connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player)) - connect(world, player, names, 'Varndagroth tower right (lower)', 'Sealed Caves (Sirens)', lambda state: logic.has_keycard_B(state) and state.has('Elevator Keycard', player)) - connect(world, player, names, 'Varndagroth tower right (lower)', 'Military Fortress', logic.can_kill_all_3_bosses) - connect(world, player, names, 'Varndagroth tower right (lower)', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Sealed Caves (Sirens)', 'Varndagroth tower left', lambda state: state.has('Elevator Keycard', player)) - connect(world, player, names, 'Sealed Caves (Sirens)', 'Varndagroth tower right (lower)', lambda state: state.has('Elevator Keycard', player)) - connect(world, player, names, 'Sealed Caves (Sirens)', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Military Fortress', 'Varndagroth tower right (lower)', logic.can_kill_all_3_bosses) - connect(world, player, names, 'Military Fortress', 'Temporal Gyre', lambda state: state.has('Timespinner Wheel', player)) - connect(world, player, names, 'Military Fortress', 'Military Fortress (hangar)', logic.has_doublejump) - connect(world, player, names, 'Military Fortress (hangar)', 'Military Fortress') - connect(world, player, names, 'Military Fortress (hangar)', 'The lab', lambda state: logic.has_keycard_B(state) and logic.has_doublejump(state)) - connect(world, player, names, 'Temporal Gyre', 'Military Fortress') - connect(world, player, names, 'The lab', 'Military Fortress') - connect(world, player, names, 'The lab', 'The lab (power off)', logic.has_doublejump_of_npc) - connect(world, player, names, 'The lab (power off)', 'The lab') - connect(world, player, names, 'The lab (power off)', 'The lab (upper)', logic.has_forwarddash_doublejump) - connect(world, player, names, 'The lab (upper)', 'The lab (power off)') - connect(world, player, names, 'The lab (upper)', 'Emperors tower', logic.has_forwarddash_doublejump) - connect(world, player, names, 'The lab (upper)', 'Ancient Pyramid (entrance)', lambda state: state.has_all({'Timespinner Wheel', 'Timespinner Spindle', 'Timespinner Gear 1', 'Timespinner Gear 2', 'Timespinner Gear 3'}, player)) - connect(world, player, names, 'Emperors tower', 'The lab (upper)') - connect(world, player, names, 'Skeleton Shaft', 'Lake desolation') - connect(world, player, names, 'Skeleton Shaft', 'Sealed Caves (upper)', logic.has_keycard_A) - connect(world, player, names, 'Skeleton Shaft', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Sealed Caves (upper)', 'Skeleton Shaft') - connect(world, player, names, 'Sealed Caves (upper)', 'Sealed Caves (Xarion)', lambda state: logic.has_teleport(state) or logic.has_doublejump(state)) - connect(world, player, names, 'Sealed Caves (Xarion)', 'Sealed Caves (upper)', logic.has_doublejump) - connect(world, player, names, 'Sealed Caves (Xarion)', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Refugee Camp', 'Forest') - #connect(world, player, names, 'Refugee Camp', 'Library', lambda state: not is_option_enabled(world, player, "Inverted")) - connect(world, player, names, 'Refugee Camp', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Forest', 'Refugee Camp') - connect(world, player, names, 'Forest', 'Left Side forest Caves', lambda state: state.has('Talaria Attachment', player) or logic.has_timestop(state)) - connect(world, player, names, 'Forest', 'Caves of Banishment (Sirens)') - connect(world, player, names, 'Forest', 'Castle Ramparts') - connect(world, player, names, 'Left Side forest Caves', 'Forest') - connect(world, player, names, 'Left Side forest Caves', 'Upper Lake Serene', logic.has_timestop) - connect(world, player, names, 'Left Side forest Caves', 'Lower Lake Serene', lambda state: state.has('Water Mask', player) or flooded.dry_lake_serene) - connect(world, player, names, 'Left Side forest Caves', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Upper Lake Serene', 'Left Side forest Caves') - connect(world, player, names, 'Upper Lake Serene', 'Lower Lake Serene', lambda state: state.has('Water Mask', player)) - connect(world, player, names, 'Lower Lake Serene', 'Upper Lake Serene') - connect(world, player, names, 'Lower Lake Serene', 'Left Side forest Caves') - connect(world, player, names, 'Lower Lake Serene', 'Caves of Banishment (upper)') - connect(world, player, names, 'Caves of Banishment (upper)', 'Upper Lake Serene', lambda state: state.has('Water Mask', player) or flooded.dry_lake_serene) - connect(world, player, names, 'Caves of Banishment (upper)', 'Caves of Banishment (Maw)', lambda state: logic.has_doublejump(state) or state.has_any({'Gas Mask', 'Twin Pyramid Key'}, player)) - connect(world, player, names, 'Caves of Banishment (upper)', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Caves of Banishment (Maw)', 'Caves of Banishment (upper)', lambda state: logic.has_doublejump(state) if not flooded.flood_maw else state.has('Water Mask', player)) - connect(world, player, names, 'Caves of Banishment (Maw)', 'Caves of Banishment (Sirens)', lambda state: state.has('Gas Mask', player)) - connect(world, player, names, 'Caves of Banishment (Maw)', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Caves of Banishment (Sirens)', 'Forest') - connect(world, player, names, 'Castle Ramparts', 'Forest') - connect(world, player, names, 'Castle Ramparts', 'Castle Keep') - connect(world, player, names, 'Castle Ramparts', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Castle Keep', 'Castle Ramparts') - connect(world, player, names, 'Castle Keep', 'Castle Basement', lambda state: state.has('Water Mask', player) or not flooded.flood_basement) - connect(world, player, names, 'Castle Keep', 'Royal towers (lower)', logic.has_doublejump) - connect(world, player, names, 'Castle Keep', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Royal towers (lower)', 'Castle Keep') - connect(world, player, names, 'Royal towers (lower)', 'Royal towers', lambda state: state.has('Timespinner Wheel', player) or logic.has_forwarddash_doublejump(state)) - connect(world, player, names, 'Royal towers (lower)', 'Space time continuum', logic.has_teleport) - connect(world, player, names, 'Royal towers', 'Royal towers (lower)') - connect(world, player, names, 'Royal towers', 'Royal towers (upper)', logic.has_doublejump) - connect(world, player, names, 'Royal towers (upper)', 'Royal towers') - #connect(world, player, names, 'Ancient Pyramid (entrance)', 'The lab (upper)', lambda state: not is_option_enabled(world, player, "EnterSandman")) - connect(world, player, names, 'Ancient Pyramid (entrance)', 'Ancient Pyramid (left)', logic.has_doublejump) - connect(world, player, names, 'Ancient Pyramid (left)', 'Ancient Pyramid (entrance)') - connect(world, player, names, 'Ancient Pyramid (left)', 'Ancient Pyramid (right)', lambda state: logic.has_upwarddash(state) or flooded.flood_pyramid_shaft) - connect(world, player, names, 'Ancient Pyramid (right)', 'Ancient Pyramid (left)', lambda state: logic.has_upwarddash(state) or flooded.flood_pyramid_shaft) - connect(world, player, names, 'Space time continuum', 'Lake desolation', lambda state: logic.can_teleport_to(state, "Present", "GateLakeDesolation")) - connect(world, player, names, 'Space time continuum', 'Lower lake desolation', lambda state: logic.can_teleport_to(state, "Present", "GateKittyBoss")) - connect(world, player, names, 'Space time continuum', 'Library', lambda state: logic.can_teleport_to(state, "Present", "GateLeftLibrary")) - connect(world, player, names, 'Space time continuum', 'Varndagroth tower right (lower)', lambda state: logic.can_teleport_to(state, "Present", "GateMilitaryGate")) - connect(world, player, names, 'Space time continuum', 'Skeleton Shaft', lambda state: logic.can_teleport_to(state, "Present", "GateSealedCaves")) - connect(world, player, names, 'Space time continuum', 'Sealed Caves (Sirens)', lambda state: logic.can_teleport_to(state, "Present", "GateSealedSirensCave")) - connect(world, player, names, 'Space time continuum', 'Upper Lake Serene', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneLeft")) - connect(world, player, names, 'Space time continuum', 'Left Side forest Caves', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneRight")) - connect(world, player, names, 'Space time continuum', 'Refugee Camp', lambda state: logic.can_teleport_to(state, "Past", "GateAccessToPast")) - connect(world, player, names, 'Space time continuum', 'Castle Ramparts', lambda state: logic.can_teleport_to(state, "Past", "GateCastleRamparts")) - connect(world, player, names, 'Space time continuum', 'Castle Keep', lambda state: logic.can_teleport_to(state, "Past", "GateCastleKeep")) - connect(world, player, names, 'Space time continuum', 'Royal towers (lower)', lambda state: logic.can_teleport_to(state, "Past", "GateRoyalTowers")) - connect(world, player, names, 'Space time continuum', 'Caves of Banishment (Maw)', lambda state: logic.can_teleport_to(state, "Past", "GateMaw")) - connect(world, player, names, 'Space time continuum', 'Caves of Banishment (upper)', lambda state: logic.can_teleport_to(state, "Past", "GateCavesOfBanishment")) - connect(world, player, names, 'Space time continuum', 'Ancient Pyramid (entrance)', lambda state: logic.can_teleport_to(state, "Time", "GateGyre") or (not is_option_enabled(world, player, "UnchainedKeys") and is_option_enabled(world, player, "EnterSandman"))) - connect(world, player, names, 'Space time continuum', 'Ancient Pyramid (left)', lambda state: logic.can_teleport_to(state, "Time", "GateLeftPyramid")) - connect(world, player, names, 'Space time continuum', 'Ancient Pyramid (right)', lambda state: logic.can_teleport_to(state, "Time", "GateRightPyramid")) + connect(world, player, 'Lake desolation', 'Lower lake desolation', lambda state: logic.has_timestop(state) or state.has('Talaria Attachment', player) or flooded.flood_lake_desolation) + connect(world, player, 'Lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player)) + connect(world, player, 'Lake desolation', 'Skeleton Shaft', lambda state: logic.has_doublejump(state) or flooded.flood_lake_desolation) + connect(world, player, 'Lake desolation', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Upper lake desolation', 'Lake desolation') + connect(world, player, 'Upper lake desolation', 'Eastern lake desolation') + connect(world, player, 'Lower lake desolation', 'Lake desolation') + connect(world, player, 'Lower lake desolation', 'Eastern lake desolation') + connect(world, player, 'Eastern lake desolation', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Eastern lake desolation', 'Library') + connect(world, player, 'Eastern lake desolation', 'Lower lake desolation') + connect(world, player, 'Eastern lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player)) + connect(world, player, 'Library', 'Eastern lake desolation') + connect(world, player, 'Library', 'Library top', lambda state: logic.has_doublejump(state) or state.has('Talaria Attachment', player)) + connect(world, player, 'Library', 'Varndagroth tower left', logic.has_keycard_D) + connect(world, player, 'Library', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Library top', 'Library') + connect(world, player, 'Varndagroth tower left', 'Library') + connect(world, player, 'Varndagroth tower left', 'Varndagroth tower right (upper)', logic.has_keycard_C) + connect(world, player, 'Varndagroth tower left', 'Varndagroth tower right (lower)', logic.has_keycard_B) + connect(world, player, 'Varndagroth tower left', 'Sealed Caves (Sirens)', lambda state: logic.has_keycard_B(state) and state.has('Elevator Keycard', player)) + connect(world, player, 'Varndagroth tower left', 'Refugee Camp', lambda state: state.has('Timespinner Wheel', player) and state.has('Timespinner Spindle', player)) + connect(world, player, 'Varndagroth tower right (upper)', 'Varndagroth tower left') + connect(world, player, 'Varndagroth tower right (upper)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player)) + connect(world, player, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (upper)') + connect(world, player, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (lower)') + connect(world, player, 'Varndagroth tower right (lower)', 'Varndagroth tower left', logic.has_keycard_B) + connect(world, player, 'Varndagroth tower right (lower)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player)) + connect(world, player, 'Varndagroth tower right (lower)', 'Sealed Caves (Sirens)', lambda state: logic.has_keycard_B(state) and state.has('Elevator Keycard', player)) + connect(world, player, 'Varndagroth tower right (lower)', 'Military Fortress', logic.can_kill_all_3_bosses) + connect(world, player, 'Varndagroth tower right (lower)', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Sealed Caves (Sirens)', 'Varndagroth tower left', lambda state: state.has('Elevator Keycard', player)) + connect(world, player, 'Sealed Caves (Sirens)', 'Varndagroth tower right (lower)', lambda state: state.has('Elevator Keycard', player)) + connect(world, player, 'Sealed Caves (Sirens)', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Military Fortress', 'Varndagroth tower right (lower)', logic.can_kill_all_3_bosses) + connect(world, player, 'Military Fortress', 'Temporal Gyre', lambda state: state.has('Timespinner Wheel', player)) + connect(world, player, 'Military Fortress', 'Military Fortress (hangar)', logic.has_doublejump) + connect(world, player, 'Military Fortress (hangar)', 'Military Fortress') + connect(world, player, 'Military Fortress (hangar)', 'The lab', lambda state: logic.has_keycard_B(state) and logic.has_doublejump(state)) + connect(world, player, 'Temporal Gyre', 'Military Fortress') + connect(world, player, 'The lab', 'Military Fortress') + connect(world, player, 'The lab', 'The lab (power off)', logic.has_doublejump_of_npc) + connect(world, player, 'The lab (power off)', 'The lab') + connect(world, player, 'The lab (power off)', 'The lab (upper)', logic.has_forwarddash_doublejump) + connect(world, player, 'The lab (upper)', 'The lab (power off)') + connect(world, player, 'The lab (upper)', 'Emperors tower', logic.has_forwarddash_doublejump) + connect(world, player, 'The lab (upper)', 'Ancient Pyramid (entrance)', lambda state: state.has_all({'Timespinner Wheel', 'Timespinner Spindle', 'Timespinner Gear 1', 'Timespinner Gear 2', 'Timespinner Gear 3'}, player)) + connect(world, player, 'Emperors tower', 'The lab (upper)') + connect(world, player, 'Skeleton Shaft', 'Lake desolation') + connect(world, player, 'Skeleton Shaft', 'Sealed Caves (upper)', logic.has_keycard_A) + connect(world, player, 'Skeleton Shaft', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Sealed Caves (upper)', 'Skeleton Shaft') + connect(world, player, 'Sealed Caves (upper)', 'Sealed Caves (Xarion)', lambda state: logic.has_teleport(state) or logic.has_doublejump(state)) + connect(world, player, 'Sealed Caves (Xarion)', 'Sealed Caves (upper)', logic.has_doublejump) + connect(world, player, 'Sealed Caves (Xarion)', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Refugee Camp', 'Forest') + #connect(world, player, 'Refugee Camp', 'Library', lambda state: not is_option_enabled(world, player, "Inverted")) + connect(world, player, 'Refugee Camp', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Forest', 'Refugee Camp') + connect(world, player, 'Forest', 'Left Side forest Caves', lambda state: state.has('Talaria Attachment', player) or logic.has_timestop(state)) + connect(world, player, 'Forest', 'Caves of Banishment (Sirens)') + connect(world, player, 'Forest', 'Castle Ramparts') + connect(world, player, 'Left Side forest Caves', 'Forest') + connect(world, player, 'Left Side forest Caves', 'Upper Lake Serene', logic.has_timestop) + connect(world, player, 'Left Side forest Caves', 'Lower Lake Serene', lambda state: state.has('Water Mask', player) or flooded.dry_lake_serene) + connect(world, player, 'Left Side forest Caves', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Upper Lake Serene', 'Left Side forest Caves') + connect(world, player, 'Upper Lake Serene', 'Lower Lake Serene', lambda state: state.has('Water Mask', player)) + connect(world, player, 'Lower Lake Serene', 'Upper Lake Serene') + connect(world, player, 'Lower Lake Serene', 'Left Side forest Caves') + connect(world, player, 'Lower Lake Serene', 'Caves of Banishment (upper)') + connect(world, player, 'Caves of Banishment (upper)', 'Upper Lake Serene', lambda state: state.has('Water Mask', player) or flooded.dry_lake_serene) + connect(world, player, 'Caves of Banishment (upper)', 'Caves of Banishment (Maw)', lambda state: logic.has_doublejump(state) or state.has_any({'Gas Mask', 'Twin Pyramid Key'}, player)) + connect(world, player, 'Caves of Banishment (upper)', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Caves of Banishment (Maw)', 'Caves of Banishment (upper)', lambda state: logic.has_doublejump(state) if not flooded.flood_maw else state.has('Water Mask', player)) + connect(world, player, 'Caves of Banishment (Maw)', 'Caves of Banishment (Sirens)', lambda state: state.has_any({'Gas Mask', 'Talaria Attachment'}, player) ) + connect(world, player, 'Caves of Banishment (Maw)', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Caves of Banishment (Sirens)', 'Forest') + connect(world, player, 'Castle Ramparts', 'Forest') + connect(world, player, 'Castle Ramparts', 'Castle Keep') + connect(world, player, 'Castle Ramparts', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Castle Keep', 'Castle Ramparts') + connect(world, player, 'Castle Keep', 'Castle Basement', lambda state: state.has('Water Mask', player) or not flooded.flood_basement) + connect(world, player, 'Castle Keep', 'Royal towers (lower)', logic.has_doublejump) + connect(world, player, 'Castle Keep', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Royal towers (lower)', 'Castle Keep') + connect(world, player, 'Royal towers (lower)', 'Royal towers', lambda state: state.has('Timespinner Wheel', player) or logic.has_forwarddash_doublejump(state)) + connect(world, player, 'Royal towers (lower)', 'Space time continuum', logic.has_teleport) + connect(world, player, 'Royal towers', 'Royal towers (lower)') + connect(world, player, 'Royal towers', 'Royal towers (upper)', logic.has_doublejump) + connect(world, player, 'Royal towers (upper)', 'Royal towers') + #connect(world, player, 'Ancient Pyramid (entrance)', 'The lab (upper)', lambda state: not is_option_enabled(world, player, "EnterSandman")) + connect(world, player, 'Ancient Pyramid (entrance)', 'Ancient Pyramid (left)', logic.has_doublejump) + connect(world, player, 'Ancient Pyramid (left)', 'Ancient Pyramid (entrance)') + connect(world, player, 'Ancient Pyramid (left)', 'Ancient Pyramid (right)', lambda state: logic.has_upwarddash(state) or flooded.flood_pyramid_shaft) + connect(world, player, 'Ancient Pyramid (right)', 'Ancient Pyramid (left)', lambda state: logic.has_upwarddash(state) or flooded.flood_pyramid_shaft) + connect(world, player, 'Space time continuum', 'Lake desolation', lambda state: logic.can_teleport_to(state, "Present", "GateLakeDesolation")) + connect(world, player, 'Space time continuum', 'Lower lake desolation', lambda state: logic.can_teleport_to(state, "Present", "GateKittyBoss")) + connect(world, player, 'Space time continuum', 'Library', lambda state: logic.can_teleport_to(state, "Present", "GateLeftLibrary")) + connect(world, player, 'Space time continuum', 'Varndagroth tower right (lower)', lambda state: logic.can_teleport_to(state, "Present", "GateMilitaryGate")) + connect(world, player, 'Space time continuum', 'Skeleton Shaft', lambda state: logic.can_teleport_to(state, "Present", "GateSealedCaves")) + connect(world, player, 'Space time continuum', 'Sealed Caves (Sirens)', lambda state: logic.can_teleport_to(state, "Present", "GateSealedSirensCave")) + connect(world, player, 'Space time continuum', 'Upper Lake Serene', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneLeft")) + connect(world, player, 'Space time continuum', 'Left Side forest Caves', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneRight")) + connect(world, player, 'Space time continuum', 'Refugee Camp', lambda state: logic.can_teleport_to(state, "Past", "GateAccessToPast")) + connect(world, player, 'Space time continuum', 'Castle Ramparts', lambda state: logic.can_teleport_to(state, "Past", "GateCastleRamparts")) + connect(world, player, 'Space time continuum', 'Castle Keep', lambda state: logic.can_teleport_to(state, "Past", "GateCastleKeep")) + connect(world, player, 'Space time continuum', 'Royal towers (lower)', lambda state: logic.can_teleport_to(state, "Past", "GateRoyalTowers")) + connect(world, player, 'Space time continuum', 'Caves of Banishment (Maw)', lambda state: logic.can_teleport_to(state, "Past", "GateMaw")) + connect(world, player, 'Space time continuum', 'Caves of Banishment (upper)', lambda state: logic.can_teleport_to(state, "Past", "GateCavesOfBanishment")) + connect(world, player, 'Space time continuum', 'Ancient Pyramid (entrance)', lambda state: logic.can_teleport_to(state, "Time", "GateGyre") or (not is_option_enabled(world, player, "UnchainedKeys") and is_option_enabled(world, player, "EnterSandman"))) + connect(world, player, 'Space time continuum', 'Ancient Pyramid (left)', lambda state: logic.can_teleport_to(state, "Time", "GateLeftPyramid")) + connect(world, player, 'Space time continuum', 'Ancient Pyramid (right)', lambda state: logic.can_teleport_to(state, "Time", "GateRightPyramid")) if is_option_enabled(world, player, "GyreArchives"): - connect(world, player, names, 'The lab (upper)', 'Ravenlord\'s Lair', lambda state: state.has('Merchant Crow', player)) - connect(world, player, names, 'Ravenlord\'s Lair', 'The lab (upper)') - connect(world, player, names, 'Library top', 'Ifrit\'s Lair', lambda state: state.has('Kobo', player) and state.can_reach('Refugee Camp', 'Region', player)) - connect(world, player, names, 'Ifrit\'s Lair', 'Library top') + connect(world, player, 'The lab (upper)', 'Ravenlord\'s Lair', lambda state: state.has('Merchant Crow', player)) + connect(world, player, 'Ravenlord\'s Lair', 'The lab (upper)') + connect(world, player, 'Library top', 'Ifrit\'s Lair', lambda state: state.has('Kobo', player) and state.can_reach('Refugee Camp', 'Region', player)) + connect(world, player, 'Ifrit\'s Lair', 'Library top') def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): @@ -203,7 +202,7 @@ def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: raise Exception("Timespinner: the following regions are used in locations: {}, but no such region exists".format(regionNames - existingRegions)) -def create_location(player: int, location_data: LocationData, region: Region, location_cache: List[Location]) -> Location: +def create_location(player: int, location_data: LocationData, region: Region) -> Location: location = Location(player, location_data.name, location_data.code, region) location.access_rule = location_data.rule @@ -211,17 +210,15 @@ def create_location(player: int, location_data: LocationData, region: Region, lo location.event = True location.locked = True - location_cache.append(location) - return location -def create_region(world: MultiWorld, player: int, locations_per_region: Dict[str, List[LocationData]], location_cache: List[Location], name: str) -> Region: +def create_region(world: MultiWorld, player: int, locations_per_region: Dict[str, List[LocationData]], name: str) -> Region: region = Region(name, player, world) if name in locations_per_region: for location_data in locations_per_region[name]: - location = create_location(player, location_data, region, location_cache) + location = create_location(player, location_data, region) region.locations.append(location) return region @@ -250,19 +247,13 @@ def connectStartingRegion(world: MultiWorld, player: int): space_time_continuum.exits.append(teleport_back_to_start) -def connect(world: MultiWorld, player: int, used_names: Dict[str, int], source: str, target: str, +def connect(world: MultiWorld, player: int, source: str, target: str, rule: Optional[Callable[[CollectionState], bool]] = None): + sourceRegion = world.get_region(source, player) targetRegion = world.get_region(target, player) - if target not in used_names: - used_names[target] = 1 - name = target - else: - used_names[target] += 1 - name = target + (' ' * used_names[target]) - - connection = Entrance(player, name, sourceRegion) + connection = Entrance(player, "", sourceRegion) if rule: connection.access_rule = rule @@ -271,7 +262,7 @@ def connect(world: MultiWorld, player: int, used_names: Dict[str, int], source: connection.connect(targetRegion) -def get_locations_per_region(locations: Tuple[LocationData, ...]) -> Dict[str, List[LocationData]]: +def split_location_datas_per_region(locations: Tuple[LocationData, ...]) -> Dict[str, List[LocationData]]: per_region: Dict[str, List[LocationData]] = {} for location in locations: diff --git a/worlds/timespinner/__init__.py b/worlds/timespinner/__init__.py index cb52459b52..de1d58e961 100644 --- a/worlds/timespinner/__init__.py +++ b/worlds/timespinner/__init__.py @@ -1,10 +1,11 @@ -from typing import Dict, List, Set, Tuple, TextIO -from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification -from .Items import get_item_names_per_category, item_table, starter_melee_weapons, starter_spells, filler_items -from .Locations import get_locations, EventId +from typing import Dict, List, Set, Tuple, TextIO, Union +from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification +from .Items import get_item_names_per_category +from .Items import item_table, starter_melee_weapons, starter_spells, filler_items, starter_progression_items +from .Locations import get_location_datas, EventId from .Options import is_option_enabled, get_option_value, timespinner_options from .PreCalculatedWeights import PreCalculatedWeights -from .Regions import create_regions +from .Regions import create_regions_and_locations from worlds.AutoWorld import World, WebWorld class TimespinnerWebWorld(WebWorld): @@ -29,7 +30,6 @@ class TimespinnerWebWorld(WebWorld): tutorials = [setup, setup_de] - class TimespinnerWorld(World): """ Timespinner is a beautiful metroidvania inspired by classic 90s action-platformers. @@ -44,21 +44,16 @@ class TimespinnerWorld(World): required_client_version = (0, 3, 7) item_name_to_id = {name: data.code for name, data in item_table.items()} - location_name_to_id = {location.name: location.code for location in get_locations(None, None, None)} + location_name_to_id = {location.name: location.code for location in get_location_datas(None, None, None)} item_name_groups = get_item_names_per_category() - locked_locations: List[str] - location_cache: List[Location] precalculated_weights: PreCalculatedWeights def __init__(self, world: MultiWorld, player: int): super().__init__(world, player) - - self.locked_locations = [] - self.location_cache = [] self.precalculated_weights = PreCalculatedWeights(world, player) - def generate_early(self): + def generate_early(self) -> None: # in generate_early the start_inventory isnt copied over to precollected_items yet, so we can still modify the options directly if self.multiworld.start_inventory[self.player].value.pop('Meyef', 0) > 0: self.multiworld.StartWithMeyef[self.player].value = self.multiworld.StartWithMeyef[self.player].option_true @@ -67,44 +62,28 @@ class TimespinnerWorld(World): if self.multiworld.start_inventory[self.player].value.pop('Jewelry Box', 0) > 0: self.multiworld.StartWithJewelryBox[self.player].value = self.multiworld.StartWithJewelryBox[self.player].option_true - def create_regions(self): - locations = get_locations(self.multiworld, self.player, self.precalculated_weights) - create_regions(self.multiworld, self.player, locations, self.location_cache, self.precalculated_weights) + def create_regions(self) -> None: + create_regions_and_locations(self.multiworld, self.player, self.precalculated_weights) - def create_item(self, name: str) -> Item: - return create_item_with_correct_settings(self.multiworld, self.player, name) + def create_items(self) -> None: + self.create_and_assign_event_items() - def get_filler_item_name(self) -> str: - trap_chance: int = get_option_value(self.multiworld, self.player, "TrapChance") - enabled_traps: List[str] = get_option_value(self.multiworld, self.player, "Traps") + excluded_items: Set[str] = self.get_excluded_items() - if self.multiworld.random.random() < (trap_chance / 100) and enabled_traps: - return self.multiworld.random.choice(enabled_traps) - else: - return self.multiworld.random.choice(filler_items) + self.assign_starter_items(excluded_items) + self.place_first_progression_item(excluded_items) - def set_rules(self): - setup_events(self.player, self.locked_locations, self.location_cache) + self.multiworld.itempool += self.get_item_pool(excluded_items) + def set_rules(self) -> None: final_boss: str - if is_option_enabled(self.multiworld, self.player, "DadPercent"): + if self.is_option_enabled("DadPercent"): final_boss = "Killed Emperor" else: final_boss = "Killed Nightmare" self.multiworld.completion_condition[self.player] = lambda state: state.has(final_boss, self.player) - def generate_basic(self): - excluded_items: Set[str] = get_excluded_items(self, self.multiworld, self.player) - - assign_starter_items(self.multiworld, self.player, excluded_items, self.locked_locations) - - pool = get_item_pool(self.multiworld, self.player, excluded_items) - - fill_item_pool_with_dummy_items(self, self.multiworld, self.player, self.locked_locations, self.location_cache, pool) - - self.multiworld.itempool += pool - def fill_slot_data(self) -> Dict[str, object]: slot_data: Dict[str, object] = {} @@ -112,12 +91,12 @@ class TimespinnerWorld(World): for option_name in timespinner_options: if (option_name not in ap_specific_settings): - slot_data[option_name] = get_option_value(self.multiworld, self.player, option_name) + slot_data[option_name] = self.get_option_value(option_name) slot_data["StinkyMaw"] = True slot_data["ProgressiveVerticalMovement"] = False slot_data["ProgressiveKeycards"] = False - slot_data["PersonalItems"] = get_personal_items(self.player, self.location_cache) + slot_data["PersonalItems"] = self.get_personal_items() slot_data["PyramidKeysGate"] = self.precalculated_weights.pyramid_keys_unlock slot_data["PresentGate"] = self.precalculated_weights.present_key_unlock slot_data["PastGate"] = self.precalculated_weights.past_key_unlock @@ -135,17 +114,17 @@ class TimespinnerWorld(World): return slot_data - def write_spoiler_header(self, spoiler_handle: TextIO): - if is_option_enabled(self.multiworld, self.player, "UnchainedKeys"): + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: + if self.is_option_enabled("UnchainedKeys"): spoiler_handle.write(f'Modern Warp Beacon unlock: {self.precalculated_weights.present_key_unlock}\n') spoiler_handle.write(f'Timeworn Warp Beacon unlock: {self.precalculated_weights.past_key_unlock}\n') - if is_option_enabled(self.multiworld, self.player, "EnterSandman"): + if self.is_option_enabled("EnterSandman"): spoiler_handle.write(f'Mysterious Warp Beacon unlock: {self.precalculated_weights.time_key_unlock}\n') else: spoiler_handle.write(f'Twin Pyramid Keys unlock: {self.precalculated_weights.pyramid_keys_unlock}\n') - if is_option_enabled(self.multiworld, self.player, "RisingTides"): + if self.is_option_enabled("RisingTides"): flooded_areas: List[str] = [] if self.precalculated_weights.flood_basement: @@ -167,8 +146,8 @@ class TimespinnerWorld(World): flooded_areas.append("Castle Courtyard") if self.precalculated_weights.flood_lake_desolation: flooded_areas.append("Lake Desolation") - if self.precalculated_weights.dry_lake_serene: - flooded_areas.append("Dry Lake Serene") + if not self.precalculated_weights.dry_lake_serene: + flooded_areas.append("Lake Serene") if len(flooded_areas) == 0: flooded_areas_string: str = "None" @@ -177,133 +156,154 @@ class TimespinnerWorld(World): spoiler_handle.write(f'Flooded Areas: {flooded_areas_string}\n') + def create_item(self, name: str) -> Item: + data = item_table[name] -def get_excluded_items(self: TimespinnerWorld, world: MultiWorld, player: int) -> Set[str]: - excluded_items: Set[str] = set() - - if is_option_enabled(world, player, "StartWithJewelryBox"): - excluded_items.add('Jewelry Box') - if is_option_enabled(world, player, "StartWithMeyef"): - excluded_items.add('Meyef') - if is_option_enabled(world, player, "QuickSeed"): - excluded_items.add('Talaria Attachment') - - if is_option_enabled(world, player, "UnchainedKeys"): - excluded_items.add('Twin Pyramid Key') - - if not is_option_enabled(world, player, "EnterSandman"): - excluded_items.add('Mysterious Warp Beacon') - else: - excluded_items.add('Timeworn Warp Beacon') - excluded_items.add('Modern Warp Beacon') - excluded_items.add('Mysterious Warp Beacon') - - for item in world.precollected_items[player]: - if item.name not in self.item_name_groups['UseItem']: - excluded_items.add(item.name) - - return excluded_items - - -def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]): - non_local_items = world.non_local_items[player].value - - local_starter_melee_weapons = tuple(item for item in starter_melee_weapons if item not in non_local_items) - if not local_starter_melee_weapons: - if 'Plasma Orb' in non_local_items: - raise Exception("Atleast one melee orb must be local") + if data.useful: + classification = ItemClassification.useful + elif data.progression: + classification = ItemClassification.progression + elif data.trap: + classification = ItemClassification.trap else: - local_starter_melee_weapons = ('Plasma Orb',) + classification = ItemClassification.filler + + item = Item(name, classification, data.code, self.player) - local_starter_spells = tuple(item for item in starter_spells if item not in non_local_items) - if not local_starter_spells: - if 'Lightwall' in non_local_items: - raise Exception("Atleast one spell must be local") - else: - local_starter_spells = ('Lightwall',) + if not item.advancement: + return item - assign_starter_item(world, player, excluded_items, locked_locations, 'Tutorial: Yo Momma 1', local_starter_melee_weapons) - assign_starter_item(world, player, excluded_items, locked_locations, 'Tutorial: Yo Momma 2', local_starter_spells) + if (name == 'Tablet' or name == 'Library Keycard V') and not self.is_option_enabled("DownloadableItems"): + item.classification = ItemClassification.filler + elif name == 'Oculus Ring' and not self.is_option_enabled("EyeSpy"): + item.classification = ItemClassification.filler + elif (name == 'Kobo' or name == 'Merchant Crow') and not self.is_option_enabled("GyreArchives"): + item.classification = ItemClassification.filler + elif name in {"Timeworn Warp Beacon", "Modern Warp Beacon", "Mysterious Warp Beacon"} \ + and not self.is_option_enabled("UnchainedKeys"): + item.classification = ItemClassification.filler - -def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str], - location: str, item_list: Tuple[str, ...]): - - item_name = world.random.choice(item_list) - - excluded_items.add(item_name) - - item = create_item_with_correct_settings(world, player, item_name) - - world.get_location(location, player).place_locked_item(item) - - locked_locations.append(location) - - -def get_item_pool(world: MultiWorld, player: int, excluded_items: Set[str]) -> List[Item]: - pool: List[Item] = [] - - for name, data in item_table.items(): - if name not in excluded_items: - for _ in range(data.count): - item = create_item_with_correct_settings(world, player, name) - pool.append(item) - - return pool - - -def fill_item_pool_with_dummy_items(self: TimespinnerWorld, world: MultiWorld, player: int, locked_locations: List[str], - location_cache: List[Location], pool: List[Item]): - for _ in range(len(location_cache) - len(locked_locations) - len(pool)): - item = create_item_with_correct_settings(world, player, self.get_filler_item_name()) - pool.append(item) - - -def create_item_with_correct_settings(world: MultiWorld, player: int, name: str) -> Item: - data = item_table[name] - - if data.useful: - classification = ItemClassification.useful - elif data.progression: - classification = ItemClassification.progression - elif data.trap: - classification = ItemClassification.trap - else: - classification = ItemClassification.filler - - item = Item(name, classification, data.code, player) - - if not item.advancement: return item - if (name == 'Tablet' or name == 'Library Keycard V') and not is_option_enabled(world, player, "DownloadableItems"): - item.classification = ItemClassification.filler - elif name == 'Oculus Ring' and not is_option_enabled(world, player, "EyeSpy"): - item.classification = ItemClassification.filler - elif (name == 'Kobo' or name == 'Merchant Crow') and not is_option_enabled(world, player, "GyreArchives"): - item.classification = ItemClassification.filler - elif name in {"Timeworn Warp Beacon", "Modern Warp Beacon", "Mysterious Warp Beacon"} \ - and not is_option_enabled(world, player, "UnchainedKeys"): - item.classification = ItemClassification.filler + def get_filler_item_name(self) -> str: + trap_chance: int = self.get_option_value("TrapChance") + enabled_traps: List[str] = self.get_option_value("Traps") - return item + if self.multiworld.random.random() < (trap_chance / 100) and enabled_traps: + return self.multiworld.random.choice(enabled_traps) + else: + return self.multiworld.random.choice(filler_items) + def get_excluded_items(self) -> Set[str]: + excluded_items: Set[str] = set() -def setup_events(player: int, locked_locations: List[str], location_cache: List[Location]): - for location in location_cache: - if location.address == EventId: - item = Item(location.name, ItemClassification.progression, EventId, player) + if self.is_option_enabled("StartWithJewelryBox"): + excluded_items.add('Jewelry Box') + if self.is_option_enabled("StartWithMeyef"): + excluded_items.add('Meyef') + if self.is_option_enabled("QuickSeed"): + excluded_items.add('Talaria Attachment') - locked_locations.append(location.name) + if self.is_option_enabled("UnchainedKeys"): + excluded_items.add('Twin Pyramid Key') - location.place_locked_item(item) + if not self.is_option_enabled("EnterSandman"): + excluded_items.add('Mysterious Warp Beacon') + else: + excluded_items.add('Timeworn Warp Beacon') + excluded_items.add('Modern Warp Beacon') + excluded_items.add('Mysterious Warp Beacon') + for item in self.multiworld.precollected_items[self.player]: + if item.name not in self.item_name_groups['UseItem']: + excluded_items.add(item.name) -def get_personal_items(player: int, locations: List[Location]) -> Dict[int, int]: - personal_items: Dict[int, int] = {} + return excluded_items - for location in locations: - if location.address and location.item and location.item.code and location.item.player == player: - personal_items[location.address] = location.item.code + def assign_starter_items(self, excluded_items: Set[str]) -> None: + non_local_items: Set[str] = self.multiworld.non_local_items[self.player].value - return personal_items + local_starter_melee_weapons = tuple(item for item in starter_melee_weapons if item not in non_local_items) + if not local_starter_melee_weapons: + if 'Plasma Orb' in non_local_items: + raise Exception("Atleast one melee orb must be local") + else: + local_starter_melee_weapons = ('Plasma Orb',) + + local_starter_spells = tuple(item for item in starter_spells if item not in non_local_items) + if not local_starter_spells: + if 'Lightwall' in non_local_items: + raise Exception("Atleast one spell must be local") + else: + local_starter_spells = ('Lightwall',) + + self.assign_starter_item(excluded_items, 'Tutorial: Yo Momma 1', local_starter_melee_weapons) + self.assign_starter_item(excluded_items, 'Tutorial: Yo Momma 2', local_starter_spells) + + def assign_starter_item(self, excluded_items: Set[str], location: str, item_list: Tuple[str, ...]) -> None: + item_name = self.multiworld.random.choice(item_list) + + self.place_locked_item(excluded_items, location, item_name) + + def place_first_progression_item(self, excluded_items: Set[str]) -> None: + if self.is_option_enabled("QuickSeed") or self.is_option_enabled("Inverted") \ + or self.precalculated_weights.flood_lake_desolation: + return + + for item in self.multiworld.precollected_items[self.player]: + if item.name in starter_progression_items and not item.name in excluded_items: + return + + local_starter_progression_items = tuple( + item for item in starter_progression_items + if item not in excluded_items and item not in self.multiworld.non_local_items[self.player].value) + + if not local_starter_progression_items: + return + + progression_item = self.multiworld.random.choice(local_starter_progression_items) + + self.multiworld.local_early_items[self.player][progression_item] = 1 + + def place_locked_item(self, excluded_items: Set[str], location: str, item: str) -> None: + excluded_items.add(item) + + item = self.create_item(item) + + self.multiworld.get_location(location, self.player).place_locked_item(item) + + def get_item_pool(self, excluded_items: Set[str]) -> List[Item]: + pool: List[Item] = [] + + for name, data in item_table.items(): + if name not in excluded_items: + for _ in range(data.count): + item = self.create_item(name) + pool.append(item) + + for _ in range(len(self.multiworld.get_unfilled_locations(self.player)) - len(pool)): + item = self.create_item(self.get_filler_item_name()) + pool.append(item) + + return pool + + def create_and_assign_event_items(self) -> None: + for location in self.multiworld.get_locations(self.player): + if location.address == EventId: + item = Item(location.name, ItemClassification.progression, EventId, self.player) + location.place_locked_item(item) + + def get_personal_items(self) -> Dict[int, int]: + personal_items: Dict[int, int] = {} + + for location in self.multiworld.get_locations(self.player): + if location.address and location.item and location.item.code and location.item.player == self.player: + personal_items[location.address] = location.item.code + + return personal_items + + def is_option_enabled(self, option: str) -> bool: + return is_option_enabled(self.multiworld, self.player, option) + + def get_option_value(self, option: str) -> Union[int, Dict, List]: + return get_option_value(self.multiworld, self.player, option) diff --git a/worlds/tloz/ItemPool.py b/worlds/tloz/ItemPool.py new file mode 100644 index 0000000000..9dcf1b7aef --- /dev/null +++ b/worlds/tloz/ItemPool.py @@ -0,0 +1,147 @@ +from BaseClasses import ItemClassification +from .Locations import level_locations, all_level_locations, standard_level_locations, shop_locations +from .Options import TriforceLocations, StartingPosition + +# Swords are in starting_weapons +overworld_items = { + "Letter": 1, + "Power Bracelet": 1, + "Heart Container": 1, + "Sword": 1 +} + +# Bomb, Arrow, 1 Small Key and Red Water of Life are in guaranteed_shop_items +shop_items = { + "Magical Shield": 3, + "Food": 2, + "Small Key": 1, + "Candle": 1, + "Recovery Heart": 1, + "Blue Ring": 1, + "Water of Life (Blue)": 1 +} + +# Magical Rod and Red Candle are in starting_weapons, Triforce Fragments are added in its section of get_pool_core +major_dungeon_items = { + "Heart Container": 8, + "Bow": 1, + "Boomerang": 1, + "Magical Boomerang": 1, + "Raft": 1, + "Stepladder": 1, + "Recorder": 1, + "Magical Key": 1, + "Book of Magic": 1, + "Silver Arrow": 1, + "Red Ring": 1 +} + +minor_dungeon_items = { + "Bomb": 23, + "Small Key": 45, + "Five Rupees": 17 +} + +take_any_items = { + "Heart Container": 4 +} + +# Map/Compasses: 18 +# Reasoning: Adding some variety to the vanilla game. + +map_compass_replacements = { + "Fairy": 6, + "Clock": 3, + "Water of Life (Red)": 1, + "Water of Life (Blue)": 2, + "Bomb": 2, + "Small Key": 2, + "Five Rupees": 2 +} +basic_pool = { + item: overworld_items.get(item, 0) + shop_items.get(item, 0) + + major_dungeon_items.get(item, 0) + map_compass_replacements.get(item, 0) + for item in set(overworld_items) | set(shop_items) | set(major_dungeon_items) | set(map_compass_replacements) +} + +starting_weapons = ["Sword", "White Sword", "Magical Sword", "Magical Rod", "Red Candle"] +guaranteed_shop_items = ["Small Key", "Bomb", "Water of Life (Red)", "Arrow"] +starting_weapon_locations = ["Starting Sword Cave", "Letter Cave", "Armos Knights"] +dangerous_weapon_locations = [ + "Level 1 Compass", "Level 2 Bomb Drop (Keese)", "Level 3 Key Drop (Zols Entrance)", "Level 3 Compass"] + +def generate_itempool(tlozworld): + (pool, placed_items) = get_pool_core(tlozworld) + tlozworld.multiworld.itempool.extend([tlozworld.multiworld.create_item(item, tlozworld.player) for item in pool]) + for (location_name, item) in placed_items.items(): + location = tlozworld.multiworld.get_location(location_name, tlozworld.player) + location.place_locked_item(tlozworld.multiworld.create_item(item, tlozworld.player)) + if item == "Bomb": + location.item.classification = ItemClassification.progression + +def get_pool_core(world): + random = world.multiworld.random + + pool = [] + placed_items = {} + minor_items = dict(minor_dungeon_items) + + # Guaranteed Shop Items + reserved_store_slots = random.sample(shop_locations[0:9], 4) + for location, item in zip(reserved_store_slots, guaranteed_shop_items): + placed_items[location] = item + + # Starting Weapon + starting_weapon = random.choice(starting_weapons) + if world.multiworld.StartingPosition[world.player] == StartingPosition.option_safe: + placed_items[starting_weapon_locations[0]] = starting_weapon + elif world.multiworld.StartingPosition[world.player] in \ + [StartingPosition.option_unsafe, StartingPosition.option_dangerous]: + if world.multiworld.StartingPosition[world.player] == StartingPosition.option_dangerous: + for location in dangerous_weapon_locations: + if world.multiworld.ExpandedPool[world.player] or "Drop" not in location: + starting_weapon_locations.append(location) + placed_items[random.choice(starting_weapon_locations)] = starting_weapon + else: + pool.append(starting_weapon) + for other_weapons in starting_weapons: + if other_weapons != starting_weapon: + pool.append(other_weapons) + + # Triforce Fragments + fragment = "Triforce Fragment" + if world.multiworld.ExpandedPool[world.player]: + possible_level_locations = [location for location in all_level_locations + if location not in level_locations[8]] + else: + possible_level_locations = [location for location in standard_level_locations + if location not in level_locations[8]] + for level in range(1, 9): + if world.multiworld.TriforceLocations[world.player] == TriforceLocations.option_vanilla: + placed_items[f"Level {level} Triforce"] = fragment + elif world.multiworld.TriforceLocations[world.player] == TriforceLocations.option_dungeons: + placed_items[possible_level_locations.pop(random.randint(0, len(possible_level_locations) - 1))] = fragment + else: + pool.append(fragment) + + # Level 9 junk fill + if world.multiworld.ExpandedPool[world.player] > 0: + spots = random.sample(level_locations[8], len(level_locations[8]) // 2) + for spot in spots: + junk = random.choice(list(minor_items.keys())) + placed_items[spot] = junk + minor_items[junk] -= 1 + + # Finish Pool + final_pool = basic_pool + if world.multiworld.ExpandedPool[world.player]: + final_pool = { + item: basic_pool.get(item, 0) + minor_items.get(item, 0) + take_any_items.get(item, 0) + for item in set(basic_pool) | set(minor_items) | set(take_any_items) + } + final_pool["Five Rupees"] -= 1 + for item in final_pool.keys(): + for i in range(0, final_pool[item]): + pool.append(item) + + return pool, placed_items diff --git a/worlds/tloz/Items.py b/worlds/tloz/Items.py new file mode 100644 index 0000000000..d896d11d77 --- /dev/null +++ b/worlds/tloz/Items.py @@ -0,0 +1,147 @@ +from BaseClasses import ItemClassification +import typing +from typing import Dict + +progression = ItemClassification.progression +filler = ItemClassification.filler +useful = ItemClassification.useful +trap = ItemClassification.trap + + +class ItemData(typing.NamedTuple): + code: typing.Optional[int] + classification: ItemClassification + + +item_table: Dict[str, ItemData] = { + "Boomerang": ItemData(100, useful), + "Bow": ItemData(101, progression), + "Magical Boomerang": ItemData(102, useful), + "Raft": ItemData(103, progression), + "Stepladder": ItemData(104, progression), + "Recorder": ItemData(105, progression), + "Magical Rod": ItemData(106, progression), + "Red Candle": ItemData(107, progression), + "Book of Magic": ItemData(108, progression), + "Magical Key": ItemData(109, useful), + "Red Ring": ItemData(110, useful), + "Silver Arrow": ItemData(111, progression), + "Sword": ItemData(112, progression), + "White Sword": ItemData(113, progression), + "Magical Sword": ItemData(114, progression), + "Heart Container": ItemData(115, progression), + "Letter": ItemData(116, progression), + "Magical Shield": ItemData(117, useful), + "Candle": ItemData(118, progression), + "Arrow": ItemData(119, progression), + "Food": ItemData(120, progression), + "Water of Life (Blue)": ItemData(121, useful), + "Water of Life (Red)": ItemData(122, useful), + "Blue Ring": ItemData(123, useful), + "Triforce Fragment": ItemData(124, progression), + "Power Bracelet": ItemData(125, useful), + "Small Key": ItemData(126, filler), + "Bomb": ItemData(127, filler), + "Recovery Heart": ItemData(128, filler), + "Five Rupees": ItemData(129, filler), + "Rupee": ItemData(130, filler), + "Clock": ItemData(131, filler), + "Fairy": ItemData(132, filler) + +} + +item_game_ids = { + "Bomb": 0x00, + "Sword": 0x01, + "White Sword": 0x02, + "Magical Sword": 0x03, + "Food": 0x04, + "Recorder": 0x05, + "Candle": 0x06, + "Red Candle": 0x07, + "Arrow": 0x08, + "Silver Arrow": 0x09, + "Bow": 0x0A, + "Magical Key": 0x0B, + "Raft": 0x0C, + "Stepladder": 0x0D, + "Five Rupees": 0x0F, + "Magical Rod": 0x10, + "Book of Magic": 0x11, + "Blue Ring": 0x12, + "Red Ring": 0x13, + "Power Bracelet": 0x14, + "Letter": 0x15, + "Small Key": 0x19, + "Heart Container": 0x1A, + "Triforce Fragment": 0x1B, + "Magical Shield": 0x1C, + "Boomerang": 0x1D, + "Magical Boomerang": 0x1E, + "Water of Life (Blue)": 0x1F, + "Water of Life (Red)": 0x20, + "Recovery Heart": 0x22, + "Rupee": 0x18, + "Clock": 0x21, + "Fairy": 0x23 +} + +# Item prices are going to get a bit of a writeup here, because these are some seemingly arbitrary +# design decisions and future contributors may want to know how these were arrived at. + +# First, I based everything off of the Blue Ring. Since the Red Ring is twice as good as the Blue Ring, +# logic dictates it should cost twice as much. Since you can't make something cost 500 rupees, the only +# solution was to halve the price of the Blue Ring. Correspondingly, everything else sold in shops was +# also cut in half. + +# Then, I decided on a factor for swords. Since each sword does double the damage of its predecessor, each +# one should be at least double. Since the sword saves so much time when upgraded (as, unlike other items, +# you don't need to switch to it), I wanted a bit of a premium on upgrades. Thus, a 4x multiplier was chosen, +# allowing the basic Sword to stay cheap while making the Magical Sword be a hefty upgrade you'll +# feel the price of. + +# Since arrows do the same amount of damage as the White Sword and silver arrows are the same with the Magical Sword. +# they were given corresponding costs. + +# Utility items were based on the prices of the shield, keys, and food. Broadly useful utility items should cost more, +# while limited use utility items should cost less. After eyeballing those, a few editorial decisions were made as +# deliberate thumbs on the scale of game balance. Those exceptions will be noted below. In general, prices were chosen +# based on how a player would feel spending that amount of money as opposed to how useful an item actually is. + +item_prices = { + "Bomb": 10, + "Sword": 10, + "White Sword": 40, + "Magical Sword": 160, + "Food": 30, + "Recorder": 45, + "Candle": 30, + "Red Candle": 60, + "Arrow": 40, + "Silver Arrow": 160, + "Bow": 40, + "Magical Key": 250, # Replacing all small keys commands a high premium + "Raft": 80, + "Stepladder": 80, + "Five Rupees": 255, # This could cost anything above 5 Rupees and be fine, but 255 is the funniest + "Magical Rod": 100, # White Sword with forever beams should cost at least more than the White Sword itself + "Book of Magic": 60, + "Blue Ring": 125, + "Red Ring": 250, + "Power Bracelet": 25, + "Letter": 20, + "Small Key": 40, + "Heart Container": 80, + "Triforce Fragment": 200, # Since I couldn't make Zelda 1 track shop purchases, this is how to discourage repeat + # Triforce purchases. The punishment for endless Rupee grinding to avoid searching out + # Triforce pieces is that you're doing endless Rupee grinding to avoid playing the game + "Magical Shield": 45, + "Boomerang": 5, + "Magical Boomerang": 20, + "Water of Life (Blue)": 20, + "Water of Life (Red)": 34, + "Recovery Heart": 5, + "Rupee": 50, + "Clock": 0, + "Fairy": 10 +} diff --git a/worlds/tloz/Locations.py b/worlds/tloz/Locations.py new file mode 100644 index 0000000000..3e46c43833 --- /dev/null +++ b/worlds/tloz/Locations.py @@ -0,0 +1,343 @@ +from . import Rom + +major_locations = [ + "Starting Sword Cave", + "White Sword Pond", + "Magical Sword Grave", + "Take Any Item Left", + "Take Any Item Middle", + "Take Any Item Right", + "Armos Knights", + "Ocean Heart Container", + "Letter Cave", +] + +level_locations = [ + [ + "Level 1 Item (Bow)", "Level 1 Item (Boomerang)", "Level 1 Map", "Level 1 Compass", "Level 1 Boss", + "Level 1 Triforce", "Level 1 Key Drop (Keese Entrance)", "Level 1 Key Drop (Stalfos Middle)", + "Level 1 Key Drop (Moblins)", "Level 1 Key Drop (Stalfos Water)", + "Level 1 Key Drop (Stalfos Entrance)", "Level 1 Key Drop (Wallmasters)", + ], + [ + "Level 2 Item (Magical Boomerang)", "Level 2 Map", "Level 2 Compass", "Level 2 Boss", "Level 2 Triforce", + "Level 2 Key Drop (Ropes West)", "Level 2 Key Drop (Moldorms)", + "Level 2 Key Drop (Ropes Middle)", "Level 2 Key Drop (Ropes Entrance)", + "Level 2 Bomb Drop (Keese)", "Level 2 Bomb Drop (Moblins)", + "Level 2 Rupee Drop (Gels)", + ], + [ + "Level 3 Item (Raft)", "Level 3 Map", "Level 3 Compass", "Level 3 Boss", "Level 3 Triforce", + "Level 3 Key Drop (Zols and Keese West)", "Level 3 Key Drop (Keese North)", + "Level 3 Key Drop (Zols Central)", "Level 3 Key Drop (Zols South)", + "Level 3 Key Drop (Zols Entrance)", "Level 3 Bomb Drop (Darknuts West)", + "Level 3 Bomb Drop (Keese Corridor)", "Level 3 Bomb Drop (Darknuts Central)", + "Level 3 Rupee Drop (Zols and Keese East)" + ], + [ + "Level 4 Item (Stepladder)", "Level 4 Map", "Level 4 Compass", "Level 4 Boss", "Level 4 Triforce", + "Level 4 Key Drop (Keese Entrance)", "Level 4 Key Drop (Keese Central)", + "Level 4 Key Drop (Zols)", "Level 4 Key Drop (Keese North)", + ], + [ + "Level 5 Item (Recorder)", "Level 5 Map", "Level 5 Compass", "Level 5 Boss", "Level 5 Triforce", + "Level 5 Key Drop (Keese North)", "Level 5 Key Drop (Gibdos North)", + "Level 5 Key Drop (Gibdos Central)", "Level 5 Key Drop (Pols Voice Entrance)", + "Level 5 Key Drop (Gibdos Entrance)", "Level 5 Key Drop (Gibdos, Keese, and Pols Voice)", + "Level 5 Key Drop (Zols)", "Level 5 Bomb Drop (Gibdos)", + "Level 5 Bomb Drop (Dodongos)", "Level 5 Rupee Drop (Zols)", + ], + [ + "Level 6 Item (Magical Rod)", "Level 6 Map", "Level 6 Compass", "Level 6 Boss", "Level 6 Triforce", + "Level 6 Key Drop (Wizzrobes Entrance)", "Level 6 Key Drop (Keese)", + "Level 6 Key Drop (Wizzrobes North Island)", "Level 6 Key Drop (Wizzrobes North Stream)", + "Level 6 Key Drop (Vires)", "Level 6 Bomb Drop (Wizzrobes)", + "Level 6 Rupee Drop (Wizzrobes)" + ], + [ + "Level 7 Item (Red Candle)", "Level 7 Map", "Level 7 Compass", "Level 7 Boss", "Level 7 Triforce", + "Level 7 Key Drop (Ropes)", "Level 7 Key Drop (Goriyas)", "Level 7 Key Drop (Stalfos)", + "Level 7 Key Drop (Moldorms)", "Level 7 Bomb Drop (Goriyas South)", "Level 7 Bomb Drop (Keese and Spikes)", + "Level 7 Bomb Drop (Moldorms South)", "Level 7 Bomb Drop (Moldorms North)", + "Level 7 Bomb Drop (Goriyas North)", "Level 7 Bomb Drop (Dodongos)", + "Level 7 Bomb Drop (Digdogger)", "Level 7 Rupee Drop (Goriyas Central)", + "Level 7 Rupee Drop (Dodongos)", "Level 7 Rupee Drop (Goriyas North)", + ], + [ + "Level 8 Item (Magical Key)", "Level 8 Map", "Level 8 Compass", "Level 8 Item (Book of Magic)", "Level 8 Boss", + "Level 8 Triforce", "Level 8 Key Drop (Darknuts West)", + "Level 8 Key Drop (Darknuts Far West)", "Level 8 Key Drop (Pols Voice South)", + "Level 8 Key Drop (Pols Voice and Keese)", "Level 8 Key Drop (Darknuts Central)", + "Level 8 Key Drop (Keese and Zols Entrance)", "Level 8 Bomb Drop (Darknuts North)", + "Level 8 Bomb Drop (Darknuts East)", "Level 8 Bomb Drop (Pols Voice North)", + "Level 8 Rupee Drop (Manhandla Entrance West)", "Level 8 Rupee Drop (Manhandla Entrance North)", + "Level 8 Rupee Drop (Darknuts and Gibdos)", + ], + [ + "Level 9 Item (Silver Arrow)", "Level 9 Item (Red Ring)", + "Level 9 Map", "Level 9 Compass", + "Level 9 Key Drop (Patra Southwest)", "Level 9 Key Drop (Like Likes and Zols East)", + "Level 9 Key Drop (Wizzrobes and Bubbles East)", "Level 9 Key Drop (Wizzrobes East Island)", + "Level 9 Bomb Drop (Blue Lanmolas)", "Level 9 Bomb Drop (Gels Lake)", + "Level 9 Bomb Drop (Like Likes and Zols Corridor)", "Level 9 Bomb Drop (Patra Northeast)", + "Level 9 Bomb Drop (Vires)", "Level 9 Rupee Drop (Wizzrobes West Island)", + "Level 9 Rupee Drop (Red Lanmolas)", "Level 9 Rupee Drop (Keese Southwest)", + "Level 9 Rupee Drop (Keese Central Island)", "Level 9 Rupee Drop (Wizzrobes Central)", + "Level 9 Rupee Drop (Wizzrobes North Island)", "Level 9 Rupee Drop (Gels East)" + ] +] + +all_level_locations = [location for level in level_locations for location in level] + +standard_level_locations = [location for level in level_locations for location in level if "Drop" not in location] + +shop_locations = [ + "Arrow Shop Item Left", "Arrow Shop Item Middle", "Arrow Shop Item Right", + "Candle Shop Item Left", "Candle Shop Item Middle", "Candle Shop Item Right", + "Blue Ring Shop Item Left", "Blue Ring Shop Item Middle", "Blue Ring Shop Item Right", + "Shield Shop Item Left", "Shield Shop Item Middle", "Shield Shop Item Right", + "Potion Shop Item Left", "Potion Shop Item Middle", "Potion Shop Item Right" +] + +food_locations = [ + "Level 7 Map", "Level 7 Boss", "Level 7 Triforce", "Level 7 Key Drop (Goriyas)", + "Level 7 Bomb Drop (Moldorms North)", "Level 7 Bomb Drop (Goriyas North)", + "Level 7 Bomb Drop (Dodongos)", "Level 7 Rupee Drop (Goriyas North)" +] + +floor_location_game_offsets_early = { + "Level 1 Item (Bow)": 0x7F, + "Level 1 Item (Boomerang)": 0x44, + "Level 1 Map": 0x43, + "Level 1 Compass": 0x54, + "Level 1 Boss": 0x35, + "Level 1 Triforce": 0x36, + "Level 1 Key Drop (Keese Entrance)": 0x72, + "Level 1 Key Drop (Moblins)": 0x23, + "Level 1 Key Drop (Stalfos Water)": 0x33, + "Level 1 Key Drop (Stalfos Entrance)": 0x74, + "Level 1 Key Drop (Stalfos Middle)": 0x53, + "Level 1 Key Drop (Wallmasters)": 0x45, + "Level 2 Item (Magical Boomerang)": 0x4F, + "Level 2 Map": 0x5F, + "Level 2 Compass": 0x6F, + "Level 2 Boss": 0x0E, + "Level 2 Triforce": 0x0D, + "Level 2 Key Drop (Ropes West)": 0x6C, + "Level 2 Key Drop (Moldorms)": 0x3E, + "Level 2 Key Drop (Ropes Middle)": 0x4E, + "Level 2 Key Drop (Ropes Entrance)": 0x7E, + "Level 2 Bomb Drop (Keese)": 0x3F, + "Level 2 Bomb Drop (Moblins)": 0x1E, + "Level 2 Rupee Drop (Gels)": 0x2F, + "Level 3 Item (Raft)": 0x0F, + "Level 3 Map": 0x4C, + "Level 3 Compass": 0x5A, + "Level 3 Boss": 0x4D, + "Level 3 Triforce": 0x3D, + "Level 3 Key Drop (Zols and Keese West)": 0x49, + "Level 3 Key Drop (Keese North)": 0x2A, + "Level 3 Key Drop (Zols Central)": 0x4B, + "Level 3 Key Drop (Zols South)": 0x6B, + "Level 3 Key Drop (Zols Entrance)": 0x7B, + "Level 3 Bomb Drop (Darknuts West)": 0x69, + "Level 3 Bomb Drop (Keese Corridor)": 0x4A, + "Level 3 Bomb Drop (Darknuts Central)": 0x5B, + "Level 3 Rupee Drop (Zols and Keese East)": 0x5D, + "Level 4 Item (Stepladder)": 0x60, + "Level 4 Map": 0x21, + "Level 4 Compass": 0x62, + "Level 4 Boss": 0x13, + "Level 4 Triforce": 0x03, + "Level 4 Key Drop (Keese Entrance)": 0x70, + "Level 4 Key Drop (Keese Central)": 0x51, + "Level 4 Key Drop (Zols)": 0x40, + "Level 4 Key Drop (Keese North)": 0x01, + "Level 5 Item (Recorder)": 0x04, + "Level 5 Map": 0x46, + "Level 5 Compass": 0x37, + "Level 5 Boss": 0x24, + "Level 5 Triforce": 0x14, + "Level 5 Key Drop (Keese North)": 0x16, + "Level 5 Key Drop (Gibdos North)": 0x26, + "Level 5 Key Drop (Gibdos Central)": 0x47, + "Level 5 Key Drop (Pols Voice Entrance)": 0x77, + "Level 5 Key Drop (Gibdos Entrance)": 0x66, + "Level 5 Key Drop (Gibdos, Keese, and Pols Voice)": 0x27, + "Level 5 Key Drop (Zols)": 0x55, + "Level 5 Bomb Drop (Gibdos)": 0x65, + "Level 5 Bomb Drop (Dodongos)": 0x56, + "Level 5 Rupee Drop (Zols)": 0x57, + "Level 6 Item (Magical Rod)": 0x75, + "Level 6 Map": 0x19, + "Level 6 Compass": 0x68, + "Level 6 Boss": 0x1C, + "Level 6 Triforce": 0x0C, + "Level 6 Key Drop (Wizzrobes Entrance)": 0x7A, + "Level 6 Key Drop (Keese)": 0x58, + "Level 6 Key Drop (Wizzrobes North Island)": 0x29, + "Level 6 Key Drop (Wizzrobes North Stream)": 0x1A, + "Level 6 Key Drop (Vires)": 0x2D, + "Level 6 Bomb Drop (Wizzrobes)": 0x3C, + "Level 6 Rupee Drop (Wizzrobes)": 0x28 +} + +floor_location_game_ids_early = {} +for key, value in floor_location_game_offsets_early.items(): + floor_location_game_ids_early[key] = value + Rom.first_quest_dungeon_items_early + +floor_location_game_offsets_late = { + "Level 7 Item (Red Candle)": 0x4A, + "Level 7 Map": 0x18, + "Level 7 Compass": 0x5A, + "Level 7 Boss": 0x2A, + "Level 7 Triforce": 0x2B, + "Level 7 Key Drop (Ropes)": 0x78, + "Level 7 Key Drop (Goriyas)": 0x0A, + "Level 7 Key Drop (Stalfos)": 0x6D, + "Level 7 Key Drop (Moldorms)": 0x3A, + "Level 7 Bomb Drop (Goriyas South)": 0x69, + "Level 7 Bomb Drop (Keese and Spikes)": 0x68, + "Level 7 Bomb Drop (Moldorms South)": 0x7A, + "Level 7 Bomb Drop (Moldorms North)": 0x0B, + "Level 7 Bomb Drop (Goriyas North)": 0x1B, + "Level 7 Bomb Drop (Dodongos)": 0x0C, + "Level 7 Bomb Drop (Digdogger)": 0x6C, + "Level 7 Rupee Drop (Goriyas Central)": 0x38, + "Level 7 Rupee Drop (Dodongos)": 0x58, + "Level 7 Rupee Drop (Goriyas North)": 0x09, + "Level 8 Item (Magical Key)": 0x0F, + "Level 8 Item (Book of Magic)": 0x6F, + "Level 8 Map": 0x2E, + "Level 8 Compass": 0x5F, + "Level 8 Boss": 0x3C, + "Level 8 Triforce": 0x2C, + "Level 8 Key Drop (Darknuts West)": 0x5C, + "Level 8 Key Drop (Darknuts Far West)": 0x4B, + "Level 8 Key Drop (Pols Voice South)": 0x4C, + "Level 8 Key Drop (Pols Voice and Keese)": 0x5D, + "Level 8 Key Drop (Darknuts Central)": 0x5E, + "Level 8 Key Drop (Keese and Zols Entrance)": 0x7F, + "Level 8 Bomb Drop (Darknuts North)": 0x0E, + "Level 8 Bomb Drop (Darknuts East)": 0x3F, + "Level 8 Bomb Drop (Pols Voice North)": 0x1D, + "Level 8 Rupee Drop (Manhandla Entrance West)": 0x7D, + "Level 8 Rupee Drop (Manhandla Entrance North)": 0x6E, + "Level 8 Rupee Drop (Darknuts and Gibdos)": 0x4E, + "Level 9 Item (Silver Arrow)": 0x4F, + "Level 9 Item (Red Ring)": 0x00, + "Level 9 Map": 0x27, + "Level 9 Compass": 0x35, + "Level 9 Key Drop (Patra Southwest)": 0x61, + "Level 9 Key Drop (Like Likes and Zols East)": 0x56, + "Level 9 Key Drop (Wizzrobes and Bubbles East)": 0x47, + "Level 9 Key Drop (Wizzrobes East Island)": 0x57, + "Level 9 Bomb Drop (Blue Lanmolas)": 0x11, + "Level 9 Bomb Drop (Gels Lake)": 0x23, + "Level 9 Bomb Drop (Like Likes and Zols Corridor)": 0x25, + "Level 9 Bomb Drop (Patra Northeast)": 0x16, + "Level 9 Bomb Drop (Vires)": 0x37, + "Level 9 Rupee Drop (Wizzrobes West Island)": 0x40, + "Level 9 Rupee Drop (Red Lanmolas)": 0x12, + "Level 9 Rupee Drop (Keese Southwest)": 0x62, + "Level 9 Rupee Drop (Keese Central Island)": 0x34, + "Level 9 Rupee Drop (Wizzrobes Central)": 0x44, + "Level 9 Rupee Drop (Wizzrobes North Island)": 0x15, + "Level 9 Rupee Drop (Gels East)": 0x26 +} + +floor_location_game_ids_late = {} +for key, value in floor_location_game_offsets_late.items(): + floor_location_game_ids_late[key] = value + Rom.first_quest_dungeon_items_late + +dungeon_items = {**floor_location_game_ids_early, **floor_location_game_ids_late} + +shop_location_ids = { + "Arrow Shop Item Left": 0x18637, + "Arrow Shop Item Middle": 0x18638, + "Arrow Shop Item Right": 0x18639, + "Candle Shop Item Left": 0x1863A, + "Candle Shop Item Middle": 0x1863B, + "Candle Shop Item Right": 0x1863C, + "Shield Shop Item Left": 0x1863D, + "Shield Shop Item Middle": 0x1863E, + "Shield Shop Item Right": 0x1863F, + "Blue Ring Shop Item Left": 0x18640, + "Blue Ring Shop Item Middle": 0x18641, + "Blue Ring Shop Item Right": 0x18642, + "Potion Shop Item Left": 0x1862E, + "Potion Shop Item Middle": 0x1862F, + "Potion Shop Item Right": 0x18630 +} + +shop_price_location_ids = { + "Arrow Shop Item Left": 0x18673, + "Arrow Shop Item Middle": 0x18674, + "Arrow Shop Item Right": 0x18675, + "Candle Shop Item Left": 0x18676, + "Candle Shop Item Middle": 0x18677, + "Candle Shop Item Right": 0x18678, + "Shield Shop Item Left": 0x18679, + "Shield Shop Item Middle": 0x1867A, + "Shield Shop Item Right": 0x1867B, + "Blue Ring Shop Item Left": 0x1867C, + "Blue Ring Shop Item Middle": 0x1867D, + "Blue Ring Shop Item Right": 0x1867E, + "Potion Shop Item Left": 0x1866A, + "Potion Shop Item Middle": 0x1866B, + "Potion Shop Item Right": 0x1866C +} + +secret_money_ids = { + "Secret Money 1": 0x18680, + "Secret Money 2": 0x18683, + "Secret Money 3": 0x18686 +} + +major_location_ids = { + "Starting Sword Cave": 0x18611, + "White Sword Pond": 0x18617, + "Magical Sword Grave": 0x1861A, + "Letter Cave": 0x18629, + "Take Any Item Left": 0x18613, + "Take Any Item Middle": 0x18614, + "Take Any Item Right": 0x18615, + "Armos Knights": 0x10D05, + "Ocean Heart Container": 0x1789A +} + +major_location_offsets = { + "Starting Sword Cave": 0x77, + "White Sword Pond": 0x0A, + "Magical Sword Grave": 0x21, + "Letter Cave": 0x0E, + # "Take Any Item Left": 0x7B, + # "Take Any Item Middle": 0x2C, + # "Take Any Item Right": 0x47, + "Armos Knights": 0x24, + "Ocean Heart Container": 0x5F +} + +overworld_locations = [ + "Starting Sword Cave", + "White Sword Pond", + "Magical Sword Grave", + "Letter Cave", + "Armos Knights", + "Ocean Heart Container" +] + +underworld1_locations = [*floor_location_game_offsets_early.keys()] + +underworld2_locations = [*floor_location_game_offsets_late.keys()] + +#cave_locations = ["Take Any Item Left", "Take Any Item Middle", "Take Any Item Right"] + [*shop_locations] + +location_table_base = [x for x in major_locations] + \ + [y for y in all_level_locations] + \ + [z for z in shop_locations] +location_table = {} +for i, location in enumerate(location_table_base): + location_table[location] = i + +location_ids = {**dungeon_items, **shop_location_ids, **major_location_ids} diff --git a/worlds/tloz/Options.py b/worlds/tloz/Options.py new file mode 100644 index 0000000000..96bd3e296d --- /dev/null +++ b/worlds/tloz/Options.py @@ -0,0 +1,40 @@ +import typing +from Options import Option, DefaultOnToggle, Choice + + +class ExpandedPool(DefaultOnToggle): + """Puts room clear drops and take any caves into the pool of items and locations.""" + display_name = "Expanded Item Pool" + + +class TriforceLocations(Choice): + """Where Triforce fragments can be located. Note that Triforce pieces + obtained in a dungeon will heal and warp you out, while overworld Triforce pieces obtained will appear to have + no immediate effect. This is normal.""" + display_name = "Triforce Locations" + option_vanilla = 0 + option_dungeons = 1 + option_anywhere = 2 + + +class StartingPosition(Choice): + """How easy is the start of the game. + Safe means a weapon is guaranteed in Starting Sword Cave. + Unsafe means that a weapon is guaranteed between Starting Sword Cave, Letter Cave, and Armos Knight. + Dangerous adds these level locations to the unsafe pool (if they exist): +# Level 1 Compass, Level 2 Bomb Drop (Keese), Level 3 Key Drop (Zols Entrance), Level 3 Compass + Very Dangerous is the same as dangerous except it doesn't guarantee a weapon. It will only mean progression + will be there in single player seeds. In multi worlds, however, this means all bets are off and after checking + the dangerous spots, you could be stuck until someone sends you a weapon""" + display_name = "Starting Position" + option_safe = 0 + option_unsafe = 1 + option_dangerous = 2 + option_very_dangerous = 3 + + +tloz_options: typing.Dict[str, type(Option)] = { + "ExpandedPool": ExpandedPool, + "TriforceLocations": TriforceLocations, + "StartingPosition": StartingPosition +} diff --git a/worlds/tloz/Rom.py b/worlds/tloz/Rom.py new file mode 100644 index 0000000000..0eaf5855d1 --- /dev/null +++ b/worlds/tloz/Rom.py @@ -0,0 +1,78 @@ +import zlib +import os + +import Utils +from Patch import APDeltaPatch + +NA10CHECKSUM = 'D7AE93DF' +ROM_PLAYER_LIMIT = 65535 +ROM_NAME = 0x10 +bit_positions = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80] +candle_shop = bit_positions[5] +arrow_shop = bit_positions[4] +potion_shop = bit_positions[1] +shield_shop = bit_positions[6] +ring_shop = bit_positions[7] +take_any = bit_positions[2] +first_quest_dungeon_items_early = 0x18910 +first_quest_dungeon_items_late = 0x18C10 +game_mode = 0x12 +sword = 0x0657 +bombs = 0x0658 +arrow = 0x0659 +bow = 0x065A +candle = 0x065B +recorder = 0x065C +food = 0x065D +potion = 0x065E +magical_rod = 0x065F +raft = 0x0660 +book_of_magic = 0x0661 +ring = 0x0662 +stepladder = 0x0663 +magical_key = 0x0664 +power_bracelet = 0x0665 +letter = 0x0666 +heart_containers = 0x066F +triforce_fragments = 0x0671 +boomerang = 0x0674 +magical_boomerang = 0x0675 +magical_shield = 0x0676 +rupees_to_add = 0x067D + + + + +class TLoZDeltaPatch(APDeltaPatch): + checksum = NA10CHECKSUM + hash = NA10CHECKSUM + game = "The Legend of Zelda" + patch_file_ending = ".aptloz" + result_file_ending = ".nes" + + @classmethod + def get_source_data(cls) -> bytes: + return get_base_rom_bytes() + + +def get_base_rom_bytes(file_name: str = "") -> bytes: + base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None) + if not base_rom_bytes: + file_name = get_base_rom_path(file_name) + base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb"))) + + basechecksum = str(hex(zlib.crc32(base_rom_bytes))).upper()[2:] + if NA10CHECKSUM != basechecksum: + raise Exception('Supplied Base Rom does not match known CRC-32 for NA (1.0) release. ' + 'Get the correct game and version, then dump it') + get_base_rom_bytes.base_rom_bytes = base_rom_bytes + return base_rom_bytes + + +def get_base_rom_path(file_name: str = "") -> str: + options = Utils.get_options() + if not file_name: + file_name = options["tloz_options"]["rom_file"] + if not os.path.exists(file_name): + file_name = Utils.local_path(file_name) + return file_name \ No newline at end of file diff --git a/worlds/tloz/Rules.py b/worlds/tloz/Rules.py new file mode 100644 index 0000000000..c73ea470b5 --- /dev/null +++ b/worlds/tloz/Rules.py @@ -0,0 +1,148 @@ +from typing import TYPE_CHECKING + +from ..generic.Rules import add_rule +from .Locations import food_locations, shop_locations +from .ItemPool import dangerous_weapon_locations +from .Options import StartingPosition + +if TYPE_CHECKING: + from . import TLoZWorld + +def set_rules(tloz_world: "TLoZWorld"): + player = tloz_world.player + world = tloz_world.multiworld + + # Boss events for a nicer spoiler log play through + for level in range(1, 9): + boss = world.get_location(f"Level {level} Boss", player) + boss_event = world.get_location(f"Level {level} Boss Status", player) + status = tloz_world.create_event(f"Boss {level} Defeated") + boss_event.place_locked_item(status) + add_rule(boss_event, lambda state, b=boss: state.can_reach(b, "Location", player)) + + # No dungeons without weapons except for the dangerous weapon locations if we're dangerous, no unsafe dungeons + for i, level in enumerate(tloz_world.levels[1:10]): + for location in level.locations: + if world.StartingPosition[player] < StartingPosition.option_dangerous \ + or location.name not in dangerous_weapon_locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has_group("weapons", player)) + if i > 0: # Don't need an extra heart for Level 1 + add_rule(world.get_location(location.name, player), + lambda state, hearts=i: state.has("Heart Container", player, hearts) or + (state.has("Blue Ring", player) and + state.has("Heart Container", player, int(hearts / 2))) or + (state.has("Red Ring", player) and + state.has("Heart Container", player, int(hearts / 4))) + + ) + # No requiring anything in a shop until we can farm for money + for location in shop_locations: + add_rule(world.get_location(location, player), + lambda state: state.has_group("weapons", player)) + + # Everything from 4 on up has dark rooms + for level in tloz_world.levels[4:]: + for location in level.locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has_group("candles", player) + or (state.has("Magical Rod", player) and state.has("Book", player))) + + # Everything from 5 on up has gaps + for level in tloz_world.levels[5:]: + for location in level.locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has("Stepladder", player)) + + add_rule(world.get_location("Level 5 Boss", player), + lambda state: state.has("Recorder", player)) + + add_rule(world.get_location("Level 6 Boss", player), + lambda state: state.has("Bow", player) and state.has_group("arrows", player)) + + add_rule(world.get_location("Level 7 Item (Red Candle)", player), + lambda state: state.has("Recorder", player)) + add_rule(world.get_location("Level 7 Boss", player), + lambda state: state.has("Recorder", player)) + if world.ExpandedPool[player]: + add_rule(world.get_location("Level 7 Key Drop (Stalfos)", player), + lambda state: state.has("Recorder", player)) + add_rule(world.get_location("Level 7 Bomb Drop (Digdogger)", player), + lambda state: state.has("Recorder", player)) + add_rule(world.get_location("Level 7 Rupee Drop (Dodongos)", player), + lambda state: state.has("Recorder", player)) + + for location in food_locations: + if world.ExpandedPool[player] or "Drop" not in location: + add_rule(world.get_location(location, player), + lambda state: state.has("Food", player)) + + add_rule(world.get_location("Level 8 Item (Magical Key)", player), + lambda state: state.has("Bow", player) and state.has_group("arrows", player)) + if world.ExpandedPool[player]: + add_rule(world.get_location("Level 8 Bomb Drop (Darknuts North)", player), + lambda state: state.has("Bow", player) and state.has_group("arrows", player)) + + for location in tloz_world.levels[9].locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has("Triforce Fragment", player, 8) and + state.has_group("swords", player)) + + # Yes we are looping this range again for Triforce locations. No I can't add it to the boss event loop + for level in range(1, 9): + add_rule(world.get_location(f"Level {level} Triforce", player), + lambda state, l=level: state.has(f"Boss {l} Defeated", player)) + + # Sword, raft, and ladder spots + add_rule(world.get_location("White Sword Pond", player), + lambda state: state.has("Heart Container", player, 2)) + add_rule(world.get_location("Magical Sword Grave", player), + lambda state: state.has("Heart Container", player, 9)) + + stepladder_locations = ["Ocean Heart Container", "Level 4 Triforce", "Level 4 Boss", "Level 4 Map"] + stepladder_locations_expanded = ["Level 4 Key Drop (Keese North)"] + for location in stepladder_locations: + add_rule(world.get_location(location, player), + lambda state: state.has("Stepladder", player)) + if world.ExpandedPool[player]: + for location in stepladder_locations_expanded: + add_rule(world.get_location(location, player), + lambda state: state.has("Stepladder", player)) + + # Don't allow Take Any Items until we can actually get in one + if world.ExpandedPool[player]: + add_rule(world.get_location("Take Any Item Left", player), + lambda state: state.has_group("candles", player) or + state.has("Raft", player)) + add_rule(world.get_location("Take Any Item Middle", player), + lambda state: state.has_group("candles", player) or + state.has("Raft", player)) + add_rule(world.get_location("Take Any Item Right", player), + lambda state: state.has_group("candles", player) or + state.has("Raft", player)) + for location in tloz_world.levels[4].locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has("Raft", player) or state.has("Recorder", player)) + for location in tloz_world.levels[7].locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has("Recorder", player)) + for location in tloz_world.levels[8].locations: + add_rule(world.get_location(location.name, player), + lambda state: state.has("Bow", player)) + + add_rule(world.get_location("Potion Shop Item Left", player), + lambda state: state.has("Letter", player)) + add_rule(world.get_location("Potion Shop Item Middle", player), + lambda state: state.has("Letter", player)) + add_rule(world.get_location("Potion Shop Item Right", player), + lambda state: state.has("Letter", player)) + + add_rule(world.get_location("Shield Shop Item Left", player), + lambda state: state.has_group("candles", player) or + state.has("Bomb", player)) + add_rule(world.get_location("Shield Shop Item Middle", player), + lambda state: state.has_group("candles", player) or + state.has("Bomb", player)) + add_rule(world.get_location("Shield Shop Item Right", player), + lambda state: state.has_group("candles", player) or + state.has("Bomb", player)) \ No newline at end of file diff --git a/worlds/tloz/__init__.py b/worlds/tloz/__init__.py new file mode 100644 index 0000000000..551d6588ef --- /dev/null +++ b/worlds/tloz/__init__.py @@ -0,0 +1,316 @@ +import logging +import os +import threading +import pkgutil +from typing import NamedTuple, Union, Dict, Any + +import bsdiff4 + +import Utils +from BaseClasses import Item, Location, Region, Entrance, MultiWorld, ItemClassification, Tutorial +from .ItemPool import generate_itempool, starting_weapons, dangerous_weapon_locations +from .Items import item_table, item_prices, item_game_ids +from .Locations import location_table, level_locations, major_locations, shop_locations, all_level_locations, \ + standard_level_locations, shop_price_location_ids, secret_money_ids, location_ids, food_locations +from .Options import tloz_options +from .Rom import TLoZDeltaPatch, get_base_rom_path, first_quest_dungeon_items_early, first_quest_dungeon_items_late +from .Rules import set_rules +from worlds.AutoWorld import World, WebWorld +from worlds.generic.Rules import add_rule + + +class TLoZWeb(WebWorld): + theme = "stone" + setup = Tutorial( + "Multiworld Setup Tutorial", + "A guide to setting up The Legend of Zelda for Archipelago on your computer.", + "English", + "multiworld_en.md", + "multiworld/en", + ["Rosalie and Figment"] + ) + + tutorials = [setup] + + +class TLoZWorld(World): + """ + The Legend of Zelda needs almost no introduction. Gather the eight fragments of the + Triforce of Courage, enter Death Mountain, defeat Ganon, and rescue Princess Zelda. + This randomizer shuffles all the items in the game around, leading to a new adventure + every time. + """ + option_definitions = tloz_options + game = "The Legend of Zelda" + topology_present = False + data_version = 1 + base_id = 7000 + web = TLoZWeb() + + item_name_to_id = {name: data.code for name, data in item_table.items()} + location_name_to_id = location_table + + item_name_groups = { + 'weapons': starting_weapons, + 'swords': { + "Sword", "White Sword", "Magical Sword" + }, + "candles": { + "Candle", "Red Candle" + }, + "arrows": { + "Arrow", "Silver Arrow" + } + } + + for k, v in item_name_to_id.items(): + item_name_to_id[k] = v + base_id + + for k, v in location_name_to_id.items(): + if v is not None: + location_name_to_id[k] = v + base_id + + def __init__(self, world: MultiWorld, player: int): + super().__init__(world, player) + self.generator_in_use = threading.Event() + self.rom_name_available_event = threading.Event() + self.levels = None + self.filler_items = None + + def create_item(self, name: str): + return TLoZItem(name, item_table[name].classification, self.item_name_to_id[name], self.player) + + def create_event(self, event: str): + return TLoZItem(event, ItemClassification.progression, None, self.player) + + def create_location(self, name, id, parent, event=False): + return_location = TLoZLocation(self.player, name, id, parent) + return_location.event = event + return return_location + + def create_regions(self): + menu = Region("Menu", self.player, self.multiworld) + overworld = Region("Overworld", self.player, self.multiworld) + self.levels = [None] # Yes I'm making a one-indexed array in a zero-indexed language. I hate me too. + for i in range(1, 10): + level = Region(f"Level {i}", self.player, self.multiworld) + self.levels.append(level) + new_entrance = Entrance(self.player, f"Level {i}", overworld) + new_entrance.connect(level) + overworld.exits.append(new_entrance) + self.multiworld.regions.append(level) + + for i, level in enumerate(level_locations): + for location in level: + if self.multiworld.ExpandedPool[self.player] or "Drop" not in location: + self.levels[i + 1].locations.append( + self.create_location(location, self.location_name_to_id[location], self.levels[i + 1])) + + for level in range(1, 9): + boss_event = self.create_location(f"Level {level} Boss Status", None, + self.multiworld.get_region(f"Level {level}", self.player), + True) + boss_event.show_in_spoiler = False + self.levels[level].locations.append(boss_event) + + for location in major_locations: + if self.multiworld.ExpandedPool[self.player] or "Take Any" not in location: + overworld.locations.append( + self.create_location(location, self.location_name_to_id[location], overworld)) + + for location in shop_locations: + overworld.locations.append( + self.create_location(location, self.location_name_to_id[location], overworld)) + + ganon = self.create_location("Ganon", None, self.multiworld.get_region("Level 9", self.player)) + zelda = self.create_location("Zelda", None, self.multiworld.get_region("Level 9", self.player)) + ganon.show_in_spoiler = False + zelda.show_in_spoiler = False + self.levels[9].locations.append(ganon) + self.levels[9].locations.append(zelda) + begin_game = Entrance(self.player, "Begin Game", menu) + menu.exits.append(begin_game) + begin_game.connect(overworld) + self.multiworld.regions.append(menu) + self.multiworld.regions.append(overworld) + + def create_items(self): + # refer to ItemPool.py + generate_itempool(self) + + # refer to Rules.py + set_rules = set_rules + + def generate_basic(self): + ganon = self.multiworld.get_location("Ganon", self.player) + ganon.place_locked_item(self.create_event("Triforce of Power")) + add_rule(ganon, lambda state: state.has("Silver Arrow", self.player) and state.has("Bow", self.player)) + + self.multiworld.get_location("Zelda", self.player).place_locked_item(self.create_event("Rescued Zelda!")) + add_rule(self.multiworld.get_location("Zelda", self.player), + lambda state: ganon in state.locations_checked) + self.multiworld.completion_condition[self.player] = lambda state: state.has("Rescued Zelda!", self.player) + + def apply_base_patch(self, rom): + # The base patch source is on a different repo, so here's the summary of changes: + # Remove Triforce check for recorder, so you can always warp. + # Remove level check for Triforce Fragments (and maps and compasses, but this won't matter) + # Replace some code with a jump to free space + # Check if we're picking up a Triforce Fragment. If so, increment the local count + # In either case, we do the instructions we overwrote with the jump and then return to normal flow + # Remove map/compass check so they're always on + # Removing a bit from the boss roars flags, so we can have more dungeon items. This allows us to + # go past 0x1F items for dungeon items. + base_patch_location = os.path.dirname(__file__) + "/z1_base_patch.bsdiff4" + with open(base_patch_location, "rb") as base_patch: + rom_data = bsdiff4.patch(rom.read(), base_patch.read()) + rom_data = bytearray(rom_data) + # Set every item to the new nothing value, but keep room flags. Type 2 boss roars should + # become type 1 boss roars, so we at least keep the sound of roaring where it should be. + for i in range(0, 0x7F): + item = rom_data[first_quest_dungeon_items_early + i] + if item & 0b00100000: + rom_data[first_quest_dungeon_items_early + i] = item & 0b11011111 + rom_data[first_quest_dungeon_items_early + i] = item | 0b01000000 + if item & 0b00011111 == 0b00000011: # Change all Item 03s to Item 3F, the proper "nothing" + rom_data[first_quest_dungeon_items_early + i] = item | 0b00111111 + + item = rom_data[first_quest_dungeon_items_late + i] + if item & 0b00100000: + rom_data[first_quest_dungeon_items_late + i] = item & 0b11011111 + rom_data[first_quest_dungeon_items_late + i] = item | 0b01000000 + if item & 0b00011111 == 0b00000011: + rom_data[first_quest_dungeon_items_late + i] = item | 0b00111111 + return rom_data + + def apply_randomizer(self): + with open(get_base_rom_path(), 'rb') as rom: + rom_data = self.apply_base_patch(rom) + # Write each location's new data in + for location in self.multiworld.get_filled_locations(self.player): + # Zelda and Ganon aren't real locations + if location.name == "Ganon" or location.name == "Zelda": + continue + + # Neither are boss defeat events + if "Status" in location.name: + continue + + item = location.item.name + # Remote items are always going to look like Rupees. + if location.item.player != self.player: + item = "Rupee" + + item_id = item_game_ids[item] + location_id = location_ids[location.name] + + # Shop prices need to be set + if location.name in shop_locations: + if location.name[-5:] == "Right": + # Final item in stores has bit 6 and 7 set. It's what marks the cave a shop. + item_id = item_id | 0b11000000 + price_location = shop_price_location_ids[location.name] + item_price = item_prices[item] + if item == "Rupee": + item_class = location.item.classification + if item_class == ItemClassification.progression: + item_price = item_price * 2 + elif item_class == ItemClassification.useful: + item_price = item_price // 2 + elif item_class == ItemClassification.filler: + item_price = item_price // 2 + elif item_class == ItemClassification.trap: + item_price = item_price * 2 + rom_data[price_location] = item_price + if location.name == "Take Any Item Right": + # Same story as above: bit 6 is what makes this a Take Any cave + item_id = item_id | 0b01000000 + rom_data[location_id] = item_id + + # We shuffle the tiers of rupee caves. Caves that shared a value before still will. + secret_caves = self.multiworld.per_slot_randoms[self.player].sample(sorted(secret_money_ids), 3) + secret_cave_money_amounts = [20, 50, 100] + for i, amount in enumerate(secret_cave_money_amounts): + # Giving approximately double the money to keep grinding down + amount = amount * self.multiworld.per_slot_randoms[self.player].triangular(1.5, 2.5) + secret_cave_money_amounts[i] = int(amount) + for i, cave in enumerate(secret_caves): + rom_data[secret_money_ids[cave]] = secret_cave_money_amounts[i] + return rom_data + + def generate_output(self, output_directory: str): + try: + patched_rom = self.apply_randomizer() + outfilebase = 'AP_' + self.multiworld.seed_name + outfilepname = f'_P{self.player}' + outfilepname += f"_{self.multiworld.get_file_safe_player_name(self.player).replace(' ', '_')}" + outputFilename = os.path.join(output_directory, f'{outfilebase}{outfilepname}.nes') + self.rom_name_text = f'LOZ{Utils.__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed:11}\0' + self.romName = bytearray(self.rom_name_text, 'utf8')[:0x20] + self.romName.extend([0] * (0x20 - len(self.romName))) + self.rom_name = self.romName + patched_rom[0x10:0x30] = self.romName + self.playerName = bytearray(self.multiworld.player_name[self.player], 'utf8')[:0x20] + self.playerName.extend([0] * (0x20 - len(self.playerName))) + patched_rom[0x30:0x50] = self.playerName + patched_filename = os.path.join(output_directory, outputFilename) + with open(patched_filename, 'wb') as patched_rom_file: + patched_rom_file.write(patched_rom) + patch = TLoZDeltaPatch(os.path.splitext(outputFilename)[0] + TLoZDeltaPatch.patch_file_ending, + player=self.player, + player_name=self.multiworld.player_name[self.player], + patched_path=outputFilename) + patch.write() + os.unlink(patched_filename) + finally: + self.rom_name_available_event.set() + + def modify_multidata(self, multidata: dict): + import base64 + self.rom_name_available_event.wait() + new_name = base64.b64encode(bytes(self.rom_name)).decode() + multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]] + + def get_filler_item_name(self) -> str: + if self.filler_items is None: + self.filler_items = [item for item in item_table if item_table[item].classification == ItemClassification.filler] + return self.multiworld.random.choice(self.filler_items) + + def fill_slot_data(self) -> Dict[str, Any]: + if self.multiworld.ExpandedPool[self.player]: + take_any_left = self.multiworld.get_location("Take Any Item Left", self.player).item + take_any_middle = self.multiworld.get_location("Take Any Item Middle", self.player).item + take_any_right = self.multiworld.get_location("Take Any Item Right", self.player).item + if take_any_left.player == self.player: + take_any_left = take_any_left.code + else: + take_any_left = -1 + if take_any_middle.player == self.player: + take_any_middle = take_any_middle.code + else: + take_any_middle = -1 + if take_any_right.player == self.player: + take_any_right = take_any_right.code + else: + take_any_right = -1 + + slot_data = { + "TakeAnyLeft": take_any_left, + "TakeAnyMiddle": take_any_middle, + "TakeAnyRight": take_any_right + } + else: + slot_data = { + "TakeAnyLeft": -1, + "TakeAnyMiddle": -1, + "TakeAnyRight": -1 + } + return slot_data + + +class TLoZItem(Item): + game = 'The Legend of Zelda' + + +class TLoZLocation(Location): + game = 'The Legend of Zelda' \ No newline at end of file diff --git a/worlds/tloz/docs/en_The Legend of Zelda.md b/worlds/tloz/docs/en_The Legend of Zelda.md new file mode 100644 index 0000000000..e443c9b953 --- /dev/null +++ b/worlds/tloz/docs/en_The Legend of Zelda.md @@ -0,0 +1,43 @@ +# The Legend of Zelda (NES) + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What does randomization do to this game? + +All acquirable pickups (except maps and compasses) are shuffled among each other. Logic is in place to ensure both +that the game is still completable, and that players aren't forced to enter dungeons under-geared. + +Shops can contain any item in the game, with prices added for the items unavailable in stores. Rupee caves are worth +more while shops cost less, making shop routing and money management important without requiring mindless grinding. + +## What items and locations get shuffled? + +In general, all item pickups in the game. More formally: + +- Every inventory item. +- Every item found in the five kinds of shops. +- Optionally, Triforce Fragments can be shuffled to be within dungeons, or anywhere. +- Optionally, enemy-held items and dungeon floor items can be included in the shuffle, along with their slots +- Maps and compasses have been replaced with bonus items, including Clocks and Fairies. + +## What items from The Legend of Zelda can appear in other players' worlds? + +All items can appear in other players' worlds. + +## What does another world's item look like in The Legend of Zelda? + +All local items appear as normal. All remote items, no matter the game they originate from, will take on the appearance +of a single Rupee. These single Rupees will have variable prices in shops: progression and trap items will cost more, +filler and useful items will cost less, and uncategorized items will be in the middle. + +## 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 Recorder will warp you between all eight levels regardless of Triforce count + - 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 diff --git a/worlds/tloz/docs/multiworld_en.md b/worlds/tloz/docs/multiworld_en.md new file mode 100644 index 0000000000..d3aa0afb1d --- /dev/null +++ b/worlds/tloz/docs/multiworld_en.md @@ -0,0 +1,104 @@ +# The Legend of Zelda (NES) Multiworld Setup Guide + +## Required Software + +- The Zelda1Client + - Bundled with Archipelago: [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) +- The BizHawk emulator. Versions 2.3.1 and higher are supported. Version 2.7 is recommended + - [BizHawk Official Website](http://tasvideos.org/BizHawk.html) + +## Installation Procedures + +1. Download and install the latest version of Archipelago. + - On Windows, download Setup.Archipelago..exe and run it. +2. Assign Bizhawk version 2.3.1 or higher as your default program for launching `.nes` files. + - Extract your Bizhawk folder to your Desktop, or somewhere you will remember. Below are optional additional steps + for loading ROMs more conveniently. + 1. Right-click on a ROM file and select **Open with...** + 2. Check the box next to **Always use this app to open .nes files**. + 3. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**. + 4. Browse for `EmuHawk.exe` located inside your Bizhawk folder (from step 1) and click **Open**. + +## Create a Config (.yaml) File + +### What is a config file and why do I need one? + +See the guide on setting up a basic YAML at the Archipelago setup +guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en) + +### Where do I get a config file? + +The Player Settings page on the website allows you to configure your personal settings and export a config file from +them. Player settings page: [The Legend of Zelda Player Settings Page](/games/The%20Legen%20of%20Zelda/player-settings) + +### Verifying your config file + +If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML +validator page: [YAML Validation page](/mysterycheck) + +## Generating a Single-Player Game + +1. Navigate to the Player Settings page, configure your options, and click the "Generate Game" button. + - Player Settings page: [The Legend of Zelda Player Settings Page](/games/The%20Legen%20of%20Zelda/player-settings) +2. You will be presented with a "Seed Info" page. +3. Click the "Create New Room" link. +4. You will be presented with a server page, from which you can download your patch file. +5. Double-click on your patch file, and the Zelda 1 Client will launch automatically, create your ROM from the + patch file, and open your emulator for you. +6. Since this is a single-player game, you will no longer need the client, so feel free to close it. + +## Joining a MultiWorld Game + +### Obtain your patch file and create your ROM + +When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done, +the host will provide you with either a link to download your patch file, or with a zip file containing everyone's patch +files. Your patch file should have a `.aptloz` extension. + +Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically launch the +client, and will also create your ROM in the same place as your patch file. + + +## Running the Client Program and Connecting to the Server + +Once the Archipelago server has been hosted: + +1. Navigate to your Archipelago install folder and run `ArchipelagoZelda1Client.exe`. +2. Notice the `/connect command` on the server hosting page. (It should look like `/connect archipelago.gg:*****` + where ***** are numbers) +3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should + already say `archipelago.gg`) and click `connect`. + +### Running Your Game and Connecting to the Client Program + +1. Open Bizhawk 2.3.1 or higher and load your ROM OR click your ROM file if it is already associated with the + extension `*.nes`. +2. Click on the Tools menu and click on **Lua Console**. +3. Click the folder button to open a new Lua script. (CTL-O or **Script** -> **Open Script**) +4. Navigate to the location you installed Archipelago to. Open `data/lua/TLOZ/tloz_connector.lua`. + 1. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception + close your emulator entirely, restart it and re-run these steps. + 2. If it says `Must use a version of bizhawk 2.3.1 or higher`, double-check your Bizhawk version by clicking ** + Help** -> **About**. + +## Play the game + +When the client shows both NES and server are connected, you are good to go. You can check the connection status of the +NES at any time by running `/nes`. + +### Other Client Commands + +All other commands may be found on the [Archipelago Server and Client Commands Guide.](/tutorial/Archipelago/commands/en) +. + +## Known Issues + +- Triforce Fragments and Heart Containers may be purchased multiple times. It is up to you if you wish to take advantage +of this; logic will not account for or require purchasing any slot more than once. Remote items, no matter what they +are, will always only be sent once. +- Obtaining a remote item will move the location of any existing item in that room. Should this make an item +inaccessible, simply exit and re-enter the room. This can be used to obtain the Ocean Heart Container item without the +stepladder; logic does not account for this. +- Whether you've purchased from a shop is tracked via Archipelago between sessions: if you revisit a single player game, +none of your shop pruchase statuses will be remembered. If you want them to be, connect to the client and server like +you would in a multiplayer game. \ No newline at end of file diff --git a/worlds/tloz/requirements.txt b/worlds/tloz/requirements.txt new file mode 100644 index 0000000000..d1f50ea5e9 --- /dev/null +++ b/worlds/tloz/requirements.txt @@ -0,0 +1 @@ +bsdiff4>=1.2.2 \ No newline at end of file diff --git a/worlds/tloz/z1_base_patch.bsdiff4 b/worlds/tloz/z1_base_patch.bsdiff4 new file mode 100644 index 0000000000..1231c86994 Binary files /dev/null and b/worlds/tloz/z1_base_patch.bsdiff4 differ diff --git a/worlds/wargroove/Items.py b/worlds/wargroove/Items.py new file mode 100644 index 0000000000..acb31a84b1 --- /dev/null +++ b/worlds/wargroove/Items.py @@ -0,0 +1,104 @@ +import typing + +from BaseClasses import Item, ItemClassification +from typing import Dict, List + +PROGRESSION = ItemClassification.progression +PROGRESSION_SKIP_BALANCING = ItemClassification.progression_skip_balancing +USEFUL = ItemClassification.useful +FILLER = ItemClassification.filler + + +class ItemData(typing.NamedTuple): + code: typing.Optional[int] + type: str + classification: ItemClassification = PROGRESSION + + +item_table: Dict[str, ItemData] = { + # Units + 'Spearman': ItemData(52000, 'Unit'), + 'Wagon': ItemData(52001, 'Unit', USEFUL), + 'Mage': ItemData(52002, 'Unit'), + 'Archer': ItemData(52003, 'Unit'), + 'Knight': ItemData(52004, 'Unit'), + 'Ballista': ItemData(52005, 'Unit'), + 'Golem': ItemData(52006, 'Unit', USEFUL), + 'Harpy': ItemData(52007, 'Unit'), + 'Witch': ItemData(52008, 'Unit', USEFUL), + 'Dragon': ItemData(52009, 'Unit'), + 'Balloon': ItemData(52010, 'Unit', USEFUL), + 'Barge': ItemData(52011, 'Unit'), + 'Merfolk': ItemData(52012, 'Unit'), + 'Turtle': ItemData(52013, 'Unit'), + 'Harpoon Ship': ItemData(52014, 'Unit'), + 'Warship': ItemData(52015, 'Unit'), + 'Thief': ItemData(52016, 'Unit'), + 'Rifleman': ItemData(52017, 'Unit'), + + # Map Triggers + 'Eastern Bridges': ItemData(52018, 'Trigger'), + 'Southern Walls': ItemData(52019, 'Trigger'), + 'Final Bridges': ItemData(52020, 'Trigger', PROGRESSION_SKIP_BALANCING), + 'Final Walls': ItemData(52021, 'Trigger', PROGRESSION_SKIP_BALANCING), + 'Final Sickle': ItemData(52022, 'Trigger', PROGRESSION_SKIP_BALANCING), + + # Player Buffs + 'Income Boost': ItemData(52023, 'Boost', FILLER), + + 'Commander Defense Boost': ItemData(52024, 'Boost', FILLER), + + # Factions + 'Cherrystone Commanders': ItemData(52025, 'Faction', USEFUL), + 'Felheim Commanders': ItemData(52026, 'Faction', USEFUL), + 'Floran Commanders': ItemData(52027, 'Faction', USEFUL), + 'Heavensong Commanders': ItemData(52028, 'Faction', USEFUL), + 'Requiem Commanders': ItemData(52029, 'Faction', USEFUL), + 'Outlaw Commanders': ItemData(52030, 'Faction', USEFUL), + + # Event Items + 'Wargroove Victory': ItemData(None, 'Goal') + +} + + +class CommanderData(typing.NamedTuple): + name: str + internal_name: str + alt_name: str = None + + +faction_table: Dict[str, List[CommanderData]] = { + 'Starter': [ + CommanderData('Mercival', 'commander_mercival') + ], + 'Cherrystone': [ + CommanderData('Mercia', 'commander_mercia'), + CommanderData('Emeric', 'commander_emeric'), + CommanderData('Caesar', 'commander_caesar'), + ], + 'Felheim': [ + CommanderData('Valder', 'commander_valder'), + CommanderData('Ragna', 'commander_ragna'), + CommanderData('Sigrid', 'commander_sigrid') + ], + 'Floran': [ + CommanderData('Greenfinger', 'commander_greenfinger'), + CommanderData('Sedge', 'commander_sedge'), + CommanderData('Nuru', 'commander_nuru') + ], + 'Heavensong': [ + CommanderData('Tenri', 'commander_tenri'), + CommanderData('Koji', 'commander_koji'), + CommanderData('Ryota', 'commander_ryota') + ], + 'Requiem': [ + CommanderData('Elodie', 'commander_elodie'), + CommanderData('Dark Mercia', 'commander_darkmercia') + ], + 'Outlaw': [ + CommanderData('Wulfar', 'commander_wulfar'), + CommanderData('Twins', 'commander_twins', 'Errol & Orla'), + CommanderData('Vesper', 'commander_vesper') + ] +} \ No newline at end of file diff --git a/worlds/wargroove/Locations.py b/worlds/wargroove/Locations.py new file mode 100644 index 0000000000..e9fe52a188 --- /dev/null +++ b/worlds/wargroove/Locations.py @@ -0,0 +1,41 @@ +location_table = { + 'Humble Beginnings: Caesar': 53001, + 'Humble Beginnings: Chest 1': 53002, + 'Humble Beginnings: Chest 2': 53003, + 'Humble Beginnings: Victory': 53004, + 'Best Friendssss: Find Sedge': 53005, + 'Best Friendssss: Victory': 53006, + 'A Knight\'s Folly: Caesar': 53007, + 'A Knight\'s Folly: Victory': 53008, + 'Denrunaway: Chest': 53009, + 'Denrunaway: Victory': 53010, + 'Dragon Freeway: Victory': 53011, + 'Deep Thicket: Find Sedge': 53012, + 'Deep Thicket: Victory': 53013, + 'Corrupted Inlet: Victory': 53014, + 'Mage Mayhem: Caesar': 53015, + 'Mage Mayhem: Victory': 53016, + 'Endless Knight: Victory': 53017, + 'Ambushed in the Middle: Victory (Blue)': 53018, + 'Ambushed in the Middle: Victory (Green)': 53019, + 'The Churning Sea: Victory': 53020, + 'Frigid Archery: Light the Torch': 53021, + 'Frigid Archery: Victory': 53022, + 'Archery Lessons: Chest': 53023, + 'Archery Lessons: Victory': 53024, + 'Surrounded: Caesar': 53025, + 'Surrounded: Victory': 53026, + 'Darkest Knight: Victory': 53027, + 'Robbed: Victory': 53028, + 'Open Season: Caesar': 53029, + 'Open Season: Victory': 53030, + 'Doggo Mountain: Find all the Dogs': 53031, + 'Doggo Mountain: Victory': 53032, + 'Tenri\'s Fall: Victory': 53033, + 'Master of the Lake: Victory': 53034, + 'A Ballista\'s Revenge: Victory': 53035, + 'Rebel Village: Victory (Pink)': 53036, + 'Rebel Village: Victory (Red)': 53037, + 'Foolish Canal: Victory': 53038, + 'Wargroove Finale: Victory': None, +} diff --git a/worlds/wargroove/Options.py b/worlds/wargroove/Options.py new file mode 100644 index 0000000000..c8b8b37ee1 --- /dev/null +++ b/worlds/wargroove/Options.py @@ -0,0 +1,38 @@ +import typing +from Options import Choice, Option, Range + + +class IncomeBoost(Range): + """How much extra income the player gets per turn per boost received.""" + display_name = "Income Boost" + range_start = 0 + range_end = 100 + default = 25 + + +class CommanderDefenseBoost(Range): + """How much extra defense the player's commander gets per boost received.""" + display_name = "Commander Defense Boost" + range_start = 0 + range_end = 8 + default = 2 + + +class CommanderChoice(Choice): + """How the player's commander is selected for missions. + Locked Random: The player's commander is randomly predetermined for each level. + Unlockable Factions: The player starts with Mercival and can unlock playable factions. + Random Starting Faction: The player starts with a random starting faction and can unlock the rest. + When playing with unlockable factions, faction items are added to the pool. + Extra faction items after the first also reward starting Groove charge.""" + display_name = "Commander Choice" + option_locked_random = 0 + option_unlockable_factions = 1 + option_random_starting_faction = 2 + + +wargroove_options: typing.Dict[str, type(Option)] = { + "income_boost": IncomeBoost, + "commander_defense_boost": CommanderDefenseBoost, + "commander_choice": CommanderChoice +} diff --git a/worlds/wargroove/Regions.py b/worlds/wargroove/Regions.py new file mode 100644 index 0000000000..02f5ab879b --- /dev/null +++ b/worlds/wargroove/Regions.py @@ -0,0 +1,169 @@ +def create_regions(world, player: int): + from . import create_region + from .Locations import location_table + + world.regions += [ + create_region(world, player, 'Menu', None, ['Humble Beginnings']), + # Level 1 + create_region(world, player, 'Humble Beginnings', [ + 'Humble Beginnings: Caesar', + 'Humble Beginnings: Chest 1', + 'Humble Beginnings: Chest 2', + 'Humble Beginnings: Victory', + ], ['Best Friendssss', 'A Knight\'s Folly', 'Denrunaway', 'Wargroove Finale']), + + # Levels 2A-2C + create_region(world, player, 'Best Friendssss', [ + 'Best Friendssss: Find Sedge', + 'Best Friendssss: Victory' + ], ['Dragon Freeway', 'Deep Thicket', 'Corrupted Inlet']), + + create_region(world, player, 'A Knight\'s Folly', [ + 'A Knight\'s Folly: Caesar', + 'A Knight\'s Folly: Victory' + ], ['Mage Mayhem', 'Endless Knight', 'Ambushed in the Middle']), + + create_region(world, player, 'Denrunaway', [ + 'Denrunaway: Chest', + 'Denrunaway: Victory' + ], ['The Churning Sea', 'Frigid Archery', 'Archery Lessons']), + + # Levels 3AA-3AC + create_region(world, player, 'Dragon Freeway', [ + 'Dragon Freeway: Victory', + ], ['Surrounded']), + + create_region(world, player, 'Deep Thicket', [ + 'Deep Thicket: Find Sedge', + 'Deep Thicket: Victory', + ], ['Darkest Knight']), + + create_region(world, player, 'Corrupted Inlet', [ + 'Corrupted Inlet: Victory', + ], ['Robbed']), + + # Levels 3BA-3BC + create_region(world, player, 'Mage Mayhem', [ + 'Mage Mayhem: Caesar', + 'Mage Mayhem: Victory', + ], ['Open Season', 'Foolish Canal: Mage Mayhem Entrance']), + + create_region(world, player, 'Endless Knight', [ + 'Endless Knight: Victory', + ], ['Doggo Mountain', 'Foolish Canal: Endless Knight Entrance']), + + create_region(world, player, 'Ambushed in the Middle', [ + 'Ambushed in the Middle: Victory (Blue)', + 'Ambushed in the Middle: Victory (Green)', + ], ['Tenri\'s Fall']), + + # Levels 3CA-3CC + create_region(world, player, 'The Churning Sea', [ + 'The Churning Sea: Victory', + ], ['Rebel Village']), + + create_region(world, player, 'Frigid Archery', [ + 'Frigid Archery: Light the Torch', + 'Frigid Archery: Victory', + ], ['A Ballista\'s Revenge']), + + create_region(world, player, 'Archery Lessons', [ + 'Archery Lessons: Chest', + 'Archery Lessons: Victory', + ], ['Master of the Lake']), + + # Levels 4AA-4AC + create_region(world, player, 'Surrounded', [ + 'Surrounded: Caesar', + 'Surrounded: Victory', + ]), + + create_region(world, player, 'Darkest Knight', [ + 'Darkest Knight: Victory', + ]), + + create_region(world, player, 'Robbed', [ + 'Robbed: Victory', + ]), + + # Levels 4BAA-4BCA + create_region(world, player, 'Open Season', [ + 'Open Season: Caesar', + 'Open Season: Victory', + ]), + + create_region(world, player, 'Doggo Mountain', [ + 'Doggo Mountain: Find all the Dogs', + 'Doggo Mountain: Victory', + ]), + + create_region(world, player, 'Tenri\'s Fall', [ + 'Tenri\'s Fall: Victory', + ]), + + # Level 4BAB + create_region(world, player, 'Foolish Canal', [ + 'Foolish Canal: Victory', + ]), + + # Levels 4CA-4CC + create_region(world, player, 'Master of the Lake', [ + 'Master of the Lake: Victory', + ]), + + create_region(world, player, 'A Ballista\'s Revenge', [ + 'A Ballista\'s Revenge: Victory', + ]), + + create_region(world, player, 'Rebel Village', [ + 'Rebel Village: Victory (Pink)', + 'Rebel Village: Victory (Red)', + ]), + + # Final Level + create_region(world, player, 'Wargroove Finale', [ + 'Wargroove Finale: Victory' + ]), + ] + + # link up our regions with the entrances + world.get_entrance('Humble Beginnings', player).connect(world.get_region('Humble Beginnings', player)) + world.get_entrance('Best Friendssss', player).connect(world.get_region('Best Friendssss', player)) + world.get_entrance('A Knight\'s Folly', player).connect(world.get_region('A Knight\'s Folly', player)) + world.get_entrance('Denrunaway', player).connect(world.get_region('Denrunaway', player)) + world.get_entrance('Wargroove Finale', player).connect(world.get_region('Wargroove Finale', player)) + + world.get_entrance('Dragon Freeway', player).connect(world.get_region('Dragon Freeway', player)) + world.get_entrance('Deep Thicket', player).connect(world.get_region('Deep Thicket', player)) + world.get_entrance('Corrupted Inlet', player).connect(world.get_region('Corrupted Inlet', player)) + + world.get_entrance('Mage Mayhem', player).connect(world.get_region('Mage Mayhem', player)) + world.get_entrance('Endless Knight', player).connect(world.get_region('Endless Knight', player)) + world.get_entrance('Ambushed in the Middle', player).connect(world.get_region('Ambushed in the Middle', player)) + + world.get_entrance('The Churning Sea', player).connect(world.get_region('The Churning Sea', player)) + world.get_entrance('Frigid Archery', player).connect(world.get_region('Frigid Archery', player)) + world.get_entrance('Archery Lessons', player).connect(world.get_region('Archery Lessons', player)) + + world.get_entrance('Surrounded', player).connect(world.get_region('Surrounded', player)) + + world.get_entrance('Darkest Knight', player).connect(world.get_region('Darkest Knight', player)) + + world.get_entrance('Robbed', player).connect(world.get_region('Robbed', player)) + + world.get_entrance('Open Season', player).connect(world.get_region('Open Season', player)) + + world.get_entrance('Doggo Mountain', player).connect(world.get_region('Doggo Mountain', player)) + + world.get_entrance('Tenri\'s Fall', player).connect(world.get_region('Tenri\'s Fall', player)) + + world.get_entrance('Foolish Canal: Mage Mayhem Entrance', player).connect(world.get_region('Foolish Canal', player)) + world.get_entrance('Foolish Canal: Endless Knight Entrance', player).connect( + world.get_region('Foolish Canal', player) + ) + + world.get_entrance('Master of the Lake', player).connect(world.get_region('Master of the Lake', player)) + + world.get_entrance('A Ballista\'s Revenge', player).connect(world.get_region('A Ballista\'s Revenge', player)) + + world.get_entrance('Rebel Village', player).connect(world.get_region('Rebel Village', player)) diff --git a/worlds/wargroove/Rules.py b/worlds/wargroove/Rules.py new file mode 100644 index 0000000000..e163377393 --- /dev/null +++ b/worlds/wargroove/Rules.py @@ -0,0 +1,161 @@ +from typing import List + +from BaseClasses import MultiWorld, Region, Location +from ..AutoWorld import LogicMixin +from ..generic.Rules import set_rule + + +class WargrooveLogic(LogicMixin): + def _wargroove_has_item(self, player: int, item: str) -> bool: + return self.has(item, player) + + def _wargroove_has_region(self, player: int, region: str) -> bool: + return self.can_reach(region, 'Region', player) + + def _wargroove_has_item_and_region(self, player: int, item: str, region: str) -> bool: + return self.can_reach(region, 'Region', player) and self.has(item, player) + + +def set_rules(world: MultiWorld, player: int): + # Final Level + set_rule(world.get_location('Wargroove Finale: Victory', player), + lambda state: state._wargroove_has_item(player, "Final Bridges") and + state._wargroove_has_item(player, "Final Walls") and + state._wargroove_has_item(player, "Final Sickle")) + # Level 1 + set_rule(world.get_location('Humble Beginnings: Caesar', player), lambda state: True) + set_rule(world.get_location('Humble Beginnings: Chest 1', player), lambda state: True) + set_rule(world.get_location('Humble Beginnings: Chest 2', player), lambda state: True) + set_rule(world.get_location('Humble Beginnings: Victory', player), lambda state: True) + set_region_exit_rules(world.get_region('Humble Beginnings', player), + [world.get_location('Humble Beginnings: Victory', player)]) + + # Levels 2A-2C + set_rule(world.get_location('Best Friendssss: Find Sedge', player), lambda state: True) + set_rule(world.get_location('Best Friendssss: Victory', player), lambda state: True) + set_region_exit_rules(world.get_region('Best Friendssss', player), + [world.get_location('Best Friendssss: Victory', player)]) + + set_rule(world.get_location('A Knight\'s Folly: Caesar', player), lambda state: True) + set_rule(world.get_location('A Knight\'s Folly: Victory', player), lambda state: True) + set_region_exit_rules(world.get_region('A Knight\'s Folly', player), + [world.get_location('A Knight\'s Folly: Victory', player)]) + + set_rule(world.get_location('Denrunaway: Chest', player), lambda state: True) + set_rule(world.get_location('Denrunaway: Victory', player), lambda state: True) + set_region_exit_rules(world.get_region('Denrunaway', player), [world.get_location('Denrunaway: Victory', player)]) + + # Levels 3AA-3AC + set_rule(world.get_location('Dragon Freeway: Victory', player), + lambda state: state._wargroove_has_item(player, 'Mage')) + set_region_exit_rules(world.get_region('Dragon Freeway', player), + [world.get_location('Dragon Freeway: Victory', player)]) + + set_rule(world.get_location('Deep Thicket: Find Sedge', player), + lambda state: state._wargroove_has_item(player, 'Mage')) + set_rule(world.get_location('Deep Thicket: Victory', player), + lambda state: state._wargroove_has_item(player, 'Mage')) + set_region_exit_rules(world.get_region('Deep Thicket', player), + [world.get_location('Deep Thicket: Victory', player)]) + + set_rule(world.get_location('Corrupted Inlet: Victory', player), + lambda state: state._wargroove_has_item(player, 'Barge') or + state._wargroove_has_item(player, 'Merfolk') or + state._wargroove_has_item(player, 'Warship')) + set_region_exit_rules(world.get_region('Corrupted Inlet', player), + [world.get_location('Corrupted Inlet: Victory', player)]) + + # Levels 3BA-3BC + set_rule(world.get_location('Mage Mayhem: Caesar', player), + lambda state: state._wargroove_has_item(player, 'Harpy') or state._wargroove_has_item(player, 'Dragon')) + set_rule(world.get_location('Mage Mayhem: Victory', player), + lambda state: state._wargroove_has_item(player, 'Harpy') or state._wargroove_has_item(player, 'Dragon')) + set_region_exit_rules(world.get_region('Mage Mayhem', player), [world.get_location('Mage Mayhem: Victory', player)]) + + set_rule(world.get_location('Endless Knight: Victory', player), + lambda state: state._wargroove_has_item(player, 'Eastern Bridges') and ( + state._wargroove_has_item(player, 'Spearman') or + state._wargroove_has_item(player, 'Harpy') or + state._wargroove_has_item(player, 'Dragon'))) + set_region_exit_rules(world.get_region('Endless Knight', player), + [world.get_location('Endless Knight: Victory', player)]) + + set_rule(world.get_location('Ambushed in the Middle: Victory (Blue)', player), + lambda state: state._wargroove_has_item(player, 'Spearman')) + set_rule(world.get_location('Ambushed in the Middle: Victory (Green)', player), + lambda state: state._wargroove_has_item(player, 'Spearman')) + set_region_exit_rules(world.get_region('Ambushed in the Middle', player), + [world.get_location('Ambushed in the Middle: Victory (Blue)', player), + world.get_location('Ambushed in the Middle: Victory (Green)', player)]) + + # Levels 3CA-3CC + set_rule(world.get_location('The Churning Sea: Victory', player), + lambda state: (state._wargroove_has_item(player, 'Merfolk') or state._wargroove_has_item(player, 'Turtle')) + and state._wargroove_has_item(player, 'Harpoon Ship')) + set_region_exit_rules(world.get_region('The Churning Sea', player), + [world.get_location('The Churning Sea: Victory', player)]) + + set_rule(world.get_location('Frigid Archery: Light the Torch', player), + lambda state: state._wargroove_has_item(player, 'Archer') and + state._wargroove_has_item(player, 'Southern Walls')) + set_rule(world.get_location('Frigid Archery: Victory', player), + lambda state: state._wargroove_has_item(player, 'Archer')) + set_region_exit_rules(world.get_region('Frigid Archery', player), + [world.get_location('Frigid Archery: Victory', player)]) + + set_rule(world.get_location('Archery Lessons: Chest', player), + lambda state: state._wargroove_has_item(player, 'Knight') and + state._wargroove_has_item(player, 'Southern Walls')) + set_rule(world.get_location('Archery Lessons: Victory', player), + lambda state: state._wargroove_has_item(player, 'Knight') and + state._wargroove_has_item(player, 'Southern Walls')) + set_region_exit_rules(world.get_region('Archery Lessons', player), + [world.get_location('Archery Lessons: Victory', player)]) + + # Levels 4AA-4AC + set_rule(world.get_location('Surrounded: Caesar', player), + lambda state: state._wargroove_has_item_and_region(player, 'Knight', 'Surrounded')) + set_rule(world.get_location('Surrounded: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Knight', 'Surrounded')) + set_rule(world.get_location('Darkest Knight: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Spearman', 'Darkest Knight')) + set_rule(world.get_location('Robbed: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Thief', 'Robbed') and + state._wargroove_has_item(player, 'Rifleman')) + + # Levels 4BA-4BC + set_rule(world.get_location('Open Season: Caesar', player), + lambda state: state._wargroove_has_item_and_region(player, 'Mage', 'Open Season') and + state._wargroove_has_item(player, 'Knight')) + set_rule(world.get_location('Open Season: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Mage', 'Open Season') and + state._wargroove_has_item(player, 'Knight')) + set_rule(world.get_location('Doggo Mountain: Find all the Dogs', player), + lambda state: state._wargroove_has_item_and_region(player, 'Knight', 'Doggo Mountain')) + set_rule(world.get_location('Doggo Mountain: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Knight', 'Doggo Mountain')) + set_rule(world.get_location('Tenri\'s Fall: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Mage', 'Tenri\'s Fall') and + state._wargroove_has_item(player, 'Thief')) + set_rule(world.get_location('Foolish Canal: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Mage', 'Foolish Canal') and + state._wargroove_has_item(player, 'Spearman')) + + # Levels 4CA-4CC + set_rule(world.get_location('Master of the Lake: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Warship', 'Master of the Lake')) + set_rule(world.get_location('A Ballista\'s Revenge: Victory', player), + lambda state: state._wargroove_has_item_and_region(player, 'Ballista', 'A Ballista\'s Revenge')) + set_rule(world.get_location('Rebel Village: Victory (Pink)', player), + lambda state: state._wargroove_has_item_and_region(player, 'Spearman', 'Rebel Village')) + set_rule(world.get_location('Rebel Village: Victory (Red)', player), + lambda state: state._wargroove_has_item_and_region(player, 'Spearman', 'Rebel Village')) + + +def set_region_exit_rules(region: Region, locations: List[Location], operator: str = "or"): + if operator == "or": + exit_rule = lambda state: any(location.access_rule(state) for location in locations) + else: + exit_rule = lambda state: all(location.access_rule(state) for location in locations) + for region_exit in region.exits: + region_exit.access_rule = exit_rule diff --git a/worlds/wargroove/Wargroove.kv b/worlds/wargroove/Wargroove.kv new file mode 100644 index 0000000000..9609684a42 --- /dev/null +++ b/worlds/wargroove/Wargroove.kv @@ -0,0 +1,28 @@ +: + orientation: 'vertical' + padding: [10,5,10,5] + size_hint_y: 0.14 + +: + orientation: 'horizontal' + +: + text_size: self.size + size_hint: (None, 0.8) + width: 100 + markup: True + halign: 'center' + valign: 'middle' + padding_x: 5 + outline_width: 1 + disabled: True + on_release: setattr(self, 'state', 'down') + +: + orientation: 'horizontal' + padding_y: 5 + +: + size_hint_x: None + size: self.texture_size + pos_hint: {'left': 1} \ No newline at end of file diff --git a/worlds/wargroove/__init__.py b/worlds/wargroove/__init__.py new file mode 100644 index 0000000000..ca387c4142 --- /dev/null +++ b/worlds/wargroove/__init__.py @@ -0,0 +1,139 @@ +import os +import string +import json + +from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification +from .Items import item_table, faction_table +from .Locations import location_table +from .Regions import create_regions +from .Rules import set_rules +from ..AutoWorld import World, WebWorld +from .Options import wargroove_options + + +class WargrooveWeb(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up Wargroove for Archipelago.", + "English", + "wargroove_en.md", + "wargroove/en", + ["Fly Sniper"] + )] + + +class WargrooveWorld(World): + """ + Command an army, in this retro style turn based strategy game! + """ + + option_definitions = wargroove_options + game = "Wargroove" + topology_present = True + data_version = 1 + web = WargrooveWeb() + + item_name_to_id = {name: data.code for name, data in item_table.items()} + location_name_to_id = location_table + + def _get_slot_data(self): + return { + 'seed': "".join(self.multiworld.per_slot_randoms[self.player].choice(string.ascii_letters) for i in range(16)), + 'income_boost': self.multiworld.income_boost[self.player], + 'commander_defense_boost': self.multiworld.commander_defense_boost[self.player], + 'can_choose_commander': self.multiworld.commander_choice[self.player] != 0, + 'starting_groove_multiplier': 20 # Backwards compatibility in case this ever becomes an option + } + + def generate_early(self): + # Selecting a random starting faction + if self.multiworld.commander_choice[self.player] == 2: + factions = [faction for faction in faction_table.keys() if faction != "Starter"] + starting_faction = WargrooveItem(self.multiworld.random.choice(factions) + ' Commanders', self.player) + self.multiworld.push_precollected(starting_faction) + + def generate_basic(self): + # Fill out our pool with our items from the item table + pool = [] + precollected_item_names = {item.name for item in self.multiworld.precollected_items[self.player]} + ignore_faction_items = self.multiworld.commander_choice[self.player] == 0 + for name, data in item_table.items(): + if data.code is not None and name not in precollected_item_names and not data.classification == ItemClassification.filler: + if name.endswith(' Commanders') and ignore_faction_items: + continue + item = WargrooveItem(name, self.player) + pool.append(item) + + # Matching number of unfilled locations with filler items + locations_remaining = len(location_table) - 1 - len(pool) + while locations_remaining > 0: + # Filling the pool equally with both types of filler items + pool.append(WargrooveItem("Commander Defense Boost", self.player)) + locations_remaining -= 1 + if locations_remaining > 0: + pool.append(WargrooveItem("Income Boost", self.player)) + locations_remaining -= 1 + + self.multiworld.itempool += pool + + # Placing victory event at final location + victory = WargrooveItem("Wargroove Victory", self.player) + self.multiworld.get_location("Wargroove Finale: Victory", self.player).place_locked_item(victory) + + self.multiworld.completion_condition[self.player] = lambda state: state.has("Wargroove Victory", self.player) + + def set_rules(self): + set_rules(self.multiworld, self.player) + + def create_item(self, name: str) -> Item: + return WargrooveItem(name, self.player) + + def create_regions(self): + create_regions(self.multiworld, self.player) + + def fill_slot_data(self) -> dict: + slot_data = self._get_slot_data() + for option_name in wargroove_options: + option = getattr(self.multiworld, option_name)[self.player] + slot_data[option_name] = int(option.value) + return slot_data + + def get_filler_item_name(self) -> str: + return self.multiworld.random.choice(["Commander Defense Boost", "Income Boost"]) + + +def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None): + ret = Region(name, player, world) + if locations: + for location in locations: + loc_id = location_table.get(location, 0) + location = WargrooveLocation(player, location, loc_id, ret) + ret.locations.append(location) + if exits: + for exit in exits: + ret.exits.append(Entrance(player, exit, ret)) + + return ret + + +class WargrooveLocation(Location): + game: str = "Wargroove" + + def __init__(self, player: int, name: str, address=None, parent=None): + super(WargrooveLocation, self).__init__(player, name, address, parent) + if address is None: + self.event = True + self.locked = True + + +class WargrooveItem(Item): + game = "Wargroove" + + def __init__(self, name, player: int = None): + item_data = item_table[name] + super(WargrooveItem, self).__init__( + name, + item_data.classification, + item_data.code, + player + ) diff --git a/worlds/wargroove/data/mods/ArchipelagoMod/maps.dat b/worlds/wargroove/data/mods/ArchipelagoMod/maps.dat new file mode 100644 index 0000000000..3e1aeae247 Binary files /dev/null and b/worlds/wargroove/data/mods/ArchipelagoMod/maps.dat differ diff --git a/worlds/wargroove/data/mods/ArchipelagoMod/mod.dat b/worlds/wargroove/data/mods/ArchipelagoMod/mod.dat new file mode 100644 index 0000000000..8b136eddb6 Binary files /dev/null and b/worlds/wargroove/data/mods/ArchipelagoMod/mod.dat differ diff --git a/worlds/wargroove/data/mods/ArchipelagoMod/modAssets.dat b/worlds/wargroove/data/mods/ArchipelagoMod/modAssets.dat new file mode 100644 index 0000000000..37dc5421ba Binary files /dev/null and b/worlds/wargroove/data/mods/ArchipelagoMod/modAssets.dat differ diff --git a/worlds/wargroove/data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp b/worlds/wargroove/data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp new file mode 100644 index 0000000000..8525595c65 Binary files /dev/null and b/worlds/wargroove/data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp differ diff --git a/worlds/wargroove/data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak b/worlds/wargroove/data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak new file mode 100644 index 0000000000..815d57726f Binary files /dev/null and b/worlds/wargroove/data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak differ diff --git a/worlds/wargroove/docs/en_Wargroove.md b/worlds/wargroove/docs/en_Wargroove.md new file mode 100644 index 0000000000..18474a4269 --- /dev/null +++ b/worlds/wargroove/docs/en_Wargroove.md @@ -0,0 +1,34 @@ +# Wargroove (Steam, Windows) + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What does randomization do to this game? + +This randomizer shuffles units, map events, factions and boosts. It features a custom, non-linear campaign where the +final level and 3 branching paths are all available to the player from the start. The player cannot beat the final level +without specific items scattered throughout the branching paths. Certain levels on these paths may require +specific units or items in order to progress. + +## What items and locations get shuffled? + +1. Every buildable unit in the game (except for soldiers and dogs, which are free). +2. Commanders available to certain factions. If the player acquires the Floran Commanders, they can select any commander +from that faction. +3. Income and Commander Defense boosts that provide the player with extra income or extra commander defense. +4. Special map events like the Eastern Bridges or the Southern Walls, which unlock certain locations in certain levels. + +## Which items can be in another player's world? + +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 +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`. diff --git a/worlds/wargroove/docs/wargroove_en.md b/worlds/wargroove/docs/wargroove_en.md new file mode 100644 index 0000000000..121e8c0890 --- /dev/null +++ b/worlds/wargroove/docs/wargroove_en.md @@ -0,0 +1,83 @@ +# Wargroove Setup Guide + +## Required Files + +- Wargroove with the Double Trouble DLC installed through Steam on Windows + - Only the Steam Windows version is supported. MAC, Switch, Xbox, and Playstation are not supported. +- [The most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases) + +## Backup playerProgress files +`playerProgress` and `playerProgress.bak` contain save data for all of your Wargroove campaigns. Backing up these files +is strongly recommended in case they become corrupted. +1. Type `%appdata%\Chucklefish\Wargroove\save` in the file browser and hit enter. +2. Copy the `playerProgress` and `playerProgress.bak` files and paste them into a backup directory. + +## Update host.yaml to include the Wargroove root directory + +1. Look for your Archipelago install files. By default, the installer puts them in `C:\ProgramData\Archipelago`. +2. Open the `host.yaml` file in your favorite text editor (Notepad will work). +3. Put your Wargroove root directory in the `root_directory:` under the `wargroove_options:` section. + - The Wargroove root directory can be found by going to + `Steam->Right Click Wargroove->Properties->Local Files->Browse Local Files` and copying the path in the address bar. + - Paste the path in between the quotes next to `root_directory:` in the `host.yaml`. + - You may have to replace all single \\ with \\\\. +4. Start the Wargroove client. + +## Installing the Archipelago Wargroove Mod and Campaign files + +1. Shut down Wargroove if it is open. +2. Start the ArchipelagoWargrooveClient.exe from the Archipelago installation. +This should install the mod and campaign for you. +3. Start Wargroove. + +## Verify the campaign can be loaded + +1. Start Wargroove from Steam. +2. Go to `Story->Campaign->Custom->Archipelago` and click play. You should see the first level. + +## Starting a Multiworld game + +1. Start the Wargroove Client and connect to the server. Enter your username from your +[settings file.](/games/Wargroove/player-settings) +2. Start Wargroove and play the Archipelago campaign by going to `Story->Campaign->Custom->Archipelago`. + +## Ending a Multiworld game +It is strongly recommended that you delete your campaign progress after finishing a multiworld game. +This can be done by going to the level selection screen in the Archipelago campaign, hitting `ESC` and clicking the +`Delete Progress` button. The main menu should now be visible. + +## Updating to a new version of the Wargroove mod or downloading new campaign files +First, delete your campaign progress by going to the level selection screen in the Archipelago campaign, +hitting `ESC` and clicking the `Delete Progress` button. + +Follow the `Installing the Archipelago Wargroove Mod and Campaign files` steps again, but look for the latest version + to download. In addition, follow the steps outlined in `Wargroove crashes when trying to run the Archipelago campaign` +when attempting to update the campaign files and the mod. + +## Troubleshooting + +### The game is too hard +`Go to the campaign overview screen->Hit escape on the keyboard->Click adjust difficulty->Adjust the setttings` + +### The mod doesn't load +Double-check the mod installation under `%appdata%\Chucklefish\Wargroove\mods`. There should be 3 `.dat` files in +`%appdata%\Chucklefish\Wargroove\mods\ArchipelagoMod`. Otherwise, follow +`Installing the Archipelago Wargroove Mod and Campaign files` steps once more. + +### Wargroove crashes or there is a lua error +Wargroove is finicky, but there could be several causes for this. If it happens often or can be reproduced, +please submit a bug report in the tech-support channel on the [discord](https://discord.gg/archipelago). + +### Wargroove crashes when trying to run the Archipelago campaign +This is caused by not deleting campaign progress before updating the mod and campaign files. +1. Go to `Custom Content->Create->Campaign->Archipelago->Edit` and attempt to update the mod. +2. Wargroove will give an error message. +3. Go back to `Custom Content->Create->Campaign->Archipelago->Edit` and attempt to update the mod again. +4. Wargroove crashes. +5. Go back to `Custom Content->Create->Campaign->Archipelago->Edit` and attempt to update the mod again. +6. In the edit menu, hit `ESC` and click `Delete Progress`. +7. If the above steps do not allow you to start the campaign from `Story->Campaign->Custom->Archipelago` replace +`playerProgress` and `playerProgress.bak` with your previously backed up files. + +### Mod is out of date when trying to run the Archipelago campaign +Please follow the above steps in `Wargroove crashes when trying to run the Archipelago campaign`. \ No newline at end of file diff --git a/worlds/witness/Options.py b/worlds/witness/Options.py index e4e7e33faa..2047eb9ca6 100644 --- a/worlds/witness/Options.py +++ b/worlds/witness/Options.py @@ -158,6 +158,12 @@ class HintAmount(Range): default = 10 +class DeathLink(Toggle): + """If on: Whenever you fail a puzzle (with some exceptions), everyone who is also on Death Link dies. + The effect of a "death" in The Witness is a Power Surge.""" + display_name = "Death Link" + + the_witness_options: Dict[str, type] = { "puzzle_randomization": PuzzleRandomization, "shuffle_symbols": ShuffleSymbols, @@ -176,6 +182,7 @@ the_witness_options: Dict[str, type] = { "trap_percentage": TrapPercentage, "puzzle_skip_amount": PuzzleSkipAmount, "hint_amount": HintAmount, + "death_link": DeathLink, } diff --git a/worlds/witness/WitnessLogic.txt b/worlds/witness/WitnessLogic.txt index 308cba385f..329cdf3ce8 100644 --- a/worlds/witness/WitnessLogic.txt +++ b/worlds/witness/WitnessLogic.txt @@ -14,7 +14,7 @@ Tutorial (Tutorial) - Outside Tutorial - 0x03629: 158005 - 0x0A3B5 (Back Left) - True - True 158006 - 0x0A3B2 (Back Right) - True - True 158007 - 0x03629 (Gate Open) - 0x002C2 & 0x0A3B5 & 0x0A3B2 - True -158008 - 0x03505 (Gate Close) - 0x2FAF6 - True +158008 - 0x03505 (Gate Close) - 0x2FAF6 & 0x03629 - True 158009 - 0x0C335 (Pillar) - True - Triangles 158010 - 0x0C373 (Patio Floor) - 0x0C335 - Dots 159512 - 0x33530 (Cloud EP) - True - True @@ -411,7 +411,7 @@ Keep Tower (Keep) - Keep - 0x04F8F: 158206 - 0x0361B (Tower Shortcut Panel) - True - True Door - 0x04F8F (Tower Shortcut) - 0x0361B 158704 - 0x0360E (Laser Panel Hedges) - 0x01A0F & 0x019E7 & 0x019DC & 0x00139 - True -158705 - 0x03317 (Laser Panel Pressure Plates) - 0x01D3F - Shapers & Black/White Squares & Colored Squares & Stars & Stars + Same Colored Symbol & Dots +158705 - 0x03317 (Laser Panel Pressure Plates) - 0x033EA & 0x01BE9 & 0x01CD3 & 0x01D3F - Shapers & Black/White Squares & Colored Squares & Stars & Stars + Same Colored Symbol & Dots Laser - 0x014BB (Laser) - 0x0360E | 0x03317 159240 - 0x033BE (Pressure Plates 1 EP) - 0x033EA - True 159241 - 0x033BF (Pressure Plates 2 EP) - 0x01BE9 - True @@ -909,7 +909,7 @@ Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 158430 - 0x09FD8 (Near Row 5) - 0x09FD7 - Colored Squares & Symmetry & Colored Dots Door - 0x09FFB (Staircase Near) - 0x09FD8 -Mountain Floor 2 Blue Bridge (Mountain Floor 2) - Mountain Floor 2 Beyond Bridge - TrueOneWay - Mountain Floor 2 At Door - TrueOneWay: +Mountain Floor 2 Blue Bridge (Mountain Floor 2) - Mountain Floor 2 Beyond Bridge - TrueOneWay - Mountain Floor 2 At Door - 0x09ED8: Mountain Floor 2 At Door (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD: Door - 0x09EDD (Elevator Room Entry) - 0x09ED8 & 0x09E86 @@ -1113,8 +1113,9 @@ Obelisks (EPs) - Entry - True: 159735 - 0xFFE35 (River Obelisk Side 6) - 0x035CB & 0x035CF - True 159740 - 0xFFE40 (Quarry Obelisk Side 1) - 0x28A7B & 0x005F6 & 0x00859 & 0x17CB9 & 0x28A4A - True 159741 - 0xFFE41 (Quarry Obelisk Side 2) - 0x334B6 & 0x00614 & 0x0069D & 0x28A4C - True -159742 - 0xFFE42 (Quarry Obelisk Side 3) - 0x289CF & 0x289D1 & 0x33692 - True -159743 - 0xFFE43 (Quarry Obelisk Side 4) - 0x03E77 & 0x03E7C - True +159742 - 0xFFE42 (Quarry Obelisk Side 3) - 0x289CF & 0x289D1 - True +159743 - 0xFFE43 (Quarry Obelisk Side 4) - 0x33692 - True +159744 - 0xFFE44 (Quarry Obelisk Side 5) - 0x03E77 & 0x03E7C - True 159750 - 0xFFE50 (Town Obelisk Side 1) - 0x035C7 - True 159751 - 0xFFE51 (Town Obelisk Side 2) - 0x01848 & 0x03D06 & 0x33530 & 0x33600 & 0x28A2F & 0x28A37 & 0x334A3 & 0x3352F - True 159752 - 0xFFE52 (Town Obelisk Side 3) - 0x33857 & 0x33879 & 0x03C19 - True diff --git a/worlds/witness/WitnessLogicExpert.txt b/worlds/witness/WitnessLogicExpert.txt index f100a3095b..c6cc59605a 100644 --- a/worlds/witness/WitnessLogicExpert.txt +++ b/worlds/witness/WitnessLogicExpert.txt @@ -14,7 +14,7 @@ Tutorial (Tutorial) - Outside Tutorial - True: 158005 - 0x0A3B5 (Back Left) - True - Dots & Full Dots 158006 - 0x0A3B2 (Back Right) - True - Dots & Full Dots 158007 - 0x03629 (Gate Open) - 0x002C2 - Symmetry & Dots -158008 - 0x03505 (Gate Close) - 0x2FAF6 - False +158008 - 0x03505 (Gate Close) - 0x2FAF6 & 0x03629 - False 158009 - 0x0C335 (Pillar) - True - Triangles 158010 - 0x0C373 (Patio Floor) - 0x0C335 - Dots 159512 - 0x33530 (Cloud EP) - True - True @@ -270,7 +270,7 @@ Door - 0x0368A (Stairs) - 0x03677 159413 - 0x00614 (Lift EP) - 0x275FF & 0x03675 - True Quarry Boathouse (Quarry Boathouse) - Quarry - True - Quarry Boathouse Upper Front - 0x03852 - Quarry Boathouse Behind Staircase - 0x2769B: -158146 - 0x034D4 (Intro Left) - True - Stars & Eraser +158146 - 0x034D4 (Intro Left) - True - Stars & Stars + Same Colored Symbol & Eraser 158147 - 0x021D5 (Intro Right) - True - Shapers & Eraser 158148 - 0x03852 (Ramp Height Control) - 0x034D4 & 0x021D5 - Rotated Shapers 158166 - 0x17CA6 (Boat Spawn) - True - Boat @@ -411,7 +411,7 @@ Keep Tower (Keep) - Keep - 0x04F8F: 158206 - 0x0361B (Tower Shortcut Panel) - True - True Door - 0x04F8F (Tower Shortcut) - 0x0361B 158704 - 0x0360E (Laser Panel Hedges) - 0x01A0F & 0x019E7 & 0x019DC & 0x00139 - True -158705 - 0x03317 (Laser Panel Pressure Plates) - 0x01BE9 - Shapers & Rotated Shapers & Triangles & Stars & Stars + Same Colored Symbol & Colored Squares & Black/White Squares +158705 - 0x03317 (Laser Panel Pressure Plates) - 0x033EA & 0x01BE9 & 0x01CD3 & 0x01D3F - Shapers & Rotated Shapers & Triangles & Stars & Stars + Same Colored Symbol & Colored Squares & Black/White Squares Laser - 0x014BB (Laser) - 0x0360E | 0x03317 159240 - 0x033BE (Pressure Plates 1 EP) - 0x033EA - True 159241 - 0x033BF (Pressure Plates 2 EP) - 0x01BE9 - True @@ -909,7 +909,7 @@ Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 158430 - 0x09FD8 (Near Row 5) - 0x09FD7 - Stars & Stars + Same Colored Symbol & Rotated Shapers & Eraser Door - 0x09FFB (Staircase Near) - 0x09FD8 -Mountain Floor 2 Blue Bridge (Mountain Floor 2) - Mountain Floor 2 Beyond Bridge - TrueOneWay - Mountain Floor 2 At Door - TrueOneWay: +Mountain Floor 2 Blue Bridge (Mountain Floor 2) - Mountain Floor 2 Beyond Bridge - TrueOneWay - Mountain Floor 2 At Door - 0x09ED8: Mountain Floor 2 At Door (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD: Door - 0x09EDD (Elevator Room Entry) - 0x09ED8 & 0x09E86 @@ -1113,11 +1113,12 @@ Obelisks (EPs) - Entry - True: 159735 - 0xFFE35 (River Obelisk Side 6) - 0x035CB & 0x035CF - True 159740 - 0xFFE40 (Quarry Obelisk Side 1) - 0x28A7B & 0x005F6 & 0x00859 & 0x17CB9 & 0x28A4A - True 159741 - 0xFFE41 (Quarry Obelisk Side 2) - 0x334B6 & 0x00614 & 0x0069D & 0x28A4C - True -159742 - 0xFFE42 (Quarry Obelisk Side 3) - 0x289CF & 0x289D1 & 0x33692 - True -159743 - 0xFFE43 (Quarry Obelisk Side 4) - 0x03E77 & 0x03E7C - True +159742 - 0xFFE42 (Quarry Obelisk Side 3) - 0x289CF & 0x289D1 - True +159743 - 0xFFE43 (Quarry Obelisk Side 4) - 0x33692 - True +159744 - 0xFFE44 (Quarry Obelisk Side 5) - 0x03E77 & 0x03E7C - True 159750 - 0xFFE50 (Town Obelisk Side 1) - 0x035C7 - True 159751 - 0xFFE51 (Town Obelisk Side 2) - 0x01848 & 0x03D06 & 0x33530 & 0x33600 & 0x28A2F & 0x28A37 & 0x334A3 & 0x3352F - True 159752 - 0xFFE52 (Town Obelisk Side 3) - 0x33857 & 0x33879 & 0x03C19 - True 159753 - 0xFFE53 (Town Obelisk Side 4) - 0x28B30 & 0x035C9 - True 159754 - 0xFFE54 (Town Obelisk Side 5) - 0x03335 & 0x03412 & 0x038A6 & 0x038AA & 0x03E3F & 0x03E40 & 0x28B8E - True -159755 - 0xFFE55 (Town Obelisk Side 6) - 0x28B91 & 0x03BCE & 0x03BCF & 0x03BD1 & 0x339B6 & 0x33A20 & 0x33A29 & 0x33A2A & 0x33B06 - True \ No newline at end of file +159755 - 0xFFE55 (Town Obelisk Side 6) - 0x28B91 & 0x03BCE & 0x03BCF & 0x03BD1 & 0x339B6 & 0x33A20 & 0x33A29 & 0x33A2A & 0x33B06 - True diff --git a/worlds/witness/WitnessLogicVanilla.txt b/worlds/witness/WitnessLogicVanilla.txt index 294950c305..9c62cc98d8 100644 --- a/worlds/witness/WitnessLogicVanilla.txt +++ b/worlds/witness/WitnessLogicVanilla.txt @@ -14,7 +14,7 @@ Tutorial (Tutorial) - Outside Tutorial - 0x03629: 158005 - 0x0A3B5 (Back Left) - True - True 158006 - 0x0A3B2 (Back Right) - True - True 158007 - 0x03629 (Gate Open) - 0x002C2 & 0x0A3B5 & 0x0A3B2 - True -158008 - 0x03505 (Gate Close) - 0x2FAF6 - True +158008 - 0x03505 (Gate Close) - 0x2FAF6 & 0x03629 - True 158009 - 0x0C335 (Pillar) - True - Triangles - True 158010 - 0x0C373 (Patio Floor) - 0x0C335 - Dots 159512 - 0x33530 (Cloud EP) - True - True @@ -411,7 +411,7 @@ Keep Tower (Keep) - Keep - 0x04F8F: 158206 - 0x0361B (Tower Shortcut Panel) - True - True Door - 0x04F8F (Tower Shortcut) - 0x0361B 158704 - 0x0360E (Laser Panel Hedges) - 0x01A0F & 0x019E7 & 0x019DC & 0x00139 - True -158705 - 0x03317 (Laser Panel Pressure Plates) - 0x01D3F - Shapers & Black/White Squares & Rotated Shapers +158705 - 0x03317 (Laser Panel Pressure Plates) - 0x033EA & 0x01BE9 & 0x01CD3 & 0x01D3F - Shapers & Black/White Squares & Rotated Shapers Laser - 0x014BB (Laser) - 0x0360E | 0x03317 159240 - 0x033BE (Pressure Plates 1 EP) - 0x033EA - True 159241 - 0x033BF (Pressure Plates 2 EP) - 0x01BE9 - True @@ -909,7 +909,7 @@ Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 158430 - 0x09FD8 (Near Row 5) - 0x09FD7 - Colored Squares & Symmetry Door - 0x09FFB (Staircase Near) - 0x09FD8 -Mountain Floor 2 Blue Bridge (Mountain Floor 2) - Mountain Floor 2 Beyond Bridge - TrueOneWay - Mountain Floor 2 At Door - TrueOneWay: +Mountain Floor 2 Blue Bridge (Mountain Floor 2) - Mountain Floor 2 Beyond Bridge - TrueOneWay - Mountain Floor 2 At Door - 0x09ED8: Mountain Floor 2 At Door (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD: Door - 0x09EDD (Elevator Room Entry) - 0x09ED8 & 0x09E86 @@ -1113,8 +1113,9 @@ Obelisks (EPs) - Entry - True: 159735 - 0xFFE35 (River Obelisk Side 6) - 0x035CB & 0x035CF - True 159740 - 0xFFE40 (Quarry Obelisk Side 1) - 0x28A7B & 0x005F6 & 0x00859 & 0x17CB9 & 0x28A4A - True 159741 - 0xFFE41 (Quarry Obelisk Side 2) - 0x334B6 & 0x00614 & 0x0069D & 0x28A4C - True -159742 - 0xFFE42 (Quarry Obelisk Side 3) - 0x289CF & 0x289D1 & 0x33692 - True -159743 - 0xFFE43 (Quarry Obelisk Side 4) - 0x03E77 & 0x03E7C - True +159742 - 0xFFE42 (Quarry Obelisk Side 3) - 0x289CF & 0x289D1 - True +159743 - 0xFFE43 (Quarry Obelisk Side 4) - 0x33692 - True +159744 - 0xFFE44 (Quarry Obelisk Side 5) - 0x03E77 & 0x03E7C - True 159750 - 0xFFE50 (Town Obelisk Side 1) - 0x035C7 - True 159751 - 0xFFE51 (Town Obelisk Side 2) - 0x01848 & 0x03D06 & 0x33530 & 0x33600 & 0x28A2F & 0x28A37 & 0x334A3 & 0x3352F - True 159752 - 0xFFE52 (Town Obelisk Side 3) - 0x33857 & 0x33879 & 0x03C19 - True diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 71bebb6eb1..358e063403 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -14,7 +14,7 @@ from .items import WitnessItem, StaticWitnessItems, WitnessPlayerItems from .rules import set_rules from .regions import WitnessRegions from .Options import is_option_enabled, the_witness_options, get_option_value -from .utils import best_junk_to_add_based_on_weights, get_audio_logs +from .utils import best_junk_to_add_based_on_weights, get_audio_logs, make_warning_string from logging import warning @@ -38,7 +38,7 @@ class WitnessWorld(World): """ game = "The Witness" topology_present = False - data_version = 12 + data_version = 13 static_logic = StaticWitnessLogic() static_locat = StaticWitnessLocations() @@ -52,7 +52,7 @@ class WitnessWorld(World): location_name_to_id = StaticWitnessLocations.ALL_LOCATIONS_TO_ID item_name_groups = StaticWitnessItems.ITEM_NAME_GROUPS - required_client_version = (0, 3, 8) + required_client_version = (0, 3, 9) def _get_slot_data(self): return { @@ -67,7 +67,7 @@ class WitnessWorld(World): 'progressive_item_lists': self.items.MULTI_LISTS_BY_CODE, 'obelisk_side_id_to_EPs': self.static_logic.OBELISK_SIDE_ID_TO_EP_HEXES, 'precompleted_puzzles': {int(h, 16) for h in self.player_logic.PRECOMPLETED_LOCATIONS}, - 'ep_to_name': self.static_logic.EP_ID_TO_NAME, + 'entity_to_name': self.static_logic.ENTITY_ID_TO_NAME, } def generate_early(self): @@ -93,13 +93,13 @@ class WitnessWorld(World): self.items = WitnessPlayerItems(self.locat, self.multiworld, self.player, self.player_logic) self.regio = WitnessRegions(self.locat) - def create_regions(self): - self.regio.create_regions(self.multiworld, self.player, self.player_logic) - - def generate_basic(self): self.log_ids_to_hints = dict() self.junk_items_created = {key: 0 for key in self.items.JUNK_WEIGHTS.keys()} + def create_regions(self): + self.regio.create_regions(self.multiworld, self.player, self.player_logic) + + def create_items(self): # Generate item pool pool = [] for item in self.items.ITEM_TABLE: @@ -109,22 +109,12 @@ class WitnessWorld(World): pool.append(witness_item) self.items_by_name[item] = witness_item - less_junk = 0 - - dog_check = self.multiworld.get_location( - "Town Pet the Dog", self.player - ) - - dog_check.place_locked_item(self.create_item("Puzzle Skip")) - - less_junk += 1 - for precol_item in self.multiworld.precollected_items[self.player]: if precol_item.name in self.items_by_name: # if item is in the pool, remove 1 instance. item_obj = self.items_by_name[precol_item.name] if item_obj in pool: - pool.remove(item_obj) # remove one instance of this pre-collected item if it exists + pool.remove(item_obj) # remove one instance of this pre-collected item if it exists for item in self.player_logic.STARTING_INVENTORY: self.multiworld.push_precollected(self.items_by_name[item]) @@ -132,15 +122,8 @@ class WitnessWorld(World): for item in self.items.EXTRA_AMOUNTS: for i in range(0, self.items.EXTRA_AMOUNTS[item]): - if len(pool) < len(self.locat.CHECK_LOCATION_TABLE) - len(self.locat.EVENT_LOCATION_TABLE) - less_junk: - witness_item = self.create_item(item) - pool.append(witness_item) - - # Put in junk items to fill the rest - junk_size = len(self.locat.CHECK_LOCATION_TABLE) - len(pool) - len(self.locat.EVENT_LOCATION_TABLE) - less_junk - - for i in range(0, junk_size): - pool.append(self.create_item(self.get_filler_item_name())) + witness_item = self.create_item(item) + pool.append(witness_item) # Tie Event Items to Event Locations (e.g. Laser Activations) for event_location in self.locat.EVENT_LOCATION_TABLE: @@ -150,27 +133,108 @@ class WitnessWorld(World): location_obj = self.multiworld.get_location(event_location, self.player) location_obj.place_locked_item(item_obj) - self.multiworld.itempool += pool + # Find out how much empty space there is for junk items. -1 for the "Town Pet the Dog" check + itempool_difference = len(self.locat.CHECK_LOCATION_TABLE) - len(self.locat.EVENT_LOCATION_TABLE) - 1 + itempool_difference -= len(pool) - def pre_fill(self): - # Put good item on first check if there are any of the designated "good items" in the pool + # Place two locked items: Good symbol on Tutorial Gate Open, and a Puzzle Skip on "Town Pet the Dog" good_items_in_the_game = [] + plandoed_items = set() + + for v in self.multiworld.plando_items[self.player]: + if v.get("from_pool", True): + plandoed_items.update({self.items_by_name[i] for i in v.get("items", dict()).keys() + if i in self.items_by_name}) + if "item" in v and v["item"] in self.items_by_name: + plandoed_items.add(self.items_by_name[v["item"]]) for symbol in self.items.GOOD_ITEMS: item = self.items_by_name[symbol] - if item in self.multiworld.itempool: # Only do this if the item is still in item pool (e.g. after plando) + if item in pool and item not in plandoed_items: + # for now, any item that is mentioned in any plando option, even if it's a list of items, is ineligible. + # Hopefully, in the future, plando gets resolved before create_items. + # I could also partially resolve lists myself, but this could introduce errors if not done carefully. good_items_in_the_game.append(symbol) if good_items_in_the_game: random_good_item = self.multiworld.random.choice(good_items_in_the_game) - first_check = self.multiworld.get_location( - "Tutorial Gate Open", self.player - ) item = self.items_by_name[random_good_item] - first_check.place_locked_item(item) - self.multiworld.itempool.remove(item) + if get_option_value(self.multiworld, self.player, "puzzle_randomization") == 1: + self.multiworld.local_early_items[self.player][random_good_item] = 1 + else: + first_check = self.multiworld.get_location( + "Tutorial Gate Open", self.player + ) + + first_check.place_locked_item(item) + pool.remove(item) + + dog_check = self.multiworld.get_location( + "Town Pet the Dog", self.player + ) + + dog_check.place_locked_item(self.create_item("Puzzle Skip")) + + # Fill rest of item pool with junk if there is room + if itempool_difference > 0: + for i in range(0, itempool_difference): + self.multiworld.itempool.append(self.create_item(self.get_filler_item_name())) + + # Remove junk, Functioning Brain, useful items (non-door), useful door items in that order until there is room + if itempool_difference < 0: + junk = [ + item for item in pool + if item.classification in {ItemClassification.filler, ItemClassification.trap} + and item.name != "Functioning Brain" + ] + + f_brain = [item for item in pool if item.name == "Functioning Brain"] + + usefuls = [ + item for item in pool + if item.classification == ItemClassification.useful + and item.name not in StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT + ] + + removable_doors = [ + item for item in pool + if item.classification == ItemClassification.useful + and item.name in StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT + ] + + self.multiworld.per_slot_randoms[self.player].shuffle(junk) + self.multiworld.per_slot_randoms[self.player].shuffle(usefuls) + self.multiworld.per_slot_randoms[self.player].shuffle(removable_doors) + + removed_junk = False + removed_usefuls = False + removed_doors = False + + for i in range(itempool_difference, 0): + if junk: + pool.remove(junk.pop()) + removed_junk = True + elif f_brain: + pool.remove(f_brain.pop()) + elif usefuls: + pool.remove(usefuls.pop()) + removed_usefuls = True + elif removable_doors: + pool.remove(removable_doors.pop()) + removed_doors = True + + warn = make_warning_string( + removed_junk, removed_usefuls, removed_doors, not junk, not usefuls, not removable_doors + ) + + if warn: + warning(f"This Witness world has too few locations to place all its items." + f" In order to make space, {warn} had to be removed.") + + # Finally, add the generated pool to the overall itempool + self.multiworld.itempool += pool def set_rules(self): set_rules(self.multiworld, self.player, self.player_logic, self.locat) diff --git a/worlds/witness/docs/setup_en.md b/worlds/witness/docs/setup_en.md index 8e85090c10..94a50846f9 100644 --- a/worlds/witness/docs/setup_en.md +++ b/worlds/witness/docs/setup_en.md @@ -3,7 +3,7 @@ ## Required Software - [The Witness for 64-bit Windows (e.g. Steam version)](https://store.steampowered.com/app/210970/The_Witness/) -- [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases) +- [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) ## Optional Software @@ -14,7 +14,7 @@ 1. Launch The Witness 2. Start a fresh save -3. Launch [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago) +3. Launch [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) 4. Enter the Archipelago address, slot name and password 5. Press "Connect" 6. Enjoy! @@ -23,7 +23,7 @@ To continue an earlier game: 1. Launch The Witness 2. Load the save you last played this world on, if it's not the one you loaded into automatically -3. Launch [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago) +3. Launch [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) 4. Press "Load Credentials" (or type them in manually) 5. Press "Connect" diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index baa6dd45dd..4c36c4826b 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -2,96 +2,96 @@ from BaseClasses import MultiWorld from .Options import is_option_enabled, get_option_value joke_hints = [ - ("Quaternions", "break", "my brain"), - ("Eclipse", "has nothing", "but you should do it anyway"), - ("", "Beep", ""), - ("Putting in custom subtitles", "shouldn't have been", "as hard as it was..."), - ("BK mode", "is right", "around the corner"), - ("", "You can do it!", ""), - ("", "I believe in you!", ""), - ("The person playing", "is", "cute <3"), - ("dash dot, dash dash dash", "dash, dot dot dot dot, dot dot", "dash dot, dash dash dot"), - ("When you think about it,", "there are actually a lot of", "bubbles in a stream"), - ("Never gonna give you up", "Never gonna let you down", "Never gonna run around and desert you"), - ("Thanks to", "the Archipelago developers", "for making this possible."), - ("Have you tried ChecksFinder?", "If you like puzzles,", "you might enjoy it!"), - ("Have you tried Dark Souls III?", "A tough game like this", "feels better when friends are helping you!"), - ("Have you tried Donkey Kong Country 3?", "A legendary game", "from a golden age of platformers!"), - ("Have you tried Factorio?", "Alone in an unknown multiworld.", "Sound familiar?"), - ("Have you tried Final Fantasy?", "Experience a classic game", "improved to fit modern standards!"), - ("Have you tried Hollow Knight?", "Another independent hit", "revolutionising a genre!"), - ("Have you tried A Link to the Past?", "The Archipelago game", "that started it all!"), - ("Have you tried Meritous?", "You should know that obscure games", "are often groundbreaking!"), - ("Have you tried Ocarine of Time?", "One of the biggest randomizers,", "big inspiration for this one's features!"), - ("Have you tried Raft?", "Haven't you always wanted to explore", "the ocean surrounding this island?"), - ("Have you tried Risk of Rain 2?", "I haven't either.", "But I hear it's incredible!"), - ("Have you tried Rogue Legacy?", "After solving so many puzzles", "it's the perfect way to rest your brain."), - ("Have you tried Secret of Evermore?", "I haven't either", "But I hear it's great!"), - ("Have you tried Slay the Spire?", "Experience the thrill of combat", "without needing fast fingers!"), - ("Have you tried SMZ3?", "Why play one incredible game", "when you can play 2 at once?"), - ("Have you tried Starcraft 2?", "Use strategy and management", "to crush your enemies!"), - ("Have you tried Super Mario 64?", "3-dimensional games like this", "owe everything to that game."), - ("Have you tried Super Metroid?", "A classic game", "that started a whole genre."), - ("Have you tried Timespinner?", "Everyone who plays it", "ends up loving it!"), - ("Have you tried VVVVVV?", "Experience the essence of gaming", "distilled into its purest form!"), - ("Have you tried The Witness?", "Oh. I guess you already have.", " Thanks for playing!"), - ("Have you tried Super Mario World?", "I don't think I need to tell you", "that it is beloved by many."), - ("Have you tried Overcooked 2?", "When you're done relaxing with puzzles,", - "use your energy to yell at your friends."), - ("Have you tried Zillion?", "Me neither. But it looks fun.", "So, let's try something new together?"), - ("Have you tried Hylics 2?", "Stop motion might just be", "the epitome of unique art styles."), - ("Have you tried Pokemon Red&Blue?", "A cute pet collecting game", "that fascinated an entire generation."), - ("Waiting to get your items?", "Try BK Sudoku!", "Make progress even while stuck."), - ("One day I was fascinated", "by the subject of", "generation of waves by wind"), - ("I don't like sandwiches", "Why would you think I like sandwiches?", "Have you ever seen me with a sandwich?"), - ("Where are you right now?", "I'm at soup!", "What do you mean you're at soup?"), - ("Remember to ask", "in the Archipelago Discord", "what the Functioning Brain does."), - ("", "Don't use your puzzle skips", "you might need them later"), - ("", "For an extra challenge", "Try playing blindfolded"), - ("Go to the top of the mountain", "and see if you can see", "your house"), - ("Yellow = Red + Green", "Cyan = Green + Blue", "Magenta = Red + Blue"), - ("", "Maybe that panel really is unsolvable", ""), - ("", "Did you make sure it was plugged in?", ""), - ("", "Do not look into laser with remaining eye", ""), - ("", "Try pressing Space to jump", ""), - ("The Witness is a Doom clone.", "Just replace the demons", "with puzzles"), - ("", "Test Hint please ignore", ""), - ("Shapers can never be placed", "outside the panel boundaries", "even if subtracted."), - ("", "The Keep laser panels use", "the same trick on both sides!"), - ("Can't get past a door? Try going around.", "Can't go around? Try building a", "nether portal."), - ("", "We've been trying to reach you", "about your car's extended warranty"), - ("I hate this game. I hate this game.", "I hate this game.", "-chess player Bobby Fischer"), - ("Dear Mario,", "Please come to the castle.", "I've baked a cake for you!"), - ("Have you tried waking up?", "", "Yeah, me neither."), - ("Why do they call it The Witness,", "when wit game the player view", "play of with the game."), - ("", "THE WIND FISH IN NAME ONLY", "FOR IT IS NEITHER"), - ("Like this game? Try The Wit.nes,", "Understand, INSIGHT, Taiji", "What the Witness?, and Tametsi."), - ("", "In a race", "It's survival of the Witnesst"), - ("", "This hint has been removed", "We apologize for your inconvenience."), - ("", "O-----------", ""), - ("Circle is draw", "Square is separate", "Line is win"), - ("Circle is draw", "Star is pair", "Line is win"), - ("Circle is draw", "Circle is copy", "Line is win"), - ("Circle is draw", "Dot is eat", "Line is win"), - ("Circle is start", "Walk is draw", "Line is win"), - ("Circle is start", "Line is win", "Witness is you"), - ("Can't find any items?", "Consider a relaxing boat trip", "around the island"), - ("", "Don't forget to like, comment, and subscribe", ""), - ("Ah crap, gimme a second.", "[papers rustling]", "Sorry, nothing."), - ("", "Trying to get a hint?", "Too bad."), - ("", "Here's a hint:", "Get good at the game."), - ("", "I'm still not entirely sure", "what we're witnessing here."), - ("Have you found a red page yet?", "No?", "Then have you found a blue page?"), - ( - "And here we see the Witness player,", - "seeking answers where there are none-", - "Did someone turn on the loudspeaker?" - ), - ( - "Hints suggested by:", - "IHNN, Beaker, MrPokemon11, Ember, TheM8, NewSoupVi,", - "KF, Yoshi348, Berserker, BowlinJim, oddGarrett, Pink Switch." - ), + "Quaternions break my brain", + "Eclipse has nothing, but you should do it anyway.", + "Beep", + "Putting in custom subtitles shouldn't have been as hard as it was...", + "BK mode is right around the corner.", + "You can do it!", + "I believe in you!", + "The person playing is cute. <3", + "dash dot, dash dash dash, dash, dot dot dot dot, dot dot, dash dot, dash dash dot", + "When you think about it, there are actually a lot of bubbles in a stream.", + "Never gonna give you up\nNever gonna let you down\nNever gonna run around and desert you", + "Thanks to the Archipelago developers for making this possible.", + "Have you tried ChecksFinder?\nIf you like puzzles, you might enjoy it!", + "Have you tried Dark Souls III?\nA tough game like this feels better when friends are helping you!", + "Have you tried Donkey Kong Country 3?\nA legendary game from a golden age of platformers!", + "Have you tried Factorio?\nAlone in an unknown multiworld. Sound familiar?", + "Have you tried Final Fantasy?\nExperience a classic game improved to fit modern standards!", + "Have you tried Hollow Knight?\nAnother independent hit revolutionising a genre!", + "Have you tried A Link to the Past?\nThe Archipelago game that started it all!", + "Have you tried Meritous?\nYou should know that obscure games are often groundbreaking!", + "Have you tried Ocarina of Time?\nOne of the biggest randomizers, big inspiration for this one's features!", + "Have you tried Raft?\nHaven't you always wanted to explore the ocean surrounding this island?", + "Have you tried Risk of Rain 2?\nI haven't either. But I hear it's incredible!", + "Have you tried Rogue Legacy?\nAfter solving so many puzzles it's the perfect way to rest your brain.", + "Have you tried Secret of Evermore?\nI haven't either But I hear it's great!", + "Have you tried Slay the Spire?\nExperience the thrill of combat without needing fast fingers!", + "Have you tried SMZ3?\nWhy play one incredible game when you can play 2 at once?", + "Have you tried Starcraft 2?\nUse strategy and management to crush your enemies!", + "Have you tried Super Mario 64?\n3-dimensional games like this owe everything to that game.", + "Have you tried Super Metroid?\nA classic game, yet still one of the best in the genre.", + "Have you tried Timespinner?\nEveryone who plays it ends up loving it!", + "Have you tried VVVVVV?\nExperience the essence of gaming distilled into its purest form!", + "Have you tried The Witness?\nOh. I guess you already have. Thanks for playing!", + "Have you tried Super Mario World?\nI don't think I need to tell you that it is beloved by many.", + "Have you tried Overcooked 2?\nWhen you're done relaxing with puzzles, use your energy to yell at your friends.", + "Have you tried Zillion?\nMe neither. But it looks fun. So, let's try something new together?", + "Have you tried Hylics 2?\nStop motion might just be the epitome of unique art styles.", + "Have you tried Pokemon Red&Blue?\nA cute pet collecting game that fascinated an entire generation.", + "Have you tried Lufia II?\nRoguelites are not just a 2010s phenomenon, turns out.", + "Have you tried Minecraft?\nI have recently learned this is a question that needs to be asked.", + "Have you tried Subnautica?\nIf you like this game's lonely atmosphere, I would suggest you try it.", + + "Have you tried Sonic Adventure 2?\nIf the silence on this island is getting to you, " + "there aren't many games more energetic.", + + "Waiting to get your items?\nTry BK Sudoku! Make progress even while stuck.", + "One day I was fascinated by the subject of generation of waves by wind.", + "I don't like sandwiches. Why would you think I like sandwiches? Have you ever seen me with a sandwich?", + "Where are you right now?\nI'm at soup!\nWhat do you mean you're at soup?", + "Remember to ask in the Archipelago Discord what the Functioning Brain does.", + "Don't use your puzzle skips, you might need them later.", + "For an extra challenge, try playing blindfolded.", + "Go to the top of the mountain and see if you can see your house.", + "Yellow = Red + Green\nCyan = Green + Blue\nMagenta = Red + Blue", + "Maybe that panel really is unsolvable.", + "Did you make sure it was plugged in?", + "Do not look into laser with remaining eye.", + "Try pressing Space to jump.", + "The Witness is a Doom clone.\nJust replace the demons with puzzles", + "Test Hint please ignore", + "Shapers can never be placed outside the panel boundaries, even if subtracted.", + "The Keep laser panels use the same trick on both sides!", + "Can't get past a door? Try going around. Can't go around? Try building a nether portal.", + "We've been trying to reach you about your car's extended warranty.", + "I hate this game. I hate this game. I hate this game.\n- Chess player Bobby Fischer", + "Dear Mario,\nPlease come to the castle. I've baked a cake for you!", + "Have you tried waking up?\nYeah, me neither.", + "Why do they call it The Witness, when wit game the player view play of with the game.", + "THE WIND FISH IN NAME ONLY, FOR IT IS NEITHER", + "Like this game?\nTry The Wit.nes, Understand, INSIGHT, Taiji What the Witness?, and Tametsi.", + "In a race, It's survival of the Witnesst.", + "This hint has been removed. We apologize for your inconvenience.", + "O-----------", + "Circle is draw\nSquare is separate\nLine is win", + "Circle is draw\nStar is pair\nLine is win", + "Circle is draw\nCircle is copy\nLine is win", + "Circle is draw\nDot is eat\nLine is win", + "Circle is start\nWalk is draw\nLine is win", + "Circle is start\nLine is win\nWitness is you", + "Can't find any items?\nConsider a relaxing boat trip around the island!", + "Don't forget to like, comment, and subscribe.", + "Ah crap, gimme a second.\n[papers rustling]\nSorry, nothing.", + "Trying to get a hint? Too bad.", + "Here's a hint: Get good at the game.", + "I'm still not entirely sure what we're witnessing here.", + "Have you found a red page yet? No? Then have you found a blue page?", + "And here we see the Witness player, seeking answers where there are none-\nDid someone turn on the loudspeaker?", + + "Hints suggested by:\nIHNN, Beaker, MrPokemon11, Ember, TheM8, NewSoupVi," + "KF, Yoshi348, Berserker, BowlinJim, oddGarrett, Pink Switch.", ] @@ -186,7 +186,7 @@ def make_hint_from_item(multiworld: MultiWorld, player: int, item: str): if location_obj.player != player: location_name += " (" + multiworld.get_player_name(location_obj.player) + ")" - return location_name, item, location_obj.address if(location_obj.player == player) else -1 + return location_name, item, location_obj.address if (location_obj.player == player) else -1 def make_hint_from_location(multiworld: MultiWorld, player: int, location: str): @@ -196,7 +196,7 @@ def make_hint_from_location(multiworld: MultiWorld, player: int, location: str): if item_obj.player != player: item_name += " (" + multiworld.get_player_name(item_obj.player) + ")" - return location, item_name, location_obj.address if(location_obj.player == player) else -1 + return location, item_name, location_obj.address if (location_obj.player == player) else -1 def make_hints(multiworld: MultiWorld, player: int, hint_amount: int): @@ -258,9 +258,9 @@ def make_hints(multiworld: MultiWorld, player: int, hint_amount: int): for loc, item in always_hint_pairs.items(): if item[1]: - hints.append((item[0], "can be found at", loc, item[2])) + hints.append((f"{item[0]} can be found at {loc}.", item[2])) else: - hints.append((loc, "contains", item[0], item[2])) + hints.append((f"{loc} contains {item[0]}.", item[2])) multiworld.per_slot_randoms[player].shuffle(hints) # shuffle always hint order in case of low hint amount @@ -279,9 +279,9 @@ def make_hints(multiworld: MultiWorld, player: int, hint_amount: int): del priority_hint_pairs[loc] if item[1]: - hints.append((item[0], "can be found at", loc, item[2])) + hints.append((f"{item[0]} can be found at {loc}.", item[2])) else: - hints.append((loc, "contains", item[0], item[2])) + hints.append((f"{loc} contains {item[0]}.", item[2])) continue if next_random_hint_is_item: @@ -290,10 +290,10 @@ def make_hints(multiworld: MultiWorld, player: int, hint_amount: int): continue hint = make_hint_from_item(multiworld, player, prog_items_in_this_world.pop()) - hints.append((hint[1], "can be found at", hint[0], hint[2])) + hints.append((f"{hint[1]} can be found at {hint[0]}.", hint[2])) else: hint = make_hint_from_location(multiworld, player, locations_in_this_world.pop()) - hints.append((hint[0], "contains", hint[1], hint[2])) + hints.append((f"{hint[0]} contains {hint[1]}.", hint[2])) next_random_hint_is_item = not next_random_hint_is_item @@ -301,4 +301,4 @@ def make_hints(multiworld: MultiWorld, player: int, hint_amount: int): def generate_joke_hints(multiworld: MultiWorld, player: int, amount: int): - return [(x, y, z, -1) for (x, y, z) in multiworld.per_slot_randoms[player].sample(joke_hints, amount)] + return [(x, -1) for x in multiworld.per_slot_randoms[player].sample(joke_hints, amount)] diff --git a/worlds/witness/items.py b/worlds/witness/items.py index 914c1af2c0..4beb3b0290 100644 --- a/worlds/witness/items.py +++ b/worlds/witness/items.py @@ -205,13 +205,10 @@ class WitnessPlayerItems: ] if is_option_enabled(multiworld, player, "shuffle_discarded_panels"): - if is_option_enabled(multiworld, player, "shuffle_discarded_panels"): - if get_option_value(multiworld, player, "puzzle_randomization") == 1: - self.GOOD_ITEMS.append("Arrows") - else: - self.GOOD_ITEMS.append("Triangles") - if not is_option_enabled(multiworld, player, "disable_non_randomized_puzzles"): - self.GOOD_ITEMS.append("Colored Squares") + if get_option_value(multiworld, player, "puzzle_randomization") == 1: + self.GOOD_ITEMS.append("Arrows") + else: + self.GOOD_ITEMS.append("Triangles") self.GOOD_ITEMS = [ StaticWitnessLogic.ITEMS_TO_PROGRESSIVE.get(item, item) for item in self.GOOD_ITEMS diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index 37f61646d1..f9d1012cb4 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -13,13 +13,10 @@ class StaticWitnessLocations: """ ID_START = 158000 - EXTRA_LOCATIONS = { + GENERAL_LOCATIONS = { "Tutorial Front Left", "Tutorial Back Left", "Tutorial Back Right", - } - - GENERAL_LOCATIONS = { "Tutorial Gate Open", "Outside Tutorial Vault Box", @@ -302,6 +299,7 @@ class StaticWitnessLocations: "Quarry Obelisk Side 2", "Quarry Obelisk Side 3", "Quarry Obelisk Side 4", + "Quarry Obelisk Side 5", "Town Obelisk Side 1", "Town Obelisk Side 2", "Town Obelisk Side 3", @@ -338,6 +336,7 @@ class StaticWitnessLocations: "Quarry Obelisk Side 2", "Quarry Obelisk Side 3", "Quarry Obelisk Side 4", + "Quarry Obelisk Side 5", "Town Obelisk Side 1", "Town Obelisk Side 2", "Town Obelisk Side 3", @@ -388,6 +387,9 @@ class StaticWitnessLocations: "Mountain Floor 2 Near Row 5", "Mountain Floor 2 Far Row 6", + "Mountain Floor 2 Light Bridge Controller Near", + "Mountain Floor 2 Light Bridge Controller Far", + "Mountain Bottom Floor Yellow Bridge EP", "Mountain Bottom Floor Blue Bridge EP", "Mountain Floor 2 Pink Bridge EP", diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 34b53b62ff..3e81993dc9 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -40,7 +40,7 @@ class WitnessPlayerLogic: Panels outside of the same region will still be checked manually. """ - if panel_hex in self.COMPLETELY_DISABLED_CHECKS: + if panel_hex in self.COMPLETELY_DISABLED_CHECKS or panel_hex in self.PRECOMPLETED_LOCATIONS: return frozenset() check_obj = self.REFERENCE_LOGIC.CHECKS_BY_HEX[panel_hex] @@ -433,7 +433,6 @@ class WitnessPlayerLogic: self.ADDED_CHECKS = set() self.VICTORY_LOCATION = "0x0356B" self.EVENT_ITEM_NAMES = { - "0x01A0F": "Keep Laser Panel (Hedge Mazes) Activates", "0x09D9B": "Monastery Shutters Open", "0x193A6": "Monastery Laser Panel Activates", "0x00037": "Monastery Branch Panels Activate", @@ -442,8 +441,11 @@ class WitnessPlayerLogic: "0x00139": "Keep Hedges 1 Knowledge", "0x019DC": "Keep Hedges 2 Knowledge", "0x019E7": "Keep Hedges 3 Knowledge", - "0x01D3F": "Keep Laser Panel (Pressure Plates) Activates", - "0x01BE9": "Keep Laser Panel (Pressure Plates) Activates - Expert", + "0x01A0F": "Keep Hedges 4 Knowledge", + "0x033EA": "Pressure Plates 1 Knowledge", + "0x01BE9": "Pressure Plates 2 Knowledge", + "0x01CD3": "Pressure Plates 3 Knowledge", + "0x01D3F": "Pressure Plates 4 Knowledge", "0x09F7F": "Mountain Access", "0x0367C": "Quarry Laser Stoneworks Requirement Met", "0x009A1": "Swamp Between Bridges Far 1 Activates", @@ -492,11 +494,9 @@ class WitnessPlayerLogic: "0x17D02": "Windmill Blades Spinning", "0x0A0C9": "Cargo Box EP completable", "0x09E39": "Pink Light Bridge Extended", - "0x01CD3": "Pressure Plates 3 EP available", "0x17CC4": "Rails EP available", "0x2896A": "Bridge Underside EP available", "0x00064": "First Tunnel EP visible", - "0x033EA": "Pressure Plates 1 EP available", "0x03553": "Tutorial Video EPs availble", "0x17C79": "Bunker Door EP available", "0x275FF": "Stoneworks Light EPs available", diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 69b0317d85..0e15cafe10 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -27,20 +27,18 @@ class WitnessRegions: ) def connect(self, world: MultiWorld, player: int, source: str, target: str, player_logic: WitnessPlayerLogic, - panel_hex_to_solve_set=frozenset({frozenset()})): + panel_hex_to_solve_set=frozenset({frozenset()}), backwards: bool = False): """ connect two regions and set the corresponding requirement """ source_region = world.get_region(source, player) target_region = world.get_region(target, player) - #print(source_region) - #print(target_region) - #print("---") + backwards = " Backwards" if backwards else "" connection = Entrance( player, - source + " to " + target, + source + " to " + target + backwards, source_region ) @@ -92,10 +90,18 @@ class WitnessRegions: self.connect(world, player, region_name, connection[0], player_logic, frozenset({frozenset()})) continue + backwards_connections = set() + for subset in connection[1]: if all({panel in player_logic.DOOR_ITEMS_BY_ID for panel in subset}): if all({reference_logic.CHECKS_BY_HEX[panel]["id"] is None for panel in subset}): - self.connect(world, player, connection[0], region_name, player_logic, frozenset({subset})) + backwards_connections.add(subset) + + if backwards_connections: + self.connect( + world, player, connection[0], region_name, player_logic, + frozenset(backwards_connections), True + ) self.connect(world, player, region_name, connection[0], player_logic, connection[1]) diff --git a/worlds/witness/settings/Disable_Unrandomized.txt b/worlds/witness/settings/Disable_Unrandomized.txt index 8f6034ccb9..dbe9caa5be 100644 --- a/worlds/witness/settings/Disable_Unrandomized.txt +++ b/worlds/witness/settings/Disable_Unrandomized.txt @@ -114,15 +114,17 @@ Disabled Locations: 0x17CAA (River Garden Entry Panel) -0x034A7 (Left Shutter EP) -0x034AD (Middle Shutter EP) -0x034AF (Right Shutter EP) -0x339B6 (Eclipse EP) - 0x03549 - True -0x33A29 (Window EP) - 0x03553 - True -0x33A2A (Door EP) - 0x03553 - True -0x33B06 (Church EP) - 0x0354E - True -0x3352F (Gate EP) -0x33600 (Patio Flowers EP) -0x035F5 (Tinted Door EP) -0x000D3 (Green Room Flowers EP) -0x33A20 (Theater Flowers EP) \ No newline at end of file +Precompleted Locations: +0x034A7 +0x034AD +0x034AF +0x339B6 +0x33A29 +0x33A2A +0x33B06 +0x3352F +0x33600 +0x035F5 +0x000D3 +0x33A20 +0x03BE2 \ No newline at end of file diff --git a/worlds/witness/settings/EP_Shuffle/EP_Easy.txt b/worlds/witness/settings/EP_Shuffle/EP_Easy.txt index 3e168b5891..939055169a 100644 --- a/worlds/witness/settings/EP_Shuffle/EP_Easy.txt +++ b/worlds/witness/settings/EP_Shuffle/EP_Easy.txt @@ -9,4 +9,6 @@ Precompleted Locations: 0x33857 0x33879 0x016B2 -0x036CE \ No newline at end of file +0x036CE +0x03B25 +0x28B2A \ No newline at end of file diff --git a/worlds/witness/static_logic.py b/worlds/witness/static_logic.py index 4311a84fa1..f395613b91 100644 --- a/worlds/witness/static_logic.py +++ b/worlds/witness/static_logic.py @@ -81,8 +81,6 @@ class StaticWitnessLogicObj: full_check_name = check_name elif "EP" in check_name: location_type = "EP" - - self.EP_ID_TO_NAME[check_hex] = full_check_name else: location_type = "General" @@ -114,6 +112,8 @@ class StaticWitnessLogicObj: "panelType": location_type } + self.ENTITY_ID_TO_NAME[check_hex] = full_check_name + self.CHECKS_BY_NAME[self.CHECKS_BY_HEX[check_hex]["checkName"]] = self.CHECKS_BY_HEX[check_hex] self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[check_hex] = requirement @@ -132,7 +132,7 @@ class StaticWitnessLogicObj: self.EP_TO_OBELISK_SIDE = dict() - self.EP_ID_TO_NAME = dict() + self.ENTITY_ID_TO_NAME = dict() self.read_logic_file(file_path) @@ -159,7 +159,7 @@ class StaticWitnessLogic: EP_TO_OBELISK_SIDE = dict() - EP_ID_TO_NAME = dict() + ENTITY_ID_TO_NAME = dict() def parse_items(self): """ @@ -235,4 +235,4 @@ class StaticWitnessLogic: self.EP_TO_OBELISK_SIDE.update(self.sigma_normal.EP_TO_OBELISK_SIDE) - self.EP_ID_TO_NAME.update(self.sigma_normal.EP_ID_TO_NAME) \ No newline at end of file + self.ENTITY_ID_TO_NAME.update(self.sigma_normal.ENTITY_ID_TO_NAME) \ No newline at end of file diff --git a/worlds/witness/utils.py b/worlds/witness/utils.py index dcb335edd9..7182545cf5 100644 --- a/worlds/witness/utils.py +++ b/worlds/witness/utils.py @@ -3,6 +3,42 @@ from Utils import cache_argsless from itertools import accumulate from typing import * from fractions import Fraction +from collections import Counter + + +def make_warning_string(any_j: bool, any_u: bool, any_d: bool, all_j: bool, all_u: bool, all_d: bool) -> str: + warning_string = "" + + if any_j: + if all_j: + warning_string += "all " + else: + warning_string += "some " + + warning_string += "junk" + + if any_u or any_d: + if warning_string: + warning_string += " and " + + if all_u: + warning_string += "all " + else: + warning_string += "some " + + warning_string += "usefuls" + + if any_d: + warning_string += ", including " + + if all_d: + warning_string += "all " + else: + warning_string += "some " + + warning_string += "non-essential door items" + + return warning_string def best_junk_to_add_based_on_weights(weights: Dict[Any, Fraction], created_junk: Dict[Any, int]): diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index 44d80cff50..241cb452a9 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -2,13 +2,12 @@ from collections import deque, Counter from contextlib import redirect_stdout import functools import threading -from typing import Any, Dict, List, Set, Tuple, Optional, cast +from typing import Any, Dict, List, Literal, Set, Tuple, Optional, cast import os import logging from BaseClasses import ItemClassification, LocationProgressType, \ MultiWorld, Item, CollectionState, Entrance, Tutorial -from Options import AssembleOptions from .logic import cs_to_zz_locs from .region import ZillionLocation, ZillionRegion from .options import ZillionStartChar, zillion_options, validate @@ -48,17 +47,17 @@ class ZillionWorld(World): game = "Zillion" web = ZillionWebWorld() - option_definitions: Dict[str, AssembleOptions] = zillion_options - topology_present: bool = True # indicate if world type has any meaningful layout/pathing + option_definitions = zillion_options + topology_present = True # indicate if world type has any meaningful layout/pathing # map names to their IDs - item_name_to_id: Dict[str, int] = _item_name_to_id - location_name_to_id: Dict[str, int] = _loc_name_to_id + item_name_to_id = _item_name_to_id + location_name_to_id = _loc_name_to_id # increment this every time something in your world's names/id mappings changes. # While this is set to 0 in *any* AutoWorld, the entire DataPackage is considered in testing mode and will be # retrieved by clients on every connection. - data_version: int = 1 + data_version = 1 logger: logging.Logger @@ -250,13 +249,13 @@ class ZillionWorld(World): if group["game"] == "Zillion": assert "item_pool" in group item_pool = group["item_pool"] - to_stay = "JJ" + to_stay: Literal['Apple', 'Champ', 'JJ'] = "JJ" if "JJ" in item_pool: assert "players" in group group_players = group["players"] start_chars = cast(Dict[int, ZillionStartChar], getattr(multiworld, "start_char")) players_start_chars = [ - (player, start_chars[player].get_current_option_name()) + (player, start_chars[player].current_option_name) for player in group_players ] start_char_counts = Counter(sc for _, sc in players_start_chars) diff --git a/worlds/zillion/logic.py b/worlds/zillion/logic.py index 204f242500..225076da09 100644 --- a/worlds/zillion/logic.py +++ b/worlds/zillion/logic.py @@ -42,6 +42,7 @@ def item_counts(cs: CollectionState, p: int) -> Tuple[Tuple[str, int], ...]: LogicCacheType = Dict[int, Tuple[_Counter[Tuple[str, int]], FrozenSet[Location]]] +""" { hash: (cs.prog_items, accessible_locations) } """ def cs_to_zz_locs(cs: CollectionState, p: int, zz_r: Randomizer, id_to_zz_item: Dict[int, Item]) -> FrozenSet[Location]: diff --git a/worlds/zillion/options.py b/worlds/zillion/options.py index 2c5a9dd8e7..6aa88f5b22 100644 --- a/worlds/zillion/options.py +++ b/worlds/zillion/options.py @@ -276,14 +276,14 @@ def validate(world: "MultiWorld", p: int) -> "Tuple[ZzOptions, Counter[str]]": skill = wo.skill[p].value jump_levels = cast(ZillionJumpLevels, wo.jump_levels[p]) - jump_option = jump_levels.get_current_option_name().lower() + jump_option = jump_levels.current_key required_level = char_to_jump["Apple"][cast(ZzVBLR, jump_option)].index(3) + 1 if skill == 0: # because of hp logic on final boss required_level = 8 gun_levels = cast(ZillionGunLevels, wo.gun_levels[p]) - gun_option = gun_levels.get_current_option_name().lower() + gun_option = gun_levels.current_key guns_required = char_to_gun["Champ"][cast(ZzVBLR, gun_option)].index(3) floppy_req = cast(ZillionFloppyReq, wo.floppy_req[p]) @@ -347,10 +347,14 @@ def validate(world: "MultiWorld", p: int) -> "Tuple[ZzOptions, Counter[str]]": # that should be all of the level requirements met + name_capitalization = { + "jj": "JJ", + "apple": "Apple", + "champ": "Champ", + } + start_char = cast(ZillionStartChar, wo.start_char[p]) - start_char_name = start_char.get_current_option_name() - if start_char_name == "Jj": - start_char_name = "JJ" + start_char_name = name_capitalization[start_char.current_key] assert start_char_name in chars start_char_name = cast(Chars, start_char_name) diff --git a/worlds/zillion/region.py b/worlds/zillion/region.py index 29ffb01d2b..cf5aa65889 100644 --- a/worlds/zillion/region.py +++ b/worlds/zillion/region.py @@ -15,7 +15,7 @@ class ZillionRegion(Region): name: str, hint: str, player: int, - multiworld: Optional[MultiWorld] = None) -> None: + multiworld: MultiWorld) -> None: super().__init__(name, player, multiworld, hint) self.zz_r = zz_r