Merge remote-tracking branch 'refs/remotes/sw/tunc-combat-logic' into tunc-portal-direction-pairing

# Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_scripts.py
#	worlds/tunic/options.py
This commit is contained in:
Scipio Wright
2024-08-04 20:07:39 -04:00
82 changed files with 2420 additions and 875 deletions

View File

@@ -680,13 +680,13 @@ class CollectionState():
def can_reach_region(self, spot: str, player: int) -> bool:
return self.multiworld.get_region(spot, player).can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_events = True
# since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.advancement and location not in self.events and
not key_only or getattr(location.item, "locked_dungeon_item", False)}
locations = {location for location in locations if location.advancement and location not in self.events}
while reachable_events:
reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events
@@ -1291,8 +1291,6 @@ class Spoiler:
state = CollectionState(multiworld)
collection_spheres = []
while required_locations:
state.sweep_for_events(key_only=True)
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:

View File

@@ -61,6 +61,7 @@ class ClientCommandProcessor(CommandProcessor):
if address:
self.ctx.server_address = None
self.ctx.username = None
self.ctx.password = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
@@ -514,6 +515,7 @@ class CommonContext:
async def shutdown(self):
self.server_address = ""
self.username = None
self.password = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()

View File

@@ -646,7 +646,6 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:

13
Main.py
View File

@@ -124,14 +124,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
world_excluded_locations = set()
for location_name in multiworld.worlds[player].options.priority_locations.value:
try:
location = multiworld.get_location(location_name, player)
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
if location_name not in multiworld.worlds[player].location_name_to_id:
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
else:
except KeyError:
continue
if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
else:
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
world_excluded_locations.add(location_name)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules.
if multiworld.players > 1:

View File

@@ -79,7 +79,7 @@ class TrackerData:
# Normal lookup tables as well.
self.item_name_to_id[game] = game_package["item_name_to_id"]
self.location_name_to_id[game] = game_package["item_name_to_id"]
self.location_name_to_id[game] = game_package["location_name_to_id"]
def get_seed_name(self) -> str:
"""Retrieves the seed name."""

View File

@@ -1,8 +1,8 @@
# Archipelago World Code Owners / Maintainers Document
#
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder. For any pull
# requests that modify these worlds, a code owner must approve the PR in addition to a core maintainer. This is not to
# be used for files/folders outside the /worlds folder, those will always need sign off from a core maintainer.
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder as well as
# certain documentation. For any pull requests that modify these worlds/docs, a code owner must approve the PR in
# addition to a core maintainer. All other files and folders are owned and maintained by core maintainers directly.
#
# All usernames must be GitHub usernames (and are case sensitive).
@@ -226,3 +226,11 @@
# Ori and the Blind Forest
# /worlds_disabled/oribf/
###################
## Documentation ##
###################
# Apworld Dev Faq
/docs/apworld_dev_faq.md @qwint @ScipioWright

45
docs/apworld_dev_faq.md Normal file
View File

@@ -0,0 +1,45 @@
# APWorld Dev FAQ
This document is meant as a reference tool to show solutions to common problems when developing an apworld.
It is not intended to answer every question about Archipelago and it assumes you have read the other docs,
including [Contributing](contributing.md), [Adding Games](<adding games.md>), and [World API](<world api.md>).
---
### 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.
```py
early_item_name = "Sword"
self.multiworld.local_early_items[self.player][early_item_name] = 1
```
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
---
### I have multiple settings that change the item/location pool counts and need to balance them out
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
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
```py
total_locations = len(self.multiworld.get_unfilled_locations(self.player))
item_pool = self.create_non_filler_items()
for _ in range(total_locations - len(item_pool)):
item_pool.append(self.create_filler())
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):
```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
```

View File

@@ -596,6 +596,7 @@ class GameManager(App):
def connect_button_action(self, button):
self.ctx.username = None
self.ctx.password = None
if self.ctx.server:
async_start(self.ctx.disconnect())
else:

View File

@@ -3,6 +3,7 @@ Application settings / host.yaml interface using type hints.
This is different from player options.
"""
import os
import os.path
import shutil
import sys
@@ -11,7 +12,6 @@ import warnings
from enum import IntEnum
from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
import os
__all__ = [
"get_settings", "fmt_doc", "no_gui",
@@ -798,6 +798,7 @@ class Settings(Group):
atexit.register(autosave)
def save(self, location: Optional[str] = None) -> None: # as above
from Utils import parse_yaml
location = location or self._filename
assert location, "No file specified"
temp_location = location + ".tmp" # not using tempfile to test expected file access
@@ -807,10 +808,18 @@ class Settings(Group):
# can't use utf-8-sig because it breaks backward compat: pyyaml on Windows with bytes does not strip the BOM
with open(temp_location, "w", encoding="utf-8") as f:
self.dump(f)
# replace old with new
if os.path.exists(location):
f.flush()
if hasattr(os, "fsync"):
os.fsync(f.fileno())
# validate new file is valid yaml
with open(temp_location, encoding="utf-8") as f:
parse_yaml(f.read())
# replace old with new, try atomic operation first
try:
os.rename(temp_location, location)
except (OSError, FileExistsError):
os.unlink(location)
os.rename(temp_location, location)
os.rename(temp_location, location)
self._filename = location
def dump(self, f: TextIO, level: int = 0) -> None:
@@ -832,7 +841,6 @@ def get_settings() -> Settings:
with _lock: # make sure we only have one instance
res = getattr(get_settings, "_cache", None)
if not res:
import os
from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml")
locations: List[str] = []

View File

@@ -1,11 +1,12 @@
import os
import os.path
import unittest
from io import StringIO
from tempfile import TemporaryFile
from tempfile import TemporaryDirectory, TemporaryFile
from typing import Any, Dict, List, cast
import Utils
from settings import Settings, Group
from settings import Group, Settings, ServerOptions
class TestIDs(unittest.TestCase):
@@ -80,3 +81,27 @@ class TestSettingsDumper(unittest.TestCase):
self.assertEqual(value_spaces[2], value_spaces[0]) # start of sub-list
self.assertGreater(value_spaces[3], value_spaces[0],
f"{value_lines[3]} should have more indentation than {value_lines[0]} in {lines}")
class TestSettingsSave(unittest.TestCase):
def test_save(self) -> None:
"""Test that saving and updating works"""
with TemporaryDirectory() as d:
filename = os.path.join(d, "host.yaml")
new_release_mode = ServerOptions.ReleaseMode("enabled")
# create default host.yaml
settings = Settings(None)
settings.save(filename)
self.assertTrue(os.path.exists(filename),
"Default settings could not be saved")
self.assertNotEqual(settings.server_options.release_mode, new_release_mode,
"Unexpected default release mode")
# update host.yaml
settings.server_options.release_mode = new_release_mode
settings.save(filename)
self.assertFalse(os.path.exists(filename + ".tmp"),
"Temp file was not removed during save")
# read back host.yaml
settings = Settings(filename)
self.assertEqual(settings.server_options.release_mode, new_release_mode,
"Settings were not overwritten")

View File

@@ -39,7 +39,7 @@ def create_itempool(world: "HatInTimeWorld") -> List[Item]:
continue
else:
if name == "Scooter Badge":
if world.options.CTRLogic is CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
if world.options.CTRLogic == CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
item_type = ItemClassification.progression
elif name == "No Bonk Badge" and world.is_dw():
item_type = ItemClassification.progression

View File

@@ -659,6 +659,10 @@ def is_valid_act_combo(world: "HatInTimeWorld", entrance_act: Region,
if exit_act.name not in chapter_finales:
return False
exit_chapter: str = act_chapters.get(exit_act.name)
# make sure that certain time rift combinations never happen
always_block: bool = exit_chapter != "Mafia Town" and exit_chapter != "Subcon Forest"
if not ignore_certain_rules or always_block:
if entrance_act.name in rift_access_regions and exit_act.name in rift_access_regions[entrance_act.name]:
return False
@@ -684,9 +688,12 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
if act.name not in guaranteed_first_acts:
return False
if world.options.ActRandomizer == ActRandomizer.option_light and "Time Rift" in act.name:
return False
# If there's only a single level in the starting chapter, only allow Mafia Town or Subcon Forest levels
start_chapter = world.options.StartingChapter
if start_chapter is ChapterIndex.ALPINE or start_chapter is ChapterIndex.SUBCON:
if start_chapter == ChapterIndex.ALPINE or start_chapter == ChapterIndex.SUBCON:
if "Time Rift" in act.name:
return False
@@ -723,7 +730,8 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
elif act.name == "Contractual Obligations" and world.options.ShuffleSubconPaintings:
return False
if world.options.ShuffleSubconPaintings and act_chapters.get(act.name, "") == "Subcon Forest":
if world.options.ShuffleSubconPaintings and "Time Rift" not in act.name \
and act_chapters.get(act.name, "") == "Subcon Forest":
# Only allow Subcon levels if painting skips are allowed
if diff < Difficulty.MODERATE or world.options.NoPaintingSkips:
return False

View File

@@ -1,7 +1,6 @@
from worlds.AutoWorld import CollectionState
from worlds.generic.Rules import add_rule, set_rule
from .Locations import location_table, zipline_unlocks, is_location_valid, contract_locations, \
shop_locations, event_locs
from .Locations import location_table, zipline_unlocks, is_location_valid, shop_locations, event_locs
from .Types import HatType, ChapterIndex, hat_type_to_item, Difficulty, HitType
from BaseClasses import Location, Entrance, Region
from typing import TYPE_CHECKING, List, Callable, Union, Dict
@@ -148,14 +147,14 @@ def set_rules(world: "HatInTimeWorld"):
if world.is_dlc1():
chapter_list.append(ChapterIndex.CRUISE)
if world.is_dlc2() and final_chapter is not ChapterIndex.METRO:
if world.is_dlc2() and final_chapter != ChapterIndex.METRO:
chapter_list.append(ChapterIndex.METRO)
chapter_list.remove(starting_chapter)
world.random.shuffle(chapter_list)
# Make sure Alpine is unlocked before any DLC chapters are, as the Alpine door needs to be open to access them
if starting_chapter is not ChapterIndex.ALPINE and (world.is_dlc1() or world.is_dlc2()):
if starting_chapter != ChapterIndex.ALPINE and (world.is_dlc1() or world.is_dlc2()):
index1 = 69
index2 = 69
pos: int
@@ -165,7 +164,7 @@ def set_rules(world: "HatInTimeWorld"):
if world.is_dlc1():
index1 = chapter_list.index(ChapterIndex.CRUISE)
if world.is_dlc2() and final_chapter is not ChapterIndex.METRO:
if world.is_dlc2() and final_chapter != ChapterIndex.METRO:
index2 = chapter_list.index(ChapterIndex.METRO)
lowest_index = min(index1, index2)
@@ -242,9 +241,6 @@ def set_rules(world: "HatInTimeWorld"):
if not is_location_valid(world, key):
continue
if key in contract_locations.keys():
continue
loc = world.multiworld.get_location(key, world.player)
for hat in data.required_hats:
@@ -256,7 +252,7 @@ def set_rules(world: "HatInTimeWorld"):
if data.paintings > 0 and world.options.ShuffleSubconPaintings:
add_rule(loc, lambda state, paintings=data.paintings: has_paintings(state, world, paintings))
if data.hit_type is not HitType.none and world.options.UmbrellaLogic:
if data.hit_type != HitType.none and world.options.UmbrellaLogic:
if data.hit_type == HitType.umbrella:
add_rule(loc, lambda state: state.has("Umbrella", world.player))
@@ -518,7 +514,7 @@ def set_hard_rules(world: "HatInTimeWorld"):
lambda state: can_use_hat(state, world, HatType.ICE))
# Hard: clear Rush Hour with Brewing Hat only
if world.options.NoTicketSkips is not NoTicketSkips.option_true:
if world.options.NoTicketSkips != NoTicketSkips.option_true:
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
lambda state: can_use_hat(state, world, HatType.BREWING))
else:

View File

@@ -1,15 +1,16 @@
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
calculate_yarn_costs
calculate_yarn_costs, alps_hooks
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
get_total_locations
from .Rules import set_rules
from .Rules import set_rules, has_paintings
from .Options import AHITOptions, slot_data_options, adjust_options, RandomizeHatOrder, EndGoal, create_option_groups
from .Types import HatType, ChapterIndex, HatInTimeItem, hat_type_to_item
from .Types import HatType, ChapterIndex, HatInTimeItem, hat_type_to_item, Difficulty
from .DeathWishLocations import create_dw_regions, dw_classes, death_wishes
from .DeathWishRules import set_dw_rules, create_enemy_events, hit_list, bosses
from worlds.AutoWorld import World, WebWorld, CollectionState
from worlds.generic.Rules import add_rule
from typing import List, Dict, TextIO
from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type
from Utils import local_path
@@ -86,19 +87,27 @@ class HatInTimeWorld(World):
if self.is_dw_only():
return
# If our starting chapter is 4 and act rando isn't on, force hookshot into inventory
# If starting chapter is 3 and painting shuffle is enabled, and act rando isn't, give one free painting unlock
start_chapter: ChapterIndex = ChapterIndex(self.options.StartingChapter)
# Take care of some extremely restrictive starts in other chapters with act shuffle off
if not self.options.ActRandomizer:
start_chapter = self.options.StartingChapter
if start_chapter == ChapterIndex.ALPINE:
self.multiworld.push_precollected(self.create_item("Hookshot Badge"))
if self.options.UmbrellaLogic:
self.multiworld.push_precollected(self.create_item("Umbrella"))
if start_chapter == ChapterIndex.ALPINE or start_chapter == ChapterIndex.SUBCON:
if not self.options.ActRandomizer:
if start_chapter == ChapterIndex.ALPINE:
self.multiworld.push_precollected(self.create_item("Hookshot Badge"))
if self.options.UmbrellaLogic:
self.multiworld.push_precollected(self.create_item("Umbrella"))
if start_chapter == ChapterIndex.SUBCON and self.options.ShuffleSubconPaintings:
if self.options.ShuffleAlpineZiplines:
ziplines = list(alps_hooks.keys())
ziplines.remove("Zipline Unlock - The Twilight Bell Path") # not enough checks from this one
self.multiworld.push_precollected(self.create_item(self.random.choice(ziplines)))
elif start_chapter == ChapterIndex.SUBCON:
if self.options.ShuffleSubconPaintings:
self.multiworld.push_precollected(self.create_item("Progressive Painting Unlock"))
elif start_chapter == ChapterIndex.BIRDS:
if self.options.UmbrellaLogic:
if self.options.LogicDifficulty < Difficulty.EXPERT:
self.multiworld.push_precollected(self.create_item("Umbrella"))
elif self.options.LogicDifficulty < Difficulty.MODERATE:
self.multiworld.push_precollected(self.create_item("Umbrella"))
def create_regions(self):
# noinspection PyClassVar
@@ -119,7 +128,10 @@ class HatInTimeWorld(World):
# place vanilla contract locations if contract shuffle is off
if not self.options.ShuffleActContracts:
for name in contract_locations.keys():
self.multiworld.get_location(name, self.player).place_locked_item(create_item(self, name))
loc = self.get_location(name)
loc.place_locked_item(create_item(self, name))
if self.options.ShuffleSubconPaintings and loc.name != "Snatcher's Contract - The Subcon Well":
add_rule(loc, lambda state: has_paintings(state, self, 1))
def create_items(self):
if self.has_yarn():
@@ -317,7 +329,7 @@ class HatInTimeWorld(World):
def remove(self, state: "CollectionState", item: "Item") -> bool:
old_count: int = state.count(item.name, self.player)
change = super().collect(state, item)
change = super().remove(state, item)
if change and old_count == 1:
if "Stamp" in item.name:
if "2 Stamp" in item.name:

View File

@@ -12,41 +12,29 @@
## Instructions
1. Have Steam running. Open the Steam console with this link: [steam://open/console](steam://open/console)
This may not work for some browsers. If that's the case, and you're on Windows, open the Run dialog using Win+R,
paste the link into the box, and hit Enter.
1. **BACK UP YOUR SAVE FILES IN YOUR MAIN INSTALL IF YOU CARE ABOUT THEM!!!**
Go to `steamapps/common/HatinTime/HatinTimeGame/SaveData/` and copy everything inside that folder over to a safe place.
**This is important! Changing the game version CAN and WILL break your existing save files!!!**
2. In the Steam console, enter the following command:
`download_depot 253230 253232 7770543545116491859`. ***Wait for the console to say the download is finished!***
This can take a while to finish (30+ minutes) depending on your connection speed, so please be patient. Additionally,
**try to prevent your connection from being interrupted or slowed while Steam is downloading the depot,**
or else the download may potentially become corrupted (see first FAQ issue below).
2. In your Steam library, right-click on **A Hat in Time** in the list of games and click on **Properties**.
3. Once the download finishes, go to `steamapps/content/app_253230` in Steam's program folder.
3. Click the **Betas** tab. In the **Beta Participation** dropdown, select `tcplink`.
While it downloads, you can subscribe to the [Archipelago workshop mod.]((https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601))
4. There should be a folder named `depot_253232`. Rename it to HatinTime_AP and move it to your `steamapps/common` folder.
4. Once the game finishes downloading, start it up.
In Game Settings, make sure **Enable Developer Console** is checked.
5. In the HatinTime_AP folder, navigate to `Binaries/Win64` and create a new file: `steam_appid.txt`.
In this new text file, input the number **253230** on the first line.
6. Create a shortcut of `HatinTimeGame.exe` from that folder and move it to wherever you'd like.
You will use this shortcut to open the Archipelago-compatible version of A Hat in Time.
7. Start up the game using your new shortcut. To confirm if you are on the correct version,
go to Settings -> Game Settings. If you don't see an option labelled ***Live Game Events*** you should be running
the correct version of the game. In Game Settings, make sure ***Enable Developer Console*** is checked.
5. You should now be good to go. See below for more details on how to use the mod and connect to an Archipelago game.
## Connecting to the Archipelago server
To connect to the multiworld server, simply run the **ArchipelagoAHITClient**
(or run it from the Launcher if you have the apworld installed) and connect it to the Archipelago server.
To connect to the multiworld server, simply run the **Archipelago AHIT Client** from the Launcher
and connect it to the Archipelago server.
The game will connect to the client automatically when you create a new save file.
@@ -61,33 +49,8 @@ make sure ***Enable Developer Console*** is checked in Game Settings and press t
## FAQ/Common Issues
### I followed the setup, but I receive an odd error message upon starting the game or creating a save file!
If you receive an error message such as
**"Failed to find default engine .ini to retrieve My Documents subdirectory to use. Force quitting."** or
**"Failed to load map "hub_spaceship"** after booting up the game or creating a save file respectively, then the depot
download was likely corrupted. The only way to fix this is to start the entire download all over again.
Unfortunately, this appears to be an underlying issue with Steam's depot downloader. The only way to really prevent this
from happening is to ensure that your connection is not interrupted or slowed while downloading.
### The game keeps crashing on startup after the splash screen!
This issue is unfortunately very hard to fix, and the underlying cause is not known. If it does happen however,
try the following:
- Close Steam **entirely**.
- Open the downpatched version of the game (with Steam closed) and allow it to load to the titlescreen.
- Close the game, and then open Steam again.
- After launching the game, the issue should hopefully disappear. If not, repeat the above steps until it does.
### I followed the setup, but "Live Game Events" still shows up in the options menu!
The most common cause of this is the `steam_appid.txt` file. If you're on Windows 10, file extensions are hidden by
default (thanks Microsoft). You likely made the mistake of still naming the file `steam_appid.txt`, which, since file
extensions are hidden, would result in the file being named `steam_appid.txt.txt`, which is incorrect.
To show file extensions in Windows 10, open any folder, click the View tab at the top, and check
"File name extensions". Then you can correct the name of the file. If the name of the file is correct,
and you're still running into the issue, re-read the setup guide again in case you missed a step.
If you still can't get it to work, ask for help in the Discord thread.
### The game is running on the older version, but it's not connecting when starting a new save!
### The game is not connecting when starting a new save!
For unknown reasons, the mod will randomly disable itself in the mod menu. To fix this, go to the Mods menu
(rocket icon) in-game, and re-enable the mod.

View File

@@ -76,10 +76,6 @@ class ALttPItem(Item):
if self.type in {"SmallKey", "BigKey", "Map", "Compass"}:
return self.type
@property
def locked_dungeon_item(self):
return self.location.locked and self.dungeon_item
class LTTPRegionType(IntEnum):
LightWorld = 1

View File

@@ -660,11 +660,18 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
end
local tech
local force = game.forces["player"]
if call.parameter == nil then
game.print("ap-get-technology is only to be used by the Archipelago Factorio Client")
return
end
chunks = split(call.parameter, "\t")
local item_name = chunks[1]
local index = chunks[2]
local source = chunks[3] or "Archipelago"
if index == -1 then -- for coop sync and restoring from an older savegame
if index == nil then
game.print("ap-get-technology is only to be used by the Archipelago Factorio Client")
return
elseif index == -1 then -- for coop sync and restoring from an older savegame
tech = force.technologies[item_name]
if tech.researched ~= true then
game.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."})

View File

@@ -1,10 +1,12 @@
import typing
import re
from dataclasses import dataclass, make_dataclass
from .ExtractedData import logic_options, starts, pool_options
from .Rules import cost_terms
from schema import And, Schema, Optional
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink, PerGameCommonOptions
from .Charms import vanilla_costs, names as charm_names
if typing.TYPE_CHECKING:
@@ -538,3 +540,5 @@ hollow_knight_options: typing.Dict[str, type(Option)] = {
},
**cost_sanity_weights
}
HKOptions = make_dataclass("HKOptions", [(name, option) for name, option in hollow_knight_options.items()], bases=(PerGameCommonOptions,))

View File

@@ -49,3 +49,42 @@ def set_rules(hk_world: World):
if term == "GEO": # No geo logic!
continue
add_rule(location, lambda state, term=term, amount=amount: state.count(term, player) >= amount)
def _hk_nail_combat(state, player) -> bool:
return state.has_any({'LEFTSLASH', 'RIGHTSLASH', 'UPSLASH'}, player)
def _hk_can_beat_thk(state, player) -> bool:
return (
state.has('Opened_Black_Egg_Temple', player)
and (state.count('FIREBALL', player) + state.count('SCREAM', player) + state.count('QUAKE', player)) > 1
and _hk_nail_combat(state, player)
and (
state.has_any({'LEFTDASH', 'RIGHTDASH'}, player)
or state._hk_option(player, 'ProficientCombat')
)
and state.has('FOCUS', player)
)
def _hk_siblings_ending(state, player) -> bool:
return _hk_can_beat_thk(state, player) and state.has('WHITEFRAGMENT', player, 3)
def _hk_can_beat_radiance(state, player) -> bool:
return (
state.has('Opened_Black_Egg_Temple', player)
and _hk_nail_combat(state, player)
and state.has('WHITEFRAGMENT', player, 3)
and state.has('DREAMNAIL', player)
and (
(state.has('LEFTCLAW', player) and state.has('RIGHTCLAW', player))
or state.has('WINGS', player)
)
and (state.count('FIREBALL', player) + state.count('SCREAM', player) + state.count('QUAKE', player)) > 1
and (
(state.has('LEFTDASH', player, 2) and state.has('RIGHTDASH', player, 2)) # Both Shade Cloaks
or (state._hk_option(player, 'ProficientCombat') and state.has('QUAKE', player)) # or Dive
)
)

View File

@@ -10,9 +10,9 @@ logger = logging.getLogger("Hollow Knight")
from .Items import item_table, lookup_type_to_names, item_name_groups
from .Regions import create_regions
from .Rules import set_rules, cost_terms
from .Rules import set_rules, cost_terms, _hk_can_beat_thk, _hk_siblings_ending, _hk_can_beat_radiance
from .Options import hollow_knight_options, hollow_knight_randomize_options, Goal, WhitePalace, CostSanity, \
shop_to_option
shop_to_option, HKOptions
from .ExtractedData import locations, starts, multi_locations, location_to_region_lookup, \
event_names, item_effects, connectors, one_ways, vanilla_shop_costs, vanilla_location_costs
from .Charms import names as charm_names
@@ -142,7 +142,8 @@ class HKWorld(World):
As the enigmatic Knight, youll traverse the depths, unravel its mysteries and conquer its evils.
""" # from https://www.hollowknight.com
game: str = "Hollow Knight"
option_definitions = hollow_knight_options
options_dataclass = HKOptions
options: HKOptions
web = HKWeb()
@@ -155,8 +156,8 @@ class HKWorld(World):
charm_costs: typing.List[int]
cached_filler_items = {}
def __init__(self, world, player):
super(HKWorld, self).__init__(world, player)
def __init__(self, multiworld, player):
super(HKWorld, self).__init__(multiworld, player)
self.created_multi_locations: typing.Dict[str, typing.List[HKLocation]] = {
location: list() for location in multi_locations
}
@@ -165,29 +166,29 @@ class HKWorld(World):
self.vanilla_shop_costs = deepcopy(vanilla_shop_costs)
def generate_early(self):
world = self.multiworld
charm_costs = world.RandomCharmCosts[self.player].get_costs(world.random)
self.charm_costs = world.PlandoCharmCosts[self.player].get_costs(charm_costs)
# world.exclude_locations[self.player].value.update(white_palace_locations)
options = self.options
charm_costs = options.RandomCharmCosts.get_costs(self.random)
self.charm_costs = options.PlandoCharmCosts.get_costs(charm_costs)
# options.exclude_locations.value.update(white_palace_locations)
for term, data in cost_terms.items():
mini = getattr(world, f"Minimum{data.option}Price")[self.player]
maxi = getattr(world, f"Maximum{data.option}Price")[self.player]
mini = getattr(options, f"Minimum{data.option}Price")
maxi = getattr(options, f"Maximum{data.option}Price")
# if minimum > maximum, set minimum to maximum
mini.value = min(mini.value, maxi.value)
self.ranges[term] = mini.value, maxi.value
world.push_precollected(HKItem(starts[world.StartLocation[self.player].current_key],
self.multiworld.push_precollected(HKItem(starts[options.StartLocation.current_key],
True, None, "Event", self.player))
def white_palace_exclusions(self):
exclusions = set()
wp = self.multiworld.WhitePalace[self.player]
wp = self.options.WhitePalace
if wp <= WhitePalace.option_nopathofpain:
exclusions.update(path_of_pain_locations)
if wp <= WhitePalace.option_kingfragment:
exclusions.update(white_palace_checks)
if wp == WhitePalace.option_exclude:
exclusions.add("King_Fragment")
if self.multiworld.RandomizeCharms[self.player]:
if self.options.RandomizeCharms:
# If charms are randomized, this will be junk-filled -- so transitions and events are not progression
exclusions.update(white_palace_transitions)
exclusions.update(white_palace_events)
@@ -200,7 +201,7 @@ class HKWorld(World):
# check for any goal that godhome events are relevant to
all_event_names = event_names.copy()
if self.multiworld.Goal[self.player] in [Goal.option_godhome, Goal.option_godhome_flower]:
if self.options.Goal in [Goal.option_godhome, Goal.option_godhome_flower]:
from .GodhomeData import godhome_event_names
all_event_names.update(set(godhome_event_names))
@@ -230,12 +231,12 @@ class HKWorld(World):
pool: typing.List[HKItem] = []
wp_exclusions = self.white_palace_exclusions()
junk_replace: typing.Set[str] = set()
if self.multiworld.RemoveSpellUpgrades[self.player]:
if self.options.RemoveSpellUpgrades:
junk_replace.update(("Abyss_Shriek", "Shade_Soul", "Descending_Dark"))
randomized_starting_items = set()
for attr, items in randomizable_starting_items.items():
if getattr(self.multiworld, attr)[self.player]:
if getattr(self.options, attr):
randomized_starting_items.update(items)
# noinspection PyShadowingNames
@@ -257,7 +258,7 @@ class HKWorld(World):
if item_name in junk_replace:
item_name = self.get_filler_item_name()
item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.multiworld.AddUnshuffledLocations[self.player] else self.create_event(item_name)
item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.options.AddUnshuffledLocations else self.create_event(item_name)
if location_name == "Start":
if item_name in randomized_starting_items:
@@ -281,55 +282,55 @@ class HKWorld(World):
location.progress_type = LocationProgressType.EXCLUDED
for option_key, option in hollow_knight_randomize_options.items():
randomized = getattr(self.multiworld, option_key)[self.player]
if all([not randomized, option_key in logicless_options, not self.multiworld.AddUnshuffledLocations[self.player]]):
randomized = getattr(self.options, option_key)
if all([not randomized, option_key in logicless_options, not self.options.AddUnshuffledLocations]):
continue
for item_name, location_name in zip(option.items, option.locations):
if item_name in junk_replace:
item_name = self.get_filler_item_name()
if (item_name == "Crystal_Heart" and self.multiworld.SplitCrystalHeart[self.player]) or \
(item_name == "Mothwing_Cloak" and self.multiworld.SplitMothwingCloak[self.player]):
if (item_name == "Crystal_Heart" and self.options.SplitCrystalHeart) or \
(item_name == "Mothwing_Cloak" and self.options.SplitMothwingCloak):
_add("Left_" + item_name, location_name, randomized)
_add("Right_" + item_name, "Split_" + location_name, randomized)
continue
if item_name == "Mantis_Claw" and self.multiworld.SplitMantisClaw[self.player]:
if item_name == "Mantis_Claw" and self.options.SplitMantisClaw:
_add("Left_" + item_name, "Left_" + location_name, randomized)
_add("Right_" + item_name, "Right_" + location_name, randomized)
continue
if item_name == "Shade_Cloak" and self.multiworld.SplitMothwingCloak[self.player]:
if self.multiworld.random.randint(0, 1):
if item_name == "Shade_Cloak" and self.options.SplitMothwingCloak:
if self.random.randint(0, 1):
item_name = "Left_Mothwing_Cloak"
else:
item_name = "Right_Mothwing_Cloak"
if item_name == "Grimmchild2" and self.multiworld.RandomizeGrimmkinFlames[self.player] and self.multiworld.RandomizeCharms[self.player]:
if item_name == "Grimmchild2" and self.options.RandomizeGrimmkinFlames and self.options.RandomizeCharms:
_add("Grimmchild1", location_name, randomized)
continue
_add(item_name, location_name, randomized)
if self.multiworld.RandomizeElevatorPass[self.player]:
if self.options.RandomizeElevatorPass:
randomized = True
_add("Elevator_Pass", "Elevator_Pass", randomized)
for shop, locations in self.created_multi_locations.items():
for _ in range(len(locations), getattr(self.multiworld, shop_to_option[shop])[self.player].value):
for _ in range(len(locations), getattr(self.options, shop_to_option[shop]).value):
loc = self.create_location(shop)
unfilled_locations += 1
# Balance the pool
item_count = len(pool)
additional_shop_items = max(item_count - unfilled_locations, self.multiworld.ExtraShopSlots[self.player].value)
additional_shop_items = max(item_count - unfilled_locations, self.options.ExtraShopSlots.value)
# Add additional shop items, as needed.
if additional_shop_items > 0:
shops = list(shop for shop, locations in self.created_multi_locations.items() if len(locations) < 16)
if not self.multiworld.EggShopSlots[self.player].value: # No eggshop, so don't place items there
if not self.options.EggShopSlots: # No eggshop, so don't place items there
shops.remove('Egg_Shop')
if shops:
for _ in range(additional_shop_items):
shop = self.multiworld.random.choice(shops)
shop = self.random.choice(shops)
loc = self.create_location(shop)
unfilled_locations += 1
if len(self.created_multi_locations[shop]) >= 16:
@@ -355,7 +356,7 @@ class HKWorld(World):
loc.costs = costs
def apply_costsanity(self):
setting = self.multiworld.CostSanity[self.player].value
setting = self.options.CostSanity.value
if not setting:
return # noop
@@ -369,10 +370,10 @@ class HKWorld(World):
return {k: v for k, v in weights.items() if v}
random = self.multiworld.random
hybrid_chance = getattr(self.multiworld, f"CostSanityHybridChance")[self.player].value
random = self.random
hybrid_chance = getattr(self.options, f"CostSanityHybridChance").value
weights = {
data.term: getattr(self.multiworld, f"CostSanity{data.option}Weight")[self.player].value
data.term: getattr(self.options, f"CostSanity{data.option}Weight").value
for data in cost_terms.values()
}
weights_geoless = dict(weights)
@@ -427,22 +428,22 @@ class HKWorld(World):
location.sort_costs()
def set_rules(self):
world = self.multiworld
multiworld = self.multiworld
player = self.player
goal = world.Goal[player]
goal = self.options.Goal
if goal == Goal.option_hollowknight:
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player)
multiworld.completion_condition[player] = lambda state: _hk_can_beat_thk(state, player)
elif goal == Goal.option_siblings:
world.completion_condition[player] = lambda state: state._hk_siblings_ending(player)
multiworld.completion_condition[player] = lambda state: _hk_siblings_ending(state, player)
elif goal == Goal.option_radiance:
world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player)
multiworld.completion_condition[player] = lambda state: _hk_can_beat_radiance(state, player)
elif goal == Goal.option_godhome:
world.completion_condition[player] = lambda state: state.count("Defeated_Pantheon_5", player)
multiworld.completion_condition[player] = lambda state: state.count("Defeated_Pantheon_5", player)
elif goal == Goal.option_godhome_flower:
world.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player)
multiworld.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player)
else:
# Any goal
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) or state._hk_can_beat_radiance(player)
multiworld.completion_condition[player] = lambda state: _hk_can_beat_thk(state, player) or _hk_can_beat_radiance(state, player)
set_rules(self)
@@ -450,8 +451,8 @@ class HKWorld(World):
slot_data = {}
options = slot_data["options"] = {}
for option_name in self.option_definitions:
option = getattr(self.multiworld, option_name)[self.player]
for option_name in hollow_knight_options:
option = getattr(self.options, option_name)
try:
optionvalue = int(option.value)
except TypeError:
@@ -460,10 +461,10 @@ class HKWorld(World):
options[option_name] = optionvalue
# 32 bit int
slot_data["seed"] = self.multiworld.per_slot_randoms[self.player].randint(-2147483647, 2147483646)
slot_data["seed"] = self.random.randint(-2147483647, 2147483646)
# Backwards compatibility for shop cost data (HKAP < 0.1.0)
if not self.multiworld.CostSanity[self.player]:
if not self.options.CostSanity:
for shop, terms in shop_cost_types.items():
unit = cost_terms[next(iter(terms))].option
if unit == "Geo":
@@ -498,7 +499,7 @@ class HKWorld(World):
basename = name
if name in shop_cost_types:
costs = {
term: self.multiworld.random.randint(*self.ranges[term])
term: self.random.randint(*self.ranges[term])
for term in shop_cost_types[name]
}
elif name in vanilla_location_costs:
@@ -512,7 +513,7 @@ class HKWorld(World):
region = self.multiworld.get_region("Menu", self.player)
if vanilla and not self.multiworld.AddUnshuffledLocations[self.player]:
if vanilla and not self.options.AddUnshuffledLocations:
loc = HKLocation(self.player, name,
None, region, costs=costs, vanilla=vanilla,
basename=basename)
@@ -554,31 +555,32 @@ class HKWorld(World):
for effect_name, effect_value in item_effects.get(item.name, {}).items():
if state.prog_items[item.player][effect_name] == effect_value:
del state.prog_items[item.player][effect_name]
state.prog_items[item.player][effect_name] -= effect_value
else:
state.prog_items[item.player][effect_name] -= effect_value
return change
@classmethod
def stage_write_spoiler(cls, world: MultiWorld, spoiler_handle):
hk_players = world.get_game_players(cls.game)
def stage_write_spoiler(cls, multiworld: MultiWorld, spoiler_handle):
hk_players = multiworld.get_game_players(cls.game)
spoiler_handle.write('\n\nCharm Notches:')
for player in hk_players:
name = world.get_player_name(player)
name = multiworld.get_player_name(player)
spoiler_handle.write(f'\n{name}\n')
hk_world: HKWorld = world.worlds[player]
hk_world: HKWorld = multiworld.worlds[player]
for charm_number, cost in enumerate(hk_world.charm_costs):
spoiler_handle.write(f"\n{charm_names[charm_number]}: {cost}")
spoiler_handle.write('\n\nShop Prices:')
for player in hk_players:
name = world.get_player_name(player)
name = multiworld.get_player_name(player)
spoiler_handle.write(f'\n{name}\n')
hk_world: HKWorld = world.worlds[player]
hk_world: HKWorld = multiworld.worlds[player]
if world.CostSanity[player].value:
if hk_world.options.CostSanity:
for loc in sorted(
(
loc for loc in itertools.chain(*(region.locations for region in world.get_regions(player)))
loc for loc in itertools.chain(*(region.locations for region in multiworld.get_regions(player)))
if loc.costs
), key=operator.attrgetter('name')
):
@@ -602,15 +604,15 @@ class HKWorld(World):
'RandomizeGeoRocks', 'RandomizeSoulTotems', 'RandomizeLoreTablets', 'RandomizeJunkPitChests',
'RandomizeRancidEggs'
):
if getattr(self.multiworld, group):
if getattr(self.options, group):
fillers.extend(item for item in hollow_knight_randomize_options[group].items if item not in
exclusions)
self.cached_filler_items[self.player] = fillers
return self.multiworld.random.choice(self.cached_filler_items[self.player])
return self.random.choice(self.cached_filler_items[self.player])
def create_region(world: MultiWorld, player: int, name: str, location_names=None) -> Region:
ret = Region(name, player, world)
def create_region(multiworld: MultiWorld, player: int, name: str, location_names=None) -> Region:
ret = Region(name, player, multiworld)
if location_names:
for location in location_names:
loc_id = HKWorld.location_name_to_id.get(location, None)
@@ -683,42 +685,7 @@ class HKLogicMixin(LogicMixin):
return sum(self.multiworld.worlds[player].charm_costs[notch] for notch in notches)
def _hk_option(self, player: int, option_name: str) -> int:
return getattr(self.multiworld, option_name)[player].value
return getattr(self.multiworld.worlds[player].options, option_name).value
def _hk_start(self, player, start_location: str) -> bool:
return self.multiworld.StartLocation[player] == start_location
def _hk_nail_combat(self, player: int) -> bool:
return self.has_any({'LEFTSLASH', 'RIGHTSLASH', 'UPSLASH'}, player)
def _hk_can_beat_thk(self, player: int) -> bool:
return (
self.has('Opened_Black_Egg_Temple', player)
and (self.count('FIREBALL', player) + self.count('SCREAM', player) + self.count('QUAKE', player)) > 1
and self._hk_nail_combat(player)
and (
self.has_any({'LEFTDASH', 'RIGHTDASH'}, player)
or self._hk_option(player, 'ProficientCombat')
)
and self.has('FOCUS', player)
)
def _hk_siblings_ending(self, player: int) -> bool:
return self._hk_can_beat_thk(player) and self.has('WHITEFRAGMENT', player, 3)
def _hk_can_beat_radiance(self, player: int) -> bool:
return (
self.has('Opened_Black_Egg_Temple', player)
and self._hk_nail_combat(player)
and self.has('WHITEFRAGMENT', player, 3)
and self.has('DREAMNAIL', player)
and (
(self.has('LEFTCLAW', player) and self.has('RIGHTCLAW', player))
or self.has('WINGS', player)
)
and (self.count('FIREBALL', player) + self.count('SCREAM', player) + self.count('QUAKE', player)) > 1
and (
(self.has('LEFTDASH', player, 2) and self.has('RIGHTDASH', player, 2)) # Both Shade Cloaks
or (self._hk_option(player, 'ProficientCombat') and self.has('QUAKE', player)) # or Dive
)
)
return self.multiworld.worlds[player].options.StartLocation == start_location

View File

@@ -9,7 +9,7 @@ from worlds.AutoWorld import WebWorld, World
from .datatypes import Room, RoomEntrance
from .items import ALL_ITEM_TABLE, ITEMS_BY_GROUP, TRAP_ITEMS, LingoItem
from .locations import ALL_LOCATION_TABLE, LOCATIONS_BY_GROUP
from .options import LingoOptions, lingo_option_groups
from .options import LingoOptions, lingo_option_groups, SunwarpAccess, VictoryCondition
from .player_logic import LingoPlayerLogic
from .regions import create_regions
@@ -54,14 +54,17 @@ class LingoWorld(World):
player_logic: LingoPlayerLogic
def generate_early(self):
if not (self.options.shuffle_doors or self.options.shuffle_colors or self.options.shuffle_sunwarps):
if not (self.options.shuffle_doors or self.options.shuffle_colors or
(self.options.sunwarp_access >= SunwarpAccess.option_unlock and
self.options.victory_condition == VictoryCondition.option_pilgrimage)):
if self.multiworld.players == 1:
warning(f"{self.multiworld.get_player_name(self.player)}'s Lingo world doesn't have any progression"
f" items. Please turn on Door Shuffle, Color Shuffle, or Sunwarp Shuffle if that doesn't seem"
f" right.")
warning(f"{self.player_name}'s Lingo world doesn't have any progression items. Please turn on Door"
f" Shuffle or Color Shuffle, or use item-blocked sunwarps with the Pilgrimage victory condition"
f" if that doesn't seem right.")
else:
raise OptionError(f"{self.multiworld.get_player_name(self.player)}'s Lingo world doesn't have any"
f" progression items. Please turn on Door Shuffle, Color Shuffle or Sunwarp Shuffle.")
raise OptionError(f"{self.player_name}'s Lingo world doesn't have any progression items. Please turn on"
f" Door Shuffle or Color Shuffle, or use item-blocked sunwarps with the Pilgrimage"
f" victory condition.")
self.player_logic = LingoPlayerLogic(self)
@@ -167,7 +170,8 @@ class LingoWorld(World):
slot_options = [
"death_link", "victory_condition", "shuffle_colors", "shuffle_doors", "shuffle_paintings", "shuffle_panels",
"enable_pilgrimage", "sunwarp_access", "mastery_achievements", "level_2_requirement", "location_checks",
"early_color_hallways", "pilgrimage_allows_roof_access", "pilgrimage_allows_paintings", "shuffle_sunwarps"
"early_color_hallways", "pilgrimage_allows_roof_access", "pilgrimage_allows_paintings", "shuffle_sunwarps",
"group_doors"
]
slot_data = {

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1478,3 +1478,145 @@ progression:
Progressive Art Gallery: 444563
Progressive Colorful: 444580
Progressive Pilgrimage: 444583
Progressive Suits Area: 444602
Progressive Symmetry Room: 444608
Progressive Number Hunt: 444654
panel_doors:
Starting Room:
HIDDEN: 444589
Hidden Room:
OPEN: 444590
Hub Room:
ORDER: 444591
SLAUGHTER: 444592
TRACE: 444594
RAT: 444595
OPEN: 444596
Crossroads:
DECAY: 444597
NOPE: 444598
WE ROT: 444599
WORDS SWORD: 444600
BEND HI: 444601
Lost Area:
LOST: 444603
Amen Name Area:
AMEN NAME: 444604
The Tenacious:
Black Palindromes: 444605
Near Far Area:
NEAR FAR: 444606
Warts Straw Area:
WARTS STRAW: 444609
Leaf Feel Area:
LEAF FEEL: 444610
Outside The Agreeable:
MASSACRED: 444611
BLACK: 444612
CLOSE: 444613
RIGHT: 444614
Compass Room:
Lookout: 444615
Hedge Maze:
DOWN: 444617
The Perceptive:
GAZE: 444618
The Observant:
BACKSIDE: 444619
STAIRS: 444621
The Incomparable:
Giant Sevens: 444622
Orange Tower:
Access: 444623
Orange Tower First Floor:
SECRET: 444624
Orange Tower Fourth Floor:
HOT CRUSTS: 444625
Orange Tower Fifth Floor:
SIZE: 444626
First Second Third Fourth:
FIRST SECOND THIRD FOURTH: 444627
The Colorful (White):
BEGIN: 444628
The Colorful (Black):
FOUND: 444630
The Colorful (Red):
LOAF: 444631
The Colorful (Yellow):
CREAM: 444632
The Colorful (Blue):
SUN: 444633
The Colorful (Purple):
SPOON: 444634
The Colorful (Orange):
LETTERS: 444635
The Colorful (Green):
WALLS: 444636
The Colorful (Brown):
IRON: 444637
The Colorful (Gray):
OBSTACLE: 444638
Owl Hallway:
STRAYS: 444639
Outside The Initiated:
UNCOVER: 444640
OXEN: 444641
Outside The Bold:
UNOPEN: 444642
BEGIN: 444643
Outside The Undeterred:
ZERO: 444644
PEN: 444645
TWO: 444646
THREE: 444647
FOUR: 444648
Number Hunt:
FIVE: 444649
SIX: 444650
SEVEN: 444651
EIGHT: 444652
NINE: 444653
Color Hunt:
EXIT: 444655
RED: 444656
BLUE: 444658
YELLOW: 444659
ORANGE: 444660
PURPLE: 444661
GREEN: 444662
The Bearer:
FARTHER: 444663
MIDDLE: 444664
Knight Night (Final):
TRUSTED: 444665
Outside The Wondrous:
SHRINK: 444666
Hallway Room (1):
CASTLE: 444667
Hallway Room (2):
COUNTERCLOCKWISE: 444669
Hallway Room (3):
TRANSFORMATION: 444670
Hallway Room (4):
WHEELBARROW: 444671
Outside The Wanderer:
WANDERLUST: 444672
Art Gallery:
ORDER: 444673
Room Room:
STAIRS: 444674
Colors: 444676
Outside The Wise:
KITTEN CAT: 444677
Outside The Scientific:
OPEN: 444678
Directional Gallery:
TURN LEARN: 444679
panel_groups:
Tenacious Entrance Panels: 444593
Symmetry Room Panels: 444607
Backside Entrance Panels: 444620
Colorful Panels: 444629
Color Hunt Panels: 444657
Hallway Room Panels: 444668
Room Room Panels: 444675

View File

@@ -12,6 +12,11 @@ class RoomAndPanel(NamedTuple):
panel: str
class RoomAndPanelDoor(NamedTuple):
room: Optional[str]
panel_door: str
class EntranceType(Flag):
NORMAL = auto()
PAINTING = auto()
@@ -63,9 +68,15 @@ class Panel(NamedTuple):
exclude_reduce: bool
achievement: bool
non_counting: bool
panel_door: Optional[RoomAndPanelDoor] # This will always be fully specified.
location_name: Optional[str]
class PanelDoor(NamedTuple):
item_name: str
panel_group: Optional[str]
class Painting(NamedTuple):
id: str
room: str

View File

@@ -3,7 +3,7 @@ from typing import Dict, List, NamedTuple, Set
from BaseClasses import Item, ItemClassification
from .static_logic import DOORS_BY_ROOM, PROGRESSIVE_ITEMS, get_door_group_item_id, get_door_item_id, \
get_progressive_item_id, get_special_item_id
get_progressive_item_id, get_special_item_id, PANEL_DOORS_BY_ROOM, get_panel_door_item_id, get_panel_group_item_id
class ItemType(Enum):
@@ -65,6 +65,21 @@ def load_item_data():
ItemClassification.progression, ItemType.NORMAL, True, [])
ITEMS_BY_GROUP.setdefault("Doors", []).append(group)
panel_groups: Set[str] = set()
for room_name, panel_doors in PANEL_DOORS_BY_ROOM.items():
for panel_door_name, panel_door in panel_doors.items():
if panel_door.panel_group is not None:
panel_groups.add(panel_door.panel_group)
ALL_ITEM_TABLE[panel_door.item_name] = ItemData(get_panel_door_item_id(room_name, panel_door_name),
ItemClassification.progression, ItemType.NORMAL, False, [])
ITEMS_BY_GROUP.setdefault("Panels", []).append(panel_door.item_name)
for group in panel_groups:
ALL_ITEM_TABLE[group] = ItemData(get_panel_group_item_id(group), ItemClassification.progression,
ItemType.NORMAL, False, [])
ITEMS_BY_GROUP.setdefault("Panels", []).append(group)
special_items: Dict[str, ItemClassification] = {
":)": ItemClassification.filler,
"The Feeling of Being Lost": ItemClassification.filler,

View File

@@ -8,21 +8,31 @@ from .items import TRAP_ITEMS
class ShuffleDoors(Choice):
"""If on, opening doors will require their respective "keys".
"""This option specifies how doors open.
- **Simple:** Doors are sorted into logical groups, which are all opened by
receiving an item.
- **Complex:** The items are much more granular, and will usually only open
a single door each.
- **None:** Doors in the game will open the way they do in vanilla.
- **Panels:** Doors still open as in vanilla, but the panels that open the
doors will be locked, and an item will be required to unlock the panels.
- **Doors:** the doors themselves are locked behind items, and will open
automatically without needing to solve a panel once the key is obtained.
"""
display_name = "Shuffle Doors"
option_none = 0
option_simple = 1
option_complex = 2
option_panels = 1
option_doors = 2
alias_simple = 2
alias_complex = 2
class GroupDoors(Toggle):
"""By default, door shuffle in either panels or doors mode will create individual keys for every panel or door to be locked.
When group doors is on, some panels and doors are sorted into logical groups, which are opened together by receiving an item."""
display_name = "Group Doors"
class ProgressiveOrangeTower(DefaultOnToggle):
"""When "Shuffle Doors" is on, this setting governs the manner in which the Orange Tower floors open up.
"""When "Shuffle Doors" is on doors mode, this setting governs the manner in which the Orange Tower floors open up.
- **Off:** There is an item for each floor of the tower, and each floor's
item is the only one needed to access that floor.
@@ -33,7 +43,7 @@ class ProgressiveOrangeTower(DefaultOnToggle):
class ProgressiveColorful(DefaultOnToggle):
"""When "Shuffle Doors" is on "complex", this setting governs the manner in which The Colorful opens up.
"""When "Shuffle Doors" is on either panels or doors mode and "Group Doors" is off, this setting governs the manner in which The Colorful opens up.
- **Off:** There is an item for each room of The Colorful, meaning that
random rooms in the middle of the sequence can open up without giving you
@@ -253,6 +263,7 @@ lingo_option_groups = [
@dataclass
class LingoOptions(PerGameCommonOptions):
shuffle_doors: ShuffleDoors
group_doors: GroupDoors
progressive_orange_tower: ProgressiveOrangeTower
progressive_colorful: ProgressiveColorful
location_checks: LocationChecks

View File

@@ -7,8 +7,8 @@ from .items import ALL_ITEM_TABLE, ItemType
from .locations import ALL_LOCATION_TABLE, LocationClassification
from .options import LocationChecks, ShuffleDoors, SunwarpAccess, VictoryCondition
from .static_logic import DOORS_BY_ROOM, PAINTINGS, PAINTING_ENTRANCES, PAINTING_EXITS, \
PANELS_BY_ROOM, PROGRESSION_BY_ROOM, REQUIRED_PAINTING_ROOMS, REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS, \
SUNWARP_ENTRANCES, SUNWARP_EXITS
PANELS_BY_ROOM, REQUIRED_PAINTING_ROOMS, REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS, PROGRESSIVE_DOORS_BY_ROOM, \
PANEL_DOORS_BY_ROOM, PROGRESSIVE_PANELS_BY_ROOM, SUNWARP_ENTRANCES, SUNWARP_EXITS
if TYPE_CHECKING:
from . import LingoWorld
@@ -18,6 +18,8 @@ class AccessRequirements:
rooms: Set[str]
doors: Set[RoomAndDoor]
colors: Set[str]
items: Set[str]
progression: Dict[str, int]
the_master: bool
postgame: bool
@@ -25,6 +27,8 @@ class AccessRequirements:
self.rooms = set()
self.doors = set()
self.colors = set()
self.items = set()
self.progression = dict()
self.the_master = False
self.postgame = False
@@ -32,12 +36,17 @@ class AccessRequirements:
self.rooms |= other.rooms
self.doors |= other.doors
self.colors |= other.colors
self.items |= other.items
self.the_master |= other.the_master
self.postgame |= other.postgame
for progression, index in other.progression.items():
if progression not in self.progression or index > self.progression[progression]:
self.progression[progression] = index
def __str__(self):
return f"AccessRequirements(rooms={self.rooms}, doors={self.doors}, colors={self.colors}," \
f" the_master={self.the_master}, postgame={self.postgame})"
return f"AccessRequirements(rooms={self.rooms}, doors={self.doors}, colors={self.colors}, items={self.items}," \
f" progression={self.progression}), the_master={self.the_master}, postgame={self.postgame}"
class PlayerLocation(NamedTuple):
@@ -117,15 +126,15 @@ class LingoPlayerLogic:
self.item_by_door.setdefault(room, {})[door] = item
def handle_non_grouped_door(self, room_name: str, door_data: Door, world: "LingoWorld"):
if room_name in PROGRESSION_BY_ROOM and door_data.name in PROGRESSION_BY_ROOM[room_name]:
progression_name = PROGRESSION_BY_ROOM[room_name][door_data.name].item_name
if room_name in PROGRESSIVE_DOORS_BY_ROOM and door_data.name in PROGRESSIVE_DOORS_BY_ROOM[room_name]:
progression_name = PROGRESSIVE_DOORS_BY_ROOM[room_name][door_data.name].item_name
progression_handling = should_split_progression(progression_name, world)
if progression_handling == ProgressiveItemBehavior.SPLIT:
self.set_door_item(room_name, door_data.name, door_data.item_name)
self.real_items.append(door_data.item_name)
elif progression_handling == ProgressiveItemBehavior.PROGRESSIVE:
progressive_item_name = PROGRESSION_BY_ROOM[room_name][door_data.name].item_name
progressive_item_name = PROGRESSIVE_DOORS_BY_ROOM[room_name][door_data.name].item_name
self.set_door_item(room_name, door_data.name, progressive_item_name)
self.real_items.append(progressive_item_name)
else:
@@ -156,17 +165,31 @@ class LingoPlayerLogic:
victory_condition = world.options.victory_condition
early_color_hallways = world.options.early_color_hallways
if location_checks == LocationChecks.option_reduced and door_shuffle != ShuffleDoors.option_none:
raise OptionError("You cannot have reduced location checks when door shuffle is on, because there would not"
" be enough locations for all of the door items.")
if location_checks == LocationChecks.option_reduced:
if door_shuffle == ShuffleDoors.option_doors:
raise OptionError(f"Slot \"{world.player_name}\" cannot have reduced location checks when door shuffle"
f" is on, because there would not be enough locations for all of the door items.")
if door_shuffle == ShuffleDoors.option_panels:
if not world.options.group_doors:
raise OptionError(f"Slot \"{world.player_name}\" cannot have reduced location checks when ungrouped"
f" panels mode door shuffle is on, because there would not be enough locations for"
f" all of the panel items.")
if color_shuffle:
raise OptionError(f"Slot \"{world.player_name}\" cannot have reduced location checks with both"
f" panels mode door shuffle and color shuffle because there would not be enough"
f" locations for all of the items.")
if world.options.sunwarp_access >= SunwarpAccess.option_individual:
raise OptionError(f"Slot \"{world.player_name}\" cannot have reduced location checks with both"
f" panels mode door shuffle and individual or progressive sunwarp access because"
f" there would not be enough locations for all of the items.")
# Create door items, where needed.
door_groups: Set[str] = set()
for room_name, room_data in DOORS_BY_ROOM.items():
for door_name, door_data in room_data.items():
if door_data.skip_item is False and door_data.event is False:
if door_data.type == DoorType.NORMAL and door_shuffle != ShuffleDoors.option_none:
if door_data.door_group is not None and door_shuffle == ShuffleDoors.option_simple:
if door_data.type == DoorType.NORMAL and door_shuffle == ShuffleDoors.option_doors:
if door_data.door_group is not None and world.options.group_doors:
# Grouped doors are handled differently if shuffle doors is on simple.
self.set_door_item(room_name, door_name, door_data.door_group)
door_groups.add(door_data.door_group)
@@ -188,7 +211,29 @@ class LingoPlayerLogic:
self.real_items.append(door_data.item_name)
self.real_items += door_groups
# Create panel items, where needed.
if world.options.shuffle_doors == ShuffleDoors.option_panels:
panel_groups: Set[str] = set()
for room_name, room_data in PANEL_DOORS_BY_ROOM.items():
for panel_door_name, panel_door_data in room_data.items():
if panel_door_data.panel_group is not None and world.options.group_doors:
panel_groups.add(panel_door_data.panel_group)
elif room_name in PROGRESSIVE_PANELS_BY_ROOM \
and panel_door_name in PROGRESSIVE_PANELS_BY_ROOM[room_name]:
progression_obj = PROGRESSIVE_PANELS_BY_ROOM[room_name][panel_door_name]
progression_handling = should_split_progression(progression_obj.item_name, world)
if progression_handling == ProgressiveItemBehavior.SPLIT:
self.real_items.append(panel_door_data.item_name)
elif progression_handling == ProgressiveItemBehavior.PROGRESSIVE:
self.real_items.append(progression_obj.item_name)
else:
self.real_items.append(panel_door_data.item_name)
self.real_items += panel_groups
# Create color items, if needed.
if color_shuffle:
self.real_items += [name for name, item in ALL_ITEM_TABLE.items() if item.type == ItemType.COLOR]
@@ -244,7 +289,7 @@ class LingoPlayerLogic:
elif location_checks == LocationChecks.option_insanity:
location_classification = LocationClassification.insanity
if door_shuffle != ShuffleDoors.option_none and not early_color_hallways:
if door_shuffle == ShuffleDoors.option_doors and not early_color_hallways:
location_classification |= LocationClassification.small_sphere_one
for location_name, location_data in ALL_LOCATION_TABLE.items():
@@ -286,7 +331,7 @@ class LingoPlayerLogic:
"iterations. This is very unlikely to happen on its own, and probably indicates some "
"kind of logic error.")
if door_shuffle != ShuffleDoors.option_none and location_checks != LocationChecks.option_insanity \
if door_shuffle == ShuffleDoors.option_doors and location_checks != LocationChecks.option_insanity \
and not early_color_hallways and world.multiworld.players > 1:
# Under the combination of door shuffle, normal location checks, and no early color hallways, sphere 1 is
# only three checks. In a multiplayer situation, this can be frustrating for the player because they are
@@ -301,19 +346,19 @@ class LingoPlayerLogic:
# Starting Room - Exit Door gives access to OPEN and TRACE.
good_item_options: List[str] = ["Starting Room - Back Right Door", "Second Room - Exit Door"]
if not color_shuffle and not world.options.enable_pilgrimage:
# HOT CRUST and THIS.
good_item_options.append("Pilgrim Room - Sun Painting")
if not color_shuffle:
if door_shuffle == ShuffleDoors.option_simple:
if not world.options.enable_pilgrimage:
# HOT CRUST and THIS.
good_item_options.append("Pilgrim Room - Sun Painting")
if world.options.group_doors:
# WELCOME BACK, CLOCKWISE, and DRAWL + RUNS.
good_item_options.append("Welcome Back Doors")
else:
# WELCOME BACK and CLOCKWISE.
good_item_options.append("Welcome Back Area - Shortcut to Starting Room")
if door_shuffle == ShuffleDoors.option_simple:
if world.options.group_doors:
# Color hallways access (NOTE: reconsider when sunwarp shuffling exists).
good_item_options.append("Rhyme Room Doors")
@@ -359,13 +404,11 @@ class LingoPlayerLogic:
def randomize_paintings(self, world: "LingoWorld") -> bool:
self.painting_mapping.clear()
door_shuffle = world.options.shuffle_doors
# First, assign mappings to the required-exit paintings. We ensure that req-blocked paintings do not lead to
# required paintings.
req_exits = []
required_painting_rooms = REQUIRED_PAINTING_ROOMS
if door_shuffle == ShuffleDoors.option_none:
if world.options.shuffle_doors != ShuffleDoors.option_doors:
required_painting_rooms += REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS
req_exits = [painting_id for painting_id, painting in PAINTINGS.items() if painting.required_when_no_doors]
@@ -432,7 +475,7 @@ class LingoPlayerLogic:
for painting_id, painting in PAINTINGS.items():
if painting_id not in self.painting_mapping.values() \
and (painting.required or (painting.required_when_no_doors and
door_shuffle == ShuffleDoors.option_none)):
world.options.shuffle_doors != ShuffleDoors.option_doors)):
return False
return True
@@ -447,12 +490,31 @@ class LingoPlayerLogic:
access_reqs = AccessRequirements()
panel_object = PANELS_BY_ROOM[room][panel]
if world.options.shuffle_doors == ShuffleDoors.option_panels and panel_object.panel_door is not None:
panel_door_room = panel_object.panel_door.room
panel_door_name = panel_object.panel_door.panel_door
panel_door = PANEL_DOORS_BY_ROOM[panel_door_room][panel_door_name]
if panel_door.panel_group is not None and world.options.group_doors:
access_reqs.items.add(panel_door.panel_group)
elif panel_door_room in PROGRESSIVE_PANELS_BY_ROOM\
and panel_door_name in PROGRESSIVE_PANELS_BY_ROOM[panel_door_room]:
progression_obj = PROGRESSIVE_PANELS_BY_ROOM[panel_door_room][panel_door_name]
progression_handling = should_split_progression(progression_obj.item_name, world)
if progression_handling == ProgressiveItemBehavior.SPLIT:
access_reqs.items.add(panel_door.item_name)
elif progression_handling == ProgressiveItemBehavior.PROGRESSIVE:
access_reqs.progression[progression_obj.item_name] = progression_obj.index
else:
access_reqs.items.add(panel_door.item_name)
for req_room in panel_object.required_rooms:
access_reqs.rooms.add(req_room)
for req_door in panel_object.required_doors:
door_object = DOORS_BY_ROOM[room if req_door.room is None else req_door.room][req_door.door]
if door_object.event or world.options.shuffle_doors == ShuffleDoors.option_none:
if door_object.event or world.options.shuffle_doors != ShuffleDoors.option_doors:
sub_access_reqs = self.calculate_door_requirements(
room if req_door.room is None else req_door.room, req_door.door, world)
access_reqs.merge(sub_access_reqs)
@@ -522,11 +584,14 @@ class LingoPlayerLogic:
continue
# We won't coalesce any panels that have requirements beyond colors. To simplify things for now, we will
# only coalesce single-color panels. Chains/stacks/combo puzzles will be separate. THE MASTER has
# special access rules and is handled separately.
# only coalesce single-color panels. Chains/stacks/combo puzzles will be separate. Panel door locked
# puzzles will be separate if panels mode is on. THE MASTER has special access rules and is handled
# separately.
if len(panel_data.required_panels) > 0 or len(panel_data.required_doors) > 0\
or len(panel_data.required_rooms) > 0\
or (world.options.shuffle_colors and len(panel_data.colors) > 1)\
or (world.options.shuffle_doors == ShuffleDoors.option_panels
and panel_data.panel_door is not None)\
or panel_name == "THE MASTER":
self.counting_panel_reqs.setdefault(room_name, []).append(
(self.calculate_panel_requirements(room_name, panel_name, world), 1))

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
from BaseClasses import CollectionState
from .datatypes import RoomAndDoor
from .player_logic import AccessRequirements, PlayerLocation
from .static_logic import PROGRESSION_BY_ROOM, PROGRESSIVE_ITEMS
from .static_logic import PROGRESSIVE_DOORS_BY_ROOM, PROGRESSIVE_ITEMS
if TYPE_CHECKING:
from . import LingoWorld
@@ -59,6 +59,12 @@ def _lingo_can_satisfy_requirements(state: CollectionState, access: AccessRequir
if not state.has(color.capitalize(), world.player):
return False
if not all(state.has(item, world.player) for item in access.items):
return False
if not all(state.has(item, world.player, index) for item, index in access.progression.items()):
return False
if access.the_master and not lingo_can_use_mastery_location(state, world):
return False
@@ -77,7 +83,7 @@ def _lingo_can_open_door(state: CollectionState, room: str, door: str, world: "L
item_name = world.player_logic.item_by_door[room][door]
if item_name in PROGRESSIVE_ITEMS:
progression = PROGRESSION_BY_ROOM[room][door]
progression = PROGRESSIVE_DOORS_BY_ROOM[room][door]
return state.has(item_name, world.player, progression.index)
return state.has(item_name, world.player)

View File

@@ -4,15 +4,17 @@ import pickle
from io import BytesIO
from typing import Dict, List, Set
from .datatypes import Door, Painting, Panel, Progression, Room
from .datatypes import Door, Painting, Panel, PanelDoor, Progression, Room
ALL_ROOMS: List[Room] = []
DOORS_BY_ROOM: Dict[str, Dict[str, Door]] = {}
PANELS_BY_ROOM: Dict[str, Dict[str, Panel]] = {}
PANEL_DOORS_BY_ROOM: Dict[str, Dict[str, PanelDoor]] = {}
PAINTINGS: Dict[str, Painting] = {}
PROGRESSIVE_ITEMS: List[str] = []
PROGRESSION_BY_ROOM: Dict[str, Dict[str, Progression]] = {}
PROGRESSIVE_ITEMS: Set[str] = set()
PROGRESSIVE_DOORS_BY_ROOM: Dict[str, Dict[str, Progression]] = {}
PROGRESSIVE_PANELS_BY_ROOM: Dict[str, Dict[str, Progression]] = {}
PAINTING_ENTRANCES: int = 0
PAINTING_EXIT_ROOMS: Set[str] = set()
@@ -28,6 +30,8 @@ PANEL_LOCATION_IDS: Dict[str, Dict[str, int]] = {}
DOOR_LOCATION_IDS: Dict[str, Dict[str, int]] = {}
DOOR_ITEM_IDS: Dict[str, Dict[str, int]] = {}
DOOR_GROUP_ITEM_IDS: Dict[str, int] = {}
PANEL_DOOR_ITEM_IDS: Dict[str, Dict[str, int]] = {}
PANEL_GROUP_ITEM_IDS: Dict[str, int] = {}
PROGRESSIVE_ITEM_IDS: Dict[str, int] = {}
HASHES: Dict[str, str] = {}
@@ -68,6 +72,20 @@ def get_door_group_item_id(name: str):
return DOOR_GROUP_ITEM_IDS[name]
def get_panel_door_item_id(room: str, name: str):
if room not in PANEL_DOOR_ITEM_IDS or name not in PANEL_DOOR_ITEM_IDS[room]:
raise Exception(f"Item ID for panel door {room} - {name} not found in ids.yaml.")
return PANEL_DOOR_ITEM_IDS[room][name]
def get_panel_group_item_id(name: str):
if name not in PANEL_GROUP_ITEM_IDS:
raise Exception(f"Item ID for panel group {name} not found in ids.yaml.")
return PANEL_GROUP_ITEM_IDS[name]
def get_progressive_item_id(name: str):
if name not in PROGRESSIVE_ITEM_IDS:
raise Exception(f"Item ID for progressive item {name} not found in ids.yaml.")
@@ -97,8 +115,10 @@ def load_static_data_from_file():
ALL_ROOMS.extend(pickdata["ALL_ROOMS"])
DOORS_BY_ROOM.update(pickdata["DOORS_BY_ROOM"])
PANELS_BY_ROOM.update(pickdata["PANELS_BY_ROOM"])
PROGRESSIVE_ITEMS.extend(pickdata["PROGRESSIVE_ITEMS"])
PROGRESSION_BY_ROOM.update(pickdata["PROGRESSION_BY_ROOM"])
PANEL_DOORS_BY_ROOM.update(pickdata["PANEL_DOORS_BY_ROOM"])
PROGRESSIVE_ITEMS.update(pickdata["PROGRESSIVE_ITEMS"])
PROGRESSIVE_DOORS_BY_ROOM.update(pickdata["PROGRESSIVE_DOORS_BY_ROOM"])
PROGRESSIVE_PANELS_BY_ROOM.update(pickdata["PROGRESSIVE_PANELS_BY_ROOM"])
PAINTING_ENTRANCES = pickdata["PAINTING_ENTRANCES"]
PAINTING_EXIT_ROOMS.update(pickdata["PAINTING_EXIT_ROOMS"])
PAINTING_EXITS = pickdata["PAINTING_EXITS"]
@@ -111,6 +131,8 @@ def load_static_data_from_file():
DOOR_LOCATION_IDS.update(pickdata["DOOR_LOCATION_IDS"])
DOOR_ITEM_IDS.update(pickdata["DOOR_ITEM_IDS"])
DOOR_GROUP_ITEM_IDS.update(pickdata["DOOR_GROUP_ITEM_IDS"])
PANEL_DOOR_ITEM_IDS.update(pickdata["PANEL_DOOR_ITEM_IDS"])
PANEL_GROUP_ITEM_IDS.update(pickdata["PANEL_GROUP_ITEM_IDS"])
PROGRESSIVE_ITEM_IDS.update(pickdata["PROGRESSIVE_ITEM_IDS"])

View File

@@ -3,7 +3,7 @@ from . import LingoTestBase
class TestRequiredRoomLogic(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"shuffle_colors": "false",
}
@@ -50,7 +50,7 @@ class TestRequiredRoomLogic(LingoTestBase):
class TestRequiredDoorLogic(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"shuffle_colors": "false",
}
@@ -78,7 +78,8 @@ class TestRequiredDoorLogic(LingoTestBase):
class TestSimpleDoors(LingoTestBase):
options = {
"shuffle_doors": "simple",
"shuffle_doors": "doors",
"group_doors": "true",
"shuffle_colors": "false",
}
@@ -90,3 +91,52 @@ class TestSimpleDoors(LingoTestBase):
self.assertTrue(self.multiworld.state.can_reach("Outside The Wanderer", "Region", self.player))
self.assertTrue(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player))
class TestPanels(LingoTestBase):
options = {
"shuffle_doors": "panels"
}
def test_requirement(self):
self.assertFalse(self.can_reach_location("Starting Room - HIDDEN"))
self.assertFalse(self.can_reach_location("Hidden Room - OPEN"))
self.assertFalse(self.can_reach_location("The Seeker - Achievement"))
self.collect_by_name("Starting Room - HIDDEN (Panel)")
self.assertTrue(self.can_reach_location("Starting Room - HIDDEN"))
self.assertFalse(self.can_reach_location("Hidden Room - OPEN"))
self.assertFalse(self.can_reach_location("The Seeker - Achievement"))
self.collect_by_name("Hidden Room - OPEN (Panel)")
self.assertTrue(self.can_reach_location("Starting Room - HIDDEN"))
self.assertTrue(self.can_reach_location("Hidden Room - OPEN"))
self.assertTrue(self.can_reach_location("The Seeker - Achievement"))
class TestGroupedPanels(LingoTestBase):
options = {
"shuffle_doors": "panels",
"group_doors": "true",
"shuffle_colors": "false",
}
def test_requirement(self):
self.assertFalse(self.can_reach_location("Hub Room - SLAUGHTER"))
self.assertFalse(self.can_reach_location("Dread Hallway - DREAD"))
self.assertFalse(self.can_reach_location("The Tenacious - Achievement"))
self.collect_by_name("Tenacious Entrance Panels")
self.assertTrue(self.can_reach_location("Hub Room - SLAUGHTER"))
self.assertFalse(self.can_reach_location("Dread Hallway - DREAD"))
self.assertFalse(self.can_reach_location("The Tenacious - Achievement"))
self.collect_by_name("Outside The Agreeable - BLACK (Panel)")
self.assertTrue(self.can_reach_location("Hub Room - SLAUGHTER"))
self.assertTrue(self.can_reach_location("Dread Hallway - DREAD"))
self.assertFalse(self.can_reach_location("The Tenacious - Achievement"))
self.collect_by_name("The Tenacious - Black Palindromes (Panels)")
self.assertTrue(self.can_reach_location("Hub Room - SLAUGHTER"))
self.assertTrue(self.can_reach_location("Dread Hallway - DREAD"))
self.assertTrue(self.can_reach_location("The Tenacious - Achievement"))

View File

@@ -3,7 +3,7 @@ from . import LingoTestBase
class TestMultiShuffleOptions(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"progressive_orange_tower": "true",
"shuffle_colors": "true",
"shuffle_paintings": "true",
@@ -13,7 +13,7 @@ class TestMultiShuffleOptions(LingoTestBase):
class TestPanelsanity(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"progressive_orange_tower": "true",
"location_checks": "insanity",
"shuffle_colors": "true"
@@ -22,7 +22,18 @@ class TestPanelsanity(LingoTestBase):
class TestAllPanelHunt(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"progressive_orange_tower": "true",
"shuffle_colors": "true",
"victory_condition": "level_2",
"level_2_requirement": "800",
"early_color_hallways": "true"
}
class TestAllPanelHuntPanelsMode(LingoTestBase):
options = {
"shuffle_doors": "panels",
"progressive_orange_tower": "true",
"shuffle_colors": "true",
"victory_condition": "level_2",

View File

@@ -3,7 +3,7 @@ from . import LingoTestBase
class TestProgressiveOrangeTower(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"progressive_orange_tower": "true"
}

View File

@@ -3,7 +3,7 @@ from . import LingoTestBase
class TestPanelHunt(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"location_checks": "insanity",
"victory_condition": "level_2",
"level_2_requirement": "15"

View File

@@ -18,7 +18,7 @@ class TestPilgrimageWithRoofAndPaintings(LingoTestBase):
options = {
"enable_pilgrimage": "true",
"shuffle_colors": "false",
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"pilgrimage_allows_roof_access": "true",
"pilgrimage_allows_paintings": "true",
"early_color_hallways": "false"
@@ -39,7 +39,7 @@ class TestPilgrimageNoRoofYesPaintings(LingoTestBase):
options = {
"enable_pilgrimage": "true",
"shuffle_colors": "false",
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"pilgrimage_allows_roof_access": "false",
"pilgrimage_allows_paintings": "true",
"early_color_hallways": "false"
@@ -62,7 +62,7 @@ class TestPilgrimageNoRoofNoPaintings(LingoTestBase):
options = {
"enable_pilgrimage": "true",
"shuffle_colors": "false",
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"pilgrimage_allows_roof_access": "false",
"pilgrimage_allows_paintings": "false",
"early_color_hallways": "false"
@@ -117,7 +117,7 @@ class TestPilgrimageYesRoofNoPaintings(LingoTestBase):
options = {
"enable_pilgrimage": "true",
"shuffle_colors": "false",
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"pilgrimage_allows_roof_access": "true",
"pilgrimage_allows_paintings": "false",
"early_color_hallways": "false"

View File

@@ -3,7 +3,7 @@ from . import LingoTestBase
class TestComplexProgressiveHallwayRoom(LingoTestBase):
options = {
"shuffle_doors": "complex"
"shuffle_doors": "doors"
}
def test_item(self):
@@ -54,7 +54,8 @@ class TestComplexProgressiveHallwayRoom(LingoTestBase):
class TestSimpleHallwayRoom(LingoTestBase):
options = {
"shuffle_doors": "simple"
"shuffle_doors": "doors",
"group_doors": "true",
}
def test_item(self):
@@ -81,7 +82,7 @@ class TestSimpleHallwayRoom(LingoTestBase):
class TestProgressiveArtGallery(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"shuffle_colors": "false",
}

View File

@@ -19,7 +19,8 @@ class TestVanillaDoorsNormalSunwarps(LingoTestBase):
class TestSimpleDoorsNormalSunwarps(LingoTestBase):
options = {
"shuffle_doors": "simple",
"shuffle_doors": "doors",
"group_doors": "true",
"sunwarp_access": "normal"
}
@@ -37,7 +38,8 @@ class TestSimpleDoorsNormalSunwarps(LingoTestBase):
class TestSimpleDoorsDisabledSunwarps(LingoTestBase):
options = {
"shuffle_doors": "simple",
"shuffle_doors": "doors",
"group_doors": "true",
"sunwarp_access": "disabled"
}
@@ -56,7 +58,8 @@ class TestSimpleDoorsDisabledSunwarps(LingoTestBase):
class TestSimpleDoorsUnlockSunwarps(LingoTestBase):
options = {
"shuffle_doors": "simple",
"shuffle_doors": "doors",
"group_doors": "true",
"sunwarp_access": "unlock"
}
@@ -78,7 +81,8 @@ class TestSimpleDoorsUnlockSunwarps(LingoTestBase):
class TestComplexDoorsNormalSunwarps(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"group_doors": "false",
"sunwarp_access": "normal"
}
@@ -96,7 +100,8 @@ class TestComplexDoorsNormalSunwarps(LingoTestBase):
class TestComplexDoorsDisabledSunwarps(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"group_doors": "false",
"sunwarp_access": "disabled"
}
@@ -115,7 +120,8 @@ class TestComplexDoorsDisabledSunwarps(LingoTestBase):
class TestComplexDoorsIndividualSunwarps(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"group_doors": "false",
"sunwarp_access": "individual"
}
@@ -142,7 +148,8 @@ class TestComplexDoorsIndividualSunwarps(LingoTestBase):
class TestComplexDoorsProgressiveSunwarps(LingoTestBase):
options = {
"shuffle_doors": "complex",
"shuffle_doors": "doors",
"group_doors": "false",
"sunwarp_access": "progressive"
}

View File

@@ -73,6 +73,22 @@ if old_generated.include? "door_groups" then
end
end
end
if old_generated.include? "panel_doors" then
old_generated["panel_doors"].each do |room, panel_doors|
panel_doors.each do |name, id|
if id >= next_item_id then
next_item_id = id + 1
end
end
end
end
if old_generated.include? "panel_groups" then
old_generated["panel_groups"].each do |name, id|
if id >= next_item_id then
next_item_id = id + 1
end
end
end
if old_generated.include? "progression" then
old_generated["progression"].each do |name, id|
if id >= next_item_id then
@@ -82,6 +98,7 @@ if old_generated.include? "progression" then
end
door_groups = Set[]
panel_groups = Set[]
config = YAML.load_file(configpath)
config.each do |room_name, room_data|
@@ -163,6 +180,29 @@ config.each do |room_name, room_data|
end
end
if room_data.include? "panel_doors"
room_data["panel_doors"].each do |panel_door_name, panel_door|
unless old_generated.include? "panel_doors" and old_generated["panel_doors"].include? room_name and old_generated["panel_doors"][room_name].include? panel_door_name then
old_generated["panel_doors"] ||= {}
old_generated["panel_doors"][room_name] ||= {}
old_generated["panel_doors"][room_name][panel_door_name] = next_item_id
next_item_id += 1
end
if panel_door.include? "panel_group" and not panel_groups.include? panel_door["panel_group"] then
panel_groups.add(panel_door["panel_group"])
unless old_generated.include? "panel_groups" and old_generated["panel_groups"].include? panel_door["panel_group"] then
old_generated["panel_groups"] ||= {}
old_generated["panel_groups"][panel_door["panel_group"]] = next_item_id
next_item_id += 1
end
end
end
end
if room_data.include? "progression"
room_data["progression"].each do |progression_name, pdata|
unless old_generated.include? "progression" and old_generated["progression"].include? progression_name then

View File

@@ -6,8 +6,8 @@ import sys
sys.path.append(os.path.join("worlds", "lingo"))
sys.path.append(".")
sys.path.append("..")
from datatypes import Door, DoorType, EntranceType, Painting, Panel, Progression, Room, RoomAndDoor, RoomAndPanel,\
RoomEntrance
from datatypes import Door, DoorType, EntranceType, Painting, Panel, PanelDoor, Progression, Room, RoomAndDoor,\
RoomAndPanel, RoomAndPanelDoor, RoomEntrance
import hashlib
import pickle
@@ -18,10 +18,12 @@ import Utils
ALL_ROOMS: List[Room] = []
DOORS_BY_ROOM: Dict[str, Dict[str, Door]] = {}
PANELS_BY_ROOM: Dict[str, Dict[str, Panel]] = {}
PANEL_DOORS_BY_ROOM: Dict[str, Dict[str, PanelDoor]] = {}
PAINTINGS: Dict[str, Painting] = {}
PROGRESSIVE_ITEMS: List[str] = []
PROGRESSION_BY_ROOM: Dict[str, Dict[str, Progression]] = {}
PROGRESSIVE_ITEMS: Set[str] = set()
PROGRESSIVE_DOORS_BY_ROOM: Dict[str, Dict[str, Progression]] = {}
PROGRESSIVE_PANELS_BY_ROOM: Dict[str, Dict[str, Progression]] = {}
PAINTING_ENTRANCES: int = 0
PAINTING_EXIT_ROOMS: Set[str] = set()
@@ -37,8 +39,13 @@ PANEL_LOCATION_IDS: Dict[str, Dict[str, int]] = {}
DOOR_LOCATION_IDS: Dict[str, Dict[str, int]] = {}
DOOR_ITEM_IDS: Dict[str, Dict[str, int]] = {}
DOOR_GROUP_ITEM_IDS: Dict[str, int] = {}
PANEL_DOOR_ITEM_IDS: Dict[str, Dict[str, int]] = {}
PANEL_GROUP_ITEM_IDS: Dict[str, int] = {}
PROGRESSIVE_ITEM_IDS: Dict[str, int] = {}
# This doesn't need to be stored in the datafile.
PANEL_DOOR_BY_PANEL_BY_ROOM: Dict[str, Dict[str, str]] = {}
def hash_file(path):
md5 = hashlib.md5()
@@ -53,7 +60,7 @@ def hash_file(path):
def load_static_data(ll1_path, ids_path):
global PAINTING_EXITS, SPECIAL_ITEM_IDS, PANEL_LOCATION_IDS, DOOR_LOCATION_IDS, DOOR_ITEM_IDS, \
DOOR_GROUP_ITEM_IDS, PROGRESSIVE_ITEM_IDS
DOOR_GROUP_ITEM_IDS, PROGRESSIVE_ITEM_IDS, PANEL_DOOR_ITEM_IDS, PANEL_GROUP_ITEM_IDS
# Load in all item and location IDs. These are broken up into groups based on the type of item/location.
with open(ids_path, "r") as file:
@@ -86,6 +93,17 @@ def load_static_data(ll1_path, ids_path):
for item_name, item_id in config["door_groups"].items():
DOOR_GROUP_ITEM_IDS[item_name] = item_id
if "panel_doors" in config:
for room_name, panel_doors in config["panel_doors"].items():
PANEL_DOOR_ITEM_IDS[room_name] = {}
for panel_door, item_id in panel_doors.items():
PANEL_DOOR_ITEM_IDS[room_name][panel_door] = item_id
if "panel_groups" in config:
for item_name, item_id in config["panel_groups"].items():
PANEL_GROUP_ITEM_IDS[item_name] = item_id
if "progression" in config:
for item_name, item_id in config["progression"].items():
PROGRESSIVE_ITEM_IDS[item_name] = item_id
@@ -147,6 +165,46 @@ def process_entrance(source_room, doors, room_obj):
room_obj.entrances.append(RoomEntrance(source_room, door, entrance_type))
def process_panel_door(room_name, panel_door_name, panel_door_data):
global PANEL_DOORS_BY_ROOM, PANEL_DOOR_BY_PANEL_BY_ROOM
panels: List[RoomAndPanel] = list()
for panel in panel_door_data["panels"]:
if isinstance(panel, dict):
panels.append(RoomAndPanel(panel["room"], panel["panel"]))
else:
panels.append(RoomAndPanel(room_name, panel))
for panel in panels:
PANEL_DOOR_BY_PANEL_BY_ROOM.setdefault(panel.room, {})[panel.panel] = RoomAndPanelDoor(room_name,
panel_door_name)
if "item_name" in panel_door_data:
item_name = panel_door_data["item_name"]
else:
panel_per_room = dict()
for panel in panels:
panel_room_name = room_name if panel.room is None else panel.room
panel_per_room.setdefault(panel_room_name, []).append(panel.panel)
room_strs = list()
for door_room_str, door_panels_str in panel_per_room.items():
room_strs.append(door_room_str + " - " + ", ".join(door_panels_str))
if len(panels) == 1:
item_name = f"{room_strs[0]} (Panel)"
else:
item_name = " and ".join(room_strs) + " (Panels)"
if "panel_group" in panel_door_data:
panel_group = panel_door_data["panel_group"]
else:
panel_group = None
panel_door_obj = PanelDoor(item_name, panel_group)
PANEL_DOORS_BY_ROOM[room_name][panel_door_name] = panel_door_obj
def process_panel(room_name, panel_name, panel_data):
global PANELS_BY_ROOM
@@ -227,13 +285,18 @@ def process_panel(room_name, panel_name, panel_data):
else:
non_counting = False
if room_name in PANEL_DOOR_BY_PANEL_BY_ROOM and panel_name in PANEL_DOOR_BY_PANEL_BY_ROOM[room_name]:
panel_door = PANEL_DOOR_BY_PANEL_BY_ROOM[room_name][panel_name]
else:
panel_door = None
if "location_name" in panel_data:
location_name = panel_data["location_name"]
else:
location_name = None
panel_obj = Panel(required_rooms, required_doors, required_panels, colors, check, event, exclude_reduce,
achievement, non_counting, location_name)
achievement, non_counting, panel_door, location_name)
PANELS_BY_ROOM[room_name][panel_name] = panel_obj
@@ -325,7 +388,7 @@ def process_door(room_name, door_name, door_data):
painting_ids = []
door_type = DoorType.NORMAL
if door_name.endswith(" Sunwarp"):
if room_name == "Sunwarps":
door_type = DoorType.SUNWARP
elif room_name == "Pilgrim Antechamber" and door_name == "Sun Painting":
door_type = DoorType.SUN_PAINTING
@@ -404,11 +467,11 @@ def process_sunwarp(room_name, sunwarp_data):
SUNWARP_EXITS[sunwarp_data["dots"] - 1] = room_name
def process_progression(room_name, progression_name, progression_doors):
global PROGRESSIVE_ITEMS, PROGRESSION_BY_ROOM
def process_progressive_door(room_name, progression_name, progression_doors):
global PROGRESSIVE_ITEMS, PROGRESSIVE_DOORS_BY_ROOM
# Progressive items are configured as a list of doors.
PROGRESSIVE_ITEMS.append(progression_name)
PROGRESSIVE_ITEMS.add(progression_name)
progression_index = 1
for door in progression_doors:
@@ -419,11 +482,31 @@ def process_progression(room_name, progression_name, progression_doors):
door_room = room_name
door_door = door
room_progressions = PROGRESSION_BY_ROOM.setdefault(door_room, {})
room_progressions = PROGRESSIVE_DOORS_BY_ROOM.setdefault(door_room, {})
room_progressions[door_door] = Progression(progression_name, progression_index)
progression_index += 1
def process_progressive_panel(room_name, progression_name, progression_panel_doors):
global PROGRESSIVE_ITEMS, PROGRESSIVE_PANELS_BY_ROOM
# Progressive items are configured as a list of panel doors.
PROGRESSIVE_ITEMS.add(progression_name)
progression_index = 1
for panel_door in progression_panel_doors:
if isinstance(panel_door, Dict):
panel_door_room = panel_door["room"]
panel_door_door = panel_door["panel_door"]
else:
panel_door_room = room_name
panel_door_door = panel_door
room_progressions = PROGRESSIVE_PANELS_BY_ROOM.setdefault(panel_door_room, {})
room_progressions[panel_door_door] = Progression(progression_name, progression_index)
progression_index += 1
def process_room(room_name, room_data):
global ALL_ROOMS
@@ -433,6 +516,12 @@ def process_room(room_name, room_data):
for source_room, doors in room_data["entrances"].items():
process_entrance(source_room, doors, room_obj)
if "panel_doors" in room_data:
PANEL_DOORS_BY_ROOM[room_name] = dict()
for panel_door_name, panel_door_data in room_data["panel_doors"].items():
process_panel_door(room_name, panel_door_name, panel_door_data)
if "panels" in room_data:
PANELS_BY_ROOM[room_name] = dict()
@@ -454,8 +543,11 @@ def process_room(room_name, room_data):
process_sunwarp(room_name, sunwarp_data)
if "progression" in room_data:
for progression_name, progression_doors in room_data["progression"].items():
process_progression(room_name, progression_name, progression_doors)
for progression_name, pdata in room_data["progression"].items():
if "doors" in pdata:
process_progressive_door(room_name, progression_name, pdata["doors"])
if "panel_doors" in pdata:
process_progressive_panel(room_name, progression_name, pdata["panel_doors"])
ALL_ROOMS.append(room_obj)
@@ -492,8 +584,10 @@ if __name__ == '__main__':
"ALL_ROOMS": ALL_ROOMS,
"DOORS_BY_ROOM": DOORS_BY_ROOM,
"PANELS_BY_ROOM": PANELS_BY_ROOM,
"PANEL_DOORS_BY_ROOM": PANEL_DOORS_BY_ROOM,
"PROGRESSIVE_ITEMS": PROGRESSIVE_ITEMS,
"PROGRESSION_BY_ROOM": PROGRESSION_BY_ROOM,
"PROGRESSIVE_DOORS_BY_ROOM": PROGRESSIVE_DOORS_BY_ROOM,
"PROGRESSIVE_PANELS_BY_ROOM": PROGRESSIVE_PANELS_BY_ROOM,
"PAINTING_ENTRANCES": PAINTING_ENTRANCES,
"PAINTING_EXIT_ROOMS": PAINTING_EXIT_ROOMS,
"PAINTING_EXITS": PAINTING_EXITS,
@@ -506,6 +600,8 @@ if __name__ == '__main__':
"DOOR_LOCATION_IDS": DOOR_LOCATION_IDS,
"DOOR_ITEM_IDS": DOOR_ITEM_IDS,
"DOOR_GROUP_ITEM_IDS": DOOR_GROUP_ITEM_IDS,
"PANEL_DOOR_ITEM_IDS": PANEL_DOOR_ITEM_IDS,
"PANEL_GROUP_ITEM_IDS": PANEL_GROUP_ITEM_IDS,
"PROGRESSIVE_ITEM_IDS": PROGRESSIVE_ITEM_IDS,
}

View File

@@ -33,19 +33,23 @@ end
configured_rooms = Set["Menu"]
configured_doors = Set[]
configured_panels = Set[]
configured_panel_doors = Set[]
mentioned_rooms = Set[]
mentioned_doors = Set[]
mentioned_panels = Set[]
mentioned_panel_doors = Set[]
mentioned_sunwarp_entrances = Set[]
mentioned_sunwarp_exits = Set[]
mentioned_paintings = Set[]
door_groups = {}
panel_groups = {}
directives = Set["entrances", "panels", "doors", "paintings", "sunwarps", "progression"]
directives = Set["entrances", "panels", "doors", "panel_doors", "paintings", "sunwarps", "progression"]
panel_directives = Set["id", "required_room", "required_door", "required_panel", "colors", "check", "exclude_reduce", "tag", "link", "subtag", "achievement", "copy_to_sign", "non_counting", "hunt", "location_name"]
door_directives = Set["id", "painting_id", "panels", "item_name", "item_group", "location_name", "skip_location", "skip_item", "door_group", "include_reduce", "event", "warp_id"]
panel_door_directives = Set["panels", "item_name", "panel_group"]
painting_directives = Set["id", "enter_only", "exit_only", "orientation", "required_door", "required", "required_when_no_doors", "move", "req_blocked", "req_blocked_when_no_doors"]
non_counting = 0
@@ -253,6 +257,43 @@ config.each do |room_name, room|
end
end
(room["panel_doors"] || {}).each do |panel_door_name, panel_door|
configured_panel_doors.add("#{room_name} - #{panel_door_name}")
if panel_door.include?("panels")
panel_door["panels"].each do |panel|
if panel.kind_of? Hash then
other_room = panel.include?("room") ? panel["room"] : room_name
mentioned_panels.add("#{other_room} - #{panel["panel"]}")
else
other_room = panel.include?("room") ? panel["room"] : room_name
mentioned_panels.add("#{room_name} - #{panel}")
end
end
else
puts "#{room_name} - #{panel_door_name} :::: Missing panels field"
end
if panel_door.include?("panel_group")
panel_groups[panel_door["panel_group"]] ||= 0
panel_groups[panel_door["panel_group"]] += 1
end
bad_subdirectives = []
panel_door.keys.each do |key|
unless panel_door_directives.include?(key) then
bad_subdirectives << key
end
end
unless bad_subdirectives.empty? then
puts "#{room_name} - #{panel_door_name} :::: Panel door has the following invalid subdirectives: #{bad_subdirectives.join(", ")}"
end
unless ids.include?("panel_doors") and ids["panel_doors"].include?(room_name) and ids["panel_doors"][room_name].include?(panel_door_name)
puts "#{room_name} - #{panel_door_name} :::: Panel door is missing an item ID"
end
end
(room["paintings"] || []).each do |painting|
if painting.include?("id") and painting["id"].kind_of? String then
unless paintings.include? painting["id"] then
@@ -327,12 +368,24 @@ config.each do |room_name, room|
end
end
(room["progression"] || {}).each do |progression_name, door_list|
door_list.each do |door|
if door.kind_of? Hash then
mentioned_doors.add("#{door["room"]} - #{door["door"]}")
else
mentioned_doors.add("#{room_name} - #{door}")
(room["progression"] || {}).each do |progression_name, pdata|
if pdata.include? "doors" then
pdata["doors"].each do |door|
if door.kind_of? Hash then
mentioned_doors.add("#{door["room"]} - #{door["door"]}")
else
mentioned_doors.add("#{room_name} - #{door}")
end
end
end
if pdata.include? "panel_doors" then
pdata["panel_doors"].each do |panel_door|
if panel_door.kind_of? Hash then
mentioned_panel_doors.add("#{panel_door["room"]} - #{panel_door["panel_door"]}")
else
mentioned_panel_doors.add("#{room_name} - #{panel_door}")
end
end
end
@@ -344,17 +397,22 @@ end
errored_rooms = mentioned_rooms - configured_rooms
unless errored_rooms.empty? then
puts "The folloring rooms are mentioned but do not exist: " + errored_rooms.to_s
puts "The following rooms are mentioned but do not exist: " + errored_rooms.to_s
end
errored_panels = mentioned_panels - configured_panels
unless errored_panels.empty? then
puts "The folloring panels are mentioned but do not exist: " + errored_panels.to_s
puts "The following panels are mentioned but do not exist: " + errored_panels.to_s
end
errored_doors = mentioned_doors - configured_doors
unless errored_doors.empty? then
puts "The folloring doors are mentioned but do not exist: " + errored_doors.to_s
puts "The following doors are mentioned but do not exist: " + errored_doors.to_s
end
errored_panel_doors = mentioned_panel_doors - configured_panel_doors
unless errored_panel_doors.empty? then
puts "The following panel doors are mentioned but do not exist: " + errored_panel_doors.to_s
end
door_groups.each do |group,num|
@@ -367,6 +425,16 @@ door_groups.each do |group,num|
end
end
panel_groups.each do |group,num|
if num == 1 then
puts "Panel group \"#{group}\" only has one panel in it"
end
unless ids.include?("panel_groups") and ids["panel_groups"].include?(group)
puts "#{group} :::: Panel group is missing an item ID"
end
end
slashed_rooms = configured_rooms.select do |room|
room.include? "/"
end

View File

@@ -52,8 +52,17 @@ class PokemonEmeraldWebWorld(WebWorld):
"setup/es",
["nachocua"]
)
setup_sv = Tutorial(
"Multivärld Installations Guide",
"En guide för att kunna spela Pokémon Emerald med Archipelago.",
"Svenska",
"setup_sv.md",
"setup/sv",
["Tsukino"]
)
tutorials = [setup_en, setup_es]
tutorials = [setup_en, setup_es, setup_sv]
class PokemonEmeraldSettings(settings.Group):

View File

@@ -0,0 +1,78 @@
# Pokémon Emerald Installationsguide
## Programvara som behövs
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- Ett engelskt Pokémon Emerald ROM, Archipelago kan inte hjälpa dig med detta.
- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 eller senare
### Konfigurera BizHawk
När du har installerat BizHawk, öppna `EmuHawk.exe` och ändra följande inställningar:
- Om du använder BizHawk 2.7 eller 2.8, gå till `Config > Customize`. På "Advanced Tab", byt Lua core från
`NLua+KopiLua` till `Lua+LuaInterface`, starta om EmuHawk efteråt. (Använder du BizHawk 2.9, kan du skippa detta steg.)
- Gå till `Config > Customize`. Markera "Run in background" inställningen för att förhindra bortkoppling från
klienten om du alt-tabbar bort från EmuHawk.
- Öppna en `.gba` fil i EmuHawk och gå till `Config > Controllers…` för att konfigurera dina inputs.
Om du inte hittar `Controllers…`, starta ett valfritt `.gba` ROM först.
- Överväg att rensa keybinds i `Config > Hotkeys…` som du inte tänkt använda. Välj en keybind och tryck på ESC
för att rensa bort den.
## Extra programvara
- [Pokémon Emerald AP Tracker](https://github.com/seto10987/Archipelago-Emerald-AP-Tracker/releases/latest),
används tillsammans med
[PopTracker](https://github.com/black-sliver/PopTracker/releases)
## Generera och patcha ett spel
1. Skapa din konfigurationsfil (YAML). Du kan göra en via att använda
[Pokémon Emerald options hemsida](../../../games/Pokemon%20Emerald/player-options).
2. Följ de allmänna Archipelago instruktionerna för att
[Generera ett spel](../../Archipelago/setup/en#generating-a-game).
Detta kommer generera en fil för dig. Din patchfil kommer ha `.apemerald` som sitt filnamnstillägg.
3. Öppna `ArchipelagoLauncher.exe`
4. Välj "Open Patch" på vänstra sidan, och välj din patchfil.
5. Om detta är första gången du patchar, så kommer du behöva välja var ditt ursprungliga ROM är.
6. En patchad `.gba` fil kommer skapas på samma plats som patchfilen.
7. Första gången du öppnar en patch med BizHawk-klienten, kommer du också behöva bekräfta var `EmuHawk.exe` filen är
installerad i din BizHawk-mapp.
Om du bara tänkt spela själv och du inte bryr dig om automatisk spårning eller ledtrådar, så kan du stanna här, stänga
av klienten, och starta ditt patchade ROM med valfri emulator. Dock, för multvärldsfunktionen eller andra
Archipelago-funktioner, fortsätt nedanför med BizHawk.
## Anslut till en server
Om du vanligtsvis öppnar en patchad fil så görs steg 1-5 automatiskt åt dig. Även om det är så, kom ihåg dessa steg
ifall du till exempel behöver stänga ner och starta om något medans du spelar.
1. Pokemon Emerald använder Archipelagos BizHawk-klient. Om klienten inte startat efter att du patchat ditt spel,
så kan du bara öppna den igen från launchern.
2. Dubbelkolla att EmuHawk faktiskt startat med den patchade ROM-filen.
3. I EmuHawk, gå till `Tools > Lua Console`. Luakonsolen måste vara igång medans du spelar.
4. I Luakonsolen, Tryck på `Script > Open Script…`.
5. Leta reda på din Archipelago-mapp och i den öppna `data/lua/connector_bizhawk_generic.lua`.
6. Emulatorn och klienten kommer så småningom ansluta till varandra. I BizHawk-klienten kommer du kunna see om allt är
anslutet och att Pokemon Emerald är igenkänt.
7. För att ansluta klienten till en server, skriv in din lobbyadress och port i textfältet t.ex.
`archipelago.gg:38281`
längst upp i din klient och tryck sen på "Connect".
Du borde nu kunna ta emot och skicka föremål. Du behöver göra dom här stegen varje gång du vill ansluta igen. Det är
helt okej att göra saker offline utan att behöva oroa sig; allt kommer att synkronisera när du ansluter till servern
igen.
## Automatisk Spårning
Pokémon Emerald har en fullt fungerande spårare med stöd för automatisk spårning.
1. Ladda ner [Pokémon Emerald AP Tracker](https://github.com/seto10987/Archipelago-Emerald-AP-Tracker/releases/latest)
och
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Placera tracker pack zip-filen i packs/ där du har PopTracker installerat.
3. Öppna PopTracker, och välj Pokemon Emerald.
4. För att automatiskt spåra, tryck på "AP" symbolen längst upp.
5. Skriv in Archipelago-serverns uppgifter (Samma som du använde för att ansluta med klienten), "Slot"-namn samt
lösenord.

View File

@@ -427,7 +427,7 @@ location_data = [
LocationData("Seafoam Islands B3F", "Hidden Item Rock", "Max Elixir", rom_addresses['Hidden_Item_Seafoam_Islands_B3F'], Hidden(50), inclusion=hidden_items),
LocationData("Vermilion City", "Hidden Item In Water Near Fan Club", "Max Ether", rom_addresses['Hidden_Item_Vermilion_City'], Hidden(51), inclusion=hidden_items),
LocationData("Cerulean City-Badge House Backyard", "Hidden Item Gym Badge Guy's Backyard", "Rare Candy", rom_addresses['Hidden_Item_Cerulean_City'], Hidden(52), inclusion=hidden_items),
LocationData("Route 4-E", "Hidden Item Plateau East Of Mt Moon", "Great Ball", rom_addresses['Hidden_Item_Route_4'], Hidden(53), inclusion=hidden_items),
LocationData("Route 4-C", "Hidden Item Plateau East Of Mt Moon", "Great Ball", rom_addresses['Hidden_Item_Route_4'], Hidden(53), inclusion=hidden_items),
LocationData("Oak's Lab", "Oak's Parcel Reward", "Pokedex", rom_addresses["Event_Pokedex"], EventFlag(0x38)),

View File

@@ -215,7 +215,6 @@ class SMZ3World(World):
niceItems = TotalSMZ3Item.Item.CreateNicePool(self.smz3World)
junkItems = TotalSMZ3Item.Item.CreateJunkPool(self.smz3World)
allJunkItems = niceItems + junkItems
self.junkItemsNames = [item.Type.name for item in junkItems]
if (self.smz3World.Config.Keysanity):
@@ -228,7 +227,8 @@ class SMZ3World(World):
self.multiworld.push_precollected(SMZ3Item(item.Type.name, ItemClassification.filler, item.Type, self.item_name_to_id[item.Type.name], self.player, item))
itemPool = [SMZ3Item(item.Type.name, ItemClassification.progression, item.Type, self.item_name_to_id[item.Type.name], self.player, item) for item in progressionItems] + \
[SMZ3Item(item.Type.name, ItemClassification.filler, item.Type, self.item_name_to_id[item.Type.name], self.player, item) for item in allJunkItems]
[SMZ3Item(item.Type.name, ItemClassification.useful, item.Type, self.item_name_to_id[item.Type.name], self.player, item) for item in niceItems] + \
[SMZ3Item(item.Type.name, ItemClassification.filler, item.Type, self.item_name_to_id[item.Type.name], self.player, item) for item in junkItems]
self.smz3DungeonItems = [SMZ3Item(item.Type.name, ItemClassification.progression, item.Type, self.item_name_to_id[item.Type.name], self.player, item) for item in self.dungeon]
self.multiworld.itempool += itemPool

View File

@@ -1,4 +1,5 @@
import logging
from random import Random
from typing import Dict, Any, Iterable, Optional, Union, List, TextIO
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
@@ -27,15 +28,20 @@ from .strings.goal_names import Goal as GoalName
from .strings.metal_names import Ore
from .strings.region_names import Region as RegionName, LogicRegion
logger = logging.getLogger(__name__)
STARDEW_VALLEY = "Stardew Valley"
UNIVERSAL_TRACKER_SEED_PROPERTY = "ut_seed"
client_version = 0
class StardewLocation(Location):
game: str = "Stardew Valley"
game: str = STARDEW_VALLEY
class StardewItem(Item):
game: str = "Stardew Valley"
game: str = STARDEW_VALLEY
class StardewWebWorld(WebWorld):
@@ -60,7 +66,7 @@ class StardewValleyWorld(World):
Stardew Valley is an open-ended country-life RPG. You can farm, fish, mine, fight, complete quests,
befriend villagers, and uncover dark secrets.
"""
game = "Stardew Valley"
game = STARDEW_VALLEY
topology_present = False
item_name_to_id = {name: data.code for name, data in item_table.items()}
@@ -95,6 +101,17 @@ class StardewValleyWorld(World):
self.total_progression_items = 0
# self.all_progression_items = dict()
# Taking the seed specified in slot data for UT, otherwise just generating the seed.
self.seed = getattr(multiworld, "re_gen_passthrough", {}).get(STARDEW_VALLEY, self.random.getrandbits(64))
self.random = Random(self.seed)
def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Optional[int]:
# If the seed is not specified in the slot data, this mean the world was generated before Universal Tracker support.
seed = slot_data.get(UNIVERSAL_TRACKER_SEED_PROPERTY)
if seed is None:
logger.warning(f"World was generated before Universal Tracker support. Tracker might not be accurate.")
return seed
def generate_early(self):
self.force_change_options_if_incompatible()
self.content = create_content(self.options)
@@ -108,12 +125,12 @@ class StardewValleyWorld(World):
self.options.exclude_ginger_island.value = ExcludeGingerIsland.option_false
goal_name = self.options.goal.current_key
player_name = self.multiworld.player_name[self.player]
logging.warning(
logger.warning(
f"Goal '{goal_name}' requires Ginger Island. Exclude Ginger Island setting forced to 'False' for player {self.player} ({player_name})")
if exclude_ginger_island and self.options.walnutsanity != Walnutsanity.preset_none:
self.options.walnutsanity.value = Walnutsanity.preset_none
player_name = self.multiworld.player_name[self.player]
logging.warning(
logger.warning(
f"Walnutsanity requires Ginger Island. Ginger Island was excluded from {self.player} ({player_name})'s world, so walnutsanity was force disabled")
def create_regions(self):
@@ -413,6 +430,7 @@ class StardewValleyWorld(World):
included_option_names: List[str] = [option_name for option_name in self.options_dataclass.type_hints if option_name not in excluded_option_names]
slot_data = self.options.as_dict(*included_option_names)
slot_data.update({
UNIVERSAL_TRACKER_SEED_PROPERTY: self.seed,
"seed": self.random.randrange(1000000000), # Seed should be max 9 digits
"randomized_entrances": self.randomized_entrances,
"modified_bundles": bundles,

View File

@@ -0,0 +1,33 @@
from ..game_content import ContentPack, StardewContent
from ..mod_registry import register_mod_content_pack
from ...data import villagers_data
from ...data.harvest import ForagingSource
from ...data.requirement import QuestRequirement
from ...mods.mod_data import ModNames
from ...strings.quest_names import ModQuest
from ...strings.region_names import Region
from ...strings.seed_names import DistantLandsSeed
class AlectoContentPack(ContentPack):
def harvest_source_hook(self, content: StardewContent):
if ModNames.distant_lands in content.registered_packs:
content.game_items.pop(DistantLandsSeed.void_mint)
content.game_items.pop(DistantLandsSeed.vile_ancient_fruit)
content.source_item(DistantLandsSeed.void_mint,
ForagingSource(regions=(Region.witch_swamp,), other_requirements=(QuestRequirement(ModQuest.WitchOrder),)),),
content.source_item(DistantLandsSeed.vile_ancient_fruit,
ForagingSource(regions=(Region.witch_swamp,), other_requirements=(QuestRequirement(ModQuest.WitchOrder),)), ),
register_mod_content_pack(ContentPack(
ModNames.alecto,
weak_dependencies=(
ModNames.distant_lands, # For Witch's order
),
villagers=(
villagers_data.alecto,
)
))

View File

@@ -1,20 +1,34 @@
from ..game_content import ContentPack
from ..game_content import ContentPack, StardewContent
from ..mod_registry import register_mod_content_pack
from ...data.game_item import ItemTag, Tag
from ...data.shop import ShopSource
from ...data.artisan import MachineSource
from ...data.skill import Skill
from ...mods.mod_data import ModNames
from ...strings.book_names import ModBook
from ...strings.region_names import LogicRegion
from ...strings.craftable_names import ModMachine
from ...strings.fish_names import ModTrash
from ...strings.metal_names import all_artifacts, all_fossils
from ...strings.skill_names import ModSkill
register_mod_content_pack(ContentPack(
class ArchaeologyContentPack(ContentPack):
def artisan_good_hook(self, content: StardewContent):
# Done as honestly there are too many display items to put into the initial registration traditionally.
display_items = all_artifacts + all_fossils
for item in display_items:
self.source_display_items(item, content)
content.source_item(ModTrash.rusty_scrap, *(MachineSource(item=artifact, machine=ModMachine.grinder) for artifact in all_artifacts))
def source_display_items(self, item: str, content: StardewContent):
wood_display = f"Wooden Display: {item}"
hardwood_display = f"Hardwood Display: {item}"
if item == "Trilobite":
wood_display = f"Wooden Display: Trilobite Fossil"
hardwood_display = f"Hardwood Display: Trilobite Fossil"
content.source_item(wood_display, MachineSource(item=str(item), machine=ModMachine.preservation_chamber))
content.source_item(hardwood_display, MachineSource(item=str(item), machine=ModMachine.hardwood_preservation_chamber))
register_mod_content_pack(ArchaeologyContentPack(
ModNames.archaeology,
shop_sources={
ModBook.digging_like_worms: (
Tag(ItemTag.BOOK, ItemTag.BOOK_SKILL),
ShopSource(money_price=500, shop_region=LogicRegion.bookseller_1),),
},
skills=(Skill(name=ModSkill.archaeology, has_mastery=False),),
))

View File

@@ -1,9 +1,26 @@
from ..game_content import ContentPack
from ..game_content import ContentPack, StardewContent
from ..mod_registry import register_mod_content_pack
from ...data import villagers_data, fish_data
from ...data.game_item import ItemTag, Tag
from ...data.harvest import ForagingSource, HarvestCropSource
from ...data.requirement import QuestRequirement
from ...mods.mod_data import ModNames
from ...strings.crop_names import DistantLandsCrop
from ...strings.forageable_names import DistantLandsForageable
from ...strings.quest_names import ModQuest
from ...strings.region_names import Region
from ...strings.season_names import Season
from ...strings.seed_names import DistantLandsSeed
register_mod_content_pack(ContentPack(
class DistantLandsContentPack(ContentPack):
def harvest_source_hook(self, content: StardewContent):
content.untag_item(DistantLandsSeed.void_mint, tag=ItemTag.CROPSANITY_SEED)
content.untag_item(DistantLandsSeed.vile_ancient_fruit, tag=ItemTag.CROPSANITY_SEED)
register_mod_content_pack(DistantLandsContentPack(
ModNames.distant_lands,
fishes=(
fish_data.void_minnow,
@@ -13,5 +30,13 @@ register_mod_content_pack(ContentPack(
),
villagers=(
villagers_data.zic,
)
),
harvest_sources={
DistantLandsForageable.swamp_herb: (ForagingSource(regions=(Region.witch_swamp,)),),
DistantLandsForageable.brown_amanita: (ForagingSource(regions=(Region.witch_swamp,)),),
DistantLandsSeed.void_mint: (ForagingSource(regions=(Region.witch_swamp,), other_requirements=(QuestRequirement(ModQuest.CorruptedCropsTask),)),),
DistantLandsCrop.void_mint: (Tag(ItemTag.VEGETABLE), HarvestCropSource(seed=DistantLandsSeed.void_mint, seasons=(Season.spring, Season.summer, Season.fall)),),
DistantLandsSeed.vile_ancient_fruit: (ForagingSource(regions=(Region.witch_swamp,), other_requirements=(QuestRequirement(ModQuest.CorruptedCropsTask),)),),
DistantLandsCrop.vile_ancient_fruit: (Tag(ItemTag.FRUIT), HarvestCropSource(seed=DistantLandsSeed.vile_ancient_fruit, seasons=(Season.spring, Season.summer, Season.fall)),)
}
))

View File

@@ -73,13 +73,6 @@ register_mod_content_pack(ContentPack(
)
))
register_mod_content_pack(ContentPack(
ModNames.alecto,
villagers=(
villagers_data.alecto,
)
))
register_mod_content_pack(ContentPack(
ModNames.lacey,
villagers=(

View File

@@ -3,15 +3,27 @@ from ..mod_registry import register_mod_content_pack
from ..override import override
from ..vanilla.ginger_island import ginger_island_content_pack as ginger_island_content_pack
from ...data import villagers_data, fish_data
from ...data.harvest import ForagingSource
from ...data.requirement import YearRequirement
from ...data.game_item import ItemTag, Tag
from ...data.harvest import ForagingSource, HarvestCropSource
from ...data.requirement import YearRequirement, CombatRequirement, RelationshipRequirement, ToolRequirement, SkillRequirement, FishingRequirement
from ...data.shop import ShopSource
from ...mods.mod_data import ModNames
from ...strings.crop_names import Fruit
from ...strings.fish_names import WaterItem
from ...strings.craftable_names import ModEdible
from ...strings.crop_names import Fruit, SVEVegetable, SVEFruit
from ...strings.fish_names import WaterItem, SVEFish, SVEWaterItem
from ...strings.flower_names import Flower
from ...strings.forageable_names import Mushroom, Forageable
from ...strings.region_names import Region, SVERegion
from ...strings.food_names import SVEMeal, SVEBeverage
from ...strings.forageable_names import Mushroom, Forageable, SVEForage
from ...strings.gift_names import SVEGift
from ...strings.metal_names import Ore
from ...strings.monster_drop_names import ModLoot, Loot
from ...strings.performance_names import Performance
from ...strings.region_names import Region, SVERegion, LogicRegion
from ...strings.season_names import Season
from ...strings.seed_names import SVESeed
from ...strings.skill_names import Skill
from ...strings.tool_names import Tool, ToolMaterial
from ...strings.villager_names import ModNPC
class SVEContentPack(ContentPack):
@@ -38,6 +50,24 @@ class SVEContentPack(ContentPack):
# Remove Lance if Ginger Island is not in content since he is first encountered in Volcano Forge
content.villagers.pop(villagers_data.lance.name)
def harvest_source_hook(self, content: StardewContent):
content.untag_item(SVESeed.shrub, tag=ItemTag.CROPSANITY_SEED)
content.untag_item(SVESeed.fungus, tag=ItemTag.CROPSANITY_SEED)
content.untag_item(SVESeed.slime, tag=ItemTag.CROPSANITY_SEED)
content.untag_item(SVESeed.stalk, tag=ItemTag.CROPSANITY_SEED)
content.untag_item(SVESeed.void, tag=ItemTag.CROPSANITY_SEED)
content.untag_item(SVESeed.ancient_fern, tag=ItemTag.CROPSANITY_SEED)
if ginger_island_content_pack.name not in content.registered_packs:
# Remove Highlands seeds as these are behind Lance existing.
content.game_items.pop(SVESeed.void)
content.game_items.pop(SVEVegetable.void_root)
content.game_items.pop(SVESeed.stalk)
content.game_items.pop(SVEFruit.monster_fruit)
content.game_items.pop(SVESeed.fungus)
content.game_items.pop(SVEVegetable.monster_mushroom)
content.game_items.pop(SVESeed.slime)
content.game_items.pop(SVEFruit.slime_berry)
register_mod_content_pack(SVEContentPack(
ModNames.sve,
@@ -45,12 +75,24 @@ register_mod_content_pack(SVEContentPack(
ginger_island_content_pack.name,
ModNames.jasper, # To override Marlon and Gunther
),
shop_sources={
SVEGift.aged_blue_moon_wine: (ShopSource(money_price=28000, shop_region=SVERegion.blue_moon_vineyard),),
SVEGift.blue_moon_wine: (ShopSource(money_price=3000, shop_region=SVERegion.blue_moon_vineyard),),
ModEdible.lightning_elixir: (ShopSource(money_price=12000, shop_region=SVERegion.galmoran_outpost),),
ModEdible.barbarian_elixir: (ShopSource(money_price=22000, shop_region=SVERegion.galmoran_outpost),),
ModEdible.gravity_elixir: (ShopSource(money_price=4000, shop_region=SVERegion.galmoran_outpost),),
SVEMeal.grampleton_orange_chicken: (ShopSource(money_price=650, shop_region=Region.saloon, other_requirements=(RelationshipRequirement(ModNPC.sophia, 6),)),),
ModEdible.hero_elixir: (ShopSource(money_price=8000, shop_region=SVERegion.isaac_shop),),
ModEdible.aegis_elixir: (ShopSource(money_price=28000, shop_region=SVERegion.galmoran_outpost),),
SVEBeverage.sports_drink: (ShopSource(money_price=750, shop_region=Region.hospital),),
SVEMeal.stamina_capsule: (ShopSource(money_price=4000, shop_region=Region.hospital),),
},
harvest_sources={
Mushroom.red: (
ForagingSource(regions=(SVERegion.forest_west,), seasons=(Season.summer, Season.fall)), ForagingSource(regions=(SVERegion.sprite_spring_cave,), )
),
Mushroom.purple: (
ForagingSource(regions=(SVERegion.forest_west,), seasons=(Season.fall,)), ForagingSource(regions=(SVERegion.sprite_spring_cave,), )
ForagingSource(regions=(SVERegion.forest_west,), seasons=(Season.fall,)), ForagingSource(regions=(SVERegion.sprite_spring_cave, SVERegion.junimo_woods), )
),
Mushroom.morel: (
ForagingSource(regions=(SVERegion.forest_west,), seasons=(Season.fall,)), ForagingSource(regions=(SVERegion.sprite_spring_cave,), )
@@ -64,17 +106,59 @@ register_mod_content_pack(SVEContentPack(
Flower.sunflower: (ForagingSource(regions=(SVERegion.sprite_spring,), seasons=(Season.summer,)),),
Flower.fairy_rose: (ForagingSource(regions=(SVERegion.sprite_spring,), seasons=(Season.fall,)),),
Fruit.ancient_fruit: (
ForagingSource(regions=(SVERegion.sprite_spring,), seasons=(Season.spring, Season.summer, Season.fall), other_requirements=(YearRequirement(3),)),
ForagingSource(regions=(SVERegion.sprite_spring,), seasons=Season.not_winter, other_requirements=(YearRequirement(3),)),
ForagingSource(regions=(SVERegion.sprite_spring_cave,)),
),
Fruit.sweet_gem_berry: (
ForagingSource(regions=(SVERegion.sprite_spring,), seasons=(Season.spring, Season.summer, Season.fall), other_requirements=(YearRequirement(3),)),
ForagingSource(regions=(SVERegion.sprite_spring,), seasons=Season.not_winter, other_requirements=(YearRequirement(3),)),
),
# New items
ModLoot.green_mushroom: (ForagingSource(regions=(SVERegion.highlands_pond,), seasons=Season.not_winter),),
ModLoot.ornate_treasure_chest: (ForagingSource(regions=(SVERegion.highlands_outside,),
other_requirements=(CombatRequirement(Performance.galaxy), ToolRequirement(Tool.axe, ToolMaterial.iron))),),
ModLoot.swirl_stone: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.galaxy),)),),
ModLoot.void_soul: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.good),)),),
SVEForage.winter_star_rose: (ForagingSource(regions=(SVERegion.summit,), seasons=(Season.winter,)),),
SVEForage.bearberry: (ForagingSource(regions=(Region.secret_woods,), seasons=(Season.winter,)),),
SVEForage.poison_mushroom: (ForagingSource(regions=(Region.secret_woods,), seasons=(Season.summer, Season.fall)),),
SVEForage.red_baneberry: (ForagingSource(regions=(Region.secret_woods,), seasons=(Season.summer, Season.summer)),),
SVEForage.ferngill_primrose: (ForagingSource(regions=(SVERegion.summit,), seasons=(Season.spring,)),),
SVEForage.goldenrod: (ForagingSource(regions=(SVERegion.summit,), seasons=(Season.summer, Season.fall)),),
SVEForage.conch: (ForagingSource(regions=(Region.beach, SVERegion.fable_reef,)),),
SVEForage.dewdrop_berry: (ForagingSource(regions=(SVERegion.enchanted_grove,)),),
SVEForage.sand_dollar: (ForagingSource(regions=(Region.beach, SVERegion.fable_reef,), seasons=(Season.spring, Season.summer)),),
SVEForage.golden_ocean_flower: (ForagingSource(regions=(SVERegion.fable_reef,)),),
SVEForage.four_leaf_clover: (ForagingSource(regions=(Region.secret_woods, SVERegion.forest_west,), seasons=(Season.summer, Season.fall)),),
SVEForage.mushroom_colony: (ForagingSource(regions=(Region.secret_woods, SVERegion.junimo_woods, SVERegion.forest_west,), seasons=(Season.fall,)),),
SVEForage.rusty_blade: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.great),)),),
SVEForage.rafflesia: (ForagingSource(regions=(Region.secret_woods,), seasons=Season.not_winter),),
SVEForage.thistle: (ForagingSource(regions=(SVERegion.summit,)),),
ModLoot.void_pebble: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.great),)),),
ModLoot.void_shard: (ForagingSource(regions=(SVERegion.crimson_badlands,),
other_requirements=(CombatRequirement(Performance.galaxy), SkillRequirement(Skill.combat, 10), YearRequirement(3),)),),
SVEWaterItem.dulse_seaweed: (ForagingSource(regions=(Region.beach,), other_requirements=(FishingRequirement(Region.beach),)),),
# Fable Reef
WaterItem.coral: (ForagingSource(regions=(SVERegion.fable_reef,)),),
Forageable.rainbow_shell: (ForagingSource(regions=(SVERegion.fable_reef,)),),
WaterItem.sea_urchin: (ForagingSource(regions=(SVERegion.fable_reef,)),),
# Crops
SVESeed.shrub: (ForagingSource(regions=(Region.secret_woods,), other_requirements=(CombatRequirement(Performance.good),)),),
SVEFruit.salal_berry: (Tag(ItemTag.FRUIT), HarvestCropSource(seed=SVESeed.shrub, seasons=(Season.spring,)),),
SVESeed.slime: (ForagingSource(regions=(SVERegion.highlands_outside,), other_requirements=(CombatRequirement(Performance.good),)),),
SVEFruit.slime_berry: (Tag(ItemTag.FRUIT), HarvestCropSource(seed=SVESeed.slime, seasons=(Season.spring,)),),
SVESeed.ancient_fern: (ForagingSource(regions=(Region.secret_woods,)),),
SVEVegetable.ancient_fiber: (Tag(ItemTag.VEGETABLE), HarvestCropSource(seed=SVESeed.ancient_fern, seasons=(Season.summer,)),),
SVESeed.stalk: (ForagingSource(regions=(SVERegion.highlands_outside,), other_requirements=(CombatRequirement(Performance.good),)),),
SVEFruit.monster_fruit: (Tag(ItemTag.FRUIT), HarvestCropSource(seed=SVESeed.stalk, seasons=(Season.summer,)),),
SVESeed.fungus: (ForagingSource(regions=(SVERegion.highlands_pond,), other_requirements=(CombatRequirement(Performance.good),)),),
SVEVegetable.monster_mushroom: (Tag(ItemTag.VEGETABLE), HarvestCropSource(seed=SVESeed.fungus, seasons=(Season.fall,)),),
SVESeed.void: (ForagingSource(regions=(SVERegion.highlands_cavern,), other_requirements=(CombatRequirement(Performance.good),)),),
SVEVegetable.void_root: (Tag(ItemTag.VEGETABLE), HarvestCropSource(seed=SVESeed.void, seasons=(Season.winter,)),),
},
fishes=(
fish_data.baby_lunaloo, # Removed when no ginger island

View File

@@ -229,7 +229,7 @@ pelican_town = ContentPack(
ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),),
Book.mapping_cave_systems: (
Tag(ItemTag.BOOK, ItemTag.BOOK_POWER),
GenericSource(regions=Region.adventurer_guild_bedroom),
GenericSource(regions=(Region.adventurer_guild_bedroom,)),
ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),),
Book.monster_compendium: (
Tag(ItemTag.BOOK, ItemTag.BOOK_POWER),
@@ -243,12 +243,12 @@ pelican_town = ContentPack(
ShopSource(money_price=3000, shop_region=LogicRegion.bookseller_2),),
Book.the_alleyway_buffet: (
Tag(ItemTag.BOOK, ItemTag.BOOK_POWER),
GenericSource(regions=Region.town,
GenericSource(regions=(Region.town,),
other_requirements=(ToolRequirement(Tool.axe, ToolMaterial.iron), ToolRequirement(Tool.pickaxe, ToolMaterial.iron))),
ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),),
Book.the_art_o_crabbing: (
Tag(ItemTag.BOOK, ItemTag.BOOK_POWER),
GenericSource(regions=Region.beach,
GenericSource(regions=(Region.beach,),
other_requirements=(ToolRequirement(Tool.fishing_rod, ToolMaterial.iridium),
SkillRequirement(Skill.fishing, 6),
SeasonRequirement(Season.winter))),

View File

@@ -46,7 +46,8 @@ pirate_cove = (Region.pirate_cove,)
crimson_badlands = (SVERegion.crimson_badlands,)
shearwater = (SVERegion.shearwater,)
highlands = (SVERegion.highlands_outside,)
highlands_pond = (SVERegion.highlands_pond,)
highlands_cave = (SVERegion.highlands_cavern,)
sprite_spring = (SVERegion.sprite_spring,)
fable_reef = (SVERegion.fable_reef,)
vineyard = (SVERegion.blue_moon_vineyard,)
@@ -133,9 +134,9 @@ bonefish = create_fish(SVEFish.bonefish, crimson_badlands, season.all_seasons, 7
bull_trout = create_fish(SVEFish.bull_trout, forest_river, season.not_spring, 45, mod_name=ModNames.sve)
butterfish = create_fish(SVEFish.butterfish, shearwater, season.not_winter, 75, mod_name=ModNames.sve)
clownfish = create_fish(SVEFish.clownfish, ginger_island_ocean, season.all_seasons, 45, mod_name=ModNames.sve)
daggerfish = create_fish(SVEFish.daggerfish, highlands, season.all_seasons, 50, mod_name=ModNames.sve)
daggerfish = create_fish(SVEFish.daggerfish, highlands_pond, season.all_seasons, 50, mod_name=ModNames.sve)
frog = create_fish(SVEFish.frog, mountain_lake, (season.spring, season.summer), 70, mod_name=ModNames.sve)
gemfish = create_fish(SVEFish.gemfish, highlands, season.all_seasons, 100, mod_name=ModNames.sve)
gemfish = create_fish(SVEFish.gemfish, highlands_cave, season.all_seasons, 100, mod_name=ModNames.sve)
goldenfish = create_fish(SVEFish.goldenfish, sprite_spring, season.all_seasons, 60, mod_name=ModNames.sve)
grass_carp = create_fish(SVEFish.grass_carp, secret_woods, (season.spring, season.summer), 85, mod_name=ModNames.sve)
king_salmon = create_fish(SVEFish.king_salmon, forest_river, (season.spring, season.summer), 80, mod_name=ModNames.sve)

View File

@@ -307,7 +307,7 @@ id,name,classification,groups,mod_name
322,Phoenix Ring,useful,"RING,RESOURCE_PACK,MAXIMUM_ONE",
323,Immunity Band,useful,"RING,RESOURCE_PACK,MAXIMUM_ONE",
324,Glowstone Ring,useful,"RING,RESOURCE_PACK,MAXIMUM_ONE",
325,Fairy Dust Recipe,progression,,
325,Fairy Dust Recipe,progression,"GINGER_ISLAND",
326,Heavy Tapper Recipe,progression,"QI_CRAFTING_RECIPE,GINGER_ISLAND",
327,Hyper Speed-Gro Recipe,progression,"QI_CRAFTING_RECIPE,GINGER_ISLAND",
328,Deluxe Fertilizer Recipe,progression,QI_CRAFTING_RECIPE,
1 id name classification groups mod_name
307 322 Phoenix Ring useful RING,RESOURCE_PACK,MAXIMUM_ONE
308 323 Immunity Band useful RING,RESOURCE_PACK,MAXIMUM_ONE
309 324 Glowstone Ring useful RING,RESOURCE_PACK,MAXIMUM_ONE
310 325 Fairy Dust Recipe progression GINGER_ISLAND
311 326 Heavy Tapper Recipe progression QI_CRAFTING_RECIPE,GINGER_ISLAND
312 327 Hyper Speed-Gro Recipe progression QI_CRAFTING_RECIPE,GINGER_ISLAND
313 328 Deluxe Fertilizer Recipe progression QI_CRAFTING_RECIPE

View File

@@ -2088,7 +2088,7 @@ id,region,name,tags,mod_name
3472,Farm,Craft Life Elixir,CRAFTSANITY,
3473,Farm,Craft Oil of Garlic,CRAFTSANITY,
3474,Farm,Craft Monster Musk,CRAFTSANITY,
3475,Farm,Craft Fairy Dust,CRAFTSANITY,
3475,Farm,Craft Fairy Dust,"CRAFTSANITY,GINGER_ISLAND",
3476,Farm,Craft Warp Totem: Beach,CRAFTSANITY,
3477,Farm,Craft Warp Totem: Mountains,CRAFTSANITY,
3478,Farm,Craft Warp Totem: Farm,CRAFTSANITY,
@@ -2900,7 +2900,6 @@ id,region,name,tags,mod_name
7055,Abandoned Mines - 3,Abandoned Treasure - Floor 3,MANDATORY,Boarding House and Bus Stop Extension
7056,Abandoned Mines - 4,Abandoned Treasure - Floor 4,MANDATORY,Boarding House and Bus Stop Extension
7057,Abandoned Mines - 5,Abandoned Treasure - Floor 5,MANDATORY,Boarding House and Bus Stop Extension
7351,Farm,Read Digging Like Worms,"BOOKSANITY,BOOKSANITY_SKILL",Archaeology
7401,Farm,Cook Magic Elixir,COOKSANITY,Magic
7402,Farm,Craft Travel Core,CRAFTSANITY,Magic
7403,Farm,Craft Haste Elixir,CRAFTSANITY,Stardew Valley Expanded
@@ -3280,10 +3279,10 @@ id,region,name,tags,mod_name
8237,Shipping,Shipsanity: Pterodactyl R Wing Bone,SHIPSANITY,Boarding House and Bus Stop Extension
8238,Shipping,Shipsanity: Scrap Rust,SHIPSANITY,Archaeology
8239,Shipping,Shipsanity: Rusty Path,SHIPSANITY,Archaeology
8240,Shipping,Shipsanity: Digging Like Worms,SHIPSANITY,Archaeology
8241,Shipping,Shipsanity: Digger's Delight,SHIPSANITY,Archaeology
8242,Shipping,Shipsanity: Rocky Root Coffee,SHIPSANITY,Archaeology
8243,Shipping,Shipsanity: Ancient Jello,SHIPSANITY,Archaeology
8244,Shipping,Shipsanity: Bone Fence,SHIPSANITY,Archaeology
8245,Shipping,Shipsanity: Grilled Cheese,SHIPSANITY,Binning Skill
8246,Shipping,Shipsanity: Fish Casserole,SHIPSANITY,Binning Skill
8247,Shipping,Shipsanity: Snatcher Worm,SHIPSANITY,Stardew Valley Expanded
1 id region name tags mod_name
2088 3472 Farm Craft Life Elixir CRAFTSANITY
2089 3473 Farm Craft Oil of Garlic CRAFTSANITY
2090 3474 Farm Craft Monster Musk CRAFTSANITY
2091 3475 Farm Craft Fairy Dust CRAFTSANITY CRAFTSANITY,GINGER_ISLAND
2092 3476 Farm Craft Warp Totem: Beach CRAFTSANITY
2093 3477 Farm Craft Warp Totem: Mountains CRAFTSANITY
2094 3478 Farm Craft Warp Totem: Farm CRAFTSANITY
2900 7055 Abandoned Mines - 3 Abandoned Treasure - Floor 3 MANDATORY Boarding House and Bus Stop Extension
2901 7056 Abandoned Mines - 4 Abandoned Treasure - Floor 4 MANDATORY Boarding House and Bus Stop Extension
2902 7057 Abandoned Mines - 5 Abandoned Treasure - Floor 5 MANDATORY Boarding House and Bus Stop Extension
7351 Farm Read Digging Like Worms BOOKSANITY,BOOKSANITY_SKILL Archaeology
2903 7401 Farm Cook Magic Elixir COOKSANITY Magic
2904 7402 Farm Craft Travel Core CRAFTSANITY Magic
2905 7403 Farm Craft Haste Elixir CRAFTSANITY Stardew Valley Expanded
3279 8237 Shipping Shipsanity: Pterodactyl R Wing Bone SHIPSANITY Boarding House and Bus Stop Extension
3280 8238 Shipping Shipsanity: Scrap Rust SHIPSANITY Archaeology
3281 8239 Shipping Shipsanity: Rusty Path SHIPSANITY Archaeology
8240 Shipping Shipsanity: Digging Like Worms SHIPSANITY Archaeology
3282 8241 Shipping Shipsanity: Digger's Delight SHIPSANITY Archaeology
3283 8242 Shipping Shipsanity: Rocky Root Coffee SHIPSANITY Archaeology
3284 8243 Shipping Shipsanity: Ancient Jello SHIPSANITY Archaeology
3285 8244 Shipping Shipsanity: Bone Fence SHIPSANITY Archaeology
3286 8245 Shipping Shipsanity: Grilled Cheese SHIPSANITY Binning Skill
3287 8246 Shipping Shipsanity: Fish Casserole SHIPSANITY Binning Skill
3288 8247 Shipping Shipsanity: Snatcher Worm SHIPSANITY Stardew Valley Expanded

View File

@@ -5,7 +5,7 @@ from ..strings.animal_product_names import AnimalProduct
from ..strings.artisan_good_names import ArtisanGood
from ..strings.craftable_names import ModEdible, Edible
from ..strings.crop_names import Fruit, Vegetable, SVEFruit, DistantLandsCrop
from ..strings.fish_names import Fish, SVEFish, WaterItem, DistantLandsFish
from ..strings.fish_names import Fish, SVEFish, WaterItem, DistantLandsFish, SVEWaterItem
from ..strings.flower_names import Flower
from ..strings.forageable_names import Forageable, SVEForage, DistantLandsForageable, Mushroom
from ..strings.ingredient_names import Ingredient
@@ -195,7 +195,7 @@ mixed_berry_pie = shop_recipe(SVEMeal.mixed_berry_pie, Region.saloon, 3500, {Fru
ModNames.sve)
mushroom_berry_rice = friendship_and_shop_recipe(SVEMeal.mushroom_berry_rice, ModNPC.marlon, 6, Region.adventurer_guild, 1500, {SVEForage.poison_mushroom: 3, SVEForage.red_baneberry: 10,
Ingredient.rice: 1, Ingredient.sugar: 2}, ModNames.sve)
seaweed_salad = shop_recipe(SVEMeal.seaweed_salad, Region.fish_shop, 1250, {SVEFish.dulse_seaweed: 2, WaterItem.seaweed: 2, Ingredient.oil: 1}, ModNames.sve)
seaweed_salad = shop_recipe(SVEMeal.seaweed_salad, Region.fish_shop, 1250, {SVEWaterItem.dulse_seaweed: 2, WaterItem.seaweed: 2, Ingredient.oil: 1}, ModNames.sve)
void_delight = friendship_and_shop_recipe(SVEMeal.void_delight, NPC.krobus, 10, Region.sewer, 5000,
{SVEFish.void_eel: 1, Loot.void_essence: 50, Loot.solar_essence: 20}, ModNames.sve)
void_salmon_sushi = friendship_and_shop_recipe(SVEMeal.void_salmon_sushi, NPC.krobus, 10, Region.sewer, 5000,

View File

@@ -31,6 +31,27 @@ class YearRequirement(Requirement):
year: int
@dataclass(frozen=True)
class CombatRequirement(Requirement):
level: str
@dataclass(frozen=True)
class QuestRequirement(Requirement):
quest: str
@dataclass(frozen=True)
class RelationshipRequirement(Requirement):
npc: str
hearts: int
@dataclass(frozen=True)
class FishingRequirement(Requirement):
region: str
@dataclass(frozen=True)
class WalnutRequirement(Requirement):
amount: int

View File

@@ -16,8 +16,8 @@ class ShopSource(ItemSource):
other_requirements: Tuple[Requirement, ...] = ()
def __post_init__(self):
assert self.money_price or self.items_price, "At least money price or items price need to be defined."
assert self.items_price is None or all(type(p) == tuple for p in self.items_price), "Items price should be a tuple."
assert self.money_price is not None or self.items_price is not None, "At least money price or items price need to be defined."
assert self.items_price is None or all(isinstance(p, tuple) for p in self.items_price), "Items price should be a tuple."
@dataclass(frozen=True, **kw_only)

View File

@@ -409,8 +409,9 @@ def create_special_quest_rewards(item_factory: StardewItemFactory, options: Star
else:
items.append(item_factory(Wallet.bears_knowledge, ItemClassification.useful)) # Not necessary outside of SVE
items.append(item_factory(Wallet.iridium_snake_milk))
items.append(item_factory("Fairy Dust Recipe"))
items.append(item_factory("Dark Talisman"))
if options.exclude_ginger_island == ExcludeGingerIsland.option_false:
items.append(item_factory("Fairy Dust Recipe"))
def create_help_wanted_quest_rewards(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]):

View File

@@ -3,15 +3,20 @@ from typing import Union, Iterable
from .base_logic import BaseLogicMixin, BaseLogic
from .book_logic import BookLogicMixin
from .combat_logic import CombatLogicMixin
from .fishing_logic import FishingLogicMixin
from .has_logic import HasLogicMixin
from .quest_logic import QuestLogicMixin
from .received_logic import ReceivedLogicMixin
from .relationship_logic import RelationshipLogicMixin
from .season_logic import SeasonLogicMixin
from .skill_logic import SkillLogicMixin
from .time_logic import TimeLogicMixin
from .tool_logic import ToolLogicMixin
from .walnut_logic import WalnutLogicMixin
from ..data.game_item import Requirement
from ..data.requirement import ToolRequirement, BookRequirement, SkillRequirement, SeasonRequirement, YearRequirement, WalnutRequirement
from ..data.requirement import ToolRequirement, BookRequirement, SkillRequirement, SeasonRequirement, YearRequirement, CombatRequirement, QuestRequirement, \
RelationshipRequirement, FishingRequirement, WalnutRequirement
class RequirementLogicMixin(BaseLogicMixin):
@@ -21,7 +26,7 @@ class RequirementLogicMixin(BaseLogicMixin):
class RequirementLogic(BaseLogic[Union[RequirementLogicMixin, HasLogicMixin, ReceivedLogicMixin, ToolLogicMixin, SkillLogicMixin, BookLogicMixin,
SeasonLogicMixin, TimeLogicMixin, WalnutLogicMixin]]):
SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, RelationshipLogicMixin, FishingLogicMixin, WalnutLogicMixin]]):
def meet_all_requirements(self, requirements: Iterable[Requirement]):
if not requirements:
@@ -55,3 +60,21 @@ SeasonLogicMixin, TimeLogicMixin, WalnutLogicMixin]]):
@meet_requirement.register
def _(self, requirement: WalnutRequirement):
return self.logic.walnut.has_walnut(requirement.amount)
@meet_requirement.register
def _(self, requirement: CombatRequirement):
return self.logic.combat.can_fight_at_level(requirement.level)
@meet_requirement.register
def _(self, requirement: QuestRequirement):
return self.logic.quest.can_complete_quest(requirement.quest)
@meet_requirement.register
def _(self, requirement: RelationshipRequirement):
return self.logic.relationship.has_hearts(requirement.npc, requirement.hearts)
@meet_requirement.register
def _(self, requirement: FishingRequirement):
return self.logic.fishing.can_fish_at(requirement.region)

View File

@@ -23,24 +23,15 @@ from ...logic.tool_logic import ToolLogicMixin
from ...options import Cropsanity
from ...stardew_rule import StardewRule, True_
from ...strings.artisan_good_names import ModArtisanGood
from ...strings.craftable_names import ModCraftable, ModEdible, ModMachine
from ...strings.crop_names import SVEVegetable, SVEFruit, DistantLandsCrop
from ...strings.fish_names import ModTrash, SVEFish
from ...strings.food_names import SVEMeal, SVEBeverage
from ...strings.forageable_names import SVEForage, DistantLandsForageable
from ...strings.gift_names import SVEGift
from ...strings.craftable_names import ModCraftable, ModMachine
from ...strings.fish_names import ModTrash
from ...strings.ingredient_names import Ingredient
from ...strings.material_names import Material
from ...strings.metal_names import all_fossils, all_artifacts, Ore, ModFossil
from ...strings.monster_drop_names import ModLoot, Loot
from ...strings.monster_drop_names import Loot
from ...strings.performance_names import Performance
from ...strings.quest_names import ModQuest
from ...strings.region_names import Region, SVERegion, DeepWoodsRegion, BoardingHouseRegion
from ...strings.season_names import Season
from ...strings.seed_names import SVESeed, DistantLandsSeed
from ...strings.skill_names import Skill
from ...strings.region_names import SVERegion, DeepWoodsRegion, BoardingHouseRegion
from ...strings.tool_names import Tool, ToolMaterial
from ...strings.villager_names import ModNPC
display_types = [ModCraftable.wooden_display, ModCraftable.hardwood_display]
display_items = all_artifacts + all_fossils
@@ -58,12 +49,6 @@ FarmingLogicMixin]]):
def get_modded_item_rules(self) -> Dict[str, StardewRule]:
items = dict()
if ModNames.sve in self.options.mods:
items.update(self.get_sve_item_rules())
if ModNames.archaeology in self.options.mods:
items.update(self.get_archaeology_item_rules())
if ModNames.distant_lands in self.options.mods:
items.update(self.get_distant_lands_item_rules())
if ModNames.boarding_house in self.options.mods:
items.update(self.get_boarding_house_item_rules())
return items
@@ -75,61 +60,6 @@ FarmingLogicMixin]]):
item_rule.update(self.get_modified_item_rules_for_deep_woods(item_rule))
return item_rule
def get_sve_item_rules(self):
return {SVEGift.aged_blue_moon_wine: self.logic.money.can_spend_at(SVERegion.sophias_house, 28000),
SVEGift.blue_moon_wine: self.logic.money.can_spend_at(SVERegion.sophias_house, 3000),
SVESeed.fungus: self.logic.region.can_reach(SVERegion.highlands_cavern) & self.logic.combat.has_good_weapon,
ModLoot.green_mushroom: self.logic.region.can_reach(SVERegion.highlands_outside) &
self.logic.tool.has_tool(Tool.axe, ToolMaterial.iron) & self.logic.season.has_any_not_winter(),
SVEFruit.monster_fruit: self.logic.season.has(Season.summer) & self.logic.has(SVESeed.stalk),
SVEVegetable.monster_mushroom: self.logic.season.has(Season.fall) & self.logic.has(SVESeed.fungus),
ModLoot.ornate_treasure_chest: self.logic.region.can_reach(SVERegion.highlands_outside) & self.logic.combat.has_galaxy_weapon &
self.logic.tool.has_tool(Tool.axe, ToolMaterial.iron),
SVEFruit.slime_berry: self.logic.season.has(Season.spring) & self.logic.has(SVESeed.slime),
SVESeed.slime: self.logic.region.can_reach(SVERegion.highlands_outside) & self.logic.combat.has_good_weapon,
SVESeed.stalk: self.logic.region.can_reach(SVERegion.highlands_outside) & self.logic.combat.has_good_weapon,
ModLoot.swirl_stone: self.logic.region.can_reach(SVERegion.crimson_badlands) & self.logic.combat.has_great_weapon,
SVEVegetable.void_root: self.logic.season.has(Season.winter) & self.logic.has(SVESeed.void),
SVESeed.void: self.logic.region.can_reach(SVERegion.highlands_cavern) & self.logic.combat.has_good_weapon,
ModLoot.void_soul: self.logic.region.can_reach(
SVERegion.crimson_badlands) & self.logic.combat.has_good_weapon & self.logic.cooking.can_cook(),
SVEForage.winter_star_rose: self.logic.region.can_reach(SVERegion.summit) & self.logic.season.has(Season.winter),
SVEForage.bearberry: self.logic.region.can_reach(Region.secret_woods) & self.logic.season.has(Season.winter),
SVEForage.poison_mushroom: self.logic.region.can_reach(Region.secret_woods) & self.logic.season.has_any([Season.summer, Season.fall]),
SVEForage.red_baneberry: self.logic.region.can_reach(Region.secret_woods) & self.logic.season.has(Season.summer),
SVEForage.ferngill_primrose: self.logic.region.can_reach(SVERegion.summit) & self.logic.season.has(Season.spring),
SVEForage.goldenrod: self.logic.region.can_reach(SVERegion.summit) & (
self.logic.season.has(Season.summer) | self.logic.season.has(Season.fall)),
SVESeed.shrub: self.logic.region.can_reach(Region.secret_woods) & self.logic.tool.has_tool(Tool.hoe, ToolMaterial.basic),
SVEFruit.salal_berry: self.logic.farming.can_plant_and_grow_item((Season.spring, Season.summer)) & self.logic.has(SVESeed.shrub),
ModEdible.aegis_elixir: self.logic.money.can_spend_at(SVERegion.galmoran_outpost, 28000),
ModEdible.lightning_elixir: self.logic.money.can_spend_at(SVERegion.galmoran_outpost, 12000),
ModEdible.barbarian_elixir: self.logic.money.can_spend_at(SVERegion.galmoran_outpost, 22000),
ModEdible.gravity_elixir: self.logic.money.can_spend_at(SVERegion.galmoran_outpost, 4000),
SVESeed.ancient_fern: self.logic.region.can_reach(Region.secret_woods) & self.logic.tool.has_tool(Tool.hoe, ToolMaterial.basic),
SVEVegetable.ancient_fiber: self.logic.farming.can_plant_and_grow_item(Season.summer) & self.logic.has(SVESeed.ancient_fern),
SVEForage.conch: self.logic.region.can_reach_any((Region.beach, SVERegion.fable_reef)),
SVEForage.dewdrop_berry: self.logic.region.can_reach(SVERegion.enchanted_grove),
SVEForage.sand_dollar: self.logic.region.can_reach(SVERegion.fable_reef) | (self.logic.region.can_reach(Region.beach) &
self.logic.season.has_any([Season.summer, Season.fall])),
SVEForage.golden_ocean_flower: self.logic.region.can_reach(SVERegion.fable_reef),
SVEMeal.grampleton_orange_chicken: self.logic.money.can_spend_at(Region.saloon, 650) & self.logic.relationship.has_hearts(ModNPC.sophia, 6),
ModEdible.hero_elixir: self.logic.money.can_spend_at(SVERegion.isaac_shop, 8000),
SVEForage.four_leaf_clover: self.logic.region.can_reach_any((Region.secret_woods, SVERegion.forest_west)) &
self.logic.season.has_any([Season.spring, Season.summer]),
SVEForage.mushroom_colony: self.logic.region.can_reach_any((Region.secret_woods, SVERegion.junimo_woods, SVERegion.forest_west)) &
self.logic.season.has(Season.fall),
SVEForage.rusty_blade: self.logic.region.can_reach(SVERegion.crimson_badlands) & self.logic.combat.has_great_weapon,
SVEForage.rafflesia: self.logic.region.can_reach(Region.secret_woods),
SVEBeverage.sports_drink: self.logic.money.can_spend_at(Region.hospital, 750),
"Stamina Capsule": self.logic.money.can_spend_at(Region.hospital, 4000),
SVEForage.thistle: self.logic.region.can_reach(SVERegion.summit),
ModLoot.void_pebble: self.logic.region.can_reach(SVERegion.crimson_badlands) & self.logic.combat.has_great_weapon,
ModLoot.void_shard: self.logic.region.can_reach(SVERegion.crimson_badlands) & self.logic.combat.has_galaxy_weapon &
self.logic.skill.has_level(Skill.combat, 10) & self.logic.region.can_reach(Region.saloon) & self.logic.time.has_year_three
}
# @formatter:on
def get_modified_item_rules_for_sve(self, items: Dict[str, StardewRule]):
return {
Loot.void_essence: items[Loot.void_essence] | self.logic.region.can_reach(SVERegion.highlands_cavern) | self.logic.region.can_reach(
@@ -141,7 +71,7 @@ FarmingLogicMixin]]):
self.logic.combat.can_fight_at_level(Performance.great)),
Ore.iridium: items[Ore.iridium] | (self.logic.tool.can_use_tool_at(Tool.pickaxe, ToolMaterial.basic, SVERegion.crimson_badlands) &
self.logic.combat.can_fight_at_level(Performance.maximum)),
SVEFish.dulse_seaweed: self.logic.fishing.can_fish_at(Region.beach) & self.logic.season.has_any([Season.spring, Season.summer, Season.winter])
}
def get_modified_item_rules_for_deep_woods(self, items: Dict[str, StardewRule]):
@@ -160,36 +90,6 @@ FarmingLogicMixin]]):
return options_to_update
def get_archaeology_item_rules(self):
archaeology_item_rules = {}
preservation_chamber_rule = self.logic.has(ModMachine.preservation_chamber)
hardwood_preservation_chamber_rule = self.logic.has(ModMachine.hardwood_preservation_chamber)
for item in display_items:
for display_type in display_types:
if item == "Trilobite":
location_name = f"{display_type}: Trilobite Fossil"
else:
location_name = f"{display_type}: {item}"
display_item_rule = self.logic.crafting.can_craft(all_crafting_recipes_by_name[display_type]) & self.logic.has(item)
if "Wooden" in display_type:
archaeology_item_rules[location_name] = display_item_rule & preservation_chamber_rule
else:
archaeology_item_rules[location_name] = display_item_rule & hardwood_preservation_chamber_rule
archaeology_item_rules[ModTrash.rusty_scrap] = self.logic.has(ModMachine.grinder) & self.logic.has_any(*all_artifacts)
return archaeology_item_rules
def get_distant_lands_item_rules(self):
return {
DistantLandsForageable.swamp_herb: self.logic.region.can_reach(Region.witch_swamp),
DistantLandsForageable.brown_amanita: self.logic.region.can_reach(Region.witch_swamp),
DistantLandsSeed.vile_ancient_fruit: self.logic.quest.can_complete_quest(ModQuest.WitchOrder) | self.logic.quest.can_complete_quest(
ModQuest.CorruptedCropsTask),
DistantLandsSeed.void_mint: self.logic.quest.can_complete_quest(ModQuest.WitchOrder) | self.logic.quest.can_complete_quest(
ModQuest.CorruptedCropsTask),
DistantLandsCrop.void_mint: self.logic.season.has_any_not_winter() & self.logic.has(DistantLandsSeed.void_mint),
DistantLandsCrop.vile_ancient_fruit: self.logic.season.has_any_not_winter() & self.logic.has(DistantLandsSeed.vile_ancient_fruit),
}
def get_boarding_house_item_rules(self):
return {
# Mob Drops from lost valley enemies
@@ -251,8 +151,3 @@ FarmingLogicMixin]]):
BoardingHouseRegion.lost_valley_house_2,)) & self.logic.combat.can_fight_at_level(
Performance.great),
}
def has_seed_unlocked(self, seed_name: str):
if self.options.cropsanity == Cropsanity.option_disabled:
return True_()
return self.logic.received(seed_name)

View File

@@ -183,7 +183,8 @@ stardew_valley_expanded_regions = [
RegionData(SVERegion.first_slash_guild, [SVEEntrance.first_slash_guild_to_hallway], is_ginger_island=True),
RegionData(SVERegion.first_slash_hallway, [SVEEntrance.first_slash_hallway_to_room], is_ginger_island=True),
RegionData(SVERegion.first_slash_spare_room, is_ginger_island=True),
RegionData(SVERegion.highlands_outside, [SVEEntrance.highlands_to_lance, SVEEntrance.highlands_to_cave], is_ginger_island=True),
RegionData(SVERegion.highlands_outside, [SVEEntrance.highlands_to_lance, SVEEntrance.highlands_to_cave, SVEEntrance.highlands_to_pond], is_ginger_island=True),
RegionData(SVERegion.highlands_pond, is_ginger_island=True),
RegionData(SVERegion.highlands_cavern, [SVEEntrance.to_dwarf_prison], is_ginger_island=True),
RegionData(SVERegion.dwarf_prison, is_ginger_island=True),
RegionData(SVERegion.lances_house, [SVEEntrance.lance_to_ladder], is_ginger_island=True),
@@ -276,6 +277,7 @@ mandatory_sve_connections = [
ConnectionData(SVEEntrance.sprite_spring_to_cave, SVERegion.sprite_spring_cave, flag=RandomizationFlag.BUILDINGS),
ConnectionData(SVEEntrance.fish_shop_to_willy_bedroom, SVERegion.willy_bedroom, flag=RandomizationFlag.BUILDINGS),
ConnectionData(SVEEntrance.museum_to_gunther_bedroom, SVERegion.gunther_bedroom, flag=RandomizationFlag.BUILDINGS),
ConnectionData(SVEEntrance.highlands_to_pond, SVERegion.highlands_pond),
]
alecto_regions = [

View File

@@ -137,7 +137,8 @@ vanilla_regions = [
[Entrance.island_west_to_islandfarmhouse, Entrance.island_west_to_gourmand_cave, Entrance.island_west_to_crystals_cave,
Entrance.island_west_to_shipwreck, Entrance.island_west_to_qi_walnut_room, Entrance.use_farm_obelisk, Entrance.parrot_express_jungle_to_docks,
Entrance.parrot_express_jungle_to_dig_site, Entrance.parrot_express_jungle_to_volcano, LogicEntrance.grow_spring_crops_on_island,
LogicEntrance.grow_summer_crops_on_island, LogicEntrance.grow_fall_crops_on_island, LogicEntrance.grow_winter_crops_on_island, LogicEntrance.grow_indoor_crops_on_island],
LogicEntrance.grow_summer_crops_on_island, LogicEntrance.grow_fall_crops_on_island, LogicEntrance.grow_winter_crops_on_island,
LogicEntrance.grow_indoor_crops_on_island],
is_ginger_island=True),
RegionData(Region.island_east, [Entrance.island_east_to_leo_hut, Entrance.island_east_to_island_shrine], is_ginger_island=True),
RegionData(Region.island_shrine, is_ginger_island=True),
@@ -536,7 +537,7 @@ def create_final_regions(world_options) -> List[RegionData]:
def create_final_connections_and_regions(world_options) -> Tuple[Dict[str, ConnectionData], Dict[str, RegionData]]:
regions_data: Dict[str, RegionData] = {region.name: region for region in create_final_regions(world_options)}
connections = {connection.name: connection for connection in vanilla_connections}
connections = modify_connections_for_mods(connections, world_options.mods)
connections = modify_connections_for_mods(connections, sorted(world_options.mods.value))
include_island = world_options.exclude_ginger_island == ExcludeGingerIsland.option_false
return remove_ginger_island_regions_and_connections(regions_data, connections, include_island)
@@ -563,10 +564,8 @@ def remove_ginger_island_regions_and_connections(regions_by_name: Dict[str, Regi
return connections, regions_by_name
def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods) -> Dict[str, ConnectionData]:
if mods is None:
return connections
for mod in mods.value:
def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods: Iterable) -> Dict[str, ConnectionData]:
for mod in mods:
if mod not in ModDataList:
continue
if mod in vanilla_connections_to_remove_by_mod:

View File

@@ -1031,6 +1031,7 @@ def set_sve_ginger_island_rules(logic: StardewLogic, multiworld: MultiWorld, pla
set_entrance_rule(multiworld, player, SVEEntrance.wizard_to_fable_reef, logic.received(SVEQuestItem.fable_reef_portal))
set_entrance_rule(multiworld, player, SVEEntrance.highlands_to_cave,
logic.tool.has_tool(Tool.pickaxe, ToolMaterial.iron) & logic.tool.has_tool(Tool.axe, ToolMaterial.iron))
set_entrance_rule(multiworld, player, SVEEntrance.highlands_to_pond, logic.tool.has_tool(Tool.axe, ToolMaterial.iron))
def set_boarding_house_rules(logic: StardewLogic, multiworld: MultiWorld, player: int, world_options: StardewValleyOptions):

View File

@@ -27,10 +27,6 @@ class Book:
the_diamond_hunter = "The Diamond Hunter"
class ModBook:
digging_like_worms = "Digging Like Worms"
ordered_lost_books = []
all_lost_books = set()

View File

@@ -358,6 +358,7 @@ class SVEEntrance:
sprite_spring_to_cave = "Sprite Spring to Sprite Spring Cave"
fish_shop_to_willy_bedroom = "Willy's Fish Shop to Willy's Bedroom"
museum_to_gunther_bedroom = "Museum to Gunther's Bedroom"
highlands_to_pond = "Highlands to Highlands Pond"
class AlectoEntrance:

View File

@@ -137,7 +137,6 @@ class SVEFish:
void_eel = "Void Eel"
water_grub = "Water Grub"
sea_sponge = "Sea Sponge"
dulse_seaweed = "Dulse Seaweed"
class DistantLandsFish:
@@ -147,6 +146,10 @@ class DistantLandsFish:
giant_horsehoe_crab = "Giant Horsehoe Crab"
class SVEWaterItem:
dulse_seaweed = "Dulse Seaweed"
class ModTrash:
rusty_scrap = "Scrap Rust"

View File

@@ -102,6 +102,7 @@ class SVEMeal:
void_delight = "Void Delight"
void_salmon_sushi = "Void Salmon Sushi"
grampleton_orange_chicken = "Grampleton Orange Chicken"
stamina_capsule = "Stamina Capsule"
class TrashyMeal:

View File

@@ -296,6 +296,7 @@ class SVERegion:
sprite_spring_cave = "Sprite Spring Cave"
willy_bedroom = "Willy's Bedroom"
gunther_bedroom = "Gunther's Bedroom"
highlands_pond = "Highlands Pond"
class AlectoRegion:

View File

@@ -441,6 +441,16 @@ def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -
for i in range(1, len(test_options) + 1):
multiworld.game[i] = StardewValleyWorld.game
multiworld.player_name.update({i: f"Tester{i}"})
args = create_args(test_options)
multiworld.set_options(args)
for step in gen_steps:
call_all(multiworld, step)
return multiworld
def create_args(test_options):
args = Namespace()
for name, option in StardewValleyWorld.options_dataclass.type_hints.items():
options = {}
@@ -449,9 +459,4 @@ def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -
value = option(player_options[name]) if name in player_options else option.from_any(option.default)
options.update({i: value})
setattr(args, name, options)
multiworld.set_options(args)
for step in gen_steps:
call_all(multiworld, step)
return multiworld
return args

View File

@@ -14,7 +14,8 @@ class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase):
def test_given_single_mods_when_generate_then_basic_checks(self):
for mod in options.Mods.valid_keys:
with self.solo_world_sub_test(f"Mod: {mod}", {options.Mods: mod}) as (multi_world, _):
world_options = {options.Mods: mod, options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false}
with self.solo_world_sub_test(f"Mod: {mod}", world_options) as (multi_world, _):
self.assert_basic_checks(multi_world)
self.assert_stray_mod_items(mod, multi_world)
@@ -22,8 +23,9 @@ class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase):
for option in options.EntranceRandomization.options:
for mod in options.Mods.valid_keys:
world_options = {
options.EntranceRandomization.internal_name: options.EntranceRandomization.options[option],
options.Mods: mod
options.EntranceRandomization: options.EntranceRandomization.options[option],
options.Mods: mod,
options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false
}
with self.solo_world_sub_test(f"entrance_randomization: {option}, Mod: {mod}", world_options) as (multi_world, _):
self.assert_basic_checks(multi_world)

View File

@@ -37,7 +37,7 @@ class TestRaccoonBundlesLogic(SVTestBase):
options.BundlePrice: options.BundlePrice.option_normal,
options.Craftsanity: options.Craftsanity.option_all,
}
seed = 1234 # Magic seed that does what I want. Might need to get changed if we change the randomness behavior of raccoon bundles
seed = 2 # Magic seed that does what I want. Might need to get changed if we change the randomness behavior of raccoon bundles
def test_raccoon_bundles_rely_on_previous_ones(self):
# The first raccoon bundle is a fishing one

View File

@@ -1,6 +1,7 @@
import argparse
import json
from ...options import FarmType, EntranceRandomization
from ...test import setup_solo_multiworld, allsanity_mods_6_x_x
if __name__ == "__main__":
@@ -10,21 +11,23 @@ if __name__ == "__main__":
args = parser.parse_args()
seed = args.seed
multi_world = setup_solo_multiworld(
allsanity_mods_6_x_x(),
seed=seed
)
options = allsanity_mods_6_x_x()
options[FarmType.internal_name] = FarmType.option_standard
options[EntranceRandomization.internal_name] = EntranceRandomization.option_buildings
multi_world = setup_solo_multiworld(options, seed=seed)
world = multi_world.worlds[1]
output = {
"bundles": {
bundle_room.name: {
bundle.name: str(bundle.items)
for bundle in bundle_room.bundles
}
for bundle_room in multi_world.worlds[1].modified_bundles
for bundle_room in world.modified_bundles
},
"items": [item.name for item in multi_world.get_items()],
"location_rules": {location.name: repr(location.access_rule) for location in multi_world.get_locations(1)}
"location_rules": {location.name: repr(location.access_rule) for location in multi_world.get_locations(1)},
"slot_data": world.fill_slot_data()
}
print(json.dumps(output))

View File

@@ -24,8 +24,7 @@ class TestGenerationIsStable(SVTestCase):
if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
# seed = get_seed(33778671150797368040) # troubleshooting seed
seed = get_seed(74716545478307145559)
seed = get_seed()
output_a = subprocess.check_output([sys.executable, '-m', 'worlds.stardew_valley.test.stability.StabilityOutputScript', '--seed', str(seed)])
output_b = subprocess.check_output([sys.executable, '-m', 'worlds.stardew_valley.test.stability.StabilityOutputScript', '--seed', str(seed)])
@@ -54,3 +53,6 @@ class TestGenerationIsStable(SVTestCase):
# We check that the actual rule has the same order to make sure it is evaluated in the same order,
# so performance tests are repeatable as much as possible.
self.assertEqual(rule_a, rule_b, f"Location rule of {location_a} at index {i} is different between both executions. Seed={seed}")
for key, value in result_a["slot_data"].items():
self.assertEqual(value, result_b["slot_data"][key], f"Slot data {key} is different between both executions. Seed={seed}")

View File

@@ -0,0 +1,52 @@
import unittest
from unittest.mock import Mock
from .. import SVTestBase, create_args, allsanity_mods_6_x_x
from ... import STARDEW_VALLEY, FarmType, BundleRandomization, EntranceRandomization
class TestUniversalTrackerGenerationIsStable(SVTestBase):
options = allsanity_mods_6_x_x()
options.update({
EntranceRandomization.internal_name: EntranceRandomization.option_buildings,
BundleRandomization.internal_name: BundleRandomization.option_shuffled,
FarmType.internal_name: FarmType.option_standard, # Need to choose one otherwise it's random
})
def test_all_locations_and_items_are_the_same_between_two_generations(self):
# This might open a kivy window temporarily, but it's the only way to test this...
if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
try:
# This test only run if UT is present, so no risk of running in the CI.
from worlds.tracker.TrackerClient import TrackerGameContext # noqa
except ImportError:
raise unittest.SkipTest("UT not loaded, skipping test")
slot_data = self.world.fill_slot_data()
ut_data = self.world.interpret_slot_data(slot_data)
fake_context = Mock()
fake_context.re_gen_passthrough = {STARDEW_VALLEY: ut_data}
args = create_args({0: self.options})
args.outputpath = None
args.outputname = None
args.multi = 1
args.race = None
args.plando_options = self.multiworld.plando_options
args.plando_items = self.multiworld.plando_items
args.plando_texts = self.multiworld.plando_texts
args.plando_connections = self.multiworld.plando_connections
args.game = self.multiworld.game
args.name = self.multiworld.player_name
args.sprite = {}
args.sprite_pool = {}
args.skip_output = True
generated_multi_world = TrackerGameContext.TMain(fake_context, args, self.multiworld.seed)
generated_slot_data = generated_multi_world.worlds[1].fill_slot_data()
# Just checking slot data should prove that UT generates the same result as AP generation.
self.maxDiff = None
self.assertEqual(slot_data, generated_slot_data)

View File

@@ -150,7 +150,7 @@ def has_ultra_glide_fins(state: "CollectionState", player: int) -> bool:
def get_max_swim_depth(state: "CollectionState", player: int) -> int:
swim_rule: SwimRule = state.multiworld.swim_rule[player]
swim_rule: SwimRule = state.multiworld.worlds[player].options.swim_rule
depth: int = swim_rule.base_depth
if swim_rule.consider_items:
if has_seaglide(state, player):
@@ -296,7 +296,7 @@ def set_rules(subnautica_world: "SubnauticaWorld"):
set_location_rule(multiworld, player, loc)
if subnautica_world.creatures_to_scan:
option = multiworld.creature_scan_logic[player]
option = multiworld.worlds[player].options.creature_scan_logic
for creature_name in subnautica_world.creatures_to_scan:
location = set_creature_rule(multiworld, player, creature_name)

View File

@@ -1,7 +1,8 @@
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union
from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld
from .items import item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names,
combat_items)
from .locations import location_table, location_name_groups, location_name_to_id, hexagon_locations
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules
@@ -9,7 +10,8 @@ from .regions import tunic_regions
from .er_scripts import create_er_regions, verify_plando_directions
from .er_data import portal_mapping
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, EntranceLayout)
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage, EntranceLayout)
from .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection, OptionError
from decimal import Decimal, ROUND_HALF_UP
@@ -84,6 +86,12 @@ class TunicWorld(World):
shop_num: int = 1 # need to make it so that you can walk out of shops, but also that they aren't all connected
def generate_early(self) -> None:
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
if self.options.plando_connections:
for index, cxn in enumerate(self.options.plando_connections):
# making shops second to simplify other things later
@@ -128,6 +136,15 @@ class TunicWorld(World):
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
for tunic in tunic_worlds:
# setting up state combat logic stuff, see has_combat_reqs for its use
# and this is magic so pycharm doesn't like it, unfortunately
if tunic.options.combat_logic:
multiworld.state.tunic_need_to_reset_combat_from_collect[tunic.player] = False
multiworld.state.tunic_need_to_reset_combat_from_remove[tunic.player] = False
multiworld.state.tunic_area_combat_state[tunic.player] = {}
for area_name in area_data.keys():
multiworld.state.tunic_area_combat_state[tunic.player][area_name] = CombatState.unchecked
# if it's one of the options, then it isn't a custom seed group
if tunic.options.entrance_rando.value in EntranceRando.options.values():
continue
@@ -363,6 +380,19 @@ class TunicWorld(World):
def get_filler_item_name(self) -> str:
return self.random.choice(filler_items)
# cache whether you can get through combat logic areas
def collect(self, state: CollectionState, item: Item) -> bool:
change = super().collect(state, item)
if change and self.options.combat_logic and item.name in combat_items:
state.tunic_need_to_reset_combat_from_collect[self.player] = True
return change
def remove(self, state: CollectionState, item: Item) -> bool:
change = super().remove(state, item)
if change and self.options.combat_logic and item.name in combat_items:
state.tunic_need_to_reset_combat_from_remove[self.player] = True
return change
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None:
if self.options.entrance_rando:
hint_data.update({self.player: {}})

View File

@@ -1,6 +1,8 @@
from typing import Dict, List, NamedTuple, Tuple, Optional
from enum import IntEnum
from BaseClasses import CollectionState
from .rules import has_sword, has_melee
from worlds.AutoWorld import LogicMixin
# the vanilla stats you are expected to have to get through an area, based on where they are in vanilla
@@ -45,7 +47,68 @@ area_data: Dict[str, AreaStats] = {
}
def has_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: Optional[AreaStats] = None) -> bool:
# these are used for caching which areas can currently be reached in state
boss_areas: List[str] = [name for name, data in area_data.items() if data.is_boss and name != "Gauntlet"]
non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss]
class CombatState(IntEnum):
unchecked = 0
failed = 1
succeeded = 2
def has_combat_reqs(area_name: str, state: CollectionState, player: int) -> bool:
# we're caching whether you've met the combat reqs before if the state didn't change first
# if the combat state is stale, mark each area's combat state as stale
if state.tunic_need_to_reset_combat_from_collect[player]:
state.tunic_need_to_reset_combat_from_collect[player] = 0
for name in area_data.keys():
if state.tunic_area_combat_state[player][name] == CombatState.failed:
state.tunic_area_combat_state[player][name] = CombatState.unchecked
if state.tunic_need_to_reset_combat_from_remove[player]:
state.tunic_need_to_reset_combat_from_remove[player] = 0
for name in area_data.keys():
if state.tunic_area_combat_state[player][name] == CombatState.succeeded:
state.tunic_area_combat_state[player][name] = CombatState.unchecked
if state.tunic_area_combat_state[player][area_name] > CombatState.unchecked:
return state.tunic_area_combat_state[player][area_name] == CombatState.succeeded
met_combat_reqs = check_combat_reqs(area_name, state, player)
# we want to skip the "none area" since we don't record its results
if area_name not in area_data.keys():
return met_combat_reqs
# loop through the lists and set the easier/harder area states accordingly
if area_name in boss_areas:
area_list = boss_areas
elif area_name in non_boss_areas:
area_list = non_boss_areas
else:
area_list = [area_name]
if met_combat_reqs:
# set the state as true for each area until you get to the area we're looking at
for name in area_list:
state.tunic_area_combat_state[player][name] = CombatState.succeeded
if name == area_name:
break
else:
# set the state as false for the area we're looking at and each area after that
reached_name = False
for name in area_list:
if name == area_name:
reached_name = True
if reached_name:
state.tunic_area_combat_state[player][name] = CombatState.failed
return met_combat_reqs
def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: Optional[AreaStats] = None) -> bool:
data = alt_data or area_data[area_name]
extra_att_needed = 0
extra_def_needed = 0
@@ -108,7 +171,7 @@ def has_combat_reqs(area_name: str, state: CollectionState, player: int, alt_dat
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level,
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count,
equip_list)
if has_combat_reqs("none", state, player, more_modified_stats):
if check_combat_reqs("none", state, player, more_modified_stats):
return True
# and we need to check if you would have the required stats if you didn't have magic
@@ -116,8 +179,9 @@ def has_combat_reqs(area_name: str, state: CollectionState, player: int, alt_dat
more_modified_stats = AreaStats(data.att_level + 2, data.def_level + 2, data.potion_level,
data.hp_level, data.sp_level, data.mp_level - 16, data.potion_count,
equip_list)
if has_combat_reqs("none", state, player, more_modified_stats):
if check_combat_reqs("none", state, player, more_modified_stats):
return True
return False
elif stick_bool and "Stick" in data.equipment and "Magic" in data.equipment:
# we need to check if you would have the required stats if you didn't have the stick
@@ -125,8 +189,9 @@ def has_combat_reqs(area_name: str, state: CollectionState, player: int, alt_dat
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level,
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count,
equip_list)
if has_combat_reqs("none", state, player, more_modified_stats):
if check_combat_reqs("none", state, player, more_modified_stats):
return True
return False
else:
return False
return True
@@ -163,12 +228,11 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) ->
free_def = player_def - def_offerings
free_sp = player_sp - sp_offerings
paid_stats = data.def_level + data.sp_level - free_def - free_sp
def_to_buy = 0
sp_to_buy = 0
if paid_stats <= 0:
# if you don't have to pay for any stats, you don't need money for these upgrades
pass
def_to_buy = 0
elif paid_stats <= def_offerings:
# get the amount needed to buy these def offerings
def_to_buy = paid_stats
@@ -265,31 +329,31 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) ->
# returns a tuple of your max attack level, the number of attack offerings
def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]:
att_offering_count = state.count("ATT Offering", player)
att_offerings = state.count("ATT Offering", player)
att_upgrades = state.count("Hero Relic - ATT", player)
sword_level = state.count("Sword Upgrade", player)
if sword_level >= 3:
att_upgrades += min(2, sword_level - 2)
# attack falls off, can just cap it at 8 for simplicity
return min(8, 1 + att_offering_count + att_upgrades), att_offering_count
return min(8, 1 + att_offerings + att_upgrades), att_offerings
# returns a tuple of your max defense level, the number of defense offerings
def get_def_level(state: CollectionState, player: int) -> Tuple[int, int]:
def_offering_count = state.count("DEF Offering", player)
def_offerings = state.count("DEF Offering", player)
# defense falls off, can just cap it at 8 for simplicity
return (min(8, 1 + def_offering_count
return (min(8, 1 + def_offerings
+ state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player)),
def_offering_count)
def_offerings)
# returns a tuple of your max potion level, the number of potion offerings
def get_potion_level(state: CollectionState, player: int) -> Tuple[int, int]:
potion_offering_count = min(2, state.count("Potion Offering", player))
potion_offerings = min(2, state.count("Potion Offering", player))
# your third potion upgrade (from offerings) costs 1,000 money, reasonable to assume you won't do that
return (1 + potion_offering_count
return (1 + potion_offerings
+ state.count_from_list({"Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"}, player),
potion_offering_count)
potion_offerings)
# returns a tuple of your max hp level, the number of hp offerings
@@ -341,3 +405,13 @@ def get_money_count(state: CollectionState, player: int) -> int:
money += money_per_break
money_per_break = min(512, money_per_break * 2)
return money
class TunicState(LogicMixin):
# the per-player need to reset the combat state when collecting a combat item
tunic_need_to_reset_combat_from_collect: Dict[int, bool] = {}
# the per-player need to reset the combat state when removing a combat item
tunic_need_to_reset_combat_from_remove: Dict[int, bool] = {}
# the per-player, per-area state of combat checking -- unchecked, failed, or succeeded
tunic_area_combat_state: Dict[int, Dict[str, int]] = {}

View File

@@ -35,497 +35,497 @@ class Portal(NamedTuple):
portal_mapping: List[Portal] = [
Portal(name="Stick House Entrance", region="Overworld",
destination="Sword Cave", tag="_", direction=Direction.north),
destination="Sword Cave", tag="_"),
Portal(name="Windmill Entrance", region="Overworld",
destination="Windmill", tag="_", direction=Direction.north),
destination="Windmill", tag="_"),
Portal(name="Well Ladder Entrance", region="Overworld Well Ladder",
destination="Sewer", tag="_entrance", direction=Direction.ladder_down),
destination="Sewer", tag="_entrance"),
Portal(name="Entrance to Well from Well Rail", region="Overworld Well to Furnace Rail",
destination="Sewer", tag="_west_aqueduct", direction=Direction.north),
destination="Sewer", tag="_west_aqueduct"),
Portal(name="Old House Door Entrance", region="Overworld Old House Door",
destination="Overworld Interiors", tag="_house", direction=Direction.east),
destination="Overworld Interiors", tag="_house"),
Portal(name="Old House Waterfall Entrance", region="Overworld",
destination="Overworld Interiors", tag="_under_checkpoint", direction=Direction.east),
destination="Overworld Interiors", tag="_under_checkpoint"),
Portal(name="Entrance to Furnace from Well Rail", region="Overworld Well to Furnace Rail",
destination="Furnace", tag="_gyro_upper_north", direction=Direction.south),
destination="Furnace", tag="_gyro_upper_north"),
Portal(name="Entrance to Furnace under Windmill", region="Overworld",
destination="Furnace", tag="_gyro_upper_east", direction=Direction.west),
destination="Furnace", tag="_gyro_upper_east"),
Portal(name="Entrance to Furnace near West Garden", region="Overworld to West Garden from Furnace",
destination="Furnace", tag="_gyro_west", direction=Direction.east),
destination="Furnace", tag="_gyro_west"),
Portal(name="Entrance to Furnace from Beach", region="Overworld Tunnel Turret",
destination="Furnace", tag="_gyro_lower", direction=Direction.north),
destination="Furnace", tag="_gyro_lower"),
Portal(name="Caustic Light Cave Entrance", region="Overworld Swamp Lower Entry",
destination="Overworld Cave", tag="_", direction=Direction.north),
destination="Overworld Cave", tag="_"),
Portal(name="Swamp Upper Entrance", region="Overworld Swamp Upper Entry",
destination="Swamp Redux 2", tag="_wall", direction=Direction.south),
destination="Swamp Redux 2", tag="_wall"),
Portal(name="Swamp Lower Entrance", region="Overworld Swamp Lower Entry",
destination="Swamp Redux 2", tag="_conduit", direction=Direction.south),
destination="Swamp Redux 2", tag="_conduit"),
Portal(name="Ruined Passage Not-Door Entrance", region="After Ruined Passage",
destination="Ruins Passage", tag="_east", direction=Direction.north),
destination="Ruins Passage", tag="_east"),
Portal(name="Ruined Passage Door Entrance", region="Overworld Ruined Passage Door",
destination="Ruins Passage", tag="_west", direction=Direction.east),
destination="Ruins Passage", tag="_west"),
Portal(name="Atoll Upper Entrance", region="Overworld to Atoll Upper",
destination="Atoll Redux", tag="_upper", direction=Direction.south),
destination="Atoll Redux", tag="_upper"),
Portal(name="Atoll Lower Entrance", region="Overworld Beach",
destination="Atoll Redux", tag="_lower", direction=Direction.south),
destination="Atoll Redux", tag="_lower"),
Portal(name="Special Shop Entrance", region="Overworld Special Shop Entry",
destination="ShopSpecial", tag="_", direction=Direction.east),
destination="ShopSpecial", tag="_"),
Portal(name="Maze Cave Entrance", region="Overworld Beach",
destination="Maze Room", tag="_", direction=Direction.north),
destination="Maze Room", tag="_"),
Portal(name="West Garden Entrance near Belltower", region="Overworld to West Garden Upper",
destination="Archipelagos Redux", tag="_upper", direction=Direction.west),
destination="Archipelagos Redux", tag="_upper"),
Portal(name="West Garden Entrance from Furnace", region="Overworld to West Garden from Furnace",
destination="Archipelagos Redux", tag="_lower", direction=Direction.west),
destination="Archipelagos Redux", tag="_lower"),
Portal(name="West Garden Laurels Entrance", region="Overworld West Garden Laurels Entry",
destination="Archipelagos Redux", tag="_lowest", direction=Direction.west),
destination="Archipelagos Redux", tag="_lowest"),
Portal(name="Temple Door Entrance", region="Overworld Temple Door",
destination="Temple", tag="_main", direction=Direction.north),
destination="Temple", tag="_main"),
Portal(name="Temple Rafters Entrance", region="Overworld after Temple Rafters",
destination="Temple", tag="_rafters", direction=Direction.east),
destination="Temple", tag="_rafters"),
Portal(name="Ruined Shop Entrance", region="Overworld",
destination="Ruined Shop", tag="_", direction=Direction.east),
destination="Ruined Shop", tag="_"),
Portal(name="Patrol Cave Entrance", region="Overworld at Patrol Cave",
destination="PatrolCave", tag="_", direction=Direction.north),
destination="PatrolCave", tag="_"),
Portal(name="Hourglass Cave Entrance", region="Overworld Beach",
destination="Town Basement", tag="_beach", direction=Direction.north),
destination="Town Basement", tag="_beach"),
Portal(name="Changing Room Entrance", region="Overworld",
destination="Changing Room", tag="_", direction=Direction.south),
destination="Changing Room", tag="_"),
Portal(name="Cube Cave Entrance", region="Overworld",
destination="CubeRoom", tag="_", direction=Direction.north),
destination="CubeRoom", tag="_"),
Portal(name="Stairs from Overworld to Mountain", region="Upper Overworld",
destination="Mountain", tag="_", direction=Direction.north),
destination="Mountain", tag="_"),
Portal(name="Overworld to Fortress", region="East Overworld",
destination="Fortress Courtyard", tag="_", direction=Direction.east),
destination="Fortress Courtyard", tag="_"),
Portal(name="Fountain HC Door Entrance", region="Overworld Fountain Cross Door",
destination="Town_FiligreeRoom", tag="_", direction=Direction.north),
destination="Town_FiligreeRoom", tag="_"),
Portal(name="Southeast HC Door Entrance", region="Overworld Southeast Cross Door",
destination="EastFiligreeCache", tag="_", direction=Direction.north),
destination="EastFiligreeCache", tag="_"),
Portal(name="Overworld to Quarry Connector", region="Overworld Quarry Entry",
destination="Darkwoods Tunnel", tag="_", direction=Direction.north),
destination="Darkwoods Tunnel", tag="_"),
Portal(name="Dark Tomb Main Entrance", region="Overworld",
destination="Crypt Redux", tag="_", direction=Direction.north),
destination="Crypt Redux", tag="_"),
Portal(name="Overworld to Forest Belltower", region="East Overworld",
destination="Forest Belltower", tag="_", direction=Direction.east),
destination="Forest Belltower", tag="_"),
Portal(name="Town to Far Shore", region="Overworld Town Portal",
destination="Transit", tag="_teleporter_town", direction=Direction.floor),
destination="Transit", tag="_teleporter_town"),
Portal(name="Spawn to Far Shore", region="Overworld Spawn Portal",
destination="Transit", tag="_teleporter_starting island", direction=Direction.floor),
destination="Transit", tag="_teleporter_starting island"),
Portal(name="Secret Gathering Place Entrance", region="Overworld",
destination="Waterfall", tag="_", direction=Direction.north),
destination="Waterfall", tag="_"),
Portal(name="Secret Gathering Place Exit", region="Secret Gathering Place",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Windmill Exit", region="Windmill",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Windmill Shop", region="Windmill",
destination="Shop", tag="_", direction=Direction.north),
destination="Shop", tag="_"),
Portal(name="Old House Door Exit", region="Old House Front",
destination="Overworld Redux", tag="_house", direction=Direction.west),
destination="Overworld Redux", tag="_house"),
Portal(name="Old House to Glyph Tower", region="Old House Front",
destination="g_elements", tag="_", direction=Direction.south), # portal drops you on north side
destination="g_elements", tag="_"),
Portal(name="Old House Waterfall Exit", region="Old House Back",
destination="Overworld Redux", tag="_under_checkpoint", direction=Direction.west),
destination="Overworld Redux", tag="_under_checkpoint"),
Portal(name="Glyph Tower Exit", region="Relic Tower",
destination="Overworld Interiors", tag="_", direction=Direction.north),
destination="Overworld Interiors", tag="_"),
Portal(name="Changing Room Exit", region="Changing Room",
destination="Overworld Redux", tag="_", direction=Direction.north),
destination="Overworld Redux", tag="_"),
Portal(name="Fountain HC Room Exit", region="Fountain Cross Room",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Cube Cave Exit", region="Cube Cave",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Guard Patrol Cave Exit", region="Patrol Cave",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Ruined Shop Exit", region="Ruined Shop",
destination="Overworld Redux", tag="_", direction=Direction.west),
destination="Overworld Redux", tag="_"),
Portal(name="Furnace Exit towards Well", region="Furnace Fuse",
destination="Overworld Redux", tag="_gyro_upper_north", direction=Direction.north),
destination="Overworld Redux", tag="_gyro_upper_north"),
Portal(name="Furnace Exit to Dark Tomb", region="Furnace Walking Path",
destination="Crypt Redux", tag="_", direction=Direction.east),
destination="Crypt Redux", tag="_"),
Portal(name="Furnace Exit towards West Garden", region="Furnace Walking Path",
destination="Overworld Redux", tag="_gyro_west", direction=Direction.west),
destination="Overworld Redux", tag="_gyro_west"),
Portal(name="Furnace Exit to Beach", region="Furnace Ladder Area",
destination="Overworld Redux", tag="_gyro_lower", direction=Direction.south),
destination="Overworld Redux", tag="_gyro_lower"),
Portal(name="Furnace Exit under Windmill", region="Furnace Ladder Area",
destination="Overworld Redux", tag="_gyro_upper_east", direction=Direction.east),
destination="Overworld Redux", tag="_gyro_upper_east"),
Portal(name="Stick House Exit", region="Stick House",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Ruined Passage Not-Door Exit", region="Ruined Passage",
destination="Overworld Redux", tag="_east", direction=Direction.south),
destination="Overworld Redux", tag="_east"),
Portal(name="Ruined Passage Door Exit", region="Ruined Passage",
destination="Overworld Redux", tag="_west", direction=Direction.west),
destination="Overworld Redux", tag="_west"),
Portal(name="Southeast HC Room Exit", region="Southeast Cross Room",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Caustic Light Cave Exit", region="Caustic Light Cave",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Maze Cave Exit", region="Maze Cave",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Hourglass Cave Exit", region="Hourglass Cave",
destination="Overworld Redux", tag="_beach", direction=Direction.south),
destination="Overworld Redux", tag="_beach"),
Portal(name="Special Shop Exit", region="Special Shop",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Temple Rafters Exit", region="Sealed Temple Rafters",
destination="Overworld Redux", tag="_rafters", direction=Direction.west),
destination="Overworld Redux", tag="_rafters"),
Portal(name="Temple Door Exit", region="Sealed Temple",
destination="Overworld Redux", tag="_main", direction=Direction.south),
destination="Overworld Redux", tag="_main"),
Portal(name="Well Ladder Exit", region="Beneath the Well Ladder Exit",
destination="Overworld Redux", tag="_entrance", direction=Direction.ladder_up),
destination="Overworld Redux", tag="_entrance"),
Portal(name="Well to Well Boss", region="Beneath the Well Back",
destination="Sewer_Boss", tag="_", direction=Direction.east),
destination="Sewer_Boss", tag="_"),
Portal(name="Well Exit towards Furnace", region="Beneath the Well Back",
destination="Overworld Redux", tag="_west_aqueduct", direction=Direction.south),
destination="Overworld Redux", tag="_west_aqueduct"),
Portal(name="Well Boss to Well", region="Well Boss",
destination="Sewer", tag="_", direction=Direction.west),
destination="Sewer", tag="_"),
Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint",
destination="Crypt Redux", tag="_", direction=Direction.ladder_up),
destination="Crypt Redux", tag="_"),
Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit",
destination="Furnace", tag="_", direction=Direction.west),
destination="Furnace", tag="_"),
Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point",
destination="Sewer_Boss", tag="_", direction=Direction.ladder_down),
destination="Sewer_Boss", tag="_"),
Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry",
destination="Overworld Redux", tag="_lower", direction=Direction.east),
destination="Overworld Redux", tag="_lower"),
Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House",
destination="archipelagos_house", tag="_", direction=Direction.east),
destination="archipelagos_house", tag="_"),
Portal(name="West Garden Exit after Boss", region="West Garden after Boss",
destination="Overworld Redux", tag="_upper", direction=Direction.east),
destination="Overworld Redux", tag="_upper"),
Portal(name="West Garden Shop", region="West Garden before Terry",
destination="Shop", tag="_", direction=Direction.east),
destination="Shop", tag="_"),
Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region",
destination="Overworld Redux", tag="_lowest", direction=Direction.east),
destination="Overworld Redux", tag="_lowest"),
Portal(name="West Garden Hero's Grave", region="West Garden Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="West Garden to Far Shore", region="West Garden Portal",
destination="Transit", tag="_teleporter_archipelagos_teleporter", direction=Direction.floor),
destination="Transit", tag="_teleporter_archipelagos_teleporter"),
Portal(name="Magic Dagger House Exit", region="Magic Dagger House",
destination="Archipelagos Redux", tag="_", direction=Direction.west),
destination="Archipelagos Redux", tag="_"),
Portal(name="Atoll Upper Exit", region="Ruined Atoll",
destination="Overworld Redux", tag="_upper", direction=Direction.north),
destination="Overworld Redux", tag="_upper"),
Portal(name="Atoll Lower Exit", region="Ruined Atoll Lower Entry Area",
destination="Overworld Redux", tag="_lower", direction=Direction.north),
destination="Overworld Redux", tag="_lower"),
Portal(name="Atoll Shop", region="Ruined Atoll",
destination="Shop", tag="_", direction=Direction.north),
destination="Shop", tag="_"),
Portal(name="Atoll to Far Shore", region="Ruined Atoll Portal",
destination="Transit", tag="_teleporter_atoll", direction=Direction.floor),
destination="Transit", tag="_teleporter_atoll"),
Portal(name="Atoll Statue Teleporter", region="Ruined Atoll Statue",
destination="Library Exterior", tag="_", direction=Direction.floor),
destination="Library Exterior", tag="_"),
Portal(name="Frog Stairs Eye Entrance", region="Ruined Atoll Frog Eye",
destination="Frog Stairs", tag="_eye", direction=Direction.south), # camera rotates, it's fine
destination="Frog Stairs", tag="_eye"),
Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth",
destination="Frog Stairs", tag="_mouth", direction=Direction.east),
destination="Frog Stairs", tag="_mouth"),
Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit",
destination="Atoll Redux", tag="_eye", direction=Direction.north),
destination="Atoll Redux", tag="_eye"),
Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper",
destination="Atoll Redux", tag="_mouth", direction=Direction.west),
destination="Atoll Redux", tag="_mouth"),
Portal(name="Frog Stairs to Frog's Domain's Entrance", region="Frog Stairs to Frog's Domain",
destination="frog cave main", tag="_Entrance", direction=Direction.ladder_down),
destination="frog cave main", tag="_Entrance"),
Portal(name="Frog Stairs to Frog's Domain's Exit", region="Frog Stairs Lower",
destination="frog cave main", tag="_Exit", direction=Direction.east),
destination="frog cave main", tag="_Exit"),
Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry",
destination="Frog Stairs", tag="_Entrance", direction=Direction.ladder_up),
destination="Frog Stairs", tag="_Entrance"),
Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back",
destination="Frog Stairs", tag="_Exit", direction=Direction.west),
destination="Frog Stairs", tag="_Exit"),
Portal(name="Library Exterior Tree", region="Library Exterior Tree Region",
destination="Atoll Redux", tag="_", direction=Direction.floor),
destination="Atoll Redux", tag="_"),
Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region",
destination="Library Hall", tag="_", direction=Direction.west), # camera rotates
destination="Library Hall", tag="_"),
Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf",
destination="Library Exterior", tag="_", direction=Direction.east),
destination="Library Exterior", tag="_"),
Portal(name="Library Hero's Grave", region="Library Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda",
destination="Library Rotunda", tag="_", direction=Direction.ladder_up),
destination="Library Rotunda", tag="_"),
Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall",
destination="Library Hall", tag="_", direction=Direction.ladder_down),
destination="Library Hall", tag="_"),
Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab",
destination="Library Lab", tag="_", direction=Direction.ladder_up),
destination="Library Lab", tag="_"),
Portal(name="Library Lab to Rotunda", region="Library Lab Lower",
destination="Library Rotunda", tag="_", direction=Direction.ladder_down),
destination="Library Rotunda", tag="_"),
Portal(name="Library to Far Shore", region="Library Portal",
destination="Transit", tag="_teleporter_library teleporter", direction=Direction.floor),
destination="Transit", tag="_teleporter_library teleporter"),
Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian",
destination="Library Arena", tag="_", direction=Direction.ladder_up),
destination="Library Arena", tag="_"),
Portal(name="Librarian Arena Exit", region="Library Arena",
destination="Library Lab", tag="_", direction=Direction.ladder_down),
destination="Library Lab", tag="_"),
Portal(name="Forest to Belltower", region="East Forest",
destination="Forest Belltower", tag="_", direction=Direction.north),
destination="Forest Belltower", tag="_"),
Portal(name="Forest Guard House 1 Lower Entrance", region="East Forest",
destination="East Forest Redux Laddercave", tag="_lower", direction=Direction.north),
destination="East Forest Redux Laddercave", tag="_lower"),
Portal(name="Forest Guard House 1 Gate Entrance", region="East Forest",
destination="East Forest Redux Laddercave", tag="_gate", direction=Direction.north),
destination="East Forest Redux Laddercave", tag="_gate"),
Portal(name="Forest Dance Fox Outside Doorway", region="East Forest Dance Fox Spot",
destination="East Forest Redux Laddercave", tag="_upper", direction=Direction.east),
destination="East Forest Redux Laddercave", tag="_upper"),
Portal(name="Forest to Far Shore", region="East Forest Portal",
destination="Transit", tag="_teleporter_forest teleporter", direction=Direction.floor),
destination="Transit", tag="_teleporter_forest teleporter"),
Portal(name="Forest Guard House 2 Lower Entrance", region="Lower Forest",
destination="East Forest Redux Interior", tag="_lower", direction=Direction.north),
destination="East Forest Redux Interior", tag="_lower"),
Portal(name="Forest Guard House 2 Upper Entrance", region="East Forest",
destination="East Forest Redux Interior", tag="_upper", direction=Direction.east),
destination="East Forest Redux Interior", tag="_upper"),
Portal(name="Forest Grave Path Lower Entrance", region="East Forest",
destination="Sword Access", tag="_lower", direction=Direction.east),
destination="Sword Access", tag="_lower"),
Portal(name="Forest Grave Path Upper Entrance", region="East Forest",
destination="Sword Access", tag="_upper", direction=Direction.east),
destination="Sword Access", tag="_upper"),
Portal(name="Guard House 1 Dance Fox Exit", region="Guard House 1 West",
destination="East Forest Redux", tag="_upper", direction=Direction.west),
destination="East Forest Redux", tag="_upper"),
Portal(name="Guard House 1 Lower Exit", region="Guard House 1 West",
destination="East Forest Redux", tag="_lower", direction=Direction.south),
destination="East Forest Redux", tag="_lower"),
Portal(name="Guard House 1 Upper Forest Exit", region="Guard House 1 East",
destination="East Forest Redux", tag="_gate", direction=Direction.south),
destination="East Forest Redux", tag="_gate"),
Portal(name="Guard House 1 to Guard Captain Room", region="Guard House 1 East",
destination="Forest Boss Room", tag="_", direction=Direction.north),
destination="Forest Boss Room", tag="_"),
Portal(name="Forest Grave Path Upper Exit", region="Forest Grave Path Upper",
destination="East Forest Redux", tag="_upper", direction=Direction.west),
destination="East Forest Redux", tag="_upper"),
Portal(name="Forest Grave Path Lower Exit", region="Forest Grave Path Main",
destination="East Forest Redux", tag="_lower", direction=Direction.west),
destination="East Forest Redux", tag="_lower"),
Portal(name="East Forest Hero's Grave", region="Forest Hero's Grave",
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Guard House 2 Lower Exit", region="Guard House 2 Lower",
destination="East Forest Redux", tag="_lower", direction=Direction.south),
destination="East Forest Redux", tag="_lower"),
Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper",
destination="East Forest Redux", tag="_upper", direction=Direction.west),
destination="East Forest Redux", tag="_upper"),
Portal(name="Guard Captain Room Non-Gate Exit", region="Forest Boss Room",
destination="East Forest Redux Laddercave", tag="_", direction=Direction.south),
destination="East Forest Redux Laddercave", tag="_"),
Portal(name="Guard Captain Room Gate Exit", region="Forest Boss Room",
destination="Forest Belltower", tag="_", direction=Direction.north),
destination="Forest Belltower", tag="_"),
Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main",
destination="Fortress Courtyard", tag="_", direction=Direction.north),
destination="Fortress Courtyard", tag="_"),
Portal(name="Forest Belltower to Forest", region="Forest Belltower Lower",
destination="East Forest Redux", tag="_", direction=Direction.south),
destination="East Forest Redux", tag="_"),
Portal(name="Forest Belltower to Overworld", region="Forest Belltower Main",
destination="Overworld Redux", tag="_", direction=Direction.west),
destination="Overworld Redux", tag="_"),
Portal(name="Forest Belltower to Guard Captain Room", region="Forest Belltower Upper",
destination="Forest Boss Room", tag="_", direction=Direction.south),
destination="Forest Boss Room", tag="_"),
Portal(name="Fortress Courtyard to Fortress Grave Path Lower", region="Fortress Courtyard",
destination="Fortress Reliquary", tag="_Lower", direction=Direction.east),
destination="Fortress Reliquary", tag="_Lower"),
Portal(name="Fortress Courtyard to Fortress Grave Path Upper", region="Fortress Courtyard Upper",
destination="Fortress Reliquary", tag="_Upper", direction=Direction.east),
destination="Fortress Reliquary", tag="_Upper"),
Portal(name="Fortress Courtyard to Fortress Interior", region="Fortress Courtyard",
destination="Fortress Main", tag="_Big Door", direction=Direction.north),
destination="Fortress Main", tag="_Big Door"),
Portal(name="Fortress Courtyard to East Fortress", region="Fortress Courtyard Upper",
destination="Fortress East", tag="_", direction=Direction.north),
destination="Fortress East", tag="_"),
Portal(name="Fortress Courtyard to Beneath the Vault", region="Beneath the Vault Entry",
destination="Fortress Basement", tag="_", direction=Direction.ladder_down),
destination="Fortress Basement", tag="_"),
Portal(name="Fortress Courtyard to Forest Belltower", region="Fortress Exterior from East Forest",
destination="Forest Belltower", tag="_", direction=Direction.south),
destination="Forest Belltower", tag="_"),
Portal(name="Fortress Courtyard to Overworld", region="Fortress Exterior from Overworld",
destination="Overworld Redux", tag="_", direction=Direction.west),
destination="Overworld Redux", tag="_"),
Portal(name="Fortress Courtyard Shop", region="Fortress Exterior near cave",
destination="Shop", tag="_", direction=Direction.north),
destination="Shop", tag="_"),
Portal(name="Beneath the Vault to Fortress Interior", region="Beneath the Vault Back",
destination="Fortress Main", tag="_", direction=Direction.east),
destination="Fortress Main", tag="_"),
Portal(name="Beneath the Vault to Fortress Courtyard", region="Beneath the Vault Ladder Exit",
destination="Fortress Courtyard", tag="_", direction=Direction.ladder_up),
destination="Fortress Courtyard", tag="_"),
Portal(name="Fortress Interior Main Exit", region="Eastern Vault Fortress",
destination="Fortress Courtyard", tag="_Big Door", direction=Direction.south),
destination="Fortress Courtyard", tag="_Big Door"),
Portal(name="Fortress Interior to Beneath the Earth", region="Eastern Vault Fortress",
destination="Fortress Basement", tag="_", direction=Direction.west),
destination="Fortress Basement", tag="_"),
Portal(name="Fortress Interior to Siege Engine Arena", region="Eastern Vault Fortress Gold Door",
destination="Fortress Arena", tag="_", direction=Direction.north),
destination="Fortress Arena", tag="_"),
Portal(name="Fortress Interior Shop", region="Eastern Vault Fortress",
destination="Shop", tag="_", direction=Direction.north),
destination="Shop", tag="_"),
Portal(name="Fortress Interior to East Fortress Upper", region="Eastern Vault Fortress",
destination="Fortress East", tag="_upper", direction=Direction.east),
destination="Fortress East", tag="_upper"),
Portal(name="Fortress Interior to East Fortress Lower", region="Eastern Vault Fortress",
destination="Fortress East", tag="_lower", direction=Direction.east),
destination="Fortress East", tag="_lower"),
Portal(name="East Fortress to Interior Lower", region="Fortress East Shortcut Lower",
destination="Fortress Main", tag="_lower", direction=Direction.west),
destination="Fortress Main", tag="_lower"),
Portal(name="East Fortress to Courtyard", region="Fortress East Shortcut Upper",
destination="Fortress Courtyard", tag="_", direction=Direction.south),
destination="Fortress Courtyard", tag="_"),
Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper",
destination="Fortress Main", tag="_upper", direction=Direction.west),
destination="Fortress Main", tag="_upper"),
Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry",
destination="Fortress Courtyard", tag="_Lower", direction=Direction.west),
destination="Fortress Courtyard", tag="_Lower"),
Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Fortress Grave Path Upper Exit", region="Fortress Grave Path Upper",
destination="Fortress Courtyard", tag="_Upper", direction=Direction.west),
destination="Fortress Courtyard", tag="_Upper"),
Portal(name="Fortress Grave Path Dusty Entrance", region="Fortress Grave Path Dusty Entrance Region",
destination="Dusty", tag="_", direction=Direction.north),
destination="Dusty", tag="_"),
Portal(name="Dusty Exit", region="Fortress Leaf Piles",
destination="Fortress Reliquary", tag="_", direction=Direction.south),
destination="Fortress Reliquary", tag="_"),
Portal(name="Siege Engine Arena to Fortress", region="Fortress Arena",
destination="Fortress Main", tag="_", direction=Direction.south),
destination="Fortress Main", tag="_"),
Portal(name="Fortress to Far Shore", region="Fortress Arena Portal",
destination="Transit", tag="_teleporter_spidertank", direction=Direction.floor),
destination="Transit", tag="_teleporter_spidertank"),
Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs",
destination="Mountaintop", tag="_", direction=Direction.north),
destination="Mountaintop", tag="_"),
Portal(name="Mountain to Quarry", region="Lower Mountain",
destination="Quarry Redux", tag="_", direction=Direction.south), # connecting is north
destination="Quarry Redux", tag="_"),
Portal(name="Mountain to Overworld", region="Lower Mountain",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Top of the Mountain Exit", region="Top of the Mountain",
destination="Mountain", tag="_", direction=Direction.south),
destination="Mountain", tag="_"),
Portal(name="Quarry Connector to Overworld", region="Quarry Connector",
destination="Overworld Redux", tag="_", direction=Direction.south),
destination="Overworld Redux", tag="_"),
Portal(name="Quarry Connector to Quarry", region="Quarry Connector",
destination="Quarry Redux", tag="_", direction=Direction.north), # rotates, it's fine
destination="Quarry Redux", tag="_"),
Portal(name="Quarry to Overworld Exit", region="Quarry Entry",
destination="Darkwoods Tunnel", tag="_", direction=Direction.south), # rotates, it's fine
destination="Darkwoods Tunnel", tag="_"),
Portal(name="Quarry Shop", region="Quarry Entry",
destination="Shop", tag="_", direction=Direction.north),
destination="Shop", tag="_"),
Portal(name="Quarry to Monastery Front", region="Quarry Monastery Entry",
destination="Monastery", tag="_front", direction=Direction.north),
destination="Monastery", tag="_front"),
Portal(name="Quarry to Monastery Back", region="Monastery Rope",
destination="Monastery", tag="_back", direction=Direction.east),
destination="Monastery", tag="_back"),
Portal(name="Quarry to Mountain", region="Quarry Back",
destination="Mountain", tag="_", direction=Direction.north),
destination="Mountain", tag="_"),
Portal(name="Quarry to Ziggurat", region="Lower Quarry Zig Door",
destination="ziggurat2020_0", tag="_", direction=Direction.north),
destination="ziggurat2020_0", tag="_"),
Portal(name="Quarry to Far Shore", region="Quarry Portal",
destination="Transit", tag="_teleporter_quarry teleporter", direction=Direction.floor),
destination="Transit", tag="_teleporter_quarry teleporter"),
Portal(name="Monastery Rear Exit", region="Monastery Back",
destination="Quarry Redux", tag="_back", direction=Direction.west),
destination="Quarry Redux", tag="_back"),
Portal(name="Monastery Front Exit", region="Monastery Front",
destination="Quarry Redux", tag="_front", direction=Direction.south),
destination="Quarry Redux", tag="_front"),
Portal(name="Monastery Hero's Grave", region="Monastery Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Ziggurat Entry Hallway to Ziggurat Upper", region="Rooted Ziggurat Entry",
destination="ziggurat2020_1", tag="_", direction=Direction.north),
destination="ziggurat2020_1", tag="_"),
Portal(name="Ziggurat Entry Hallway to Quarry", region="Rooted Ziggurat Entry",
destination="Quarry Redux", tag="_", direction=Direction.south),
destination="Quarry Redux", tag="_"),
Portal(name="Ziggurat Upper to Ziggurat Entry Hallway", region="Rooted Ziggurat Upper Entry",
destination="ziggurat2020_0", tag="_", direction=Direction.south),
destination="ziggurat2020_0", tag="_"),
Portal(name="Ziggurat Upper to Ziggurat Tower", region="Rooted Ziggurat Upper Back",
destination="ziggurat2020_2", tag="_", direction=Direction.north), # connecting is south
destination="ziggurat2020_2", tag="_"),
Portal(name="Ziggurat Tower to Ziggurat Upper", region="Rooted Ziggurat Middle Top",
destination="ziggurat2020_1", tag="_", direction=Direction.south),
destination="ziggurat2020_1", tag="_"),
Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom",
destination="ziggurat2020_3", tag="_", direction=Direction.south),
destination="ziggurat2020_3", tag="_"),
Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry",
destination="ziggurat2020_2", tag="_", direction=Direction.north),
destination="ziggurat2020_2", tag="_"),
Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance",
destination="ziggurat2020_FTRoom", tag="_", direction=Direction.north),
destination="ziggurat2020_FTRoom", tag="_"),
# only if fixed shop is on, removed otherwise
Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit",
destination="ziggurat2020_1", tag="_zig2_skip", direction=Direction.none),
destination="ziggurat2020_1", tag="_zig2_skip"),
Portal(name="Ziggurat Portal Room Exit", region="Rooted Ziggurat Portal Room Exit",
destination="ziggurat2020_3", tag="_", direction=Direction.south),
destination="ziggurat2020_3", tag="_"),
Portal(name="Ziggurat to Far Shore", region="Rooted Ziggurat Portal",
destination="Transit", tag="_teleporter_ziggurat teleporter", direction=Direction.floor),
destination="Transit", tag="_teleporter_ziggurat teleporter"),
Portal(name="Swamp Lower Exit", region="Swamp Front",
destination="Overworld Redux", tag="_conduit", direction=Direction.north),
destination="Overworld Redux", tag="_conduit"),
Portal(name="Swamp to Cathedral Main Entrance", region="Swamp to Cathedral Main Entrance Region",
destination="Cathedral Redux", tag="_main", direction=Direction.north),
destination="Cathedral Redux", tag="_main"),
Portal(name="Swamp to Cathedral Secret Legend Room Entrance", region="Swamp to Cathedral Treasure Room",
destination="Cathedral Redux", tag="_secret", direction=Direction.south), # feels a little weird
destination="Cathedral Redux", tag="_secret"),
Portal(name="Swamp to Gauntlet", region="Back of Swamp",
destination="Cathedral Arena", tag="_", direction=Direction.north),
destination="Cathedral Arena", tag="_"),
Portal(name="Swamp Shop", region="Swamp Front",
destination="Shop", tag="_", direction=Direction.north),
destination="Shop", tag="_"),
Portal(name="Swamp Upper Exit", region="Back of Swamp Laurels Area",
destination="Overworld Redux", tag="_wall", direction=Direction.north),
destination="Overworld Redux", tag="_wall"),
Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Cathedral Main Exit", region="Cathedral Entry",
destination="Swamp Redux 2", tag="_main", direction=Direction.south),
destination="Swamp Redux 2", tag="_main"),
Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet",
destination="Cathedral Arena", tag="_", direction=Direction.ladder_down), # elevators are ladders, right?
destination="Cathedral Arena", tag="_"),
Portal(name="Cathedral Secret Legend Room Exit", region="Cathedral Secret Legend Room",
destination="Swamp Redux 2", tag="_secret", direction=Direction.north),
destination="Swamp Redux 2", tag="_secret"),
Portal(name="Gauntlet to Swamp", region="Cathedral Gauntlet Exit",
destination="Swamp Redux 2", tag="_", direction=Direction.south),
destination="Swamp Redux 2", tag="_"),
Portal(name="Gauntlet Elevator", region="Cathedral Gauntlet Checkpoint",
destination="Cathedral Redux", tag="_", direction=Direction.ladder_up),
destination="Cathedral Redux", tag="_"),
Portal(name="Gauntlet Shop", region="Cathedral Gauntlet Checkpoint",
destination="Shop", tag="_", direction=Direction.east),
destination="Shop", tag="_"),
Portal(name="Hero's Grave to Fortress", region="Hero Relic - Fortress",
destination="Fortress Reliquary", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="Fortress Reliquary", tag="_teleporter_relic plinth"),
Portal(name="Hero's Grave to Monastery", region="Hero Relic - Quarry",
destination="Monastery", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="Monastery", tag="_teleporter_relic plinth"),
Portal(name="Hero's Grave to West Garden", region="Hero Relic - West Garden",
destination="Archipelagos Redux", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="Archipelagos Redux", tag="_teleporter_relic plinth"),
Portal(name="Hero's Grave to East Forest", region="Hero Relic - East Forest",
destination="Sword Access", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="Sword Access", tag="_teleporter_relic plinth"),
Portal(name="Hero's Grave to Library", region="Hero Relic - Library",
destination="Library Hall", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="Library Hall", tag="_teleporter_relic plinth"),
Portal(name="Hero's Grave to Swamp", region="Hero Relic - Swamp",
destination="Swamp Redux 2", tag="_teleporter_relic plinth", direction=Direction.floor),
destination="Swamp Redux 2", tag="_teleporter_relic plinth"),
Portal(name="Far Shore to West Garden", region="Far Shore to West Garden Region",
destination="Archipelagos Redux", tag="_teleporter_archipelagos_teleporter", direction=Direction.floor),
destination="Archipelagos Redux", tag="_teleporter_archipelagos_teleporter"),
Portal(name="Far Shore to Library", region="Far Shore to Library Region",
destination="Library Lab", tag="_teleporter_library teleporter", direction=Direction.floor),
destination="Library Lab", tag="_teleporter_library teleporter"),
Portal(name="Far Shore to Quarry", region="Far Shore to Quarry Region",
destination="Quarry Redux", tag="_teleporter_quarry teleporter", direction=Direction.floor),
destination="Quarry Redux", tag="_teleporter_quarry teleporter"),
Portal(name="Far Shore to East Forest", region="Far Shore to East Forest Region",
destination="East Forest Redux", tag="_teleporter_forest teleporter", direction=Direction.floor),
destination="East Forest Redux", tag="_teleporter_forest teleporter"),
Portal(name="Far Shore to Fortress", region="Far Shore to Fortress Region",
destination="Fortress Arena", tag="_teleporter_spidertank", direction=Direction.floor),
destination="Fortress Arena", tag="_teleporter_spidertank"),
Portal(name="Far Shore to Atoll", region="Far Shore",
destination="Atoll Redux", tag="_teleporter_atoll", direction=Direction.floor),
destination="Atoll Redux", tag="_teleporter_atoll"),
Portal(name="Far Shore to Ziggurat", region="Far Shore",
destination="ziggurat2020_FTRoom", tag="_teleporter_ziggurat teleporter", direction=Direction.floor),
destination="ziggurat2020_FTRoom", tag="_teleporter_ziggurat teleporter"),
Portal(name="Far Shore to Heir", region="Far Shore",
destination="Spirit Arena", tag="_teleporter_spirit arena", direction=Direction.floor),
destination="Spirit Arena", tag="_teleporter_spirit arena"),
Portal(name="Far Shore to Town", region="Far Shore",
destination="Overworld Redux", tag="_teleporter_town", direction=Direction.floor),
destination="Overworld Redux", tag="_teleporter_town"),
Portal(name="Far Shore to Spawn", region="Far Shore to Spawn Region",
destination="Overworld Redux", tag="_teleporter_starting island", direction=Direction.floor),
destination="Overworld Redux", tag="_teleporter_starting island"),
Portal(name="Heir Arena Exit", region="Spirit Arena",
destination="Transit", tag="_teleporter_spirit arena", direction=Direction.floor),
destination="Transit", tag="_teleporter_spirit arena"),
Portal(name="Purgatory Bottom Exit", region="Purgatory",
destination="Purgatory", tag="_bottom", direction=Direction.south),
destination="Purgatory", tag="_bottom"),
Portal(name="Purgatory Top Exit", region="Purgatory",
destination="Purgatory", tag="_top", direction=Direction.north),
destination="Purgatory", tag="_top"),
]

View File

@@ -925,7 +925,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Upper Entry"].connect(
connecting_region=regions["Rooted Ziggurat Upper Front"])
regions["Rooted Ziggurat Upper Front"].connect(
zig_upper_front_back = regions["Rooted Ziggurat Upper Front"].connect(
connecting_region=regions["Rooted Ziggurat Upper Back"],
rule=lambda state: state.has(laurels, player) or has_sword(state, player))
regions["Rooted Ziggurat Upper Back"].connect(
@@ -1328,6 +1328,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
set_rule(lower_quarry_empty_to_combat,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(zig_upper_front_back,
lambda state: state.has(laurels, player)
or has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_entry_to_front,
lambda state: has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_mid_to_front,
@@ -1651,17 +1654,14 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# laurel means you can dodge the enemies freely with the laurels
if set_instead:
set_rule(multiworld.get_location(loc_name, player),
# someome tell me if you actually need to do the p=player and c=combat_req_area, lambdas scary
lambda state, p=player, c=combat_req_area, d=dagger, la=laurel:
has_combat_reqs(c, state, p)
or (state.has(ice_dagger, player) if d else False)
or (state.has(laurels, player) if la else False))
lambda state: has_combat_reqs(combat_req_area, state, player)
or (dagger and state.has(ice_dagger, player))
or (laurel and state.has(laurels, player)))
else:
add_rule(multiworld.get_location(loc_name, player),
lambda state, p=player, c=combat_req_area, d=dagger, la=laurel:
has_combat_reqs(c, state, p)
or (state.has(ice_dagger, player) if d else True)
or (state.has(laurels, player) if la else False))
lambda state: has_combat_reqs(combat_req_area, state, player)
or (dagger and state.has(ice_dagger, player))
or (laurel and state.has(laurels, player)))
if world.options.combat_logic >= CombatLogic.option_bosses_only:
# garden knight is in the regions part above

View File

@@ -157,7 +157,7 @@ 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
entrance_layout = world.options.entrance_layout
fixed_shop = world.options.fixed_shop
laurels_location = world.options.laurels_location
traversal_reqs = deepcopy(traversal_requirements)
has_laurels = True
@@ -169,7 +169,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"]
entrance_layout = seed_group["entrance_layout"]
fixed_shop = seed_group["fixed_shop"]
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)
@@ -181,7 +181,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# need to keep track of which scenes have shops, since you shouldn't have multiple shops connected to the same scene
shop_scenes: Set[str] = set()
shop_count = 6
if entrance_layout == EntranceLayout.option_fixed_shop:
if fixed_shop:
shop_count = 0
shop_scenes.add("Overworld Redux")
else:
@@ -190,9 +190,6 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
if portal.region == "Zig Skip Exit":
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
# If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit
if hasattr(world.multiworld, "re_gen_passthrough"):
@@ -219,7 +216,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
else:
dead_ends.append(portal)
if portal.region == "Zig Skip Exit":
if entrance_layout == EntranceLayout.option_fixed_shop:
if fixed_shop:
two_plus.append(portal)
else:
dead_ends.append(portal)
@@ -266,7 +263,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# secret gathering place and zig skip get weird, special handling
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 entrance_layout == EntranceLayout.option_fixed_shop):
or (region_name == "Zig Skip Exit" and fixed_shop):
non_dead_end_regions.add(region_name)
if plando_connections:
@@ -322,12 +319,8 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
break
# if it's not a dead end, it might be a shop
if p_exit == "Shop Portal":
# 6 of the shops have south exits, 2 of them have west exits
shop_dir = Direction.south
if world.shop_num > 6:
shop_dir = Direction.west
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}",
destination="Previous Region", tag="_", direction=shop_dir)
destination="Previous Region", tag="_")
create_shop_region(world, regions)
shop_count -= 1
# need to maintain an even number of portals total
@@ -374,15 +367,15 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
# 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 entrance_layout == EntranceLayout.option_fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"):
if fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"):
portal1 = None
for portal in two_plus:
if portal.scene_destination() == "Overworld Redux, Windmill_":
portal1 = portal
break
if not portal1:
raise Exception(f"Failed to do Fixed Shop option for Entrance Layout. "
f"Did {player_name} plando the Windmill Shop entrance?")
raise Exception(f"Failed to do Fixed Shop option. "
f"Did {player_name} plando connection the Windmill Shop entrance?")
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}",
destination="Previous Region", tag="_")
@@ -461,7 +454,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
if "TUNIC" in world.multiworld.re_gen_passthrough:
shop_count = 0
for _ in range(shop_count):
for i in range(shop_count):
portal1 = None
for portal in two_plus:
if portal.scene() not in shop_scenes:
@@ -471,12 +464,8 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
break
if portal1 is None:
raise Exception("Too many shops in the pool, or something else went wrong.")
# 6 of the shops have south exits, 2 of them have west exits
shop_dir = Direction.south
if world.shop_num > 6:
shop_dir = Direction.west
portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}",
destination="Previous Region", tag="_", direction=shop_dir)
destination="Previous Region", tag="_")
create_shop_region(world, regions)
portal_pairs[portal1] = portal2
@@ -557,53 +546,3 @@ def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[s
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic)
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:
if portal1.direction == direction_pairs[portal2.direction]:
return True
elif portal1.name.startswith("Shop"):
if portal2.direction in [Direction.north, Direction.east]:
return True
elif portal2.name.startswith("Shop"):
if portal1.direction in [Direction.north, Direction.east]:
return True
else:
return False
# 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:
if entrance_portal.direction == direction_pairs[exit_portal.direction]:
return True
# 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:
if exit_portal.direction in [Direction.north, Direction.east]:
return True
elif not exit_portal:
if entrance_portal.direction in [Direction.north, Direction.east]:
return True

View File

@@ -208,6 +208,10 @@ slot_data_item_names = [
"Gold Questagon",
]
combat_items: List[str] = [name for name, data in item_table.items()
if data.combat_ic and IC.progression in data.combat_ic]
combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's Laurels"])
item_name_to_id: Dict[str, int] = {name: item_base_id + data.item_id_offset for name, data in item_table.items()}
filler_items: List[str] = [name for name, data in item_table.items() if data.classification == IC.filler]

View File

@@ -263,8 +263,8 @@ class LadderStorage(Choice):
class LadderStorageWithoutItems(Toggle):
"""
If disabled, you logically require Stick, Sword, or Magic Orb to Ladder Storage.
If enabled, you will be expected to Ladder Storage without progression items.
If disabled, you logically require Stick, Sword, or Magic Orb to perform Ladder Storage.
If enabled, you will be expected to perform Ladder Storage without progression items.
This can be done with the plushie code, a Golden Coin, Prayer, and many other options.
This option has no effect if you do not have Ladder Storage Logic enabled
@@ -273,6 +273,24 @@ class LadderStorageWithoutItems(Toggle):
display_name = "Ladder Storage without Items"
class LogicRules(Choice):
"""
This option has been superseded by the individual trick options.
If set to nmg, it will set Ice Grappling to medium and Laurels Zips on.
If set to ur, it will do nmg as well as set Ladder Storage to medium.
It is here to avoid breaking old yamls, and will be removed at a later date.
"""
visibility = Visibility.none
internal_name = "logic_rules"
display_name = "Logic Rules"
option_restricted = 0
option_no_major_glitches = 1
alias_nmg = 1
option_unrestricted = 2
alias_ur = 2
default = 0
@dataclass
class TunicOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
@@ -297,6 +315,7 @@ class TunicOptions(PerGameCommonOptions):
ice_grappling: IceGrappling
ladder_storage: LadderStorage
ladder_storage_without_items: LadderStorageWithoutItems
plando_connections: TunicPlandoConnections
fixed_shop: FixedShop
logic_rules: Removed # fully removed in the direction pairs update