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
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs:
# 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
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
@@ -65,6 +70,18 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
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
shell: bash
run: |
@@ -142,6 +159,16 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $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
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate

View File

@@ -11,6 +11,11 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs:
create-release:
runs-on: ubuntu-latest
@@ -26,11 +31,79 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# 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:
runs-on: ubuntu-22.04
needs: create-release
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -74,6 +147,14 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - 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
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:

View File

@@ -196,25 +196,11 @@ class CommonContext:
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._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(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
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]
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.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._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
@@ -356,7 +341,6 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self)
@@ -571,7 +555,6 @@ class CommonContext:
# DataPackage
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]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -580,33 +563,26 @@ class CommonContext:
needed_updates: typing.Set[str] = set()
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
remote_version: int = remote_date_package_versions.get(game, 0)
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)
continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
if remote_checksum != cached_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)
and remote_checksum == local_checksum):
if remote_checksum == local_checksum:
self.update_game(network_data_package["games"][game], game)
else:
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")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
if remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
@@ -616,7 +592,6 @@ class CommonContext:
def update_game(self, game_package: dict, game: str):
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.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
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))
# update data package
data_package_versions = args.get("datapackage_versions", {})
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'])

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:
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:

View File

@@ -84,12 +84,16 @@ def browse_files():
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, folder_path])
else:
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():

View File

@@ -33,7 +33,7 @@ from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
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):
@@ -52,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
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:
# Connector version
VERSION = 0x01
@@ -530,7 +514,9 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None:
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):
logging_pairs = [
@@ -543,8 +529,10 @@ class LinksAwakeningContext(CommonContext):
b = super().build()
if self.ctx.magpie_enabled:
button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
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)
return b
@@ -638,6 +626,11 @@ class LinksAwakeningContext(CommonContext):
"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
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
@@ -653,6 +646,13 @@ class LinksAwakeningContext(CommonContext):
sync_msg = [{'cmd': 'Sync'}]
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()
async def run_game_loop(self):
@@ -661,11 +661,7 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
for check in 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'])
self.add_linked_items(ladxr_checks)
async def 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]
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]]]] = {}

View File

@@ -1292,42 +1292,47 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
def as_dict(
self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case 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 option_names: Names of the options to get the values of.
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
: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."
option_results = {}
for option_name in option_names:
if option_name 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:
if option_name not in 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
@@ -1348,6 +1353,7 @@ class StartInventory(ItemDict):
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory):

View File

@@ -635,6 +635,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish
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())
/ 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)
if len(picks) > 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"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75:
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)"

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
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
early_item_name = "Sword"
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
* Pre-place items yourself, such as during `create_items`
* 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
```py
@@ -39,7 +47,8 @@ for _ in range(total_locations - len(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
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?
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:
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
* 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.
---
### 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
bsdiff4>=1.2.6
platformdirs>=4.3.6
certifi>=2025.1.31
certifi>=2025.4.26
cython>=3.0.12
cymem>=2.0.11
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.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):
# Items
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:
"""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.")
return self.multiworld.random.choice(tuple(self.item_name_to_id.keys()))
return self.random.choice(tuple(self.item_name_to_id.keys()))
@classmethod
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:
# 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))
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_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
set_rule(multiworld.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player))
if world.options.accessibility != 'full':
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, [], ['Hammer']],
["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, [], ['Flippers']],
@@ -38,7 +38,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - West Chest", False, [], ['Open Floodgate']],
["Swamp Palace - West Chest", False, [], ['Hammer']],
["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, [], ['Flippers']],

View File

@@ -9,7 +9,6 @@ import random
import re
import string
import subprocess
import sys
import time
import typing
@@ -17,15 +16,16 @@ from queue import Queue
import factorio_rcon
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled, get_base_parser
from MultiServer import mark_raw
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:
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.")
@@ -67,7 +67,7 @@ class FactorioContext(CommonContext):
items_handling = 0b111 # full remote
# 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):
super(FactorioContext, self).__init__(server_address, password)
@@ -133,7 +133,7 @@ class FactorioContext(CommonContext):
elif self.current_energy_link_value is None:
return "Standby"
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):
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:
# it's our deplete request
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
gained_text = format_SI_prefix(gained) + "J"
if gained:
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}")
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(
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)
@@ -439,9 +439,9 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
factorio_server_logger.info(msg)
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
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:
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:
logger.warning("It appears your mods are loaded from Appdata, "
"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(
random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_settings()
executable = options["factorio_options"]["executable"]
settings: FactorioSettings = get_settings().factorio_options
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 \
else options["factorio_options"].get("server_settings", None)
else getattr(settings, "server_settings", None)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password)
@@ -535,12 +541,8 @@ def launch():
if server_settings:
server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
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"])
initial_filter_item_sends = bool(settings.filter_item_sends)
initial_bridge_chat_out = bool(settings.bridge_chat_out)
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.")

View File

@@ -5,7 +5,6 @@ import logging
import typing
import Utils
import settings
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld
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, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
fluids, stacking_items, valid_ingredients, progressive_rows
from .settings import FactorioSettings
def launch_client():
@@ -27,30 +27,7 @@ def launch_client():
launch_component(launch, name="FactorioClient")
components.append(Component("Factorio Client", "FactorioClient", 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
components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT))
class FactorioWeb(WebWorld):
@@ -115,6 +92,7 @@ class Factorio(World):
settings: typing.ClassVar[FactorioSettings]
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
want_progressives: dict[str, bool] = collections.defaultdict(lambda: False)
def __init__(self, world, player: int):
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.tech_mix = self.options.tech_cost_mix.value
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):
player = self.player
@@ -201,9 +181,6 @@ class Factorio(World):
range(getattr(self.options,
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)
special_index = {"automation": 0,
"logistics": 1,
@@ -218,7 +195,7 @@ class Factorio(World):
for tech_name in base_tech_table:
if tech_name not in self.removed_technologies:
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
tech_item = self.create_item(item_name)
index = special_index.get(tech_name, None)
@@ -233,6 +210,12 @@ class Factorio(World):
loc.place_locked_item(tech_item)
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):
player = self.player
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.
4. If you want to exit the virtual environment, run the command `deactivate`.
## 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.
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.
3. Your client should now be running and rom created (where applicable).
1. Run the command `python3 Launcher.py`.
2. If your game doesn't have a patch file, just click the desired client in the right side column.
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
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.

View File

@@ -151,8 +151,7 @@ class ItemTracker:
def __init__(self, gameboy) -> None:
self.gameboy = gameboy
self.loadItems()
pass
extraItems = {}
self.extraItems = {}
async def readRamByte(self, 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
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
1. Download the latest TheMessengerRandomizerAP.zip from
[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
* 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
1. Download the latest TheMessengerRandomizerAP.zip from
[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
* You cannot have both the non-AP randomizer and the AP randomizer installed at the same time
## 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
2. Navigate to `Options > Archipelago Options`
3. Enter connection info using the relevant option buttons
* **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
website.
* 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.
* **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
website.
* 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.
4. Select the `Connect to Archipelago` button
5. Navigate to save file selection
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

View File

@@ -62,7 +62,7 @@ chunksanity_starting_chunks: typing.List[str] = [
ItemNames.South_Of_Varrock,
ItemNames.Central_Varrock,
ItemNames.Varrock_Palace,
ItemNames.East_Of_Varrock,
ItemNames.Lumberyard,
ItemNames.West_Varrock,
ItemNames.Edgeville,
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.
data_repository_address = "https://raw.githubusercontent.com/digiholic/osrs-archipelago-logic/"
# 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__":
import sys
@@ -26,98 +28,167 @@ if __name__ == "__main__":
def load_location_csv():
this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "locations_generated.py"), 'w+') as locPyFile:
locPyFile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
locPyFile.write("from ..Locations import LocationRow, SkillRequirement\n")
locPyFile.write("\n")
locPyFile.write("location_rows = [\n")
with open(os.path.join(this_dir, "locations_generated.py"), 'w+') as loc_py_file:
loc_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
loc_py_file.write("from ..Locations import LocationRow, SkillRequirement\n")
loc_py_file.write("\n")
loc_py_file.write("location_rows = [\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/locations.csv") as req:
locations_reader = csv.reader(req.text.splitlines())
for row in locations_reader:
row_line = "LocationRow("
row_line += str_format(row[0])
row_line += str_format(row[1].lower())
if debug:
with open(os.path.join(this_dir, "locations.csv"), "r") as loc_file:
locations_reader = csv.reader(loc_file.read().splitlines())
parse_loc_file(loc_py_file, locations_reader)
else:
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(", ")
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 += "], "
def parse_loc_file(loc_py_file, locations_reader):
for row in locations_reader:
# Skip the header row, if present
if row[0] == "Location Name":
continue
row_line = "LocationRow("
row_line += str_format(row[0])
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():
this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "regions_generated.py"), 'w+') as regPyFile:
regPyFile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
regPyFile.write("from ..Regions import RegionRow\n")
regPyFile.write("\n")
regPyFile.write("region_rows = [\n")
with open(os.path.join(this_dir, "regions_generated.py"), 'w+') as reg_py_file:
reg_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
reg_py_file.write("from ..Regions import RegionRow\n")
reg_py_file.write("\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():
this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "resources_generated.py"), 'w+') as resPyFile:
resPyFile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
resPyFile.write("from ..Regions import ResourceRow\n")
resPyFile.write("\n")
resPyFile.write("resource_rows = [\n")
with open(os.path.join(this_dir, "resources_generated.py"), 'w+') as res_py_file:
res_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
res_py_file.write("from ..Regions import ResourceRow\n")
res_py_file.write("\n")
res_py_file.write("resource_rows = [\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/resources.csv") as req:
resource_reader = csv.reader(req.text.splitlines())
for row in resource_reader:
name = row[0].replace("'", "\\'")
row_line = f"ResourceRow('{name}')"
resPyFile.write(f"\t{row_line},\n")
resPyFile.write("]\n")
if debug:
with open(os.path.join(this_dir, "resources.csv"), "r") as region_file:
regions_reader = csv.reader(region_file.read().splitlines())
parse_resources_file(res_py_file, regions_reader)
else:
print("Loading: " + data_repository_address + "/" + data_csv_tag + "/resources.csv")
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():
this_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(this_dir, "items_generated.py"), 'w+') as itemPyfile:
itemPyfile.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
itemPyfile.write("from BaseClasses import ItemClassification\n")
itemPyfile.write("from ..Items import ItemRow\n")
itemPyfile.write("\n")
itemPyfile.write("item_rows = [\n")
with open(os.path.join(this_dir, "items_generated.py"), 'w+') as item_py_file:
item_py_file.write('"""\nThis file was auto generated by LogicCSVToPython.py\n"""\n')
item_py_file.write("from BaseClasses import ItemClassification\n")
item_py_file.write("from ..Items import ItemRow\n")
item_py_file.write("\n")
item_py_file.write("item_rows = [\n")
with requests.get(data_repository_address + "/" + data_csv_tag + "/items.csv") as req:
item_reader = csv.reader(req.text.splitlines())
for row in item_reader:
row_line = "ItemRow("
row_line += str_format(row[0])
row_line += f"{row[1]}, "
if debug:
with open(os.path.join(this_dir, "items.csv"), "r") as region_file:
regions_reader = csv.reader(region_file.read().splitlines())
parse_item_file(item_py_file, regions_reader)
else:
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")
itemPyfile.write("]\n")
def parse_item_file(item_py_file, item_reader):
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:
@@ -128,7 +199,7 @@ if __name__ == "__main__":
def str_list_to_py(str_list) -> str:
ret_str = "["
for s in str_list:
ret_str += f"'{s}', "
ret_str += str_format(s)
ret_str += "]"
return ret_str

View File

@@ -10,7 +10,7 @@ item_rows = [
ItemRow('Area: HAM Hideout', 1, ItemClassification.progression),
ItemRow('Area: Lumbridge Farms', 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: Varrock Palace', 1, ItemClassification.progression),
ItemRow('Area: West Varrock', 1, ItemClassification.progression),
@@ -37,7 +37,58 @@ item_rows = [
ItemRow('Progressive Armor', 6, ItemClassification.progression),
ItemRow('Progressive Weapons', 6, ItemClassification.progression),
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 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: 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: 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: Misthalin Mystery', 'quest', ['Lumbridge Swamp', ], [], [], 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: 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('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 "Protect Item" Prayer', 'prayer', [], [SkillRequirement('Prayer', 25), ], [], 2),
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 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 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('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 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('Cut a Sapphire', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 20), ], [], 0),
LocationRow('Cut an Emerald', 'crafting', ['Chisel', ], [SkillRequirement('Crafting', 27), ], [], 0),
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('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('Crush a Barronite Deposit', 'mining', ['Camdozaal', ], [SkillRequirement('Mining', 14), ], [], 0),
LocationRow('Mine Silver', 'mining', ['Silver Ore', ], [SkillRequirement('Mining', 20), ], [], 0),
LocationRow('Mine Coal', 'mining', ['Coal Ore', ], [SkillRequirement('Mining', 30), ], [], 2),
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 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 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 a Trout', 'fishing', ['Fly Fishing Spot', ], [SkillRequirement('Fishing', 20), ], [], 0),
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('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('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 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 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('Cut an Oak Log', 'woodcutting', ['Oak Tree', ], [SkillRequirement('Woodcutting', 15), ], [], 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 a Goblin', 'combat', ['Goblin', ], [SkillRequirement('Combat', 2), ], [], 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 Obor', 'combat', ['Edgeville', ], [SkillRequirement('Combat', 106), ], [], 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('Combat Level 5', 'general', [], [], [], 0),
LocationRow('Total XP 10,000', 'general', [], [], [], 0),
LocationRow('Total Level 50', 'general', [], [], [], 0),
LocationRow('Reach a Level 20', 'general', [], [], [], 0),
LocationRow('Total XP 25,000', 'general', [], [], [], 0),
LocationRow('Total Level 100', 'general', [], [], [], 0),
LocationRow('Total XP 50,000', 'general', [], [], [], 0),
LocationRow('Combat Level 15', 'general', [], [], [], 0),
LocationRow('Total Level 150', 'general', [], [], [], 2),
LocationRow('Reach a Level 30', 'general', [], [], [], 2),
LocationRow('Total XP 75,000', 'general', [], [], [], 2),
LocationRow('Combat Level 25', 'general', [], [], [], 2),
LocationRow('Total XP 100,000', 'general', [], [], [], 6),
LocationRow('Total Level 200', 'general', [], [], [], 6),
LocationRow('Reach a Level 40', 'general', [], [], [], 6),
LocationRow('Total XP 125,000', 'general', [], [], [], 6),
LocationRow('Combat Level 30', 'general', [], [], [], 10),
LocationRow('Total Level 250', 'general', [], [], [], 10),
@@ -103,6 +130,28 @@ location_rows = [
LocationRow('Open a Simple Lockbox', 'general', ['Camdozaal', ], [], [], 0),
LocationRow('Open an Elaborate 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: Demon Slayer', '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
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 Swamp', 'Area: Lumbridge Swamp', ['Lumbridge', 'HAM Hideout', ], ['Bronze Ores', 'Coal Ore', 'Shrimp Spot', 'Meat', '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', 'Big Bones', 'Duck', ]),
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('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('East Varrock', 'Area: East Varrock', ['Wilderness', 'South of Varrock', 'Central Varrock', 'Varrock Palace', ], ['Guard', ]),
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('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('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('Lumberyard', 'Area: Lumberyard', ['Wilderness', 'South of Varrock', 'Central Varrock', 'Varrock Palace', ], ['Guard', 'Bar', ]),
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', '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('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('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', ], ['', ]),
@@ -27,21 +27,21 @@ region_rows = [
RegionRow('Ice Mountain', 'Area: Ice Mountain', ['Wilderness', 'Monastery', 'Dwarven Mines', 'Camdozaal*', ], ['', ]),
RegionRow('Camdozaal', 'Area: Ice Mountain', ['Ice Mountain', ], ['Clay Ore', ]),
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 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', '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', '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('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('Karamja', 'Area: Karamja', ['Karamja Docks', 'Crandor', ], ['Gold Ore', 'Lobster Spot', 'Bowl', 'Cake Tin', 'Deadly Red Spider', 'Imps', ]),
RegionRow('Crandor', 'Area: Crandor', ['Karamja', 'Port Sarim', ], ['Coal Ore', 'Gold Ore', 'Moss Giant', 'Lesser Demon', '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', 'Red Spider Eggs', ]),
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('Crafting Guild Peninsula', 'Area: Crafting Guild', ['Falador Farms', 'Rimmington', ], ['', ]),
RegionRow('Crafting Guild Outskirts', 'Area: Crafting Guild', ['Falador Farms', 'Crafting Guild', ], ['Sheep', 'Willow Tree', 'Oak Tree', ]),
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', 'Makeover', ]),
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('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('Citharede Abbey', 'Area: Citharede Abbey', ['Al Kharid', ], ['Iron Ore', 'Coal Ore', 'Anvil', 'Hill Giant', 'Nature Runes', 'Law Runes', ]),
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('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', ['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('Onion'),
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"
Central_Varrock = "Area: Central Varrock"
Varrock_Palace = "Area: Varrock Palace"
East_Of_Varrock = "Area: East Varrock"
Lumberyard = "Area: Lumberyard"
West_Varrock = "Area: West Varrock"
Edgeville = "Area: Edgeville"
Barbarian_Village = "Area: Barbarian Village"
@@ -94,8 +94,8 @@ class ItemNames(str, Enum):
Progressive_Weapons = "Progressive Weapons"
Progressive_Tools = "Progressive Tools"
Progressive_Range_Armor = "Progressive Ranged Armor"
Progressive_Range_Weapon = "Progressive Ranged Weapons"
Progressive_Magic = "Progressive Magic"
Progressive_Range_Weapon = "Progressive Ranged Weapon"
Progressive_Magic = "Progressive Magic Spell"
Lobsters = "10 Lobsters"
Swordfish = "5 Swordfish"
Energy_Potions = "10 Energy Potions"

View File

@@ -3,18 +3,19 @@ from dataclasses import dataclass
from Options import Choice, Toggle, Range, PerGameCommonOptions
MAX_COMBAT_TASKS = 16
MAX_PRAYER_TASKS = 3
MAX_MAGIC_TASKS = 4
MAX_RUNECRAFT_TASKS = 3
MAX_CRAFTING_TASKS = 5
MAX_MINING_TASKS = 5
MAX_SMITHING_TASKS = 4
MAX_FISHING_TASKS = 5
MAX_COOKING_TASKS = 5
MAX_FIREMAKING_TASKS = 2
MAX_PRAYER_TASKS = 5
MAX_MAGIC_TASKS = 7
MAX_RUNECRAFT_TASKS = 8
MAX_CRAFTING_TASKS = 11
MAX_MINING_TASKS = 6
MAX_SMITHING_TASKS = 5
MAX_FISHING_TASKS = 6
MAX_COOKING_TASKS = 6
MAX_FIREMAKING_TASKS = 3
MAX_WOODCUTTING_TASKS = 3
NON_QUEST_LOCATION_COUNT = 22
NON_QUEST_LOCATION_COUNT = 49
class StartingArea(Choice):
@@ -58,6 +59,31 @@ class ProgressiveTasks(Toggle):
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):
"""
The highest combat level of monster to possibly be assigned as a task.
@@ -472,6 +498,9 @@ class OSRSOptions(PerGameCommonOptions):
starting_area: StartingArea
brutal_grinds: BrutalGrinds
progressive_tasks: ProgressiveTasks
enable_duds: EnableDuds
dud_count: DudCount
enable_carepacks: EnableCarePacks
max_combat_level: MaxCombatLevel
max_combat_tasks: MaxCombatTasks
combat_task_weight: CombatTaskWeight

View File

@@ -212,11 +212,14 @@ def get_skill_rule(skill, level, player, options) -> CollectionRule:
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:
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:
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:
# 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))
@@ -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))
elif region_row.name == "Dwarven Mountain Pass" and outbound_region_name == "Anvil*":
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
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
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:
if not resource_region:
@@ -179,7 +179,7 @@ class OSRSWorld(World):
entrance.connect(self.region_name_to_data[resource_region])
else:
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()
@@ -195,7 +195,16 @@ class OSRSWorld(World):
generation_is_fake = hasattr(self.multiworld, "generation_is_fake") # UT specific override
locations_required = 0
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
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
@@ -232,6 +241,7 @@ class OSRSWorld(World):
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]
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:
rnd.shuffle(tasks_for_this_type)
else:
@@ -286,16 +296,36 @@ class OSRSWorld(World):
self.create_and_add_location(index)
def create_items(self) -> None:
filler_items = []
for item_row in item_rows:
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):
item = self.create_item(item_row.name)
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:
return self.random.choice(
[ItemNames.Progressive_Armor, ItemNames.Progressive_Weapons, ItemNames.Progressive_Magic,
ItemNames.Progressive_Tools, ItemNames.Progressive_Range_Armor, ItemNames.Progressive_Range_Weapon])
if self.options.enable_duds:
return self.random.choice([item for item in item_rows if item.progression == ItemClassification.filler])
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:
location_row = location_rows[row_index]

View File

@@ -173,7 +173,7 @@ class Overcooked2World(World):
game_item_count = len(self.itempool)
game_progression_count = 0
for item in self.itempool:
if item.classification == ItemClassification.progression:
if item.advancement:
game_progression_count += 1
game_progression_density = game_progression_count/game_item_count
@@ -189,7 +189,7 @@ class Overcooked2World(World):
total_progression_count = 0
for item in self.multiworld.itempool:
if item.classification == ItemClassification.progression:
if item.advancement:
total_progression_count += 1
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 .rom_addresses import rom_addresses
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 .encounters import process_pokemon_locations, process_trainer_data
from .rules import set_rules
@@ -33,12 +33,12 @@ class PokemonSettings(settings.Group):
"""File names of the Pokemon Red and Blue roms"""
description = "Pokemon Red (UE) ROM File"
copy_to = "Pokemon Red (UE) [S][!].gb"
md5s = [RedDeltaPatch.hash]
md5s = [PokemonRedProcedurePatch.hash]
class BlueRomFile(settings.UserFilePath):
description = "Pokemon Blue (UE) ROM File"
copy_to = "Pokemon Blue (UE) [S][!].gb"
md5s = [BlueDeltaPatch.hash]
md5s = [PokemonBlueProcedurePatch.hash]
red_rom_file: RedRomFile = RedRomFile(RedRomFile.copy_to)
blue_rom_file: BlueRomFile = BlueRomFile(BlueRomFile.copy_to)
@@ -113,16 +113,6 @@ class PokemonRedBlueWorld(World):
self.local_locs = []
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
def stage_generate_early(cls, multiworld: MultiWorld):

View File

@@ -1,9 +1,17 @@
from copy import deepcopy
import typing
from worlds.Files import APTokenTypes
from . import poke_data, logic
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":
return
pallet_map = {
@@ -31,12 +39,9 @@ def set_mon_palettes(world, random, data):
poke_data.evolves_from and poke_data.evolves_from[mon] != "Eevee"):
pallet = palettes[-1]
else: # completely_random or follow_evolutions and it is not an evolved form (except eeveelutions)
pallet = random.choice(list(pallet_map.values()))
pallet = world.random.choice(list(pallet_map.values()))
palettes.append(pallet)
address = rom_addresses["Mon_Palettes"]
for pallet in palettes:
data[address] = pallet
address += 1
patch.write_token(APTokenTypes.WRITE, rom_addresses["Mon_Palettes"], bytes(palettes))
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)
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:
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):
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
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],
[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],
@@ -7,29 +57,12 @@ disallowed2F = [[16, 2], [17, 2], [18, 2], [15, 5], [15, 6], [10, 10], [11, 10],
[11, 1]]
def randomize_rock_tunnel(data, random):
def randomize_rock_tunnel(patch: "PokemonRedProcedurePatch | PokemonBlueProcedurePatch", random: random.Random):
seed = random.randint(0, 999999999999999999)
random.seed(seed)
map1f = []
map2f = []
address = rom_addresses["Map_Rock_Tunnel1F"]
for y in range(0, 18):
row = []
for x in range(0, 20):
row.append(data[address])
address += 1
map1f.append(row)
address = rom_addresses["Map_Rock_TunnelB1F"]
for y in range(0, 18):
row = []
for x in range(0, 20):
row.append(data[address])
address += 1
map2f.append(row)
map1f = [row.copy() for row in layout1F]
map2f = [row.copy() for row in layout2F]
current_map = map1f
@@ -305,14 +338,6 @@ def randomize_rock_tunnel(data, random):
current_map = map2f
check_addable_block(map2f, disallowed2F)
address = rom_addresses["Map_Rock_Tunnel1F"]
for y in map1f:
for x in y:
data[address] = x
address += 1
address = rom_addresses["Map_Rock_TunnelB1F"]
for y in map2f:
for x in y:
data[address] = x
address += 1
return seed
patch.write_token(APTokenTypes.WRITE, rom_addresses["Map_Rock_Tunnel1F"], bytes([b for row in map1f for b in row]))
patch.write_token(APTokenTypes.WRITE, rom_addresses["Map_Rock_TunnelB1F"], bytes([b for row in map2f for b in row]))
return seed

View File

@@ -1,21 +1,66 @@
import os
import hashlib
import Utils
import bsdiff4
import pkgutil
from worlds.Files import APDeltaPatch
from .text import encode_text
import typing
import Utils
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes
from . import poke_data
from .items import item_table
from .text import encode_text
from .pokemon import set_mon_palettes
from .regions import PokemonRBWarp, map_ids, town_map_coords
from .rock_tunnel import randomize_rock_tunnel
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:
r = random.randint(0, 3)
if r == 0:
@@ -122,13 +167,13 @@ def write_quizzes(world, data, random):
elif q2 == 1:
if a:
state = random.choice(
['Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut',
'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas',
'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota',
'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Jersey', 'New Mexico',
'New York', 'North Carolina', 'North Dakota', 'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania',
'Rhode Island', 'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont',
'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming'])
["Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut",
"Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas",
"Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
"Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Jersey", "New Mexico",
"New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania",
"Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming"])
else:
state = "New Hampshire"
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>")
elif q == 14:
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:
fossil_level += random.choice((-5, 5))
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>")
answers = [random.randint(0, 1) for _ in range(6)]
questions = random.sample((range(0, 16)), 6)
question_texts = []
question_texts: list[bytearray] = []
for i, question in enumerate(questions):
question_texts.append(get_quiz(question, answers[i]))
for i, quiz in enumerate(["A", "B", "C", "D", "E", "F"]):
data[rom_addresses[f"Quiz_Answer_{quiz}"]] = int(not answers[i]) << 4 | (i + 1)
write_bytes(data, question_texts[i], rom_addresses[f"Text_Quiz_{quiz}"])
patch.write_token(APTokenTypes.WRITE, rom_addresses[f"Quiz_Answer_{quiz}"], bytes([int(not answers[i]) << 4 | (i + 1)]))
patch.write_token(APTokenTypes.WRITE, rom_addresses[f"Text_Quiz_{quiz}"], bytes(question_texts[i]))
def generate_output(world, output_directory: str):
random = world.random
def generate_output(world: "PokemonRedBlueWorld", output_directory: str):
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()
basemd5.update(data)
patch.write_token(APTokenTypes.WRITE, address, data)
pallet_connections = {entrance: world.multiworld.get_entrance(f"Pallet Town to {entrance}",
world.player).connected_region.name for
entrance in ["Player's House 1F", "Oak's Lab",
"Rival's House"]}
world.player).connected_region.name
for entrance in ["Player's House 1F", "Oak's Lab", "Rival's House"]}
paths = None
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":
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:
write_bytes(data, paths[0], rom_addresses["Path_Pallet_Oak"])
write_bytes(data, paths[1], rom_addresses["Path_Pallet_Player"])
write_bytes(rom_addresses["Path_Pallet_Oak"], paths[0])
write_bytes(rom_addresses["Path_Pallet_Player"], paths[1])
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":
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 entrance in region.exits:
@@ -281,16 +329,18 @@ def generate_output(world, output_directory: str):
while i > len(warp_to_ids) - 1:
i -= len(warp_to_ids)
connected_map_name = entrance.connected_region.name.split("-")[0]
data[address] = 0 if "Elevator" in connected_map_name else warp_to_ids[i]
data[address + 1] = map_ids[connected_map_name]
write_bytes(address, [
0 if "Elevator" in connected_map_name else warp_to_ids[i],
map_ids[connected_map_name]
])
if world.options.door_shuffle == "simple":
for (entrance, _, _, map_coords_entries, map_name, _) in town_map_coords.values():
destination = world.multiworld.get_entrance(entrance, world.player).connected_region.name
(_, x, y, _, _, map_order_entry) = town_map_coords[destination]
for map_coord_entry in map_coords_entries:
data[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_Coords"] + (map_coord_entry * 4) + 1, (y << 4) | x)
write_bytes(rom_addresses["Town_Map_Order"] + map_order_entry, map_ids[map_name])
if not world.options.key_items_only:
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:
tm = int(item_name[2:4])
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:
pass
def set_trade_mon(address, loc):
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
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_Crinkles", "Route 12 - Wild Pokemon - 4")
data[rom_addresses['Fly_Location']] = world.fly_map_code
data[rom_addresses['Map_Fly_Location']] = world.town_map_fly_map_code
write_bytes(rom_addresses["Fly_Location"], world.fly_map_code)
write_bytes(rom_addresses["Map_Fly_Location"], world.town_map_fly_map_code)
if world.options.fix_combat_bugs:
data[rom_addresses["Option_Fix_Combat_Bugs"]] = 1
data[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)
data[rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"]] = 0xe6 # and a, direct
data[rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"] + 1] = 0b0011111
data[rom_addresses["Option_Fix_Combat_Bugs_Struggle"]] = 0xe6 # and a, direct
data[rom_addresses["Option_Fix_Combat_Bugs_Struggle"] + 1] = 0x3f
data[rom_addresses["Option_Fix_Combat_Bugs_Dig_Fly"]] = 0b10001100
data[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
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Stat_Modifiers"]] = 1
write_bytes(rom_addresses["Option_Fix_Combat_Bugs"], 1)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Focus_Energy"], 0x28) # jr z
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_HP_Drain_Dream_Eater"], 0x1A) # ld a, (de)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"], 0xe6) # and a, direct
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"] + 1, 0b0011111)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Struggle"], 0xe6) # and a, direct
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Struggle"] + 1, 0x3f)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Dig_Fly"], 0b10001100)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"], 0x20) # jr nz,
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"] + 1, 5) # 5 bytes ahead
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Stat_Modifiers"], 1)
if world.options.poke_doll_skip == "in_logic":
data[rom_addresses["Option_Silph_Scope_Skip"]] = 0x00 # nop
data[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"], 0x00) # nop
write_bytes(rom_addresses["Option_Silph_Scope_Skip"] + 1, 0x00) # nop
write_bytes(rom_addresses["Option_Silph_Scope_Skip"] + 2, 0x00) # nop
if world.options.bicycle_gate_skips == "patched":
data[rom_addresses["Option_Route_16_Gate_Fix"]] = 0x00 # nop
data[rom_addresses["Option_Route_16_Gate_Fix"] + 1] = 0x00 # nop
data[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_16_Gate_Fix"], 0x00) # nop
write_bytes(rom_addresses["Option_Route_16_Gate_Fix"] + 1, 0x00) # nop
write_bytes(rom_addresses["Option_Route_18_Gate_Fix"], 0x00) # nop
write_bytes(rom_addresses["Option_Route_18_Gate_Fix"] + 1, 0x00) # nop
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:
data[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_Celadon"], 0x20) # jr nz
write_bytes(rom_addresses["Option_Locked_Elevator_Silph"], 0x20) # jr nz
if world.options.tea:
data[rom_addresses["Option_Tea"]] = 1
data[rom_addresses["Guard_Drink_List"]] = 0x54
data[rom_addresses["Guard_Drink_List"] + 1] = 0
data[rom_addresses["Guard_Drink_List"] + 2] = 0
write_bytes(data, encode_text("<LINE>Gee, I have the<CONT>worst caffeine<CONT>headache though."
"<PARA>Oh wait there,<LINE>the road's closed.<DONE>"),
rom_addresses["Text_Saffron_Gate"])
write_bytes(rom_addresses["Option_Tea"], 1)
write_bytes(rom_addresses["Guard_Drink_List"], 0x54)
write_bytes(rom_addresses["Guard_Drink_List"] + 1, 0)
write_bytes(rom_addresses["Guard_Drink_List"] + 2, 0)
write_bytes(rom_addresses["Text_Saffron_Gate"],
encode_text("<LINE>Gee, I have the<CONT>worst caffeine<CONT>headache though."
"<PARA>Oh wait there,<LINE>the road's closed.<DONE>"))
data[rom_addresses["Tea_Key_Item_A"]] = 0x28 # jr .z
data[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_A"], 0x28) # jr .z
write_bytes(rom_addresses["Tea_Key_Item_B"], 0x28) # jr .z
write_bytes(rom_addresses["Tea_Key_Item_C"], 0x28) # jr .z
data[rom_addresses["Fossils_Needed_For_Second_Item"]] = (
world.options.second_fossil_check_condition.value)
write_bytes(rom_addresses["Fossils_Needed_For_Second_Item"], 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:
data[rom_addresses['Option_Extra_Key_Items_A']] = 1
data[rom_addresses['Option_Extra_Key_Items_B']] = 1
data[rom_addresses['Option_Extra_Key_Items_C']] = 1
data[rom_addresses['Option_Extra_Key_Items_D']] = 1
data[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)
data[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(data, encode_text(str(world.options.cerulean_cave_badges_condition.value)), rom_addresses["Text_Cerulean_Cave_Badges"])
write_bytes(data, encode_text(str(world.options.cerulean_cave_key_items_condition.total) + " key items."), rom_addresses["Text_Cerulean_Cave_Key_Items"])
data[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
data[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
data[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(data, encode_text(str(world.options.elite_four_badges_condition.value)), rom_addresses["Text_Elite_Four_Badges"])
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(data, encode_text(str(world.options.elite_four_pokedex_condition.total) + " #MON"), rom_addresses["Text_Elite_Four_Pokedex"])
write_bytes(data, encode_text(str(world.total_key_items), length=2), rom_addresses["Trainer_Screen_Total_Key_Items"])
write_bytes(rom_addresses["Option_Extra_Key_Items_A"], 1)
write_bytes(rom_addresses["Option_Extra_Key_Items_B"], 1)
write_bytes(rom_addresses["Option_Extra_Key_Items_C"], 1)
write_bytes(rom_addresses["Option_Extra_Key_Items_D"], 1)
write_bytes(rom_addresses["Option_Split_Card_Key"], world.options.split_card_key.value)
write_bytes(rom_addresses["Option_Blind_Trainers"], round(world.options.blind_trainers.value * 2.55))
write_bytes(rom_addresses["Option_Cerulean_Cave_Badges"], world.options.cerulean_cave_badges_condition.value)
write_bytes(rom_addresses["Option_Cerulean_Cave_Key_Items"], world.options.cerulean_cave_key_items_condition.total)
write_bytes(rom_addresses["Text_Cerulean_Cave_Badges"], encode_text(str(world.options.cerulean_cave_badges_condition.value)))
write_bytes(rom_addresses["Text_Cerulean_Cave_Key_Items"], encode_text(str(world.options.cerulean_cave_key_items_condition.total) + " key items."))
write_bytes(rom_addresses["Option_Encounter_Minimum_Steps"], world.options.minimum_steps_between_encounters.value)
write_bytes(rom_addresses["Option_Route23_Badges"], world.options.victory_road_condition.value)
write_bytes(rom_addresses["Option_Victory_Road_Badges"], world.options.route_22_gate_condition.value)
write_bytes(rom_addresses["Option_Elite_Four_Pokedex"], world.options.elite_four_pokedex_condition.total)
write_bytes(rom_addresses["Option_Elite_Four_Key_Items"], world.options.elite_four_key_items_condition.total)
write_bytes(rom_addresses["Option_Elite_Four_Badges"], world.options.elite_four_badges_condition.value)
write_bytes(rom_addresses["Text_Elite_Four_Badges"], encode_text(str(world.options.elite_four_badges_condition.value)))
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(rom_addresses["Text_Elite_Four_Pokedex"], encode_text(str(world.options.elite_four_pokedex_condition.total) + " #MON"))
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
data[rom_addresses['Option_EXP_Modifier']] = world.options.exp_modifier.value
write_bytes(rom_addresses["Option_Viridian_Gym_Badges"], world.options.viridian_gym_condition.value)
write_bytes(rom_addresses["Option_EXP_Modifier"], world.options.exp_modifier.value)
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:
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:
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":
data[rom_addresses['Option_Old_Man']] = 0x11
data[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_Old_Man"], 0x11)
write_bytes(rom_addresses["Option_Old_Man_Lying"], 0x15)
write_bytes(rom_addresses["Option_Route3_Guard_B"], world.options.route_3_condition.value)
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:
data[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_A"], 0x15)
write_bytes(rom_addresses["Option_Trashed_House_Guard_B"], 0x11)
if world.options.require_pokedex:
data[rom_addresses["Require_Pokedex_A"]] = 1
data[rom_addresses["Require_Pokedex_B"]] = 1
data[rom_addresses["Require_Pokedex_C"]] = 1
write_bytes(rom_addresses["Require_Pokedex_A"], 1)
write_bytes(rom_addresses["Require_Pokedex_B"], 1)
write_bytes(rom_addresses["Require_Pokedex_C"], 1)
else:
data[rom_addresses["Require_Pokedex_D"]] = 0x18 # jr
write_bytes(rom_addresses["Require_Pokedex_D"], 0x18) # jr
if world.options.dexsanity:
data[rom_addresses["Option_Dexsanity_A"]] = 1
data[rom_addresses["Option_Dexsanity_B"]] = 1
write_bytes(rom_addresses["Option_Dexsanity_A"], 1)
write_bytes(rom_addresses["Option_Dexsanity_B"], 1)
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)
data[rom_addresses["Starting_Money_High"]] = int(money[:2], 16)
data[rom_addresses["Starting_Money_Middle"]] = int(money[2:4], 16)
data[rom_addresses["Starting_Money_Low"]] = int(money[4:], 16)
data[rom_addresses["Text_Badges_Needed_Viridian_Gym"]] = encode_text(
str(world.options.viridian_gym_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_A"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_B"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_C"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_D"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Badges_Needed"]] = encode_text(
str(world.options.elite_four_badges_condition.value))[0]
write_bytes(data, 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"])
write_bytes(rom_addresses["Starting_Money_High"], int(money[:2], 16))
write_bytes(rom_addresses["Starting_Money_Middle"], int(money[2:4], 16))
write_bytes(rom_addresses["Starting_Money_Low"], int(money[4:], 16))
write_bytes(rom_addresses["Text_Badges_Needed_Viridian_Gym"],
encode_text(str(world.options.viridian_gym_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_A"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_B"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_C"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_D"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Badges_Needed"],
encode_text(str(world.options.elite_four_badges_condition.value))[0])
write_bytes(rom_addresses["Text_Magikarp_Salesman"],
encode_text(" ".join(world.multiworld.get_location("Route 4 Pokemon Center - Pokemon For Sale", world.player).item.name.upper().split()[1:])))
if world.options.badges_needed_for_hm_moves.value == 0:
for hm_move in poke_data.hm_moves:
write_bytes(data, bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
rom_addresses["HM_" + hm_move + "_Badge_a"])
write_bytes(rom_addresses["HM_" + hm_move + "_Badge_a"], [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
elif world.extra_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():
data[rom_addresses["HM_" + hm_move + "_Badge_b"]] = {"Boulder Badge": 0x47, "Cascade Badge": 0x4F,
"Thunder Badge": 0x57, "Rainbow Badge": 0x5F,
"Soul Badge": 0x67, "Marsh Badge": 0x6F,
"Volcano Badge": 0x77, "Earth Badge": 0x7F}[badge]
write_bytes(rom_addresses["HM_" + hm_move + "_Badge_b"], badge_codes[badge])
move_text = hm_move
if badge not in ["Marsh Badge", "Volcano Badge", "Earth Badge"]:
move_text = ", " + move_text
@@ -467,62 +515,58 @@ def generate_output(world, output_directory: str):
if badge in written_badges:
rom_address += len(written_badges[badge])
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
for badge in ["Marsh Badge", "Volcano Badge", "Earth Badge"]:
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"]
for matchup in world.type_chart:
if matchup[2] != 10: # don't needlessly divide damage by 10 and multiply by 10
data[type_loc] = poke_data.type_ids[matchup[0]]
data[type_loc + 1] = poke_data.type_ids[matchup[1]]
data[type_loc + 2] = matchup[2]
write_bytes(type_loc, [poke_data.type_ids[matchup[0]], poke_data.type_ids[matchup[1]], matchup[2]])
type_loc += 3
data[type_loc] = 0xFF
data[type_loc + 1] = 0xFF
data[type_loc + 2] = 0xFF
write_bytes(type_loc, b"\xFF\xFF\xFF")
if world.options.normalize_encounter_chances.value:
chances = [25, 51, 77, 103, 129, 155, 180, 205, 230, 255]
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():
if mon == "Mew":
address = rom_addresses["Base_Stats_Mew"]
else:
address = rom_addresses["Base_Stats"] + (28 * (mon_data["dex"] - 1))
data[address + 1] = world.local_poke_data[mon]["hp"]
data[address + 2] = world.local_poke_data[mon]["atk"]
data[address + 3] = world.local_poke_data[mon]["def"]
data[address + 4] = world.local_poke_data[mon]["spd"]
data[address + 5] = world.local_poke_data[mon]["spc"]
data[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"]]
data[address + 8] = world.local_poke_data[mon]["catch rate"]
data[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"]
data[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(data, world.local_poke_data[mon]["tms"], address + 20)
write_bytes(address + 1, world.local_poke_data[mon]["hp"])
write_bytes(address + 2, world.local_poke_data[mon]["atk"])
write_bytes(address + 3, world.local_poke_data[mon]["def"])
write_bytes(address + 4, world.local_poke_data[mon]["spd"])
write_bytes(address + 5, world.local_poke_data[mon]["spc"])
write_bytes(address + 6, poke_data.type_ids[world.local_poke_data[mon]["type1"]])
write_bytes(address + 7, poke_data.type_ids[world.local_poke_data[mon]["type2"]])
write_bytes(address + 8, world.local_poke_data[mon]["catch rate"])
write_bytes(address + 15, poke_data.moves[world.local_poke_data[mon]["start move 1"]]["id"])
write_bytes(address + 16, poke_data.moves[world.local_poke_data[mon]["start move 2"]]["id"])
write_bytes(address + 17, poke_data.moves[world.local_poke_data[mon]["start move 3"]]["id"])
write_bytes(address + 18, poke_data.moves[world.local_poke_data[mon]["start move 4"]]["id"])
write_bytes(address + 20, world.local_poke_data[mon]["tms"])
if mon in world.learnsets and world.learnsets[mon]:
address = rom_addresses["Learnset_" + mon.replace(" ", "")]
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
data[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_Rt2"], world.options.oaks_aide_rt_2.value)
write_bytes(rom_addresses["Option_Aide_Rt11"], world.options.oaks_aide_rt_11.value)
write_bytes(rom_addresses["Option_Aide_Rt15"], world.options.oaks_aide_rt_15.value)
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:
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:
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",
"Awakening", "Burn Heal", "Ice Heal", "Paralyze Heal", "Full Heal", "Repel", "Super Repel",
"Max Repel", "Escape Rope"]
shop_data = bytearray([0xFE, len(inventory)])
shop_data += bytearray([item_table[item].id - 172000000 for item in inventory])
shop_data = [0xFE, len(inventory)]
shop_data += [item_table[item].id - 172000000 for item in inventory]
shop_data.append(0xFF)
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:
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 = bytearray([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
price = [int(price[:2], 16), int(price[2:4], 16), int(price[4:], 16)]
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]):
if data[rom_addresses["Start_Inventory"] + item.code - 172000000] < 255:
data[rom_addresses["Start_Inventory"] + item.code - 172000000] += 1
from collections import Counter
start_inventory = Counter(item.code for item in reversed(world.multiworld.precollected_items[world.player]))
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():
if move_data["id"] == 0:
continue
address = rom_addresses["Move_Data"] + ((move_data["id"] - 1) * 6)
write_bytes(data, bytearray([move_data["id"], move_data["effect"], move_data["power"],
poke_data.type_ids[move_data["type"]], round(move_data["accuracy"] * 2.55), move_data["pp"]]), address)
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"]])
TM_IDs = bytearray([poke_data.moves[move]["id"] for move in world.local_tms])
write_bytes(data, TM_IDs, rom_addresses["TM_Moves"])
TM_IDs = [poke_data.moves[move]["id"] for move in world.local_tms]
write_bytes(rom_addresses["TM_Moves"], TM_IDs)
if world.options.randomize_rock_tunnel:
seed = randomize_rock_tunnel(data, random)
write_bytes(data, encode_text(f"SEED: <LINE>{seed}"), rom_addresses["Text_Rock_Tunnel_Sign"])
seed = randomize_rock_tunnel(patch, world.random)
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()]
random.shuffle(mons)
data[rom_addresses['Title_Mon_First']] = mons.pop()
world.random.shuffle(mons)
write_bytes(rom_addresses["Title_Mon_First"], mons.pop())
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:
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
@@ -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
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)
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.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":
data[rom_addresses["Skip_Player_Name"]] = 0
write_bytes(rom_addresses["Skip_Player_Name"], 0)
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":
data[rom_addresses["Skip_Rival_Name"]] = 0
write_bytes(rom_addresses["Skip_Rival_Name"], 0)
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
rom_name = bytearray(f'AP{Utils.__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0',
'utf8')[:21]
write_bytes(0xFF00, 2) # client compatibility version
rom_name = bytearray(f"AP{Utils.__version__.replace('.', '')[0:3]}_{world.player}_{world.multiworld.seed:11}\0",
"utf8")[:21]
rom_name.extend([0] * (21 - len(rom_name)))
write_bytes(data, rom_name, 0xFFC6)
write_bytes(data, world.multiworld.seed_name.encode(), 0xFFDB)
write_bytes(data, world.multiworld.player_name[world.player].encode(), 0xFFF0)
write_bytes(0xFFC6, rom_name)
write_bytes(0xFFDB, world.multiworld.seed_name.encode())
write_bytes(0xFFF0, world.multiworld.player_name[world.player].encode())
world.finished_level_scaling.wait()
write_quizzes(world, data, random)
write_quizzes(world, patch)
for location in world.multiworld.get_locations(world.player):
if location.party_data:
@@ -617,18 +663,18 @@ def generate_output(world, output_directory: str):
levels = party["level"]
for address, party in zip(addresses, parties):
if isinstance(levels, int):
data[address] = levels
write_bytes(address, levels)
address += 1
for mon in party:
data[address] = poke_data.pokemon_data[mon]["id"]
write_bytes(address, poke_data.pokemon_data[mon]["id"])
address += 1
else:
address += 1
for level, mon in zip(levels, party):
data[address] = level
data[address + 1] = poke_data.pokemon_data[mon]["id"]
write_bytes(address, [level, poke_data.pokemon_data[mon]["id"]])
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
elif location.rom_address is None:
continue
@@ -639,85 +685,24 @@ def generate_output(world, output_directory: str):
rom_address = [rom_address]
for address in rom_address:
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():
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:
item_id = world.item_name_to_id[location.item.name] - 172000000
if item_id > 255:
item_id -= 256
data[address] = item_id
write_bytes(address, item_id)
if location.level:
data[location.level_address] = location.level
write_bytes(location.level_address, location.level)
else:
rom_address = location.rom_address
if not isinstance(rom_address, list):
rom_address = [rom_address]
for address in rom_address:
data[address] = 0x2C # AP Item
write_bytes(address, 0x2C) # AP Item
outfilepname = f'_P{world.player}'
outfilepname += f"_{world.multiworld.get_file_safe_player_name(world.player).replace(' ', '_')}" \
if world.multiworld.player_name[world.player] != 'Player%d' % world.player else ''
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)
patch.write_file("token_data.bin", patch.get_token_binary())
out_file_name = world.multiworld.get_out_file_name_base(world.player)
patch.write(os.path.join(output_directory, f"{out_file_name}{patch.patch_file_ending}"))

View File

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

View File

@@ -1,6 +1,15 @@
# 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
### Features:

View File

@@ -1,9 +1,11 @@
import logging
import time
from typing import Any
from NetUtils import ClientStatus, color
from NetUtils import ClientStatus, NetworkItem, color
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")
@@ -42,10 +44,13 @@ SMW_MOON_ACTIVE_ADDR = ROM_START + 0x01BFA8
SMW_HIDDEN_1UP_ACTIVE_ADDR = ROM_START + 0x01BFA9
SMW_BONUS_BLOCK_ACTIVE_ADDR = ROM_START + 0x01BFAA
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_MARIO_STATE_ADDR = WRAM_START + 0x71
SMW_COIN_COUNT_ADDR = WRAM_START + 0xDBF
SMW_BOSS_STATE_ADDR = WRAM_START + 0xD9B
SMW_ACTIVE_BOSS_ADDR = WRAM_START + 0x13FC
SMW_CURRENT_LEVEL_ADDR = WRAM_START + 0x13BF
@@ -76,6 +81,7 @@ SMW_UNCOLLECTABLE_DRAGON_COINS = [0x24]
class SMWSNIClient(SNIClient):
game = "Super Mario World"
patch_suffix = ".apsmw"
slot_data: dict[str, Any] | None
async def deathlink_kill_player(self, ctx):
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()
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):
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)
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.send_option = send_option[0]
ctx.trap_link = trap_link[0]
ctx.allow_collect = True
@@ -133,6 +219,15 @@ class SMWSNIClient(SNIClient):
if death_link:
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:
ctx.current_sublevel_value = 0
@@ -142,12 +237,17 @@ class SMWSNIClient(SNIClient):
def add_message_to_queue(self, new_message):
if not hasattr(self, "message_queue"):
self.message_queue = []
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):
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):
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
game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
@@ -221,7 +322,24 @@ class SMWSNIClient(SNIClient):
if pause_state[0] != 0x00:
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
if next_trap.item in trap_rom_data:
@@ -231,16 +349,22 @@ class SMWSNIClient(SNIClient):
# Timer Trap
if trap_active[0] == 0 or (trap_active[0] == 1 and trap_active[1] == 0 and trap_active[2] == 0):
# Trap already active
self.add_trap_to_queue(next_trap, message)
if from_queue:
self.add_trap_to_queue(next_trap, message)
return
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] + 1, bytes([0x00]))
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 2, bytes([0x00]))
else:
if trap_active[0] > 0:
# Trap already active
self.add_trap_to_queue(next_trap, message)
if from_queue:
self.add_trap_to_queue(next_trap, message)
return
else:
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
active_thwimp = await snes_read(ctx, SMW_ACTIVE_THWIMP_ADDR, 0x1)
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
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:
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]
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
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)
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):
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_trap_queue(ctx)
await self.handle_ring_link(ctx)
new_checks = []
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)))
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
item_name = ctx.item_names.lookup_in_game(item.item)
player_name = ctx.player_names[item.player]
@@ -515,7 +712,7 @@ class SMWSNIClient(SNIClient):
self.add_message_to_queue(receive_message)
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)
player_name = ctx.player_names[item.player]
@@ -572,14 +769,6 @@ class SMWSNIClient(SNIClient):
else:
# Extra Powerup?
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)

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}
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
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):
out_array = bytearray()

View File

@@ -398,6 +398,20 @@ class StartingLifeCount(Range):
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 = [
OptionGroup("Goal Options", [
Goal,
@@ -447,6 +461,8 @@ smw_option_groups = [
@dataclass
class SMWOptions(PerGameCommonOptions):
death_link: DeathLink
ring_link: RingLink
trap_link: TrapLink
goal: Goal
bosses_required: BossesRequired
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, 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, 0x02, 0x02, 0x02, 0x02, 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, 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, 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 130-13F
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(0x01BFAA, world.options.bonus_block_checks.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__

View File

@@ -90,6 +90,7 @@ class SMWWorld(World):
"blocksanity",
)
slot_data["active_levels"] = self.active_level_dict
slot_data["trap_weights"] = self.output_trap_weights()
return slot_data
@@ -322,3 +323,15 @@ class SMWWorld(World):
def 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)
def create_event_location(self, location_data: LocationData, rule: StardewRule = None, item: Optional[str] = None):
if rule is None:
rule = True_()
if item is None:
item = location_data.name
def create_event_location(self, location_data: LocationData, rule: StardewRule, item: str):
region = self.multiworld.get_region(location_data.region, self.player)
location = StardewLocation(self.player, location_data.name, None, region)
location.access_rule = rule
region.locations.append(location)
location.place_locked_item(StardewItem(item, ItemClassification.progression, None, self.player))
region.add_event(location_data.name, item, rule, StardewLocation, StardewItem)
def set_rules(self):
set_rules(self)

View File

@@ -154,7 +154,7 @@ class FestivalLogic(BaseLogic):
# Salads at the bar are good enough
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
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
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:
rules = []

View File

@@ -1,3 +1,5 @@
from functools import cached_property
from Utils import cache_self1
from .base_logic import BaseLogicMixin, BaseLogic
from ..data import fish_data
@@ -12,6 +14,8 @@ from ..strings.quality_names import FishQuality
from ..strings.region_names import Region
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):
def __init__(self, *args, **kwargs):
@@ -20,17 +24,35 @@ class FishingLogicMixin(BaseLogicMixin):
class FishingLogic(BaseLogic):
def can_fish_in_freshwater(self) -> StardewRule:
return self.logic.skill.can_fish() & self.logic.region.can_reach_any((Region.forest, Region.town, Region.mountain))
@cache_self1
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:
return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 10)
@cached_property
def can_fish_chests(self) -> StardewRule:
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:
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
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()
region_rule = self.logic.region.can_reach_any(fish.locations)
season_rule = self.logic.season.has_any(fish.seasons)
if fish.difficulty == -1:
difficulty_rule = self.logic.skill.can_crab_pot
difficulty_rule = self.logic.fishing.can_crab_pot
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:
item_rule = self.logic.received(SVEQuestItem.kittyfish_spell)
else:
item_rule = True_()
return quest_rule & region_rule & season_rule & difficulty_rule & item_rule
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)
def can_catch_every_fish(self) -> StardewRule:
rules = [self.has_max_fishing()]
rules = [self.has_max_fishing]
rules.extend(
self.logic.fishing.can_catch_fish(fish)
@@ -89,3 +114,23 @@ class FishingLogic(BaseLogic):
def has_specific_bait(self, fish: FishItem) -> StardewRule:
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:
return self.logic.fishing.can_catch_every_fish()
rules = [self.logic.fishing.has_max_fishing()]
rules = [self.logic.fishing.has_max_fishing]
rules.extend(
self.logic.fishing.can_catch_fish_for_fishsanity(fish)

View File

@@ -130,9 +130,9 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
# @formatter:off
self.registry.item_rules.update({
"Energy Tonic": self.money.can_spend_at(Region.hospital, 1000),
WaterChest.fishing_chest: self.fishing.can_fish_chests(),
WaterChest.golden_fishing_chest: self.fishing.can_fish_chests() & self.skill.has_mastery(Skill.fishing),
WaterChest.treasure: 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.treasure: self.fishing.can_fish_chests,
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),
"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.milk: self.animal.has_animal(Animal.cow),
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.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(),
@@ -198,7 +198,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
ArtisanGood.targeted_bait: self.artisan.has_targeted_bait(),
ArtisanGood.stardrop_tea: self.has(WaterChest.golden_fishing_chest),
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.triple_shot_espresso: self.has("Hot Java Ring"),
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.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.crab: self.skill.can_crab_pot_at(Region.beach),
Fish.crayfish: self.skill.can_crab_pot_at(Region.town),
Fish.lobster: self.skill.can_crab_pot_at(Region.beach),
Fish.crab: self.fishing.can_crab_pot_at(Region.beach),
Fish.crayfish: self.fishing.can_crab_pot_at(Region.town),
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_node: self.region.can_reach(Region.island_west),
Fish.oyster: self.tool.can_forage(Generic.any, Region.beach),
Fish.periwinkle: self.skill.can_crab_pot_at(Region.town),
Fish.shrimp: self.skill.can_crab_pot_at(Region.beach),
Fish.snail: self.skill.can_crab_pot_at(Region.town),
Fish.periwinkle: self.fishing.can_crab_pot_at(Region.town),
Fish.shrimp: self.fishing.can_crab_pot_at(Region.beach),
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.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), #
@@ -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_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_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.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),
@@ -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),
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),
Trash.broken_cd: self.skill.can_crab_pot,
Trash.broken_glasses: self.skill.can_crab_pot,
Trash.driftwood: self.skill.can_crab_pot,
Trash.broken_cd: self.fishing.can_crab_pot_anywhere,
Trash.broken_glasses: self.fishing.can_crab_pot_anywhere,
Trash.driftwood: self.fishing.can_crab_pot_anywhere,
Trash.joja_cola: self.money.can_spend_at(Region.saloon, 75),
Trash.soggy_newspaper: self.skill.can_crab_pot,
Trash.trash: self.skill.can_crab_pot,
Trash.soggy_newspaper: self.fishing.can_crab_pot_anywhere,
Trash.trash: self.fishing.can_crab_pot_anywhere,
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.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.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.seaweed: self.skill.can_fish(Region.tide_pools),
WaterItem.white_algae: self.skill.can_fish(Region.mines_floor_20),
WaterItem.seaweed: self.fishing.can_fish_at(Region.tide_pools),
WaterItem.white_algae: self.fishing.can_fish_at(Region.mines_floor_20),
WildSeeds.grass_starter: self.money.can_spend_at(Region.pierre_store, 100),
})
# @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.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.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.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),
@@ -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.dark_talisman: self.logic.region.can_reach(Region.railroad) & self.logic.wallet.has_rusty_key() & self.logic.relationship.can_meet(
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.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) &

View File

@@ -1,13 +1,10 @@
from functools import cached_property
from typing import Union, Tuple
from Utils import cache_self1
from .base_logic import BaseLogicMixin, BaseLogic
from ..data.harvest import HarvestCropSource
from ..mods.logic.mod_skills_levels import get_mod_skill_levels
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.quality_names import ForageQuality
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.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")
@@ -138,44 +134,9 @@ class SkillLogic(BaseLogic):
@cached_property
def can_get_fishing_xp(self) -> StardewRule:
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()
# 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
return self.logic.fishing.can_fish_anywhere()
def can_forage_quality(self, quality: str) -> StardewRule:
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.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.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.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() &

View File

@@ -44,9 +44,9 @@ class ModSkillLogic(BaseLogic):
def can_earn_luck_skill_level(self, level: int) -> StardewRule:
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:
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.
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.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,
(logic.wallet.has_rusty_key() & logic.region.can_reach(Region.railroad) & logic.relationship.can_meet(
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),
logic.has("Fire Quartz"))
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),
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: Tendrils", player),
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.has("Coffee") & logic.has("Life Elixir")
& 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.mines_floor_100) &
logic.region.can_reach(Region.farm) & logic.time.has_lived_months(12)))

View File

@@ -151,7 +151,7 @@ Magma Stone;
// Evil
Smashing, Poppet!; Achievement;
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;
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 logging import warning
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 .er_rules import set_er_location_rules
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 .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
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 .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection, OptionError
from Options import PlandoConnection, OptionError, PerGameCommonOptions, Removed, Range
from settings import Group, Bool
@@ -61,8 +62,9 @@ class SeedGroup(TypedDict):
ice_grappling: int # ice_grappling value
ladder_storage: int # ls value
laurels_at_10_fairies: bool # laurels location value
fixed_shop: bool # fixed shop value
plando: TunicPlandoConnections # consolidated plando connections for the seed group
entrance_layout: int # entrance layout value
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):
@@ -95,7 +97,7 @@ class TunicWorld(World):
tunic_portal_pairs: Dict[str, str]
er_portal_hints: Dict[int, str]
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
# for the local_fill option
@@ -119,27 +121,52 @@ class TunicWorld(World):
raise Exception("You have a TUNIC APWorld in your lib/worlds folder and custom_worlds folder.\n"
"This would cause an error at the end of generation.\n"
"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)
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()
if self.options.plando_connections and not self.options.entrance_rando:
self.options.plando_connections.value = ()
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):
# making shops second to simplify other things later
if cxn.entrance.startswith("Shop"):
replacement = PlandoConnection(cxn.exit, "Shop Portal", "both")
self.options.plando_connections.value.remove(cxn)
self.options.plando_connections.value.insert(index, replacement)
elif cxn.exit.startswith("Shop"):
replacement = PlandoConnection(cxn.entrance, "Shop Portal", "both")
self.options.plando_connections.value.remove(cxn)
self.options.plando_connections.value.insert(index, replacement)
replacement = None
if self.options.decoupled:
# flip any that are pointing to exit to point to entrance so that I don't have to deal with it
if cxn.direction == "exit":
replacement = PlandoConnection(cxn.exit, cxn.entrance, "entrance", cxn.percentage)
# if decoupled is on and you plando'd an entrance to itself but left the direction as both
if cxn.direction == "both" and cxn.entrance == cxn.exit:
replacement = PlandoConnection(cxn.entrance, cxn.exit, "entrance")
# 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
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.entrance_rando.value = self.passthrough["entrance_rando"]
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.breakable_shuffle.value = self.passthrough.get("breakable_shuffle", 0)
self.options.laurels_location.value = self.options.laurels_location.option_anywhere
self.options.combat_logic.value = self.passthrough["combat_logic"]
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
self.options.combat_logic.value = self.passthrough.get("combat_logic", 0)
else:
self.using_ut = False
else:
@@ -227,10 +254,14 @@ class TunicWorld(World):
ice_grappling=tunic.options.ice_grappling.value,
ladder_storage=tunic.options.ladder_storage.value,
laurels_at_10_fairies=tunic.options.laurels_location == LaurelsLocation.option_10_fairies,
fixed_shop=bool(tunic.options.fixed_shop),
plando=tunic.options.plando_connections)
entrance_layout=tunic.options.entrance_layout.value,
has_decoupled_enabled=bool(tunic.options.decoupled),
plando=tunic.options.plando_connections.value.copy())
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
if not tunic.options.laurels_zips:
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
if tunic.options.laurels_location == 3:
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
if tunic.options.fixed_shop:
cls.seed_groups[group]["fixed_shop"] = True
# fixed shop and direction pairs override standard, but conflict with each other
if tunic.options.entrance_layout:
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:
# 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
for group_cxn in cls.seed_groups[group]["plando"]:
# if neither entrance nor exit match anything in the group, add to group
if ((cxn.entrance == group_cxn.entrance and cxn.exit == group_cxn.exit)
or (cxn.exit == group_cxn.entrance and cxn.entrance == group_cxn.exit)):
new_cxn = False
break
# verify that it abides by direction pairs if enabled
if (cls.seed_groups[group]["entrance_layout"] == EntranceLayout.option_direction_pairs
and not verify_plando_directions(player_cxn)):
player_dir = "<->" if player_cxn.direction == "both" else "-->"
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
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 = (
cxn.entrance == group_cxn.entrance and cxn.exit != group_cxn.exit
or cxn.entrance == group_cxn.exit and cxn.exit != 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
player_cxn.entrance == group_cxn.entrance and player_cxn.exit != group_cxn.exit
or player_cxn.exit == group_cxn.exit and player_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:
raise Exception(f"TUNIC: Conflict between seed group {group}'s plando "
f"connection {group_cxn.entrance} <-> {group_cxn.exit} and "
f"{tunic.player_name}'s plando connection {cxn.entrance} <-> {cxn.exit}")
group_dir = "<->" if group_cxn.direction == "both" else "-->"
player_dir = "<->" if player_cxn.direction == "both" else "-->"
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:
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:
item_data = item_table[name]
@@ -486,10 +535,10 @@ class TunicWorld(World):
multiworld.random.shuffle(non_grass_fill_locations)
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:
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:
self.tunic_portal_pairs = {}
@@ -571,7 +620,7 @@ class TunicWorld(World):
all_state = self.multiworld.get_all_state(True)
all_state.update_reachable_regions(self.player)
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):
# skipping event locations
if not location.address:
@@ -630,6 +679,7 @@ class TunicWorld(World):
"lanternless": self.options.lanternless.value,
"maskless": self.options.maskless.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,
"grass_randomizer": self.options.grass_randomizer.value,
"combat_logic": self.options.combat_logic.value,

View File

@@ -22,6 +22,7 @@ class AreaStats(NamedTuple):
# the vanilla upgrades/equipment you would have
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"]),
"East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]),
"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 `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.
- 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.

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
if TYPE_CHECKING:
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):
name: str # human-readable name
region: str # AP region
destination: str # vanilla destination scene
tag: str # vanilla tag
direction: int # the direction you go to enter a portal
def scene(self) -> str: # the actual scene name in Tunic
if self.region.startswith("Shop"):
@@ -25,497 +38,497 @@ class Portal(NamedTuple):
portal_mapping: List[Portal] = [
Portal(name="Stick House Entrance", region="Overworld",
destination="Sword Cave", tag="_"),
destination="Sword Cave", tag="_", direction=Direction.north),
Portal(name="Windmill Entrance", region="Overworld",
destination="Windmill", tag="_"),
destination="Windmill", tag="_", direction=Direction.north),
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",
destination="Sewer", tag="_west_aqueduct"),
destination="Sewer", tag="_west_aqueduct", direction=Direction.north),
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",
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",
destination="Furnace", tag="_gyro_upper_north"),
destination="Furnace", tag="_gyro_upper_north", direction=Direction.south),
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",
destination="Furnace", tag="_gyro_west"),
destination="Furnace", tag="_gyro_west", direction=Direction.east),
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",
destination="Overworld Cave", tag="_"),
destination="Overworld Cave", tag="_", direction=Direction.north),
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",
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",
destination="Ruins Passage", tag="_east"),
destination="Ruins Passage", tag="_east", direction=Direction.north),
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",
destination="Atoll Redux", tag="_upper"),
destination="Atoll Redux", tag="_upper", direction=Direction.south),
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",
destination="ShopSpecial", tag="_"),
destination="ShopSpecial", tag="_", direction=Direction.east),
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",
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",
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",
destination="Archipelagos Redux", tag="_lowest"),
destination="Archipelagos Redux", tag="_lowest", direction=Direction.west),
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",
destination="Temple", tag="_rafters"),
destination="Temple", tag="_rafters", direction=Direction.east),
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",
destination="PatrolCave", tag="_"),
destination="PatrolCave", tag="_", direction=Direction.north),
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",
destination="Changing Room", tag="_"),
destination="Changing Room", tag="_", direction=Direction.south),
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",
destination="Mountain", tag="_"),
destination="Mountain", tag="_", direction=Direction.north),
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",
destination="Town_FiligreeRoom", tag="_"),
destination="Town_FiligreeRoom", tag="_", direction=Direction.north),
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",
destination="Darkwoods Tunnel", tag="_"),
destination="Darkwoods Tunnel", tag="_", direction=Direction.north),
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",
destination="Forest Belltower", tag="_"),
destination="Forest Belltower", tag="_", direction=Direction.east),
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",
destination="Transit", tag="_teleporter_starting island"),
destination="Transit", tag="_teleporter_starting island", direction=Direction.floor),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
Portal(name="Windmill Exit", region="Windmill",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
destination="Overworld Redux", tag="_house"),
destination="Overworld Redux", tag="_house", direction=Direction.west),
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",
destination="Overworld Redux", tag="_under_checkpoint"),
destination="Overworld Redux", tag="_under_checkpoint", direction=Direction.west),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.north),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.west),
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",
destination="Crypt Redux", tag="_"),
destination="Crypt Redux", tag="_", direction=Direction.east),
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",
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",
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
destination="Overworld Redux", tag="_west"),
destination="Overworld Redux", tag="_west", direction=Direction.west),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
destination="Overworld Redux", tag="_beach"),
destination="Overworld Redux", tag="_beach", direction=Direction.south),
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",
destination="Overworld Redux", tag="_rafters"),
destination="Overworld Redux", tag="_rafters", direction=Direction.west),
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",
destination="Fortress Courtyard", tag="_"),
destination="Fortress Courtyard", tag="_", direction=Direction.north),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.west),
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",
destination="Forest Belltower", tag="_"),
destination="Forest Belltower", tag="_", direction=Direction.north),
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",
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",
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",
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",
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",
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",
destination="Sword Access", tag="_lower"),
destination="Sword Access", tag="_lower", direction=Direction.east),
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",
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",
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",
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",
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",
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",
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",
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",
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",
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",
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",
destination="Forest Belltower", tag="_"),
destination="Forest Belltower", tag="_", direction=Direction.north),
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",
destination="Sewer_Boss", tag="_"),
destination="Sewer_Boss", tag="_", direction=Direction.east),
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",
destination="Sewer", tag="_"),
destination="Sewer", tag="_", direction=Direction.west),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
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",
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",
destination="archipelagos_house", tag="_"),
destination="archipelagos_house", tag="_", direction=Direction.east),
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",
destination="Shop", tag="_"),
destination="Shop", tag="_", direction=Direction.east),
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",
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",
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",
destination="Archipelagos Redux", tag="_"),
destination="Archipelagos Redux", tag="_", direction=Direction.west),
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",
destination="Fortress Reliquary", tag="_Upper"),
destination="Fortress Reliquary", tag="_Upper", direction=Direction.east),
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",
destination="Fortress East", tag="_"),
destination="Fortress East", tag="_", direction=Direction.north),
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",
destination="Forest Belltower", tag="_"),
destination="Forest Belltower", tag="_", direction=Direction.south),
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",
destination="Shop", tag="_"),
destination="Shop", tag="_", direction=Direction.north),
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",
destination="Fortress Courtyard", tag="_"),
destination="Fortress Courtyard", tag="_", direction=Direction.ladder_up),
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",
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",
destination="Fortress Arena", tag="_"),
destination="Fortress Arena", tag="_", direction=Direction.north),
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",
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",
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",
destination="Fortress Main", tag="_lower"),
destination="Fortress Main", tag="_lower", direction=Direction.west),
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",
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",
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",
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",
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",
destination="Dusty", tag="_"),
destination="Dusty", tag="_", direction=Direction.north),
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",
destination="Fortress Main", tag="_"),
destination="Fortress Main", tag="_", direction=Direction.south),
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",
destination="Overworld Redux", tag="_upper"),
destination="Overworld Redux", tag="_upper", direction=Direction.north),
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",
destination="Shop", tag="_"),
destination="Shop", tag="_", direction=Direction.north),
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",
destination="Library Exterior", tag="_"),
destination="Library Exterior", tag="_", direction=Direction.floor),
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",
destination="Frog Stairs", tag="_mouth"),
destination="Frog Stairs", tag="_mouth", direction=Direction.east),
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",
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",
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",
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",
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",
destination="Frog Stairs", tag="_Exit"),
destination="Frog Stairs", tag="_Exit", direction=Direction.west),
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",
destination="Library Hall", tag="_"),
destination="Library Hall", tag="_", direction=Direction.west), # camera rotates
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",
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",
destination="Library Rotunda", tag="_"),
destination="Library Rotunda", tag="_", direction=Direction.ladder_up),
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",
destination="Library Lab", tag="_"),
destination="Library Lab", tag="_", direction=Direction.ladder_up),
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",
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",
destination="Library Arena", tag="_"),
destination="Library Arena", tag="_", direction=Direction.ladder_up),
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",
destination="Mountaintop", tag="_"),
destination="Mountaintop", tag="_", direction=Direction.north),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
destination="Overworld Redux", tag="_"),
destination="Overworld Redux", tag="_", direction=Direction.south),
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",
destination="Darkwoods Tunnel", tag="_"),
destination="Darkwoods Tunnel", tag="_", direction=Direction.south), # rotates, it's fine
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",
destination="Monastery", tag="_front"),
destination="Monastery", tag="_front", direction=Direction.north),
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",
destination="Mountain", tag="_"),
destination="Mountain", tag="_", direction=Direction.north),
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",
destination="Transit", tag="_teleporter_quarry teleporter"),
destination="Transit", tag="_teleporter_quarry teleporter", direction=Direction.floor),
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",
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",
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",
destination="ziggurat2020_1", tag="_"),
destination="ziggurat2020_1", tag="_", direction=Direction.north),
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",
destination="ziggurat2020_0", tag="_"),
destination="ziggurat2020_0", tag="_", direction=Direction.south),
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",
destination="ziggurat2020_1", tag="_"),
destination="ziggurat2020_1", tag="_", direction=Direction.south),
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",
destination="ziggurat2020_2", tag="_"),
destination="ziggurat2020_2", tag="_", direction=Direction.north),
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
Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit",
destination="ziggurat2020_1", tag="_zig2_skip"),
Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit", # not a real region
destination="ziggurat2020_1", tag="_zig2_skip", direction=Direction.none),
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",
destination="Transit", tag="_teleporter_ziggurat teleporter"),
destination="Transit", tag="_teleporter_ziggurat teleporter", direction=Direction.floor),
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",
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",
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",
destination="Cathedral Arena", tag="_"),
destination="Cathedral Arena", tag="_", direction=Direction.north),
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",
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",
destination="RelicVoid", tag="_teleporter_relic plinth"),
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
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",
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",
destination="Swamp Redux 2", tag="_secret"),
destination="Swamp Redux 2", tag="_secret", direction=Direction.north),
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",
destination="Cathedral Redux", tag="_"),
destination="Cathedral Redux", tag="_", direction=Direction.ladder_up),
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",
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",
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",
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",
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",
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",
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",
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",
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",
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",
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",
destination="Fortress Arena", tag="_teleporter_spidertank"),
destination="Fortress Arena", tag="_teleporter_spidertank", direction=Direction.floor),
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",
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",
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",
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",
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",
destination="Transit", tag="_teleporter_spirit arena"),
destination="Transit", tag="_teleporter_spirit arena", direction=Direction.floor),
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",
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
dead_end: int = 0 # if a region has only one exit
outlet_region: Optional[str] = None
is_fake_region: bool = False
# 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
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 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 at Bell": RegionInfo("Overworld Redux"), # being able to ring the belltower, basically
"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 Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin
"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": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"),
"Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"),
@@ -758,7 +772,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Purgatory": RegionInfo("Purgatory"),
"Shop": RegionInfo("Shop", 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),
}
@@ -813,7 +827,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld Southeast Cross Door":
[],
"Overworld Fountain Cross Door":
[],
[],
"Overworld Town Portal":
[],
"Overworld Spawn Portal":
@@ -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 by Tree":
[],
@@ -1634,10 +1647,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Rooted Ziggurat Portal Room Entrance":
[],
},
"Zig Skip Exit": {
"Rooted Ziggurat Lower Front":
[],
},
"Rooted Ziggurat Portal Room Entrance": {
"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(
connecting_region=regions["Overworld Tunnel Turret"],
rule=lambda state: state.has(laurels, player))
regions["Overworld Tunnel Turret"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: state.has_any({grapple, laurels}, player))
# always have access to Overworld, so connecting back isn't needed
# regions["Overworld Tunnel Turret"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: state.has_any({grapple, laurels}, player))
cube_entrance = regions["Overworld"].connect(
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(
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(
connecting_region=regions["Rooted Ziggurat Portal Room"])
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))))
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
def ls_connect(origin_name: str, portal_sdt: str) -> None:
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 BaseClasses import Region, ItemClassification, Item, Location
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 .breakables import create_breakable_exclusive_regions, set_breakable_location_rules
from Options import PlandoConnection
from .options import EntranceRando
from .options import EntranceRando, EntranceLayout
from random import Random
from copy import deepcopy
@@ -23,17 +24,18 @@ class TunicERLocation(Location):
def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
regions: Dict[str, Region] = {}
world.used_shop_numbers = set()
for region_name, region_data in world.er_regions.items():
if world.options.entrance_rando and region_name == "Zig Skip Exit":
# need to check if there's a seed group for this first
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
elif not world.options.fixed_shop:
elif world.options.entrance_layout != EntranceLayout.option_fixed_shop:
continue
if not world.options.entrance_rando and region_name in ("Zig Skip Exit", "Purgatory"):
continue
region = Region(region_name, world.player, world.multiworld)
regions[region_name] = 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)
# output the entrances to the spoiler log here for convenience
sorted_portal_pairs = sort_portals(portal_pairs)
for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player)
sorted_portal_pairs = sort_portals(portal_pairs, world)
if not world.options.decoupled:
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:
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)
@@ -75,6 +82,7 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
return portal_pairs
# keys are event names, values are event regions
tunic_events: Dict[str, str] = {
"Eastern Bell": "Forest Belltower Upper",
"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)
# 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.
# 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:
new_shop_name = f"Shop {world.shop_num}"
def create_shop_region(world: "TunicWorld", regions: Dict[str, Region], portal_num) -> None:
new_shop_name = f"Shop {portal_num}"
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.connect(regions["Shop"])
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]:
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
@@ -135,9 +157,10 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
portal2_sdt = portal1.destination_scene()
if portal2_sdt.startswith("Shop,"):
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_num = get_shop_num(world)
portal2 = 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)
for portal in portal_map:
if portal.scene_destination() == portal2_sdt:
@@ -152,7 +175,13 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
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]:
portal_pairs: Dict[Portal, 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
ice_grappling = world.options.ice_grappling.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
decoupled = world.options.decoupled
traversal_reqs = deepcopy(traversal_requirements)
has_laurels = True
waterfall_plando = False
@@ -174,7 +204,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
laurels_zips = seed_group["laurels_zips"]
ice_grappling = seed_group["ice_grappling"]
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
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:
has_laurels = False
shop_count = 6
if 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":
portal_map.remove(portal)
break
# for the direction pairs option with decoupled off
# tracks how many portals are in each direction in each list
two_plus_direction_tracker: Dict[int, int] = {direction: 0 for direction in range(8)}
dead_end_direction_tracker: Dict[int, int] = {direction: 0 for direction in range(8)}
# for ensuring we have enough entrances in directions left that we don't leave dead ends without any
def too_few_portals_for_direction_pairs(direction: int, offset: int) -> bool:
if two_plus_direction_tracker[direction] <= (dead_end_direction_tracker[direction_pairs[direction]] + offset):
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 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
if dead_end_status == DeadEnd.free:
two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
elif dead_end_status == DeadEnd.all_cats:
dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
elif dead_end_status == DeadEnd.restricted:
if ice_grappling:
two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
else:
dead_ends.append(portal)
dead_end_direction_tracker[portal.direction] += 1
# these two get special handling
elif dead_end_status == DeadEnd.special:
if portal.region == "Secret Gathering Place":
if laurels_location == "10_fairies":
two_plus.append(portal)
two_plus_direction_tracker[portal.direction] += 1
else:
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 fixed_shop:
two_plus.append(portal)
else:
dead_ends.append(portal)
portal_map.remove(portal)
break
# need 8 shops with direction pairs or there won't be a valid set of pairs
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()
# 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
# connected_regions.update(add_dependent_regions(portal.region, logic_rules))
# shops have special handling
if not portal_name2 and portal2 == "Shop, Previous Region_":
portal_name2 = "Shop Portal"
plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both"))
if not portal_name1 and portal1.startswith("Shop"):
# it should show up as "Shop, 1_" for shop 1
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()
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)
# if ice grappling to places is in logic, both places stop being dead ends
elif region_info.dead_end == DeadEnd.restricted and ice_grappling:
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:
if (region_name == "Secret Gathering Place" and laurels_location == "10_fairies") \
or (region_name == "Zig Skip Exit" and fixed_shop):
if region_name == "Secret Gathering Place" and laurels_location == "10_fairies":
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:
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_exit = connection.exit
# 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
portal1_dead_end = True
portal2_dead_end = True
@@ -279,118 +385,186 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
portal1 = 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:
if p_entrance == portal.name:
portal1 = portal
portal1_dead_end = False
break
for portal in two_plus2:
if p_exit == portal.name:
portal2 = portal
portal2_dead_end = False
break
# search dead_ends individually since we can't really remove items from two_plus during the loop
if portal1:
two_plus.remove(portal1)
else:
# 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():
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.")
f"plando connections -- {connection.entrance} to {connection.exit}")
for portal in dead_ends:
if p_entrance == portal.name:
portal1 = portal
dead_ends.remove(portal1)
break
if not portal1:
raise Exception(f"Could not find entrance named {p_entrance} for "
f"plando connections in {player_name}'s YAML.")
dead_ends.remove(portal1)
else:
if p_entrance.startswith("Shop Portal "):
portal_num = int(p_entrance.split("Shop Portal ")[-1])
# 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:
two_plus.remove(portal2)
two_plus2.remove(portal2)
else:
for portal in dead_ends:
if p_exit == portal.name:
portal2 = portal
dead_ends.remove(portal2)
break
# if it's not a dead end, it might be a shop
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
# if it's not a dead end, maybe it's a plando'd shop portal that doesn't normally exist
else:
if not portal2:
raise Exception(f"Could not find entrance named {p_exit} for "
f"plando connections in {player_name}'s YAML.\n"
f"If you are using Universal Tracker, the most likely reason for this error "
f"is that the host generated with a newer version of the APWorld.\n"
f"Please check the TUNIC Randomizer Github and place the newest APWorld in your "
f"custom_worlds folder, and remove the one in lib/worlds if there is one there.")
dead_ends.remove(portal2)
if p_exit.startswith("Shop Portal "):
portal_num = int(p_exit.split("Shop Portal ")[-1])
if portal_num <= 6:
pdir = Direction.south
elif portal_num in [7, 8]:
pdir = Direction.east
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 not portal1_dead_end and not portal2_dead_end:
traversal_reqs.setdefault(portal1.region, dict())[portal2.region] = []
traversal_reqs.setdefault(portal2.region, dict())[portal1.region] = []
# if we're doing decoupled, we don't need to do complex checks
if decoupled:
# we turn any plando that uses "exit" to use "entrance" instead
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")
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 (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")):
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():
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
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
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks)
if fixed_shop and not world.using_ut:
portal1 = None
# if there are an odd number of shops after plando, add another one, except in decoupled where it doesn't matter
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:
if portal.scene_destination() == "Overworld Redux, Windmill_":
portal1 = portal
windmill = portal
break
if not portal1:
raise Exception(f"Failed to do Fixed Shop option. "
f"Did {player_name} plando connection the Windmill Shop entrance?")
if not windmill:
raise Exception(f"Failed to do Fixed Shop option for Entrance Layout. "
f"Did {player_name} plando the Windmill Shop entrance?")
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_num = get_shop_num(world)
shop = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}",
destination=str(portal_num), tag="_", direction=Direction.south)
create_shop_region(world, regions, portal_num)
portal_pairs[portal1] = portal2
two_plus.remove(portal1)
portal_pairs[windmill] = shop
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
if isinstance(world.options.entrance_rando.value, str):
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
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
portal2 = None
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
if previous_conn_num == len(connected_regions):
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. "
"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:
fail_count = 0
previous_conn_num = len(connected_regions)
# find a portal in a connected region
if check_success == 0:
for portal in two_plus:
if portal.region in connected_regions:
portal1 = portal
two_plus.remove(portal)
check_success = 1
break
for portal in two_plus:
if portal.region in connected_regions:
# if there's more dead ends of a direction than two plus of the opposite direction,
# then we'll run out of viable connections for those dead ends later
# decoupled does not have this issue since dead ends aren't real in decoupled
if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs:
if not too_few_portals_for_direction_pairs(portal.direction, 0):
continue
# then we find a portal in an inaccessible region
if check_success == 1:
for portal in two_plus:
if portal.region not in connected_regions:
# if secret gathering place happens to get paired really late, you can end up running out
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
if waterfall_plando:
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
# if not waterfall_plando, then we just want to pair secret gathering place now
elif portal.region != "Secret Gathering Place":
portal1 = portal
two_plus.remove(portal)
break
if not portal1:
raise Exception("TUNIC: Failed to pair portals at first part of first phase.")
# then we find a portal in an unconnected region
for portal in two_plus2:
if portal.region not in connected_regions:
# if secret gathering place happens to get paired really late, you can end up running out
if not has_laurels and len(two_plus2) < 80:
# if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this
if waterfall_plando:
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
portal2 = portal
connected_regions.add(portal.region)
two_plus.remove(portal)
check_success = 2
break
# if not waterfall_plando, then we just want to pair secret gathering place now
elif portal.region != "Secret Gathering Place":
continue
# 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
if check_success == 2:
if "Secret Gathering Place" in connected_regions:
has_laurels = True
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)
if not has_laurels and "Secret Gathering Place" in connected_regions:
has_laurels = True
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks)
# 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
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
# 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:
if world.using_ut:
break
portal1 = two_plus.pop()
portal2 = dead_ends.pop()
portal_pairs[portal1] = portal2
portal2 = dead_ends[0]
for portal in two_plus:
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
# every region is accessible, so gate_before_switch is not necessary
while len(two_plus) > 1:
final_pair_number = 0
while len(two_plus) > 0:
if world.using_ut:
break
portal1 = two_plus.pop()
portal2 = two_plus.pop()
final_pair_number += 1
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
if len(two_plus) == 1:
raise Exception("two plus had an odd number of portals, investigate this. last portal is " + two_plus[0].name)
if len(two_plus2) > 0:
raise Exception(f"TUNIC: Something went horribly wrong in ER for {world.player_name}. "
f"Please contact the TUNIC rando devs.")
return portal_pairs
# 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():
region1 = regions[portal1.region]
region2 = regions[portal2.region]
region1.connect(connecting_region=region2, name=portal1.name)
region2.connect(connecting_region=region1, name=portal2.name)
# connect to the outlet region if there is one, if not connect to the actual region
regions[portal1.region].connect(
connecting_region=regions[get_portal_outlet_region(portal2, world)],
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]]]],
@@ -541,22 +801,58 @@ def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[s
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
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] = {}
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
# I could either do it like this, or just go up to like 200, this seemed better
# shop_count = 0
# 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)])
largest_shop_number = max(world.used_shop_numbers)
reference_list.extend([f"Shop Portal {i + 1}" for i in range(largest_shop_number)])
for name in reference_list:
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 Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections,
PerGameCommonOptions, OptionGroup, Visibility, NamedRange)
PerGameCommonOptions, OptionGroup, Removed, Visibility, NamedRange)
from .er_data import portal_mapping
if TYPE_CHECKING:
from . import TunicWorld
@@ -147,14 +147,42 @@ class EntranceRando(TextChoice):
class FixedShop(Toggle):
"""
Forces the Windmill entrance to lead to a shop, and removes the remaining shops from the pool.
Adds another entrance in Rooted Ziggurat Lower to keep an even number of entrances.
Has no effect if Entrance Rando is not enabled.
This option has been superseded by the Entrance Layout option.
If enabled, it will override the Entrance Layout option.
This is kept to keep older yamls working, and will be removed at a later date.
"""
visibility = Visibility.none
internal_name = "fixed_shop"
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):
"""
Force the Hero's Laurels to be placed at a location in your world.
@@ -210,13 +238,22 @@ class LocalFill(NamedRange):
class TunicPlandoConnections(PlandoConnections):
"""
Generic connection plando. Format is:
- entrance: "Entrance Name"
exit: "Exit Name"
- entrance: Entrance Name
exit: Exit Name
direction: Direction
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.
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"}
exits = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"}
shops = {f"Shop Portal {i + 1}" for i in range(500)}
entrances = {portal.name for portal in portal_mapping}.union(shops)
exits = {portal.name for portal in portal_mapping}.union(shops)
duplicate_exits = True
@@ -295,6 +332,16 @@ class LadderStorageWithoutItems(Toggle):
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):
"""
This option has been superseded by the individual trick options.
@@ -329,6 +376,7 @@ class TunicOptions(PerGameCommonOptions):
start_with_sword: StartWithSword
keys_behind_bosses: KeysBehindBosses
ability_shuffling: AbilityShuffling
fool_traps: FoolTraps
laurels_location: LaurelsLocation
@@ -343,7 +391,9 @@ class TunicOptions(PerGameCommonOptions):
local_fill: LocalFill
entrance_rando: EntranceRando
fixed_shop: FixedShop
entrance_layout: EntranceLayout
decoupled: Decoupled
plando_connections: TunicPlandoConnections
combat_logic: CombatLogic
lanternless: Lanternless
@@ -352,10 +402,11 @@ class TunicOptions(PerGameCommonOptions):
ice_grappling: IceGrappling
ladder_storage: LadderStorage
ladder_storage_without_items: LadderStorageWithoutItems
all_random: HiddenAllRandom
plando_connections: TunicPlandoConnections
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 = [
@@ -372,8 +423,14 @@ tunic_option_groups = [
LaurelsZips,
IceGrappling,
LadderStorage,
LadderStorageWithoutItems
])
LadderStorageWithoutItems,
]),
OptionGroup("Entrance Randomizer", [
EntranceRando,
EntranceLayout,
Decoupled,
TunicPlandoConnections,
]),
]
tunic_option_presets: Dict[str, Dict[str, Any]] = {

View File

@@ -1,5 +1,4 @@
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 BaseClasses import CollectionState
@@ -157,8 +156,8 @@ def set_region_rules(world: "TunicWorld") -> None:
if options.ladder_storage >= LadderStorage.option_medium:
# 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"),
rule=lambda state: can_ladder_storage(state, world))
add_rule(world.get_entrance(entrance_name="Quarry Back -> Monastery"),
rule=lambda state: can_ladder_storage(state, world))
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.AbilityShuffling.internal_name: options.AbilityShuffling.option_true,
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,
"plando_connections": [
{
@@ -126,3 +127,262 @@ class TestLadderStorage(TunicTestBase):
self.assertFalse(self.can_reach_location("Fortress Courtyard - Page Near Cave"))
self.collect_by_name(["Pages 24-25 (Prayer)"])
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 typing import Any
from Options import (
Choice,
@@ -18,6 +19,8 @@ from .Locations import DUNGEON_NAMES
class Dungeons(DefaultOnToggle):
"""
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"
@@ -752,6 +755,68 @@ class TWWOptions(PerGameCommonOptions):
remove_music: RemoveMusic
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] = [
OptionGroup(

View File

@@ -462,7 +462,7 @@ class TWWWorld(World):
"Seed": multiworld.seed_name,
"Slot": player,
"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,
"Locations": {},
"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?
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?
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.
## 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
randomizers, Link **will not** hold the item above his head.
When the player receives an item, it will automatically be added to Link's inventory. Link **will not** hold the item
above his head like many other Zelda randomizers.
## 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
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
- 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.
- Demo items (items which are held over Link's head) which are **not** randomized, such as rupees from salvages from
random light rings or rewards from minigames, will not work.
- Demo items (items held over Link's head) that are **not** randomized, such as rupees from salvages from random light
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
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!
@@ -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
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
[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
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.
- **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
preset features full entrance rando and includes many locations behind a randomized entrance. There are also a bunch
of overworld locations, as these settings were intended to be played in a two-person co-op team. The preset also has 6
preset features full entrance rando and includes most locations behind a randomized entrance. There are also many
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
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)
- Cyb3R: (reference for `TWWClient`)
- DeamonHunter: (additional programming)
- Dev5ter: (initial TWW AP implmentation)
- Dev5ter: (initial TWW AP implementation)
- Gamma / SageOfMirrors: (additional programming)
- LagoLunatic: (base randomizer, additional assistance)
- 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
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.**
* 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).
* 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".
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
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
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
`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
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.
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.
4. Click `Randomize` at the bottom-right. This randomizes the ISO and puts it in the output folder you specified. The
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
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.
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
`ArchipelagoServer.exe` (without `.exe` on Linux), the port number will default to `38281` but may be changed in the
`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.
## 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 `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
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.
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?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
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
emulation and make troubleshooting errors difficult.
* If you get an error message, ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` >
`Configuration` > `Advanced`) is **disabled**.
emulation and make troubleshooting errors difficult.
* Ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` > `Configuration` > `Advanced`) is
**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`
and checking `Skip Main Menu`.
and checking `Skip Main Menu`.