mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-11 01:53:46 -07:00
Compare commits
37 Commits
oc2-progre
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453d89460f | ||
|
|
28889e58aa | ||
|
|
f3c76399e0 | ||
|
|
8384a23fe2 | ||
|
|
bcd7d62d0b | ||
|
|
703f5a22fd | ||
|
|
1ee8e339af | ||
|
|
dffde64079 | ||
|
|
17bc184e28 | ||
|
|
0ba9ee0695 | ||
|
|
c40214e20f | ||
|
|
a3aac3d737 | ||
|
|
7bbe62019a | ||
|
|
b898b9d9e6 | ||
|
|
b217372fea | ||
|
|
b2d2c8e596 | ||
|
|
68e37b8f9a | ||
|
|
fa2d7797f4 | ||
|
|
1885dab066 | ||
|
|
9425f5b772 | ||
|
|
83ed3c8b50 | ||
|
|
f4690e296d | ||
|
|
68c350b4c0 | ||
|
|
da0207f5cb | ||
|
|
2455f1158f | ||
|
|
1031fc4923 | ||
|
|
6beaacb905 | ||
|
|
c46ee7c420 | ||
|
|
227f0bce3d | ||
|
|
611e1c2b19 | ||
|
|
5f974b7457 | ||
|
|
3ef35105c8 | ||
|
|
ec768a2e89 | ||
|
|
b580d3c25a | ||
|
|
ce14f190fb | ||
|
|
4e3da005d4 | ||
|
|
0d9967e8d8 |
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
83
.github/workflows/release.yml
vendored
83
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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()
|
||||
15
Generate.py
15
Generate.py
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
Main.py
1
Main.py
@@ -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]]]] = {}
|
||||
|
||||
|
||||
62
Options.py
62
Options.py
@@ -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):
|
||||
|
||||
6
Utils.py
6
Utils.py
@@ -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.
@@ -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 → 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 → 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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
26
worlds/factorio/settings.py
Normal file
26
worlds/factorio/settings.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', ]),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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"]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) &
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() &
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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":
|
||||
[],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
]}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user