Compare commits

...

37 Commits

Author SHA1 Message Date
NewSoupVi
453d89460f Update Options.py 2025-05-10 04:11:28 +02:00
NewSoupVi
28889e58aa Update Options.py
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-09 13:28:36 +02:00
NewSoupVi
f3c76399e0 Update Options.py 2025-05-08 15:00:21 +02:00
NewSoupVi
8384a23fe2 Institute limit on StartInventory 2025-05-08 14:54:11 +02:00
kbranch
bcd7d62d0b LADX: Improve Fake Tracker Items (#4897) 2025-05-07 14:53:58 -04:00
digiholic
703f5a22fd OSRS: New Tasks, New Options, Compatibility with new Plugin Features (#4688) 2025-05-07 13:43:03 -04:00
Benjamin S Wolf
1ee8e339af Launcher: Warn if there is no File Browser (#4275) 2025-05-07 12:51:26 -04:00
Ixrec
dffde64079 Docs: add a "soft logic" question to apworld_dev_faq.md (#4953)
* add a "soft logic" question to apworld_dev_faq.md

* Update apworld_dev_faq.md

* Update docs/apworld_dev_faq.md

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Update docs/apworld_dev_faq.md

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* add a reminder about progression and how it influences soft logic implementations

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-07 12:20:21 -04:00
Scipio Wright
17bc184e28 TUNIC: Add Hidden all_random Option (#4635) 2025-05-07 10:59:16 -04:00
qwint
0ba9ee0695 Docs: update line length in apworld faq doc (#4960) 2025-05-07 10:47:14 -04:00
Scipio Wright
c40214e20f Docs: Minor Changes to apworld_dev_faq.md (#4947)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-05-07 10:41:37 -04:00
Scipio Wright
a3aac3d737 TUNIC: Entrance rando Direction Pairs + Decoupled (#3761)
* Fix merge conflict

* Fix formatting, fix rule for heir access after merge

* Writing combat logic helpers

* More helpers!

* More logic!

* Rename has_stick to has_melee, some fixes per Medic's review

* Clamp max power from sword upgrades

* Wrote the rest of the helpers

* Remove unused import

* Apply item classifications

* Create the combat logic option

* Item classification varies based on option

* Add the shop sword logic stuff in

* Add the rules for the boss-only option

* Fix tiny issues

* Some early Overworld combat logic

* Fill out swamp combat logic

* Add note

* Bump up Boss Scav and Heir

* More revisions to combat logic

* Some changes, currently broken

* New system for power, kinda jank probably

* Revisions to new system, needs more balancing

* Cap attack upgrades

* Uncap mp power since it's directly related to damage output

* Voidlings

* Put together a table showing the vanilla-expected stats for each area

* Added some info on potion counts

* Made new helper functions

* Make has_required_stats

* Make has_combat_reqs

* Update er_rules for new combat reqs

* Fix all the broken things ever

* Remove outdated todo

* Make temp option for testing logic

* More flexible choices for combat items

* Hard require sword for bosses

* Temporarily default combat logic to on

* Finish writing overworld combat logic

* East Forest combat logic done

* Remove a few easy ones

* Finish beneath the well

* Dark Tomb combat logic

* West Garden combat logic

* make unit tests checkmark again

* Weird west garden dagger house edge case

* Try block for that weird west garden edge case

* Add quarry combat logic

* Update to filter out unreachable regions outside of ER

* Fortress Grave Path logic, and a couple fixes to the west garden logic

* Fortress east shortcut logic, and rewriting the try except blocks to use finally

* Refactor to use a new function cause wow there was a lot of repeated code

* Add combat logic to the other two sets of fortress fuses

* Add combat rules to beneath the vault

* Fix missing cathedral -> elevator connection

* Combat logic for cathedral to elevator

* Add cathedral main region, rename cathedral -> cathedral entry

* Setup cathedral combat logic

* Adjust locations' regions for ER

* Add laurels zip logic to the chest in the spike room in cathedral

* Add combat logic to frog's domain

* Move frog's domain locations to regions for combat logic

* Add new frog's domain regions for combat logic

* Update region name for frog's domain

* Fix typo

* Add more regions for lower zig

* Move around lower zig regions for combat logic

* Lower Zig combat logic

* Upper zig combat logic

* Fix typo

* Fix typos

* Fix missing world.

* Update combat logic description

* Add todo

* Add todo

* Don't make zig skip if er or fixed shop is off

* Make it so zig skip is only made with fewer shops and er

* Temporarily default combat logic on

* Update test to explicitly disable combat logic

* Update test_access.py

* Slight wording changes

* Fix bugs, refactor quarry regions so you can access chests in lower quarry with ice grapples

* Run through checks you can do with magic dagger

* Run through checks you can do with magic dagger

* Add rule for entering town portal of having equipment to deal with enemies

* Add rule for atoll near the 6 crabs surrounding a poor defenseless baby slorm

* Update the rule for the chest near the 6 crabs surrounding a slorm to also possibly require laurels

* Revamp combat logic function to work properly without melee

* Add laurels rules to combat logic chests

* Modify beneath the vault bridge rule to need a lantern if combat logic is on

* Put in money logic

* Dagger or combat for swamp big skeleton chest

* Remove the 100 moneys from logic

* Modify lower zig ls drop region destinations

* Remove completed todo

* Reword combat logic option description, remove test option

* Add combat logic to slot data

* Merge Silent's missing slot data bugfix PR #3628

* Remove test combat option

* Update combat logic description

* Fix secret gathering place issue

* Fix secret gathering place issue

* Fix lower zig ls rule

* Fix accidentally removed librarian rule

* Remove redundant rule

* Update gauntlet rule to hard-require a sword

* Add test for a problematic connection

* Adjust combat logic to deal with weird edge cases so it doesn't take stuff out of logic that was previously in logic

* Fix create_item classification

* Update some comments

* Update per exempt's suggestion

* Add combat logic to the well boss fight, reorder the combat logic stuff a little to better section them off

* Add EntranceLayout option

* Add back LogicRules as an invisible option, to not break old yamls

* Fix a bug with seed group, continue changing fixed shop to entrance layout

* Fix missed fixed shop -> entrance layout spot

* Fix bug in seed groups with fixed shop on and off

* Add entrance layout to the UT regen stuff

* Put direction. in, will add them later

* Remove unused elevation from portal class

* Got like half of them in

* Finish adding all of the directions

* Add combat rule for zig front to back

* Update per Medic's suggestion

* Update ladder storage without items option description

* Mess with state with collect and remove to save like 2 seconds (never again)

* Save even more time, still never going to do this again on anything else

* Add option check for collect and remove

* Add directions to shop portals

* Update direction in Portal with default

* Move Direction above Portal

* Add decoupled option, mess with plando connection stuff

* Merge, implement verify plando directions

* Condense the stuff in change and remove to less lines (thanks medic)

* Remove unused thing

* Swap to using logicmixin instead of prog_items (thanks Vi)

* Fix consistency in stat counters

* Add back something that was needed

* Fix mistake when adding back

* Making the fix better (thanks medic)

* Make it actually return false if it gets to the backup lists and fails them

* Fix stuff after merge

* Add outlet regions, create new regions as needed for them

* Put together part of decoupled and direction pairs

* make direction pairs work

* Make decoupled work

* Make fixed shop work again

* Fix a few minor bugs

* Fix a few minor bugs

* Fix plando

* god i love programming

* Reorder portal list

* Update portal sorter for variable shops

* Add missing parameter

* Some cleanup of prints and functions

* Fix typo

* it's aliiiiiive

* Make seed groups not sync decoupled

* Add test with full-shop plando

* Fix bug with vanilla portals

* Handle plando connections and direction pair errors

* Update plando checking for decoupled

* Fix typo

* Fix exception text to be shorter

* Add some more comments

* Add todo note

* Remove unused safety thing

* Remove extra plando connections definition in options

* Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log

* Fix weird edge case that is technically user error

* Add note to fixed shop

* Fix parsing shop names in UT

* Remove debug print

* Actually make UT work

* multiworld. to world.

* Fix typo from merge

* Make it so the shops show up in the entrance hints

* Fix bug in ladder storage rules

* Remove blank line

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py
#	worlds/tunic/rules.py
#	worlds/tunic/test/test_access.py

* Fix issues after merge

* Update plando connections stuff in docs

* Fix library mistake

* has_stick -> has_melee

* has_stick -> has_melee

* Add a failsafe for direction pairing

* Fix playthrough crash bug

* Remove init from logicmixin

* Updates per code review (thanks hesto)

* has_stick to has_melee in newer update

* has_stick to has_melee in newer update

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/combat_logic.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py

* Cleanup more stuff after merge

* Revert "Cleanup more stuff after merge"

This reverts commit a6ee9a93da.

* Revert "# Conflicts:"

This reverts commit c74ccd74a4.

* Cleanup more stuff after merge

* Swap to .get for decoupled so it works with older games probably maybe

* Fix after merge

* Fix typo

* Fix UT support with fixed shop option

* Backport plando connections fix

* Fix issue with fixed shop + decoupled

* Make the error not duplicate the while loop condition

* Fix rule for quarry back to monastery

* Fix more stuff after merge

* Make it not output anything if you set plando connections but not ER

* Add obvious note to plando connections description

* Fix after merge

* add comment to commented out connection

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-06 12:33:21 -04:00
Seldom
7bbe62019a Terraria: Fix inaccessible Leading Landlord achievement when getfixedboi is enabled #4958 2025-05-06 18:32:55 +02:00
Aaron Wagener
b898b9d9e6 The Messenger: fix indentation in setup guide (#4959)
* The Messenger: fix indentation in setup guide

* just delete the save backup section tbh
2025-05-06 18:32:30 +02:00
Exempt-Medic
b217372fea Core: Make Perfect Fuzzy Match Prioritize Casing (#4956) 2025-05-05 19:18:20 -04:00
Jérémie Bolduc
b2d2c8e596 Stardew Valley: Add void mayo requirement for Goblin Problem quest (#4933)
This adds the requirement of a void mayo for the Goblin Problem quest. There are also some small adjustments to related rules
- Fishing a void mayo is only considered an option during the Goblin Problem quest, as the odds of finding one after the quest drops drastically.
- Entrance to the witch hut now requires the goblin problem quest, not just a void mayo.
- Fishing rules are all moved to `fishing_logic.py`.
- `can_fish_at` no longer check that you have any of the fishing regions and the region you actually want to fish in.
- created `can_fish_anywhere` and `can_crab_pot_anywhere` to better illustrate when any fish satisfies the rule.
2025-05-04 16:28:38 +02:00
Fabian Dill
68e37b8f9a Factorio: client cleanup and prevent process bomb (#4882)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-04 16:22:48 +02:00
Fabian Dill
fa2d7797f4 Core: update certifi (#4954) 2025-05-04 15:59:41 +02:00
Jonathan Tan
1885dab066 TWW: Documentation Cleanup (#4942) 2025-05-03 20:06:16 -04:00
Tim Mahan
9425f5b772 Docs: Direct Mac users to Launcher.py (#4767) 2025-05-03 08:42:52 -04:00
Fabian Dill
83ed3c8b50 Core: always embed Archipelago (#4880) 2025-05-03 11:53:52 +02:00
qwint
f4690e296d CommonClient: remove Datapackage Version handling (#4487)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-03 01:31:40 +02:00
Fabian Dill
68c350b4c0 CommonClient: rip out old global name lookup (#4941) 2025-05-02 23:39:52 +02:00
Fabian Dill
da0207f5cb Factorio: implement custom filler items (#4945) 2025-05-02 23:39:14 +02:00
Aaron Wagener
2455f1158f Options: Cleanup CommonOptions.as_dict (#4921)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-02 12:39:58 -04:00
Fabian Dill
1031fc4923 Factorio: remove FactorioClient executable (#4928) 2025-05-02 15:59:27 +02:00
qwint
6beaacb905 Generate: Better yaml parsing error messaging (#4927)
Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>
2025-05-02 09:46:34 -04:00
Scipio Wright
c46ee7c420 TUNIC: Lock pre-placed filler to make the game play nicer with prog balancing (#4917) 2025-04-30 21:57:46 +02:00
Bryce Wilson
227f0bce3d Pokemon Red/Blue: Convert to Procedure Patch (#4801) 2025-04-30 16:31:33 +02:00
PoryGone
611e1c2b19 SMW: v2.1 Feature Update (#4652)
### Features:
- Trap Link
  - When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Ring Link
    - Any coin amounts gained and lost by a linked player will be instantly shared with all other active linked players

Co-authored-by: TheLX5 <luisyuregi@gmail.com>
2025-04-30 16:24:10 +02:00
Mysteryem
5f974b7457 SM: Fix FakeROM instances sharing the same data dictionary (#4912)
FakeROM instances were being created with default arguments, which
included a mutable default argument data dictionary, so all FakeROM
instances would be writing to and reading the same dictionary, resulting
in broken patch data in multiworlds with more than one Super Metroid
world.
2025-04-30 04:57:35 +02:00
threeandthreee
3ef35105c8 LADX: Remove copyrighted assets (#4935) 2025-04-30 04:27:54 +02:00
Alchav
ec768a2e89 ALTTP: Swamp Palace West logic fix (#4936) 2025-04-29 16:53:31 +02:00
black-sliver
b580d3c25a CI: add optional windows release build and build attestation (#4940)
* CI: github attestation for manually started builds

* CI: include appimage zsync in build attestation

* CI: github attestation for Linux release builds

* CI: reorder steps in build.yml

* CI: add windows builds to release.yml

* CI: order jobs in release.yml

* CI: add missing permission to release.yml

* CI: enable windows build in release.yml

* CI: false is skip
2025-04-29 08:32:36 +02:00
Jérémie Bolduc
ce14f190fb Stardew Valley: Replace event creation stardew code with add_event (#4922)
* replace event creation stardew code with add_event

* delete unnecessary default args
2025-04-29 00:12:52 +02:00
Jonathan Tan
4e3da005d4 TWW: Fix generation failure with output file (#4932) 2025-04-27 09:43:24 +02:00
Exempt-Medic
0d9967e8d8 OC2: Account for Multiclass Items in Progression Balancing (#4929) 2025-04-26 13:28:07 -04:00
75 changed files with 2734 additions and 1316 deletions

View File

@@ -21,12 +21,17 @@ env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13 APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-win: # RCs will still be built and signed by hand build-win: # RCs and releases may still be built and signed by hand
runs-on: windows-latest runs-on: windows-latest
steps: steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install python - name: Install python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
@@ -65,6 +70,18 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name $SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds - name: Check build loads expected worlds
shell: bash shell: bash
run: | run: |
@@ -142,6 +159,16 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml - # - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again - name: Build Again
run: | run: |
source venv/bin/activate source venv/bin/activate

View File

@@ -11,6 +11,11 @@ env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13 APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs: jobs:
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -26,11 +31,79 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-release-win:
runs-on: windows-latest
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
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
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204: build-release-ubuntu2204:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: create-release
steps: steps:
- name: Set env - name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -74,6 +147,14 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml - # - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release - name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with: with:

View File

@@ -196,25 +196,11 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})" self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {} self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict( self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item))) lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker # noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]: def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key] return self._game_store[key]
def __len__(self) -> int: def __len__(self) -> int:
@@ -254,7 +240,6 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item) id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()}) id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table) self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago": if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage, # Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically. # it updates in all chain maps automatically.
@@ -356,7 +341,6 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item") self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location") self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {} self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
@@ -571,7 +555,6 @@ class CommonContext:
# DataPackage # DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str], async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]): remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld. """Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server.""" Download, assimilate and cache missing data from the server."""
@@ -580,33 +563,26 @@ class CommonContext:
needed_updates: typing.Set[str] = set() needed_updates: typing.Set[str] = set()
for game in relevant_games: for game in relevant_games:
if game not in remote_date_package_versions and game not in remote_data_package_checksums: if game not in remote_data_package_checksums:
continue continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game) remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game if not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game) needed_updates.add(game)
continue continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game) cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough # no action required if cached version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \ if remote_checksum != cached_checksum:
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if ((remote_checksum or remote_version <= local_version and remote_version != 0) if remote_checksum == local_checksum:
and remote_checksum == local_checksum):
self.update_game(network_data_package["games"][game], game) self.update_game(network_data_package["games"][game], game)
else: else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum") cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough # download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \ if remote_checksum != cache_checksum:
or remote_checksum != cache_checksum:
needed_updates.add(game) needed_updates.add(game)
else: else:
self.update_game(cached_game, game) self.update_game(cached_game, game)
@@ -616,7 +592,6 @@ class CommonContext:
def update_game(self, game_package: dict, game: str): def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"]) self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"]) self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum") self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict): def update_data_package(self, data_package: dict):
@@ -887,9 +862,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package # update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {}) data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums) await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.server_auth(args['password']) await ctx.server_auth(args['password'])

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()

View File

@@ -252,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
except Exception as e: except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e raise Exception(f"Failed to read weights ({path})") from e
return tuple(parse_yamls(yaml)) from yaml.error import MarkedYAMLError
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
def interpret_on_off(value) -> bool: def interpret_on_off(value) -> bool:

View File

@@ -84,12 +84,16 @@ def browse_files():
def open_folder(folder_path): def open_folder(folder_path):
if is_linux: if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open') exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos: elif is_macos:
exe = which("open") exe = which("open")
subprocess.Popen([exe, folder_path])
else: else:
webbrowser.open(folder_path) webbrowser.open(folder_path)
return
if exe:
subprocess.Popen([exe, folder_path])
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings(): def update_settings():

View File

@@ -33,7 +33,7 @@ from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
class GameboyException(Exception): class GameboyException(Exception):
@@ -52,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
pass pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants: class LAClientConstants:
# Connector version # Connector version
VERSION = 0x01 VERSION = 0x01
@@ -530,7 +514,9 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None: def run_gui(self) -> None:
import webbrowser import webbrowser
from kvui import GameManager, ImageButton from kvui import GameManager
from kivy.metrics import dp
from kivymd.uix.button import MDButton, MDButtonText
class LADXManager(GameManager): class LADXManager(GameManager):
logging_pairs = [ logging_pairs = [
@@ -543,8 +529,10 @@ class LinksAwakeningContext(CommonContext):
b = super().build() b = super().build()
if self.ctx.magpie_enabled: if self.ctx.magpie_enabled:
button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None, button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button.height = self.server_connect_bar.height
self.connect_layout.add_widget(button) self.connect_layout.add_widget(button)
return b return b
@@ -638,6 +626,11 @@ class LinksAwakeningContext(CommonContext):
"password": self.password, "password": self.password,
}) })
# We can process linked items on already-checked checks now that we have slot_data
if self.client.tracker:
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
self.add_linked_items(checked_checks)
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): for index, item in enumerate(args["items"], start=args["index"]):
@@ -653,6 +646,13 @@ class LinksAwakeningContext(CommonContext):
sync_msg = [{'cmd': 'Sync'}] sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg) await self.send_msgs(sync_msg)
def add_linked_items(self, checks: typing.List[Check]):
for check in checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
item_id_lookup = get_locations_to_id() item_id_lookup = get_locations_to_id()
async def run_game_loop(self): async def run_game_loop(self):
@@ -661,11 +661,7 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks] checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks]) self.new_checks(checks, [check.id for check in ladxr_checks])
for check in ladxr_checks: self.add_linked_items(ladxr_checks)
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
async def victory(): async def victory():
await self.send_victory() await self.send_victory()

View File

@@ -301,6 +301,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
game_world.game: worlds.network_data_package["games"][game_world.game] game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values() for game_world in multiworld.worlds.values()
} }
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}

View File

@@ -1292,42 +1292,47 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing progression_balancing: ProgressionBalancing
accessibility: Accessibility accessibility: Accessibility
def as_dict(self, def as_dict(
*option_names: str, self,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake", *option_names: str,
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]: casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> dict[str, typing.Any]:
""" """
Returns a dictionary of [str, Option.value] Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return :param option_names: Names of the options to get the values of.
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` :param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
:param toggles_as_bools: whether toggle options should be output as bools instead of strings :param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
will be returned as a sorted list.
""" """
assert option_names, "options.as_dict() was used without any option names." assert option_names, "options.as_dict() was used without any option names."
option_results = {} option_results = {}
for option_name in option_names: for option_name in option_names:
if option_name in type(self).type_hints: if option_name not in type(self).type_hints:
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
return option_results return option_results
@@ -1348,6 +1353,7 @@ class StartInventory(ItemDict):
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):

View File

@@ -635,6 +635,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float: def get_fuzzy_ratio(word1: str, word2: str) -> float:
if word1 == word2:
return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2))) / max(len(word1), len(word2)))
@@ -655,8 +657,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
picks = get_fuzzy_results(input_text, possible_answers, limit=2) picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1: if len(picks) > 1:
dif = picks[0][1] - picks[1][1] dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100: if picks[0][1] == 101:
return picks[0][0], True, "Perfect Match" return picks[0][0], True, "Perfect Match"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75: elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,7 +8,11 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
### My game has a restrictive start that leads to fill errors ### My game has a restrictive start that leads to fill errors
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one. A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
than one item to get a player to sphere 2.
One way to fix this is to hint to the Generator that an item needs to be in sphere one with local_early_items.
Here, `1` represents the number of "Sword" items the Generator will attempt to place in sphere one.
```py ```py
early_item_name = "Sword" early_item_name = "Sword"
self.multiworld.local_early_items[self.player][early_item_name] = 1 self.multiworld.local_early_items[self.player][early_item_name] = 1
@@ -18,15 +22,19 @@ Some alternative ways to try to fix this problem are:
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start * Add more locations to sphere one of your world, potentially only when there would be a restrictive start
* Pre-place items yourself, such as during `create_items` * Pre-place items yourself, such as during `create_items`
* Put items into the player's starting inventory using `push_precollected` * Put items into the player's starting inventory using `push_precollected`
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start * Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a
restrictive start
--- ---
### I have multiple settings that change the item/location pool counts and need to balance them out ### I have multiple options that change the item/location pool counts and need to make sure I am not submitting more/fewer items than locations
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible. In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be
unbalanced. But in real, complex situations, that might be unfeasible.
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit If that's the case, you can create extra filler based on the difference between your unfilled locations and your
itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations)
to your list of items to submit
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
```py ```py
@@ -39,7 +47,8 @@ for _ in range(total_locations - len(item_pool)):
self.multiworld.itempool += item_pool self.multiworld.itempool += item_pool
``` ```
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions): A faster alternative to the `for` loop would be to use a
[list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
```py ```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))] item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
``` ```
@@ -48,24 +57,39 @@ item_pool += [self.create_filler() for _ in range(total_locations - len(item_poo
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary? ### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated. The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and
**when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is
quite complicated.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check. Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph.
It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to
the queue until there is nothing more to check.
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen: For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search. access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been
reached yet during the graph search.
2. Then, the region in its access_rule is determined to be reachable. 2. Then, the region in its access_rule is determined to be reachable.
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle. This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached. To account for this case, AP would have to recheck all entrances every time a new region is reached until no new
regions are reached.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it. An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found". if a specific region is reached during it.
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region. This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness),
using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they
call `region.can_reach` on their respective parent/source region.
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules. We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition,
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost. and that some games have very complex access rules.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster. As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682)
being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of
checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should
be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are
much faster.
--- ---
@@ -85,3 +109,16 @@ Common situations where this can happen include:
Also, consider using the `options.as_dict("option_name", "option_two")` helper. Also, consider using the `options.as_dict("option_name", "option_two")` helper.
* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`, * Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`,
make sure that you are not using your enum class for either the names or ids in these mappings. make sure that you are not using your enum class for either the names or ids in these mappings.
---
### Some locations are technically possible to check with few or no items, but they'd be very tedious or frustrating. How do worlds deal with this?
Sometimes the game can be modded to skip these locations or make them less tedious. But when this issue is due to a fundamental aspect of the game, then the general answer is "soft logic" (and its subtypes like "combat logic", "money logic", etc.). For example: you can logically require that a player have several helpful items before fighting the final boss, even if a skilled player technically needs no items to beat it. Randomizer logic should describe what's *fun* rather than what's technically possible.
Concrete examples of soft logic include:
- Defeating a boss might logically require health upgrades, damage upgrades, certain weapons, etc. that aren't strictly necessary.
- Entering a high-level area might logically require access to enough other parts of the game that checking other locations should naturally get the player to the soft-required level.
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.

View File

@@ -7,7 +7,7 @@ schema>=0.7.7
kivy>=2.3.1 kivy>=2.3.1
bsdiff4>=1.2.6 bsdiff4>=1.2.6
platformdirs>=4.3.6 platformdirs>=4.3.6
certifi>=2025.1.31 certifi>=2025.4.26
cython>=3.0.12 cython>=3.0.12
cymem>=2.0.11 cymem>=2.0.11
orjson>=3.10.15 orjson>=3.10.15

View File

@@ -47,17 +47,6 @@ class TestCommonContext(unittest.IsolatedAsyncioTestCase):
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist" assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist" assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
async def test_implicit_name_lookups(self):
# Items
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names[-1] == "Nothing"
# Locations
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names[-1] == "Cheat Console"
async def test_explicit_name_lookups(self): async def test_explicit_name_lookups(self):
# Items # Items
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe" assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"

View File

@@ -485,7 +485,7 @@ class World(metaclass=AutoWorldRegister):
def get_filler_item_name(self) -> str: def get_filler_item_name(self) -> str:
"""Called when the item pool needs to be filled with additional items to match location count.""" """Called when the item pool needs to be filled with additional items to match location count."""
logging.warning(f"World {self} is generating a filler item without custom filler pool.") logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.multiworld.random.choice(tuple(self.item_name_to_id.keys())) return self.random.choice(tuple(self.item_name_to_id.keys()))
@classmethod @classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World: def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:

View File

@@ -393,9 +393,7 @@ def global_rules(multiworld: MultiWorld, player: int):
if world.options.pot_shuffle: if world.options.pot_shuffle:
# it could move the key to the top right platform which can only be reached with bombs # it could move the key to the top right platform which can only be reached with bombs
add_rule(multiworld.get_location('Swamp Palace - Hookshot Pot Key', player), lambda state: can_use_bombs(state, player)) add_rule(multiworld.get_location('Swamp Palace - Hookshot Pot Key', player), lambda state: can_use_bombs(state, player))
set_rule(multiworld.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6) set_rule(multiworld.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
if state.has('Hookshot', player)
else state._lttp_has_key('Small Key (Swamp Palace)', player, 4))
set_rule(multiworld.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player)) set_rule(multiworld.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player))
if world.options.accessibility != 'full': if world.options.accessibility != 'full':
allow_self_locking_items(multiworld.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)') allow_self_locking_items(multiworld.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)')

View File

@@ -24,7 +24,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - Big Key Chest", False, [], ['Open Floodgate']], ["Swamp Palace - Big Key Chest", False, [], ['Open Floodgate']],
["Swamp Palace - Big Key Chest", False, [], ['Hammer']], ["Swamp Palace - Big Key Chest", False, [], ['Hammer']],
["Swamp Palace - Big Key Chest", False, [], ['Small Key (Swamp Palace)']], ["Swamp Palace - Big Key Chest", False, [], ['Small Key (Swamp Palace)']],
["Swamp Palace - Big Key Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']], ["Swamp Palace - Big Key Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Map Chest", False, []], ["Swamp Palace - Map Chest", False, []],
["Swamp Palace - Map Chest", False, [], ['Flippers']], ["Swamp Palace - Map Chest", False, [], ['Flippers']],
@@ -38,7 +38,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - West Chest", False, [], ['Open Floodgate']], ["Swamp Palace - West Chest", False, [], ['Open Floodgate']],
["Swamp Palace - West Chest", False, [], ['Hammer']], ["Swamp Palace - West Chest", False, [], ['Hammer']],
["Swamp Palace - West Chest", False, [], ['Small Key (Swamp Palace)']], ["Swamp Palace - West Chest", False, [], ['Small Key (Swamp Palace)']],
["Swamp Palace - West Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']], ["Swamp Palace - West Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Compass Chest", False, []], ["Swamp Palace - Compass Chest", False, []],
["Swamp Palace - Compass Chest", False, [], ['Flippers']], ["Swamp Palace - Compass Chest", False, [], ['Flippers']],

View File

@@ -9,7 +9,6 @@ import random
import re import re
import string import string
import subprocess import subprocess
import sys import sys
import time import time
import typing import typing
@@ -17,15 +16,16 @@ from queue import Queue
import factorio_rcon import factorio_rcon
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled, get_base_parser from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled, get_base_parser
from MultiServer import mark_raw from MultiServer import mark_raw
from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart
from Utils import async_start, get_file_safe_name from Utils import async_start, get_file_safe_name, is_windows, Version, format_SI_prefix, get_text_between
from .settings import FactorioSettings
from settings import get_settings
def check_stdin() -> None: def check_stdin() -> None:
if Utils.is_windows and sys.stdin: if is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.") print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
@@ -67,7 +67,7 @@ class FactorioContext(CommonContext):
items_handling = 0b111 # full remote items_handling = 0b111 # full remote
# updated by spinup server # updated by spinup server
mod_version: Utils.Version = Utils.Version(0, 0, 0) mod_version: Version = Version(0, 0, 0)
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool): def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool):
super(FactorioContext, self).__init__(server_address, password) super(FactorioContext, self).__init__(server_address, password)
@@ -133,7 +133,7 @@ class FactorioContext(CommonContext):
elif self.current_energy_link_value is None: elif self.current_energy_link_value is None:
return "Standby" return "Standby"
else: else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J" return f"{format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict): def on_deathlink(self, data: dict):
if self.rcon_client: if self.rcon_client:
@@ -155,10 +155,10 @@ class FactorioContext(CommonContext):
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete: if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
# it's our deplete request # it's our deplete request
gained = int(args["original_value"] - args["value"]) gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J" gained_text = format_SI_prefix(gained) + "J"
if gained: if gained:
logger.debug(f"EnergyLink: Received {gained_text}. " logger.debug(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.") f"{format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}") self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]: def on_user_say(self, text: str) -> typing.Optional[str]:
@@ -278,7 +278,7 @@ async def game_watcher(ctx: FactorioContext):
}])) }]))
ctx.rcon_client.send_command( ctx.rcon_client.send_command(
f"/ap-energylink -{value}") f"/ap-energylink -{value}")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J") logger.debug(f"EnergyLink: Sent {format_SI_prefix(value)}J")
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -439,9 +439,9 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
factorio_server_logger.info(msg) factorio_server_logger.info(msg)
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"): if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
parts = msg.split() parts = msg.split()
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split("."))) ctx.mod_version = Version(*(int(number) for number in parts[-2].split(".")))
elif "Write data path: " in msg: elif "Write data path: " in msg:
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [") ctx.write_data_path = get_text_between(msg, "Write data path: ", " [")
if "AppData" in ctx.write_data_path: if "AppData" in ctx.write_data_path:
logger.warning("It appears your mods are loaded from Appdata, " logger.warning("It appears your mods are loaded from Appdata, "
"this can lead to problems with multiple Factorio instances. " "this can lead to problems with multiple Factorio instances. "
@@ -521,10 +521,16 @@ rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join( rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for x in range(32)) random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer") factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_settings() settings: FactorioSettings = get_settings().factorio_options
executable = options["factorio_options"]["executable"] if os.path.samefile(settings.executable, sys.executable):
selected_executable = settings.executable
settings.executable = FactorioSettings.executable # reset to default
raise Exception(f"FactorioClient was set to run itself {selected_executable}, aborting process bomb.")
executable = settings.executable
server_settings = args.server_settings if args.server_settings \ server_settings = args.server_settings if args.server_settings \
else options["factorio_options"].get("server_settings", None) else getattr(settings, "server_settings", None)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password) server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password)
@@ -535,12 +541,8 @@ def launch():
if server_settings: if server_settings:
server_settings = os.path.abspath(server_settings) server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool): initial_filter_item_sends = bool(settings.filter_item_sends)
logging.warning(f"Warning: Option filter_item_sends should be a bool.") initial_bridge_chat_out = bool(settings.bridge_chat_out)
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)): if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")

View File

@@ -5,7 +5,6 @@ import logging
import typing import typing
import Utils import Utils
import settings
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import Component, components, Type, launch as launch_component from worlds.LauncherComponents import Component, components, Type, launch as launch_component
@@ -20,6 +19,7 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \ progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \ get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
fluids, stacking_items, valid_ingredients, progressive_rows fluids, stacking_items, valid_ingredients, progressive_rows
from .settings import FactorioSettings
def launch_client(): def launch_client():
@@ -27,30 +27,7 @@ def launch_client():
launch_component(launch, name="FactorioClient") launch_component(launch, name="FactorioClient")
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT)) components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT))
class FactorioSettings(settings.Group):
class Executable(settings.UserFilePath):
is_exe = True
class ServerSettings(settings.OptionalUserFilePath):
"""
by default, no settings are loaded if this file does not exist. \
If this file does exist, then it will be used.
server_settings: "factorio\\\\data\\\\server-settings.json"
"""
class FilterItemSends(settings.Bool):
"""Whether to filter item send messages displayed in-game to only those that involve you."""
class BridgeChatOut(settings.Bool):
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
executable: Executable = Executable("factorio/bin/x64/factorio")
server_settings: typing.Optional[FactorioSettings.ServerSettings] = None
filter_item_sends: typing.Union[FilterItemSends, bool] = False
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True
class FactorioWeb(WebWorld): class FactorioWeb(WebWorld):
@@ -115,6 +92,7 @@ class Factorio(World):
settings: typing.ClassVar[FactorioSettings] settings: typing.ClassVar[FactorioSettings]
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill") "Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
want_progressives: dict[str, bool] = collections.defaultdict(lambda: False)
def __init__(self, world, player: int): def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player) super(Factorio, self).__init__(world, player)
@@ -133,6 +111,8 @@ class Factorio(World):
self.options.max_tech_cost.value, self.options.min_tech_cost.value self.options.max_tech_cost.value, self.options.min_tech_cost.value
self.tech_mix = self.options.tech_cost_mix.value self.tech_mix = self.options.tech_cost_mix.value
self.skip_silo = self.options.silo.value == Silo.option_spawn self.skip_silo = self.options.silo.value == Silo.option_spawn
self.want_progressives = collections.defaultdict(
lambda: self.options.progressive.want_progressives(self.random))
def create_regions(self): def create_regions(self):
player = self.player player = self.player
@@ -201,9 +181,6 @@ class Factorio(World):
range(getattr(self.options, range(getattr(self.options,
f"{trap_name.lower().replace(' ', '_')}_traps"))) f"{trap_name.lower().replace(' ', '_')}_traps")))
want_progressives = collections.defaultdict(lambda: self.options.progressive.
want_progressives(self.random))
cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name) cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name)
special_index = {"automation": 0, special_index = {"automation": 0,
"logistics": 1, "logistics": 1,
@@ -218,7 +195,7 @@ class Factorio(World):
for tech_name in base_tech_table: for tech_name in base_tech_table:
if tech_name not in self.removed_technologies: if tech_name not in self.removed_technologies:
progressive_item_name = tech_to_progressive_lookup.get(tech_name, tech_name) progressive_item_name = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive = want_progressives[progressive_item_name] want_progressive = self.want_progressives[progressive_item_name]
item_name = progressive_item_name if want_progressive else tech_name item_name = progressive_item_name if want_progressive else tech_name
tech_item = self.create_item(item_name) tech_item = self.create_item(item_name)
index = special_index.get(tech_name, None) index = special_index.get(tech_name, None)
@@ -233,6 +210,12 @@ class Factorio(World):
loc.place_locked_item(tech_item) loc.place_locked_item(tech_item)
loc.revealed = True loc.revealed = True
def get_filler_item_name(self) -> str:
tech_name: str = self.random.choice(tuple(tech_table))
progressive_item_name: str = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive: bool = self.want_progressives[progressive_item_name]
return progressive_item_name if want_progressive else tech_name
def set_rules(self): def set_rules(self):
player = self.player player = self.player
shapes = get_shapes(self) shapes = get_shapes(self)

View File

@@ -0,0 +1,26 @@
import typing
import settings
class FactorioSettings(settings.Group):
class Executable(settings.UserFilePath):
is_exe = True
class ServerSettings(settings.OptionalUserFilePath):
"""
by default, no settings are loaded if this file does not exist. \
If this file does exist, then it will be used.
server_settings: "factorio\\\\data\\\\server-settings.json"
"""
class FilterItemSends(settings.Bool):
"""Whether to filter item send messages displayed in-game to only those that involve you."""
class BridgeChatOut(settings.Bool):
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
executable: Executable = Executable("factorio/bin/x64/factorio")
server_settings: typing.Optional[ServerSettings] = None
filter_item_sends: typing.Union[FilterItemSends, bool] = False
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True

View File

@@ -20,9 +20,11 @@ It is generally recommended that you use a virtual environment to run python bas
3. Run the command `source venv/bin/activate` to activate the virtual environment. 3. Run the command `source venv/bin/activate` to activate the virtual environment.
4. If you want to exit the virtual environment, run the command `deactivate`. 4. If you want to exit the virtual environment, run the command `deactivate`.
## Steps to Run the Clients ## Steps to Run the Clients
1. If your game doesn't have a patch file, run the command `python3 SNIClient.py`, changing the filename with the file of the client you want to run. 1. Run the command `python3 Launcher.py`.
2. If your game does have a patch file, move the base rom to the Archipelago directory and run the command `python3 SNIClient.py 'patchfile'` with the filename extension for the patch file (apsm, aplttp, apsmz3, etc.) included and changing the filename with the file of the client you want to run. 2. If your game doesn't have a patch file, just click the desired client in the right side column.
3. Your client should now be running and rom created (where applicable). 3. If your game does have a patch file, click the 'Open Patch' button and navigate to your patch file (the filename extension will look something like apsm, aplttp, apsmz3, etc.).
4. If the patching process needs a rom, but cannot find it, it will ask you to navigate to your legally obtained rom.
5. Your client should now be running and rom created (where applicable).
## Additional Steps for SNES Games ## Additional Steps for SNES Games
1. If using RetroArch, the instructions to set up your emulator [here in the Link to the Past setup guide](https://archipelago.gg/tutorial/A%20Link%20to%20the%20Past/multiworld/en) also work on the macOS version of RetroArch. 1. If using RetroArch, the instructions to set up your emulator [here in the Link to the Past setup guide](https://archipelago.gg/tutorial/A%20Link%20to%20the%20Past/multiworld/en) also work on the macOS version of RetroArch.
2. Double click on the SNI tar.gz download to extract the files to an SNI directory. If it isn't already, rename this directory to SNI to make some steps easier. 2. Double click on the SNI tar.gz download to extract the files to an SNI directory. If it isn't already, rename this directory to SNI to make some steps easier.

View File

@@ -151,8 +151,7 @@ class ItemTracker:
def __init__(self, gameboy) -> None: def __init__(self, gameboy) -> None:
self.gameboy = gameboy self.gameboy = gameboy
self.loadItems() self.loadItems()
pass self.extraItems = {}
extraItems = {}
async def readRamByte(self, byte): async def readRamByte(self, byte):
return (await self.gameboy.read_memory_cache([byte]))[byte] return (await self.gameboy.read_memory_cache([byte]))[byte]

View File

@@ -23,21 +23,12 @@ These steps can also be followed to launch the game and check for mod updates af
### Manual Installation ### Manual Installation
1. Download and install Courier Mod Loader using the instructions on the release page 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) * [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases)
2. Download and install the randomizer mod 2. Download and install the randomizer mod
1. Download the latest TheMessengerRandomizerAP.zip from 1. Download the latest TheMessengerRandomizerAP.zip from
[The Messenger Randomizer Mod AP releases page](https://github.com/alwaysintreble/TheMessengerRandomizerModAP/releases) [The Messenger Randomizer Mod AP releases page](https://github.com/alwaysintreble/TheMessengerRandomizerModAP/releases)
2. Extract the zip file to `TheMessenger/Mods/` of your game's install location 2. Extract the zip file to `TheMessenger/Mods/` of your game's install location
* You cannot have both the non-AP randomizer and the AP randomizer installed at the same time * You cannot have both the non-AP randomizer and the AP randomizer installed at the same time
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 ## Joining a MultiWorld Game
@@ -57,15 +48,15 @@ These steps can also be followed to launch the game and check for mod updates af
1. Launch the game 1. Launch the game
2. Navigate to `Options > Archipelago Options` 2. Navigate to `Options > Archipelago Options`
3. Enter connection info using the relevant option buttons 3. Enter connection info using the relevant option buttons
* **The game is limited to alphanumerical characters, `.`, and `-`.** * **The game is limited to alphanumerical characters, `.`, and `-`.**
* This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the
website. website.
* If using a name that cannot be entered in the in game menus, there is a config file (APConfig.toml) in the game * If using a name that cannot be entered in the in game menus, there is a config file (APConfig.toml) in the game
directory. When using this, all connection information must be entered in the file. directory. When using this, all connection information must be entered in the file.
4. Select the `Connect to Archipelago` button 4. Select the `Connect to Archipelago` button
5. Navigate to save file selection 5. Navigate to save file selection
6. Start a new game 6. Start a new game
* If you're already connected, deleting an existing save will not disconnect you and is completely safe. * If you're already connected, deleting an existing save will not disconnect you and is completely safe.
## Continuing a MultiWorld Game ## Continuing a MultiWorld Game

View File

@@ -62,7 +62,7 @@ chunksanity_starting_chunks: typing.List[str] = [
ItemNames.South_Of_Varrock, ItemNames.South_Of_Varrock,
ItemNames.Central_Varrock, ItemNames.Central_Varrock,
ItemNames.Varrock_Palace, ItemNames.Varrock_Palace,
ItemNames.East_Of_Varrock, ItemNames.Lumberyard,
ItemNames.West_Varrock, ItemNames.West_Varrock,
ItemNames.Edgeville, ItemNames.Edgeville,
ItemNames.Barbarian_Village, ItemNames.Barbarian_Village,

View File

@@ -8,7 +8,9 @@ import requests
# The CSVs are updated at this repository to be shared between generator and client. # The CSVs are updated at this repository to be shared between generator and client.
data_repository_address = "https://raw.githubusercontent.com/digiholic/osrs-archipelago-logic/" data_repository_address = "https://raw.githubusercontent.com/digiholic/osrs-archipelago-logic/"
# The Github tag of the CSVs this was generated with # The Github tag of the CSVs this was generated with
data_csv_tag = "v1.5" data_csv_tag = "v2.0.4"
# If true, generate using file names in the repository
debug = False
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
@@ -26,98 +28,167 @@ if __name__ == "__main__":
def load_location_csv(): def load_location_csv():
this_dir = os.path.dirname(os.path.abspath(__file__)) this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "locations_generated.py"), 'w+') as locPyFile: with open(os.path.join(this_dir, "locations_generated.py"), 'w+') as loc_py_file:
locPyFile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n') loc_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
locPyFile.write("from ..Locations import LocationRow, SkillRequirement\n") loc_py_file.write("from ..Locations import LocationRow, SkillRequirement\n")
locPyFile.write("\n") loc_py_file.write("\n")
locPyFile.write("location_rows = [\n") loc_py_file.write("location_rows = [\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/locations.csv") as req: if debug:
locations_reader = csv.reader(req.text.splitlines()) with open(os.path.join(this_dir, "locations.csv"), "r") as loc_file:
for row in locations_reader: locations_reader = csv.reader(loc_file.read().splitlines())
row_line = "LocationRow(" parse_loc_file(loc_py_file, locations_reader)
row_line += str_format(row[0]) else:
row_line += str_format(row[1].lower()) print("Loading: " + data_repository_address + "/" + data_csv_tag + "/locations.csv")
with requests.get(data_repository_address + "/" + data_csv_tag + "/locations.csv") as req:
if req.status_code == 200:
locations_reader = csv.reader(req.text.splitlines())
parse_loc_file(loc_py_file, locations_reader)
else:
print(str(req.status_code) + ": " + req.reason)
loc_py_file.write("]\n")
region_strings = row[2].split(", ") if row[2] else []
row_line += f"{str_list_to_py(region_strings)}, "
skill_strings = row[3].split(", ") def parse_loc_file(loc_py_file, locations_reader):
row_line += "[" for row in locations_reader:
if skill_strings: # Skip the header row, if present
split_skills = [skill.split(" ") for skill in skill_strings if skill != ""] if row[0] == "Location Name":
if split_skills: continue
for split in split_skills: row_line = "LocationRow("
row_line += f"SkillRequirement('{split[0]}', {split[1]}), " row_line += str_format(row[0])
row_line += "], " row_line += str_format(row[1].lower())
region_strings = row[2].split(", ") if row[2] else []
row_line += f"{str_list_to_py(region_strings)}, "
skill_strings = row[3].split(", ")
row_line += "["
if skill_strings:
split_skills = [skill.split(" ") for skill in skill_strings if skill != ""]
if split_skills:
for split in split_skills:
row_line += f"SkillRequirement('{split[0]}', {split[1]}), "
row_line += "], "
item_strings = row[4].split(", ") if row[4] else []
row_line += f"{str_list_to_py(item_strings)}, "
row_line += f"{row[5]})" if row[5] != "" else "0)"
loc_py_file.write(f"\t{row_line},\n")
item_strings = row[4].split(", ") if row[4] else []
row_line += f"{str_list_to_py(item_strings)}, "
row_line += f"{row[5]})" if row[5] != "" else "0)"
locPyFile.write(f"\t{row_line},\n")
locPyFile.write("]\n")
def load_region_csv(): def load_region_csv():
this_dir = os.path.dirname(os.path.abspath(__file__)) this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "regions_generated.py"), 'w+') as regPyFile: with open(os.path.join(this_dir, "regions_generated.py"), 'w+') as reg_py_file:
regPyFile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n') reg_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
regPyFile.write("from ..Regions import RegionRow\n") reg_py_file.write("from ..Regions import RegionRow\n")
regPyFile.write("\n") reg_py_file.write("\n")
regPyFile.write("region_rows = [\n") reg_py_file.write("region_rows = [\n")
if debug:
with open(os.path.join(this_dir, "regions.csv"), "r") as region_file:
regions_reader = csv.reader(region_file.read().splitlines())
parse_region_file(reg_py_file, regions_reader)
else:
print("Loading: "+ data_repository_address + "/" + data_csv_tag + "/regions.csv")
with requests.get(data_repository_address + "/" + data_csv_tag + "/regions.csv") as req:
if req.status_code == 200:
regions_reader = csv.reader(req.text.splitlines())
parse_region_file(reg_py_file, regions_reader)
else:
print(str(req.status_code) + ": " + req.reason)
reg_py_file.write("]\n")
def parse_region_file(reg_py_file, regions_reader):
for row in regions_reader:
# Skip the header row, if present
if row[0] == "Region Name":
continue
row_line = "RegionRow("
row_line += str_format(row[0])
row_line += str_format(row[1])
connections = row[2]
row_line += f"{str_list_to_py(connections.split(', '))}, "
resources = row[3]
row_line += f"{str_list_to_py(resources.split(', '))})"
reg_py_file.write(f"\t{row_line},\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/regions.csv") as req:
regions_reader = csv.reader(req.text.splitlines())
for row in regions_reader:
row_line = "RegionRow("
row_line += str_format(row[0])
row_line += str_format(row[1])
connections = row[2].replace("'", "\\'")
row_line += f"{str_list_to_py(connections.split(', '))}, "
resources = row[3].replace("'", "\\'")
row_line += f"{str_list_to_py(resources.split(', '))})"
regPyFile.write(f"\t{row_line},\n")
regPyFile.write("]\n")
def load_resource_csv(): def load_resource_csv():
this_dir = os.path.dirname(os.path.abspath(__file__)) this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "resources_generated.py"), 'w+') as resPyFile: with open(os.path.join(this_dir, "resources_generated.py"), 'w+') as res_py_file:
resPyFile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n') res_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
resPyFile.write("from ..Regions import ResourceRow\n") res_py_file.write("from ..Regions import ResourceRow\n")
resPyFile.write("\n") res_py_file.write("\n")
resPyFile.write("resource_rows = [\n") res_py_file.write("resource_rows = [\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/resources.csv") as req: if debug:
resource_reader = csv.reader(req.text.splitlines()) with open(os.path.join(this_dir, "resources.csv"), "r") as region_file:
for row in resource_reader: regions_reader = csv.reader(region_file.read().splitlines())
name = row[0].replace("'", "\\'") parse_resources_file(res_py_file, regions_reader)
row_line = f"ResourceRow('{name}')" else:
resPyFile.write(f"\t{row_line},\n") print("Loading: " + data_repository_address + "/" + data_csv_tag + "/resources.csv")
resPyFile.write("]\n") with requests.get(data_repository_address + "/" + data_csv_tag + "/resources.csv") as req:
if req.status_code == 200:
resource_reader = csv.reader(req.text.splitlines())
parse_resources_file(res_py_file, resource_reader)
else:
print(str(req.status_code) + ": " + req.reason)
res_py_file.write("]\n")
def parse_resources_file(res_py_file, resource_reader):
for row in resource_reader:
# Skip the header row, if present
if row[0] == "Resource Name":
continue
name = row[0].replace("'", "\\'")
row_line = f"ResourceRow('{name}')"
res_py_file.write(f"\t{row_line},\n")
def load_item_csv(): def load_item_csv():
this_dir = os.path.dirname(os.path.abspath(__file__)) this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "items_generated.py"), 'w+') as itemPyfile: with open(os.path.join(this_dir, "items_generated.py"), 'w+') as item_py_file:
itemPyfile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n') item_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
itemPyfile.write("from BaseClasses import ItemClassification\n") item_py_file.write("from BaseClasses import ItemClassification\n")
itemPyfile.write("from ..Items import ItemRow\n") item_py_file.write("from ..Items import ItemRow\n")
itemPyfile.write("\n") item_py_file.write("\n")
itemPyfile.write("item_rows = [\n") item_py_file.write("item_rows = [\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/items.csv") as req: if debug:
item_reader = csv.reader(req.text.splitlines()) with open(os.path.join(this_dir, "items.csv"), "r") as region_file:
for row in item_reader: regions_reader = csv.reader(region_file.read().splitlines())
row_line = "ItemRow(" parse_item_file(item_py_file, regions_reader)
row_line += str_format(row[0]) else:
row_line += f"{row[1]}, " print("Loading: " + data_repository_address + "/" + data_csv_tag + "/items.csv")
with requests.get(data_repository_address + "/" + data_csv_tag + "/items.csv") as req:
if req.status_code == 200:
item_reader = csv.reader(req.text.splitlines())
parse_item_file(item_py_file, item_reader)
else:
print(str(req.status_code) + ": " + req.reason)
item_py_file.write("]\n")
row_line += f"ItemClassification.{row[2]})"
itemPyfile.write(f"\t{row_line},\n") def parse_item_file(item_py_file, item_reader):
itemPyfile.write("]\n") for row in item_reader:
# Skip the header row, if present
if row[0] == "Name":
continue
row_line = "ItemRow("
row_line += str_format(row[0])
row_line += f"{row[1]}, "
row_line += f"ItemClassification.{row[2]})"
item_py_file.write(f"\t{row_line},\n")
def str_format(s) -> str: def str_format(s) -> str:
@@ -128,7 +199,7 @@ if __name__ == "__main__":
def str_list_to_py(str_list) -> str: def str_list_to_py(str_list) -> str:
ret_str = "[" ret_str = "["
for s in str_list: for s in str_list:
ret_str += f"'{s}', " ret_str += str_format(s)
ret_str += "]" ret_str += "]"
return ret_str return ret_str

View File

@@ -10,7 +10,7 @@ item_rows = [
ItemRow('Area: HAM Hideout', 1, ItemClassification.progression), ItemRow('Area: HAM Hideout', 1, ItemClassification.progression),
ItemRow('Area: Lumbridge Farms', 1, ItemClassification.progression), ItemRow('Area: Lumbridge Farms', 1, ItemClassification.progression),
ItemRow('Area: South of Varrock', 1, ItemClassification.progression), ItemRow('Area: South of Varrock', 1, ItemClassification.progression),
ItemRow('Area: East Varrock', 1, ItemClassification.progression), ItemRow('Area: Lumberyard', 1, ItemClassification.progression),
ItemRow('Area: Central Varrock', 1, ItemClassification.progression), ItemRow('Area: Central Varrock', 1, ItemClassification.progression),
ItemRow('Area: Varrock Palace', 1, ItemClassification.progression), ItemRow('Area: Varrock Palace', 1, ItemClassification.progression),
ItemRow('Area: West Varrock', 1, ItemClassification.progression), ItemRow('Area: West Varrock', 1, ItemClassification.progression),
@@ -37,7 +37,58 @@ item_rows = [
ItemRow('Progressive Armor', 6, ItemClassification.progression), ItemRow('Progressive Armor', 6, ItemClassification.progression),
ItemRow('Progressive Weapons', 6, ItemClassification.progression), ItemRow('Progressive Weapons', 6, ItemClassification.progression),
ItemRow('Progressive Tools', 6, ItemClassification.useful), ItemRow('Progressive Tools', 6, ItemClassification.useful),
ItemRow('Progressive Ranged Weapons', 3, ItemClassification.useful), ItemRow('Progressive Ranged Weapon', 3, ItemClassification.useful),
ItemRow('Progressive Ranged Armor', 3, ItemClassification.useful), ItemRow('Progressive Ranged Armor', 3, ItemClassification.useful),
ItemRow('Progressive Magic', 2, ItemClassification.useful), ItemRow('Progressive Magic Spell', 2, ItemClassification.useful),
ItemRow('An Invitation to the Gielinor Games', 1, ItemClassification.filler),
ItemRow('Settled\'s Crossbow', 1, ItemClassification.filler),
ItemRow('The Stone of Jas', 1, ItemClassification.filler),
ItemRow('Nieve\'s Phone Number', 1, ItemClassification.filler),
ItemRow('Hannanie\'s Lost Sanity', 1, ItemClassification.filler),
ItemRow('XP Waste', 1, ItemClassification.filler),
ItemRow('Ten Free Pulls on the Squeal of Fortune', 1, ItemClassification.filler),
ItemRow('Project Zanaris Beta Invite', 1, ItemClassification.filler),
ItemRow('A Funny Feeling You Would Have Been Followed', 1, ItemClassification.filler),
ItemRow('An Ominous Prediction From Gnome Child', 1, ItemClassification.filler),
ItemRow('A Logic Error', 1, ItemClassification.filler),
ItemRow('The Warding Skill', 1, ItemClassification.filler),
ItemRow('A 1/2500 Chance At Your Very Own Pet Baron Sucellus, Redeemable at your Local Duke, Some Restrictions May Apply', 1, ItemClassification.filler),
ItemRow('A Suspicious Email From Iagex.com Asking for your Password', 1, ItemClassification.filler),
ItemRow('A Review on that Pull Request You\'ve Been Waiting On', 1, ItemClassification.filler),
ItemRow('Fifty Billion RS3 GP (Worthless)', 1, ItemClassification.filler),
ItemRow('Mod Ash\'s Coffee Cup', 1, ItemClassification.filler),
ItemRow('An Embarrasing Photo of Zammorak at the Christmas Party', 1, ItemClassification.filler),
ItemRow('Another Bug To Report', 1, ItemClassification.filler),
ItemRow('1-Up Mushroom', 1, ItemClassification.filler),
ItemRow('Empty White Hallways', 1, ItemClassification.filler),
ItemRow('Area: Menaphos', 1, ItemClassification.filler),
ItemRow('A Ratcatchers Dialogue Rewrite', 1, ItemClassification.filler),
ItemRow('"Nostalgia"', 1, ItemClassification.filler),
ItemRow('A Hornless Unicorn', 1, ItemClassification.filler),
ItemRow('The Ability To Use ::bank', 1, ItemClassification.filler),
ItemRow('Free Haircut at the Falador Hairdresser', 1, ItemClassification.filler),
ItemRow('Nothing Interesting Happens', 1, ItemClassification.filler),
ItemRow('Why Fletch?', 1, ItemClassification.filler),
ItemRow('Evolution of Combat', 1, ItemClassification.filler),
ItemRow('Care Pack: 10,000 GP', 1, ItemClassification.useful),
ItemRow('Care Pack: 90 Steel Nails', 1, ItemClassification.useful),
ItemRow('Care Pack: 25 Swordfish', 1, ItemClassification.useful),
ItemRow('Care Pack: 50 Lobsters', 1, ItemClassification.useful),
ItemRow('Care Pack: 100 Law Runes', 1, ItemClassification.useful),
ItemRow('Care Pack: 300 Each Elemental Rune', 1, ItemClassification.useful),
ItemRow('Care Pack: 100 Chaos Runes', 1, ItemClassification.useful),
ItemRow('Care Pack: 100 Death Runes', 1, ItemClassification.useful),
ItemRow('Care Pack: 100 Oak Logs', 1, ItemClassification.useful),
ItemRow('Care Pack: 50 Willow Logs', 1, ItemClassification.useful),
ItemRow('Care Pack: 50 Bronze Bars', 1, ItemClassification.useful),
ItemRow('Care Pack: 200 Iron Ore', 1, ItemClassification.useful),
ItemRow('Care Pack: 100 Coal Ore', 1, ItemClassification.useful),
ItemRow('Care Pack: 100 Raw Trout', 1, ItemClassification.useful),
ItemRow('Care Pack: 200 Leather', 1, ItemClassification.useful),
ItemRow('Care Pack: 50 Energy Potion (4)', 2, ItemClassification.useful),
ItemRow('Care Pack: 200 Big Bones', 1, ItemClassification.useful),
ItemRow('Care Pack: 10 Each Uncut gems', 1, ItemClassification.useful),
ItemRow('Care Pack: 3 Rings of Forging', 1, ItemClassification.useful),
ItemRow('Care Pack: 500 Rune Essence', 1, ItemClassification.useful),
ItemRow('Care Pack: 200 Mind Runes', 1, ItemClassification.useful),
] ]

View File

@@ -19,37 +19,56 @@ location_rows = [
LocationRow('Quest: Witch\'s Potion', 'quest', ['Rimmington', 'Port Sarim', ], [], [], 0), LocationRow('Quest: Witch\'s Potion', 'quest', ['Rimmington', 'Port Sarim', ], [], [], 0),
LocationRow('Quest: The Knight\'s Sword', 'quest', ['Falador', 'Varrock Palace', 'Mudskipper Point', 'South of Varrock', 'Windmill', 'Pie Dish', 'Port Sarim', ], [SkillRequirement('Cooking', 10), SkillRequirement('Mining', 10), ], [], 0), LocationRow('Quest: The Knight\'s Sword', 'quest', ['Falador', 'Varrock Palace', 'Mudskipper Point', 'South of Varrock', 'Windmill', 'Pie Dish', 'Port Sarim', ], [SkillRequirement('Cooking', 10), SkillRequirement('Mining', 10), ], [], 0),
LocationRow('Quest: Goblin Diplomacy', 'quest', ['Goblin Village', 'Draynor Village', 'Falador', 'South of Varrock', 'Onion', ], [], [], 0), LocationRow('Quest: Goblin Diplomacy', 'quest', ['Goblin Village', 'Draynor Village', 'Falador', 'South of Varrock', 'Onion', ], [], [], 0),
LocationRow('Quest: Pirate\'s Treasure', 'quest', ['Port Sarim', 'Karamja', 'Falador', ], [], [], 0), LocationRow('Quest: Pirate\'s Treasure', 'quest', ['Port Sarim', 'Karamja', 'Falador', 'Central Varrock', ], [], [], 0),
LocationRow('Quest: Rune Mysteries', 'quest', ['Lumbridge', 'Wizard Tower', 'Central Varrock', ], [], [], 0), LocationRow('Quest: Rune Mysteries', 'quest', ['Lumbridge', 'Wizard Tower', 'Central Varrock', ], [], [], 0),
LocationRow('Quest: Misthalin Mystery', 'quest', ['Lumbridge Swamp', ], [], [], 0), LocationRow('Quest: Misthalin Mystery', 'quest', ['Lumbridge Swamp', ], [], [], 0),
LocationRow('Quest: The Corsair Curse', 'quest', ['Rimmington', 'Falador Farms', 'Corsair Cove', ], [], [], 0), LocationRow('Quest: The Corsair Curse', 'quest', ['Rimmington', 'Falador Farms', 'Corsair Cove', ], [], [], 0),
LocationRow('Quest: X Marks the Spot', 'quest', ['Lumbridge', 'Draynor Village', 'Port Sarim', ], [], [], 0), LocationRow('Quest: X Marks the Spot', 'quest', ['Lumbridge', 'Draynor Village', 'Port Sarim', ], [], [], 0),
LocationRow('Quest: Below Ice Mountain', 'quest', ['Dwarven Mines', 'Dwarven Mountain Pass', 'Ice Mountain', 'Barbarian Village', 'Falador', 'Central Varrock', 'Edgeville', ], [], [], 16), LocationRow('Quest: Below Ice Mountain', 'quest', ['Dwarven Mines', 'Dwarven Mountain Pass', 'Ice Mountain', 'Barbarian Village', 'Falador', 'Central Varrock', 'Edgeville', ], [], [], 16),
LocationRow('Quest: Dragon Slayer', 'goal', ['Crandor', 'South of Varrock', 'Edgeville', 'Lumbridge', 'Rimmington', 'Monastery', 'Dwarven Mines', 'Port Sarim', 'Draynor Village', ], [], [], 32), LocationRow('Quest: Dragon Slayer', 'goal', ['Crandor', 'South of Varrock', 'Edgeville', 'Lumbridge', 'Rimmington', 'Monastery', 'Dwarven Mines', 'Port Sarim', 'Draynor Village', ], [], [], 32),
LocationRow('Bury Some Big Bones', 'prayer', ['Big Bones', ], [SkillRequirement('Prayer', 1), ], [], 0),
LocationRow('Activate the "Sharp Eye" Prayer', 'prayer', [], [SkillRequirement('Prayer', 8), ], [], 0),
LocationRow('Activate the "Rock Skin" Prayer', 'prayer', [], [SkillRequirement('Prayer', 10), ], [], 0), LocationRow('Activate the "Rock Skin" Prayer', 'prayer', [], [SkillRequirement('Prayer', 10), ], [], 0),
LocationRow('Activate the "Protect Item" Prayer', 'prayer', [], [SkillRequirement('Prayer', 25), ], [], 2), LocationRow('Activate the "Protect Item" Prayer', 'prayer', [], [SkillRequirement('Prayer', 25), ], [], 2),
LocationRow('Pray at the Edgeville Monastery', 'prayer', ['Monastery', ], [SkillRequirement('Prayer', 31), ], [], 6), LocationRow('Pray at the Edgeville Monastery', 'prayer', ['Monastery', ], [SkillRequirement('Prayer', 31), ], [], 6),
LocationRow('Cast Bones To Bananas', 'magic', ['Nature Runes', ], [SkillRequirement('Magic', 15), ], [], 0), LocationRow('Cast Bones To Bananas', 'magic', ['Nature Runes', ], [SkillRequirement('Magic', 15), ], [], 0),
LocationRow('Cast Earth Strike', 'magic', [], [SkillRequirement('Magic', 9), ], [], 0),
LocationRow('Cast Curse', 'magic', [], [SkillRequirement('Magic', 19), ], [], 0),
LocationRow('Teleport to Varrock', 'magic', ['Central Varrock', 'Law Runes', ], [SkillRequirement('Magic', 25), ], [], 0), LocationRow('Teleport to Varrock', 'magic', ['Central Varrock', 'Law Runes', ], [SkillRequirement('Magic', 25), ], [], 0),
LocationRow('Teleport to Lumbridge', 'magic', ['Lumbridge', 'Law Runes', ], [SkillRequirement('Magic', 31), ], [], 2), LocationRow('Teleport to Lumbridge', 'magic', ['Lumbridge', 'Law Runes', ], [SkillRequirement('Magic', 31), ], [], 0),
LocationRow('Telegrab a Gold Bar from the Varrock Bank', 'magic', ['Law Runes', 'West Varrock', ], [SkillRequirement('Magic', 33), ], [], 0),
LocationRow('Teleport to Falador', 'magic', ['Falador', 'Law Runes', ], [SkillRequirement('Magic', 37), ], [], 6), LocationRow('Teleport to Falador', 'magic', ['Falador', 'Law Runes', ], [SkillRequirement('Magic', 37), ], [], 6),
LocationRow('Craft an Air Rune', 'runecraft', ['Rune Essence', 'Falador Farms', ], [SkillRequirement('Runecraft', 1), ], [], 0), LocationRow('Craft an Air Rune', 'runecraft', ['Rune Essence', 'Falador Farms', ], [SkillRequirement('Runecraft', 1), ], [], 0),
LocationRow('Craft a Mind Rune', 'runecraft', ['Rune Essence', 'Goblin Village', ], [SkillRequirement('Runecraft', 2), ], [], 0),
LocationRow('Craft a Water Rune', 'runecraft', ['Rune Essence', 'Lumbridge Swamp', ], [SkillRequirement('Runecraft', 5), ], [], 0),
LocationRow('Craft an Earth Rune', 'runecraft', ['Rune Essence', 'Lumberyard', ], [SkillRequirement('Runecraft', 9), ], [], 0),
LocationRow('Craft a Fire Rune', 'runecraft', ['Rune Essence', 'Al Kharid', ], [SkillRequirement('Runecraft', 14), ], [], 0),
LocationRow('Craft a Body Rune', 'runecraft', ['Rune Essence', 'Dwarven Mountain Pass', ], [SkillRequirement('Runecraft', 20), ], [], 0),
LocationRow('Craft runes with a Mind Core', 'runecraft', ['Camdozaal', 'Goblin Village', ], [SkillRequirement('Runecraft', 2), ], [], 0), LocationRow('Craft runes with a Mind Core', 'runecraft', ['Camdozaal', 'Goblin Village', ], [SkillRequirement('Runecraft', 2), ], [], 0),
LocationRow('Craft runes with a Body Core', 'runecraft', ['Camdozaal', 'Dwarven Mountain Pass', ], [SkillRequirement('Runecraft', 20), ], [], 0), LocationRow('Craft runes with a Body Core', 'runecraft', ['Camdozaal', 'Dwarven Mountain Pass', ], [SkillRequirement('Runecraft', 20), ], [], 0),
LocationRow('Craft a Pot', 'crafting', ['Clay Ore', 'Barbarian Village', ], [SkillRequirement('Crafting', 1), ], [], 0),
LocationRow('Craft a pair of Leather Boots', 'crafting', ['Milk', 'Al Kharid', ], [SkillRequirement('Crafting', 7), ], [], 0),
LocationRow('Make an Unblessed Symbol', 'crafting', ['Silver Ore', 'Furnace', 'Al Kharid', 'Sheep', 'Spinning Wheel', ], [SkillRequirement('Crafting', 16), ], [], 0), LocationRow('Make an Unblessed Symbol', 'crafting', ['Silver Ore', 'Furnace', 'Al Kharid', 'Sheep', 'Spinning Wheel', ], [SkillRequirement('Crafting', 16), ], [], 0),
LocationRow('Cut a Sapphire', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 20), ], [], 0), LocationRow('Cut a Sapphire', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 20), ], [], 0),
LocationRow('Cut an Emerald', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 27), ], [], 0), LocationRow('Cut an Emerald', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 27), ], [], 0),
LocationRow('Cut a Ruby', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 34), ], [], 4), LocationRow('Cut a Ruby', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 34), ], [], 4),
LocationRow('Enter the Crafting Guild', 'crafting', ['Crafting Guild', ], [SkillRequirement('Crafting', 40), ], [], 0),
LocationRow('Cut a Diamond', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 43), ], [], 8), LocationRow('Cut a Diamond', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 43), ], [], 8),
LocationRow('Mine Copper', 'crafting', ['Bronze Ores', ], [SkillRequirement('Mining', 1), ], [], 0),
LocationRow('Mine Tin', 'crafting', ['Bronze Ores', ], [SkillRequirement('Mining', 1), ], [], 0),
LocationRow('Mine Clay', 'crafting', ['Clay Ore', ], [SkillRequirement('Mining', 1), ], [], 0),
LocationRow('Mine Iron', 'mining', ['Iron Ore', ], [SkillRequirement('Mining', 1), ], [], 0),
LocationRow('Mine a Blurite Ore', 'mining', ['Mudskipper Point', 'Port Sarim', ], [SkillRequirement('Mining', 10), ], [], 0), LocationRow('Mine a Blurite Ore', 'mining', ['Mudskipper Point', 'Port Sarim', ], [SkillRequirement('Mining', 10), ], [], 0),
LocationRow('Crush a Barronite Deposit', 'mining', ['Camdozaal', ], [SkillRequirement('Mining', 14), ], [], 0), LocationRow('Crush a Barronite Deposit', 'mining', ['Camdozaal', ], [SkillRequirement('Mining', 14), ], [], 0),
LocationRow('Mine Silver', 'mining', ['Silver Ore', ], [SkillRequirement('Mining', 20), ], [], 0), LocationRow('Mine Silver', 'mining', ['Silver Ore', ], [SkillRequirement('Mining', 20), ], [], 0),
LocationRow('Mine Coal', 'mining', ['Coal Ore', ], [SkillRequirement('Mining', 30), ], [], 2), LocationRow('Mine Coal', 'mining', ['Coal Ore', ], [SkillRequirement('Mining', 30), ], [], 2),
LocationRow('Mine Gold', 'mining', ['Gold Ore', ], [SkillRequirement('Mining', 40), ], [], 6), LocationRow('Mine Gold', 'mining', ['Gold Ore', ], [SkillRequirement('Mining', 40), ], [], 6),
LocationRow('Smelt a Bronze Bar', 'smithing', ['Bronze Ores', 'Furnace', ], [SkillRequirement('Smithing', 1), SkillRequirement('Mining', 1), ], [], 0),
LocationRow('Smelt an Iron Bar', 'smithing', ['Iron Ore', 'Furnace', ], [SkillRequirement('Smithing', 15), SkillRequirement('Mining', 15), ], [], 0), LocationRow('Smelt an Iron Bar', 'smithing', ['Iron Ore', 'Furnace', ], [SkillRequirement('Smithing', 15), SkillRequirement('Mining', 15), ], [], 0),
LocationRow('Smelt a Silver Bar', 'smithing', ['Silver Ore', 'Furnace', ], [SkillRequirement('Smithing', 20), SkillRequirement('Mining', 20), ], [], 0), LocationRow('Smelt a Silver Bar', 'smithing', ['Silver Ore', 'Furnace', ], [SkillRequirement('Smithing', 20), SkillRequirement('Mining', 20), ], [], 0),
LocationRow('Smelt a Steel Bar', 'smithing', ['Coal Ore', 'Iron Ore', 'Furnace', ], [SkillRequirement('Smithing', 30), SkillRequirement('Mining', 30), ], [], 2), LocationRow('Smelt a Steel Bar', 'smithing', ['Coal Ore', 'Iron Ore', 'Furnace', ], [SkillRequirement('Smithing', 30), SkillRequirement('Mining', 30), ], [], 2),
LocationRow('Smelt a Gold Bar', 'smithing', ['Gold Ore', 'Furnace', ], [SkillRequirement('Smithing', 40), SkillRequirement('Mining', 40), ], [], 6), LocationRow('Smelt a Gold Bar', 'smithing', ['Gold Ore', 'Furnace', ], [SkillRequirement('Smithing', 40), SkillRequirement('Mining', 40), ], [], 6),
LocationRow('Catch a Sardine', 'fishing', ['Shrimp Spot', ], [SkillRequirement('Fishing', 5), ], [], 0),
LocationRow('Catch some Anchovies', 'fishing', ['Shrimp Spot', ], [SkillRequirement('Fishing', 15), ], [], 0), LocationRow('Catch some Anchovies', 'fishing', ['Shrimp Spot', ], [SkillRequirement('Fishing', 15), ], [], 0),
LocationRow('Catch a Trout', 'fishing', ['Fly Fishing Spot', ], [SkillRequirement('Fishing', 20), ], [], 0), LocationRow('Catch a Trout', 'fishing', ['Fly Fishing Spot', ], [SkillRequirement('Fishing', 20), ], [], 0),
LocationRow('Prepare a Tetra', 'fishing', ['Camdozaal', ], [SkillRequirement('Fishing', 33), SkillRequirement('Cooking', 33), ], [], 2), LocationRow('Prepare a Tetra', 'fishing', ['Camdozaal', ], [SkillRequirement('Fishing', 33), SkillRequirement('Cooking', 33), ], [], 2),
@@ -58,13 +77,16 @@ location_rows = [
LocationRow('Bake a Redberry Pie', 'cooking', ['Redberry Bush', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 10), ], [], 0), LocationRow('Bake a Redberry Pie', 'cooking', ['Redberry Bush', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 10), ], [], 0),
LocationRow('Cook some Stew', 'cooking', ['Bowl', 'Meat', 'Potato', ], [SkillRequirement('Cooking', 25), ], [], 0), LocationRow('Cook some Stew', 'cooking', ['Bowl', 'Meat', 'Potato', ], [SkillRequirement('Cooking', 25), ], [], 0),
LocationRow('Bake an Apple Pie', 'cooking', ['Cooking Apple', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 32), ], [], 2), LocationRow('Bake an Apple Pie', 'cooking', ['Cooking Apple', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 32), ], [], 2),
LocationRow('Enter the Cook\'s Guild', 'cooking', ['Cook\'s Guild', ], [], [], 0),
LocationRow('Bake a Cake', 'cooking', ['Wheat', 'Windmill', 'Egg', 'Milk', 'Cake Tin', ], [SkillRequirement('Cooking', 40), ], [], 6), LocationRow('Bake a Cake', 'cooking', ['Wheat', 'Windmill', 'Egg', 'Milk', 'Cake Tin', ], [SkillRequirement('Cooking', 40), ], [], 6),
LocationRow('Bake a Meat Pizza', 'cooking', ['Wheat', 'Windmill', 'Cheese', 'Tomato', 'Meat', ], [SkillRequirement('Cooking', 45), ], [], 8), LocationRow('Bake a Meat Pizza', 'cooking', ['Wheat', 'Windmill', 'Cheese', 'Tomato', 'Meat', ], [SkillRequirement('Cooking', 45), ], [], 8),
LocationRow('Burn a Log', 'firemaking', [], [SkillRequirement('Firemaking', 1), SkillRequirement('Woodcutting', 1), ], [], 0),
LocationRow('Burn some Oak Logs', 'firemaking', ['Oak Tree', ], [SkillRequirement('Firemaking', 15), SkillRequirement('Woodcutting', 15), ], [], 0), LocationRow('Burn some Oak Logs', 'firemaking', ['Oak Tree', ], [SkillRequirement('Firemaking', 15), SkillRequirement('Woodcutting', 15), ], [], 0),
LocationRow('Burn some Willow Logs', 'firemaking', ['Willow Tree', ], [SkillRequirement('Firemaking', 30), SkillRequirement('Woodcutting', 30), ], [], 0), LocationRow('Burn some Willow Logs', 'firemaking', ['Willow Tree', ], [SkillRequirement('Firemaking', 30), SkillRequirement('Woodcutting', 30), ], [], 0),
LocationRow('Travel on a Canoe', 'woodcutting', ['Canoe Tree', ], [SkillRequirement('Woodcutting', 12), ], [], 0), LocationRow('Travel on a Canoe', 'woodcutting', ['Canoe Tree', ], [SkillRequirement('Woodcutting', 12), ], [], 0),
LocationRow('Cut an Oak Log', 'woodcutting', ['Oak Tree', ], [SkillRequirement('Woodcutting', 15), ], [], 0), LocationRow('Cut an Oak Log', 'woodcutting', ['Oak Tree', ], [SkillRequirement('Woodcutting', 15), ], [], 0),
LocationRow('Cut a Willow Log', 'woodcutting', ['Willow Tree', ], [SkillRequirement('Woodcutting', 30), ], [], 0), LocationRow('Cut a Willow Log', 'woodcutting', ['Willow Tree', ], [SkillRequirement('Woodcutting', 30), ], [], 0),
LocationRow('Kill a Duck', 'combat', ['Duck', ], [SkillRequirement('Combat', 1), ], [], 0),
LocationRow('Kill Jeff', 'combat', ['Dwarven Mountain Pass', ], [SkillRequirement('Combat', 2), ], [], 0), LocationRow('Kill Jeff', 'combat', ['Dwarven Mountain Pass', ], [SkillRequirement('Combat', 2), ], [], 0),
LocationRow('Kill a Goblin', 'combat', ['Goblin', ], [SkillRequirement('Combat', 2), ], [], 0), LocationRow('Kill a Goblin', 'combat', ['Goblin', ], [SkillRequirement('Combat', 2), ], [], 0),
LocationRow('Kill a Monkey', 'combat', ['Karamja', ], [SkillRequirement('Combat', 3), ], [], 0), LocationRow('Kill a Monkey', 'combat', ['Karamja', ], [SkillRequirement('Combat', 3), ], [], 0),
@@ -81,19 +103,24 @@ location_rows = [
LocationRow('Kill an Ogress Shaman', 'combat', ['Corsair Cove', ], [SkillRequirement('Combat', 82), ], [], 8), LocationRow('Kill an Ogress Shaman', 'combat', ['Corsair Cove', ], [SkillRequirement('Combat', 82), ], [], 8),
LocationRow('Kill Obor', 'combat', ['Edgeville', ], [SkillRequirement('Combat', 106), ], [], 28), LocationRow('Kill Obor', 'combat', ['Edgeville', ], [SkillRequirement('Combat', 106), ], [], 28),
LocationRow('Kill Bryophyta', 'combat', ['Central Varrock', ], [SkillRequirement('Combat', 128), ], [], 28), LocationRow('Kill Bryophyta', 'combat', ['Central Varrock', ], [SkillRequirement('Combat', 128), ], [], 28),
LocationRow('Die', 'general', [], [], [], 0),
LocationRow('Reach a Level 10', 'general', [], [], [], 0),
LocationRow('Total XP 5,000', 'general', [], [], [], 0), LocationRow('Total XP 5,000', 'general', [], [], [], 0),
LocationRow('Combat Level 5', 'general', [], [], [], 0), LocationRow('Combat Level 5', 'general', [], [], [], 0),
LocationRow('Total XP 10,000', 'general', [], [], [], 0), LocationRow('Total XP 10,000', 'general', [], [], [], 0),
LocationRow('Total Level 50', 'general', [], [], [], 0), LocationRow('Total Level 50', 'general', [], [], [], 0),
LocationRow('Reach a Level 20', 'general', [], [], [], 0),
LocationRow('Total XP 25,000', 'general', [], [], [], 0), LocationRow('Total XP 25,000', 'general', [], [], [], 0),
LocationRow('Total Level 100', 'general', [], [], [], 0), LocationRow('Total Level 100', 'general', [], [], [], 0),
LocationRow('Total XP 50,000', 'general', [], [], [], 0), LocationRow('Total XP 50,000', 'general', [], [], [], 0),
LocationRow('Combat Level 15', 'general', [], [], [], 0), LocationRow('Combat Level 15', 'general', [], [], [], 0),
LocationRow('Total Level 150', 'general', [], [], [], 2), LocationRow('Total Level 150', 'general', [], [], [], 2),
LocationRow('Reach a Level 30', 'general', [], [], [], 2),
LocationRow('Total XP 75,000', 'general', [], [], [], 2), LocationRow('Total XP 75,000', 'general', [], [], [], 2),
LocationRow('Combat Level 25', 'general', [], [], [], 2), LocationRow('Combat Level 25', 'general', [], [], [], 2),
LocationRow('Total XP 100,000', 'general', [], [], [], 6), LocationRow('Total XP 100,000', 'general', [], [], [], 6),
LocationRow('Total Level 200', 'general', [], [], [], 6), LocationRow('Total Level 200', 'general', [], [], [], 6),
LocationRow('Reach a Level 40', 'general', [], [], [], 6),
LocationRow('Total XP 125,000', 'general', [], [], [], 6), LocationRow('Total XP 125,000', 'general', [], [], [], 6),
LocationRow('Combat Level 30', 'general', [], [], [], 10), LocationRow('Combat Level 30', 'general', [], [], [], 10),
LocationRow('Total Level 250', 'general', [], [], [], 10), LocationRow('Total Level 250', 'general', [], [], [], 10),
@@ -103,6 +130,28 @@ location_rows = [
LocationRow('Open a Simple Lockbox', 'general', ['Camdozaal', ], [], [], 0), LocationRow('Open a Simple Lockbox', 'general', ['Camdozaal', ], [], [], 0),
LocationRow('Open an Elaborate Lockbox', 'general', ['Camdozaal', ], [], [], 0), LocationRow('Open an Elaborate Lockbox', 'general', ['Camdozaal', ], [], [], 0),
LocationRow('Open an Ornate Lockbox', 'general', ['Camdozaal', ], [], [], 0), LocationRow('Open an Ornate Lockbox', 'general', ['Camdozaal', ], [], [], 0),
LocationRow('Trans your Gender', 'general', ['Makeover', ], [], [], 0),
LocationRow('Read a Flyer from Ali the Leaflet Dropper', 'general', ['Al Kharid', 'South of Varrock', ], [], [], 0),
LocationRow('Cry by the Members Gate to Taverley', 'general', ['Dwarven Mountain Pass', ], [], [], 0),
LocationRow('Get Prompted to Buy Membership', 'general', [], [], [], 0),
LocationRow('Pet the Stray Dog in Varrock', 'general', ['Central Varrock', 'West Varrock', 'South of Varrock', ], [], [], 0),
LocationRow('Get Sent to Jail in Shantay Pass', 'general', ['Al Kharid', 'Port Sarim', ], [], [], 0),
LocationRow('Have the Apothecary Make a Strength Potion', 'general', ['Central Varrock', 'Red Spider Eggs', 'Limpwurt Root', ], [], [], 0),
LocationRow('Put a Whole Banana into a Bottle of Karamjan Rum', 'general', ['Karamja', ], [], [], 0),
LocationRow('Attempt to Shear "The Thing"', 'general', ['Lumbridge Farms West', ], [], [], 0),
LocationRow('Eat a Kebab', 'general', ['Al Kharid', ], [], [], 0),
LocationRow('Return a Beer Glass to a Bar', 'general', ['Falador', ], [], [], 0),
LocationRow('Enter the Varrock Bear Cage', 'general', ['Varrock Palace', ], [], [], 0),
LocationRow('Equip a Cabbage Cape', 'general', ['Draynor Village', ], [], [], 0),
LocationRow('Equip a Pride Scarf', 'general', ['Draynor Village', ], [], [], 0),
LocationRow('Visit the Black Hole', 'general', ['Draynor Village', 'Dwarven Mines', ], [], [], 0),
LocationRow('Try to Equip Goblin Mail', 'general', ['Goblin', ], [], [], 0),
LocationRow('Equip an Orange Cape', 'general', ['Draynor Village', ], [], [], 0),
LocationRow('Find a Needle in a Haystack', 'general', ['Haystack', ], [], [], 0),
LocationRow('Insult the Homeless (but not Charlie he\'s cool)', 'general', ['Central Varrock', 'South of Varrock', ], [], [], 0),
LocationRow('Dance with Party Pete', 'general', ['Falador', ], [], [], 0),
LocationRow('Read a Newspaper', 'general', ['Central Varrock', ], [], [], 0),
LocationRow('Add a Card to the Chronicle', 'general', ['Draynor Village', ], [], [], 0),
LocationRow('Points: Cook\'s Assistant', 'points', [], [], [], 0), LocationRow('Points: Cook\'s Assistant', 'points', [], [], [], 0),
LocationRow('Points: Demon Slayer', 'points', [], [], [], 0), LocationRow('Points: Demon Slayer', 'points', [], [], [], 0),
LocationRow('Points: The Restless Ghost', 'points', [], [], [], 0), LocationRow('Points: The Restless Ghost', 'points', [], [], [], 0),

View File

@@ -4,19 +4,19 @@ This file was auto generated by LogicCSVToPython.py
from ..Regions import RegionRow from ..Regions import RegionRow
region_rows = [ region_rows = [
RegionRow('Lumbridge', 'Area: Lumbridge', ['Lumbridge Farms East', 'Lumbridge Farms West', 'Al Kharid', 'Lumbridge Swamp', 'HAM Hideout', 'South of Varrock', 'Barbarian Village', 'Edgeville', 'Wilderness', ], ['Mind Runes', 'Spinning Wheel', 'Furnace', 'Chisel', 'Bronze Anvil', 'Fly Fishing Spot', 'Bowl', 'Cake Tin', 'Oak Tree', 'Willow Tree', 'Canoe Tree', 'Goblin', 'Imps', ]), RegionRow('Lumbridge', 'Area: Lumbridge', ['Lumbridge Farms East', 'Lumbridge Farms West', 'Al Kharid', 'Lumbridge Swamp', 'HAM Hideout', 'South of Varrock', 'Barbarian Village', 'Edgeville', 'Wilderness', ], ['Mind Runes', 'Spinning Wheel', 'Furnace', 'Chisel', 'Bronze Anvil', 'Fly Fishing Spot', 'Bowl', 'Cake Tin', 'Oak Tree', 'Willow Tree', 'Canoe Tree', 'Goblin', 'Imps', 'Duck', 'Bar', ]),
RegionRow('Lumbridge Swamp', 'Area: Lumbridge Swamp', ['Lumbridge', 'HAM Hideout', ], ['Bronze Ores', 'Coal Ore', 'Shrimp Spot', 'Meat', 'Goblin', 'Imps', ]), RegionRow('Lumbridge Swamp', 'Area: Lumbridge Swamp', ['Lumbridge', 'HAM Hideout', ], ['Bronze Ores', 'Coal Ore', 'Shrimp Spot', 'Meat', 'Goblin', 'Imps', 'Big Bones', 'Duck', ]),
RegionRow('HAM Hideout', 'Area: HAM Hideout', ['Lumbridge Farms West', 'Lumbridge', 'Lumbridge Swamp', 'Draynor Village', ], ['Goblin', ]), RegionRow('HAM Hideout', 'Area: HAM Hideout', ['Lumbridge Farms West', 'Lumbridge', 'Lumbridge Swamp', 'Draynor Village', ], ['Goblin', ]),
RegionRow('Lumbridge Farms West', 'Area: Lumbridge Farms', ['Sourhog\'s Lair', 'HAM Hideout', 'Draynor Village', ], ['Sheep', 'Meat', 'Wheat', 'Windmill', 'Egg', 'Milk', 'Willow Tree', 'Imps', 'Potato', ]), RegionRow('Lumbridge Farms West', 'Area: Lumbridge Farms', ['Sourhog\'s Lair', 'HAM Hideout', 'Draynor Village', ], ['Sheep', 'Meat', 'Wheat', 'Windmill', 'Egg', 'Milk', 'Willow Tree', 'Imps', 'Potato', 'Haystack', ]),
RegionRow('Lumbridge Farms East', 'Area: Lumbridge Farms', ['South of Varrock', 'Lumbridge', ], ['Meat', 'Egg', 'Milk', 'Willow Tree', 'Goblin', 'Imps', 'Potato', ]), RegionRow('Lumbridge Farms East', 'Area: Lumbridge Farms', ['South of Varrock', 'Lumbridge', ], ['Meat', 'Egg', 'Milk', 'Willow Tree', 'Goblin', 'Imps', 'Potato', ]),
RegionRow('Sourhog\'s Lair', 'Area: South of Varrock', ['Lumbridge Farms West', 'Draynor Manor Outskirts', ], ['', ]), RegionRow('Sourhog\'s Lair', 'Area: South of Varrock', ['Lumbridge Farms West', 'Draynor Manor Outskirts', ], ['', ]),
RegionRow('South of Varrock', 'Area: South of Varrock', ['Al Kharid', 'West Varrock', 'Central Varrock', 'East Varrock', 'Lumbridge Farms East', 'Lumbridge', 'Barbarian Village', 'Edgeville', 'Wilderness', ], ['Sheep', 'Bronze Ores', 'Iron Ore', 'Silver Ore', 'Redberry Bush', 'Meat', 'Wheat', 'Oak Tree', 'Willow Tree', 'Canoe Tree', 'Guard', 'Imps', 'Clay Ore', ]), RegionRow('South of Varrock', 'Area: South of Varrock', ['Al Kharid', 'West Varrock', 'Central Varrock', 'Lumberyard', 'Lumbridge Farms East', 'Lumbridge', 'Barbarian Village', 'Edgeville', 'Wilderness', ], ['Sheep', 'Bronze Ores', 'Iron Ore', 'Silver Ore', 'Redberry Bush', 'Meat', 'Wheat', 'Oak Tree', 'Willow Tree', 'Canoe Tree', 'Guard', 'Imps', 'Clay Ore', 'Duck', ]),
RegionRow('East Varrock', 'Area: East Varrock', ['Wilderness', 'South of Varrock', 'Central Varrock', 'Varrock Palace', ], ['Guard', ]), RegionRow('Lumberyard', 'Area: Lumberyard', ['Wilderness', 'South of Varrock', 'Central Varrock', 'Varrock Palace', ], ['Guard', 'Bar', ]),
RegionRow('Central Varrock', 'Area: Central Varrock', ['Varrock Palace', 'East Varrock', 'South of Varrock', 'West Varrock', ], ['Mind Runes', 'Chisel', 'Anvil', 'Bowl', 'Cake Tin', 'Oak Tree', 'Barbarian', 'Guard', 'Rune Essence', 'Imps', ]), RegionRow('Central Varrock', 'Area: Central Varrock', ['Varrock Palace', 'Lumberyard', 'South of Varrock', 'West Varrock', ], ['Mind Runes', 'Chisel', 'Anvil', 'Bowl', 'Cake Tin', 'Oak Tree', 'Barbarian', 'Guard', 'Rune Essence', 'Imps', 'Makeover', 'Bar', ]),
RegionRow('Varrock Palace', 'Area: Varrock Palace', ['Wilderness', 'East Varrock', 'Central Varrock', 'West Varrock', ], ['Pie Dish', 'Oak Tree', 'Zombie', 'Guard', 'Deadly Red Spider', 'Moss Giant', 'Nature Runes', 'Law Runes', ]), RegionRow('Varrock Palace', 'Area: Varrock Palace', ['Wilderness', 'Lumberyard', 'Central Varrock', 'West Varrock', ], ['Pie Dish', 'Oak Tree', 'Zombie', 'Guard', 'Deadly Red Spider', 'Moss Giant', 'Nature Runes', 'Law Runes', 'Big Bones', 'Makeover', 'Red Spider Eggs', ]),
RegionRow('West Varrock', 'Area: West Varrock', ['Wilderness', 'Varrock Palace', 'South of Varrock', 'Barbarian Village', 'Edgeville', 'Cook\'s Guild', ], ['Anvil', 'Wheat', 'Oak Tree', 'Goblin', 'Guard', 'Onion', ]), RegionRow('West Varrock', 'Area: West Varrock', ['Wilderness', 'Varrock Palace', 'South of Varrock', 'Barbarian Village', 'Edgeville', 'Cook\'s Guild', ], ['Anvil', 'Wheat', 'Oak Tree', 'Goblin', 'Guard', 'Onion', ]),
RegionRow('Cook\'s Guild', 'Area: West Varrock*', ['West Varrock', ], ['Bowl', 'Cooking Apple', 'Pie Dish', 'Cake Tin', 'Windmill', ]), RegionRow('Cook\'s Guild', 'Area: West Varrock*', ['West Varrock', ], ['Bowl', 'Cooking Apple', 'Pie Dish', 'Cake Tin', 'Windmill', ]),
RegionRow('Edgeville', 'Area: Edgeville', ['Wilderness', 'West Varrock', 'Barbarian Village', 'South of Varrock', 'Lumbridge', ], ['Furnace', 'Chisel', 'Bronze Ores', 'Iron Ore', 'Coal Ore', 'Bowl', 'Meat', 'Cake Tin', 'Willow Tree', 'Canoe Tree', 'Zombie', 'Guard', 'Hill Giant', 'Nature Runes', 'Law Runes', 'Imps', ]), RegionRow('Edgeville', 'Area: Edgeville', ['Wilderness', 'West Varrock', 'Barbarian Village', 'South of Varrock', 'Lumbridge', ], ['Furnace', 'Chisel', 'Bronze Ores', 'Iron Ore', 'Coal Ore', 'Bowl', 'Meat', 'Cake Tin', 'Willow Tree', 'Canoe Tree', 'Zombie', 'Guard', 'Hill Giant', 'Nature Runes', 'Law Runes', 'Imps', 'Big Bones', 'Limpwurt Root', 'Haystack', ]),
RegionRow('Barbarian Village', 'Area: Barbarian Village', ['Edgeville', 'West Varrock', 'Draynor Manor Outskirts', 'Dwarven Mountain Pass', ], ['Spinning Wheel', 'Coal Ore', 'Anvil', 'Fly Fishing Spot', 'Meat', 'Canoe Tree', 'Barbarian', 'Zombie', 'Law Runes', ]), RegionRow('Barbarian Village', 'Area: Barbarian Village', ['Edgeville', 'West Varrock', 'Draynor Manor Outskirts', 'Dwarven Mountain Pass', ], ['Spinning Wheel', 'Coal Ore', 'Anvil', 'Fly Fishing Spot', 'Meat', 'Canoe Tree', 'Barbarian', 'Zombie', 'Law Runes', ]),
RegionRow('Draynor Manor Outskirts', 'Area: Draynor Manor', ['Barbarian Village', 'Sourhog\'s Lair', 'Draynor Village', 'Falador East Outskirts', ], ['Goblin', ]), RegionRow('Draynor Manor Outskirts', 'Area: Draynor Manor', ['Barbarian Village', 'Sourhog\'s Lair', 'Draynor Village', 'Falador East Outskirts', ], ['Goblin', ]),
RegionRow('Draynor Manor', 'Area: Draynor Manor', ['Draynor Village', ], ['', ]), RegionRow('Draynor Manor', 'Area: Draynor Manor', ['Draynor Village', ], ['', ]),
@@ -27,21 +27,21 @@ region_rows = [
RegionRow('Ice Mountain', 'Area: Ice Mountain', ['Wilderness', 'Monastery', 'Dwarven Mines', 'Camdozaal*', ], ['', ]), RegionRow('Ice Mountain', 'Area: Ice Mountain', ['Wilderness', 'Monastery', 'Dwarven Mines', 'Camdozaal*', ], ['', ]),
RegionRow('Camdozaal', 'Area: Ice Mountain', ['Ice Mountain', ], ['Clay Ore', ]), RegionRow('Camdozaal', 'Area: Ice Mountain', ['Ice Mountain', ], ['Clay Ore', ]),
RegionRow('Monastery', 'Area: Monastery', ['Wilderness', 'Dwarven Mountain Pass', 'Dwarven Mines', 'Ice Mountain', ], ['Sheep', ]), RegionRow('Monastery', 'Area: Monastery', ['Wilderness', 'Dwarven Mountain Pass', 'Dwarven Mines', 'Ice Mountain', ], ['Sheep', ]),
RegionRow('Falador', 'Area: Falador', ['Dwarven Mountain Pass', 'Falador Farms', 'Dwarven Mines', ], ['Furnace', 'Chisel', 'Bowl', 'Cake Tin', 'Oak Tree', 'Guard', 'Imps', ]), RegionRow('Falador', 'Area: Falador', ['Dwarven Mountain Pass', 'Falador Farms', 'Dwarven Mines', ], ['Furnace', 'Chisel', 'Bowl', 'Cake Tin', 'Oak Tree', 'Guard', 'Imps', 'Duck', 'Makeover', 'Bar', ]),
RegionRow('Falador Farms', 'Area: Falador Farms', ['Falador', 'Falador East Outskirts', 'Draynor Village', 'Port Sarim', 'Rimmington', 'Crafting Guild Outskirts', ], ['Spinning Wheel', 'Meat', 'Egg', 'Milk', 'Oak Tree', 'Imps', ]), RegionRow('Falador Farms', 'Area: Falador Farms', ['Falador', 'Falador East Outskirts', 'Draynor Village', 'Port Sarim', 'Rimmington', 'Crafting Guild Outskirts', ], ['Spinning Wheel', 'Meat', 'Egg', 'Milk', 'Oak Tree', 'Imps', 'Duck', ]),
RegionRow('Port Sarim', 'Area: Port Sarim', ['Falador Farms', 'Mudskipper Point', 'Rimmington', 'Karamja Docks', 'Crandor', ], ['Mind Runes', 'Shrimp Spot', 'Meat', 'Cheese', 'Tomato', 'Oak Tree', 'Willow Tree', 'Goblin', 'Potato', ]), RegionRow('Port Sarim', 'Area: Port Sarim', ['Falador Farms', 'Mudskipper Point', 'Rimmington', 'Karamja Docks', 'Crandor', ], ['Mind Runes', 'Shrimp Spot', 'Meat', 'Cheese', 'Tomato', 'Oak Tree', 'Willow Tree', 'Goblin', 'Potato', ]),
RegionRow('Karamja Docks', 'Area: Mudskipper Point', ['Port Sarim', 'Karamja', ], ['', ]), RegionRow('Karamja Docks', 'Area: Mudskipper Point', ['Port Sarim', 'Karamja', ], ['', ]),
RegionRow('Mudskipper Point', 'Area: Mudskipper Point', ['Rimmington', 'Port Sarim', ], ['Anvil', 'Ice Giant', 'Nature Runes', 'Law Runes', ]), RegionRow('Mudskipper Point', 'Area: Mudskipper Point', ['Rimmington', 'Port Sarim', ], ['Anvil', 'Ice Giant', 'Nature Runes', 'Law Runes', 'Big Bones', 'Limpwurt Root', ]),
RegionRow('Karamja', 'Area: Karamja', ['Karamja Docks', 'Crandor', ], ['Gold Ore', 'Lobster Spot', 'Bowl', 'Cake Tin', 'Deadly Red Spider', 'Imps', ]), RegionRow('Karamja', 'Area: Karamja', ['Karamja Docks', 'Crandor', ], ['Gold Ore', 'Lobster Spot', 'Bowl', 'Cake Tin', 'Deadly Red Spider', 'Imps', 'Red Spider Eggs', ]),
RegionRow('Crandor', 'Area: Crandor', ['Karamja', 'Port Sarim', ], ['Coal Ore', 'Gold Ore', 'Moss Giant', 'Lesser Demon', 'Nature Runes', 'Law Runes', ]), RegionRow('Crandor', 'Area: Crandor', ['Karamja', 'Port Sarim', ], ['Coal Ore', 'Gold Ore', 'Moss Giant', 'Lesser Demon', 'Nature Runes', 'Law Runes', 'Big Bones', 'Limpwurt Root', ]),
RegionRow('Rimmington', 'Area: Rimmington', ['Falador Farms', 'Port Sarim', 'Mudskipper Point', 'Crafting Guild Peninsula', 'Corsair Cove', ], ['Chisel', 'Bronze Ores', 'Iron Ore', 'Gold Ore', 'Bowl', 'Cake Tin', 'Wheat', 'Oak Tree', 'Willow Tree', 'Crafting Moulds', 'Imps', 'Clay Ore', 'Onion', ]), RegionRow('Rimmington', 'Area: Rimmington', ['Falador Farms', 'Port Sarim', 'Mudskipper Point', 'Crafting Guild Peninsula', 'Corsair Cove', ], ['Chisel', 'Bronze Ores', 'Iron Ore', 'Gold Ore', 'Bowl', 'Cake Tin', 'Wheat', 'Oak Tree', 'Willow Tree', 'Crafting Moulds', 'Imps', 'Clay Ore', 'Onion', ]),
RegionRow('Crafting Guild Peninsula', 'Area: Crafting Guild', ['Falador Farms', 'Rimmington', ], ['', ]), RegionRow('Crafting Guild Peninsula', 'Area: Crafting Guild', ['Falador Farms', 'Rimmington', ], ['Limpwurt Root', ]),
RegionRow('Crafting Guild Outskirts', 'Area: Crafting Guild', ['Falador Farms', 'Crafting Guild', ], ['Sheep', 'Willow Tree', 'Oak Tree', ]), RegionRow('Crafting Guild Outskirts', 'Area: Crafting Guild', ['Falador Farms', 'Crafting Guild', ], ['Sheep', 'Willow Tree', 'Oak Tree', 'Makeover', ]),
RegionRow('Crafting Guild', 'Area: Crafting Guild*', ['Crafting Guild', ], ['Spinning Wheel', 'Chisel', 'Silver Ore', 'Gold Ore', 'Meat', 'Milk', 'Clay Ore', ]), RegionRow('Crafting Guild', 'Area: Crafting Guild*', ['Crafting Guild', ], ['Spinning Wheel', 'Chisel', 'Silver Ore', 'Gold Ore', 'Meat', 'Milk', 'Clay Ore', ]),
RegionRow('Draynor Village', 'Area: Draynor Village', ['Draynor Manor', 'Lumbridge Farms West', 'HAM Hideout', 'Wizard Tower', ], ['Anvil', 'Shrimp Spot', 'Wheat', 'Cheese', 'Tomato', 'Willow Tree', 'Goblin', 'Zombie', 'Nature Runes', 'Law Runes', 'Imps', ]), RegionRow('Draynor Village', 'Area: Draynor Village', ['Draynor Manor', 'Lumbridge Farms West', 'HAM Hideout', 'Wizard Tower', ], ['Anvil', 'Shrimp Spot', 'Wheat', 'Cheese', 'Tomato', 'Willow Tree', 'Goblin', 'Zombie', 'Nature Runes', 'Law Runes', 'Imps', ]),
RegionRow('Wizard Tower', 'Area: Wizard Tower', ['Draynor Village', ], ['Lesser Demon', 'Rune Essence', ]), RegionRow('Wizard Tower', 'Area: Wizard Tower', ['Draynor Village', ], ['Lesser Demon', 'Rune Essence', ]),
RegionRow('Corsair Cove', 'Area: Corsair Cove*', ['Rimmington', ], ['Anvil', 'Meat', ]), RegionRow('Corsair Cove', 'Area: Corsair Cove*', ['Rimmington', ], ['Anvil', 'Meat', 'Limpwurt Root', ]),
RegionRow('Al Kharid', 'Area: Al Kharid', ['South of Varrock', 'Citharede Abbey', 'Lumbridge', 'Port Sarim', ], ['Furnace', 'Chisel', 'Bronze Ores', 'Iron Ore', 'Silver Ore', 'Coal Ore', 'Gold Ore', 'Shrimp Spot', 'Bowl', 'Cake Tin', 'Cheese', 'Crafting Moulds', 'Imps', ]), RegionRow('Al Kharid', 'Area: Al Kharid', ['South of Varrock', 'Citharede Abbey', 'Lumbridge', 'Port Sarim', ], ['Furnace', 'Chisel', 'Bronze Ores', 'Iron Ore', 'Silver Ore', 'Coal Ore', 'Gold Ore', 'Shrimp Spot', 'Bowl', 'Cake Tin', 'Cheese', 'Crafting Moulds', 'Imps', ]),
RegionRow('Citharede Abbey', 'Area: Citharede Abbey', ['Al Kharid', ], ['Iron Ore', 'Coal Ore', 'Anvil', 'Hill Giant', 'Nature Runes', 'Law Runes', ]), RegionRow('Citharede Abbey', 'Area: Citharede Abbey', ['Al Kharid', ], ['Iron Ore', 'Coal Ore', 'Anvil', 'Hill Giant', 'Nature Runes', 'Law Runes', 'Big Bones', 'Limpwurt Root', ]),
RegionRow('Wilderness', 'Area: Wilderness', ['East Varrock', 'Varrock Palace', 'West Varrock', 'Edgeville', 'Monastery', 'Ice Mountain', 'Goblin Village', 'South of Varrock', 'Lumbridge', ], ['Furnace', 'Chisel', 'Iron Ore', 'Coal Ore', 'Anvil', 'Meat', 'Cake Tin', 'Cheese', 'Tomato', 'Oak Tree', 'Canoe Tree', 'Zombie', 'Hill Giant', 'Deadly Red Spider', 'Moss Giant', 'Ice Giant', 'Lesser Demon', 'Nature Runes', 'Law Runes', ]), RegionRow('Wilderness', 'Area: Wilderness', ['Lumberyard', 'Varrock Palace', 'West Varrock', 'Edgeville', 'Monastery', 'Ice Mountain', 'Goblin Village', 'South of Varrock', 'Lumbridge', ], ['Furnace', 'Chisel', 'Iron Ore', 'Coal Ore', 'Anvil', 'Meat', 'Cake Tin', 'Cheese', 'Tomato', 'Oak Tree', 'Canoe Tree', 'Zombie', 'Hill Giant', 'Deadly Red Spider', 'Moss Giant', 'Ice Giant', 'Lesser Demon', 'Nature Runes', 'Law Runes', 'Big Bones', 'Limpwurt Root', 'Bar', ]),
] ]

View File

@@ -51,4 +51,11 @@ resource_rows = [
ResourceRow('Clay Ore'), ResourceRow('Clay Ore'),
ResourceRow('Onion'), ResourceRow('Onion'),
ResourceRow('Potato'), ResourceRow('Potato'),
ResourceRow('Big Bones'),
ResourceRow('Duck'),
ResourceRow('Makeover'),
ResourceRow('Limpwurt Root'),
ResourceRow('Bar'),
ResourceRow('Haystack'),
ResourceRow('Red Spider Eggs'),
] ]

View File

@@ -73,7 +73,7 @@ class ItemNames(str, Enum):
South_Of_Varrock = "Area: South of Varrock" South_Of_Varrock = "Area: South of Varrock"
Central_Varrock = "Area: Central Varrock" Central_Varrock = "Area: Central Varrock"
Varrock_Palace = "Area: Varrock Palace" Varrock_Palace = "Area: Varrock Palace"
East_Of_Varrock = "Area: East Varrock" Lumberyard = "Area: Lumberyard"
West_Varrock = "Area: West Varrock" West_Varrock = "Area: West Varrock"
Edgeville = "Area: Edgeville" Edgeville = "Area: Edgeville"
Barbarian_Village = "Area: Barbarian Village" Barbarian_Village = "Area: Barbarian Village"
@@ -94,8 +94,8 @@ class ItemNames(str, Enum):
Progressive_Weapons = "Progressive Weapons" Progressive_Weapons = "Progressive Weapons"
Progressive_Tools = "Progressive Tools" Progressive_Tools = "Progressive Tools"
Progressive_Range_Armor = "Progressive Ranged Armor" Progressive_Range_Armor = "Progressive Ranged Armor"
Progressive_Range_Weapon = "Progressive Ranged Weapons" Progressive_Range_Weapon = "Progressive Ranged Weapon"
Progressive_Magic = "Progressive Magic" Progressive_Magic = "Progressive Magic Spell"
Lobsters = "10 Lobsters" Lobsters = "10 Lobsters"
Swordfish = "5 Swordfish" Swordfish = "5 Swordfish"
Energy_Potions = "10 Energy Potions" Energy_Potions = "10 Energy Potions"

View File

@@ -3,18 +3,19 @@ from dataclasses import dataclass
from Options import Choice, Toggle, Range, PerGameCommonOptions from Options import Choice, Toggle, Range, PerGameCommonOptions
MAX_COMBAT_TASKS = 16 MAX_COMBAT_TASKS = 16
MAX_PRAYER_TASKS = 3
MAX_MAGIC_TASKS = 4 MAX_PRAYER_TASKS = 5
MAX_RUNECRAFT_TASKS = 3 MAX_MAGIC_TASKS = 7
MAX_CRAFTING_TASKS = 5 MAX_RUNECRAFT_TASKS = 8
MAX_MINING_TASKS = 5 MAX_CRAFTING_TASKS = 11
MAX_SMITHING_TASKS = 4 MAX_MINING_TASKS = 6
MAX_FISHING_TASKS = 5 MAX_SMITHING_TASKS = 5
MAX_COOKING_TASKS = 5 MAX_FISHING_TASKS = 6
MAX_FIREMAKING_TASKS = 2 MAX_COOKING_TASKS = 6
MAX_FIREMAKING_TASKS = 3
MAX_WOODCUTTING_TASKS = 3 MAX_WOODCUTTING_TASKS = 3
NON_QUEST_LOCATION_COUNT = 22 NON_QUEST_LOCATION_COUNT = 49
class StartingArea(Choice): class StartingArea(Choice):
@@ -58,6 +59,31 @@ class ProgressiveTasks(Toggle):
display_name = "Progressive Tasks" display_name = "Progressive Tasks"
class EnableDuds(Toggle):
"""
Whether to include filler "Dud" items that serve no purpose but allow for more tasks in the pool.
"""
display_name = "Enable Duds"
class DudCount(Range):
"""
How many "Dud" items to include in the pool. This setting is ignored if "Enable Duds" is not included
"""
display_name = "Dud Item Count"
range_start = 0
range_end = 30
default = 10
class EnableCarePacks(Toggle):
"""
Whether or not to include useful "Care Pack" items that allow you to trade over specific items.
Note: Requires your account NOT to be an Ironman. Also, requires access to another account to trade over the items,
or gold to purchase off of the grand exchange.
"""
display_name = "Enable Care Packs"
class MaxCombatLevel(Range): class MaxCombatLevel(Range):
""" """
The highest combat level of monster to possibly be assigned as a task. The highest combat level of monster to possibly be assigned as a task.
@@ -472,6 +498,9 @@ class OSRSOptions(PerGameCommonOptions):
starting_area: StartingArea starting_area: StartingArea
brutal_grinds: BrutalGrinds brutal_grinds: BrutalGrinds
progressive_tasks: ProgressiveTasks progressive_tasks: ProgressiveTasks
enable_duds: EnableDuds
dud_count: DudCount
enable_carepacks: EnableCarePacks
max_combat_level: MaxCombatLevel max_combat_level: MaxCombatLevel
max_combat_tasks: MaxCombatTasks max_combat_tasks: MaxCombatTasks
combat_task_weight: CombatTaskWeight combat_task_weight: CombatTaskWeight

View File

@@ -212,11 +212,14 @@ def get_skill_rule(skill, level, player, options) -> CollectionRule:
return lambda state: True return lambda state: True
def generate_special_rules_for(entrance, region_row, outbound_region_name, player, options): def generate_special_rules_for(entrance, region_row, outbound_region_name, player, options, world):
if outbound_region_name == RegionNames.Cooks_Guild: if outbound_region_name == RegionNames.Cooks_Guild:
add_rule(entrance, get_cooking_skill_rule(32, player, options)) add_rule(entrance, get_cooking_skill_rule(32, player, options))
# Since there's goblins in this chunk, checking for hat access is superfluous, you'd always have it anyway
elif outbound_region_name == RegionNames.Crafting_Guild: elif outbound_region_name == RegionNames.Crafting_Guild:
add_rule(entrance, get_crafting_skill_rule(40, player, options)) add_rule(entrance, get_crafting_skill_rule(40, player, options))
# Literally the only brown apron access in the entirety of f2p is buying it in varrock
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Central_Varrock, player))
elif outbound_region_name == RegionNames.Corsair_Cove: elif outbound_region_name == RegionNames.Corsair_Cove:
# Need to be able to start Corsair Curse in addition to having the item # Need to be able to start Corsair Curse in addition to having the item
add_rule(entrance, lambda state: state.can_reach(RegionNames.Falador_Farm, "Region", player)) add_rule(entrance, lambda state: state.can_reach(RegionNames.Falador_Farm, "Region", player))
@@ -224,6 +227,17 @@ def generate_special_rules_for(entrance, region_row, outbound_region_name, playe
add_rule(entrance, lambda state: state.has(ItemNames.QP_Below_Ice_Mountain, player)) add_rule(entrance, lambda state: state.has(ItemNames.QP_Below_Ice_Mountain, player))
elif region_row.name == "Dwarven Mountain Pass" and outbound_region_name == "Anvil*": elif region_row.name == "Dwarven Mountain Pass" and outbound_region_name == "Anvil*":
add_rule(entrance, lambda state: state.has(ItemNames.QP_Dorics_Quest, player)) add_rule(entrance, lambda state: state.has(ItemNames.QP_Dorics_Quest, player))
elif outbound_region_name == RegionNames.Crandor:
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.South_Of_Varrock, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Edgeville, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Lumbridge, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Rimmington, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Monastery, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Dwarven_Mines, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Port_Sarim, player))
add_rule(entrance, lambda state: state.can_reach_region(RegionNames.Draynor_Village, player))
add_rule(entrance, lambda state: world.quest_points(state) >= 32)
# Special logic for canoes # Special logic for canoes
canoe_regions = [RegionNames.Lumbridge, RegionNames.South_Of_Varrock, RegionNames.Barbarian_Village, canoe_regions = [RegionNames.Lumbridge, RegionNames.South_Of_Varrock, RegionNames.Barbarian_Village,

View File

@@ -168,7 +168,7 @@ class OSRSWorld(World):
item_name = self.region_rows_by_name[parsed_outbound].itemReq item_name = self.region_rows_by_name[parsed_outbound].itemReq
entrance.access_rule = lambda state, item_name=item_name.replace("*",""): state.has(item_name, self.player) entrance.access_rule = lambda state, item_name=item_name.replace("*",""): state.has(item_name, self.player)
generate_special_rules_for(entrance, region_row, outbound_region_name, self.player, self.options) generate_special_rules_for(entrance, region_row, outbound_region_name, self.player, self.options, self)
for resource_region in region_row.resources: for resource_region in region_row.resources:
if not resource_region: if not resource_region:
@@ -179,7 +179,7 @@ class OSRSWorld(World):
entrance.connect(self.region_name_to_data[resource_region]) entrance.connect(self.region_name_to_data[resource_region])
else: else:
entrance.connect(self.region_name_to_data[resource_region.replace('*', '')]) entrance.connect(self.region_name_to_data[resource_region.replace('*', '')])
generate_special_rules_for(entrance, region_row, resource_region, self.player, self.options) generate_special_rules_for(entrance, region_row, resource_region, self.player, self.options, self)
self.roll_locations() self.roll_locations()
@@ -195,7 +195,16 @@ class OSRSWorld(World):
generation_is_fake = hasattr(self.multiworld, "generation_is_fake") # UT specific override generation_is_fake = hasattr(self.multiworld, "generation_is_fake") # UT specific override
locations_required = 0 locations_required = 0
for item_row in item_rows: for item_row in item_rows:
# If it's a filler item, set it aside for later
if item_row.progression == ItemClassification.filler:
continue
# If it starts with "Care Pack", only add it if Care Packs are enabled
if item_row.name.startswith("Care Pack"):
if not self.options.enable_carepacks:
continue
locations_required += item_row.amount locations_required += item_row.amount
if self.options.enable_duds: locations_required += self.options.dud_count
locations_added = 1 # At this point we've already added the starting area, so we start at 1 instead of 0 locations_added = 1 # At this point we've already added the starting area, so we start at 1 instead of 0
@@ -232,6 +241,7 @@ class OSRSWorld(World):
max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks") max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks")
tasks_for_this_type = [task for task in self.locations_by_category[task_type] tasks_for_this_type = [task for task in self.locations_by_category[task_type]
if self.task_within_skill_levels(task.skills)] if self.task_within_skill_levels(task.skills)]
max_amount_for_task_type = min(max_amount_for_task_type, len(tasks_for_this_type))
if not self.options.progressive_tasks: if not self.options.progressive_tasks:
rnd.shuffle(tasks_for_this_type) rnd.shuffle(tasks_for_this_type)
else: else:
@@ -286,16 +296,36 @@ class OSRSWorld(World):
self.create_and_add_location(index) self.create_and_add_location(index)
def create_items(self) -> None: def create_items(self) -> None:
filler_items = []
for item_row in item_rows: for item_row in item_rows:
if item_row.name != self.starting_area_item: if item_row.name != self.starting_area_item:
# If it's a filler item, set it aside for later
if item_row.progression == ItemClassification.filler:
filler_items.append(item_row)
continue
# If it starts with "Care Pack", only add it if Care Packs are enabled
if item_row.name.startswith("Care Pack"):
if not self.options.enable_carepacks:
continue
for c in range(item_row.amount): for c in range(item_row.amount):
item = self.create_item(item_row.name) item = self.create_item(item_row.name)
self.multiworld.itempool.append(item) self.multiworld.itempool.append(item)
if self.options.enable_duds:
self.random.shuffle(filler_items)
filler_items = filler_items[0:self.options.dud_count]
for item_row in filler_items:
item = self.create_item(item_row.name)
self.multiworld.itempool.append(item)
def get_filler_item_name(self) -> str: def get_filler_item_name(self) -> str:
return self.random.choice( if self.options.enable_duds:
[ItemNames.Progressive_Armor, ItemNames.Progressive_Weapons, ItemNames.Progressive_Magic, return self.random.choice([item for item in item_rows if item.progression == ItemClassification.filler])
ItemNames.Progressive_Tools, ItemNames.Progressive_Range_Armor, ItemNames.Progressive_Range_Weapon]) else:
return self.random.choice([ItemNames.Progressive_Weapons, ItemNames.Progressive_Magic,
ItemNames.Progressive_Range_Weapon, ItemNames.Progressive_Armor,
ItemNames.Progressive_Range_Armor, ItemNames.Progressive_Tools])
def create_and_add_location(self, row_index) -> None: def create_and_add_location(self, row_index) -> None:
location_row = location_rows[row_index] location_row = location_rows[row_index]

View File

@@ -173,7 +173,7 @@ class Overcooked2World(World):
game_item_count = len(self.itempool) game_item_count = len(self.itempool)
game_progression_count = 0 game_progression_count = 0
for item in self.itempool: for item in self.itempool:
if item.classification == ItemClassification.progression: if item.advancement:
game_progression_count += 1 game_progression_count += 1
game_progression_density = game_progression_count/game_item_count game_progression_density = game_progression_count/game_item_count
@@ -189,7 +189,7 @@ class Overcooked2World(World):
total_progression_count = 0 total_progression_count = 0
for item in self.multiworld.itempool: for item in self.multiworld.itempool:
if item.classification == ItemClassification.progression: if item.advancement:
total_progression_count += 1 total_progression_count += 1
total_progression_density = total_progression_count/total_item_count total_progression_density = total_progression_count/total_item_count

View File

@@ -18,7 +18,7 @@ from .regions import create_regions
from .options import PokemonRBOptions from .options import PokemonRBOptions
from .rom_addresses import rom_addresses from .rom_addresses import rom_addresses
from .text import encode_text from .text import encode_text
from .rom import generate_output, get_base_rom_bytes, get_base_rom_path, RedDeltaPatch, BlueDeltaPatch from .rom import generate_output, PokemonRedProcedurePatch, PokemonBlueProcedurePatch
from .pokemon import process_pokemon_data, process_move_data, verify_hm_moves from .pokemon import process_pokemon_data, process_move_data, verify_hm_moves
from .encounters import process_pokemon_locations, process_trainer_data from .encounters import process_pokemon_locations, process_trainer_data
from .rules import set_rules from .rules import set_rules
@@ -33,12 +33,12 @@ class PokemonSettings(settings.Group):
"""File names of the Pokemon Red and Blue roms""" """File names of the Pokemon Red and Blue roms"""
description = "Pokemon Red (UE) ROM File" description = "Pokemon Red (UE) ROM File"
copy_to = "Pokemon Red (UE) [S][!].gb" copy_to = "Pokemon Red (UE) [S][!].gb"
md5s = [RedDeltaPatch.hash] md5s = [PokemonRedProcedurePatch.hash]
class BlueRomFile(settings.UserFilePath): class BlueRomFile(settings.UserFilePath):
description = "Pokemon Blue (UE) ROM File" description = "Pokemon Blue (UE) ROM File"
copy_to = "Pokemon Blue (UE) [S][!].gb" copy_to = "Pokemon Blue (UE) [S][!].gb"
md5s = [BlueDeltaPatch.hash] md5s = [PokemonBlueProcedurePatch.hash]
red_rom_file: RedRomFile = RedRomFile(RedRomFile.copy_to) red_rom_file: RedRomFile = RedRomFile(RedRomFile.copy_to)
blue_rom_file: BlueRomFile = BlueRomFile(BlueRomFile.copy_to) blue_rom_file: BlueRomFile = BlueRomFile(BlueRomFile.copy_to)
@@ -113,16 +113,6 @@ class PokemonRedBlueWorld(World):
self.local_locs = [] self.local_locs = []
self.pc_item = None self.pc_item = None
@classmethod
def stage_assert_generate(cls, multiworld: MultiWorld):
versions = set()
for player in multiworld.player_ids:
if multiworld.worlds[player].game == "Pokemon Red and Blue":
versions.add(multiworld.worlds[player].options.game_version.current_key)
for version in versions:
if not os.path.exists(get_base_rom_path(version)):
raise FileNotFoundError(get_base_rom_path(version))
@classmethod @classmethod
def stage_generate_early(cls, multiworld: MultiWorld): def stage_generate_early(cls, multiworld: MultiWorld):

View File

@@ -1,9 +1,17 @@
from copy import deepcopy from copy import deepcopy
import typing
from worlds.Files import APTokenTypes
from . import poke_data, logic from . import poke_data, logic
from .rom_addresses import rom_addresses from .rom_addresses import rom_addresses
if typing.TYPE_CHECKING:
from . import PokemonRedBlueWorld
from .rom import PokemonRedProcedurePatch, PokemonBlueProcedurePatch
def set_mon_palettes(world, random, data):
def set_mon_palettes(world: "PokemonRedBlueWorld", patch: "PokemonRedProcedurePatch | PokemonBlueProcedurePatch"):
if world.options.randomize_pokemon_palettes == "vanilla": if world.options.randomize_pokemon_palettes == "vanilla":
return return
pallet_map = { pallet_map = {
@@ -31,12 +39,9 @@ def set_mon_palettes(world, random, data):
poke_data.evolves_from and poke_data.evolves_from[mon] != "Eevee"): poke_data.evolves_from and poke_data.evolves_from[mon] != "Eevee"):
pallet = palettes[-1] pallet = palettes[-1]
else: # completely_random or follow_evolutions and it is not an evolved form (except eeveelutions) else: # completely_random or follow_evolutions and it is not an evolved form (except eeveelutions)
pallet = random.choice(list(pallet_map.values())) pallet = world.random.choice(list(pallet_map.values()))
palettes.append(pallet) palettes.append(pallet)
address = rom_addresses["Mon_Palettes"] patch.write_token(APTokenTypes.WRITE, rom_addresses["Mon_Palettes"], bytes(palettes))
for pallet in palettes:
data[address] = pallet
address += 1
def choose_forced_type(chances, random): def choose_forced_type(chances, random):
@@ -253,9 +258,9 @@ def process_pokemon_data(self):
mon_data[f"start move {i}"] = learnsets[mon].pop(0) mon_data[f"start move {i}"] = learnsets[mon].pop(0)
if self.options.randomize_pokemon_catch_rates: if self.options.randomize_pokemon_catch_rates:
mon_data["catch rate"] = self.random.randint(self.options.minimum_catch_rate, 255) mon_data["catch rate"] = self.random.randint(self.options.minimum_catch_rate.value, 255)
else: else:
mon_data["catch rate"] = max(self.options.minimum_catch_rate, mon_data["catch rate"]) mon_data["catch rate"] = max(self.options.minimum_catch_rate.value, mon_data["catch rate"])
def roll_tm_compat(roll_move): def roll_tm_compat(roll_move):
if self.local_move_data[roll_move]["type"] in [mon_data["type1"], mon_data["type2"]]: if self.local_move_data[roll_move]["type"] in [mon_data["type1"], mon_data["type2"]]:

View File

@@ -1,5 +1,55 @@
import random
import typing
from worlds.Files import APTokenTypes
from .rom_addresses import rom_addresses from .rom_addresses import rom_addresses
if typing.TYPE_CHECKING:
from .rom import PokemonBlueProcedurePatch, PokemonRedProcedurePatch
layout1F = [
[20, 22, 32, 34, 20, 25, 22, 32, 34, 20, 25, 25, 25, 22, 20, 25, 22, 2, 2, 2],
[24, 26, 40, 1, 24, 25, 26, 62, 1, 28, 29, 29, 29, 30, 28, 29, 30, 1, 40, 2],
[28, 30, 1, 1, 28, 29, 30, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 23],
[23, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 31],
[31, 1, 1, 1, 1, 1, 31, 32, 34, 2, 1, 1, 2, 32, 34, 32, 34, 1, 1, 23],
[23, 1, 1, 23, 1, 1, 23, 1, 40, 23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31],
[31, 1, 1, 31, 1, 1, 31, 1, 1, 31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23],
[23, 1, 1, 23, 1, 1, 1, 1, 1, 2, 32, 34, 32, 34, 32, 34, 32, 34, 2, 31],
[31, 1, 1, 31, 1, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 23, 1, 1, 40, 23],
[23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31, 1, 1, 1, 31, 1, 1, 1, 31],
[31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 23, 1, 1, 1, 23],
[23, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 31, 1, 1, 1, 31, 1, 1, 1, 31],
[31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 23],
[ 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31, 1, 1, 1, 31],
[20, 21, 21, 21, 22, 42, 1, 1, 1, 1, 20, 21, 22, 1, 1, 1, 1, 1, 1, 23],
[24, 25, 25, 25, 26, 1, 1, 1, 1, 1, 24, 25, 26, 1, 1, 1, 1, 1, 1, 31],
[24, 25, 25, 25, 26, 1, 1, 62, 1, 1, 24, 25, 26, 20, 21, 21, 21, 21, 21, 22],
[28, 29, 29, 29, 30, 78, 81, 82, 77, 78, 28, 29, 30, 28, 29, 29, 29, 29, 29, 30],
]
layout2F = [
[23, 2, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34],
[31, 62, 1, 23, 1, 1, 23, 1, 1, 1, 1, 1, 23, 62, 1, 1, 1, 1, 1, 2],
[23, 1, 1, 31, 1, 1, 31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 23, 1, 1, 23, 1, 1, 23, 1, 1, 23, 1, 1, 23, 23, 1, 1, 31],
[23, 1, 1, 31, 1, 1, 31, 1, 1, 31, 2, 2, 31, 1, 1, 31, 31, 1, 1, 23],
[31, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 62, 23, 1, 1, 1, 1, 1, 1, 31],
[23, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 23, 1, 1, 1, 1, 1, 23, 32, 34, 32, 34, 32, 34, 1, 1, 1, 31],
[23, 1, 1, 31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 23, 1, 1, 2, 1, 1, 23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31],
[23, 1, 1, 31, 1, 1, 2, 1, 1, 31, 1, 1, 1, 32, 34, 32, 34, 32, 34, 23],
[31, 2, 2, 2, 1, 1, 32, 34, 32, 34, 1, 1, 1, 23, 1, 1, 1, 1, 1, 31],
[23, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 1, 1, 31, 1, 1, 62, 1, 1, 23],
[31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 1, 31],
[23, 32, 34, 32, 34, 32, 34, 1, 1, 32, 34, 32, 34, 31, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31],
[ 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23],
[32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 2, 31]
]
disallowed1F = [[2, 2], [3, 2], [1, 8], [2, 8], [7, 7], [8, 7], [10, 4], [11, 4], [11, 12], 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]] [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], disallowed2F = [[16, 2], [17, 2], [18, 2], [15, 5], [15, 6], [10, 10], [11, 10], [12, 10], [7, 14], [8, 14], [1, 15],
@@ -7,29 +57,12 @@ disallowed2F = [[16, 2], [17, 2], [18, 2], [15, 5], [15, 6], [10, 10], [11, 10],
[11, 1]] [11, 1]]
def randomize_rock_tunnel(data, random): def randomize_rock_tunnel(patch: "PokemonRedProcedurePatch | PokemonBlueProcedurePatch", random: random.Random):
seed = random.randint(0, 999999999999999999) seed = random.randint(0, 999999999999999999)
random.seed(seed) random.seed(seed)
map1f = [] map1f = [row.copy() for row in layout1F]
map2f = [] map2f = [row.copy() for row in layout2F]
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 current_map = map1f
@@ -305,14 +338,6 @@ def randomize_rock_tunnel(data, random):
current_map = map2f current_map = map2f
check_addable_block(map2f, disallowed2F) check_addable_block(map2f, disallowed2F)
address = rom_addresses["Map_Rock_Tunnel1F"] patch.write_token(APTokenTypes.WRITE, rom_addresses["Map_Rock_Tunnel1F"], bytes([b for row in map1f for b in row]))
for y in map1f: patch.write_token(APTokenTypes.WRITE, rom_addresses["Map_Rock_TunnelB1F"], bytes([b for row in map2f for b in row]))
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 return seed

View File

@@ -1,21 +1,66 @@
import os import os
import hashlib
import Utils
import bsdiff4
import pkgutil import pkgutil
from worlds.Files import APDeltaPatch import typing
from .text import encode_text
import Utils
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes
from . import poke_data
from .items import item_table from .items import item_table
from .text import encode_text
from .pokemon import set_mon_palettes from .pokemon import set_mon_palettes
from .regions import PokemonRBWarp, map_ids, town_map_coords
from .rock_tunnel import randomize_rock_tunnel from .rock_tunnel import randomize_rock_tunnel
from .rom_addresses import rom_addresses from .rom_addresses import rom_addresses
from .regions import PokemonRBWarp, map_ids, town_map_coords
from . import poke_data if typing.TYPE_CHECKING:
from . import PokemonRedBlueWorld
def write_quizzes(world, data, random): class PokemonRedProcedurePatch(APProcedurePatch, APTokenMixin):
game = "Pokemon Red and Blue"
hash = "3d45c1ee9abd5738df46d2bdda8b57dc"
patch_file_ending = ".apred"
result_file_ending = ".gb"
def get_quiz(q, a): procedure = [
("apply_bsdiff4", ["base_patch.bsdiff4"]),
("apply_tokens", ["token_data.bin"]),
]
@classmethod
def get_source_data(cls) -> bytes:
from . import PokemonRedBlueWorld
with open(PokemonRedBlueWorld.settings.red_rom_file, "rb") as infile:
base_rom_bytes = bytes(infile.read())
return base_rom_bytes
class PokemonBlueProcedurePatch(APProcedurePatch, APTokenMixin):
game = "Pokemon Red and Blue"
hash = "50927e843568814f7ed45ec4f944bd8b"
patch_file_ending = ".apblue"
result_file_ending = ".gb"
procedure = [
("apply_bsdiff4", ["base_patch.bsdiff4"]),
("apply_tokens", ["token_data.bin"]),
]
@classmethod
def get_source_data(cls) -> bytes:
from . import PokemonRedBlueWorld
with open(PokemonRedBlueWorld.settings.blue_rom_file, "rb") as infile:
base_rom_bytes = bytes(infile.read())
return base_rom_bytes
def write_quizzes(world: "PokemonRedBlueWorld", patch: PokemonBlueProcedurePatch | PokemonRedProcedurePatch):
random = world.random
def get_quiz(q: int, a: int):
if q == 0: if q == 0:
r = random.randint(0, 3) r = random.randint(0, 3)
if r == 0: if r == 0:
@@ -122,13 +167,13 @@ def write_quizzes(world, data, random):
elif q2 == 1: elif q2 == 1:
if a: if a:
state = random.choice( state = random.choice(
['Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut', ["Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut",
'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas', "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas",
'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Jersey', 'New Mexico', "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Jersey", "New Mexico",
'New York', 'North Carolina', 'North Dakota', 'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania', "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania",
'Rhode Island', 'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont', "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming']) "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming"])
else: else:
state = "New Hampshire" state = "New Hampshire"
return encode_text( return encode_text(
@@ -209,7 +254,7 @@ def write_quizzes(world, data, random):
return encode_text(f"{type1} deals<LINE>{eff}damage to<CONT>{type2} type?<DONE>") return encode_text(f"{type1} deals<LINE>{eff}damage to<CONT>{type2} type?<DONE>")
elif q == 14: elif q == 14:
fossil_level = world.multiworld.get_location("Fossil Level - Trainer Parties", fossil_level = world.multiworld.get_location("Fossil Level - Trainer Parties",
world.player).party_data[0]['level'] world.player).party_data[0]["level"]
if not a: if not a:
fossil_level += random.choice((-5, 5)) fossil_level += random.choice((-5, 5))
return encode_text(f"Fossil #MON<LINE>revive at level<CONT>{fossil_level}?<DONE>") return encode_text(f"Fossil #MON<LINE>revive at level<CONT>{fossil_level}?<DONE>")
@@ -224,46 +269,49 @@ def write_quizzes(world, data, random):
return encode_text(f"According to<LINE>Monash Uni.,<CONT>{fodmap} {are_is}<CONT>considered high<CONT>in FODMAPs?<DONE>") return encode_text(f"According to<LINE>Monash Uni.,<CONT>{fodmap} {are_is}<CONT>considered high<CONT>in FODMAPs?<DONE>")
answers = [random.randint(0, 1) for _ in range(6)] answers = [random.randint(0, 1) for _ in range(6)]
questions = random.sample((range(0, 16)), 6) questions = random.sample((range(0, 16)), 6)
question_texts: list[bytearray] = []
question_texts = []
for i, question in enumerate(questions): for i, question in enumerate(questions):
question_texts.append(get_quiz(question, answers[i])) question_texts.append(get_quiz(question, answers[i]))
for i, quiz in enumerate(["A", "B", "C", "D", "E", "F"]): 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) patch.write_token(APTokenTypes.WRITE, rom_addresses[f"Quiz_Answer_{quiz}"], bytes([int(not answers[i]) << 4 | (i + 1)]))
write_bytes(data, question_texts[i], rom_addresses[f"Text_Quiz_{quiz}"]) patch.write_token(APTokenTypes.WRITE, rom_addresses[f"Text_Quiz_{quiz}"], bytes(question_texts[i]))
def generate_output(world, output_directory: str): def generate_output(world: "PokemonRedBlueWorld", output_directory: str):
random = world.random
game_version = world.options.game_version.current_key game_version = world.options.game_version.current_key
data = bytes(get_base_rom_bytes(game_version))
base_patch = pkgutil.get_data(__name__, f'basepatch_{game_version}.bsdiff4') patch_type = PokemonBlueProcedurePatch if game_version == "blue" else PokemonRedProcedurePatch
patch = patch_type(player=world.player, player_name=world.player_name)
patch.write_file("base_patch.bsdiff4", pkgutil.get_data(__name__, f"basepatch_{game_version}.bsdiff4"))
data = bytearray(bsdiff4.patch(data, base_patch)) def write_bytes(address: int, data: typing.Sequence[int] | int):
if isinstance(data, int):
data = bytes([data])
else:
data = bytes(data)
basemd5 = hashlib.md5() patch.write_token(APTokenTypes.WRITE, address, data)
basemd5.update(data)
pallet_connections = {entrance: world.multiworld.get_entrance(f"Pallet Town to {entrance}", pallet_connections = {entrance: world.multiworld.get_entrance(f"Pallet Town to {entrance}",
world.player).connected_region.name for world.player).connected_region.name
entrance in ["Player's House 1F", "Oak's Lab", for entrance in ["Player's House 1F", "Oak's Lab", "Rival's House"]}
"Rival's House"]}
paths = None paths = None
if pallet_connections["Player's House 1F"] == "Oak's Lab": if pallet_connections["Player's House 1F"] == "Oak's Lab":
paths = ((0x00, 4, 0x80, 5, 0x40, 1, 0xE0, 1, 0xFF), (0x40, 2, 0x20, 5, 0x80, 5, 0xFF)) paths = (bytes([0x00, 4, 0x80, 5, 0x40, 1, 0xE0, 1, 0xFF]), bytes([0x40, 2, 0x20, 5, 0x80, 5, 0xFF]))
elif pallet_connections["Rival's House"] == "Oak's Lab": elif pallet_connections["Rival's House"] == "Oak's Lab":
paths = ((0x00, 4, 0xC0, 3, 0x40, 1, 0xE0, 1, 0xFF), (0x40, 2, 0x10, 3, 0x80, 5, 0xFF)) paths = (bytes([0x00, 4, 0xC0, 3, 0x40, 1, 0xE0, 1, 0xFF]), bytes([0x40, 2, 0x10, 3, 0x80, 5, 0xFF]))
if paths: if paths:
write_bytes(data, paths[0], rom_addresses["Path_Pallet_Oak"]) write_bytes(rom_addresses["Path_Pallet_Oak"], paths[0])
write_bytes(data, paths[1], rom_addresses["Path_Pallet_Player"]) write_bytes(rom_addresses["Path_Pallet_Player"], paths[1])
if pallet_connections["Rival's House"] == "Player's House 1F": if pallet_connections["Rival's House"] == "Player's House 1F":
write_bytes(data, [0x2F, 0xC7, 0x06, 0x0D, 0x00, 0x01], rom_addresses["Pallet_Fly_Coords"]) write_bytes(rom_addresses["Pallet_Fly_Coords"], [0x2F, 0xC7, 0x06, 0x0D, 0x00, 0x01])
elif pallet_connections["Oak's Lab"] == "Player's House 1F": elif pallet_connections["Oak's Lab"] == "Player's House 1F":
write_bytes(data, [0x5F, 0xC7, 0x0C, 0x0C, 0x00, 0x00], rom_addresses["Pallet_Fly_Coords"]) write_bytes(rom_addresses["Pallet_Fly_Coords"], [0x5F, 0xC7, 0x0C, 0x0C, 0x00, 0x00])
for region in world.multiworld.get_regions(world.player): for region in world.multiworld.get_regions(world.player):
for entrance in region.exits: for entrance in region.exits:
@@ -281,16 +329,18 @@ def generate_output(world, output_directory: str):
while i > len(warp_to_ids) - 1: while i > len(warp_to_ids) - 1:
i -= len(warp_to_ids) i -= len(warp_to_ids)
connected_map_name = entrance.connected_region.name.split("-")[0] connected_map_name = entrance.connected_region.name.split("-")[0]
data[address] = 0 if "Elevator" in connected_map_name else warp_to_ids[i] write_bytes(address, [
data[address + 1] = map_ids[connected_map_name] 0 if "Elevator" in connected_map_name else warp_to_ids[i],
map_ids[connected_map_name]
])
if world.options.door_shuffle == "simple": if world.options.door_shuffle == "simple":
for (entrance, _, _, map_coords_entries, map_name, _) in town_map_coords.values(): for (entrance, _, _, map_coords_entries, map_name, _) in town_map_coords.values():
destination = world.multiworld.get_entrance(entrance, world.player).connected_region.name destination = world.multiworld.get_entrance(entrance, world.player).connected_region.name
(_, x, y, _, _, map_order_entry) = town_map_coords[destination] (_, x, y, _, _, map_order_entry) = town_map_coords[destination]
for map_coord_entry in map_coords_entries: for map_coord_entry in map_coords_entries:
data[rom_addresses["Town_Map_Coords"] + (map_coord_entry * 4) + 1] = (y << 4) | x write_bytes(rom_addresses["Town_Map_Coords"] + (map_coord_entry * 4) + 1, (y << 4) | x)
data[rom_addresses["Town_Map_Order"] + map_order_entry] = map_ids[map_name] write_bytes(rom_addresses["Town_Map_Order"] + map_order_entry, map_ids[map_name])
if not world.options.key_items_only: if not world.options.key_items_only:
for i, gym_leader in enumerate(("Pewter Gym - Brock TM", "Cerulean Gym - Misty TM", for i, gym_leader in enumerate(("Pewter Gym - Brock TM", "Cerulean Gym - Misty TM",
@@ -302,13 +352,13 @@ def generate_output(world, output_directory: str):
try: try:
tm = int(item_name[2:4]) tm = int(item_name[2:4])
move = poke_data.moves[world.local_tms[tm - 1]]["id"] move = poke_data.moves[world.local_tms[tm - 1]]["id"]
data[rom_addresses["Gym_Leader_Moves"] + (2 * i)] = move write_bytes(rom_addresses["Gym_Leader_Moves"] + (2 * i), move)
except KeyError: except KeyError:
pass pass
def set_trade_mon(address, loc): def set_trade_mon(address, loc):
mon = world.multiworld.get_location(loc, world.player).item.name mon = world.multiworld.get_location(loc, world.player).item.name
data[rom_addresses[address]] = poke_data.pokemon_data[mon]["id"] write_bytes(rom_addresses[address], poke_data.pokemon_data[mon]["id"])
world.trade_mons[address] = mon world.trade_mons[address] = mon
if game_version == "red": if game_version == "red":
@@ -325,141 +375,139 @@ def generate_output(world, output_directory: str):
set_trade_mon("Trade_Doris", "Cerulean Cave 1F - Wild Pokemon - 9") set_trade_mon("Trade_Doris", "Cerulean Cave 1F - Wild Pokemon - 9")
set_trade_mon("Trade_Crinkles", "Route 12 - Wild Pokemon - 4") set_trade_mon("Trade_Crinkles", "Route 12 - Wild Pokemon - 4")
data[rom_addresses['Fly_Location']] = world.fly_map_code write_bytes(rom_addresses["Fly_Location"], world.fly_map_code)
data[rom_addresses['Map_Fly_Location']] = world.town_map_fly_map_code write_bytes(rom_addresses["Map_Fly_Location"], world.town_map_fly_map_code)
if world.options.fix_combat_bugs: if world.options.fix_combat_bugs:
data[rom_addresses["Option_Fix_Combat_Bugs"]] = 1 write_bytes(rom_addresses["Option_Fix_Combat_Bugs"], 1)
data[rom_addresses["Option_Fix_Combat_Bugs_Focus_Energy"]] = 0x28 # jr z write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Focus_Energy"], 0x28) # jr z
data[rom_addresses["Option_Fix_Combat_Bugs_HP_Drain_Dream_Eater"]] = 0x1A # ld a, (de) write_bytes(rom_addresses["Option_Fix_Combat_Bugs_HP_Drain_Dream_Eater"], 0x1A) # ld a, (de)
data[rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"]] = 0xe6 # and a, direct write_bytes(rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"], 0xe6) # and a, direct
data[rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"] + 1] = 0b0011111 write_bytes(rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"] + 1, 0b0011111)
data[rom_addresses["Option_Fix_Combat_Bugs_Struggle"]] = 0xe6 # and a, direct write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Struggle"], 0xe6) # and a, direct
data[rom_addresses["Option_Fix_Combat_Bugs_Struggle"] + 1] = 0x3f write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Struggle"] + 1, 0x3f)
data[rom_addresses["Option_Fix_Combat_Bugs_Dig_Fly"]] = 0b10001100 write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Dig_Fly"], 0b10001100)
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"]] = 0x20 # jr nz, write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"], 0x20) # jr nz,
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"] + 1] = 5 # 5 bytes ahead write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"] + 1, 5) # 5 bytes ahead
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Stat_Modifiers"]] = 1 write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Stat_Modifiers"], 1)
if world.options.poke_doll_skip == "in_logic": if world.options.poke_doll_skip == "in_logic":
data[rom_addresses["Option_Silph_Scope_Skip"]] = 0x00 # nop write_bytes(rom_addresses["Option_Silph_Scope_Skip"], 0x00) # nop
data[rom_addresses["Option_Silph_Scope_Skip"] + 1] = 0x00 # nop write_bytes(rom_addresses["Option_Silph_Scope_Skip"] + 1, 0x00) # nop
data[rom_addresses["Option_Silph_Scope_Skip"] + 2] = 0x00 # nop write_bytes(rom_addresses["Option_Silph_Scope_Skip"] + 2, 0x00) # nop
if world.options.bicycle_gate_skips == "patched": if world.options.bicycle_gate_skips == "patched":
data[rom_addresses["Option_Route_16_Gate_Fix"]] = 0x00 # nop write_bytes(rom_addresses["Option_Route_16_Gate_Fix"], 0x00) # nop
data[rom_addresses["Option_Route_16_Gate_Fix"] + 1] = 0x00 # nop write_bytes(rom_addresses["Option_Route_16_Gate_Fix"] + 1, 0x00) # nop
data[rom_addresses["Option_Route_18_Gate_Fix"]] = 0x00 # nop write_bytes(rom_addresses["Option_Route_18_Gate_Fix"], 0x00) # nop
data[rom_addresses["Option_Route_18_Gate_Fix"] + 1] = 0x00 # nop write_bytes(rom_addresses["Option_Route_18_Gate_Fix"] + 1, 0x00) # nop
if world.options.door_shuffle: if world.options.door_shuffle:
data[rom_addresses["Entrance_Shuffle_Fuji_Warp"]] = 1 # prevent warping to Fuji's House from Pokemon Tower 7F write_bytes(rom_addresses["Entrance_Shuffle_Fuji_Warp"], 1) # prevent warping to Fuji's House from Pokemon Tower 7F
if world.options.all_elevators_locked: if world.options.all_elevators_locked:
data[rom_addresses["Option_Locked_Elevator_Celadon"]] = 0x20 # jr nz write_bytes(rom_addresses["Option_Locked_Elevator_Celadon"], 0x20) # jr nz
data[rom_addresses["Option_Locked_Elevator_Silph"]] = 0x20 # jr nz write_bytes(rom_addresses["Option_Locked_Elevator_Silph"], 0x20) # jr nz
if world.options.tea: if world.options.tea:
data[rom_addresses["Option_Tea"]] = 1 write_bytes(rom_addresses["Option_Tea"], 1)
data[rom_addresses["Guard_Drink_List"]] = 0x54 write_bytes(rom_addresses["Guard_Drink_List"], 0x54)
data[rom_addresses["Guard_Drink_List"] + 1] = 0 write_bytes(rom_addresses["Guard_Drink_List"] + 1, 0)
data[rom_addresses["Guard_Drink_List"] + 2] = 0 write_bytes(rom_addresses["Guard_Drink_List"] + 2, 0)
write_bytes(data, encode_text("<LINE>Gee, I have the<CONT>worst caffeine<CONT>headache though." write_bytes(rom_addresses["Text_Saffron_Gate"],
"<PARA>Oh wait there,<LINE>the road's closed.<DONE>"), encode_text("<LINE>Gee, I have the<CONT>worst caffeine<CONT>headache though."
rom_addresses["Text_Saffron_Gate"]) "<PARA>Oh wait there,<LINE>the road's closed.<DONE>"))
data[rom_addresses["Tea_Key_Item_A"]] = 0x28 # jr .z write_bytes(rom_addresses["Tea_Key_Item_A"], 0x28) # jr .z
data[rom_addresses["Tea_Key_Item_B"]] = 0x28 # jr .z write_bytes(rom_addresses["Tea_Key_Item_B"], 0x28) # jr .z
data[rom_addresses["Tea_Key_Item_C"]] = 0x28 # jr .z write_bytes(rom_addresses["Tea_Key_Item_C"], 0x28) # jr .z
data[rom_addresses["Fossils_Needed_For_Second_Item"]] = ( write_bytes(rom_addresses["Fossils_Needed_For_Second_Item"], world.options.second_fossil_check_condition.value)
world.options.second_fossil_check_condition.value)
data[rom_addresses["Option_Lose_Money"]] = int(not world.options.lose_money_on_blackout.value) write_bytes(rom_addresses["Option_Lose_Money"], int(not world.options.lose_money_on_blackout.value))
if world.options.extra_key_items: if world.options.extra_key_items:
data[rom_addresses['Option_Extra_Key_Items_A']] = 1 write_bytes(rom_addresses["Option_Extra_Key_Items_A"], 1)
data[rom_addresses['Option_Extra_Key_Items_B']] = 1 write_bytes(rom_addresses["Option_Extra_Key_Items_B"], 1)
data[rom_addresses['Option_Extra_Key_Items_C']] = 1 write_bytes(rom_addresses["Option_Extra_Key_Items_C"], 1)
data[rom_addresses['Option_Extra_Key_Items_D']] = 1 write_bytes(rom_addresses["Option_Extra_Key_Items_D"], 1)
data[rom_addresses["Option_Split_Card_Key"]] = world.options.split_card_key.value write_bytes(rom_addresses["Option_Split_Card_Key"], world.options.split_card_key.value)
data[rom_addresses["Option_Blind_Trainers"]] = round(world.options.blind_trainers.value * 2.55) write_bytes(rom_addresses["Option_Blind_Trainers"], round(world.options.blind_trainers.value * 2.55))
data[rom_addresses["Option_Cerulean_Cave_Badges"]] = world.options.cerulean_cave_badges_condition.value write_bytes(rom_addresses["Option_Cerulean_Cave_Badges"], world.options.cerulean_cave_badges_condition.value)
data[rom_addresses["Option_Cerulean_Cave_Key_Items"]] = world.options.cerulean_cave_key_items_condition.total write_bytes(rom_addresses["Option_Cerulean_Cave_Key_Items"], world.options.cerulean_cave_key_items_condition.total)
write_bytes(data, encode_text(str(world.options.cerulean_cave_badges_condition.value)), rom_addresses["Text_Cerulean_Cave_Badges"]) write_bytes(rom_addresses["Text_Cerulean_Cave_Badges"], encode_text(str(world.options.cerulean_cave_badges_condition.value)))
write_bytes(data, encode_text(str(world.options.cerulean_cave_key_items_condition.total) + " key items."), rom_addresses["Text_Cerulean_Cave_Key_Items"]) write_bytes(rom_addresses["Text_Cerulean_Cave_Key_Items"], encode_text(str(world.options.cerulean_cave_key_items_condition.total) + " key items."))
data[rom_addresses['Option_Encounter_Minimum_Steps']] = world.options.minimum_steps_between_encounters.value write_bytes(rom_addresses["Option_Encounter_Minimum_Steps"], world.options.minimum_steps_between_encounters.value)
data[rom_addresses['Option_Route23_Badges']] = world.options.victory_road_condition.value write_bytes(rom_addresses["Option_Route23_Badges"], world.options.victory_road_condition.value)
data[rom_addresses['Option_Victory_Road_Badges']] = world.options.route_22_gate_condition.value write_bytes(rom_addresses["Option_Victory_Road_Badges"], world.options.route_22_gate_condition.value)
data[rom_addresses['Option_Elite_Four_Pokedex']] = world.options.elite_four_pokedex_condition.total write_bytes(rom_addresses["Option_Elite_Four_Pokedex"], world.options.elite_four_pokedex_condition.total)
data[rom_addresses['Option_Elite_Four_Key_Items']] = world.options.elite_four_key_items_condition.total write_bytes(rom_addresses["Option_Elite_Four_Key_Items"], world.options.elite_four_key_items_condition.total)
data[rom_addresses['Option_Elite_Four_Badges']] = world.options.elite_four_badges_condition.value write_bytes(rom_addresses["Option_Elite_Four_Badges"], world.options.elite_four_badges_condition.value)
write_bytes(data, encode_text(str(world.options.elite_four_badges_condition.value)), rom_addresses["Text_Elite_Four_Badges"]) write_bytes(rom_addresses["Text_Elite_Four_Badges"], encode_text(str(world.options.elite_four_badges_condition.value)))
write_bytes(data, encode_text(str(world.options.elite_four_key_items_condition.total) + " key items, and"), rom_addresses["Text_Elite_Four_Key_Items"]) write_bytes(rom_addresses["Text_Elite_Four_Key_Items"], encode_text(str(world.options.elite_four_key_items_condition.total) + " key items, and"))
write_bytes(data, encode_text(str(world.options.elite_four_pokedex_condition.total) + " #MON"), rom_addresses["Text_Elite_Four_Pokedex"]) write_bytes(rom_addresses["Text_Elite_Four_Pokedex"], encode_text(str(world.options.elite_four_pokedex_condition.total) + " #MON"))
write_bytes(data, encode_text(str(world.total_key_items), length=2), rom_addresses["Trainer_Screen_Total_Key_Items"]) write_bytes(rom_addresses["Trainer_Screen_Total_Key_Items"], encode_text(str(world.total_key_items), length=2))
data[rom_addresses['Option_Viridian_Gym_Badges']] = world.options.viridian_gym_condition.value write_bytes(rom_addresses["Option_Viridian_Gym_Badges"], world.options.viridian_gym_condition.value)
data[rom_addresses['Option_EXP_Modifier']] = world.options.exp_modifier.value write_bytes(rom_addresses["Option_EXP_Modifier"], world.options.exp_modifier.value)
if not world.options.require_item_finder: if not world.options.require_item_finder:
data[rom_addresses['Option_Itemfinder']] = 0 # nop write_bytes(rom_addresses["Option_Itemfinder"], 0) # nop
if world.options.extra_strength_boulders: if world.options.extra_strength_boulders:
for i in range(0, 3): for i in range(0, 3):
data[rom_addresses['Option_Boulders'] + (i * 3)] = 0x15 write_bytes(rom_addresses["Option_Boulders"] + (i * 3), 0x15)
if world.options.extra_key_items: if world.options.extra_key_items:
for i in range(0, 4): for i in range(0, 4):
data[rom_addresses['Option_Rock_Tunnel_Extra_Items'] + (i * 3)] = 0x15 write_bytes(rom_addresses["Option_Rock_Tunnel_Extra_Items"] + (i * 3), 0x15)
if world.options.old_man == "open_viridian_city": if world.options.old_man == "open_viridian_city":
data[rom_addresses['Option_Old_Man']] = 0x11 write_bytes(rom_addresses["Option_Old_Man"], 0x11)
data[rom_addresses['Option_Old_Man_Lying']] = 0x15 write_bytes(rom_addresses["Option_Old_Man_Lying"], 0x15)
data[rom_addresses['Option_Route3_Guard_B']] = world.options.route_3_condition.value write_bytes(rom_addresses["Option_Route3_Guard_B"], world.options.route_3_condition.value)
if world.options.route_3_condition == "open": if world.options.route_3_condition == "open":
data[rom_addresses['Option_Route3_Guard_A']] = 0x11 write_bytes(rom_addresses["Option_Route3_Guard_A"], 0x11)
if not world.options.robbed_house_officer: if not world.options.robbed_house_officer:
data[rom_addresses['Option_Trashed_House_Guard_A']] = 0x15 write_bytes(rom_addresses["Option_Trashed_House_Guard_A"], 0x15)
data[rom_addresses['Option_Trashed_House_Guard_B']] = 0x11 write_bytes(rom_addresses["Option_Trashed_House_Guard_B"], 0x11)
if world.options.require_pokedex: if world.options.require_pokedex:
data[rom_addresses["Require_Pokedex_A"]] = 1 write_bytes(rom_addresses["Require_Pokedex_A"], 1)
data[rom_addresses["Require_Pokedex_B"]] = 1 write_bytes(rom_addresses["Require_Pokedex_B"], 1)
data[rom_addresses["Require_Pokedex_C"]] = 1 write_bytes(rom_addresses["Require_Pokedex_C"], 1)
else: else:
data[rom_addresses["Require_Pokedex_D"]] = 0x18 # jr write_bytes(rom_addresses["Require_Pokedex_D"], 0x18) # jr
if world.options.dexsanity: if world.options.dexsanity:
data[rom_addresses["Option_Dexsanity_A"]] = 1 write_bytes(rom_addresses["Option_Dexsanity_A"], 1)
data[rom_addresses["Option_Dexsanity_B"]] = 1 write_bytes(rom_addresses["Option_Dexsanity_B"], 1)
if world.options.all_pokemon_seen: if world.options.all_pokemon_seen:
data[rom_addresses["Option_Pokedex_Seen"]] = 1 write_bytes(rom_addresses["Option_Pokedex_Seen"], 1)
money = str(world.options.starting_money.value).zfill(6) money = str(world.options.starting_money.value).zfill(6)
data[rom_addresses["Starting_Money_High"]] = int(money[:2], 16) write_bytes(rom_addresses["Starting_Money_High"], int(money[:2], 16))
data[rom_addresses["Starting_Money_Middle"]] = int(money[2:4], 16) write_bytes(rom_addresses["Starting_Money_Middle"], int(money[2:4], 16))
data[rom_addresses["Starting_Money_Low"]] = int(money[4:], 16) write_bytes(rom_addresses["Starting_Money_Low"], int(money[4:], 16))
data[rom_addresses["Text_Badges_Needed_Viridian_Gym"]] = encode_text( write_bytes(rom_addresses["Text_Badges_Needed_Viridian_Gym"],
str(world.options.viridian_gym_condition.value))[0] encode_text(str(world.options.viridian_gym_condition.value))[0])
data[rom_addresses["Text_Rt23_Badges_A"]] = encode_text( write_bytes(rom_addresses["Text_Rt23_Badges_A"],
str(world.options.victory_road_condition.value))[0] encode_text(str(world.options.victory_road_condition.value))[0])
data[rom_addresses["Text_Rt23_Badges_B"]] = encode_text( write_bytes(rom_addresses["Text_Rt23_Badges_B"],
str(world.options.victory_road_condition.value))[0] encode_text(str(world.options.victory_road_condition.value))[0])
data[rom_addresses["Text_Rt23_Badges_C"]] = encode_text( write_bytes(rom_addresses["Text_Rt23_Badges_C"],
str(world.options.victory_road_condition.value))[0] encode_text(str(world.options.victory_road_condition.value))[0])
data[rom_addresses["Text_Rt23_Badges_D"]] = encode_text( write_bytes(rom_addresses["Text_Rt23_Badges_D"],
str(world.options.victory_road_condition.value))[0] encode_text(str(world.options.victory_road_condition.value))[0])
data[rom_addresses["Text_Badges_Needed"]] = encode_text( write_bytes(rom_addresses["Text_Badges_Needed"],
str(world.options.elite_four_badges_condition.value))[0] encode_text(str(world.options.elite_four_badges_condition.value))[0])
write_bytes(data, encode_text( write_bytes(rom_addresses["Text_Magikarp_Salesman"],
" ".join(world.multiworld.get_location("Route 4 Pokemon Center - Pokemon For Sale", world.player).item.name.upper().split()[1:])), encode_text(" ".join(world.multiworld.get_location("Route 4 Pokemon Center - Pokemon For Sale", world.player).item.name.upper().split()[1:])))
rom_addresses["Text_Magikarp_Salesman"])
if world.options.badges_needed_for_hm_moves.value == 0: if world.options.badges_needed_for_hm_moves.value == 0:
for hm_move in poke_data.hm_moves: for hm_move in poke_data.hm_moves:
write_bytes(data, bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), write_bytes(rom_addresses["HM_" + hm_move + "_Badge_a"], [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
rom_addresses["HM_" + hm_move + "_Badge_a"])
elif world.extra_badges: elif world.extra_badges:
written_badges = {} written_badges = {}
badge_codes = {"Boulder Badge": 0x47, "Cascade Badge": 0x4F,
"Thunder Badge": 0x57, "Rainbow Badge": 0x5F,
"Soul Badge": 0x67, "Marsh Badge": 0x6F,
"Volcano Badge": 0x77, "Earth Badge": 0x7F}
for hm_move, badge in world.extra_badges.items(): for hm_move, badge in world.extra_badges.items():
data[rom_addresses["HM_" + hm_move + "_Badge_b"]] = {"Boulder Badge": 0x47, "Cascade Badge": 0x4F, write_bytes(rom_addresses["HM_" + hm_move + "_Badge_b"], badge_codes[badge])
"Thunder Badge": 0x57, "Rainbow Badge": 0x5F,
"Soul Badge": 0x67, "Marsh Badge": 0x6F,
"Volcano Badge": 0x77, "Earth Badge": 0x7F}[badge]
move_text = hm_move move_text = hm_move
if badge not in ["Marsh Badge", "Volcano Badge", "Earth Badge"]: if badge not in ["Marsh Badge", "Volcano Badge", "Earth Badge"]:
move_text = ", " + move_text move_text = ", " + move_text
@@ -467,62 +515,58 @@ def generate_output(world, output_directory: str):
if badge in written_badges: if badge in written_badges:
rom_address += len(written_badges[badge]) rom_address += len(written_badges[badge])
move_text = ", " + move_text move_text = ", " + move_text
write_bytes(data, encode_text(move_text.upper()), rom_address) write_bytes(rom_address, encode_text(move_text.upper()))
written_badges[badge] = move_text written_badges[badge] = move_text
for badge in ["Marsh Badge", "Volcano Badge", "Earth Badge"]: for badge in ["Marsh Badge", "Volcano Badge", "Earth Badge"]:
if badge not in written_badges: if badge not in written_badges:
write_bytes(data, encode_text("Nothing"), rom_addresses["Badge_Text_" + badge.replace(" ", "_")]) write_bytes(rom_addresses["Badge_Text_" + badge.replace(" ", "_")], encode_text("Nothing"))
type_loc = rom_addresses["Type_Chart"] type_loc = rom_addresses["Type_Chart"]
for matchup in world.type_chart: for matchup in world.type_chart:
if matchup[2] != 10: # don't needlessly divide damage by 10 and multiply by 10 if matchup[2] != 10: # don't needlessly divide damage by 10 and multiply by 10
data[type_loc] = poke_data.type_ids[matchup[0]] write_bytes(type_loc, [poke_data.type_ids[matchup[0]], poke_data.type_ids[matchup[1]], matchup[2]])
data[type_loc + 1] = poke_data.type_ids[matchup[1]]
data[type_loc + 2] = matchup[2]
type_loc += 3 type_loc += 3
data[type_loc] = 0xFF write_bytes(type_loc, b"\xFF\xFF\xFF")
data[type_loc + 1] = 0xFF
data[type_loc + 2] = 0xFF
if world.options.normalize_encounter_chances.value: if world.options.normalize_encounter_chances.value:
chances = [25, 51, 77, 103, 129, 155, 180, 205, 230, 255] chances = [25, 51, 77, 103, 129, 155, 180, 205, 230, 255]
for i, chance in enumerate(chances): for i, chance in enumerate(chances):
data[rom_addresses['Encounter_Chances'] + (i * 2)] = chance write_bytes(rom_addresses["Encounter_Chances"] + (i * 2), chance)
for mon, mon_data in world.local_poke_data.items(): for mon, mon_data in world.local_poke_data.items():
if mon == "Mew": if mon == "Mew":
address = rom_addresses["Base_Stats_Mew"] address = rom_addresses["Base_Stats_Mew"]
else: else:
address = rom_addresses["Base_Stats"] + (28 * (mon_data["dex"] - 1)) address = rom_addresses["Base_Stats"] + (28 * (mon_data["dex"] - 1))
data[address + 1] = world.local_poke_data[mon]["hp"] write_bytes(address + 1, world.local_poke_data[mon]["hp"])
data[address + 2] = world.local_poke_data[mon]["atk"] write_bytes(address + 2, world.local_poke_data[mon]["atk"])
data[address + 3] = world.local_poke_data[mon]["def"] write_bytes(address + 3, world.local_poke_data[mon]["def"])
data[address + 4] = world.local_poke_data[mon]["spd"] write_bytes(address + 4, world.local_poke_data[mon]["spd"])
data[address + 5] = world.local_poke_data[mon]["spc"] write_bytes(address + 5, world.local_poke_data[mon]["spc"])
data[address + 6] = poke_data.type_ids[world.local_poke_data[mon]["type1"]] write_bytes(address + 6, poke_data.type_ids[world.local_poke_data[mon]["type1"]])
data[address + 7] = poke_data.type_ids[world.local_poke_data[mon]["type2"]] write_bytes(address + 7, poke_data.type_ids[world.local_poke_data[mon]["type2"]])
data[address + 8] = world.local_poke_data[mon]["catch rate"] write_bytes(address + 8, world.local_poke_data[mon]["catch rate"])
data[address + 15] = poke_data.moves[world.local_poke_data[mon]["start move 1"]]["id"] write_bytes(address + 15, poke_data.moves[world.local_poke_data[mon]["start move 1"]]["id"])
data[address + 16] = poke_data.moves[world.local_poke_data[mon]["start move 2"]]["id"] write_bytes(address + 16, poke_data.moves[world.local_poke_data[mon]["start move 2"]]["id"])
data[address + 17] = poke_data.moves[world.local_poke_data[mon]["start move 3"]]["id"] write_bytes(address + 17, poke_data.moves[world.local_poke_data[mon]["start move 3"]]["id"])
data[address + 18] = poke_data.moves[world.local_poke_data[mon]["start move 4"]]["id"] write_bytes(address + 18, poke_data.moves[world.local_poke_data[mon]["start move 4"]]["id"])
write_bytes(data, world.local_poke_data[mon]["tms"], address + 20) write_bytes(address + 20, world.local_poke_data[mon]["tms"])
if mon in world.learnsets and world.learnsets[mon]: if mon in world.learnsets and world.learnsets[mon]:
address = rom_addresses["Learnset_" + mon.replace(" ", "")] address = rom_addresses["Learnset_" + mon.replace(" ", "")]
for i, move in enumerate(world.learnsets[mon]): for i, move in enumerate(world.learnsets[mon]):
data[(address + 1) + i * 2] = poke_data.moves[move]["id"] write_bytes((address + 1) + i * 2, poke_data.moves[move]["id"])
data[rom_addresses["Option_Aide_Rt2"]] = world.options.oaks_aide_rt_2.value write_bytes(rom_addresses["Option_Aide_Rt2"], world.options.oaks_aide_rt_2.value)
data[rom_addresses["Option_Aide_Rt11"]] = world.options.oaks_aide_rt_11.value write_bytes(rom_addresses["Option_Aide_Rt11"], world.options.oaks_aide_rt_11.value)
data[rom_addresses["Option_Aide_Rt15"]] = world.options.oaks_aide_rt_15.value write_bytes(rom_addresses["Option_Aide_Rt15"], world.options.oaks_aide_rt_15.value)
if world.options.safari_zone_normal_battles.value == 1: if world.options.safari_zone_normal_battles.value == 1:
data[rom_addresses["Option_Safari_Zone_Battle_Type"]] = 255 write_bytes(rom_addresses["Option_Safari_Zone_Battle_Type"], 255)
if world.options.reusable_tms.value: if world.options.reusable_tms.value:
data[rom_addresses["Option_Reusable_TMs"]] = 0xC9 write_bytes(rom_addresses["Option_Reusable_TMs"], 0xC9)
data[rom_addresses["Option_Always_Half_STAB"]] = int(not world.options.same_type_attack_bonus.value) write_bytes(rom_addresses["Option_Always_Half_STAB"], int(not world.options.same_type_attack_bonus.value))
if world.options.better_shops: if world.options.better_shops:
inventory = ["Poke Ball", "Great Ball", "Ultra Ball"] inventory = ["Poke Ball", "Great Ball", "Ultra Ball"]
@@ -531,43 +575,45 @@ def generate_output(world, output_directory: str):
inventory += ["Potion", "Super Potion", "Hyper Potion", "Max Potion", "Full Restore", "Revive", "Antidote", inventory += ["Potion", "Super Potion", "Hyper Potion", "Max Potion", "Full Restore", "Revive", "Antidote",
"Awakening", "Burn Heal", "Ice Heal", "Paralyze Heal", "Full Heal", "Repel", "Super Repel", "Awakening", "Burn Heal", "Ice Heal", "Paralyze Heal", "Full Heal", "Repel", "Super Repel",
"Max Repel", "Escape Rope"] "Max Repel", "Escape Rope"]
shop_data = bytearray([0xFE, len(inventory)]) shop_data = [0xFE, len(inventory)]
shop_data += bytearray([item_table[item].id - 172000000 for item in inventory]) shop_data += [item_table[item].id - 172000000 for item in inventory]
shop_data.append(0xFF) shop_data.append(0xFF)
for shop in range(1, 11): for shop in range(1, 11):
write_bytes(data, shop_data, rom_addresses[f"Shop{shop}"]) write_bytes(rom_addresses[f"Shop{shop}"], shop_data)
if world.options.stonesanity: if world.options.stonesanity:
write_bytes(data, bytearray([0xFE, 1, item_table["Poke Doll"].id - 172000000, 0xFF]), rom_addresses[f"Shop_Stones"]) write_bytes(rom_addresses["Shop_Stones"], [0xFE, 1, item_table["Poke Doll"].id - 172000000, 0xFF])
price = str(world.options.master_ball_price.value).zfill(6) price = str(world.options.master_ball_price.value).zfill(6)
price = bytearray([int(price[:2], 16), int(price[2:4], 16), int(price[4:], 16)]) price = [int(price[:2], 16), int(price[2:4], 16), int(price[4:], 16)]
write_bytes(data, price, rom_addresses["Price_Master_Ball"]) # Money values in Red and Blue are weird write_bytes(rom_addresses["Price_Master_Ball"], price) # Money values in Red and Blue are weird
for item in reversed(world.multiworld.precollected_items[world.player]): from collections import Counter
if data[rom_addresses["Start_Inventory"] + item.code - 172000000] < 255: start_inventory = Counter(item.code for item in reversed(world.multiworld.precollected_items[world.player]))
data[rom_addresses["Start_Inventory"] + item.code - 172000000] += 1 for item, value in start_inventory.items():
write_bytes(rom_addresses["Start_Inventory"] + item - 172000000, min(value, 255))
set_mon_palettes(world, random, data) set_mon_palettes(world, patch)
for move_data in world.local_move_data.values(): for move_data in world.local_move_data.values():
if move_data["id"] == 0: if move_data["id"] == 0:
continue continue
address = rom_addresses["Move_Data"] + ((move_data["id"] - 1) * 6) address = rom_addresses["Move_Data"] + ((move_data["id"] - 1) * 6)
write_bytes(data, bytearray([move_data["id"], move_data["effect"], move_data["power"], write_bytes(address, [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) poke_data.type_ids[move_data["type"]], round(move_data["accuracy"] * 2.55),
move_data["pp"]])
TM_IDs = bytearray([poke_data.moves[move]["id"] for move in world.local_tms]) TM_IDs = [poke_data.moves[move]["id"] for move in world.local_tms]
write_bytes(data, TM_IDs, rom_addresses["TM_Moves"]) write_bytes(rom_addresses["TM_Moves"], TM_IDs)
if world.options.randomize_rock_tunnel: if world.options.randomize_rock_tunnel:
seed = randomize_rock_tunnel(data, random) seed = randomize_rock_tunnel(patch, world.random)
write_bytes(data, encode_text(f"SEED: <LINE>{seed}"), rom_addresses["Text_Rock_Tunnel_Sign"]) write_bytes(rom_addresses["Text_Rock_Tunnel_Sign"], encode_text(f"SEED: <LINE>{seed}"))
mons = [mon["id"] for mon in poke_data.pokemon_data.values()] mons = [mon["id"] for mon in poke_data.pokemon_data.values()]
random.shuffle(mons) world.random.shuffle(mons)
data[rom_addresses['Title_Mon_First']] = mons.pop() write_bytes(rom_addresses["Title_Mon_First"], mons.pop())
for mon in range(0, 16): for mon in range(0, 16):
data[rom_addresses['Title_Mons'] + mon] = mons.pop() write_bytes(rom_addresses["Title_Mons"] + mon, mons.pop())
if world.options.game_version.value: if world.options.game_version.value:
mons.sort(key=lambda mon: 0 if mon == world.multiworld.get_location("Oak's Lab - Starter 1", world.player).item.name mons.sort(key=lambda mon: 0 if mon == world.multiworld.get_location("Oak's Lab - Starter 1", world.player).item.name
else 1 if mon == world.multiworld.get_location("Oak's Lab - Starter 2", world.player).item.name else else 1 if mon == world.multiworld.get_location("Oak's Lab - Starter 2", world.player).item.name else
@@ -576,34 +622,34 @@ def generate_output(world, output_directory: str):
mons.sort(key=lambda mon: 0 if mon == world.multiworld.get_location("Oak's Lab - Starter 2", world.player).item.name mons.sort(key=lambda mon: 0 if mon == world.multiworld.get_location("Oak's Lab - Starter 2", world.player).item.name
else 1 if mon == world.multiworld.get_location("Oak's Lab - Starter 1", world.player).item.name else else 1 if mon == world.multiworld.get_location("Oak's Lab - Starter 1", world.player).item.name else
2 if mon == world.multiworld.get_location("Oak's Lab - Starter 3", world.player).item.name else 3) 2 if mon == world.multiworld.get_location("Oak's Lab - Starter 3", world.player).item.name else 3)
write_bytes(data, encode_text(world.multiworld.seed_name[-20:], 20, True), rom_addresses['Title_Seed']) write_bytes(rom_addresses["Title_Seed"], encode_text(world.multiworld.seed_name[-20:], 20, True))
slot_name = world.multiworld.player_name[world.player] slot_name = world.multiworld.player_name[world.player]
slot_name.replace("@", " ") slot_name.replace("@", " ")
slot_name.replace("<", " ") slot_name.replace("<", " ")
slot_name.replace(">", " ") slot_name.replace(">", " ")
write_bytes(data, encode_text(slot_name, 16, True, True), rom_addresses['Title_Slot_Name']) write_bytes(rom_addresses["Title_Slot_Name"], encode_text(slot_name, 16, True, True))
if world.trainer_name == "choose_in_game": if world.trainer_name == "choose_in_game":
data[rom_addresses["Skip_Player_Name"]] = 0 write_bytes(rom_addresses["Skip_Player_Name"], 0)
else: else:
write_bytes(data, world.trainer_name, rom_addresses['Player_Name']) write_bytes(rom_addresses["Player_Name"], world.trainer_name)
if world.rival_name == "choose_in_game": if world.rival_name == "choose_in_game":
data[rom_addresses["Skip_Rival_Name"]] = 0 write_bytes(rom_addresses["Skip_Rival_Name"], 0)
else: else:
write_bytes(data, world.rival_name, rom_addresses['Rival_Name']) write_bytes(rom_addresses["Rival_Name"], world.rival_name)
data[0xFF00] = 2 # client compatibility version write_bytes(0xFF00, 2) # client compatibility version
rom_name = bytearray(f'AP{Utils.__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', rom_name = bytearray(f"AP{Utils.__version__.replace('.', '')[0:3]}_{world.player}_{world.multiworld.seed:11}\0",
'utf8')[:21] "utf8")[:21]
rom_name.extend([0] * (21 - len(rom_name))) rom_name.extend([0] * (21 - len(rom_name)))
write_bytes(data, rom_name, 0xFFC6) write_bytes(0xFFC6, rom_name)
write_bytes(data, world.multiworld.seed_name.encode(), 0xFFDB) write_bytes(0xFFDB, world.multiworld.seed_name.encode())
write_bytes(data, world.multiworld.player_name[world.player].encode(), 0xFFF0) write_bytes(0xFFF0, world.multiworld.player_name[world.player].encode())
world.finished_level_scaling.wait() world.finished_level_scaling.wait()
write_quizzes(world, data, random) write_quizzes(world, patch)
for location in world.multiworld.get_locations(world.player): for location in world.multiworld.get_locations(world.player):
if location.party_data: if location.party_data:
@@ -617,18 +663,18 @@ def generate_output(world, output_directory: str):
levels = party["level"] levels = party["level"]
for address, party in zip(addresses, parties): for address, party in zip(addresses, parties):
if isinstance(levels, int): if isinstance(levels, int):
data[address] = levels write_bytes(address, levels)
address += 1 address += 1
for mon in party: for mon in party:
data[address] = poke_data.pokemon_data[mon]["id"] write_bytes(address, poke_data.pokemon_data[mon]["id"])
address += 1 address += 1
else: else:
address += 1 address += 1
for level, mon in zip(levels, party): for level, mon in zip(levels, party):
data[address] = level write_bytes(address, [level, poke_data.pokemon_data[mon]["id"]])
data[address + 1] = poke_data.pokemon_data[mon]["id"]
address += 2 address += 2
assert data[address] == 0 or location.name == "Fossil Level - Trainer Parties" # This assert can't be done with procedure patch tokens.
# assert data[address] == 0 or location.name == "Fossil Level - Trainer Parties"
continue continue
elif location.rom_address is None: elif location.rom_address is None:
continue continue
@@ -639,85 +685,24 @@ def generate_output(world, output_directory: str):
rom_address = [rom_address] rom_address = [rom_address]
for address in rom_address: for address in rom_address:
if location.item.name in poke_data.pokemon_data.keys(): if location.item.name in poke_data.pokemon_data.keys():
data[address] = poke_data.pokemon_data[location.item.name]["id"] write_bytes(address, poke_data.pokemon_data[location.item.name]["id"])
elif " ".join(location.item.name.split()[1:]) in poke_data.pokemon_data.keys(): 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"] write_bytes(address, poke_data.pokemon_data[" ".join(location.item.name.split()[1:])]["id"])
else: else:
item_id = world.item_name_to_id[location.item.name] - 172000000 item_id = world.item_name_to_id[location.item.name] - 172000000
if item_id > 255: if item_id > 255:
item_id -= 256 item_id -= 256
data[address] = item_id write_bytes(address, item_id)
if location.level: if location.level:
data[location.level_address] = location.level write_bytes(location.level_address, location.level)
else: else:
rom_address = location.rom_address rom_address = location.rom_address
if not isinstance(rom_address, list): if not isinstance(rom_address, list):
rom_address = [rom_address] rom_address = [rom_address]
for address in rom_address: for address in rom_address:
data[address] = 0x2C # AP Item write_bytes(address, 0x2C) # AP Item
outfilepname = f'_P{world.player}' patch.write_file("token_data.bin", patch.get_token_binary())
outfilepname += f"_{world.multiworld.get_file_safe_player_name(world.player).replace(' ', '_')}" \ out_file_name = world.multiworld.get_out_file_name_base(world.player)
if world.multiworld.player_name[world.player] != 'Player%d' % world.player else '' patch.write(os.path.join(output_directory, f"{out_file_name}{patch.patch_file_ending}"))
rompath = os.path.join(output_directory, f'AP_{world.multiworld.seed_name}{outfilepname}.gb')
with open(rompath, 'wb') as outfile:
outfile.write(data)
if world.options.game_version.current_key == "red":
patch = RedDeltaPatch(os.path.splitext(rompath)[0] + RedDeltaPatch.patch_file_ending, player=world.player,
player_name=world.multiworld.player_name[world.player], patched_path=rompath)
else:
patch = BlueDeltaPatch(os.path.splitext(rompath)[0] + BlueDeltaPatch.patch_file_ending, player=world.player,
player_name=world.multiworld.player_name[world.player], patched_path=rompath)
patch.write()
os.unlink(rompath)
def write_bytes(data, byte_array, address):
for byte in byte_array:
data[address] = byte
address += 1
def get_base_rom_bytes(game_version: str, hash: str="") -> bytes:
file_name = get_base_rom_path(game_version)
with open(file_name, "rb") as file:
base_rom_bytes = bytes(file.read())
if hash:
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if hash != basemd5.hexdigest():
raise Exception(f"Supplied Base Rom does not match known MD5 for Pokémon {game_version.title()} UE "
"release. Get the correct game and version, then dump it")
return base_rom_bytes
def get_base_rom_path(game_version: str) -> str:
options = Utils.get_options()
file_name = options["pokemon_rb_options"][f"{game_version}_rom_file"]
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name
class BlueDeltaPatch(APDeltaPatch):
patch_file_ending = ".apblue"
hash = "50927e843568814f7ed45ec4f944bd8b"
game_version = "blue"
game = "Pokemon Red and Blue"
result_file_ending = ".gb"
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes(cls.game_version, cls.hash)
class RedDeltaPatch(APDeltaPatch):
patch_file_ending = ".apred"
hash = "3d45c1ee9abd5738df46d2bdda8b57dc"
game_version = "red"
game = "Pokemon Red and Blue"
result_file_ending = ".gb"
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes(cls.game_version, cls.hash)

View File

@@ -89,9 +89,12 @@ class ROM(object):
class FakeROM(ROM): class FakeROM(ROM):
# to have the same code for real ROM and the webservice # to have the same code for real ROM and the webservice
def __init__(self, data={}): def __init__(self, data=None):
super(FakeROM, self).__init__() super(FakeROM, self).__init__()
self.data = data if data is None:
self.data = {}
else:
self.data = data
self.ipsPatches = [] self.ipsPatches = []
def write(self, bytes): def write(self, bytes):

View File

@@ -1,6 +1,15 @@
# Super Mario World - Changelog # Super Mario World - Changelog
## v2.1
### Features:
- Trap Link
- When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Ring Link
- Any coin amounts gained and lost by a linked player will be instantly shared with all other active linked players
## v2.0 ## v2.0
### Features: ### Features:

View File

@@ -1,9 +1,11 @@
import logging import logging
import time import time
from typing import Any
from NetUtils import ClientStatus, color from NetUtils import ClientStatus, NetworkItem, color
from worlds.AutoSNIClient import SNIClient from worlds.AutoSNIClient import SNIClient
from .Names.TextBox import generate_received_text from .Names.TextBox import generate_received_text, generate_received_trap_link_text
from .Items import trap_value_to_name, trap_name_to_value
snes_logger = logging.getLogger("SNES") snes_logger = logging.getLogger("SNES")
@@ -42,10 +44,13 @@ SMW_MOON_ACTIVE_ADDR = ROM_START + 0x01BFA8
SMW_HIDDEN_1UP_ACTIVE_ADDR = ROM_START + 0x01BFA9 SMW_HIDDEN_1UP_ACTIVE_ADDR = ROM_START + 0x01BFA9
SMW_BONUS_BLOCK_ACTIVE_ADDR = ROM_START + 0x01BFAA SMW_BONUS_BLOCK_ACTIVE_ADDR = ROM_START + 0x01BFAA
SMW_BLOCKSANITY_ACTIVE_ADDR = ROM_START + 0x01BFAB SMW_BLOCKSANITY_ACTIVE_ADDR = ROM_START + 0x01BFAB
SMW_TRAP_LINK_ACTIVE_ADDR = ROM_START + 0x01BFB7
SMW_RING_LINK_ACTIVE_ADDR = ROM_START + 0x01BFB8
SMW_GAME_STATE_ADDR = WRAM_START + 0x100 SMW_GAME_STATE_ADDR = WRAM_START + 0x100
SMW_MARIO_STATE_ADDR = WRAM_START + 0x71 SMW_MARIO_STATE_ADDR = WRAM_START + 0x71
SMW_COIN_COUNT_ADDR = WRAM_START + 0xDBF
SMW_BOSS_STATE_ADDR = WRAM_START + 0xD9B SMW_BOSS_STATE_ADDR = WRAM_START + 0xD9B
SMW_ACTIVE_BOSS_ADDR = WRAM_START + 0x13FC SMW_ACTIVE_BOSS_ADDR = WRAM_START + 0x13FC
SMW_CURRENT_LEVEL_ADDR = WRAM_START + 0x13BF SMW_CURRENT_LEVEL_ADDR = WRAM_START + 0x13BF
@@ -76,6 +81,7 @@ SMW_UNCOLLECTABLE_DRAGON_COINS = [0x24]
class SMWSNIClient(SNIClient): class SMWSNIClient(SNIClient):
game = "Super Mario World" game = "Super Mario World"
patch_suffix = ".apsmw" patch_suffix = ".apsmw"
slot_data: dict[str, Any] | None
async def deathlink_kill_player(self, ctx): async def deathlink_kill_player(self, ctx):
from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read
@@ -111,6 +117,84 @@ class SMWSNIClient(SNIClient):
ctx.last_death_link = time.time() ctx.last_death_link = time.time()
def on_package(self, ctx: SNIClient, cmd: str, args: dict[str, Any]) -> None:
super().on_package(ctx, cmd, args)
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
if cmd != "Bounced":
return
if "tags" not in args:
return
if not hasattr(self, "instance_id"):
self.instance_id = time.time()
source_name = args["data"]["source"]
if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.slot_info[ctx.slot].name:
trap_name: str = args["data"]["trap_name"]
if trap_name not in trap_name_to_value:
# We don't know how to handle this trap, ignore it
return
trap_id: int = trap_name_to_value[trap_name]
if "trap_weights" not in self.slot_data:
return
if f"{trap_id}" not in self.slot_data["trap_weights"]:
return
if self.slot_data["trap_weights"][f"{trap_id}"] == 0:
# The player disabled this trap type
return
self.priority_trap = NetworkItem(trap_id, None, None)
self.priority_trap_message = generate_received_trap_link_text(trap_name, source_name)
self.priority_trap_message_str = f"Received linked {trap_name} from {source_name}"
elif "RingLink" in ctx.tags and "RingLink" in args["tags"] and source_name != self.instance_id:
if not hasattr(self, "pending_ring_link"):
self.pending_ring_link = 0
self.pending_ring_link += args["data"]["amount"]
async def send_trap_link(self, ctx: SNIClient, trap_name: str):
if "TrapLink" not in ctx.tags or ctx.slot == None:
return
await ctx.send_msgs([{
"cmd": "Bounce", "tags": ["TrapLink"],
"data": {
"time": time.time(),
"source": ctx.player_names[ctx.slot],
"trap_name": trap_name
}
}])
snes_logger.info(f"Sent linked {trap_name}")
async def send_ring_link(self, ctx: SNIClient, amount: int):
from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read
if "RingLink" not in ctx.tags or ctx.slot == None:
return
game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
if game_state[0] != 0x14:
return
if not hasattr(self, "instance_id"):
self.instance_id = time.time()
await ctx.send_msgs([{
"cmd": "Bounce", "tags": ["RingLink"],
"data": {
"time": time.time(),
"source": self.instance_id,
"amount": amount
}
}])
async def validate_rom(self, ctx): async def validate_rom(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
@@ -123,9 +207,11 @@ class SMWSNIClient(SNIClient):
receive_option = await snes_read(ctx, SMW_RECEIVE_MSG_DATA, 0x1) receive_option = await snes_read(ctx, SMW_RECEIVE_MSG_DATA, 0x1)
send_option = await snes_read(ctx, SMW_SEND_MSG_DATA, 0x1) send_option = await snes_read(ctx, SMW_SEND_MSG_DATA, 0x1)
trap_link = await snes_read(ctx, SMW_TRAP_LINK_ACTIVE_ADDR, 0x1)
ctx.receive_option = receive_option[0] ctx.receive_option = receive_option[0]
ctx.send_option = send_option[0] ctx.send_option = send_option[0]
ctx.trap_link = trap_link[0]
ctx.allow_collect = True ctx.allow_collect = True
@@ -133,6 +219,15 @@ class SMWSNIClient(SNIClient):
if death_link: if death_link:
await ctx.update_death_link(bool(death_link[0] & 0b1)) await ctx.update_death_link(bool(death_link[0] & 0b1))
if trap_link and bool(trap_link[0] & 0b1) and "TrapLink" not in ctx.tags:
ctx.tags.add("TrapLink")
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
ring_link = await snes_read(ctx, SMW_RING_LINK_ACTIVE_ADDR, 1)
if ring_link and bool(ring_link[0] & 0b1) and "RingLink" not in ctx.tags:
ctx.tags.add("RingLink")
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
if ctx.rom != rom_name: if ctx.rom != rom_name:
ctx.current_sublevel_value = 0 ctx.current_sublevel_value = 0
@@ -142,12 +237,17 @@ class SMWSNIClient(SNIClient):
def add_message_to_queue(self, new_message): def add_message_to_queue(self, new_message):
if not hasattr(self, "message_queue"): if not hasattr(self, "message_queue"):
self.message_queue = [] self.message_queue = []
self.message_queue.append(new_message) self.message_queue.append(new_message)
def add_message_to_queue_front(self, new_message):
if not hasattr(self, "message_queue"):
self.message_queue = []
self.message_queue.insert(0, new_message)
async def handle_message_queue(self, ctx): async def handle_message_queue(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
@@ -206,7 +306,8 @@ class SMWSNIClient(SNIClient):
async def handle_trap_queue(self, ctx): async def handle_trap_queue(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
if not hasattr(self, "trap_queue") or len(self.trap_queue) == 0: if (not hasattr(self, "trap_queue") or len(self.trap_queue) == 0) and\
(not hasattr(self, "priority_trap") or self.priority_trap == 0):
return return
game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
@@ -221,7 +322,24 @@ class SMWSNIClient(SNIClient):
if pause_state[0] != 0x00: if pause_state[0] != 0x00:
return return
next_trap, message = self.trap_queue.pop(0)
next_trap = None
message = bytearray()
message_str = ""
from_queue = False
if getattr(self, "priority_trap", None) and self.priority_trap.item != 0:
next_trap = self.priority_trap
message = self.priority_trap_message
message_str = self.priority_trap_message_str
self.priority_trap = None
self.priority_trap_message = bytearray()
self.priority_trap_message_str = ""
elif hasattr(self, "trap_queue") and len(self.trap_queue) > 0:
from_queue = True
next_trap, message = self.trap_queue.pop(0)
else:
return
from .Rom import trap_rom_data from .Rom import trap_rom_data
if next_trap.item in trap_rom_data: if next_trap.item in trap_rom_data:
@@ -231,16 +349,22 @@ class SMWSNIClient(SNIClient):
# Timer Trap # Timer Trap
if trap_active[0] == 0 or (trap_active[0] == 1 and trap_active[1] == 0 and trap_active[2] == 0): if trap_active[0] == 0 or (trap_active[0] == 1 and trap_active[1] == 0 and trap_active[2] == 0):
# Trap already active # Trap already active
self.add_trap_to_queue(next_trap, message) if from_queue:
self.add_trap_to_queue(next_trap, message)
return return
else: else:
if len(message_str) > 0:
snes_logger.info(message_str)
if "TrapLink" in ctx.tags and from_queue:
await self.send_trap_link(ctx, trap_value_to_name[next_trap.item])
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0], bytes([0x01])) snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0], bytes([0x01]))
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 1, bytes([0x00])) snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 1, bytes([0x00]))
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 2, bytes([0x00])) snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 2, bytes([0x00]))
else: else:
if trap_active[0] > 0: if trap_active[0] > 0:
# Trap already active # Trap already active
self.add_trap_to_queue(next_trap, message) if from_queue:
self.add_trap_to_queue(next_trap, message)
return return
else: else:
if next_trap.item == 0xBC001D: if next_trap.item == 0xBC001D:
@@ -248,12 +372,18 @@ class SMWSNIClient(SNIClient):
# Do not fire if the previous thwimp hasn't reached the player's Y pos # Do not fire if the previous thwimp hasn't reached the player's Y pos
active_thwimp = await snes_read(ctx, SMW_ACTIVE_THWIMP_ADDR, 0x1) active_thwimp = await snes_read(ctx, SMW_ACTIVE_THWIMP_ADDR, 0x1)
if active_thwimp[0] != 0xFF: if active_thwimp[0] != 0xFF:
self.add_trap_to_queue(next_trap, message) if from_queue:
self.add_trap_to_queue(next_trap, message)
return return
verify_game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) verify_game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
if verify_game_state[0] == 0x14 and len(trap_rom_data[next_trap.item]) > 2: if verify_game_state[0] == 0x14 and len(trap_rom_data[next_trap.item]) > 2:
snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([trap_rom_data[next_trap.item][2]])) snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([trap_rom_data[next_trap.item][2]]))
if len(message_str) > 0:
snes_logger.info(message_str)
if "TrapLink" in ctx.tags and from_queue:
await self.send_trap_link(ctx, trap_value_to_name[next_trap.item])
new_item_count = trap_rom_data[next_trap.item][1] new_item_count = trap_rom_data[next_trap.item][1]
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0], bytes([new_item_count])) snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0], bytes([new_item_count]))
@@ -270,9 +400,75 @@ class SMWSNIClient(SNIClient):
return return
if self.should_show_message(ctx, next_trap): if self.should_show_message(ctx, next_trap):
self.add_message_to_queue_front(message)
elif next_trap.item == 0xBC0015:
if self.should_show_message(ctx, next_trap):
self.add_message_to_queue_front(message)
if len(message_str) > 0:
snes_logger.info(message_str)
if "TrapLink" in ctx.tags and from_queue:
await self.send_trap_link(ctx, trap_value_to_name[next_trap.item])
# Handle Literature Trap
from .Names.LiteratureTrap import lit_trap_text_list
import random
rand_trap = random.choice(lit_trap_text_list)
for message in rand_trap:
self.add_message_to_queue(message) self.add_message_to_queue(message)
async def handle_ring_link(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
if "RingLink" not in ctx.tags:
return
if not hasattr(self, "prev_coins"):
self.prev_coins = 0
curr_coins_byte = await snes_read(ctx, SMW_COIN_COUNT_ADDR, 0x1)
curr_coins = curr_coins_byte[0]
if curr_coins < self.prev_coins:
# Coins rolled over from 1-Up
curr_coins += 100
coins_diff = curr_coins - self.prev_coins
if coins_diff > 0:
await self.send_ring_link(ctx, coins_diff)
self.prev_coins = curr_coins % 100
new_coins = curr_coins
if not hasattr(self, "pending_ring_link"):
self.pending_ring_link = 0
if self.pending_ring_link != 0:
new_coins += self.pending_ring_link
new_coins = max(new_coins, 0)
new_1_ups = 0
while new_coins >= 100:
new_1_ups += 1
new_coins -= 100
if new_1_ups > 0:
curr_lives_inc_byte = await snes_read(ctx, WRAM_START + 0x18E4, 0x1)
curr_lives_inc = curr_lives_inc_byte[0]
new_lives_inc = curr_lives_inc + new_1_ups
snes_buffered_write(ctx, WRAM_START + 0x18E4, bytes([new_lives_inc]))
snes_buffered_write(ctx, SMW_COIN_COUNT_ADDR, bytes([new_coins]))
if self.pending_ring_link > 0:
snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x01]))
else:
snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x2A]))
self.pending_ring_link = 0
self.prev_coins = new_coins
await snes_flush_writes(ctx)
async def game_watcher(self, ctx): async def game_watcher(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
@@ -333,6 +529,7 @@ class SMWSNIClient(SNIClient):
await self.handle_message_queue(ctx) await self.handle_message_queue(ctx)
await self.handle_trap_queue(ctx) await self.handle_trap_queue(ctx)
await self.handle_ring_link(ctx)
new_checks = [] new_checks = []
event_data = await snes_read(ctx, SMW_EVENT_ROM_DATA, 0x60) event_data = await snes_read(ctx, SMW_EVENT_ROM_DATA, 0x60)
@@ -506,7 +703,7 @@ class SMWSNIClient(SNIClient):
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received))) ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
if self.should_show_message(ctx, item): if self.should_show_message(ctx, item):
if item.item != 0xBC0012 and item.item not in trap_rom_data: if item.item != 0xBC0012 and item.item != 0xBC0015 and item.item not in trap_rom_data:
# Don't send messages for Boss Tokens # Don't send messages for Boss Tokens
item_name = ctx.item_names.lookup_in_game(item.item) item_name = ctx.item_names.lookup_in_game(item.item)
player_name = ctx.player_names[item.player] player_name = ctx.player_names[item.player]
@@ -515,7 +712,7 @@ class SMWSNIClient(SNIClient):
self.add_message_to_queue(receive_message) self.add_message_to_queue(receive_message)
snes_buffered_write(ctx, SMW_RECV_PROGRESS_ADDR, bytes([recv_index&0xFF, (recv_index>>8)&0xFF])) snes_buffered_write(ctx, SMW_RECV_PROGRESS_ADDR, bytes([recv_index&0xFF, (recv_index>>8)&0xFF]))
if item.item in trap_rom_data: if item.item in trap_rom_data or item.item == 0xBC0015:
item_name = ctx.item_names.lookup_in_game(item.item) item_name = ctx.item_names.lookup_in_game(item.item)
player_name = ctx.player_names[item.player] player_name = ctx.player_names[item.player]
@@ -572,14 +769,6 @@ class SMWSNIClient(SNIClient):
else: else:
# Extra Powerup? # Extra Powerup?
pass pass
elif item.item == 0xBC0015:
# Handle Literature Trap
from .Names.LiteratureTrap import lit_trap_text_list
import random
rand_trap = random.choice(lit_trap_text_list)
for message in rand_trap:
self.add_message_to_queue(message)
await snes_flush_writes(ctx) await snes_flush_writes(ctx)

View File

@@ -75,3 +75,49 @@ item_table = {
} }
lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code} lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code}
trap_value_to_name: typing.Dict[int, str] = {
0xBC0013: ItemName.ice_trap,
0xBC0014: ItemName.stun_trap,
0xBC0015: ItemName.literature_trap,
0xBC0016: ItemName.timer_trap,
0xBC001C: ItemName.reverse_controls_trap,
0xBC001D: ItemName.thwimp_trap,
}
trap_name_to_value: typing.Dict[str, int] = {
# Our native Traps
ItemName.ice_trap: 0xBC0013,
ItemName.stun_trap: 0xBC0014,
ItemName.literature_trap: 0xBC0015,
ItemName.timer_trap: 0xBC0016,
ItemName.reverse_controls_trap: 0xBC001C,
ItemName.thwimp_trap: 0xBC001D,
# Common other trap names
"Chaos Control Trap": 0xBC0014, # Stun Trap
"Confuse Trap": 0xBC001C, # Reverse Trap
"Exposition Trap": 0xBC0015, # Literature Trap
"Cutscene Trap": 0xBC0015, # Literature Trap
"Freeze Trap": 0xBC0014, # Stun Trap
"Frozen Trap": 0xBC0014, # Stun Trap
"Paralyze Trap": 0xBC0014, # Stun Trap
"Reversal Trap": 0xBC001C, # Reverse Trap
"Fuzzy Trap": 0xBC001C, # Reverse Trap
"Confound Trap": 0xBC001C, # Reverse Trap
"Confusion Trap": 0xBC001C, # Reverse Trap
"Police Trap": 0xBC001D, # Thwimp Trap
"Buyon Trap": 0xBC001D, # Thwimp Trap
"Gooey Bag": 0xBC001D, # Thwimp Trap
"TNT Barrel Trap": 0xBC001D, # Thwimp Trap
"Honey Trap": 0xBC0014, # Stun Trap
"Screen Flip Trap": 0xBC001C, # Reverse Trap
"Banana Trap": 0xBC0013, # Ice Trap
"Bomb": 0xBC001D, # Thwimp Trap
"Bonk Trap": 0xBC0014, # Stun Trap
"Ghost": 0xBC001D, # Thwimp Trap
"Fast Trap": 0xBC0016, # Timer Trap
"Nut Trap": 0xBC001D, # Thwimp Trap
"Army Trap": 0xBC001D, # Thwimp Trap
}

View File

@@ -117,6 +117,31 @@ def generate_received_text(item_name: str, player_name: str):
return out_array return out_array
def generate_received_trap_link_text(item_name: str, player_name: str):
out_array = bytearray()
item_name = item_name[:18]
player_name = player_name[:18]
item_buffer = max(0, math.floor((18 - len(item_name)) / 2))
player_buffer = max(0, math.floor((18 - len(player_name)) / 2))
out_array += bytearray([0x9F, 0x9F])
out_array += string_to_bytes(" Received linked")
out_array[-1] += 0x80
out_array += bytearray([0x1F] * item_buffer)
out_array += string_to_bytes(item_name)
out_array[-1] += 0x80
out_array += string_to_bytes(" from")
out_array[-1] += 0x80
out_array += bytearray([0x1F] * player_buffer)
out_array += string_to_bytes(player_name)
out_array[-1] += 0x80
out_array += bytearray([0x9F, 0x9F])
return out_array
def generate_sent_text(item_name: str, player_name: str): def generate_sent_text(item_name: str, player_name: str):
out_array = bytearray() out_array = bytearray()

View File

@@ -398,6 +398,20 @@ class StartingLifeCount(Range):
default = 5 default = 5
class RingLink(Toggle):
"""
Whether your in-level coin gain/loss is linked to other players
"""
display_name = "Ring Link"
class TrapLink(Toggle):
"""
Whether your received traps are linked to other players
"""
display_name = "Trap Link"
smw_option_groups = [ smw_option_groups = [
OptionGroup("Goal Options", [ OptionGroup("Goal Options", [
Goal, Goal,
@@ -447,6 +461,8 @@ smw_option_groups = [
@dataclass @dataclass
class SMWOptions(PerGameCommonOptions): class SMWOptions(PerGameCommonOptions):
death_link: DeathLink death_link: DeathLink
ring_link: RingLink
trap_link: TrapLink
goal: Goal goal: Goal
bosses_required: BossesRequired bosses_required: BossesRequired
max_yoshi_egg_cap: NumberOfYoshiEggs max_yoshi_egg_cap: NumberOfYoshiEggs

View File

@@ -719,8 +719,8 @@ def handle_vertical_scroll(rom):
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, # Levels 0D0-0DF 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, # Levels 0D0-0DF
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, # Levels 0E0-0EF 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, # Levels 0E0-0EF
0x02, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, # Levels 0F0-0FF 0x02, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, # Levels 0F0-0FF
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, # Levels 100-10F 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, # Levels 100-10F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 110-11F 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, # Levels 110-11F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 120-12F 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 120-12F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 130-13F 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 130-13F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 140-14F 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 140-14F
@@ -3160,6 +3160,8 @@ def patch_rom(world: World, rom, player, active_level_dict):
rom.write_byte(0x01BFA9, world.options.hidden_1up_checks.value) rom.write_byte(0x01BFA9, world.options.hidden_1up_checks.value)
rom.write_byte(0x01BFAA, world.options.bonus_block_checks.value) rom.write_byte(0x01BFAA, world.options.bonus_block_checks.value)
rom.write_byte(0x01BFAB, world.options.blocksanity.value) rom.write_byte(0x01BFAB, world.options.blocksanity.value)
rom.write_byte(0x01BFB7, world.options.trap_link.value)
rom.write_byte(0x01BFB8, world.options.ring_link.value)
from Utils import __version__ from Utils import __version__

View File

@@ -90,6 +90,7 @@ class SMWWorld(World):
"blocksanity", "blocksanity",
) )
slot_data["active_levels"] = self.active_level_dict slot_data["active_levels"] = self.active_level_dict
slot_data["trap_weights"] = self.output_trap_weights()
return slot_data return slot_data
@@ -322,3 +323,15 @@ class SMWWorld(World):
def set_rules(self): def set_rules(self):
set_rules(self) set_rules(self)
def output_trap_weights(self) -> dict[int, int]:
trap_data = {}
trap_data[0xBC0013] = self.options.ice_trap_weight.value
trap_data[0xBC0014] = self.options.stun_trap_weight.value
trap_data[0xBC0015] = self.options.literature_trap_weight.value
trap_data[0xBC0016] = self.options.timer_trap_weight.value
trap_data[0xBC001C] = self.options.reverse_trap_weight.value
trap_data[0xBC001D] = self.options.thwimp_trap_weight.value
return trap_data

View File

@@ -299,17 +299,9 @@ class StardewValleyWorld(World):
return StardewItem(item.name, override_classification, item.code, self.player) return StardewItem(item.name, override_classification, item.code, self.player)
def create_event_location(self, location_data: LocationData, rule: StardewRule = None, item: Optional[str] = None): def create_event_location(self, location_data: LocationData, rule: StardewRule, item: str):
if rule is None:
rule = True_()
if item is None:
item = location_data.name
region = self.multiworld.get_region(location_data.region, self.player) region = self.multiworld.get_region(location_data.region, self.player)
location = StardewLocation(self.player, location_data.name, None, region) region.add_event(location_data.name, item, rule, StardewLocation, StardewItem)
location.access_rule = rule
region.locations.append(location)
location.place_locked_item(StardewItem(item, ItemClassification.progression, None, self.player))
def set_rules(self): def set_rules(self):
set_rules(self) set_rules(self)

View File

@@ -154,7 +154,7 @@ class FestivalLogic(BaseLogic):
# Salads at the bar are good enough # Salads at the bar are good enough
cooking_rule = self.logic.money.can_spend_at(Region.saloon, 220) cooking_rule = self.logic.money.can_spend_at(Region.saloon, 220)
fish_rule = self.logic.skill.can_fish(difficulty=50) fish_rule = self.logic.fishing.can_fish_anywhere(50)
# Hazelnut always available since the grange display is in fall # Hazelnut always available since the grange display is in fall
forage_rule = self.logic.region.can_reach_any((Region.forest, Region.backwoods)) forage_rule = self.logic.region.can_reach_any((Region.forest, Region.backwoods))
@@ -179,7 +179,7 @@ class FestivalLogic(BaseLogic):
return animal_rule & artisan_rule & cooking_rule & fish_rule & forage_rule & fruit_rule & mineral_rule & vegetable_rule return animal_rule & artisan_rule & cooking_rule & fish_rule & forage_rule & fruit_rule & mineral_rule & vegetable_rule
def can_win_fishing_competition(self) -> StardewRule: def can_win_fishing_competition(self) -> StardewRule:
return self.logic.skill.can_fish(difficulty=60) return self.logic.fishing.can_fish(60)
def has_all_rarecrows(self) -> StardewRule: def has_all_rarecrows(self) -> StardewRule:
rules = [] rules = []

View File

@@ -1,3 +1,5 @@
from functools import cached_property
from Utils import cache_self1 from Utils import cache_self1
from .base_logic import BaseLogicMixin, BaseLogic from .base_logic import BaseLogicMixin, BaseLogic
from ..data import fish_data from ..data import fish_data
@@ -12,6 +14,8 @@ from ..strings.quality_names import FishQuality
from ..strings.region_names import Region from ..strings.region_names import Region
from ..strings.skill_names import Skill from ..strings.skill_names import Skill
fishing_regions = (Region.beach, Region.town, Region.forest, Region.mountain, Region.island_south, Region.island_west)
class FishingLogicMixin(BaseLogicMixin): class FishingLogicMixin(BaseLogicMixin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -20,17 +24,35 @@ class FishingLogicMixin(BaseLogicMixin):
class FishingLogic(BaseLogic): class FishingLogic(BaseLogic):
def can_fish_in_freshwater(self) -> StardewRule: @cache_self1
return self.logic.skill.can_fish() & self.logic.region.can_reach_any((Region.forest, Region.town, Region.mountain)) def can_fish_anywhere(self, difficulty: int = 0) -> StardewRule:
return self.logic.fishing.can_fish(difficulty) & self.logic.region.can_reach_any(fishing_regions)
def can_fish_in_freshwater(self) -> StardewRule:
return self.logic.fishing.can_fish() & self.logic.region.can_reach_any((Region.forest, Region.town, Region.mountain))
@cached_property
def has_max_fishing(self) -> StardewRule: def has_max_fishing(self) -> StardewRule:
return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 10) return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 10)
@cached_property
def can_fish_chests(self) -> StardewRule: def can_fish_chests(self) -> StardewRule:
return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 6) return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 6)
@cache_self1
def can_fish_at(self, region: str) -> StardewRule: def can_fish_at(self, region: str) -> StardewRule:
return self.logic.skill.can_fish() & self.logic.region.can_reach(region) return self.logic.fishing.can_fish() & self.logic.region.can_reach(region)
@cache_self1
def can_fish(self, difficulty: int = 0) -> StardewRule:
skill_required = min(10, max(0, int((difficulty / 10) - 1)))
if difficulty <= 40:
skill_required = 0
skill_rule = self.logic.skill.has_level(Skill.fishing, skill_required)
# Training rod only works with fish < 50. Fiberglass does not help you to catch higher difficulty fish, so it's skipped in logic.
number_fishing_rod_required = 1 if difficulty < 50 else (2 if difficulty < 80 else 4)
return self.logic.tool.has_fishing_rod(number_fishing_rod_required) & skill_rule
@cache_self1 @cache_self1
def can_catch_fish(self, fish: FishItem) -> StardewRule: def can_catch_fish(self, fish: FishItem) -> StardewRule:
@@ -39,14 +61,17 @@ class FishingLogic(BaseLogic):
quest_rule = self.logic.fishing.can_start_extended_family_quest() quest_rule = self.logic.fishing.can_start_extended_family_quest()
region_rule = self.logic.region.can_reach_any(fish.locations) region_rule = self.logic.region.can_reach_any(fish.locations)
season_rule = self.logic.season.has_any(fish.seasons) season_rule = self.logic.season.has_any(fish.seasons)
if fish.difficulty == -1: if fish.difficulty == -1:
difficulty_rule = self.logic.skill.can_crab_pot difficulty_rule = self.logic.fishing.can_crab_pot
else: else:
difficulty_rule = self.logic.skill.can_fish(difficulty=(120 if fish.legendary else fish.difficulty)) difficulty_rule = self.logic.fishing.can_fish(120 if fish.legendary else fish.difficulty)
if fish.name == SVEFish.kittyfish: if fish.name == SVEFish.kittyfish:
item_rule = self.logic.received(SVEQuestItem.kittyfish_spell) item_rule = self.logic.received(SVEQuestItem.kittyfish_spell)
else: else:
item_rule = True_() item_rule = True_()
return quest_rule & region_rule & season_rule & difficulty_rule & item_rule return quest_rule & region_rule & season_rule & difficulty_rule & item_rule
def can_catch_fish_for_fishsanity(self, fish: FishItem) -> StardewRule: def can_catch_fish_for_fishsanity(self, fish: FishItem) -> StardewRule:
@@ -78,7 +103,7 @@ class FishingLogic(BaseLogic):
return self.logic.tool.has_fishing_rod(4) & self.logic.has(tackle) return self.logic.tool.has_fishing_rod(4) & self.logic.has(tackle)
def can_catch_every_fish(self) -> StardewRule: def can_catch_every_fish(self) -> StardewRule:
rules = [self.has_max_fishing()] rules = [self.has_max_fishing]
rules.extend( rules.extend(
self.logic.fishing.can_catch_fish(fish) self.logic.fishing.can_catch_fish(fish)
@@ -89,3 +114,23 @@ class FishingLogic(BaseLogic):
def has_specific_bait(self, fish: FishItem) -> StardewRule: def has_specific_bait(self, fish: FishItem) -> StardewRule:
return self.can_catch_fish(fish) & self.logic.has(Machine.bait_maker) return self.can_catch_fish(fish) & self.logic.has(Machine.bait_maker)
@cached_property
def can_crab_pot_anywhere(self) -> StardewRule:
return self.logic.fishing.can_fish() & self.logic.region.can_reach_any(fishing_regions)
@cache_self1
def can_crab_pot_at(self, region: str) -> StardewRule:
return self.logic.fishing.can_crab_pot & self.logic.region.can_reach(region)
@cached_property
def can_crab_pot(self) -> StardewRule:
crab_pot_rule = self.logic.has(Fishing.bait)
# We can't use the same rule if skills are vanilla, because fishing levels are required to crab pot, which is required to get fishing levels...
if self.content.features.skill_progression.is_progressive:
crab_pot_rule = crab_pot_rule & self.logic.has(Machine.crab_pot)
else:
crab_pot_rule = crab_pot_rule & self.logic.skill.can_get_fishing_xp
return crab_pot_rule

View File

@@ -60,7 +60,7 @@ class GoalLogic(BaseLogic):
if not self.content.features.fishsanity.is_enabled: if not self.content.features.fishsanity.is_enabled:
return self.logic.fishing.can_catch_every_fish() return self.logic.fishing.can_catch_every_fish()
rules = [self.logic.fishing.has_max_fishing()] rules = [self.logic.fishing.has_max_fishing]
rules.extend( rules.extend(
self.logic.fishing.can_catch_fish_for_fishsanity(fish) self.logic.fishing.can_catch_fish_for_fishsanity(fish)

View File

@@ -130,9 +130,9 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
# @formatter:off # @formatter:off
self.registry.item_rules.update({ self.registry.item_rules.update({
"Energy Tonic": self.money.can_spend_at(Region.hospital, 1000), "Energy Tonic": self.money.can_spend_at(Region.hospital, 1000),
WaterChest.fishing_chest: self.fishing.can_fish_chests(), WaterChest.fishing_chest: self.fishing.can_fish_chests,
WaterChest.golden_fishing_chest: self.fishing.can_fish_chests() & self.skill.has_mastery(Skill.fishing), WaterChest.golden_fishing_chest: self.fishing.can_fish_chests & self.skill.has_mastery(Skill.fishing),
WaterChest.treasure: self.fishing.can_fish_chests(), WaterChest.treasure: self.fishing.can_fish_chests,
Ring.hot_java_ring: self.region.can_reach(Region.volcano_floor_10), Ring.hot_java_ring: self.region.can_reach(Region.volcano_floor_10),
"Galaxy Soul": self.money.can_trade_at(Region.qi_walnut_room, Currency.qi_gem, 40), "Galaxy Soul": self.money.can_trade_at(Region.qi_walnut_room, Currency.qi_gem, 40),
"JotPK Big Buff": self.arcade.has_jotpk_power_level(7), "JotPK Big Buff": self.arcade.has_jotpk_power_level(7),
@@ -164,7 +164,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
AnimalProduct.large_milk: self.animal.has_happy_animal(Animal.cow), AnimalProduct.large_milk: self.animal.has_happy_animal(Animal.cow),
AnimalProduct.milk: self.animal.has_animal(Animal.cow), AnimalProduct.milk: self.animal.has_animal(Animal.cow),
AnimalProduct.rabbit_foot: self.animal.has_happy_animal(Animal.rabbit), AnimalProduct.rabbit_foot: self.animal.has_happy_animal(Animal.rabbit),
AnimalProduct.roe: self.skill.can_fish() & self.building.has_building(Building.fish_pond), AnimalProduct.roe: self.fishing.can_fish_anywhere() & self.building.has_building(Building.fish_pond),
AnimalProduct.squid_ink: self.mine.can_mine_in_the_mines_floor_81_120() | (self.building.has_building(Building.fish_pond) & self.has(Fish.squid)), AnimalProduct.squid_ink: self.mine.can_mine_in_the_mines_floor_81_120() | (self.building.has_building(Building.fish_pond) & self.has(Fish.squid)),
AnimalProduct.sturgeon_roe: self.has(Fish.sturgeon) & self.building.has_building(Building.fish_pond), AnimalProduct.sturgeon_roe: self.has(Fish.sturgeon) & self.building.has_building(Building.fish_pond),
AnimalProduct.truffle: self.animal.has_animal(Animal.pig) & self.season.has_any_not_winter(), AnimalProduct.truffle: self.animal.has_animal(Animal.pig) & self.season.has_any_not_winter(),
@@ -198,7 +198,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
ArtisanGood.targeted_bait: self.artisan.has_targeted_bait(), ArtisanGood.targeted_bait: self.artisan.has_targeted_bait(),
ArtisanGood.stardrop_tea: self.has(WaterChest.golden_fishing_chest), ArtisanGood.stardrop_tea: self.has(WaterChest.golden_fishing_chest),
ArtisanGood.truffle_oil: self.has(AnimalProduct.truffle) & self.has(Machine.oil_maker), ArtisanGood.truffle_oil: self.has(AnimalProduct.truffle) & self.has(Machine.oil_maker),
ArtisanGood.void_mayonnaise: (self.skill.can_fish(Region.witch_swamp)) | (self.artisan.can_mayonnaise(AnimalProduct.void_egg)), ArtisanGood.void_mayonnaise: self.artisan.can_mayonnaise(AnimalProduct.void_egg),
Beverage.pina_colada: self.money.can_spend_at(Region.island_resort, 600), Beverage.pina_colada: self.money.can_spend_at(Region.island_resort, 600),
Beverage.triple_shot_espresso: self.has("Hot Java Ring"), Beverage.triple_shot_espresso: self.has("Hot Java Ring"),
Consumable.butterfly_powder: self.money.can_spend_at(Region.sewer, 20000), Consumable.butterfly_powder: self.money.can_spend_at(Region.sewer, 20000),
@@ -217,15 +217,15 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Fertilizer.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150), Fertilizer.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150),
Fertilizer.tree: self.skill.has_level(Skill.foraging, 7) & self.has(Material.fiber) & self.has(Material.stone), Fertilizer.tree: self.skill.has_level(Skill.foraging, 7) & self.has(Material.fiber) & self.has(Material.stone),
Fish.any: self.logic.or_(*(self.fishing.can_catch_fish(fish) for fish in content.fishes.values())), Fish.any: self.logic.or_(*(self.fishing.can_catch_fish(fish) for fish in content.fishes.values())),
Fish.crab: self.skill.can_crab_pot_at(Region.beach), Fish.crab: self.fishing.can_crab_pot_at(Region.beach),
Fish.crayfish: self.skill.can_crab_pot_at(Region.town), Fish.crayfish: self.fishing.can_crab_pot_at(Region.town),
Fish.lobster: self.skill.can_crab_pot_at(Region.beach), Fish.lobster: self.fishing.can_crab_pot_at(Region.beach),
Fish.mussel: self.tool.can_forage(Generic.any, Region.beach) or self.has(Fish.mussel_node), Fish.mussel: self.tool.can_forage(Generic.any, Region.beach) or self.has(Fish.mussel_node),
Fish.mussel_node: self.region.can_reach(Region.island_west), Fish.mussel_node: self.region.can_reach(Region.island_west),
Fish.oyster: self.tool.can_forage(Generic.any, Region.beach), Fish.oyster: self.tool.can_forage(Generic.any, Region.beach),
Fish.periwinkle: self.skill.can_crab_pot_at(Region.town), Fish.periwinkle: self.fishing.can_crab_pot_at(Region.town),
Fish.shrimp: self.skill.can_crab_pot_at(Region.beach), Fish.shrimp: self.fishing.can_crab_pot_at(Region.beach),
Fish.snail: self.skill.can_crab_pot_at(Region.town), Fish.snail: self.fishing.can_crab_pot_at(Region.town),
Fishing.curiosity_lure: self.monster.can_kill(self.monster.all_monsters_by_name[Monster.mummy]), Fishing.curiosity_lure: self.monster.can_kill(self.monster.all_monsters_by_name[Monster.mummy]),
Fishing.lead_bobber: self.skill.has_level(Skill.fishing, 6) & self.money.can_spend_at(Region.fish_shop, 200), Fishing.lead_bobber: self.skill.has_level(Skill.fishing, 6) & self.money.can_spend_at(Region.fish_shop, 200),
Forageable.hay: self.building.has_building(Building.silo) & self.tool.has_tool(Tool.scythe), # Forageable.hay: self.building.has_building(Building.silo) & self.tool.has_tool(Tool.scythe), #
@@ -235,7 +235,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Fossil.fossilized_leg: self.region.can_reach(Region.dig_site) & self.tool.has_tool(Tool.pickaxe), Fossil.fossilized_leg: self.region.can_reach(Region.dig_site) & self.tool.has_tool(Tool.pickaxe),
Fossil.fossilized_ribs: self.region.can_reach(Region.island_south) & self.tool.has_tool(Tool.hoe), Fossil.fossilized_ribs: self.region.can_reach(Region.island_south) & self.tool.has_tool(Tool.hoe),
Fossil.fossilized_skull: self.action.can_open_geode(Geode.golden_coconut), Fossil.fossilized_skull: self.action.can_open_geode(Geode.golden_coconut),
Fossil.fossilized_spine: self.skill.can_fish(Region.dig_site), Fossil.fossilized_spine: self.fishing.can_fish_at(Region.dig_site),
Fossil.fossilized_tail: self.action.can_pan_at(Region.dig_site, ToolMaterial.copper), Fossil.fossilized_tail: self.action.can_pan_at(Region.dig_site, ToolMaterial.copper),
Fossil.mummified_bat: self.region.can_reach(Region.volcano_floor_10), Fossil.mummified_bat: self.region.can_reach(Region.volcano_floor_10),
Fossil.mummified_frog: self.region.can_reach(Region.island_east) & self.tool.has_tool(Tool.scythe), Fossil.mummified_frog: self.region.can_reach(Region.island_east) & self.tool.has_tool(Tool.scythe),
@@ -296,12 +296,12 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
RetainingSoil.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150), RetainingSoil.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150),
SpeedGro.basic: self.money.can_spend_at(Region.pierre_store, 100), SpeedGro.basic: self.money.can_spend_at(Region.pierre_store, 100),
SpeedGro.deluxe: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150), SpeedGro.deluxe: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150),
Trash.broken_cd: self.skill.can_crab_pot, Trash.broken_cd: self.fishing.can_crab_pot_anywhere,
Trash.broken_glasses: self.skill.can_crab_pot, Trash.broken_glasses: self.fishing.can_crab_pot_anywhere,
Trash.driftwood: self.skill.can_crab_pot, Trash.driftwood: self.fishing.can_crab_pot_anywhere,
Trash.joja_cola: self.money.can_spend_at(Region.saloon, 75), Trash.joja_cola: self.money.can_spend_at(Region.saloon, 75),
Trash.soggy_newspaper: self.skill.can_crab_pot, Trash.soggy_newspaper: self.fishing.can_crab_pot_anywhere,
Trash.trash: self.skill.can_crab_pot, Trash.trash: self.fishing.can_crab_pot_anywhere,
TreeSeed.acorn: self.skill.has_level(Skill.foraging, 1) & self.ability.can_chop_trees(), TreeSeed.acorn: self.skill.has_level(Skill.foraging, 1) & self.ability.can_chop_trees(),
TreeSeed.mahogany: self.region.can_reach(Region.secret_woods) & self.tool.has_tool(Tool.axe, ToolMaterial.iron) & self.skill.has_level(Skill.foraging, 1), TreeSeed.mahogany: self.region.can_reach(Region.secret_woods) & self.tool.has_tool(Tool.axe, ToolMaterial.iron) & self.skill.has_level(Skill.foraging, 1),
TreeSeed.maple: self.skill.has_level(Skill.foraging, 1) & self.ability.can_chop_trees(), TreeSeed.maple: self.skill.has_level(Skill.foraging, 1) & self.ability.can_chop_trees(),
@@ -314,8 +314,8 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
WaterItem.cave_jelly: self.fishing.can_fish_at(Region.mines_floor_100) & self.tool.has_fishing_rod(2), WaterItem.cave_jelly: self.fishing.can_fish_at(Region.mines_floor_100) & self.tool.has_fishing_rod(2),
WaterItem.river_jelly: self.fishing.can_fish_at(Region.town) & self.tool.has_fishing_rod(2), WaterItem.river_jelly: self.fishing.can_fish_at(Region.town) & self.tool.has_fishing_rod(2),
WaterItem.sea_jelly: self.fishing.can_fish_at(Region.beach) & self.tool.has_fishing_rod(2), WaterItem.sea_jelly: self.fishing.can_fish_at(Region.beach) & self.tool.has_fishing_rod(2),
WaterItem.seaweed: self.skill.can_fish(Region.tide_pools), WaterItem.seaweed: self.fishing.can_fish_at(Region.tide_pools),
WaterItem.white_algae: self.skill.can_fish(Region.mines_floor_20), WaterItem.white_algae: self.fishing.can_fish_at(Region.mines_floor_20),
WildSeeds.grass_starter: self.money.can_spend_at(Region.pierre_store, 100), WildSeeds.grass_starter: self.money.can_spend_at(Region.pierre_store, 100),
}) })
# @formatter:on # @formatter:on

View File

@@ -39,7 +39,7 @@ class QuestLogic(BaseLogic):
Quest.raising_animals: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.building.has_building(Building.coop), Quest.raising_animals: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.building.has_building(Building.coop),
Quest.feeding_animals: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.building.has_building(Building.silo), Quest.feeding_animals: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.building.has_building(Building.silo),
Quest.advancement: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.has(Craftable.scarecrow), Quest.advancement: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.has(Craftable.scarecrow),
Quest.archaeology: self.logic.tool.has_tool(Tool.hoe) | self.logic.mine.can_mine_in_the_mines_floor_1_40() | self.logic.skill.can_fish(), Quest.archaeology: self.logic.tool.has_tool(Tool.hoe) | self.logic.mine.can_mine_in_the_mines_floor_1_40() | self.logic.fishing.can_fish_chests,
Quest.rat_problem: self.logic.region.can_reach_all((Region.town, Region.community_center)), Quest.rat_problem: self.logic.region.can_reach_all((Region.town, Region.community_center)),
Quest.meet_the_wizard: self.logic.quest.can_complete_quest(Quest.rat_problem), Quest.meet_the_wizard: self.logic.quest.can_complete_quest(Quest.rat_problem),
Quest.forging_ahead: self.logic.has(Ore.copper) & self.logic.has(Machine.furnace), Quest.forging_ahead: self.logic.has(Ore.copper) & self.logic.has(Machine.furnace),
@@ -86,7 +86,9 @@ class QuestLogic(BaseLogic):
Quest.catch_a_lingcod: self.logic.season.has(Season.winter) & self.logic.has(Fish.lingcod) & self.logic.relationship.can_meet(NPC.willy), Quest.catch_a_lingcod: self.logic.season.has(Season.winter) & self.logic.has(Fish.lingcod) & self.logic.relationship.can_meet(NPC.willy),
Quest.dark_talisman: self.logic.region.can_reach(Region.railroad) & self.logic.wallet.has_rusty_key() & self.logic.relationship.can_meet( Quest.dark_talisman: self.logic.region.can_reach(Region.railroad) & self.logic.wallet.has_rusty_key() & self.logic.relationship.can_meet(
NPC.krobus), NPC.krobus),
Quest.goblin_problem: self.logic.region.can_reach(Region.witch_swamp), Quest.goblin_problem: self.logic.region.can_reach(Region.witch_swamp)
# Void mayo can be fished at 5% chance in the witch swamp while the quest is active. It drops a lot after the quest.
& (self.logic.has(ArtisanGood.void_mayonnaise) | self.logic.fishing.can_fish()),
Quest.magic_ink: self.logic.relationship.can_meet(NPC.wizard), Quest.magic_ink: self.logic.relationship.can_meet(NPC.wizard),
Quest.the_pirates_wife: self.logic.relationship.can_meet(NPC.kent) & self.logic.relationship.can_meet(NPC.gus) & Quest.the_pirates_wife: self.logic.relationship.can_meet(NPC.kent) & self.logic.relationship.can_meet(NPC.gus) &
self.logic.relationship.can_meet(NPC.sandy) & self.logic.relationship.can_meet(NPC.george) & self.logic.relationship.can_meet(NPC.sandy) & self.logic.relationship.can_meet(NPC.george) &

View File

@@ -1,13 +1,10 @@
from functools import cached_property from functools import cached_property
from typing import Union, Tuple
from Utils import cache_self1 from Utils import cache_self1
from .base_logic import BaseLogicMixin, BaseLogic from .base_logic import BaseLogicMixin, BaseLogic
from ..data.harvest import HarvestCropSource from ..data.harvest import HarvestCropSource
from ..mods.logic.mod_skills_levels import get_mod_skill_levels from ..mods.logic.mod_skills_levels import get_mod_skill_levels
from ..stardew_rule import StardewRule, true_, True_, False_ from ..stardew_rule import StardewRule, true_, True_, False_
from ..strings.craftable_names import Fishing
from ..strings.machine_names import Machine
from ..strings.performance_names import Performance from ..strings.performance_names import Performance
from ..strings.quality_names import ForageQuality from ..strings.quality_names import ForageQuality
from ..strings.region_names import Region from ..strings.region_names import Region
@@ -15,7 +12,6 @@ from ..strings.skill_names import Skill, all_mod_skills, all_vanilla_skills
from ..strings.tool_names import ToolMaterial, Tool from ..strings.tool_names import ToolMaterial, Tool
from ..strings.wallet_item_names import Wallet from ..strings.wallet_item_names import Wallet
fishing_regions = (Region.beach, Region.town, Region.forest, Region.mountain, Region.island_south, Region.island_west)
vanilla_skill_items = ("Farming Level", "Mining Level", "Foraging Level", "Fishing Level", "Combat Level") vanilla_skill_items = ("Farming Level", "Mining Level", "Foraging Level", "Fishing Level", "Combat Level")
@@ -138,44 +134,9 @@ class SkillLogic(BaseLogic):
@cached_property @cached_property
def can_get_fishing_xp(self) -> StardewRule: def can_get_fishing_xp(self) -> StardewRule:
if self.content.features.skill_progression.is_progressive: if self.content.features.skill_progression.is_progressive:
return self.logic.skill.can_fish() | self.logic.skill.can_crab_pot return self.logic.fishing.can_fish_anywhere() | self.logic.fishing.can_crab_pot
return self.logic.skill.can_fish() return self.logic.fishing.can_fish_anywhere()
# Should be cached
def can_fish(self, regions: Union[str, Tuple[str, ...]] = None, difficulty: int = 0) -> StardewRule:
if isinstance(regions, str):
regions = regions,
if regions is None or len(regions) == 0:
regions = fishing_regions
skill_required = min(10, max(0, int((difficulty / 10) - 1)))
if difficulty <= 40:
skill_required = 0
skill_rule = self.logic.skill.has_level(Skill.fishing, skill_required)
region_rule = self.logic.region.can_reach_any(regions)
# Training rod only works with fish < 50. Fiberglass does not help you to catch higher difficulty fish, so it's skipped in logic.
number_fishing_rod_required = 1 if difficulty < 50 else (2 if difficulty < 80 else 4)
return self.logic.tool.has_fishing_rod(number_fishing_rod_required) & skill_rule & region_rule
@cache_self1
def can_crab_pot_at(self, region: str) -> StardewRule:
return self.logic.skill.can_crab_pot & self.logic.region.can_reach(region)
@cached_property
def can_crab_pot(self) -> StardewRule:
crab_pot_rule = self.logic.has(Fishing.bait)
# We can't use the same rule if skills are vanilla, because fishing levels are required to crab pot, which is required to get fishing levels...
if self.content.features.skill_progression.is_progressive:
crab_pot_rule = crab_pot_rule & self.logic.has(Machine.crab_pot)
else:
crab_pot_rule = crab_pot_rule & self.logic.skill.can_get_fishing_xp
water_region_rules = self.logic.region.can_reach_any(fishing_regions)
return crab_pot_rule & water_region_rules
def can_forage_quality(self, quality: str) -> StardewRule: def can_forage_quality(self, quality: str) -> StardewRule:
if quality == ForageQuality.basic: if quality == ForageQuality.basic:

View File

@@ -42,7 +42,7 @@ class SpecialOrderLogic(BaseLogic):
SpecialOrder.fragments_of_the_past: self.logic.monster.can_kill(Monster.skeleton), SpecialOrder.fragments_of_the_past: self.logic.monster.can_kill(Monster.skeleton),
SpecialOrder.gus_famous_omelet: self.logic.has(AnimalProduct.any_egg), SpecialOrder.gus_famous_omelet: self.logic.has(AnimalProduct.any_egg),
SpecialOrder.crop_order: self.logic.ability.can_farm_perfectly() & self.logic.shipping.can_use_shipping_bin, SpecialOrder.crop_order: self.logic.ability.can_farm_perfectly() & self.logic.shipping.can_use_shipping_bin,
SpecialOrder.community_cleanup: self.logic.skill.can_crab_pot, SpecialOrder.community_cleanup: self.logic.fishing.can_crab_pot_anywhere,
SpecialOrder.the_strong_stuff: self.logic.has(ArtisanGood.specific_juice(Vegetable.potato)), SpecialOrder.the_strong_stuff: self.logic.has(ArtisanGood.specific_juice(Vegetable.potato)),
SpecialOrder.pierres_prime_produce: self.logic.ability.can_farm_perfectly(), SpecialOrder.pierres_prime_produce: self.logic.ability.can_farm_perfectly(),
SpecialOrder.robins_project: self.logic.relationship.can_meet(NPC.robin) & self.logic.ability.can_chop_perfectly() & SpecialOrder.robins_project: self.logic.relationship.can_meet(NPC.robin) & self.logic.ability.can_chop_perfectly() &

View File

@@ -44,9 +44,9 @@ class ModSkillLogic(BaseLogic):
def can_earn_luck_skill_level(self, level: int) -> StardewRule: def can_earn_luck_skill_level(self, level: int) -> StardewRule:
if level >= 6: if level >= 6:
return self.logic.fishing.can_fish_chests() | self.logic.action.can_open_geode(Geode.magma) return self.logic.fishing.can_fish_chests | self.logic.action.can_open_geode(Geode.magma)
if level >= 3: if level >= 3:
return self.logic.fishing.can_fish_chests() | self.logic.action.can_open_geode(Geode.geode) return self.logic.fishing.can_fish_chests | self.logic.action.can_open_geode(Geode.geode)
return True_() # You can literally wake up and or get them by opening starting chests. return True_() # You can literally wake up and or get them by opening starting chests.
def can_earn_magic_skill_level(self, level: int) -> StardewRule: def can_earn_magic_skill_level(self, level: int) -> StardewRule:

View File

@@ -214,7 +214,7 @@ def set_entrance_rules(logic: StardewLogic, multiworld, player, world_options: S
set_entrance_rule(multiworld, player, Entrance.mountain_to_railroad, logic.received("Railroad Boulder Removed")) set_entrance_rule(multiworld, player, Entrance.mountain_to_railroad, logic.received("Railroad Boulder Removed"))
set_entrance_rule(multiworld, player, Entrance.enter_witch_warp_cave, logic.quest.has_dark_talisman() | (logic.mod.magic.can_blink())) set_entrance_rule(multiworld, player, Entrance.enter_witch_warp_cave, logic.quest.has_dark_talisman() | (logic.mod.magic.can_blink()))
set_entrance_rule(multiworld, player, Entrance.enter_witch_hut, (logic.has(ArtisanGood.void_mayonnaise) | logic.mod.magic.can_blink())) set_entrance_rule(multiworld, player, Entrance.enter_witch_hut, (logic.quest.can_complete_quest(Quest.goblin_problem) | logic.mod.magic.can_blink()))
set_entrance_rule(multiworld, player, Entrance.enter_mutant_bug_lair, set_entrance_rule(multiworld, player, Entrance.enter_mutant_bug_lair,
(logic.wallet.has_rusty_key() & logic.region.can_reach(Region.railroad) & logic.relationship.can_meet( (logic.wallet.has_rusty_key() & logic.region.can_reach(Region.railroad) & logic.relationship.can_meet(
NPC.krobus)) | logic.mod.magic.can_blink()) NPC.krobus)) | logic.mod.magic.can_blink())
@@ -923,9 +923,9 @@ def set_magic_spell_rules(logic: StardewLogic, multiworld: MultiWorld, player: i
set_rule(multiworld.get_location("Analyze: Fireball", player), set_rule(multiworld.get_location("Analyze: Fireball", player),
logic.has("Fire Quartz")) logic.has("Fire Quartz"))
set_rule(multiworld.get_location("Analyze: Frostbolt", player), set_rule(multiworld.get_location("Analyze: Frostbolt", player),
logic.region.can_reach(Region.mines_floor_60) & logic.skill.can_fish(difficulty=85)) logic.region.can_reach(Region.mines_floor_60) & logic.fishing.can_fish(85))
set_rule(multiworld.get_location("Analyze All Elemental School Locations", player), set_rule(multiworld.get_location("Analyze All Elemental School Locations", player),
logic.has("Fire Quartz") & logic.region.can_reach(Region.mines_floor_60) & logic.skill.can_fish(difficulty=85)) logic.has("Fire Quartz") & logic.region.can_reach(Region.mines_floor_60) & logic.fishing.can_fish(85))
# set_rule(multiworld.get_location("Analyze: Lantern", player),) # set_rule(multiworld.get_location("Analyze: Lantern", player),)
set_rule(multiworld.get_location("Analyze: Tendrils", player), set_rule(multiworld.get_location("Analyze: Tendrils", player),
logic.region.can_reach(Region.farm)) logic.region.can_reach(Region.farm))
@@ -948,7 +948,7 @@ def set_magic_spell_rules(logic: StardewLogic, multiworld: MultiWorld, player: i
& (logic.tool.has_tool("Axe", "Basic") | logic.tool.has_tool("Pickaxe", "Basic")) & & (logic.tool.has_tool("Axe", "Basic") | logic.tool.has_tool("Pickaxe", "Basic")) &
logic.has("Coffee") & logic.has("Life Elixir") logic.has("Coffee") & logic.has("Life Elixir")
& logic.ability.can_mine_perfectly() & logic.has("Earth Crystal") & & logic.ability.can_mine_perfectly() & logic.has("Earth Crystal") &
logic.has("Fire Quartz") & logic.skill.can_fish(difficulty=85) & logic.has("Fire Quartz") & logic.fishing.can_fish(85) &
logic.region.can_reach(Region.witch_hut) & logic.region.can_reach(Region.witch_hut) &
logic.region.can_reach(Region.mines_floor_100) & logic.region.can_reach(Region.mines_floor_100) &
logic.region.can_reach(Region.farm) & logic.time.has_lived_months(12))) logic.region.can_reach(Region.farm) & logic.time.has_lived_months(12)))

View File

@@ -151,7 +151,7 @@ Magma Stone;
// Evil // Evil
Smashing, Poppet!; Achievement; Smashing, Poppet!; Achievement;
Arms Dealer; Npc; Arms Dealer; Npc;
Leading Landlord; Achievement; Nurse & Arms Dealer; // The logic is way more complex, but that doesn't affect anything Leading Landlord; Achievement | Not Getfixedboi; Nurse & Arms Dealer; // The logic is way more complex, but that doesn't affect anything
Completely Awesome; Achievement; Arms Dealer; Completely Awesome; Achievement; Arms Dealer;
Illegal Gun Parts; ; Arms Dealer | Flamethrower; Illegal Gun Parts; ; Arms Dealer | Flamethrower;

View File

@@ -1,3 +1,4 @@
from dataclasses import fields
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set, TextIO from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set, TextIO
from logging import warning from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
@@ -7,16 +8,16 @@ from .locations import location_table, location_name_groups, standard_location_n
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules from .er_rules import set_er_location_rules
from .regions import tunic_regions from .regions import tunic_regions
from .er_scripts import create_er_regions from .er_scripts import create_er_regions, verify_plando_directions
from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations
from .er_data import portal_mapping, RegionInfo, tunic_er_regions from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections, from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage, check_options, LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage, check_options,
get_hexagons_in_pool, HexagonQuestAbilityUnlockType) get_hexagons_in_pool, HexagonQuestAbilityUnlockType, EntranceLayout)
from .breakables import breakable_location_name_to_id, breakable_location_groups, breakable_location_table from .breakables import breakable_location_name_to_id, breakable_location_groups, breakable_location_table
from .combat_logic import area_data, CombatState from .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection, OptionError from Options import PlandoConnection, OptionError, PerGameCommonOptions, Removed, Range
from settings import Group, Bool from settings import Group, Bool
@@ -61,8 +62,9 @@ class SeedGroup(TypedDict):
ice_grappling: int # ice_grappling value ice_grappling: int # ice_grappling value
ladder_storage: int # ls value ladder_storage: int # ls value
laurels_at_10_fairies: bool # laurels location value laurels_at_10_fairies: bool # laurels location value
fixed_shop: bool # fixed shop value entrance_layout: int # entrance layout value
plando: TunicPlandoConnections # consolidated plando connections for the seed group has_decoupled_enabled: bool # for checking that players don't have conflicting options
plando: List[PlandoConnection] # consolidated plando connections for the seed group
class TunicWorld(World): class TunicWorld(World):
@@ -95,7 +97,7 @@ class TunicWorld(World):
tunic_portal_pairs: Dict[str, str] tunic_portal_pairs: Dict[str, str]
er_portal_hints: Dict[int, str] er_portal_hints: Dict[int, str]
seed_groups: Dict[str, SeedGroup] = {} seed_groups: Dict[str, SeedGroup] = {}
shop_num: int = 1 # need to make it so that you can walk out of shops, but also that they aren't all connected used_shop_numbers: Set[int]
er_regions: Dict[str, RegionInfo] # absolutely needed so outlet regions work er_regions: Dict[str, RegionInfo] # absolutely needed so outlet regions work
# for the local_fill option # for the local_fill option
@@ -120,26 +122,51 @@ class TunicWorld(World):
"This would cause an error at the end of generation.\n" "This would cause an error at the end of generation.\n"
"Please remove one of them, most likely the one in lib/worlds.") "Please remove one of them, most likely the one in lib/worlds.")
if self.options.all_random:
for option_name in (attr.name for attr in fields(TunicOptions)
if attr not in fields(PerGameCommonOptions)):
option = getattr(self.options, option_name)
if option_name == "all_random":
continue
if isinstance(option, Removed):
continue
if option.supports_weighting:
if isinstance(option, Range):
option.value = self.random.randint(option.range_start, option.range_end)
else:
option.value = self.random.choice(list(option.name_lookup))
check_options(self) check_options(self)
if self.options.logic_rules >= LogicRules.option_no_major_glitches:
self.options.laurels_zips.value = LaurelsZips.option_true
self.options.ice_grappling.value = IceGrappling.option_medium
if self.options.logic_rules.value == LogicRules.option_unrestricted:
self.options.ladder_storage.value = LadderStorage.option_medium
self.er_regions = tunic_er_regions.copy() self.er_regions = tunic_er_regions.copy()
if self.options.plando_connections and not self.options.entrance_rando:
self.options.plando_connections.value = ()
if self.options.plando_connections: if self.options.plando_connections:
def replace_connection(old_cxn: PlandoConnection, new_cxn: PlandoConnection, index: int) -> None:
self.options.plando_connections.value.remove(old_cxn)
self.options.plando_connections.value.insert(index, new_cxn)
for index, cxn in enumerate(self.options.plando_connections): for index, cxn in enumerate(self.options.plando_connections):
# making shops second to simplify other things later replacement = None
if cxn.entrance.startswith("Shop"): if self.options.decoupled:
replacement = PlandoConnection(cxn.exit, "Shop Portal", "both") # flip any that are pointing to exit to point to entrance so that I don't have to deal with it
self.options.plando_connections.value.remove(cxn) if cxn.direction == "exit":
self.options.plando_connections.value.insert(index, replacement) replacement = PlandoConnection(cxn.exit, cxn.entrance, "entrance", cxn.percentage)
elif cxn.exit.startswith("Shop"): # if decoupled is on and you plando'd an entrance to itself but left the direction as both
replacement = PlandoConnection(cxn.entrance, "Shop Portal", "both") if cxn.direction == "both" and cxn.entrance == cxn.exit:
self.options.plando_connections.value.remove(cxn) replacement = PlandoConnection(cxn.entrance, cxn.exit, "entrance")
self.options.plando_connections.value.insert(index, replacement) # if decoupled is off, just convert these to both
elif cxn.direction != "both":
replacement = PlandoConnection(cxn.entrance, cxn.exit, "both", cxn.percentage)
if replacement:
replace_connection(cxn, replacement, index)
if (self.options.entrance_layout == EntranceLayout.option_direction_pairs
and not verify_plando_directions(cxn)):
raise OptionError(f"TUNIC: Player {self.player_name} has invalid plando connections. "
f"They have Direction Pairs enabled and the connection "
f"{cxn.entrance} --> {cxn.exit} does not abide by this option.")
# Universal tracker stuff, shouldn't do anything in standard gen # Universal tracker stuff, shouldn't do anything in standard gen
if hasattr(self.multiworld, "re_gen_passthrough"): if hasattr(self.multiworld, "re_gen_passthrough"):
@@ -160,16 +187,16 @@ class TunicWorld(World):
self.options.hexagon_quest_ability_type.value = self.passthrough.get("hexagon_quest_ability_type", 0) self.options.hexagon_quest_ability_type.value = self.passthrough.get("hexagon_quest_ability_type", 0)
self.options.entrance_rando.value = self.passthrough["entrance_rando"] self.options.entrance_rando.value = self.passthrough["entrance_rando"]
self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"] self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"]
self.options.entrance_layout.value = EntranceLayout.option_standard
if ("ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].keys()
or "ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].values()):
self.options.entrance_layout.value = EntranceLayout.option_fixed_shop
self.options.decoupled = self.passthrough.get("decoupled", 0)
self.options.laurels_location.value = LaurelsLocation.option_anywhere
self.options.grass_randomizer.value = self.passthrough.get("grass_randomizer", 0) self.options.grass_randomizer.value = self.passthrough.get("grass_randomizer", 0)
self.options.breakable_shuffle.value = self.passthrough.get("breakable_shuffle", 0) self.options.breakable_shuffle.value = self.passthrough.get("breakable_shuffle", 0)
self.options.laurels_location.value = self.options.laurels_location.option_anywhere self.options.laurels_location.value = self.options.laurels_location.option_anywhere
self.options.combat_logic.value = self.passthrough["combat_logic"] self.options.combat_logic.value = self.passthrough.get("combat_logic", 0)
self.options.fixed_shop.value = self.options.fixed_shop.option_false
if ("ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].keys()
or "ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].values()):
self.options.fixed_shop.value = self.options.fixed_shop.option_true
else: else:
self.using_ut = False self.using_ut = False
else: else:
@@ -227,10 +254,14 @@ class TunicWorld(World):
ice_grappling=tunic.options.ice_grappling.value, ice_grappling=tunic.options.ice_grappling.value,
ladder_storage=tunic.options.ladder_storage.value, ladder_storage=tunic.options.ladder_storage.value,
laurels_at_10_fairies=tunic.options.laurels_location == LaurelsLocation.option_10_fairies, laurels_at_10_fairies=tunic.options.laurels_location == LaurelsLocation.option_10_fairies,
fixed_shop=bool(tunic.options.fixed_shop), entrance_layout=tunic.options.entrance_layout.value,
plando=tunic.options.plando_connections) has_decoupled_enabled=bool(tunic.options.decoupled),
plando=tunic.options.plando_connections.value.copy())
continue continue
# I feel that syncing this one is worse than erroring out
if bool(tunic.options.decoupled) != cls.seed_groups[group]["has_decoupled_enabled"]:
raise OptionError(f"TUNIC: All players in the seed group {group} must "
f"have Decoupled either enabled or disabled.")
# off is more restrictive # off is more restrictive
if not tunic.options.laurels_zips: if not tunic.options.laurels_zips:
cls.seed_groups[group]["laurels_zips"] = False cls.seed_groups[group]["laurels_zips"] = False
@@ -243,34 +274,52 @@ class TunicWorld(World):
# laurels at 10 fairies changes logic for secret gathering place placement # laurels at 10 fairies changes logic for secret gathering place placement
if tunic.options.laurels_location == 3: if tunic.options.laurels_location == 3:
cls.seed_groups[group]["laurels_at_10_fairies"] = True cls.seed_groups[group]["laurels_at_10_fairies"] = True
# more restrictive, overrides the option for others in the same group, which is better than failing imo # fixed shop and direction pairs override standard, but conflict with each other
if tunic.options.fixed_shop: if tunic.options.entrance_layout:
cls.seed_groups[group]["fixed_shop"] = True if cls.seed_groups[group]["entrance_layout"] == EntranceLayout.option_standard:
cls.seed_groups[group]["entrance_layout"] = tunic.options.entrance_layout.value
elif cls.seed_groups[group]["entrance_layout"] != tunic.options.entrance_layout.value:
raise OptionError(f"TUNIC: Conflict between seed group {group}'s Entrance Layout options. "
f"Seed group cannot have both Fixed Shop and Direction Pairs enabled.")
if tunic.options.plando_connections: if tunic.options.plando_connections:
# loop through the connections in the player's yaml # loop through the connections in the player's yaml
for cxn in tunic.options.plando_connections: for index, player_cxn in enumerate(tunic.options.plando_connections):
new_cxn = True new_cxn = True
for group_cxn in cls.seed_groups[group]["plando"]: for group_cxn in cls.seed_groups[group]["plando"]:
# if neither entrance nor exit match anything in the group, add to group # verify that it abides by direction pairs if enabled
if ((cxn.entrance == group_cxn.entrance and cxn.exit == group_cxn.exit) if (cls.seed_groups[group]["entrance_layout"] == EntranceLayout.option_direction_pairs
or (cxn.exit == group_cxn.entrance and cxn.entrance == group_cxn.exit)): and not verify_plando_directions(player_cxn)):
new_cxn = False player_dir = "<->" if player_cxn.direction == "both" else "-->"
break raise Exception(f"TUNIC: Conflict between Entrance Layout option and Plando Connection: "
f"{player_cxn.entrance} {player_dir} {player_cxn.exit}")
# check if this pair is the same as a pair in the group already # check if this pair is the same as a pair in the group already
if ((player_cxn.entrance == group_cxn.entrance and player_cxn.exit == group_cxn.exit)
or (player_cxn.entrance == group_cxn.exit and player_cxn.exit == group_cxn.entrance
and "both" in [player_cxn.direction, group_cxn.direction])):
new_cxn = False
# if the group's was one-way and the player's was two-way, we replace the group's now
if player_cxn.direction == "both" and group_cxn.direction == "entrance":
cls.seed_groups[group]["plando"].remove(group_cxn)
cls.seed_groups[group]["plando"].insert(index, player_cxn)
break
is_mismatched = ( is_mismatched = (
cxn.entrance == group_cxn.entrance and cxn.exit != group_cxn.exit player_cxn.entrance == group_cxn.entrance and player_cxn.exit != group_cxn.exit
or cxn.entrance == group_cxn.exit and cxn.exit != group_cxn.entrance or player_cxn.exit == group_cxn.exit and player_cxn.entrance != group_cxn.entrance
or cxn.exit == group_cxn.entrance and cxn.entrance != group_cxn.exit
or cxn.exit == group_cxn.exit and cxn.entrance != group_cxn.entrance
) )
if not tunic.options.decoupled:
is_mismatched = is_mismatched or (
player_cxn.entrance == group_cxn.exit and player_cxn.exit != group_cxn.entrance
or player_cxn.exit == group_cxn.entrance and player_cxn.entrance != group_cxn.exit
)
if is_mismatched: if is_mismatched:
raise Exception(f"TUNIC: Conflict between seed group {group}'s plando " group_dir = "<->" if group_cxn.direction == "both" else "-->"
f"connection {group_cxn.entrance} <-> {group_cxn.exit} and " player_dir = "<->" if player_cxn.direction == "both" else "-->"
f"{tunic.player_name}'s plando connection {cxn.entrance} <-> {cxn.exit}") raise OptionError(f"TUNIC: Conflict between seed group {group}'s plando "
f"connection {group_cxn.entrance} {group_dir} {group_cxn.exit} and "
f"{tunic.player_name}'s plando connection "
f"{player_cxn.entrance} {player_dir} {player_cxn.exit}")
if new_cxn: if new_cxn:
cls.seed_groups[group]["plando"].value.append(cxn) cls.seed_groups[group]["plando"].append(player_cxn)
def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem: def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem:
item_data = item_table[name] item_data = item_table[name]
@@ -486,10 +535,10 @@ class TunicWorld(World):
multiworld.random.shuffle(non_grass_fill_locations) multiworld.random.shuffle(non_grass_fill_locations)
for filler_item in grass_fill: for filler_item in grass_fill:
multiworld.push_item(grass_fill_locations.pop(), filler_item, collect=False) grass_fill_locations.pop().place_locked_item(filler_item)
for filler_item in non_grass_fill: for filler_item in non_grass_fill:
multiworld.push_item(non_grass_fill_locations.pop(), filler_item, collect=False) non_grass_fill_locations.pop().place_locked_item(filler_item)
def create_regions(self) -> None: def create_regions(self) -> None:
self.tunic_portal_pairs = {} self.tunic_portal_pairs = {}
@@ -571,7 +620,7 @@ class TunicWorld(World):
all_state = self.multiworld.get_all_state(True) all_state = self.multiworld.get_all_state(True)
all_state.update_reachable_regions(self.player) all_state.update_reachable_regions(self.player)
paths = all_state.path paths = all_state.path
portal_names = [portal.name for portal in portal_mapping] portal_names = {portal.name for portal in portal_mapping}.union({f"Shop Portal {i + 1}" for i in range(500)})
for location in self.multiworld.get_locations(self.player): for location in self.multiworld.get_locations(self.player):
# skipping event locations # skipping event locations
if not location.address: if not location.address:
@@ -630,6 +679,7 @@ class TunicWorld(World):
"lanternless": self.options.lanternless.value, "lanternless": self.options.lanternless.value,
"maskless": self.options.maskless.value, "maskless": self.options.maskless.value,
"entrance_rando": int(bool(self.options.entrance_rando.value)), "entrance_rando": int(bool(self.options.entrance_rando.value)),
"decoupled": self.options.decoupled.value if self.options.entrance_rando else 0,
"shuffle_ladders": self.options.shuffle_ladders.value, "shuffle_ladders": self.options.shuffle_ladders.value,
"grass_randomizer": self.options.grass_randomizer.value, "grass_randomizer": self.options.grass_randomizer.value,
"combat_logic": self.options.combat_logic.value, "combat_logic": self.options.combat_logic.value,

View File

@@ -22,6 +22,7 @@ class AreaStats(NamedTuple):
# the vanilla upgrades/equipment you would have # the vanilla upgrades/equipment you would have
area_data: Dict[str, AreaStats] = { area_data: Dict[str, AreaStats] = {
# The upgrade page is right by the Well entrance. Upper Overworld by the chest in the top right might need something
"Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]), "Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]),
"East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]), "East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]),
"Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]), "Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]),

View File

@@ -83,7 +83,7 @@ Notes:
- The Entrance Randomizer option must be enabled for it to work. - The Entrance Randomizer option must be enabled for it to work.
- The `direction` field is not supported. Connections are always coupled. - The `direction` field is not supported. Connections are always coupled.
- For a list of entrance names, check `er_data.py` in the TUNIC world folder or generate a game with the Entrance Randomizer option enabled and check the spoiler log. - For a list of entrance names, check `er_data.py` in the TUNIC world folder or generate a game with the Entrance Randomizer option enabled and check the spoiler log.
- There is no limit to the number of Shops you can plando. - You can plando up to 500 additional shops in Decoupled. You should not do this.
See the [Archipelago Plando Guide](../../../tutorial/Archipelago/plando/en) for more information on Plando and Connection Plando. See the [Archipelago Plando Guide](../../../tutorial/Archipelago/plando/en) for more information on Plando and Connection Plando.

View File

@@ -1,15 +1,28 @@
from typing import Dict, NamedTuple, List, TYPE_CHECKING, Optional from typing import Dict, NamedTuple, List, Optional, TYPE_CHECKING
from enum import IntEnum from enum import IntEnum
if TYPE_CHECKING: if TYPE_CHECKING:
from . import TunicWorld from . import TunicWorld
# the direction you go to enter a portal
class Direction(IntEnum):
none = 0 # for when the direction isn't relevant
north = 1
south = 2
east = 3
west = 4
floor = 5
ladder_up = 6
ladder_down = 7
class Portal(NamedTuple): class Portal(NamedTuple):
name: str # human-readable name name: str # human-readable name
region: str # AP region region: str # AP region
destination: str # vanilla destination scene destination: str # vanilla destination scene
tag: str # vanilla tag tag: str # vanilla tag
direction: int # the direction you go to enter a portal
def scene(self) -> str: # the actual scene name in Tunic def scene(self) -> str: # the actual scene name in Tunic
if self.region.startswith("Shop"): if self.region.startswith("Shop"):
@@ -25,497 +38,497 @@ class Portal(NamedTuple):
portal_mapping: List[Portal] = [ portal_mapping: List[Portal] = [
Portal(name="Stick House Entrance", region="Overworld", Portal(name="Stick House Entrance", region="Overworld",
destination="Sword Cave", tag="_"), destination="Sword Cave", tag="_", direction=Direction.north),
Portal(name="Windmill Entrance", region="Overworld", Portal(name="Windmill Entrance", region="Overworld",
destination="Windmill", tag="_"), destination="Windmill", tag="_", direction=Direction.north),
Portal(name="Well Ladder Entrance", region="Overworld Well Ladder", Portal(name="Well Ladder Entrance", region="Overworld Well Ladder",
destination="Sewer", tag="_entrance"), destination="Sewer", tag="_entrance", direction=Direction.ladder_down),
Portal(name="Entrance to Well from Well Rail", region="Overworld Well to Furnace Rail", Portal(name="Entrance to Well from Well Rail", region="Overworld Well to Furnace Rail",
destination="Sewer", tag="_west_aqueduct"), destination="Sewer", tag="_west_aqueduct", direction=Direction.north),
Portal(name="Old House Door Entrance", region="Overworld Old House Door", Portal(name="Old House Door Entrance", region="Overworld Old House Door",
destination="Overworld Interiors", tag="_house"), destination="Overworld Interiors", tag="_house", direction=Direction.east),
Portal(name="Old House Waterfall Entrance", region="Overworld", Portal(name="Old House Waterfall Entrance", region="Overworld",
destination="Overworld Interiors", tag="_under_checkpoint"), destination="Overworld Interiors", tag="_under_checkpoint", direction=Direction.east),
Portal(name="Entrance to Furnace from Well Rail", region="Overworld Well to Furnace Rail", Portal(name="Entrance to Furnace from Well Rail", region="Overworld Well to Furnace Rail",
destination="Furnace", tag="_gyro_upper_north"), destination="Furnace", tag="_gyro_upper_north", direction=Direction.south),
Portal(name="Entrance to Furnace under Windmill", region="Overworld", Portal(name="Entrance to Furnace under Windmill", region="Overworld",
destination="Furnace", tag="_gyro_upper_east"), destination="Furnace", tag="_gyro_upper_east", direction=Direction.west),
Portal(name="Entrance to Furnace near West Garden", region="Overworld to West Garden from Furnace", Portal(name="Entrance to Furnace near West Garden", region="Overworld to West Garden from Furnace",
destination="Furnace", tag="_gyro_west"), destination="Furnace", tag="_gyro_west", direction=Direction.east),
Portal(name="Entrance to Furnace from Beach", region="Overworld Tunnel Turret", Portal(name="Entrance to Furnace from Beach", region="Overworld Tunnel Turret",
destination="Furnace", tag="_gyro_lower"), destination="Furnace", tag="_gyro_lower", direction=Direction.north),
Portal(name="Caustic Light Cave Entrance", region="Overworld Swamp Lower Entry", Portal(name="Caustic Light Cave Entrance", region="Overworld Swamp Lower Entry",
destination="Overworld Cave", tag="_"), destination="Overworld Cave", tag="_", direction=Direction.north),
Portal(name="Swamp Upper Entrance", region="Overworld Swamp Upper Entry", Portal(name="Swamp Upper Entrance", region="Overworld Swamp Upper Entry",
destination="Swamp Redux 2", tag="_wall"), destination="Swamp Redux 2", tag="_wall", direction=Direction.south),
Portal(name="Swamp Lower Entrance", region="Overworld Swamp Lower Entry", Portal(name="Swamp Lower Entrance", region="Overworld Swamp Lower Entry",
destination="Swamp Redux 2", tag="_conduit"), destination="Swamp Redux 2", tag="_conduit", direction=Direction.south),
Portal(name="Ruined Passage Not-Door Entrance", region="After Ruined Passage", Portal(name="Ruined Passage Not-Door Entrance", region="After Ruined Passage",
destination="Ruins Passage", tag="_east"), destination="Ruins Passage", tag="_east", direction=Direction.north),
Portal(name="Ruined Passage Door Entrance", region="Overworld Ruined Passage Door", Portal(name="Ruined Passage Door Entrance", region="Overworld Ruined Passage Door",
destination="Ruins Passage", tag="_west"), destination="Ruins Passage", tag="_west", direction=Direction.east),
Portal(name="Atoll Upper Entrance", region="Overworld to Atoll Upper", Portal(name="Atoll Upper Entrance", region="Overworld to Atoll Upper",
destination="Atoll Redux", tag="_upper"), destination="Atoll Redux", tag="_upper", direction=Direction.south),
Portal(name="Atoll Lower Entrance", region="Overworld Beach", Portal(name="Atoll Lower Entrance", region="Overworld Beach",
destination="Atoll Redux", tag="_lower"), destination="Atoll Redux", tag="_lower", direction=Direction.south),
Portal(name="Special Shop Entrance", region="Overworld Special Shop Entry", Portal(name="Special Shop Entrance", region="Overworld Special Shop Entry",
destination="ShopSpecial", tag="_"), destination="ShopSpecial", tag="_", direction=Direction.east),
Portal(name="Maze Cave Entrance", region="Overworld Beach", Portal(name="Maze Cave Entrance", region="Overworld Beach",
destination="Maze Room", tag="_"), destination="Maze Room", tag="_", direction=Direction.north),
Portal(name="West Garden Entrance near Belltower", region="Overworld to West Garden Upper", Portal(name="West Garden Entrance near Belltower", region="Overworld to West Garden Upper",
destination="Archipelagos Redux", tag="_upper"), destination="Archipelagos Redux", tag="_upper", direction=Direction.west),
Portal(name="West Garden Entrance from Furnace", region="Overworld to West Garden from Furnace", Portal(name="West Garden Entrance from Furnace", region="Overworld to West Garden from Furnace",
destination="Archipelagos Redux", tag="_lower"), destination="Archipelagos Redux", tag="_lower", direction=Direction.west),
Portal(name="West Garden Laurels Entrance", region="Overworld West Garden Laurels Entry", Portal(name="West Garden Laurels Entrance", region="Overworld West Garden Laurels Entry",
destination="Archipelagos Redux", tag="_lowest"), destination="Archipelagos Redux", tag="_lowest", direction=Direction.west),
Portal(name="Temple Door Entrance", region="Overworld Temple Door", Portal(name="Temple Door Entrance", region="Overworld Temple Door",
destination="Temple", tag="_main"), destination="Temple", tag="_main", direction=Direction.north),
Portal(name="Temple Rafters Entrance", region="Overworld after Temple Rafters", Portal(name="Temple Rafters Entrance", region="Overworld after Temple Rafters",
destination="Temple", tag="_rafters"), destination="Temple", tag="_rafters", direction=Direction.east),
Portal(name="Ruined Shop Entrance", region="Overworld", Portal(name="Ruined Shop Entrance", region="Overworld",
destination="Ruined Shop", tag="_"), destination="Ruined Shop", tag="_", direction=Direction.east),
Portal(name="Patrol Cave Entrance", region="Overworld at Patrol Cave", Portal(name="Patrol Cave Entrance", region="Overworld at Patrol Cave",
destination="PatrolCave", tag="_"), destination="PatrolCave", tag="_", direction=Direction.north),
Portal(name="Hourglass Cave Entrance", region="Overworld Beach", Portal(name="Hourglass Cave Entrance", region="Overworld Beach",
destination="Town Basement", tag="_beach"), destination="Town Basement", tag="_beach", direction=Direction.north),
Portal(name="Changing Room Entrance", region="Overworld", Portal(name="Changing Room Entrance", region="Overworld",
destination="Changing Room", tag="_"), destination="Changing Room", tag="_", direction=Direction.south),
Portal(name="Cube Cave Entrance", region="Cube Cave Entrance Region", Portal(name="Cube Cave Entrance", region="Cube Cave Entrance Region",
destination="CubeRoom", tag="_"), destination="CubeRoom", tag="_", direction=Direction.north),
Portal(name="Stairs from Overworld to Mountain", region="Upper Overworld", Portal(name="Stairs from Overworld to Mountain", region="Upper Overworld",
destination="Mountain", tag="_"), destination="Mountain", tag="_", direction=Direction.north),
Portal(name="Overworld to Fortress", region="East Overworld", Portal(name="Overworld to Fortress", region="East Overworld",
destination="Fortress Courtyard", tag="_"), destination="Fortress Courtyard", tag="_", direction=Direction.east),
Portal(name="Fountain HC Door Entrance", region="Overworld Fountain Cross Door", Portal(name="Fountain HC Door Entrance", region="Overworld Fountain Cross Door",
destination="Town_FiligreeRoom", tag="_"), destination="Town_FiligreeRoom", tag="_", direction=Direction.north),
Portal(name="Southeast HC Door Entrance", region="Overworld Southeast Cross Door", Portal(name="Southeast HC Door Entrance", region="Overworld Southeast Cross Door",
destination="EastFiligreeCache", tag="_"), destination="EastFiligreeCache", tag="_", direction=Direction.north),
Portal(name="Overworld to Quarry Connector", region="Overworld Quarry Entry", Portal(name="Overworld to Quarry Connector", region="Overworld Quarry Entry",
destination="Darkwoods Tunnel", tag="_"), destination="Darkwoods Tunnel", tag="_", direction=Direction.north),
Portal(name="Dark Tomb Main Entrance", region="Overworld", Portal(name="Dark Tomb Main Entrance", region="Overworld",
destination="Crypt Redux", tag="_"), destination="Crypt Redux", tag="_", direction=Direction.north),
Portal(name="Overworld to Forest Belltower", region="East Overworld", Portal(name="Overworld to Forest Belltower", region="East Overworld",
destination="Forest Belltower", tag="_"), destination="Forest Belltower", tag="_", direction=Direction.east),
Portal(name="Town to Far Shore", region="Overworld Town Portal", Portal(name="Town to Far Shore", region="Overworld Town Portal",
destination="Transit", tag="_teleporter_town"), destination="Transit", tag="_teleporter_town", direction=Direction.floor),
Portal(name="Spawn to Far Shore", region="Overworld Spawn Portal", Portal(name="Spawn to Far Shore", region="Overworld Spawn Portal",
destination="Transit", tag="_teleporter_starting island"), destination="Transit", tag="_teleporter_starting island", direction=Direction.floor),
Portal(name="Secret Gathering Place Entrance", region="Overworld", Portal(name="Secret Gathering Place Entrance", region="Overworld",
destination="Waterfall", tag="_"), destination="Waterfall", tag="_", direction=Direction.north),
Portal(name="Secret Gathering Place Exit", region="Secret Gathering Place", Portal(name="Secret Gathering Place Exit", region="Secret Gathering Place",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Windmill Exit", region="Windmill", Portal(name="Windmill Exit", region="Windmill",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Windmill Shop", region="Windmill", Portal(name="Windmill Shop", region="Windmill",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.north),
Portal(name="Old House Door Exit", region="Old House Front", Portal(name="Old House Door Exit", region="Old House Front",
destination="Overworld Redux", tag="_house"), destination="Overworld Redux", tag="_house", direction=Direction.west),
Portal(name="Old House to Glyph Tower", region="Old House Front", Portal(name="Old House to Glyph Tower", region="Old House Front",
destination="g_elements", tag="_"), destination="g_elements", tag="_", direction=Direction.south), # portal drops you on north side
Portal(name="Old House Waterfall Exit", region="Old House Back", Portal(name="Old House Waterfall Exit", region="Old House Back",
destination="Overworld Redux", tag="_under_checkpoint"), destination="Overworld Redux", tag="_under_checkpoint", direction=Direction.west),
Portal(name="Glyph Tower Exit", region="Relic Tower", Portal(name="Glyph Tower Exit", region="Relic Tower",
destination="Overworld Interiors", tag="_"), destination="Overworld Interiors", tag="_", direction=Direction.north),
Portal(name="Changing Room Exit", region="Changing Room", Portal(name="Changing Room Exit", region="Changing Room",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.north),
Portal(name="Fountain HC Room Exit", region="Fountain Cross Room", Portal(name="Fountain HC Room Exit", region="Fountain Cross Room",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Cube Cave Exit", region="Cube Cave", Portal(name="Cube Cave Exit", region="Cube Cave",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Guard Patrol Cave Exit", region="Patrol Cave", Portal(name="Guard Patrol Cave Exit", region="Patrol Cave",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Ruined Shop Exit", region="Ruined Shop", Portal(name="Ruined Shop Exit", region="Ruined Shop",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.west),
Portal(name="Furnace Exit towards Well", region="Furnace Fuse", Portal(name="Furnace Exit towards Well", region="Furnace Fuse",
destination="Overworld Redux", tag="_gyro_upper_north"), destination="Overworld Redux", tag="_gyro_upper_north", direction=Direction.north),
Portal(name="Furnace Exit to Dark Tomb", region="Furnace Walking Path", Portal(name="Furnace Exit to Dark Tomb", region="Furnace Walking Path",
destination="Crypt Redux", tag="_"), destination="Crypt Redux", tag="_", direction=Direction.east),
Portal(name="Furnace Exit towards West Garden", region="Furnace Walking Path", Portal(name="Furnace Exit towards West Garden", region="Furnace Walking Path",
destination="Overworld Redux", tag="_gyro_west"), destination="Overworld Redux", tag="_gyro_west", direction=Direction.west),
Portal(name="Furnace Exit to Beach", region="Furnace Ladder Area", Portal(name="Furnace Exit to Beach", region="Furnace Ladder Area",
destination="Overworld Redux", tag="_gyro_lower"), destination="Overworld Redux", tag="_gyro_lower", direction=Direction.south),
Portal(name="Furnace Exit under Windmill", region="Furnace Ladder Area", Portal(name="Furnace Exit under Windmill", region="Furnace Ladder Area",
destination="Overworld Redux", tag="_gyro_upper_east"), destination="Overworld Redux", tag="_gyro_upper_east", direction=Direction.east),
Portal(name="Stick House Exit", region="Stick House", Portal(name="Stick House Exit", region="Stick House",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Ruined Passage Not-Door Exit", region="Ruined Passage", Portal(name="Ruined Passage Not-Door Exit", region="Ruined Passage",
destination="Overworld Redux", tag="_east"), destination="Overworld Redux", tag="_east", direction=Direction.south),
Portal(name="Ruined Passage Door Exit", region="Ruined Passage", Portal(name="Ruined Passage Door Exit", region="Ruined Passage",
destination="Overworld Redux", tag="_west"), destination="Overworld Redux", tag="_west", direction=Direction.west),
Portal(name="Southeast HC Room Exit", region="Southeast Cross Room", Portal(name="Southeast HC Room Exit", region="Southeast Cross Room",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Caustic Light Cave Exit", region="Caustic Light Cave", Portal(name="Caustic Light Cave Exit", region="Caustic Light Cave",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Maze Cave Exit", region="Maze Cave", Portal(name="Maze Cave Exit", region="Maze Cave",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Hourglass Cave Exit", region="Hourglass Cave", Portal(name="Hourglass Cave Exit", region="Hourglass Cave",
destination="Overworld Redux", tag="_beach"), destination="Overworld Redux", tag="_beach", direction=Direction.south),
Portal(name="Special Shop Exit", region="Special Shop", Portal(name="Special Shop Exit", region="Special Shop",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.west),
Portal(name="Temple Rafters Exit", region="Sealed Temple Rafters", Portal(name="Temple Rafters Exit", region="Sealed Temple Rafters",
destination="Overworld Redux", tag="_rafters"), destination="Overworld Redux", tag="_rafters", direction=Direction.west),
Portal(name="Temple Door Exit", region="Sealed Temple", Portal(name="Temple Door Exit", region="Sealed Temple",
destination="Overworld Redux", tag="_main"), destination="Overworld Redux", tag="_main", direction=Direction.south),
Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main behind bushes", Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main behind bushes",
destination="Fortress Courtyard", tag="_"), destination="Fortress Courtyard", tag="_", direction=Direction.north),
Portal(name="Forest Belltower to Forest", region="Forest Belltower Lower", Portal(name="Forest Belltower to Forest", region="Forest Belltower Lower",
destination="East Forest Redux", tag="_"), destination="East Forest Redux", tag="_", direction=Direction.south),
Portal(name="Forest Belltower to Overworld", region="Forest Belltower Main", Portal(name="Forest Belltower to Overworld", region="Forest Belltower Main",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.west),
Portal(name="Forest Belltower to Guard Captain Room", region="Forest Belltower Upper", Portal(name="Forest Belltower to Guard Captain Room", region="Forest Belltower Upper",
destination="Forest Boss Room", tag="_"), destination="Forest Boss Room", tag="_", direction=Direction.south),
Portal(name="Forest to Belltower", region="East Forest", Portal(name="Forest to Belltower", region="East Forest",
destination="Forest Belltower", tag="_"), destination="Forest Belltower", tag="_", direction=Direction.north),
Portal(name="Forest Guard House 1 Lower Entrance", region="East Forest", Portal(name="Forest Guard House 1 Lower Entrance", region="East Forest",
destination="East Forest Redux Laddercave", tag="_lower"), destination="East Forest Redux Laddercave", tag="_lower", direction=Direction.north),
Portal(name="Forest Guard House 1 Gate Entrance", region="East Forest", Portal(name="Forest Guard House 1 Gate Entrance", region="East Forest",
destination="East Forest Redux Laddercave", tag="_gate"), destination="East Forest Redux Laddercave", tag="_gate", direction=Direction.north),
Portal(name="Forest Dance Fox Outside Doorway", region="East Forest Dance Fox Spot", Portal(name="Forest Dance Fox Outside Doorway", region="East Forest Dance Fox Spot",
destination="East Forest Redux Laddercave", tag="_upper"), destination="East Forest Redux Laddercave", tag="_upper", direction=Direction.east),
Portal(name="Forest to Far Shore", region="East Forest Portal", Portal(name="Forest to Far Shore", region="East Forest Portal",
destination="Transit", tag="_teleporter_forest teleporter"), destination="Transit", tag="_teleporter_forest teleporter", direction=Direction.floor),
Portal(name="Forest Guard House 2 Lower Entrance", region="Lower Forest", Portal(name="Forest Guard House 2 Lower Entrance", region="Lower Forest",
destination="East Forest Redux Interior", tag="_lower"), destination="East Forest Redux Interior", tag="_lower", direction=Direction.north),
Portal(name="Forest Guard House 2 Upper Entrance", region="East Forest", Portal(name="Forest Guard House 2 Upper Entrance", region="East Forest",
destination="East Forest Redux Interior", tag="_upper"), destination="East Forest Redux Interior", tag="_upper", direction=Direction.east),
Portal(name="Forest Grave Path Lower Entrance", region="East Forest", Portal(name="Forest Grave Path Lower Entrance", region="East Forest",
destination="Sword Access", tag="_lower"), destination="Sword Access", tag="_lower", direction=Direction.east),
Portal(name="Forest Grave Path Upper Entrance", region="East Forest", Portal(name="Forest Grave Path Upper Entrance", region="East Forest",
destination="Sword Access", tag="_upper"), destination="Sword Access", tag="_upper", direction=Direction.east),
Portal(name="Forest Grave Path Upper Exit", region="Forest Grave Path Upper", Portal(name="Forest Grave Path Upper Exit", region="Forest Grave Path Upper",
destination="East Forest Redux", tag="_upper"), destination="East Forest Redux", tag="_upper", direction=Direction.west),
Portal(name="Forest Grave Path Lower Exit", region="Forest Grave Path Main", Portal(name="Forest Grave Path Lower Exit", region="Forest Grave Path Main",
destination="East Forest Redux", tag="_lower"), destination="East Forest Redux", tag="_lower", direction=Direction.west),
Portal(name="East Forest Hero's Grave", region="Forest Hero's Grave", Portal(name="East Forest Hero's Grave", region="Forest Hero's Grave",
destination="RelicVoid", tag="_teleporter_relic plinth"), destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Guard House 1 Dance Fox Exit", region="Guard House 1 West", Portal(name="Guard House 1 Dance Fox Exit", region="Guard House 1 West",
destination="East Forest Redux", tag="_upper"), destination="East Forest Redux", tag="_upper", direction=Direction.west),
Portal(name="Guard House 1 Lower Exit", region="Guard House 1 West", Portal(name="Guard House 1 Lower Exit", region="Guard House 1 West",
destination="East Forest Redux", tag="_lower"), destination="East Forest Redux", tag="_lower", direction=Direction.south),
Portal(name="Guard House 1 Upper Forest Exit", region="Guard House 1 East", Portal(name="Guard House 1 Upper Forest Exit", region="Guard House 1 East",
destination="East Forest Redux", tag="_gate"), destination="East Forest Redux", tag="_gate", direction=Direction.south),
Portal(name="Guard House 1 to Guard Captain Room", region="Guard House 1 East", Portal(name="Guard House 1 to Guard Captain Room", region="Guard House 1 East",
destination="Forest Boss Room", tag="_"), destination="Forest Boss Room", tag="_", direction=Direction.north),
Portal(name="Guard House 2 Lower Exit", region="Guard House 2 Lower", Portal(name="Guard House 2 Lower Exit", region="Guard House 2 Lower",
destination="East Forest Redux", tag="_lower"), destination="East Forest Redux", tag="_lower", direction=Direction.south),
Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper before bushes", Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper before bushes",
destination="East Forest Redux", tag="_upper"), destination="East Forest Redux", tag="_upper", direction=Direction.west),
Portal(name="Guard Captain Room Non-Gate Exit", region="Forest Boss Room", Portal(name="Guard Captain Room Non-Gate Exit", region="Forest Boss Room",
destination="East Forest Redux Laddercave", tag="_"), destination="East Forest Redux Laddercave", tag="_", direction=Direction.south),
Portal(name="Guard Captain Room Gate Exit", region="Forest Boss Room", Portal(name="Guard Captain Room Gate Exit", region="Forest Boss Room",
destination="Forest Belltower", tag="_"), destination="Forest Belltower", tag="_", direction=Direction.north),
Portal(name="Well Ladder Exit", region="Beneath the Well Ladder Exit", Portal(name="Well Ladder Exit", region="Beneath the Well Ladder Exit",
destination="Overworld Redux", tag="_entrance"), destination="Overworld Redux", tag="_entrance", direction=Direction.ladder_up),
Portal(name="Well to Well Boss", region="Beneath the Well Back", Portal(name="Well to Well Boss", region="Beneath the Well Back",
destination="Sewer_Boss", tag="_"), destination="Sewer_Boss", tag="_", direction=Direction.east),
Portal(name="Well Exit towards Furnace", region="Beneath the Well Back", Portal(name="Well Exit towards Furnace", region="Beneath the Well Back",
destination="Overworld Redux", tag="_west_aqueduct"), destination="Overworld Redux", tag="_west_aqueduct", direction=Direction.south),
Portal(name="Well Boss to Well", region="Well Boss", Portal(name="Well Boss to Well", region="Well Boss",
destination="Sewer", tag="_"), destination="Sewer", tag="_", direction=Direction.west),
Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint", Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint",
destination="Crypt Redux", tag="_"), destination="Crypt Redux", tag="_", direction=Direction.ladder_up),
Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point", Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit", Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit",
destination="Furnace", tag="_"), destination="Furnace", tag="_", direction=Direction.west),
Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point", Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point",
destination="Sewer_Boss", tag="_"), destination="Sewer_Boss", tag="_", direction=Direction.ladder_down),
Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry", Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry",
destination="Overworld Redux", tag="_lower"), destination="Overworld Redux", tag="_lower", direction=Direction.east),
Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House", Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House",
destination="archipelagos_house", tag="_"), destination="archipelagos_house", tag="_", direction=Direction.east),
Portal(name="West Garden Exit after Boss", region="West Garden after Boss", Portal(name="West Garden Exit after Boss", region="West Garden after Boss",
destination="Overworld Redux", tag="_upper"), destination="Overworld Redux", tag="_upper", direction=Direction.east),
Portal(name="West Garden Shop", region="West Garden before Terry", Portal(name="West Garden Shop", region="West Garden before Terry",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.east),
Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region", Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region",
destination="Overworld Redux", tag="_lowest"), destination="Overworld Redux", tag="_lowest", direction=Direction.east),
Portal(name="West Garden Hero's Grave", region="West Garden Hero's Grave Region", Portal(name="West Garden Hero's Grave", region="West Garden Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"), destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="West Garden to Far Shore", region="West Garden Portal", Portal(name="West Garden to Far Shore", region="West Garden Portal",
destination="Transit", tag="_teleporter_archipelagos_teleporter"), destination="Transit", tag="_teleporter_archipelagos_teleporter", direction=Direction.floor),
Portal(name="Magic Dagger House Exit", region="Magic Dagger House", Portal(name="Magic Dagger House Exit", region="Magic Dagger House",
destination="Archipelagos Redux", tag="_"), destination="Archipelagos Redux", tag="_", direction=Direction.west),
Portal(name="Fortress Courtyard to Fortress Grave Path Lower", region="Fortress Courtyard", Portal(name="Fortress Courtyard to Fortress Grave Path Lower", region="Fortress Courtyard",
destination="Fortress Reliquary", tag="_Lower"), destination="Fortress Reliquary", tag="_Lower", direction=Direction.east),
Portal(name="Fortress Courtyard to Fortress Grave Path Upper", region="Fortress Courtyard Upper", Portal(name="Fortress Courtyard to Fortress Grave Path Upper", region="Fortress Courtyard Upper",
destination="Fortress Reliquary", tag="_Upper"), destination="Fortress Reliquary", tag="_Upper", direction=Direction.east),
Portal(name="Fortress Courtyard to Fortress Interior", region="Fortress Courtyard", Portal(name="Fortress Courtyard to Fortress Interior", region="Fortress Courtyard",
destination="Fortress Main", tag="_Big Door"), destination="Fortress Main", tag="_Big Door", direction=Direction.north),
Portal(name="Fortress Courtyard to East Fortress", region="Fortress Courtyard Upper", Portal(name="Fortress Courtyard to East Fortress", region="Fortress Courtyard Upper",
destination="Fortress East", tag="_"), destination="Fortress East", tag="_", direction=Direction.north),
Portal(name="Fortress Courtyard to Beneath the Vault", region="Beneath the Vault Entry", Portal(name="Fortress Courtyard to Beneath the Vault", region="Beneath the Vault Entry",
destination="Fortress Basement", tag="_"), destination="Fortress Basement", tag="_", direction=Direction.ladder_down),
Portal(name="Fortress Courtyard to Forest Belltower", region="Fortress Exterior from East Forest", Portal(name="Fortress Courtyard to Forest Belltower", region="Fortress Exterior from East Forest",
destination="Forest Belltower", tag="_"), destination="Forest Belltower", tag="_", direction=Direction.south),
Portal(name="Fortress Courtyard to Overworld", region="Fortress Exterior from Overworld", Portal(name="Fortress Courtyard to Overworld", region="Fortress Exterior from Overworld",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.west),
Portal(name="Fortress Courtyard Shop", region="Fortress Exterior near cave", Portal(name="Fortress Courtyard Shop", region="Fortress Exterior near cave",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.north),
Portal(name="Beneath the Vault to Fortress Interior", region="Beneath the Vault Back", Portal(name="Beneath the Vault to Fortress Interior", region="Beneath the Vault Back",
destination="Fortress Main", tag="_"), destination="Fortress Main", tag="_", direction=Direction.east),
Portal(name="Beneath the Vault to Fortress Courtyard", region="Beneath the Vault Ladder Exit", Portal(name="Beneath the Vault to Fortress Courtyard", region="Beneath the Vault Ladder Exit",
destination="Fortress Courtyard", tag="_"), destination="Fortress Courtyard", tag="_", direction=Direction.ladder_up),
Portal(name="Fortress Interior Main Exit", region="Eastern Vault Fortress", Portal(name="Fortress Interior Main Exit", region="Eastern Vault Fortress",
destination="Fortress Courtyard", tag="_Big Door"), destination="Fortress Courtyard", tag="_Big Door", direction=Direction.south),
Portal(name="Fortress Interior to Beneath the Earth", region="Eastern Vault Fortress", Portal(name="Fortress Interior to Beneath the Earth", region="Eastern Vault Fortress",
destination="Fortress Basement", tag="_"), destination="Fortress Basement", tag="_", direction=Direction.west),
Portal(name="Fortress Interior to Siege Engine Arena", region="Eastern Vault Fortress Gold Door", Portal(name="Fortress Interior to Siege Engine Arena", region="Eastern Vault Fortress Gold Door",
destination="Fortress Arena", tag="_"), destination="Fortress Arena", tag="_", direction=Direction.north),
Portal(name="Fortress Interior Shop", region="Eastern Vault Fortress", Portal(name="Fortress Interior Shop", region="Eastern Vault Fortress",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.north),
Portal(name="Fortress Interior to East Fortress Upper", region="Eastern Vault Fortress", Portal(name="Fortress Interior to East Fortress Upper", region="Eastern Vault Fortress",
destination="Fortress East", tag="_upper"), destination="Fortress East", tag="_upper", direction=Direction.east),
Portal(name="Fortress Interior to East Fortress Lower", region="Eastern Vault Fortress", Portal(name="Fortress Interior to East Fortress Lower", region="Eastern Vault Fortress",
destination="Fortress East", tag="_lower"), destination="Fortress East", tag="_lower", direction=Direction.east),
Portal(name="East Fortress to Interior Lower", region="Fortress East Shortcut Lower", Portal(name="East Fortress to Interior Lower", region="Fortress East Shortcut Lower",
destination="Fortress Main", tag="_lower"), destination="Fortress Main", tag="_lower", direction=Direction.west),
Portal(name="East Fortress to Courtyard", region="Fortress East Shortcut Upper", Portal(name="East Fortress to Courtyard", region="Fortress East Shortcut Upper",
destination="Fortress Courtyard", tag="_"), destination="Fortress Courtyard", tag="_", direction=Direction.south),
Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper", Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper",
destination="Fortress Main", tag="_upper"), destination="Fortress Main", tag="_upper", direction=Direction.west),
Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry", Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry",
destination="Fortress Courtyard", tag="_Lower"), destination="Fortress Courtyard", tag="_Lower", direction=Direction.west),
Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region", Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"), destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Fortress Grave Path Upper Exit", region="Fortress Grave Path Upper", Portal(name="Fortress Grave Path Upper Exit", region="Fortress Grave Path Upper",
destination="Fortress Courtyard", tag="_Upper"), destination="Fortress Courtyard", tag="_Upper", direction=Direction.west),
Portal(name="Fortress Grave Path Dusty Entrance", region="Fortress Grave Path Dusty Entrance Region", Portal(name="Fortress Grave Path Dusty Entrance", region="Fortress Grave Path Dusty Entrance Region",
destination="Dusty", tag="_"), destination="Dusty", tag="_", direction=Direction.north),
Portal(name="Dusty Exit", region="Fortress Leaf Piles", Portal(name="Dusty Exit", region="Fortress Leaf Piles",
destination="Fortress Reliquary", tag="_"), destination="Fortress Reliquary", tag="_", direction=Direction.south),
Portal(name="Siege Engine Arena to Fortress", region="Fortress Arena", Portal(name="Siege Engine Arena to Fortress", region="Fortress Arena",
destination="Fortress Main", tag="_"), destination="Fortress Main", tag="_", direction=Direction.south),
Portal(name="Fortress to Far Shore", region="Fortress Arena Portal", Portal(name="Fortress to Far Shore", region="Fortress Arena Portal",
destination="Transit", tag="_teleporter_spidertank"), destination="Transit", tag="_teleporter_spidertank", direction=Direction.floor),
Portal(name="Atoll Upper Exit", region="Ruined Atoll", Portal(name="Atoll Upper Exit", region="Ruined Atoll",
destination="Overworld Redux", tag="_upper"), destination="Overworld Redux", tag="_upper", direction=Direction.north),
Portal(name="Atoll Lower Exit", region="Ruined Atoll Lower Entry Area", Portal(name="Atoll Lower Exit", region="Ruined Atoll Lower Entry Area",
destination="Overworld Redux", tag="_lower"), destination="Overworld Redux", tag="_lower", direction=Direction.north),
Portal(name="Atoll Shop", region="Ruined Atoll", Portal(name="Atoll Shop", region="Ruined Atoll",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.north),
Portal(name="Atoll to Far Shore", region="Ruined Atoll Portal", Portal(name="Atoll to Far Shore", region="Ruined Atoll Portal",
destination="Transit", tag="_teleporter_atoll"), destination="Transit", tag="_teleporter_atoll", direction=Direction.floor),
Portal(name="Atoll Statue Teleporter", region="Ruined Atoll Statue", Portal(name="Atoll Statue Teleporter", region="Ruined Atoll Statue",
destination="Library Exterior", tag="_"), destination="Library Exterior", tag="_", direction=Direction.floor),
Portal(name="Frog Stairs Eye Entrance", region="Ruined Atoll Frog Eye", Portal(name="Frog Stairs Eye Entrance", region="Ruined Atoll Frog Eye",
destination="Frog Stairs", tag="_eye"), destination="Frog Stairs", tag="_eye", direction=Direction.south), # camera rotates, it's fine
Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth", Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth",
destination="Frog Stairs", tag="_mouth"), destination="Frog Stairs", tag="_mouth", direction=Direction.east),
Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit", Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit",
destination="Atoll Redux", tag="_eye"), destination="Atoll Redux", tag="_eye", direction=Direction.north),
Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper", Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper",
destination="Atoll Redux", tag="_mouth"), destination="Atoll Redux", tag="_mouth", direction=Direction.west),
Portal(name="Frog Stairs to Frog's Domain's Entrance", region="Frog Stairs to Frog's Domain", Portal(name="Frog Stairs to Frog's Domain's Entrance", region="Frog Stairs to Frog's Domain",
destination="frog cave main", tag="_Entrance"), destination="frog cave main", tag="_Entrance", direction=Direction.ladder_down),
Portal(name="Frog Stairs to Frog's Domain's Exit", region="Frog Stairs Lower", Portal(name="Frog Stairs to Frog's Domain's Exit", region="Frog Stairs Lower",
destination="frog cave main", tag="_Exit"), destination="frog cave main", tag="_Exit", direction=Direction.east),
Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry", Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry",
destination="Frog Stairs", tag="_Entrance"), destination="Frog Stairs", tag="_Entrance", direction=Direction.ladder_up),
Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back", Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back",
destination="Frog Stairs", tag="_Exit"), destination="Frog Stairs", tag="_Exit", direction=Direction.west),
Portal(name="Library Exterior Tree", region="Library Exterior Tree Region", Portal(name="Library Exterior Tree", region="Library Exterior Tree Region",
destination="Atoll Redux", tag="_"), destination="Atoll Redux", tag="_", direction=Direction.floor),
Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region", Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region",
destination="Library Hall", tag="_"), destination="Library Hall", tag="_", direction=Direction.west), # camera rotates
Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf", Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf",
destination="Library Exterior", tag="_"), destination="Library Exterior", tag="_", direction=Direction.east),
Portal(name="Library Hero's Grave", region="Library Hero's Grave Region", Portal(name="Library Hero's Grave", region="Library Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"), destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda", Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda",
destination="Library Rotunda", tag="_"), destination="Library Rotunda", tag="_", direction=Direction.ladder_up),
Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall", Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall",
destination="Library Hall", tag="_"), destination="Library Hall", tag="_", direction=Direction.ladder_down),
Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab", Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab",
destination="Library Lab", tag="_"), destination="Library Lab", tag="_", direction=Direction.ladder_up),
Portal(name="Library Lab to Rotunda", region="Library Lab Lower", Portal(name="Library Lab to Rotunda", region="Library Lab Lower",
destination="Library Rotunda", tag="_"), destination="Library Rotunda", tag="_", direction=Direction.ladder_down),
Portal(name="Library to Far Shore", region="Library Portal", Portal(name="Library to Far Shore", region="Library Portal",
destination="Transit", tag="_teleporter_library teleporter"), destination="Transit", tag="_teleporter_library teleporter", direction=Direction.floor),
Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian", Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian",
destination="Library Arena", tag="_"), destination="Library Arena", tag="_", direction=Direction.ladder_up),
Portal(name="Librarian Arena Exit", region="Library Arena", Portal(name="Librarian Arena Exit", region="Library Arena",
destination="Library Lab", tag="_"), destination="Library Lab", tag="_", direction=Direction.ladder_down),
Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs", Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs",
destination="Mountaintop", tag="_"), destination="Mountaintop", tag="_", direction=Direction.north),
Portal(name="Mountain to Quarry", region="Lower Mountain", Portal(name="Mountain to Quarry", region="Lower Mountain",
destination="Quarry Redux", tag="_"), destination="Quarry Redux", tag="_", direction=Direction.south), # connecting is north
Portal(name="Mountain to Overworld", region="Lower Mountain", Portal(name="Mountain to Overworld", region="Lower Mountain",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Top of the Mountain Exit", region="Top of the Mountain", Portal(name="Top of the Mountain Exit", region="Top of the Mountain",
destination="Mountain", tag="_"), destination="Mountain", tag="_", direction=Direction.south),
Portal(name="Quarry Connector to Overworld", region="Quarry Connector", Portal(name="Quarry Connector to Overworld", region="Quarry Connector",
destination="Overworld Redux", tag="_"), destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Quarry Connector to Quarry", region="Quarry Connector", Portal(name="Quarry Connector to Quarry", region="Quarry Connector",
destination="Quarry Redux", tag="_"), destination="Quarry Redux", tag="_", direction=Direction.north), # rotates, it's fine
Portal(name="Quarry to Overworld Exit", region="Quarry Entry", Portal(name="Quarry to Overworld Exit", region="Quarry Entry",
destination="Darkwoods Tunnel", tag="_"), destination="Darkwoods Tunnel", tag="_", direction=Direction.south), # rotates, it's fine
Portal(name="Quarry Shop", region="Quarry Entry", Portal(name="Quarry Shop", region="Quarry Entry",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.north),
Portal(name="Quarry to Monastery Front", region="Quarry Monastery Entry", Portal(name="Quarry to Monastery Front", region="Quarry Monastery Entry",
destination="Monastery", tag="_front"), destination="Monastery", tag="_front", direction=Direction.north),
Portal(name="Quarry to Monastery Back", region="Monastery Rope", Portal(name="Quarry to Monastery Back", region="Monastery Rope",
destination="Monastery", tag="_back"), destination="Monastery", tag="_back", direction=Direction.east),
Portal(name="Quarry to Mountain", region="Quarry Back", Portal(name="Quarry to Mountain", region="Quarry Back",
destination="Mountain", tag="_"), destination="Mountain", tag="_", direction=Direction.north),
Portal(name="Quarry to Ziggurat", region="Lower Quarry Zig Door", Portal(name="Quarry to Ziggurat", region="Lower Quarry Zig Door",
destination="ziggurat2020_0", tag="_"), destination="ziggurat2020_0", tag="_", direction=Direction.north),
Portal(name="Quarry to Far Shore", region="Quarry Portal", Portal(name="Quarry to Far Shore", region="Quarry Portal",
destination="Transit", tag="_teleporter_quarry teleporter"), destination="Transit", tag="_teleporter_quarry teleporter", direction=Direction.floor),
Portal(name="Monastery Rear Exit", region="Monastery Back", Portal(name="Monastery Rear Exit", region="Monastery Back",
destination="Quarry Redux", tag="_back"), destination="Quarry Redux", tag="_back", direction=Direction.west),
Portal(name="Monastery Front Exit", region="Monastery Front", Portal(name="Monastery Front Exit", region="Monastery Front",
destination="Quarry Redux", tag="_front"), destination="Quarry Redux", tag="_front", direction=Direction.south),
Portal(name="Monastery Hero's Grave", region="Monastery Hero's Grave Region", Portal(name="Monastery Hero's Grave", region="Monastery Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"), destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Ziggurat Entry Hallway to Ziggurat Upper", region="Rooted Ziggurat Entry", Portal(name="Ziggurat Entry Hallway to Ziggurat Upper", region="Rooted Ziggurat Entry",
destination="ziggurat2020_1", tag="_"), destination="ziggurat2020_1", tag="_", direction=Direction.north),
Portal(name="Ziggurat Entry Hallway to Quarry", region="Rooted Ziggurat Entry", Portal(name="Ziggurat Entry Hallway to Quarry", region="Rooted Ziggurat Entry",
destination="Quarry Redux", tag="_"), destination="Quarry Redux", tag="_", direction=Direction.south),
Portal(name="Ziggurat Upper to Ziggurat Entry Hallway", region="Rooted Ziggurat Upper Entry", Portal(name="Ziggurat Upper to Ziggurat Entry Hallway", region="Rooted Ziggurat Upper Entry",
destination="ziggurat2020_0", tag="_"), destination="ziggurat2020_0", tag="_", direction=Direction.south),
Portal(name="Ziggurat Upper to Ziggurat Tower", region="Rooted Ziggurat Upper Back", Portal(name="Ziggurat Upper to Ziggurat Tower", region="Rooted Ziggurat Upper Back",
destination="ziggurat2020_2", tag="_"), destination="ziggurat2020_2", tag="_", direction=Direction.north), # connecting is south
Portal(name="Ziggurat Tower to Ziggurat Upper", region="Rooted Ziggurat Middle Top", Portal(name="Ziggurat Tower to Ziggurat Upper", region="Rooted Ziggurat Middle Top",
destination="ziggurat2020_1", tag="_"), destination="ziggurat2020_1", tag="_", direction=Direction.south),
Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom", Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom",
destination="ziggurat2020_3", tag="_"), destination="ziggurat2020_3", tag="_", direction=Direction.south),
Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry", Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry",
destination="ziggurat2020_2", tag="_"), destination="ziggurat2020_2", tag="_", direction=Direction.north),
Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance", Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance",
destination="ziggurat2020_FTRoom", tag="_"), destination="ziggurat2020_FTRoom", tag="_", direction=Direction.north),
# only if fixed shop is on, removed otherwise # only if fixed shop is on, removed otherwise
Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit", Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit", # not a real region
destination="ziggurat2020_1", tag="_zig2_skip"), destination="ziggurat2020_1", tag="_zig2_skip", direction=Direction.none),
Portal(name="Ziggurat Portal Room Exit", region="Rooted Ziggurat Portal Room Exit", Portal(name="Ziggurat Portal Room Exit", region="Rooted Ziggurat Portal Room Exit",
destination="ziggurat2020_3", tag="_"), destination="ziggurat2020_3", tag="_", direction=Direction.south),
Portal(name="Ziggurat to Far Shore", region="Rooted Ziggurat Portal", Portal(name="Ziggurat to Far Shore", region="Rooted Ziggurat Portal",
destination="Transit", tag="_teleporter_ziggurat teleporter"), destination="Transit", tag="_teleporter_ziggurat teleporter", direction=Direction.floor),
Portal(name="Swamp Lower Exit", region="Swamp Front", Portal(name="Swamp Lower Exit", region="Swamp Front",
destination="Overworld Redux", tag="_conduit"), destination="Overworld Redux", tag="_conduit", direction=Direction.north),
Portal(name="Swamp to Cathedral Main Entrance", region="Swamp to Cathedral Main Entrance Region", Portal(name="Swamp to Cathedral Main Entrance", region="Swamp to Cathedral Main Entrance Region",
destination="Cathedral Redux", tag="_main"), destination="Cathedral Redux", tag="_main", direction=Direction.north),
Portal(name="Swamp to Cathedral Secret Legend Room Entrance", region="Swamp to Cathedral Treasure Room", Portal(name="Swamp to Cathedral Secret Legend Room Entrance", region="Swamp to Cathedral Treasure Room",
destination="Cathedral Redux", tag="_secret"), destination="Cathedral Redux", tag="_secret", direction=Direction.south), # feels a little weird
Portal(name="Swamp to Gauntlet", region="Back of Swamp", Portal(name="Swamp to Gauntlet", region="Back of Swamp",
destination="Cathedral Arena", tag="_"), destination="Cathedral Arena", tag="_", direction=Direction.north),
Portal(name="Swamp Shop", region="Swamp Front", Portal(name="Swamp Shop", region="Swamp Front",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.north),
Portal(name="Swamp Upper Exit", region="Back of Swamp Laurels Area", Portal(name="Swamp Upper Exit", region="Back of Swamp Laurels Area",
destination="Overworld Redux", tag="_wall"), destination="Overworld Redux", tag="_wall", direction=Direction.north),
Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region", Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"), destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Cathedral Main Exit", region="Cathedral Entry", Portal(name="Cathedral Main Exit", region="Cathedral Entry",
destination="Swamp Redux 2", tag="_main"), destination="Swamp Redux 2", tag="_main", direction=Direction.south),
Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet", Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet",
destination="Cathedral Arena", tag="_"), destination="Cathedral Arena", tag="_", direction=Direction.ladder_down), # elevators are ladders, right?
Portal(name="Cathedral Secret Legend Room Exit", region="Cathedral Secret Legend Room", Portal(name="Cathedral Secret Legend Room Exit", region="Cathedral Secret Legend Room",
destination="Swamp Redux 2", tag="_secret"), destination="Swamp Redux 2", tag="_secret", direction=Direction.north),
Portal(name="Gauntlet to Swamp", region="Cathedral Gauntlet Exit", Portal(name="Gauntlet to Swamp", region="Cathedral Gauntlet Exit",
destination="Swamp Redux 2", tag="_"), destination="Swamp Redux 2", tag="_", direction=Direction.south),
Portal(name="Gauntlet Elevator", region="Cathedral Gauntlet Checkpoint", Portal(name="Gauntlet Elevator", region="Cathedral Gauntlet Checkpoint",
destination="Cathedral Redux", tag="_"), destination="Cathedral Redux", tag="_", direction=Direction.ladder_up),
Portal(name="Gauntlet Shop", region="Cathedral Gauntlet Checkpoint", Portal(name="Gauntlet Shop", region="Cathedral Gauntlet Checkpoint",
destination="Shop", tag="_"), destination="Shop", tag="_", direction=Direction.east),
Portal(name="Hero's Grave to Fortress", region="Hero Relic - Fortress", Portal(name="Hero's Grave to Fortress", region="Hero Relic - Fortress",
destination="Fortress Reliquary", tag="_teleporter_relic plinth"), destination="Fortress Reliquary", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Hero's Grave to Monastery", region="Hero Relic - Quarry", Portal(name="Hero's Grave to Monastery", region="Hero Relic - Quarry",
destination="Monastery", tag="_teleporter_relic plinth"), destination="Monastery", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Hero's Grave to West Garden", region="Hero Relic - West Garden", Portal(name="Hero's Grave to West Garden", region="Hero Relic - West Garden",
destination="Archipelagos Redux", tag="_teleporter_relic plinth"), destination="Archipelagos Redux", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Hero's Grave to East Forest", region="Hero Relic - East Forest", Portal(name="Hero's Grave to East Forest", region="Hero Relic - East Forest",
destination="Sword Access", tag="_teleporter_relic plinth"), destination="Sword Access", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Hero's Grave to Library", region="Hero Relic - Library", Portal(name="Hero's Grave to Library", region="Hero Relic - Library",
destination="Library Hall", tag="_teleporter_relic plinth"), destination="Library Hall", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Hero's Grave to Swamp", region="Hero Relic - Swamp", Portal(name="Hero's Grave to Swamp", region="Hero Relic - Swamp",
destination="Swamp Redux 2", tag="_teleporter_relic plinth"), destination="Swamp Redux 2", tag="_teleporter_relic plinth", direction=Direction.floor),
Portal(name="Far Shore to West Garden", region="Far Shore to West Garden Region", Portal(name="Far Shore to West Garden", region="Far Shore to West Garden Region",
destination="Archipelagos Redux", tag="_teleporter_archipelagos_teleporter"), destination="Archipelagos Redux", tag="_teleporter_archipelagos_teleporter", direction=Direction.floor),
Portal(name="Far Shore to Library", region="Far Shore to Library Region", Portal(name="Far Shore to Library", region="Far Shore to Library Region",
destination="Library Lab", tag="_teleporter_library teleporter"), destination="Library Lab", tag="_teleporter_library teleporter", direction=Direction.floor),
Portal(name="Far Shore to Quarry", region="Far Shore to Quarry Region", Portal(name="Far Shore to Quarry", region="Far Shore to Quarry Region",
destination="Quarry Redux", tag="_teleporter_quarry teleporter"), destination="Quarry Redux", tag="_teleporter_quarry teleporter", direction=Direction.floor),
Portal(name="Far Shore to East Forest", region="Far Shore to East Forest Region", Portal(name="Far Shore to East Forest", region="Far Shore to East Forest Region",
destination="East Forest Redux", tag="_teleporter_forest teleporter"), destination="East Forest Redux", tag="_teleporter_forest teleporter", direction=Direction.floor),
Portal(name="Far Shore to Fortress", region="Far Shore to Fortress Region", Portal(name="Far Shore to Fortress", region="Far Shore to Fortress Region",
destination="Fortress Arena", tag="_teleporter_spidertank"), destination="Fortress Arena", tag="_teleporter_spidertank", direction=Direction.floor),
Portal(name="Far Shore to Atoll", region="Far Shore", Portal(name="Far Shore to Atoll", region="Far Shore",
destination="Atoll Redux", tag="_teleporter_atoll"), destination="Atoll Redux", tag="_teleporter_atoll", direction=Direction.floor),
Portal(name="Far Shore to Ziggurat", region="Far Shore", Portal(name="Far Shore to Ziggurat", region="Far Shore",
destination="ziggurat2020_FTRoom", tag="_teleporter_ziggurat teleporter"), destination="ziggurat2020_FTRoom", tag="_teleporter_ziggurat teleporter", direction=Direction.floor),
Portal(name="Far Shore to Heir", region="Far Shore", Portal(name="Far Shore to Heir", region="Far Shore",
destination="Spirit Arena", tag="_teleporter_spirit arena"), destination="Spirit Arena", tag="_teleporter_spirit arena", direction=Direction.floor),
Portal(name="Far Shore to Town", region="Far Shore", Portal(name="Far Shore to Town", region="Far Shore",
destination="Overworld Redux", tag="_teleporter_town"), destination="Overworld Redux", tag="_teleporter_town", direction=Direction.floor),
Portal(name="Far Shore to Spawn", region="Far Shore to Spawn Region", Portal(name="Far Shore to Spawn", region="Far Shore to Spawn Region",
destination="Overworld Redux", tag="_teleporter_starting island"), destination="Overworld Redux", tag="_teleporter_starting island", direction=Direction.floor),
Portal(name="Heir Arena Exit", region="Spirit Arena", Portal(name="Heir Arena Exit", region="Spirit Arena",
destination="Transit", tag="_teleporter_spirit arena"), destination="Transit", tag="_teleporter_spirit arena", direction=Direction.floor),
Portal(name="Purgatory Bottom Exit", region="Purgatory", Portal(name="Purgatory Bottom Exit", region="Purgatory",
destination="Purgatory", tag="_bottom"), destination="Purgatory", tag="_bottom", direction=Direction.south),
Portal(name="Purgatory Top Exit", region="Purgatory", Portal(name="Purgatory Top Exit", region="Purgatory",
destination="Purgatory", tag="_top"), destination="Purgatory", tag="_top", direction=Direction.north),
] ]
@@ -523,6 +536,7 @@ class RegionInfo(NamedTuple):
game_scene: str # the name of the scene in the actual game game_scene: str # the name of the scene in the actual game
dead_end: int = 0 # if a region has only one exit dead_end: int = 0 # if a region has only one exit
outlet_region: Optional[str] = None outlet_region: Optional[str] = None
is_fake_region: bool = False
# gets the outlet region name if it exists, the region if it doesn't # gets the outlet region name if it exists, the region if it doesn't
@@ -540,9 +554,9 @@ class DeadEnd(IntEnum):
# key is the AP region name. "Fake" in region info just means the mod won't receive that info at all # key is the AP region name. "Fake" in region info just means the mod won't receive that info at all
tunic_er_regions: Dict[str, RegionInfo] = { tunic_er_regions: Dict[str, RegionInfo] = {
"Menu": RegionInfo("Fake", dead_end=DeadEnd.all_cats), "Menu": RegionInfo("Fake", dead_end=DeadEnd.all_cats, is_fake_region=True),
"Overworld": RegionInfo("Overworld Redux"), # main overworld, the central area "Overworld": RegionInfo("Overworld Redux"), # main overworld, the central area
"Overworld Holy Cross": RegionInfo("Fake", dead_end=DeadEnd.all_cats), # main overworld holy cross checks "Overworld Holy Cross": RegionInfo("Fake", dead_end=DeadEnd.all_cats, is_fake_region=True), # main overworld holy cross checks
"Overworld Belltower": RegionInfo("Overworld Redux"), # the area with the belltower and chest "Overworld Belltower": RegionInfo("Overworld Redux"), # the area with the belltower and chest
"Overworld Belltower at Bell": RegionInfo("Overworld Redux"), # being able to ring the belltower, basically "Overworld Belltower at Bell": RegionInfo("Overworld Redux"), # being able to ring the belltower, basically
"Overworld Swamp Upper Entry": RegionInfo("Overworld Redux"), # upper swamp entry spot "Overworld Swamp Upper Entry": RegionInfo("Overworld Redux"), # upper swamp entry spot
@@ -722,7 +736,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic "Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic
"Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin "Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin
"Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side "Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side
"Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry"), # for use with fixed shop on "Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry", is_fake_region=True), # for use with fixed shop on
"Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side "Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side
"Rooted Ziggurat Portal": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"), "Rooted Ziggurat Portal": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"),
"Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"), "Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"),
@@ -758,7 +772,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Purgatory": RegionInfo("Purgatory"), "Purgatory": RegionInfo("Purgatory"),
"Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats), "Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats),
"Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats), "Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats),
"Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats), "Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats, is_fake_region=True),
} }
@@ -1301,7 +1315,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
[], [],
}, },
# cannot get from frogs back to front
"Library Exterior Ladder Region": { "Library Exterior Ladder Region": {
"Library Exterior by Tree": "Library Exterior by Tree":
[], [],
@@ -1634,10 +1647,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Rooted Ziggurat Portal Room Entrance": "Rooted Ziggurat Portal Room Entrance":
[], [],
}, },
"Zig Skip Exit": {
"Rooted Ziggurat Lower Front":
[],
},
"Rooted Ziggurat Portal Room Entrance": { "Rooted Ziggurat Portal Room Entrance": {
"Rooted Ziggurat Lower Back": "Rooted Ziggurat Lower Back":
[], [],

View File

@@ -381,9 +381,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Overworld"].connect( regions["Overworld"].connect(
connecting_region=regions["Overworld Tunnel Turret"], connecting_region=regions["Overworld Tunnel Turret"],
rule=lambda state: state.has(laurels, player)) rule=lambda state: state.has(laurels, player))
regions["Overworld Tunnel Turret"].connect(
connecting_region=regions["Overworld"], # always have access to Overworld, so connecting back isn't needed
rule=lambda state: state.has_any({grapple, laurels}, player)) # regions["Overworld Tunnel Turret"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: state.has_any({grapple, laurels}, player))
cube_entrance = regions["Overworld"].connect( cube_entrance = regions["Overworld"].connect(
connecting_region=regions["Cube Cave Entrance Region"], connecting_region=regions["Cube Cave Entrance Region"],
@@ -1053,11 +1055,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Portal Room Entrance"].connect( regions["Rooted Ziggurat Portal Room Entrance"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"]) connecting_region=regions["Rooted Ziggurat Lower Back"])
# zig skip region only gets made if entrance rando and fewer shops are on
if options.entrance_rando and options.fixed_shop:
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Portal"].connect( regions["Rooted Ziggurat Portal"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room"]) connecting_region=regions["Rooted Ziggurat Portal Room"])
regions["Rooted Ziggurat Portal Room"].connect( regions["Rooted Ziggurat Portal Room"].connect(
@@ -1226,14 +1223,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
and has_sword(state, player)))) and has_sword(state, player))))
if options.ladder_storage: if options.ladder_storage:
def get_portal_info(portal_sd: str) -> Tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal1.name, get_portal_outlet_region(portal2, world)
if portal2.scene_destination() == portal_sd:
return portal2.name, get_portal_outlet_region(portal1, world)
raise Exception("no matches found in get_paired_region")
# connect ls elevation regions to their destinations # connect ls elevation regions to their destinations
def ls_connect(origin_name: str, portal_sdt: str) -> None: def ls_connect(origin_name: str, portal_sdt: str) -> None:
p_name, paired_region_name = get_portal_info(portal_sdt) p_name, paired_region_name = get_portal_info(portal_sdt)

View File

@@ -1,11 +1,12 @@
from typing import Dict, List, Set, Tuple, TYPE_CHECKING from typing import Dict, List, Set, Tuple, TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location from BaseClasses import Region, ItemClassification, Item, Location
from .locations import all_locations from .locations import all_locations
from .er_data import Portal, portal_mapping, traversal_requirements, DeadEnd, RegionInfo from .er_data import (Portal, portal_mapping, traversal_requirements, DeadEnd, Direction, RegionInfo,
get_portal_outlet_region)
from .er_rules import set_er_region_rules from .er_rules import set_er_region_rules
from .breakables import create_breakable_exclusive_regions, set_breakable_location_rules from .breakables import create_breakable_exclusive_regions, set_breakable_location_rules
from Options import PlandoConnection from Options import PlandoConnection
from .options import EntranceRando from .options import EntranceRando, EntranceLayout
from random import Random from random import Random
from copy import deepcopy from copy import deepcopy
@@ -23,17 +24,18 @@ class TunicERLocation(Location):
def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
regions: Dict[str, Region] = {} regions: Dict[str, Region] = {}
world.used_shop_numbers = set()
for region_name, region_data in world.er_regions.items(): for region_name, region_data in world.er_regions.items():
if world.options.entrance_rando and region_name == "Zig Skip Exit": if world.options.entrance_rando and region_name == "Zig Skip Exit":
# need to check if there's a seed group for this first # need to check if there's a seed group for this first
if world.options.entrance_rando.value not in EntranceRando.options.values(): if world.options.entrance_rando.value not in EntranceRando.options.values():
if not world.seed_groups[world.options.entrance_rando.value]["fixed_shop"]: if world.seed_groups[world.options.entrance_rando.value]["entrance_layout"] != EntranceLayout.option_fixed_shop:
continue continue
elif not world.options.fixed_shop: elif world.options.entrance_layout != EntranceLayout.option_fixed_shop:
continue continue
if not world.options.entrance_rando and region_name in ("Zig Skip Exit", "Purgatory"): if not world.options.entrance_rando and region_name in ("Zig Skip Exit", "Purgatory"):
continue continue
region = Region(region_name, world.player, world.multiworld) region = Region(region_name, world.player, world.multiworld)
regions[region_name] = region regions[region_name] = region
world.multiworld.regions.append(region) world.multiworld.regions.append(region)
@@ -46,13 +48,18 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
portal_pairs = pair_portals(world, regions) portal_pairs = pair_portals(world, regions)
# output the entrances to the spoiler log here for convenience # output the entrances to the spoiler log here for convenience
sorted_portal_pairs = sort_portals(portal_pairs) sorted_portal_pairs = sort_portals(portal_pairs, world)
for portal1, portal2 in sorted_portal_pairs.items(): if not world.options.decoupled:
world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player) for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player)
else:
for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "entrance", world.player)
else: else:
portal_pairs = vanilla_portals(world, regions) portal_pairs = vanilla_portals(world, regions)
create_randomized_entrances(portal_pairs, regions) create_randomized_entrances(world, portal_pairs, regions)
set_er_region_rules(world, regions, portal_pairs) set_er_region_rules(world, regions, portal_pairs)
@@ -75,6 +82,7 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
return portal_pairs return portal_pairs
# keys are event names, values are event regions
tunic_events: Dict[str, str] = { tunic_events: Dict[str, str] = {
"Eastern Bell": "Forest Belltower Upper", "Eastern Bell": "Forest Belltower Upper",
"Western Bell": "Overworld Belltower at Bell", "Western Bell": "Overworld Belltower at Bell",
@@ -111,17 +119,31 @@ def place_event_items(world: "TunicWorld", regions: Dict[str, Region]) -> None:
region.locations.append(location) region.locations.append(location)
# keeping track of which shop numbers have been used already to avoid duplicates
# due to plando, shops can be added out of order, so a set is the best way to make this work smoothly
def get_shop_num(world: "TunicWorld") -> int:
portal_num = -1
for i in range(500):
if i + 1 not in world.used_shop_numbers:
portal_num = i + 1
world.used_shop_numbers.add(portal_num)
break
if portal_num == -1:
raise Exception(f"TUNIC: {world.player_name} has plando'd too many shops.")
return portal_num
# all shops are the same shop. however, you cannot get to all shops from the same shop entrance. # all shops are the same shop. however, you cannot get to all shops from the same shop entrance.
# so, we need a bunch of shop regions that connect to the actual shop, but the actual shop cannot connect back # so, we need a bunch of shop regions that connect to the actual shop, but the actual shop cannot connect back
def create_shop_region(world: "TunicWorld", regions: Dict[str, Region]) -> None: def create_shop_region(world: "TunicWorld", regions: Dict[str, Region], portal_num) -> None:
new_shop_name = f"Shop {world.shop_num}" new_shop_name = f"Shop {portal_num}"
world.er_regions[new_shop_name] = RegionInfo("Shop", dead_end=DeadEnd.all_cats) world.er_regions[new_shop_name] = RegionInfo("Shop", dead_end=DeadEnd.all_cats)
new_shop_region = Region(new_shop_name, world.player, world.multiworld) new_shop_region = Region(new_shop_name, world.player, world.multiworld)
new_shop_region.connect(regions["Shop"]) new_shop_region.connect(regions["Shop"])
regions[new_shop_name] = new_shop_region regions[new_shop_name] = new_shop_region
world.shop_num += 1
# for non-ER that uses the ER rules, we create a vanilla set of portal pairs
def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]: def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]:
portal_pairs: Dict[Portal, Portal] = {} portal_pairs: Dict[Portal, Portal] = {}
# we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here # we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here
@@ -135,9 +157,10 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
portal2_sdt = portal1.destination_scene() portal2_sdt = portal1.destination_scene()
if portal2_sdt.startswith("Shop,"): if portal2_sdt.startswith("Shop,"):
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}", portal_num = get_shop_num(world)
destination="Previous Region", tag="_") portal2 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
create_shop_region(world, regions) destination=str(portal_num), tag="_", direction=Direction.none)
create_shop_region(world, regions, portal_num)
for portal in portal_map: for portal in portal_map:
if portal.scene_destination() == portal2_sdt: if portal.scene_destination() == portal2_sdt:
@@ -152,7 +175,13 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
return portal_pairs return portal_pairs
# pairing off portals, starting with dead ends # the really long function that gives us our portal pairs
# before we start pairing, we separate the portals into dead ends and non-dead ends (two_plus)
# then, we do a few other important tasks to accommodate options and seed gropus
# first phase: pick a two_plus in a reachable region and non-reachable region and pair them
# repeat this phase until all regions are reachable
# second phase: randomly pair dead ends to random two_plus
# third phase: randomly pair the remaining two_plus to each other
def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]: def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]:
portal_pairs: Dict[Portal, Portal] = {} portal_pairs: Dict[Portal, Portal] = {}
dead_ends: List[Portal] = [] dead_ends: List[Portal] = []
@@ -162,8 +191,9 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
laurels_zips = world.options.laurels_zips.value laurels_zips = world.options.laurels_zips.value
ice_grappling = world.options.ice_grappling.value ice_grappling = world.options.ice_grappling.value
ladder_storage = world.options.ladder_storage.value ladder_storage = world.options.ladder_storage.value
fixed_shop = world.options.fixed_shop entrance_layout = world.options.entrance_layout
laurels_location = world.options.laurels_location laurels_location = world.options.laurels_location
decoupled = world.options.decoupled
traversal_reqs = deepcopy(traversal_requirements) traversal_reqs = deepcopy(traversal_requirements)
has_laurels = True has_laurels = True
waterfall_plando = False waterfall_plando = False
@@ -174,7 +204,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
laurels_zips = seed_group["laurels_zips"] laurels_zips = seed_group["laurels_zips"]
ice_grappling = seed_group["ice_grappling"] ice_grappling = seed_group["ice_grappling"]
ladder_storage = seed_group["ladder_storage"] ladder_storage = seed_group["ladder_storage"]
fixed_shop = seed_group["fixed_shop"] entrance_layout = seed_group["entrance_layout"]
laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False
logic_tricks: Tuple[bool, int, int] = (laurels_zips, ice_grappling, ladder_storage) logic_tricks: Tuple[bool, int, int] = (laurels_zips, ice_grappling, ladder_storage)
@@ -183,15 +213,18 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
if laurels_location == "10_fairies" and not world.using_ut: if laurels_location == "10_fairies" and not world.using_ut:
has_laurels = False has_laurels = False
shop_count = 6 # for the direction pairs option with decoupled off
if fixed_shop: # tracks how many portals are in each direction in each list
shop_count = 0 two_plus_direction_tracker: Dict[int, int] = {direction: 0 for direction in range(8)}
else: dead_end_direction_tracker: Dict[int, int] = {direction: 0 for direction in range(8)}
# if fixed shop is off, remove this portal
for portal in portal_map: # for ensuring we have enough entrances in directions left that we don't leave dead ends without any
if portal.region == "Zig Skip Exit": def too_few_portals_for_direction_pairs(direction: int, offset: int) -> bool:
portal_map.remove(portal) if two_plus_direction_tracker[direction] <= (dead_end_direction_tracker[direction_pairs[direction]] + offset):
break return False
if two_plus_direction_tracker[direction_pairs[direction]] <= dead_end_direction_tracker[direction] + offset:
return False
return True
# If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit # If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit
if world.using_ut: if world.using_ut:
@@ -202,25 +235,59 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
dead_end_status = world.er_regions[portal.region].dead_end dead_end_status = world.er_regions[portal.region].dead_end
if dead_end_status == DeadEnd.free: if dead_end_status == DeadEnd.free:
two_plus.append(portal) two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
elif dead_end_status == DeadEnd.all_cats: elif dead_end_status == DeadEnd.all_cats:
dead_ends.append(portal) dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
elif dead_end_status == DeadEnd.restricted: elif dead_end_status == DeadEnd.restricted:
if ice_grappling: if ice_grappling:
two_plus.append(portal) two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
else: else:
dead_ends.append(portal) dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
# these two get special handling # these two get special handling
elif dead_end_status == DeadEnd.special: elif dead_end_status == DeadEnd.special:
if portal.region == "Secret Gathering Place": if portal.region == "Secret Gathering Place":
if laurels_location == "10_fairies": if laurels_location == "10_fairies":
two_plus.append(portal) two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
else: else:
dead_ends.append(portal) dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
if portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop:
# direction isn't meaningful here since zig skip cannot be in direction pairs mode
two_plus.append(portal)
# now we generate the shops and add them to the dead ends list
shop_count = 6
if entrance_layout == EntranceLayout.option_fixed_shop:
shop_count = 0
else:
# if fixed shop is off, remove this portal
for portal in portal_map:
if portal.region == "Zig Skip Exit": if portal.region == "Zig Skip Exit":
if fixed_shop: portal_map.remove(portal)
two_plus.append(portal) break
else: # need 8 shops with direction pairs or there won't be a valid set of pairs
dead_ends.append(portal) if entrance_layout == EntranceLayout.option_direction_pairs:
shop_count = 8
# for universal tracker, we want to skip shop gen since it's essentially full plando
if world.using_ut:
shop_count = 0
for _ in range(shop_count):
# 6 of the shops have south exits, 2 of them have west exits
portal_num = get_shop_num(world)
shop_dir = Direction.south
if portal_num > 6:
shop_dir = Direction.west
shop_portal = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=shop_dir)
create_shop_region(world, regions, portal_num)
dead_ends.append(shop_portal)
dead_end_direction_tracker[shop_portal.direction] += 1
connected_regions: Set[str] = set() connected_regions: Set[str] = set()
# make better start region stuff when/if implementing random start # make better start region stuff when/if implementing random start
@@ -249,29 +316,68 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
portal_name2 = portal.name portal_name2 = portal.name
# connected_regions.update(add_dependent_regions(portal.region, logic_rules)) # connected_regions.update(add_dependent_regions(portal.region, logic_rules))
# shops have special handling # shops have special handling
if not portal_name2 and portal2 == "Shop, Previous Region_": if not portal_name1 and portal1.startswith("Shop"):
portal_name2 = "Shop Portal" # it should show up as "Shop, 1_" for shop 1
plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both")) portal_name1 = "Shop Portal " + str(portal1).split(", ")[1].split("_")[0]
if not portal_name2 and portal2.startswith("Shop"):
portal_name2 = "Shop Portal " + str(portal2).split(", ")[1].split("_")[0]
if world.options.decoupled:
plando_connections.append(PlandoConnection(portal_name1, portal_name2, "entrance"))
else:
plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both"))
# put together the list of non-deadend regions
non_dead_end_regions = set() non_dead_end_regions = set()
for region_name, region_info in world.er_regions.items(): for region_name, region_info in world.er_regions.items():
if not region_info.dead_end: # these are not real regions, they are just here to be descriptive
if region_info.is_fake_region or region_name == "Shop":
continue
# dead ends aren't real in decoupled
if decoupled:
non_dead_end_regions.add(region_name)
elif not region_info.dead_end:
non_dead_end_regions.add(region_name) non_dead_end_regions.add(region_name)
# if ice grappling to places is in logic, both places stop being dead ends # if ice grappling to places is in logic, both places stop being dead ends
elif region_info.dead_end == DeadEnd.restricted and ice_grappling: elif region_info.dead_end == DeadEnd.restricted and ice_grappling:
non_dead_end_regions.add(region_name) non_dead_end_regions.add(region_name)
# secret gathering place and zig skip get weird, special handling # secret gathering place is treated as a non-dead end if 10 fairies is on to assure non-laurels access to it
elif region_info.dead_end == DeadEnd.special: elif region_info.dead_end == DeadEnd.special:
if (region_name == "Secret Gathering Place" and laurels_location == "10_fairies") \ if region_name == "Secret Gathering Place" and laurels_location == "10_fairies":
or (region_name == "Zig Skip Exit" and fixed_shop):
non_dead_end_regions.add(region_name) non_dead_end_regions.add(region_name)
if decoupled:
# add the dead ends to the two plus list, since dead ends aren't real in decoupled
two_plus.extend(dead_ends)
dead_ends.clear()
# if decoupled is on, we make a second two_plus list, where the first is entrances and the second is exits
two_plus2 = two_plus.copy()
else:
# if decoupled is off, the two lists are the same list, since entrances and exits are intertwined
two_plus2 = two_plus
if plando_connections: if plando_connections:
for connection in plando_connections: if decoupled:
modified_plando_connections = plando_connections.copy()
for index, cxn in enumerate(modified_plando_connections):
# it's much easier if we split both-direction portals into two one-ways in decoupled
if cxn.direction == "both":
replacement1 = PlandoConnection(cxn.entrance, cxn.exit, "entrance")
replacement2 = PlandoConnection(cxn.exit, cxn.entrance, "entrance")
modified_plando_connections.remove(cxn)
modified_plando_connections.insert(index, replacement1)
modified_plando_connections.append(replacement2)
else:
modified_plando_connections = plando_connections
connected_shop_portal1s: Set[int] = set()
connected_shop_portal2s: Set[int] = set()
for connection in modified_plando_connections:
p_entrance = connection.entrance p_entrance = connection.entrance
p_exit = connection.exit p_exit = connection.exit
# if you plando secret gathering place, need to know that during portal pairing # if you plando secret gathering place, need to know that during portal pairing
if "Secret Gathering Place Exit" in [p_entrance, p_exit]: if p_exit == "Secret Gathering Place Exit":
waterfall_plando = True
if p_entrance == "Secret Gathering Place Exit" and not decoupled:
waterfall_plando = True waterfall_plando = True
portal1_dead_end = True portal1_dead_end = True
portal2_dead_end = True portal2_dead_end = True
@@ -279,118 +385,186 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
portal1 = None portal1 = None
portal2 = None portal2 = None
# search two_plus for both at once # search the two_plus lists (or list) for the portals
for portal in two_plus: for portal in two_plus:
if p_entrance == portal.name: if p_entrance == portal.name:
portal1 = portal portal1 = portal
portal1_dead_end = False portal1_dead_end = False
break
for portal in two_plus2:
if p_exit == portal.name: if p_exit == portal.name:
portal2 = portal portal2 = portal
portal2_dead_end = False portal2_dead_end = False
break
# search dead_ends individually since we can't really remove items from two_plus during the loop # search dead_ends individually since we can't really remove items from two_plus during the loop
if portal1: if portal1:
two_plus.remove(portal1) two_plus.remove(portal1)
else: else:
# if not both, they're both dead ends # if not both, they're both dead ends
if not portal2: if not portal2 and not decoupled:
if world.options.entrance_rando.value not in EntranceRando.options.values(): if world.options.entrance_rando.value not in EntranceRando.options.values():
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.") "end to a dead end in their plando connections.")
else: else:
raise Exception(f"{player_name} paired a dead end to a dead end in their " raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.") f"plando connections -- {connection.entrance} to {connection.exit}")
for portal in dead_ends: for portal in dead_ends:
if p_entrance == portal.name: if p_entrance == portal.name:
portal1 = portal portal1 = portal
dead_ends.remove(portal1)
break break
if not portal1: else:
raise Exception(f"Could not find entrance named {p_entrance} for " if p_entrance.startswith("Shop Portal "):
f"plando connections in {player_name}'s YAML.") portal_num = int(p_entrance.split("Shop Portal ")[-1])
dead_ends.remove(portal1) # shops 1-6 are south, 7 and 8 are east, and after that it just breaks direction pairs
if portal_num <= 6:
pdir = Direction.south
elif portal_num in [7, 8]:
pdir = Direction.east
else:
pdir = Direction.none
portal1 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=pdir)
connected_shop_portal1s.add(portal_num)
if portal_num not in world.used_shop_numbers:
create_shop_region(world, regions, portal_num)
world.used_shop_numbers.add(portal_num)
if decoupled and portal_num not in connected_shop_portal2s:
two_plus2.append(portal1)
non_dead_end_regions.add(portal1.region)
else:
raise Exception(f"Could not find entrance named {p_entrance} for "
f"plando connections in {player_name}'s YAML.")
if portal2: if portal2:
two_plus.remove(portal2) two_plus2.remove(portal2)
else: else:
for portal in dead_ends: for portal in dead_ends:
if p_exit == portal.name: if p_exit == portal.name:
portal2 = portal portal2 = portal
dead_ends.remove(portal2)
break break
# if it's not a dead end, it might be a shop # if it's not a dead end, maybe it's a plando'd shop portal that doesn't normally exist
if p_exit == "Shop Portal":
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}",
destination="Previous Region", tag="_")
create_shop_region(world, regions)
shop_count -= 1
# need to maintain an even number of portals total
if shop_count < 0:
shop_count += 2
# and if it's neither shop nor dead end, it just isn't correct
else: else:
if not portal2: if not portal2:
raise Exception(f"Could not find entrance named {p_exit} for " if p_exit.startswith("Shop Portal "):
f"plando connections in {player_name}'s YAML.\n" portal_num = int(p_exit.split("Shop Portal ")[-1])
f"If you are using Universal Tracker, the most likely reason for this error " if portal_num <= 6:
f"is that the host generated with a newer version of the APWorld.\n" pdir = Direction.south
f"Please check the TUNIC Randomizer Github and place the newest APWorld in your " elif portal_num in [7, 8]:
f"custom_worlds folder, and remove the one in lib/worlds if there is one there.") pdir = Direction.east
dead_ends.remove(portal2) else:
pdir = Direction.none
portal2 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=pdir)
connected_shop_portal2s.add(portal_num)
if portal_num not in world.used_shop_numbers:
create_shop_region(world, regions, portal_num)
world.used_shop_numbers.add(portal_num)
if decoupled and portal_num not in connected_shop_portal1s:
two_plus.append(portal2)
non_dead_end_regions.add(portal2.region)
else:
raise Exception(f"Could not find entrance named {p_exit} for "
f"plando connections in {player_name}'s YAML.")
# update the traversal chart to say you can get from portal1's region to portal2's and vice versa # if we're doing decoupled, we don't need to do complex checks
if not portal1_dead_end and not portal2_dead_end: if decoupled:
traversal_reqs.setdefault(portal1.region, dict())[portal2.region] = [] # we turn any plando that uses "exit" to use "entrance" instead
traversal_reqs.setdefault(portal2.region, dict())[portal1.region] = [] traversal_reqs.setdefault(portal1.region, dict())[get_portal_outlet_region(portal2, world)] = []
# outside decoupled, we want to use what we were doing before decoupled got added
else:
# update the traversal chart to say you can get from portal1's region to portal2's and vice versa
if not portal1_dead_end and not portal2_dead_end:
traversal_reqs.setdefault(portal1.region, dict())[get_portal_outlet_region(portal2, world)] = []
traversal_reqs.setdefault(portal2.region, dict())[get_portal_outlet_region(portal1, world)] = []
if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place") if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place")
or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")): or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")):
if world.options.entrance_rando.value not in EntranceRando.options.values():
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.")
else:
raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.")
if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit")
or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")):
# need to make sure you didn't pair this to a dead end or zig skip
if portal1_dead_end or portal2_dead_end or \
portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
if world.options.entrance_rando.value not in EntranceRando.options.values(): if world.options.entrance_rando.value not in EntranceRando.options.values():
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.") "end to a dead end in their plando connections.")
else: else:
raise Exception(f"{player_name} paired a dead end to a dead end in their " raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.") "plando connections.")
if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit")
or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")):
# need to make sure you didn't pair this to a dead end or zig skip
if portal1_dead_end or portal2_dead_end or \
portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
if world.options.entrance_rando.value not in EntranceRando.options.values():
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.")
else:
raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.")
# okay now that we're done with all of that nonsense, we can finally make the portal pair
portal_pairs[portal1] = portal2 portal_pairs[portal1] = portal2
if portal1_dead_end:
dead_end_direction_tracker[portal1.direction] -= 1
else:
two_plus_direction_tracker[portal1.direction] -= 1
if portal2_dead_end:
dead_end_direction_tracker[portal2.direction] -= 1
else:
two_plus_direction_tracker[portal2.direction] -= 1
# if we have plando connections, our connected regions may change somewhat # if we have plando connections, our connected regions may change somewhat
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks) connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks)
if fixed_shop and not world.using_ut: # if there are an odd number of shops after plando, add another one, except in decoupled where it doesn't matter
portal1 = None if not decoupled and len(world.used_shop_numbers) % 2 == 1:
if entrance_layout == EntranceLayout.option_direction_pairs:
raise Exception(f"TUNIC: {world.player_name} plando'd too many shops for the Direction Pairs option.")
portal_num = get_shop_num(world)
shop_portal = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=Direction.none)
create_shop_region(world, regions, portal_num)
dead_ends.append(shop_portal)
if entrance_layout == EntranceLayout.option_fixed_shop and not world.using_ut:
windmill = None
for portal in two_plus: for portal in two_plus:
if portal.scene_destination() == "Overworld Redux, Windmill_": if portal.scene_destination() == "Overworld Redux, Windmill_":
portal1 = portal windmill = portal
break break
if not portal1: if not windmill:
raise Exception(f"Failed to do Fixed Shop option. " raise Exception(f"Failed to do Fixed Shop option for Entrance Layout. "
f"Did {player_name} plando connection the Windmill Shop entrance?") f"Did {player_name} plando the Windmill Shop entrance?")
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}", portal_num = get_shop_num(world)
destination="Previous Region", tag="_") shop = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
create_shop_region(world, regions) destination=str(portal_num), tag="_", direction=Direction.south)
create_shop_region(world, regions, portal_num)
portal_pairs[portal1] = portal2 portal_pairs[windmill] = shop
two_plus.remove(portal1) two_plus.remove(windmill)
if decoupled:
two_plus.append(shop)
non_dead_end_regions.add(shop.region)
connected_regions.add(shop.region)
random_object: Random = world.random
# use the seed given in the options to shuffle the portals # use the seed given in the options to shuffle the portals
if isinstance(world.options.entrance_rando.value, str): if isinstance(world.options.entrance_rando.value, str):
random_object = Random(world.options.entrance_rando.value) random_object = Random(world.options.entrance_rando.value)
else:
random_object: Random = world.random
# we want to start by making sure every region is accessible # we want to start by making sure every region is accessible
random_object.shuffle(two_plus) random_object.shuffle(two_plus)
check_success = 0
# this is a backup in case we run into that rare direction pairing failure
# so that we don't have to redo the plando bit basically
backup_connected_regions = connected_regions.copy()
backup_portal_pairs = portal_pairs.copy()
backup_two_plus = two_plus.copy()
backup_two_plus_direction_tracker = two_plus_direction_tracker.copy()
rare_failure_count = 0
portal1 = None portal1 = None
portal2 = None portal2 = None
previous_conn_num = 0 previous_conn_num = 0
@@ -403,96 +577,182 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# should, hopefully, only ever occur if someone plandos connections poorly # should, hopefully, only ever occur if someone plandos connections poorly
if previous_conn_num == len(connected_regions): if previous_conn_num == len(connected_regions):
fail_count += 1 fail_count += 1
if fail_count >= 500: if fail_count > 500:
raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for errors. " raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for errors. "
"Unconnected regions:", non_dead_end_regions - connected_regions) f"Unconnected regions: {non_dead_end_regions - connected_regions}.\n"
f"Unconnected portals: {[portal.name for portal in two_plus]}")
if (fail_count > 100 and not decoupled
and (world.options.entrance_layout == EntranceLayout.option_direction_pairs or waterfall_plando)):
# in direction pairs, we may run into a case where we run out of pairable directions
# since we need to ensure the dead ends will have something to connect to
# or if fairy cave is plando'd, it may run into an issue where it is trying to get access to 2 separate
# areas at once to give access to laurels
# so, this is basically just resetting entrance pairing
# this should be very rare, so this fail-safe shouldn't be covering up for an actual solution
# this should never happen in decoupled, since it's entirely too flexible for that
portal_pairs = backup_portal_pairs.copy()
two_plus = two_plus2 = backup_two_plus.copy()
two_plus_direction_tracker = backup_two_plus_direction_tracker.copy()
random_object.shuffle(two_plus)
connected_regions = backup_connected_regions.copy()
rare_failure_count += 1
fail_count = 0
if rare_failure_count > 100:
raise Exception(f"Failed to pair regions due to rare pairing issues for {player_name}. "
f"Unconnected regions: {non_dead_end_regions - connected_regions}.\n"
f"Unconnected portals: {[portal.name for portal in two_plus]}")
else: else:
fail_count = 0 fail_count = 0
previous_conn_num = len(connected_regions) previous_conn_num = len(connected_regions)
# find a portal in a connected region # find a portal in a connected region
if check_success == 0: for portal in two_plus:
for portal in two_plus: if portal.region in connected_regions:
if portal.region in connected_regions: # if there's more dead ends of a direction than two plus of the opposite direction,
portal1 = portal # then we'll run out of viable connections for those dead ends later
two_plus.remove(portal) # decoupled does not have this issue since dead ends aren't real in decoupled
check_success = 1 if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs:
break if not too_few_portals_for_direction_pairs(portal.direction, 0):
continue
# then we find a portal in an inaccessible region portal1 = portal
if check_success == 1: two_plus.remove(portal)
for portal in two_plus: break
if portal.region not in connected_regions: if not portal1:
# if secret gathering place happens to get paired really late, you can end up running out raise Exception("TUNIC: Failed to pair portals at first part of first phase.")
if not has_laurels and len(two_plus) < 80:
# if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this # then we find a portal in an unconnected region
if waterfall_plando: for portal in two_plus2:
cr = connected_regions.copy() if portal.region not in connected_regions:
cr.add(portal.region) # if secret gathering place happens to get paired really late, you can end up running out
if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks): if not has_laurels and len(two_plus2) < 80:
continue # if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this
# if not waterfall_plando, then we just want to pair secret gathering place now if waterfall_plando:
elif portal.region != "Secret Gathering Place": cr = connected_regions.copy()
cr.add(portal.region)
if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks):
continue continue
portal2 = portal # if not waterfall_plando, then we just want to pair secret gathering place now
connected_regions.add(portal.region) elif portal.region != "Secret Gathering Place":
two_plus.remove(portal) continue
check_success = 2
break # if they're not facing opposite directions, just continue
if entrance_layout == EntranceLayout.option_direction_pairs and not verify_direction_pair(portal, portal1):
continue
# if you have direction pairs, we need to make sure we don't run out of spots for problem portals
# this cuts down on using the failsafe significantly
if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs:
should_continue = False
# these portals are weird since they're one-ways essentially
# we need to make sure they are connected in this first phase
south_problems = ["Ziggurat Upper to Ziggurat Entry Hallway",
"Ziggurat Tower to Ziggurat Upper", "Forest Belltower to Guard Captain Room"]
if (portal.direction == Direction.south and portal.name not in south_problems
and not too_few_portals_for_direction_pairs(portal.direction, 3)):
for test_portal in two_plus:
if test_portal.name in south_problems:
should_continue = True
# at risk of connecting frog's domain entry ladder to librarian exit
if (portal.direction == Direction.ladder_down
or portal.direction == Direction.ladder_up and portal.name != "Frog's Domain Ladder Exit"
and not too_few_portals_for_direction_pairs(portal.direction, 1)):
for test_portal in two_plus:
if test_portal.name == "Frog's Domain Ladder Exit":
should_continue = True
if should_continue:
continue
portal2 = portal
connected_regions.add(get_portal_outlet_region(portal, world))
two_plus2.remove(portal)
break
if not portal2:
if entrance_layout == EntranceLayout.option_direction_pairs or waterfall_plando:
# portal1 doesn't have a valid direction pair yet, throw it back and start over
two_plus.append(portal1)
continue
else:
raise Exception(f"TUNIC: Failed to pair portals at second part of first phase for {world.player_name}.")
# once we have both portals, connect them and add the new region(s) to connected_regions # once we have both portals, connect them and add the new region(s) to connected_regions
if check_success == 2: if not has_laurels and "Secret Gathering Place" in connected_regions:
if "Secret Gathering Place" in connected_regions: has_laurels = True
has_laurels = True connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks)
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks)
portal_pairs[portal1] = portal2
check_success = 0
random_object.shuffle(two_plus)
# for universal tracker, we want to skip shop gen
if world.using_ut:
shop_count = 0
for i in range(shop_count):
portal1 = two_plus.pop()
if portal1 is None:
raise Exception("TUNIC: Too many shops in the pool, or something else went wrong.")
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}",
destination="Previous Region", tag="_")
create_shop_region(world, regions)
portal_pairs[portal1] = portal2 portal_pairs[portal1] = portal2
two_plus_direction_tracker[portal1.direction] -= 1
two_plus_direction_tracker[portal2.direction] -= 1
portal1 = None
portal2 = None
random_object.shuffle(two_plus)
if two_plus != two_plus2:
random_object.shuffle(two_plus2)
# connect dead ends to random non-dead ends # connect dead ends to random non-dead ends
# none of the key events are in dead ends, so we don't need to do gate_before_switch # there are no dead ends in decoupled
while len(dead_ends) > 0: while len(dead_ends) > 0:
if world.using_ut: if world.using_ut:
break break
portal1 = two_plus.pop() portal2 = dead_ends[0]
portal2 = dead_ends.pop() for portal in two_plus:
portal_pairs[portal1] = portal2 if entrance_layout == EntranceLayout.option_direction_pairs and not verify_direction_pair(portal, portal2):
continue
if entrance_layout == EntranceLayout.option_fixed_shop and portal.region == "Zig Skip Exit":
continue
portal1 = portal
portal_pairs[portal1] = portal2
two_plus.remove(portal1)
dead_ends.remove(portal2)
break
else:
raise Exception(f"Failed to pair {portal2.name} with anything in two_plus for player {world.player_name}.")
# then randomly connect the remaining portals to each other # then randomly connect the remaining portals to each other
# every region is accessible, so gate_before_switch is not necessary final_pair_number = 0
while len(two_plus) > 1: while len(two_plus) > 0:
if world.using_ut: if world.using_ut:
break break
portal1 = two_plus.pop() final_pair_number += 1
portal2 = two_plus.pop() if final_pair_number > 10000:
raise Exception(f"Failed to pair portals while pairing the final entrances off to each other. "
f"Remaining portals in two_plus: {[portal.name for portal in two_plus]}. "
f"Remaining portals in two_plus2: {[portal.name for portal in two_plus2]}.")
portal1 = two_plus[0]
two_plus.remove(portal1)
portal2 = None
if entrance_layout != EntranceLayout.option_direction_pairs:
portal2 = two_plus2.pop()
else:
for portal in two_plus2:
if verify_direction_pair(portal1, portal):
portal2 = portal
two_plus2.remove(portal2)
break
if portal2 is None:
raise Exception("Something went wrong with the remaining two plus portals. Contact the TUNIC rando devs.")
portal_pairs[portal1] = portal2 portal_pairs[portal1] = portal2
if len(two_plus) == 1: if len(two_plus2) > 0:
raise Exception("two plus had an odd number of portals, investigate this. last portal is " + two_plus[0].name) raise Exception(f"TUNIC: Something went horribly wrong in ER for {world.player_name}. "
f"Please contact the TUNIC rando devs.")
return portal_pairs return portal_pairs
# loop through our list of paired portals and make two-way connections # loop through our list of paired portals and make two-way connections
def create_randomized_entrances(portal_pairs: Dict[Portal, Portal], regions: Dict[str, Region]) -> None: def create_randomized_entrances(world: "TunicWorld", portal_pairs: Dict[Portal, Portal], regions: Dict[str, Region]) -> None:
for portal1, portal2 in portal_pairs.items(): for portal1, portal2 in portal_pairs.items():
region1 = regions[portal1.region] # connect to the outlet region if there is one, if not connect to the actual region
region2 = regions[portal2.region] regions[portal1.region].connect(
region1.connect(connecting_region=region2, name=portal1.name) connecting_region=regions[get_portal_outlet_region(portal2, world)],
region2.connect(connecting_region=region1, name=portal2.name) name=portal1.name)
if not world.options.decoupled or not world.options.entrance_rando:
regions[portal2.region].connect(
connecting_region=regions[get_portal_outlet_region(portal1, world)],
name=portal2.name)
def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[str, Dict[str, List[List[str]]]], def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[str, Dict[str, List[List[str]]]],
@@ -541,22 +801,58 @@ def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[s
return connected_regions return connected_regions
# which directions are opposites
direction_pairs: Dict[int, int] = {
Direction.north: Direction.south,
Direction.south: Direction.north,
Direction.east: Direction.west,
Direction.west: Direction.east,
Direction.ladder_up: Direction.ladder_down,
Direction.ladder_down: Direction.ladder_up,
Direction.floor: Direction.floor,
}
# verify that two portals are in compatible directions
def verify_direction_pair(portal1: Portal, portal2: Portal) -> bool:
return portal1.direction == direction_pairs[portal2.direction]
# verify that two plando'd portals are in compatible directions
def verify_plando_directions(connection: PlandoConnection) -> bool:
entrance_portal = None
exit_portal = None
for portal in portal_mapping:
if connection.entrance == portal.name:
entrance_portal = portal
if connection.exit == portal.name:
exit_portal = portal
if entrance_portal and exit_portal:
break
# neither of these are shops, so verify the pair
if entrance_portal and exit_portal:
return verify_direction_pair(entrance_portal, exit_portal)
# this is two shop portals, they can never pair directions
elif not entrance_portal and not exit_portal:
return False
# if one of them is none, it's a shop, which has two possible directions
elif not entrance_portal:
return exit_portal.direction in [Direction.north, Direction.east]
elif not exit_portal:
return entrance_portal.direction in [Direction.north, Direction.east]
else:
# shouldn't be reachable, more of a just in case
raise Exception("Something went very wrong with verify_plando_directions")
# sort the portal dict by the name of the first portal, referring to the portal order in the master portal list # sort the portal dict by the name of the first portal, referring to the portal order in the master portal list
def sort_portals(portal_pairs: Dict[Portal, Portal]) -> Dict[str, str]: def sort_portals(portal_pairs: Dict[Portal, Portal], world: "TunicWorld") -> Dict[str, str]:
sorted_pairs: Dict[str, str] = {} sorted_pairs: Dict[str, str] = {}
reference_list: List[str] = [portal.name for portal in portal_mapping] reference_list: List[str] = [portal.name for portal in portal_mapping]
reference_list.append("Shop Portal")
# note: this is not necessary yet since the shop portals aren't numbered yet -- they will be when decoupled happens
# due to plando, there can be a variable number of shops # due to plando, there can be a variable number of shops
# I could either do it like this, or just go up to like 200, this seemed better largest_shop_number = max(world.used_shop_numbers)
# shop_count = 0 reference_list.extend([f"Shop Portal {i + 1}" for i in range(largest_shop_number)])
# for portal1, portal2 in portal_pairs.items():
# if portal1.name.startswith("Shop"):
# shop_count += 1
# if portal2.name.startswith("Shop"):
# shop_count += 1
# reference_list.extend([f"Shop Portal {i + 1}" for i in range(shop_count)])
for name in reference_list: for name in reference_list:
for portal1, portal2 in portal_pairs.items(): for portal1, portal2 in portal_pairs.items():

View File

@@ -5,7 +5,7 @@ from typing import Dict, Any, TYPE_CHECKING
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections, from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections,
PerGameCommonOptions, OptionGroup, Visibility, NamedRange) PerGameCommonOptions, OptionGroup, Removed, Visibility, NamedRange)
from .er_data import portal_mapping from .er_data import portal_mapping
if TYPE_CHECKING: if TYPE_CHECKING:
from . import TunicWorld from . import TunicWorld
@@ -147,14 +147,42 @@ class EntranceRando(TextChoice):
class FixedShop(Toggle): class FixedShop(Toggle):
""" """
Forces the Windmill entrance to lead to a shop, and removes the remaining shops from the pool. This option has been superseded by the Entrance Layout option.
Adds another entrance in Rooted Ziggurat Lower to keep an even number of entrances. If enabled, it will override the Entrance Layout option.
Has no effect if Entrance Rando is not enabled. This is kept to keep older yamls working, and will be removed at a later date.
""" """
visibility = Visibility.none
internal_name = "fixed_shop" internal_name = "fixed_shop"
display_name = "Fewer Shops in Entrance Rando" display_name = "Fewer Shops in Entrance Rando"
class EntranceLayout(Choice):
"""
Decide how the Entrance Randomizer chooses how to pair the entrances.
Standard: Entrances are randomly connected. There are 6 shops in the pool with this option.
Fixed Shop: Forces the Windmill entrance to lead to a shop, and removes the other shops from the pool.
Adds another entrance in Rooted Ziggurat Lower to keep an even number of entrances.
Direction Pairs: Entrances facing opposite directions are paired together. There are 8 shops in the pool with this option.
Note: For seed groups, if one player in a group chooses Fixed Shop and another chooses Direction Pairs, it will error out.
Either of these options will override Standard within a seed group.
"""
internal_name = "entrance_layout"
display_name = "Entrance Layout"
option_standard = 0
option_fixed_shop = 1
option_direction_pairs = 2
default = 0
class Decoupled(Toggle):
"""
Decouple the entrances, so that when you go from one entrance to another, the return trip won't necessarily bring you back to the same place.
Note: For seed groups, all players in the group must have this option enabled or disabled.
"""
internal_name = "decoupled"
display_name = "Decoupled Entrances"
class LaurelsLocation(Choice): class LaurelsLocation(Choice):
""" """
Force the Hero's Laurels to be placed at a location in your world. Force the Hero's Laurels to be placed at a location in your world.
@@ -210,13 +238,22 @@ class LocalFill(NamedRange):
class TunicPlandoConnections(PlandoConnections): class TunicPlandoConnections(PlandoConnections):
""" """
Generic connection plando. Format is: Generic connection plando. Format is:
- entrance: "Entrance Name" - entrance: Entrance Name
exit: "Exit Name" exit: Exit Name
direction: Direction
percentage: 100 percentage: 100
Direction must be one of entrance, exit, or both, and defaults to both if omitted.
Direction entrance means the entrance leads to the exit. Direction exit means the exit leads to the entrance.
If you do not have Decoupled enabled, you do not need the direction line, as it will only use both.
Percentage is an integer from 0 to 100 which determines whether that connection will be made. Defaults to 100 if omitted. Percentage is an integer from 0 to 100 which determines whether that connection will be made. Defaults to 100 if omitted.
If the Entrance Layout option is set to Standard or Fixed Shop, you can plando multiple shops.
If the Entrance Layout option is set to Direction Pairs, your plando connections must be facing opposite directions.
Shop Portal 1-6 are South portals, and Shop Portal 7-8 are West portals.
This option does nothing if Entrance Rando is disabled.
""" """
entrances = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"} shops = {f"Shop Portal {i + 1}" for i in range(500)}
exits = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"} entrances = {portal.name for portal in portal_mapping}.union(shops)
exits = {portal.name for portal in portal_mapping}.union(shops)
duplicate_exits = True duplicate_exits = True
@@ -295,6 +332,16 @@ class LadderStorageWithoutItems(Toggle):
display_name = "Ladder Storage without Items" display_name = "Ladder Storage without Items"
class HiddenAllRandom(Toggle):
"""
Sets all options that can be random to random.
For test gens.
"""
internal_name = "all_random"
display_name = "All Random Debug"
visibility = Visibility.none
class LogicRules(Choice): class LogicRules(Choice):
""" """
This option has been superseded by the individual trick options. This option has been superseded by the individual trick options.
@@ -329,6 +376,7 @@ class TunicOptions(PerGameCommonOptions):
start_with_sword: StartWithSword start_with_sword: StartWithSword
keys_behind_bosses: KeysBehindBosses keys_behind_bosses: KeysBehindBosses
ability_shuffling: AbilityShuffling ability_shuffling: AbilityShuffling
fool_traps: FoolTraps fool_traps: FoolTraps
laurels_location: LaurelsLocation laurels_location: LaurelsLocation
@@ -343,7 +391,9 @@ class TunicOptions(PerGameCommonOptions):
local_fill: LocalFill local_fill: LocalFill
entrance_rando: EntranceRando entrance_rando: EntranceRando
fixed_shop: FixedShop entrance_layout: EntranceLayout
decoupled: Decoupled
plando_connections: TunicPlandoConnections
combat_logic: CombatLogic combat_logic: CombatLogic
lanternless: Lanternless lanternless: Lanternless
@@ -353,9 +403,10 @@ class TunicOptions(PerGameCommonOptions):
ladder_storage: LadderStorage ladder_storage: LadderStorage
ladder_storage_without_items: LadderStorageWithoutItems ladder_storage_without_items: LadderStorageWithoutItems
plando_connections: TunicPlandoConnections all_random: HiddenAllRandom
logic_rules: LogicRules fixed_shop: FixedShop # will be removed at a later date
logic_rules: Removed # fully removed in the direction pairs update
tunic_option_groups = [ tunic_option_groups = [
@@ -372,8 +423,14 @@ tunic_option_groups = [
LaurelsZips, LaurelsZips,
IceGrappling, IceGrappling,
LadderStorage, LadderStorage,
LadderStorageWithoutItems LadderStorageWithoutItems,
]) ]),
OptionGroup("Entrance Randomizer", [
EntranceRando,
EntranceLayout,
Decoupled,
TunicPlandoConnections,
]),
] ]
tunic_option_presets: Dict[str, Dict[str, Any]] = { tunic_option_presets: Dict[str, Dict[str, Any]] = {

View File

@@ -1,5 +1,4 @@
from typing import Dict, TYPE_CHECKING from typing import Dict, TYPE_CHECKING
from decimal import Decimal, ROUND_HALF_UP
from worlds.generic.Rules import set_rule, forbid_item, add_rule from worlds.generic.Rules import set_rule, forbid_item, add_rule
from BaseClasses import CollectionState from BaseClasses import CollectionState
@@ -157,8 +156,8 @@ def set_region_rules(world: "TunicWorld") -> None:
if options.ladder_storage >= LadderStorage.option_medium: if options.ladder_storage >= LadderStorage.option_medium:
# ls at any ladder in a safe spot in quarry to get to the monastery rope entrance # ls at any ladder in a safe spot in quarry to get to the monastery rope entrance
world.get_region("Quarry Back").connect(world.get_region("Monastery"), add_rule(world.get_entrance(entrance_name="Quarry Back -> Monastery"),
rule=lambda state: can_ladder_storage(state, world)) rule=lambda state: can_ladder_storage(state, world))
def set_location_rules(world: "TunicWorld") -> None: def set_location_rules(world: "TunicWorld") -> None:

View File

@@ -78,7 +78,8 @@ class TestERSpecial(TunicTestBase):
options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes, options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes,
options.AbilityShuffling.internal_name: options.AbilityShuffling.option_true, options.AbilityShuffling.internal_name: options.AbilityShuffling.option_true,
options.HexagonQuest.internal_name: options.HexagonQuest.option_false, options.HexagonQuest.internal_name: options.HexagonQuest.option_false,
options.FixedShop.internal_name: options.FixedShop.option_false, options.CombatLogic.internal_name: options.CombatLogic.option_off,
options.EntranceLayout.internal_name: options.EntranceLayout.option_fixed_shop,
options.IceGrappling.internal_name: options.IceGrappling.option_easy, options.IceGrappling.internal_name: options.IceGrappling.option_easy,
"plando_connections": [ "plando_connections": [
{ {
@@ -126,3 +127,262 @@ class TestLadderStorage(TunicTestBase):
self.assertFalse(self.can_reach_location("Fortress Courtyard - Page Near Cave")) self.assertFalse(self.can_reach_location("Fortress Courtyard - Page Near Cave"))
self.collect_by_name(["Pages 24-25 (Prayer)"]) self.collect_by_name(["Pages 24-25 (Prayer)"])
self.assertTrue(self.can_reach_location("Fortress Courtyard - Page Near Cave")) self.assertTrue(self.can_reach_location("Fortress Courtyard - Page Near Cave"))
# check that it still functions if in decoupled and every single normal entrance leads to a shop
class TestERDecoupledPlando(TunicTestBase):
options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes,
options.Decoupled.internal_name: options.Decoupled.option_true,
"plando_connections": [
{"entrance": "Stick House Entrance", "exit": "Shop Portal 1", "direction": "entrance"},
{"entrance": "Windmill Entrance", "exit": "Shop Portal 2", "direction": "entrance"},
{"entrance": "Well Ladder Entrance", "exit": "Shop Portal 3", "direction": "entrance"},
{"entrance": "Entrance to Well from Well Rail", "exit": "Shop Portal 4", "direction": "entrance"},
{"entrance": "Old House Door Entrance", "exit": "Shop Portal 5", "direction": "entrance"},
{"entrance": "Old House Waterfall Entrance", "exit": "Shop Portal 6", "direction": "entrance"},
{"entrance": "Entrance to Furnace from Well Rail", "exit": "Shop Portal 7", "direction": "entrance"},
{"entrance": "Entrance to Furnace under Windmill", "exit": "Shop Portal 8", "direction": "entrance"},
{"entrance": "Entrance to Furnace near West Garden", "exit": "Shop Portal 9",
"direction": "entrance"},
{"entrance": "Entrance to Furnace from Beach", "exit": "Shop Portal 10", "direction": "entrance"},
{"entrance": "Caustic Light Cave Entrance", "exit": "Shop Portal 11", "direction": "entrance"},
{"entrance": "Swamp Upper Entrance", "exit": "Shop Portal 12", "direction": "entrance"},
{"entrance": "Swamp Lower Entrance", "exit": "Shop Portal 13", "direction": "entrance"},
{"entrance": "Ruined Passage Not-Door Entrance", "exit": "Shop Portal 14", "direction": "entrance"},
{"entrance": "Ruined Passage Door Entrance", "exit": "Shop Portal 15", "direction": "entrance"},
{"entrance": "Atoll Upper Entrance", "exit": "Shop Portal 16", "direction": "entrance"},
{"entrance": "Atoll Lower Entrance", "exit": "Shop Portal 17", "direction": "entrance"},
{"entrance": "Special Shop Entrance", "exit": "Shop Portal 18", "direction": "entrance"},
{"entrance": "Maze Cave Entrance", "exit": "Shop Portal 19", "direction": "entrance"},
{"entrance": "West Garden Entrance near Belltower", "exit": "Shop Portal 20",
"direction": "entrance"},
{"entrance": "West Garden Entrance from Furnace", "exit": "Shop Portal 21", "direction": "entrance"},
{"entrance": "West Garden Laurels Entrance", "exit": "Shop Portal 22", "direction": "entrance"},
{"entrance": "Temple Door Entrance", "exit": "Shop Portal 23", "direction": "entrance"},
{"entrance": "Temple Rafters Entrance", "exit": "Shop Portal 24", "direction": "entrance"},
{"entrance": "Ruined Shop Entrance", "exit": "Shop Portal 25", "direction": "entrance"},
{"entrance": "Patrol Cave Entrance", "exit": "Shop Portal 26", "direction": "entrance"},
{"entrance": "Hourglass Cave Entrance", "exit": "Shop Portal 27", "direction": "entrance"},
{"entrance": "Changing Room Entrance", "exit": "Shop Portal 28", "direction": "entrance"},
{"entrance": "Cube Cave Entrance", "exit": "Shop Portal 29", "direction": "entrance"},
{"entrance": "Stairs from Overworld to Mountain", "exit": "Shop Portal 30", "direction": "entrance"},
{"entrance": "Overworld to Fortress", "exit": "Shop Portal 31", "direction": "entrance"},
{"entrance": "Fountain HC Door Entrance", "exit": "Shop Portal 32", "direction": "entrance"},
{"entrance": "Southeast HC Door Entrance", "exit": "Shop Portal 33", "direction": "entrance"},
{"entrance": "Overworld to Quarry Connector", "exit": "Shop Portal 34", "direction": "entrance"},
{"entrance": "Dark Tomb Main Entrance", "exit": "Shop Portal 35", "direction": "entrance"},
{"entrance": "Overworld to Forest Belltower", "exit": "Shop Portal 36", "direction": "entrance"},
{"entrance": "Town to Far Shore", "exit": "Shop Portal 37", "direction": "entrance"},
{"entrance": "Spawn to Far Shore", "exit": "Shop Portal 38", "direction": "entrance"},
{"entrance": "Secret Gathering Place Entrance", "exit": "Shop Portal 39", "direction": "entrance"},
{"entrance": "Secret Gathering Place Exit", "exit": "Shop Portal 40", "direction": "entrance"},
{"entrance": "Windmill Exit", "exit": "Shop Portal 41", "direction": "entrance"},
{"entrance": "Windmill Shop", "exit": "Shop Portal 42", "direction": "entrance"},
{"entrance": "Old House Door Exit", "exit": "Shop Portal 43", "direction": "entrance"},
{"entrance": "Old House to Glyph Tower", "exit": "Shop Portal 44", "direction": "entrance"},
{"entrance": "Old House Waterfall Exit", "exit": "Shop Portal 45", "direction": "entrance"},
{"entrance": "Glyph Tower Exit", "exit": "Shop Portal 46", "direction": "entrance"},
{"entrance": "Changing Room Exit", "exit": "Shop Portal 47", "direction": "entrance"},
{"entrance": "Fountain HC Room Exit", "exit": "Shop Portal 48", "direction": "entrance"},
{"entrance": "Cube Cave Exit", "exit": "Shop Portal 49", "direction": "entrance"},
{"entrance": "Guard Patrol Cave Exit", "exit": "Shop Portal 50", "direction": "entrance"},
{"entrance": "Ruined Shop Exit", "exit": "Shop Portal 51", "direction": "entrance"},
{"entrance": "Furnace Exit towards Well", "exit": "Shop Portal 52", "direction": "entrance"},
{"entrance": "Furnace Exit to Dark Tomb", "exit": "Shop Portal 53", "direction": "entrance"},
{"entrance": "Furnace Exit towards West Garden", "exit": "Shop Portal 54", "direction": "entrance"},
{"entrance": "Furnace Exit to Beach", "exit": "Shop Portal 55", "direction": "entrance"},
{"entrance": "Furnace Exit under Windmill", "exit": "Shop Portal 56", "direction": "entrance"},
{"entrance": "Stick House Exit", "exit": "Shop Portal 57", "direction": "entrance"},
{"entrance": "Ruined Passage Not-Door Exit", "exit": "Shop Portal 58", "direction": "entrance"},
{"entrance": "Ruined Passage Door Exit", "exit": "Shop Portal 59", "direction": "entrance"},
{"entrance": "Southeast HC Room Exit", "exit": "Shop Portal 60", "direction": "entrance"},
{"entrance": "Caustic Light Cave Exit", "exit": "Shop Portal 61", "direction": "entrance"},
{"entrance": "Maze Cave Exit", "exit": "Shop Portal 62", "direction": "entrance"},
{"entrance": "Hourglass Cave Exit", "exit": "Shop Portal 63", "direction": "entrance"},
{"entrance": "Special Shop Exit", "exit": "Shop Portal 64", "direction": "entrance"},
{"entrance": "Temple Rafters Exit", "exit": "Shop Portal 65", "direction": "entrance"},
{"entrance": "Temple Door Exit", "exit": "Shop Portal 66", "direction": "entrance"},
{"entrance": "Forest Belltower to Fortress", "exit": "Shop Portal 67", "direction": "entrance"},
{"entrance": "Forest Belltower to Forest", "exit": "Shop Portal 68", "direction": "entrance"},
{"entrance": "Forest Belltower to Overworld", "exit": "Shop Portal 69", "direction": "entrance"},
{"entrance": "Forest Belltower to Guard Captain Room", "exit": "Shop Portal 70",
"direction": "entrance"},
{"entrance": "Forest to Belltower", "exit": "Shop Portal 71", "direction": "entrance"},
{"entrance": "Forest Guard House 1 Lower Entrance", "exit": "Shop Portal 72",
"direction": "entrance"},
{"entrance": "Forest Guard House 1 Gate Entrance", "exit": "Shop Portal 73",
"direction": "entrance"},
{"entrance": "Forest Dance Fox Outside Doorway", "exit": "Shop Portal 74", "direction": "entrance"},
{"entrance": "Forest to Far Shore", "exit": "Shop Portal 75", "direction": "entrance"},
{"entrance": "Forest Guard House 2 Lower Entrance", "exit": "Shop Portal 76",
"direction": "entrance"},
{"entrance": "Forest Guard House 2 Upper Entrance", "exit": "Shop Portal 77",
"direction": "entrance"},
{"entrance": "Forest Grave Path Lower Entrance", "exit": "Shop Portal 78", "direction": "entrance"},
{"entrance": "Forest Grave Path Upper Entrance", "exit": "Shop Portal 79", "direction": "entrance"},
{"entrance": "Forest Grave Path Upper Exit", "exit": "Shop Portal 80", "direction": "entrance"},
{"entrance": "Forest Grave Path Lower Exit", "exit": "Shop Portal 81", "direction": "entrance"},
{"entrance": "East Forest Hero's Grave", "exit": "Shop Portal 82", "direction": "entrance"},
{"entrance": "Guard House 1 Dance Fox Exit", "exit": "Shop Portal 83", "direction": "entrance"},
{"entrance": "Guard House 1 Lower Exit", "exit": "Shop Portal 84", "direction": "entrance"},
{"entrance": "Guard House 1 Upper Forest Exit", "exit": "Shop Portal 85", "direction": "entrance"},
{"entrance": "Guard House 1 to Guard Captain Room", "exit": "Shop Portal 86",
"direction": "entrance"},
{"entrance": "Guard House 2 Lower Exit", "exit": "Shop Portal 87", "direction": "entrance"},
{"entrance": "Guard House 2 Upper Exit", "exit": "Shop Portal 88", "direction": "entrance"},
{"entrance": "Guard Captain Room Non-Gate Exit", "exit": "Shop Portal 89", "direction": "entrance"},
{"entrance": "Guard Captain Room Gate Exit", "exit": "Shop Portal 90", "direction": "entrance"},
{"entrance": "Well Ladder Exit", "exit": "Shop Portal 91", "direction": "entrance"},
{"entrance": "Well to Well Boss", "exit": "Shop Portal 92", "direction": "entrance"},
{"entrance": "Well Exit towards Furnace", "exit": "Shop Portal 93", "direction": "entrance"},
{"entrance": "Well Boss to Well", "exit": "Shop Portal 94", "direction": "entrance"},
{"entrance": "Checkpoint to Dark Tomb", "exit": "Shop Portal 95", "direction": "entrance"},
{"entrance": "Dark Tomb to Overworld", "exit": "Shop Portal 96", "direction": "entrance"},
{"entrance": "Dark Tomb to Furnace", "exit": "Shop Portal 97", "direction": "entrance"},
{"entrance": "Dark Tomb to Checkpoint", "exit": "Shop Portal 98", "direction": "entrance"},
{"entrance": "West Garden Exit near Hero's Grave", "exit": "Shop Portal 99",
"direction": "entrance"},
{"entrance": "West Garden to Magic Dagger House", "exit": "Shop Portal 100",
"direction": "entrance"},
{"entrance": "West Garden Exit after Boss", "exit": "Shop Portal 101", "direction": "entrance"},
{"entrance": "West Garden Shop", "exit": "Shop Portal 102", "direction": "entrance"},
{"entrance": "West Garden Laurels Exit", "exit": "Shop Portal 103", "direction": "entrance"},
{"entrance": "West Garden Hero's Grave", "exit": "Shop Portal 104", "direction": "entrance"},
{"entrance": "West Garden to Far Shore", "exit": "Shop Portal 105", "direction": "entrance"},
{"entrance": "Magic Dagger House Exit", "exit": "Shop Portal 106", "direction": "entrance"},
{"entrance": "Fortress Courtyard to Fortress Grave Path Lower", "exit": "Shop Portal 107",
"direction": "entrance"},
{"entrance": "Fortress Courtyard to Fortress Grave Path Upper", "exit": "Shop Portal 108",
"direction": "entrance"},
{"entrance": "Fortress Courtyard to Fortress Interior", "exit": "Shop Portal 109",
"direction": "entrance"},
{"entrance": "Fortress Courtyard to East Fortress", "exit": "Shop Portal 110",
"direction": "entrance"},
{"entrance": "Fortress Courtyard to Beneath the Vault", "exit": "Shop Portal 111",
"direction": "entrance"},
{"entrance": "Fortress Courtyard to Forest Belltower", "exit": "Shop Portal 112",
"direction": "entrance"},
{"entrance": "Fortress Courtyard to Overworld", "exit": "Shop Portal 113", "direction": "entrance"},
{"entrance": "Fortress Courtyard Shop", "exit": "Shop Portal 114", "direction": "entrance"},
{"entrance": "Beneath the Vault to Fortress Interior", "exit": "Shop Portal 115",
"direction": "entrance"},
{"entrance": "Beneath the Vault to Fortress Courtyard", "exit": "Shop Portal 116",
"direction": "entrance"},
{"entrance": "Fortress Interior Main Exit", "exit": "Shop Portal 117", "direction": "entrance"},
{"entrance": "Fortress Interior to Beneath the Earth", "exit": "Shop Portal 118",
"direction": "entrance"},
{"entrance": "Fortress Interior to Siege Engine Arena", "exit": "Shop Portal 119",
"direction": "entrance"},
{"entrance": "Fortress Interior Shop", "exit": "Shop Portal 120", "direction": "entrance"},
{"entrance": "Fortress Interior to East Fortress Upper", "exit": "Shop Portal 121",
"direction": "entrance"},
{"entrance": "Fortress Interior to East Fortress Lower", "exit": "Shop Portal 122",
"direction": "entrance"},
{"entrance": "East Fortress to Interior Lower", "exit": "Shop Portal 123", "direction": "entrance"},
{"entrance": "East Fortress to Courtyard", "exit": "Shop Portal 124", "direction": "entrance"},
{"entrance": "East Fortress to Interior Upper", "exit": "Shop Portal 125", "direction": "entrance"},
{"entrance": "Fortress Grave Path Lower Exit", "exit": "Shop Portal 126", "direction": "entrance"},
{"entrance": "Fortress Hero's Grave", "exit": "Shop Portal 127", "direction": "entrance"},
{"entrance": "Fortress Grave Path Upper Exit", "exit": "Shop Portal 128", "direction": "entrance"},
{"entrance": "Fortress Grave Path Dusty Entrance", "exit": "Shop Portal 129",
"direction": "entrance"},
{"entrance": "Dusty Exit", "exit": "Shop Portal 130", "direction": "entrance"},
{"entrance": "Siege Engine Arena to Fortress", "exit": "Shop Portal 131", "direction": "entrance"},
{"entrance": "Fortress to Far Shore", "exit": "Shop Portal 132", "direction": "entrance"},
{"entrance": "Atoll Upper Exit", "exit": "Shop Portal 133", "direction": "entrance"},
{"entrance": "Atoll Lower Exit", "exit": "Shop Portal 134", "direction": "entrance"},
{"entrance": "Atoll Shop", "exit": "Shop Portal 135", "direction": "entrance"},
{"entrance": "Atoll to Far Shore", "exit": "Shop Portal 136", "direction": "entrance"},
{"entrance": "Atoll Statue Teleporter", "exit": "Shop Portal 137", "direction": "entrance"},
{"entrance": "Frog Stairs Eye Entrance", "exit": "Shop Portal 138", "direction": "entrance"},
{"entrance": "Frog Stairs Mouth Entrance", "exit": "Shop Portal 139", "direction": "entrance"},
{"entrance": "Frog Stairs Eye Exit", "exit": "Shop Portal 140", "direction": "entrance"},
{"entrance": "Frog Stairs Mouth Exit", "exit": "Shop Portal 141", "direction": "entrance"},
{"entrance": "Frog Stairs to Frog's Domain's Entrance", "exit": "Shop Portal 142",
"direction": "entrance"},
{"entrance": "Frog Stairs to Frog's Domain's Exit", "exit": "Shop Portal 143",
"direction": "entrance"},
{"entrance": "Frog's Domain Ladder Exit", "exit": "Shop Portal 144", "direction": "entrance"},
{"entrance": "Frog's Domain Orb Exit", "exit": "Shop Portal 145", "direction": "entrance"},
{"entrance": "Library Exterior Tree", "exit": "Shop Portal 146", "direction": "entrance"},
{"entrance": "Library Exterior Ladder", "exit": "Shop Portal 147", "direction": "entrance"},
{"entrance": "Library Hall Bookshelf Exit", "exit": "Shop Portal 148", "direction": "entrance"},
{"entrance": "Library Hero's Grave", "exit": "Shop Portal 149", "direction": "entrance"},
{"entrance": "Library Hall to Rotunda", "exit": "Shop Portal 150", "direction": "entrance"},
{"entrance": "Library Rotunda Lower Exit", "exit": "Shop Portal 151", "direction": "entrance"},
{"entrance": "Library Rotunda Upper Exit", "exit": "Shop Portal 152", "direction": "entrance"},
{"entrance": "Library Lab to Rotunda", "exit": "Shop Portal 153", "direction": "entrance"},
{"entrance": "Library to Far Shore", "exit": "Shop Portal 154", "direction": "entrance"},
{"entrance": "Library Lab to Librarian Arena", "exit": "Shop Portal 155", "direction": "entrance"},
{"entrance": "Librarian Arena Exit", "exit": "Shop Portal 156", "direction": "entrance"},
{"entrance": "Stairs to Top of the Mountain", "exit": "Shop Portal 157", "direction": "entrance"},
{"entrance": "Mountain to Quarry", "exit": "Shop Portal 158", "direction": "entrance"},
{"entrance": "Mountain to Overworld", "exit": "Shop Portal 159", "direction": "entrance"},
{"entrance": "Top of the Mountain Exit", "exit": "Shop Portal 160", "direction": "entrance"},
{"entrance": "Quarry Connector to Overworld", "exit": "Shop Portal 161", "direction": "entrance"},
{"entrance": "Quarry Connector to Quarry", "exit": "Shop Portal 162", "direction": "entrance"},
{"entrance": "Quarry to Overworld Exit", "exit": "Shop Portal 163", "direction": "entrance"},
{"entrance": "Quarry Shop", "exit": "Shop Portal 164", "direction": "entrance"},
{"entrance": "Quarry to Monastery Front", "exit": "Shop Portal 165", "direction": "entrance"},
{"entrance": "Quarry to Monastery Back", "exit": "Shop Portal 166", "direction": "entrance"},
{"entrance": "Quarry to Mountain", "exit": "Shop Portal 167", "direction": "entrance"},
{"entrance": "Quarry to Ziggurat", "exit": "Shop Portal 168", "direction": "entrance"},
{"entrance": "Quarry to Far Shore", "exit": "Shop Portal 169", "direction": "entrance"},
{"entrance": "Monastery Rear Exit", "exit": "Shop Portal 170", "direction": "entrance"},
{"entrance": "Monastery Front Exit", "exit": "Shop Portal 171", "direction": "entrance"},
{"entrance": "Monastery Hero's Grave", "exit": "Shop Portal 172", "direction": "entrance"},
{"entrance": "Ziggurat Entry Hallway to Ziggurat Upper", "exit": "Shop Portal 173",
"direction": "entrance"},
{"entrance": "Ziggurat Entry Hallway to Quarry", "exit": "Shop Portal 174", "direction": "entrance"},
{"entrance": "Ziggurat Upper to Ziggurat Entry Hallway", "exit": "Shop Portal 175",
"direction": "entrance"},
{"entrance": "Ziggurat Upper to Ziggurat Tower", "exit": "Shop Portal 176", "direction": "entrance"},
{"entrance": "Ziggurat Tower to Ziggurat Upper", "exit": "Shop Portal 177", "direction": "entrance"},
{"entrance": "Ziggurat Tower to Ziggurat Lower", "exit": "Shop Portal 178", "direction": "entrance"},
{"entrance": "Ziggurat Lower to Ziggurat Tower", "exit": "Shop Portal 179", "direction": "entrance"},
{"entrance": "Ziggurat Portal Room Entrance", "exit": "Shop Portal 180", "direction": "entrance"},
{"entrance": "Ziggurat Portal Room Exit", "exit": "Shop Portal 181", "direction": "entrance"},
{"entrance": "Ziggurat to Far Shore", "exit": "Shop Portal 182", "direction": "entrance"},
{"entrance": "Swamp Lower Exit", "exit": "Shop Portal 183", "direction": "entrance"},
{"entrance": "Swamp to Cathedral Main Entrance", "exit": "Shop Portal 184", "direction": "entrance"},
{"entrance": "Swamp to Cathedral Secret Legend Room Entrance", "exit": "Shop Portal 185",
"direction": "entrance"},
{"entrance": "Swamp to Gauntlet", "exit": "Shop Portal 186", "direction": "entrance"},
{"entrance": "Swamp Shop", "exit": "Shop Portal 187", "direction": "entrance"},
{"entrance": "Swamp Upper Exit", "exit": "Shop Portal 188", "direction": "entrance"},
{"entrance": "Swamp Hero's Grave", "exit": "Shop Portal 189", "direction": "entrance"},
{"entrance": "Cathedral Main Exit", "exit": "Shop Portal 190", "direction": "entrance"},
{"entrance": "Cathedral Elevator", "exit": "Shop Portal 191", "direction": "entrance"},
{"entrance": "Cathedral Secret Legend Room Exit", "exit": "Shop Portal 192",
"direction": "entrance"},
{"entrance": "Gauntlet to Swamp", "exit": "Shop Portal 193", "direction": "entrance"},
{"entrance": "Gauntlet Elevator", "exit": "Shop Portal 194", "direction": "entrance"},
{"entrance": "Gauntlet Shop", "exit": "Shop Portal 195", "direction": "entrance"},
{"entrance": "Hero's Grave to Fortress", "exit": "Shop Portal 196", "direction": "entrance"},
{"entrance": "Hero's Grave to Monastery", "exit": "Shop Portal 197", "direction": "entrance"},
{"entrance": "Hero's Grave to West Garden", "exit": "Shop Portal 198", "direction": "entrance"},
{"entrance": "Hero's Grave to East Forest", "exit": "Shop Portal 199", "direction": "entrance"},
{"entrance": "Hero's Grave to Library", "exit": "Shop Portal 200", "direction": "entrance"},
{"entrance": "Hero's Grave to Swamp", "exit": "Shop Portal 201", "direction": "entrance"},
{"entrance": "Far Shore to West Garden", "exit": "Shop Portal 202", "direction": "entrance"},
{"entrance": "Far Shore to Library", "exit": "Shop Portal 203", "direction": "entrance"},
{"entrance": "Far Shore to Quarry", "exit": "Shop Portal 204", "direction": "entrance"},
{"entrance": "Far Shore to East Forest", "exit": "Shop Portal 205", "direction": "entrance"},
{"entrance": "Far Shore to Fortress", "exit": "Shop Portal 206", "direction": "entrance"},
{"entrance": "Far Shore to Atoll", "exit": "Shop Portal 207", "direction": "entrance"},
{"entrance": "Far Shore to Ziggurat", "exit": "Shop Portal 208", "direction": "entrance"},
{"entrance": "Far Shore to Heir", "exit": "Shop Portal 209", "direction": "entrance"},
{"entrance": "Far Shore to Town", "exit": "Shop Portal 210", "direction": "entrance"},
{"entrance": "Far Shore to Spawn", "exit": "Shop Portal 211", "direction": "entrance"},
{"entrance": "Heir Arena Exit", "exit": "Shop Portal 212", "direction": "entrance"},
{"entrance": "Purgatory Bottom Exit", "exit": "Shop Portal 213", "direction": "entrance"},
{"entrance": "Purgatory Top Exit", "exit": "Shop Portal 214", "direction": "entrance"},
{"entrance": "Shop Portal 215", "exit": "Shop Portal 216", "direction": "entrance"},
{"entrance": "Shop Portal 217", "exit": "Shop Portal 218", "direction": "entrance"},
{"entrance": "Shop Portal 219", "exit": "Shop Portal 220", "direction": "entrance"},
{"entrance": "Shop Portal 221", "exit": "Shop Portal 222", "direction": "entrance"},
{"entrance": "Shop Portal 223", "exit": "Shop Portal 224", "direction": "entrance"},
{"entrance": "Shop Portal 225", "exit": "Shop Portal 226", "direction": "entrance"},
{"entrance": "Shop Portal 227", "exit": "Shop Portal 228", "direction": "entrance"},
{"entrance": "Shop Portal 229", "exit": "Shop Portal 230", "direction": "entrance"},
]}

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from Options import ( from Options import (
Choice, Choice,
@@ -18,6 +19,8 @@ from .Locations import DUNGEON_NAMES
class Dungeons(DefaultOnToggle): class Dungeons(DefaultOnToggle):
""" """
This controls whether dungeon locations are randomized. This controls whether dungeon locations are randomized.
This means the items found in dungeons will be randomized, not that the entrances to dungeons will be randomized.
""" """
display_name = "Dungeons" display_name = "Dungeons"
@@ -752,6 +755,68 @@ class TWWOptions(PerGameCommonOptions):
remove_music: RemoveMusic remove_music: RemoveMusic
death_link: DeathLink death_link: DeathLink
def get_output_dict(self) -> dict[str, Any]:
"""
Returns a dictionary of option name to value to be placed in
the output APTWW file.
:return: Dictionary of option name to value for the output file.
"""
# Note: these options' values must be able to be passed through
# `yaml.safe_dump`.
return self.as_dict(
"progression_dungeons",
"progression_tingle_chests",
"progression_dungeon_secrets",
"progression_puzzle_secret_caves",
"progression_combat_secret_caves",
"progression_savage_labyrinth",
"progression_great_fairies",
"progression_short_sidequests",
"progression_long_sidequests",
"progression_spoils_trading",
"progression_minigames",
"progression_battlesquid",
"progression_free_gifts",
"progression_mail",
"progression_platforms_rafts",
"progression_submarines",
"progression_eye_reef_chests",
"progression_big_octos_gunboats",
"progression_triforce_charts",
"progression_treasure_charts",
"progression_expensive_purchases",
"progression_island_puzzles",
"progression_misc",
"randomize_mapcompass",
"randomize_smallkeys",
"randomize_bigkeys",
"sword_mode",
"required_bosses",
"num_required_bosses",
"chest_type_matches_contents",
"hero_mode",
"logic_obscurity",
"logic_precision",
"randomize_dungeon_entrances",
"randomize_secret_cave_entrances",
"randomize_miniboss_entrances",
"randomize_boss_entrances",
"randomize_secret_cave_inner_entrances",
"randomize_fairy_fountain_entrances",
"mix_entrances",
"randomize_enemies",
"randomize_starting_island",
"randomize_charts",
"swift_sail",
"instant_text_boxes",
"reveal_full_sea_chart",
"add_shortcut_warps_between_dungeons",
"skip_rematch_bosses",
"remove_music",
)
tww_option_groups: list[OptionGroup] = [ tww_option_groups: list[OptionGroup] = [
OptionGroup( OptionGroup(

View File

@@ -462,7 +462,7 @@ class TWWWorld(World):
"Seed": multiworld.seed_name, "Seed": multiworld.seed_name,
"Slot": player, "Slot": player,
"Name": self.player_name, "Name": self.player_name,
"Options": self.options.as_dict(*self.options_dataclass.type_hints), "Options": self.options.get_output_dict(),
"Required Bosses": self.boss_reqs.required_boss_item_locations, "Required Bosses": self.boss_reqs.required_boss_item_locations,
"Locations": {}, "Locations": {},
"Entrances": {}, "Entrances": {},

View File

@@ -19,17 +19,18 @@ a yellow Rupee, which includes a message that the location is not randomized.
## What is the goal of The Wind Waker? ## What is the goal of The Wind Waker?
Reach and defeat Ganondorf atop Ganon's Tower. This will require all eight shards of the Triforce of Courage, the Reach and defeat Ganondorf atop Ganon's Tower. This will require all eight shards of the Triforce of Courage, the
fully-powered Master Sword (unless it's swordless mode), Light Arrows, and any other items necessary to reach Ganondorf. fully-powered Master Sword (unless it's swords optional or swordless mode), Light Arrows, and any other items necessary
to reach Ganondorf.
## What does another world's item look like in TWW? ## What does another world's item look like in TWW?
Items belonging to other non-TWW worlds are represented by Father's Letter (the letter Medli gives you to give to Items belonging to other non-TWW worlds are represented by Father's Letter (the letter Medli gives you to give to
Komali), an unused item in the randomizer. Komali), an unused item in the randomizer.
## When the player receives an item, what happens? ## What happens when the player receives an item?
When the player receives an item, it will automatically be added to Link's inventory. Unlike many other Zelda When the player receives an item, it will automatically be added to Link's inventory. Link **will not** hold the item
randomizers, Link **will not** hold the item above his head. above his head like many other Zelda randomizers.
## I need help! What do I do? ## I need help! What do I do?
@@ -37,16 +38,20 @@ Refer to the [FAQ](https://lagolunatic.github.io/wwrando/faq/) first. Then, try
[setup guide](/tutorial/The%20Wind%20Waker/setup/en). If you are still stuck, please ask in the Wind Waker channel in [setup guide](/tutorial/The%20Wind%20Waker/setup/en). If you are still stuck, please ask in the Wind Waker channel in
the Archipelago server. the Archipelago server.
## I opened the game in Dolphin, but I don't have any of my starting items!
You must connect to the multiworld room to receive any items, including your starting inventory.
## Known issues ## Known issues
- Randomized freestanding rupees, spoils, and bait will also be given to the player picking up the item. The item will - Randomized freestanding rupees, spoils, and bait will also be given to the player picking up the item. The item will
be sent properly, but the collecting player will receive an extra copy. be sent properly, but the collecting player will receive an extra copy.
- Demo items (items which are held over Link's head) which are **not** randomized, such as rupees from salvages from - Demo items (items held over Link's head) that are **not** randomized, such as rupees from salvages from random light
random light rings or rewards from minigames, will not work. rings or rewards from minigames, will not work.
- Item get messages for progressive items received on locations that send earlier than intended will be incorrect. This - Item get messages for progressive items received on locations that send earlier than intended will be incorrect. This
does not affect gameplay. does not affect gameplay.
- The Heart Piece count in item get messages will be off by one. This does not affect gameplay. - The Heart Piece count in item get messages will be off by one. This does not affect gameplay.
- It has been reported that item links can be buggy. Nothing game-breaking, but do be aware of it. - It has been reported that item links can be buggy. It is nothing game-breaking, but do be aware of it.
Feel free to report any other issues or suggest improvements in the Wind Waker channel in the Archipelago server! Feel free to report any other issues or suggest improvements in the Wind Waker channel in the Archipelago server!
@@ -76,14 +81,14 @@ A few presets are available on the [player options page](../player-options) for
The preset features 3 required bosses and hard obscurity difficulty, and while the list of enabled progression options The preset features 3 required bosses and hard obscurity difficulty, and while the list of enabled progression options
may seem intimidating, the preset also excludes several locations. may seem intimidating, the preset also excludes several locations.
- **Miniblins 2025**: These are (as close to as possible) the settings used in the WWR Racing Server's - **Miniblins 2025**: These are (as close to as possible) the settings used in the WWR Racing Server's
[2025 Season of Minblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8). This [2025 Season of Miniblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8). This
preset is great if you're new to Wind Waker! There aren't too many locations in the world, and you only need to preset is great if you're new to Wind Waker! There aren't too many locations in the world, and you only need to
complete two dungeons. You also start with many convenience items, such as double magic, a capacity upgrade for your complete two dungeons. You also start with many convenience items, such as double magic, a capacity upgrade for your
bow and bombs, and six hearts. bow and bombs, and six hearts.
- **Mixed Pools**: These are the settings used in the WWR Racing Server's - **Mixed Pools**: These are the settings used in the WWR Racing Server's
[Mixed Pools Co-op Tournament](https://docs.google.com/document/d/1YGPTtEgP978TIi0PUAD792OtZbE2jBQpI8XCAy63qpg). This [Mixed Pools Co-op Tournament](https://docs.google.com/document/d/1YGPTtEgP978TIi0PUAD792OtZbE2jBQpI8XCAy63qpg). This
preset features full entrance rando and includes many locations behind a randomized entrance. There are also a bunch preset features full entrance rando and includes most locations behind a randomized entrance. There are also many
of overworld locations, as these settings were intended to be played in a two-person co-op team. The preset also has 6 overworld locations, as these settings were intended to be played in a two-person co-op team. The preset also has 6
required bosses, but since entrance pools are randomized, the bosses could be found anywhere! Check your Sea Chart to required bosses, but since entrance pools are randomized, the bosses could be found anywhere! Check your Sea Chart to
find out which island the bosses are on. find out which island the bosses are on.
@@ -106,7 +111,7 @@ This randomizer would not be possible without the help from:
- CrainWWR: (multiworld and Dolphin memory assistance, additional programming) - CrainWWR: (multiworld and Dolphin memory assistance, additional programming)
- Cyb3R: (reference for `TWWClient`) - Cyb3R: (reference for `TWWClient`)
- DeamonHunter: (additional programming) - DeamonHunter: (additional programming)
- Dev5ter: (initial TWW AP implmentation) - Dev5ter: (initial TWW AP implementation)
- Gamma / SageOfMirrors: (additional programming) - Gamma / SageOfMirrors: (additional programming)
- LagoLunatic: (base randomizer, additional assistance) - LagoLunatic: (base randomizer, additional assistance)
- Lunix: (Linux support, additional programming) - Lunix: (Linux support, additional programming)

View File

@@ -5,11 +5,13 @@ If you're playing The Wind Waker, you must follow a few simple steps to get star
## Requirements ## Requirements
You'll need the following components to be able to play with The Wind Waker: You'll need the following components to be able to play The Wind Waker:
* Install [Dolphin Emulator](https://dolphin-emu.org/download/). **We recommend using the latest release.** * Install [Dolphin Emulator](https://dolphin-emu.org/download/). **We recommend using the latest release.**
* For Linux users, you can use the flatpak package * Linux users can use the flatpak package
[available on Flathub](https://flathub.org/apps/org.DolphinEmu.dolphin-emu). [available on Flathub](https://flathub.org/apps/org.DolphinEmu.dolphin-emu).
* The 2.5.0 version of the [TWW AP Randomizer Build](https://github.com/tanjo3/wwrando/releases/tag/ap_2.5.0). * The latest version of the [TWW AP Randomizer Build](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2).
* Please note that this build is **different** from the one the standalone randomizer uses. This build is
specifically for Archipelago.
* A The Wind Waker ISO (North American version), probably named "Legend of Zelda, The - The Wind Waker (USA).iso". * A The Wind Waker ISO (North American version), probably named "Legend of Zelda, The - The Wind Waker (USA).iso".
Optionally, you can also download: Optionally, you can also download:
@@ -26,17 +28,17 @@ world. Once you're happy with your settings, provide the room host with your YAM
## Connecting to a Room ## Connecting to a Room
The multiworld host will provide you a link to download your `aptww` file or a zip file containing everyone's files. The The multiworld host will provide you a link to download your APTWW file or a zip file containing everyone's files. The
`aptww` file should be named `P#_<name>_XXXXX.aptww`, where `#` is your player ID, `<name>` is your player name, and APTWW file should be named `P#_<name>_XXXXX.aptww`, where `#` is your player ID, `<name>` is your player name, and
`XXXXX` is the room ID. The host should also provide you with the room's server name and port number. `XXXXX` is the room ID. The host should also provide you with the room's server name and port number.
Once you do, follow these steps to connect to the room: Once you're ready, follow these steps to connect to the room:
1. Run the TWW AP Randomizer Build. If this is the first time you've opened the randomizer, you'll need to specify the 1. Run the TWW AP Randomizer Build. If this is the first time you've opened the randomizer, you'll need to specify the
path to your The Wind Waker ISO and the output folder for the randomized ISO. These will be saved for the next time you path to your The Wind Waker ISO and the output folder for the randomized ISO. These will be saved for the next time you
open the program. open the program.
2. Modify any cosmetic convenience tweaks and player customization options as desired. 2. Modify any cosmetic convenience tweaks and player customization options as desired.
3. For the APTWW file, browse and locate the path to your `aptww` file. 3. For the APTWW file, browse and locate the path to your APTWW file.
4. Click `Randomize` at the bottom-right. This randomizes the ISO and puts it in the output folder you specified. The 4. Click `Randomize` at the bottom right. This randomizes the ISO and puts it in the output folder you specified. The
file will be named `TWW AP_YYYYY_P# (<name>).iso`, where `YYYYY` is the seed name, `#` is your player ID, and `<name>` file will be named `TWW AP_YYYYY_P# (<name>).iso`, where `YYYYY` is the seed name, `#` is your player ID, and `<name>`
is your player (slot) name. Verify that the values are correct for the multiworld. is your player (slot) name. Verify that the values are correct for the multiworld.
5. Open Dolphin and use it to open the randomized ISO. 5. Open Dolphin and use it to open the randomized ISO.
@@ -47,7 +49,7 @@ text client. If Dolphin is not already open, or you have yet to start a new file
on the website, this will be `archipelago.gg:<port>`, where `<port>` is the port number. If a game is hosted from the on the website, this will be `archipelago.gg:<port>`, where `<port>` is the port number. If a game is hosted from the
`ArchipelagoServer.exe` (without `.exe` on Linux), the port number will default to `38281` but may be changed in the `ArchipelagoServer.exe` (without `.exe` on Linux), the port number will default to `38281` but may be changed in the
`host.yaml`. `host.yaml`.
8. If you've opened a ROM corresponding to the multiworld to which you are connected, it should authenticate your slot 8. If you've opened an ISO corresponding to the multiworld to which you are connected, it should authenticate your slot
name automatically when you start a new save file. name automatically when you start a new save file.
## Troubleshooting ## Troubleshooting
@@ -55,13 +57,18 @@ name automatically when you start a new save file.
* Ensure you are running the same version of Archipelago on which the multiworld was generated. * Ensure you are running the same version of Archipelago on which the multiworld was generated.
* Ensure `tww.apworld` is not in your Archipelago installation's `custom_worlds` folder. * Ensure `tww.apworld` is not in your Archipelago installation's `custom_worlds` folder.
* Ensure you are using the correct randomizer build for the version of Archipelago you are using. The build should * Ensure you are using the correct randomizer build for the version of Archipelago you are using. The build should
provide an error message directing you to the correct version. You can also look at the release notes of TWW AP builds provide an error message directing you to the correct version. You can also look at the release notes of TWW AP builds
[here](https://github.com/tanjo3/wwrando/releases) to see which versions of Archipelago each build is compatible with. [here](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2) to see which versions of Archipelago each build is
compatible with.
* Do not run the Archipelago Launcher or Dolphin as an administrator on Windows.
* If you encounter issues with authenticating, ensure that the randomized ROM is open in Dolphin and corresponds to the * If you encounter issues with authenticating, ensure that the randomized ROM is open in Dolphin and corresponds to the
multiworld to which you are connecting. multiworld to which you are connecting.
* Ensure that you do not have any Dolphin cheats or codes enabled. Some cheats or codes can unexpectedly interfere with * Ensure that you do not have any Dolphin cheats or codes enabled. Some cheats or codes can unexpectedly interfere with
emulation and make troubleshooting errors difficult. emulation and make troubleshooting errors difficult.
* If you get an error message, ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` > * Ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` > `Configuration` > `Advanced`) is
`Configuration` > `Advanced`) is **disabled**. **disabled**.
* If the client cannot connect to Dolphin, ensure Dolphin is on the same drive as Archipelago. Having Dolphin on an
external drive has reportedly caused connection issues.
* Ensure the `Fallback Region` in Dolphin (under `Options` > `Configuration` > `General`) is set to `NTSC-U`.
* If you run with a custom GC boot menu, you'll need to skip it by going to `Options` > `Configuration` > `GameCube` * If you run with a custom GC boot menu, you'll need to skip it by going to `Options` > `Configuration` > `GameCube`
and checking `Skip Main Menu`. and checking `Skip Main Menu`.