Merge remote-tracking branch 'remotes/upstream/main'

This commit is contained in:
massimilianodelliubaldini
2025-01-07 16:14:51 -05:00
107 changed files with 8235 additions and 417 deletions

View File

@@ -40,10 +40,10 @@ jobs:
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh
sudo ./llvm.sh 17
sudo ./llvm.sh 19
- name: Install scan-build command
run: |
sudo apt install clang-tools-17
sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v5
with:
@@ -56,7 +56,7 @@ jobs:
- name: scan-build
run: |
source venv/bin/activate
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4

View File

@@ -53,7 +53,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest "pytest-subtests<0.14.0" pytest-xdist
pip install pytest pytest-subtests pytest-xdist
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests

View File

@@ -1914,7 +1914,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hint = ctx.get_hint(client.team, player, location)
if not hint:
return # Ignored safely
if hint.receiving_player != client.slot:
if client.slot not in ctx.slot_set(hint.receiving_player):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
"original_cmd": cmd}])

View File

@@ -232,7 +232,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"])
node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
node["text"] = self.ctx.player_names[player]
return self._handle_color(node)
@@ -410,6 +410,8 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
if slot not in self:
raise KeyError(slot)
return []
return [location_id for
location_id in self[slot] if

View File

@@ -754,7 +754,7 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring
for key in self.special_range_names:
if key != key.lower():
@@ -1180,7 +1180,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
class Accessibility(Choice):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1198,7 +1198,7 @@ class Accessibility(Choice):
class ItemsAccessibility(Accessibility):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1249,12 +1249,16 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
@@ -1276,6 +1280,8 @@ class CommonOptions(metaclass=OptionsMetaProperty):
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")

View File

@@ -79,6 +79,7 @@ Currently, the following games are supported:
* Yacht Dice
* Faxanadu
* Saving Princess
* Castlevania: Circle of the Moon
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -534,7 +534,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger(exception_logger).exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
exc_info=(exc_type, exc_value, exc_traceback),
extra={"NoStream": exception_logger is None})
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True

View File

@@ -34,7 +34,7 @@ def get_app() -> "Flask":
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument('--config_override', default=None,
help="Path to yaml config file that overrules config.yaml.")
args = parser.parse_known_args()[0]

View File

@@ -39,6 +39,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent

View File

@@ -6,6 +6,7 @@ import multiprocessing
import typing
from datetime import timedelta, datetime
from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit
@@ -53,7 +54,21 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
generation.state = STATE_STARTED
def init_db(pony_config: dict):
def init_generator(config: dict[str, Any]) -> None:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# set soft limit for memory to from config (default 4GiB)
soft_limit = config["GENERATOR_MEMORY_LIMIT"]
old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
if soft_limit != old_limit:
resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
del resource, soft_limit, hard_limit
pony_config = config["PONY"]
db.bind(**pony_config)
db.generate_mapping()
@@ -105,8 +120,8 @@ def autogen(config: dict):
try:
with Locker("autogen"):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool:
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)

View File

@@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": options_source.get("server_password", None),
"server_password": str(options_source.get("server_password", None)),
}
generator_options = {
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),

View File

@@ -69,6 +69,14 @@ cdef struct IndexEntry:
size_t count
if TYPE_CHECKING:
State = Dict[Tuple[int, int], Set[int]]
else:
State = Union[Tuple[int, int], Set[int], defaultdict]
T = TypeVar('T')
@cython.auto_pickle(False)
cdef class LocationStore:
"""Compact store for locations and their items in a MultiServer"""
@@ -137,10 +145,16 @@ cdef class LocationStore:
warnings.warn("Game has no locations")
# allocate the arrays and invalidate index (0xff...)
self.entries = <LocationEntry*>self._mem.alloc(count, sizeof(LocationEntry))
if count:
# leaving entries as NULL if there are none, makes potential memory errors more visible
self.entries = <LocationEntry*>self._mem.alloc(count, sizeof(LocationEntry))
self.sender_index = <IndexEntry*>self._mem.alloc(max_sender + 1, sizeof(IndexEntry))
self._raw_proxies = <PyObject**>self._mem.alloc(max_sender + 1, sizeof(PyObject*))
assert (not self.entries) == (not count)
assert self.sender_index
assert self._raw_proxies
# build entries and index
cdef size_t i = 0
for sender, locations in sorted(locations_dict.items()):
@@ -190,8 +204,6 @@ cdef class LocationStore:
raise KeyError(key)
return <object>self._raw_proxies[key]
T = TypeVar('T')
def get(self, key: int, default: T) -> Union[PlayerLocationProxy, T]:
# calling into self.__getitem__ here is slow, but this is not used in MultiServer
try:
@@ -246,12 +258,11 @@ cdef class LocationStore:
all_locations[sender].add(entry.location)
return all_locations
if TYPE_CHECKING:
State = Dict[Tuple[int, int], Set[int]]
else:
State = Union[Tuple[int, int], Set[int], defaultdict]
def get_checked(self, state: State, team: int, slot: int) -> List[int]:
cdef ap_player_t sender = slot
if sender < 0 or sender >= self.sender_index_size:
raise KeyError(slot)
# This used to validate checks actually exist. A remnant from the past.
# If the order of locations becomes relevant at some point, we could not do sorted(set), so leaving it.
cdef set checked = state[team, slot]
@@ -263,7 +274,6 @@ cdef class LocationStore:
# Unless the set is close to empty, it's cheaper to use the python set directly, so we do that.
cdef LocationEntry* entry
cdef ap_player_t sender = slot
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
return [entry.location for
@@ -273,9 +283,11 @@ cdef class LocationStore:
def get_missing(self, state: State, team: int, slot: int) -> List[int]:
cdef LocationEntry* entry
cdef ap_player_t sender = slot
if sender < 0 or sender >= self.sender_index_size:
raise KeyError(slot)
cdef set checked = state[team, slot]
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
cdef set checked = state[team, slot]
if not len(checked):
# Skip `in` if none have been checked.
# This optimizes the case where everyone connects to a fresh game at the same time.
@@ -290,9 +302,11 @@ cdef class LocationStore:
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
cdef LocationEntry* entry
cdef ap_player_t sender = slot
if sender < 0 or sender >= self.sender_index_size:
raise KeyError(slot)
cdef set checked = state[team, slot]
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
cdef set checked = state[team, slot]
return sorted([(entry.receiver, entry.item) for
entry in self.entries[start:start+count] if
entry.location not in checked])
@@ -328,7 +342,8 @@ cdef class PlayerLocationProxy:
cdef LocationEntry* entry = NULL
# binary search
cdef size_t l = self._store.sender_index[self._player].start
cdef size_t r = l + self._store.sender_index[self._player].count
cdef size_t e = l + self._store.sender_index[self._player].count
cdef size_t r = e
cdef size_t m
while l < r:
m = (l + r) // 2
@@ -337,7 +352,7 @@ cdef class PlayerLocationProxy:
l = m + 1
else:
r = m
if entry: # count != 0
if l < e:
entry = self._store.entries + l
if entry.location == loc:
return entry
@@ -349,8 +364,6 @@ cdef class PlayerLocationProxy:
return entry.item, entry.receiver, entry.flags
raise KeyError(f"No location {key} for player {self._player}")
T = TypeVar('T')
def get(self, key: int, default: T) -> Union[Tuple[int, int, int], T]:
cdef LocationEntry* entry = self._get(key)
if entry:

View File

@@ -3,8 +3,16 @@ import os
def make_ext(modname, pyxfilename):
from distutils.extension import Extension
return Extension(name=modname,
sources=[pyxfilename],
depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c")
return Extension(
name=modname,
sources=[pyxfilename],
depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c",
# to enable ASAN and debug build:
# extra_compile_args=["-fsanitize=address", "-UNDEBUG", "-Og", "-g"],
# extra_objects=["-fsanitize=address"],
# NOTE: we can not put -lasan at the front of link args, so needs to be run with
# LD_PRELOAD=/usr/lib/libasan.so ASAN_OPTIONS=detect_leaks=0 path/to/exe
# NOTE: this can't find everything unless libpython and cymem are also built with ASAN
)

View File

@@ -36,6 +36,9 @@
# Castlevania 64
/worlds/cv64/ @LiquidCat64
# Castlevania: Circle of the Moon
/worlds/cvcotm/ @LiquidCat64
# Celeste 64
/worlds/celeste64/ @PoryGone

View File

@@ -43,3 +43,26 @@ A faster alternative to the `for` loop would be to use a [list comprehension](ht
```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
```
---
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check.
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search.
2. Then, the region in its access_rule is determined to be reachable.
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it.
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region.
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster.

View File

@@ -43,9 +43,9 @@ Recommended steps
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
* In PyCharm: right-click Generate.py and select `Run 'Generate'`
* Without PyCharm: open a command prompt in the source folder and type `py Generate.py`
* Run ModuleUpdate.py which will prompt installation of missing modules, press enter to confirm
* In PyCharm: right-click ModuleUpdate.py and select `Run 'ModuleUpdate'`
* Without PyCharm: open a command prompt in the source folder and type `py ModuleUpdate.py`
## macOS

View File

@@ -27,8 +27,14 @@
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
# SECRET_KEY: "Your secret key here"
# TODO
#JOB_THRESHOLD: 2
# Slot limit to post a generation to Generator process pool instead of rolling directly in WebHost process
#JOB_THRESHOLD: 1
# After what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
#JOB_TIME: 600
# Memory limit for Generator processes in bytes, -1 for unlimited. Currently only works on Linux.
#GENERATOR_MEMORY_LIMIT: 4294967296
# waitress uses one thread for I/O, these are for processing of view that get sent
#WAITRESS_THREADS: 10

View File

@@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apcvcotm"; ValueData: "{#MyAppName}cvcotmpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch"; ValueData: "Archipelago Castlevania Circle of the Moon Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";

View File

@@ -371,7 +371,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
if self.hint["status"] == HintStatus.HINT_FOUND:
return
ctx = App.get_running_app().ctx
if ctx.slot == self.hint["receiving_player"]: # If this player owns this hint
if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint
# open a dropdown
self.dropdown.open(self.ids["status"])
elif self.selected:
@@ -800,7 +800,7 @@ class HintLog(RecycleView):
hint_status_node = self.parser.handle_node({"type": "color",
"color": status_colors.get(hint["status"], "red"),
"text": status_names.get(hint["status"], "Unknown")})
if hint["status"] != HintStatus.HINT_FOUND and hint["receiving_player"] == ctx.slot:
if hint["status"] != HintStatus.HINT_FOUND and ctx.slot_concerns_self(hint["receiving_player"]):
hint_status_node = f"[u]{hint_status_node}[/u]"
data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},

View File

@@ -115,6 +115,7 @@ class Base:
def test_get_for_player(self) -> None:
self.assertEqual(self.store.get_for_player(3), {4: {9}})
self.assertEqual(self.store.get_for_player(1), {1: {13}, 2: {22, 23}})
self.assertEqual(self.store.get_for_player(9999), {})
def test_get_checked(self) -> None:
self.assertEqual(self.store.get_checked(full_state, 0, 1), [11, 12, 13])
@@ -122,18 +123,48 @@ class Base:
self.assertEqual(self.store.get_checked(empty_state, 0, 1), [])
self.assertEqual(self.store.get_checked(full_state, 0, 3), [9])
def test_get_checked_exception(self) -> None:
with self.assertRaises(KeyError):
self.store.get_checked(empty_state, 0, 9999)
bad_state = {(0, 6): {1}}
with self.assertRaises(KeyError):
self.store.get_checked(bad_state, 0, 6)
bad_state = {(0, 9999): set()}
with self.assertRaises(KeyError):
self.store.get_checked(bad_state, 0, 9999)
def test_get_missing(self) -> None:
self.assertEqual(self.store.get_missing(full_state, 0, 1), [])
self.assertEqual(self.store.get_missing(one_state, 0, 1), [11, 13])
self.assertEqual(self.store.get_missing(empty_state, 0, 1), [11, 12, 13])
self.assertEqual(self.store.get_missing(empty_state, 0, 3), [9])
def test_get_missing_exception(self) -> None:
with self.assertRaises(KeyError):
self.store.get_missing(empty_state, 0, 9999)
bad_state = {(0, 6): {1}}
with self.assertRaises(KeyError):
self.store.get_missing(bad_state, 0, 6)
bad_state = {(0, 9999): set()}
with self.assertRaises(KeyError):
self.store.get_missing(bad_state, 0, 9999)
def test_get_remaining(self) -> None:
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
def test_get_remaining_exception(self) -> None:
with self.assertRaises(KeyError):
self.store.get_remaining(empty_state, 0, 9999)
bad_state = {(0, 6): {1}}
with self.assertRaises(KeyError):
self.store.get_missing(bad_state, 0, 6)
bad_state = {(0, 9999): set()}
with self.assertRaises(KeyError):
self.store.get_remaining(bad_state, 0, 9999)
def test_location_set_intersection(self) -> None:
locations = {10, 11, 12}
locations.intersection_update(self.store[1])
@@ -181,6 +212,16 @@ class Base:
})
self.assertEqual(len(store), 1)
self.assertEqual(len(store[1]), 0)
self.assertEqual(sorted(store.find_item(set(), 1)), [])
self.assertEqual(sorted(store.find_item({1}, 1)), [])
self.assertEqual(sorted(store.find_item({1, 2}, 1)), [])
self.assertEqual(store.get_for_player(1), {})
self.assertEqual(store.get_checked(empty_state, 0, 1), [])
self.assertEqual(store.get_checked(full_state, 0, 1), [])
self.assertEqual(store.get_missing(empty_state, 0, 1), [])
self.assertEqual(store.get_missing(full_state, 0, 1), [])
self.assertEqual(store.get_remaining(empty_state, 0, 1), [])
self.assertEqual(store.get_remaining(full_state, 0, 1), [])
def test_no_locations_for_1(self) -> None:
store = self.type({

View File

@@ -7,7 +7,7 @@ import sys
import time
from random import Random
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple,
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
@@ -534,12 +534,24 @@ class World(metaclass=AutoWorldRegister):
def get_location(self, location_name: str) -> "Location":
return self.multiworld.get_location(location_name, self.player)
def get_locations(self) -> "Iterable[Location]":
return self.multiworld.get_locations(self.player)
def get_entrance(self, entrance_name: str) -> "Entrance":
return self.multiworld.get_entrance(entrance_name, self.player)
def get_entrances(self) -> "Iterable[Entrance]":
return self.multiworld.get_entrances(self.player)
def get_region(self, region_name: str) -> "Region":
return self.multiworld.get_region(region_name, self.player)
def get_regions(self) -> "Iterable[Region]":
return self.multiworld.get_regions(self.player)
def push_precollected(self, item: Item) -> None:
self.multiworld.push_precollected(item)
@property
def player_name(self) -> str:
return self.multiworld.get_player_name(self.player)

248
worlds/cvcotm/LICENSES.txt Normal file
View File

@@ -0,0 +1,248 @@
Regarding the sprite data specifically for the Archipelago logo found in data > patches.py:
The Archipelago Logo is © 2022 by Krista Corkos and Christopher Wilson and licensed under Attribution-NonCommercial 4.0
International. Logo modified by Liquid Cat to fit artstyle and uses within this mod. To view a copy of this license,
visit http://creativecommons.org/licenses/by-nc/4.0/
The other custom sprites that I made, as long as you don't lie by claiming you were the one who drew them, I am fine
with you using and distributing them however you want to. -Liquid Cat
========================================================================================================================
For the lz10.py and cvcotm_text.py modules specifically the MIT license applies. Its terms are as follows:
MIT License
cvcotm_text.py Copyright (c) 2024 Liquid Cat
(Please consider the associated pixel data for the ASCII characters missing from CotM in data > patches.py
in the public domain, if there was any thought that that could even be copyrighted. -Liquid Cat)
lz10.py Copyright (c) 2024 lilDavid, NoiseCrush
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================================================================
Everything else in this world package not mentioned above can be assumed covered by standalone CotMR's Apache license
being a piece of a direct derivative of it. The terms are as follows:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021-2024 DevAnj, fusecavator, spooky, Malaert64
Archipelago version by Liquid Cat
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

4
worlds/cvcotm/NOTICE.txt Normal file
View File

@@ -0,0 +1,4 @@
Circle of the Moon Randomizer
Copyright 2021-2024 DevAnj, fusecavator, spooky, Malaert64
Archipelago version by Liquid Cat

221
worlds/cvcotm/__init__.py Normal file
View File

@@ -0,0 +1,221 @@
import os
import typing
import settings
import base64
import logging
from BaseClasses import Item, Region, Tutorial, ItemClassification
from .items import CVCotMItem, FILLER_ITEM_NAMES, ACTION_CARDS, ATTRIBUTE_CARDS, cvcotm_item_info, \
get_item_names_to_ids, get_item_counts
from .locations import CVCotMLocation, get_location_names_to_ids, BASE_ID, get_named_locations_data, \
get_location_name_groups
from .options import cvcotm_option_groups, CVCotMOptions, SubWeaponShuffle, IronMaidenBehavior, RequiredSkirmishes, \
CompletionGoal, EarlyEscapeItem
from .regions import get_region_info, get_all_region_names
from .rules import CVCotMRules
from .data import iname, lname
from .presets import cvcotm_options_presets
from worlds.AutoWorld import WebWorld, World
from .aesthetics import shuffle_sub_weapons, get_location_data, get_countdown_flags, populate_enemy_drops, \
get_start_inventory_data
from .rom import RomData, patch_rom, get_base_rom_path, CVCotMProcedurePatch, CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, \
CVCOTM_VC_US_HASH
from .client import CastlevaniaCotMClient
class CVCotMSettings(settings.Group):
class RomFile(settings.UserFilePath):
"""File name of the Castlevania CotM US rom"""
copy_to = "Castlevania - Circle of the Moon (USA).gba"
description = "Castlevania CotM (US) ROM File"
md5s = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]
rom_file: RomFile = RomFile(RomFile.copy_to)
class CVCotMWeb(WebWorld):
theme = "stone"
options_presets = cvcotm_options_presets
tutorials = [Tutorial(
"Multiworld Setup Guide",
"A guide to setting up the Archipleago Castlevania: Circle of the Moon randomizer on your computer and "
"connecting it to a multiworld.",
"English",
"setup_en.md",
"setup/en",
["Liquid Cat"]
)]
option_groups = cvcotm_option_groups
class CVCotMWorld(World):
"""
Castlevania: Circle of the Moon is a launch title for the Game Boy Advance and the first of three Castlevania games
released for the handheld in the "Metroidvania" format. As Nathan Graves, wielding the Hunter Whip and utilizing the
Dual Set-Up System for new possibilities, you must battle your way through Camilla's castle and rescue your master
from a demonic ritual to restore the Count's power...
"""
game = "Castlevania - Circle of the Moon"
item_name_groups = {
"DSS": ACTION_CARDS.union(ATTRIBUTE_CARDS),
"Card": ACTION_CARDS.union(ATTRIBUTE_CARDS),
"Action": ACTION_CARDS,
"Action Card": ACTION_CARDS,
"Attribute": ATTRIBUTE_CARDS,
"Attribute Card": ATTRIBUTE_CARDS,
"Freeze": {iname.serpent, iname.cockatrice, iname.mercury, iname.mars},
"Freeze Action": {iname.mercury, iname.mars},
"Freeze Attribute": {iname.serpent, iname.cockatrice}
}
location_name_groups = get_location_name_groups()
options_dataclass = CVCotMOptions
options: CVCotMOptions
settings: typing.ClassVar[CVCotMSettings]
origin_region_name = "Catacomb"
hint_blacklist = frozenset({lname.ba24}) # The Battle Arena reward, if it's put in, will always be a Last Key.
item_name_to_id = {name: cvcotm_item_info[name].code + BASE_ID for name in cvcotm_item_info
if cvcotm_item_info[name].code is not None}
location_name_to_id = get_location_names_to_ids()
# Default values to possibly be updated in generate_early
total_last_keys: int = 0
required_last_keys: int = 0
auth: bytearray
web = CVCotMWeb()
def generate_early(self) -> None:
# Generate the player's unique authentication
self.auth = bytearray(self.random.getrandbits(8) for _ in range(16))
# If Required Skirmishes are on, force the Required and Available Last Keys to 8 or 9 depending on which option
# was chosen.
if self.options.required_skirmishes == RequiredSkirmishes.option_all_bosses:
self.options.required_last_keys.value = 8
self.options.available_last_keys.value = 8
elif self.options.required_skirmishes == RequiredSkirmishes.option_all_bosses_and_arena:
self.options.required_last_keys.value = 9
self.options.available_last_keys.value = 9
self.total_last_keys = self.options.available_last_keys.value
self.required_last_keys = self.options.required_last_keys.value
# If there are more Last Keys required than there are Last Keys in total, drop the required Last Keys to
# the total Last Keys.
if self.required_last_keys > self.total_last_keys:
self.required_last_keys = self.total_last_keys
logging.warning(f"[{self.player_name}] The Required Last Keys "
f"({self.options.required_last_keys.value}) is higher than the Available Last Keys "
f"({self.options.available_last_keys.value}). Lowering the required number to: "
f"{self.required_last_keys}")
self.options.required_last_keys.value = self.required_last_keys
# Place the Double or Roc Wing in local_early_items if the Early Escape option is being used.
if self.options.early_escape_item == EarlyEscapeItem.option_double:
self.multiworld.local_early_items[self.player][iname.double] = 1
elif self.options.early_escape_item == EarlyEscapeItem.option_roc_wing:
self.multiworld.local_early_items[self.player][iname.roc_wing] = 1
elif self.options.early_escape_item == EarlyEscapeItem.option_double_or_roc_wing:
self.multiworld.local_early_items[self.player][self.random.choice([iname.double, iname.roc_wing])] = 1
def create_regions(self) -> None:
# Create every Region object.
created_regions = [Region(name, self.player, self.multiworld) for name in get_all_region_names()]
# Attach the Regions to the Multiworld.
self.multiworld.regions.extend(created_regions)
for reg in created_regions:
# Add the Entrances to all the Regions.
ent_destinations_and_names = get_region_info(reg.name, "entrances")
if ent_destinations_and_names is not None:
reg.add_exits(ent_destinations_and_names)
# Add the Locations to all the Regions.
loc_names = get_region_info(reg.name, "locations")
if loc_names is None:
continue
locations_with_ids, locked_pairs = get_named_locations_data(loc_names, self.options)
reg.add_locations(locations_with_ids, CVCotMLocation)
# Place locked Items on all of their associated Locations.
for locked_loc, locked_item in locked_pairs.items():
self.get_location(locked_loc).place_locked_item(self.create_item(locked_item,
ItemClassification.progression))
def create_item(self, name: str, force_classification: typing.Optional[ItemClassification] = None) -> Item:
if force_classification is not None:
classification = force_classification
else:
classification = cvcotm_item_info[name].default_classification
code = cvcotm_item_info[name].code
if code is not None:
code += BASE_ID
created_item = CVCotMItem(name, classification, code, self.player)
return created_item
def create_items(self) -> None:
item_counts = get_item_counts(self)
# Set up the items correctly
self.multiworld.itempool += [self.create_item(item, classification) for classification in item_counts for item
in item_counts[classification] for _ in range(item_counts[classification][item])]
def set_rules(self) -> None:
# Set all the Entrance and Location rules properly.
CVCotMRules(self).set_cvcotm_rules()
def generate_output(self, output_directory: str) -> None:
# Get out all the Locations that are not Events. Only take the Iron Maiden switch if the Maiden Detonator is in
# the item pool.
active_locations = [loc for loc in self.multiworld.get_locations(self.player) if loc.address is not None and
(loc.name != lname.ct21 or self.options.iron_maiden_behavior ==
IronMaidenBehavior.option_detonator_in_pool)]
# Location data
offset_data = get_location_data(self, active_locations)
# Sub-weapons
if self.options.sub_weapon_shuffle:
offset_data.update(shuffle_sub_weapons(self))
# Item drop randomization
if self.options.item_drop_randomization:
offset_data.update(populate_enemy_drops(self))
# Countdown
if self.options.countdown:
offset_data.update(get_countdown_flags(self, active_locations))
# Start Inventory
start_inventory_data = get_start_inventory_data(self)
offset_data.update(start_inventory_data[0])
patch = CVCotMProcedurePatch(player=self.player, player_name=self.player_name)
patch_rom(self, patch, offset_data, start_inventory_data[1])
rom_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}"
f"{patch.patch_file_ending}")
patch.write(rom_path)
def fill_slot_data(self) -> dict:
return {"death_link": self.options.death_link.value,
"iron_maiden_behavior": self.options.iron_maiden_behavior.value,
"ignore_cleansing": self.options.ignore_cleansing.value,
"skip_tutorials": self.options.skip_tutorials.value,
"required_last_keys": self.required_last_keys,
"completion_goal": self.options.completion_goal.value}
def get_filler_item_name(self) -> str:
return self.random.choice(FILLER_ITEM_NAMES)
def modify_multidata(self, multidata: typing.Dict[str, typing.Any]):
# Put the player's unique authentication in connect_names.
multidata["connect_names"][base64.b64encode(self.auth).decode("ascii")] = \
multidata["connect_names"][self.player_name]

761
worlds/cvcotm/aesthetics.py Normal file
View File

@@ -0,0 +1,761 @@
from BaseClasses import ItemClassification, Location
from .options import ItemDropRandomization, Countdown, RequiredSkirmishes, IronMaidenBehavior
from .locations import cvcotm_location_info
from .items import cvcotm_item_info, MAJORS_CLASSIFICATIONS
from .data import iname
from typing import TYPE_CHECKING, Dict, List, Iterable, Tuple, NamedTuple, Optional, TypedDict
if TYPE_CHECKING:
from . import CVCotMWorld
class StatInfo(TypedDict):
# Amount this stat increases per Max Up the player starts with.
amount_per: int
# The most amount of this stat the player is allowed to start with. Problems arise if the stat exceeds 9999, so we
# must ensure it can't if the player raises any class to level 99 as well as collects 255 of that max up. The game
# caps hearts at 999 automatically, so it doesn't matter so much for that one.
max_allowed: int
# The key variable in extra_stats that the stat max up affects.
variable: str
extra_starting_stat_info: Dict[str, StatInfo] = {
iname.hp_max: {"amount_per": 10,
"max_allowed": 5289,
"variable": "extra health"},
iname.mp_max: {"amount_per": 10,
"max_allowed": 3129,
"variable": "extra magic"},
iname.heart_max: {"amount_per": 6,
"max_allowed": 999,
"variable": "extra hearts"},
}
other_player_subtype_bytes = {
0xE4: 0x03,
0xE6: 0x14,
0xE8: 0x0A
}
class OtherGameAppearancesInfo(TypedDict):
# What type of item to place for the other player.
type: int
# What item to display it as for the other player.
appearance: int
other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = {
# NOTE: Symphony of the Night is currently an unsupported world not in main.
"Symphony of the Night": {"Life Vessel": {"type": 0xE4,
"appearance": 0x01},
"Heart Vessel": {"type": 0xE4,
"appearance": 0x00}},
"Timespinner": {"Max HP": {"type": 0xE4,
"appearance": 0x01},
"Max Aura": {"type": 0xE4,
"appearance": 0x02},
"Max Sand": {"type": 0xE8,
"appearance": 0x0F}}
}
# 0 = Holy water 22
# 1 = Axe 24
# 2 = Knife 32
# 3 = Cross 6
# 4 = Stopwatch 12
# 5 = Small heart
# 6 = Big heart
rom_sub_weapon_offsets = {
0xD034E: b"\x01",
0xD0462: b"\x02",
0xD064E: b"\x00",
0xD06F6: b"\x02",
0xD0882: b"\x00",
0xD0912: b"\x02",
0xD0C2A: b"\x02",
0xD0C96: b"\x01",
0xD0D92: b"\x02",
0xD0DCE: b"\x01",
0xD1332: b"\x00",
0xD13AA: b"\x01",
0xD1722: b"\x02",
0xD17A6: b"\x01",
0xD1926: b"\x01",
0xD19AA: b"\x02",
0xD1A9A: b"\x02",
0xD1AA6: b"\x00",
0xD1EBA: b"\x00",
0xD1ED2: b"\x01",
0xD2262: b"\x02",
0xD23B2: b"\x03",
0xD256E: b"\x02",
0xD2742: b"\x02",
0xD2832: b"\x04",
0xD2862: b"\x01",
0xD2A2A: b"\x01",
0xD2DBA: b"\x04",
0xD2DC6: b"\x00",
0xD2E02: b"\x02",
0xD2EFE: b"\x04",
0xD2F0A: b"\x02",
0xD302A: b"\x00",
0xD3042: b"\x01",
0xD304E: b"\x04",
0xD3066: b"\x02",
0xD322E: b"\x04",
0xD334E: b"\x04",
0xD3516: b"\x03",
0xD35CA: b"\x02",
0xD371A: b"\x01",
0xD38EE: b"\x00",
0xD3BE2: b"\x02",
0xD3D1A: b"\x01",
0xD3D56: b"\x02",
0xD3ECA: b"\x00",
0xD3EE2: b"\x02",
0xD4056: b"\x01",
0xD40E6: b"\x04",
0xD413A: b"\x04",
0xD4326: b"\x00",
0xD460E: b"\x00",
0xD48D2: b"\x00",
0xD49E6: b"\x01",
0xD4ABE: b"\x02",
0xD4B8A: b"\x01",
0xD4D0A: b"\x04",
0xD4EAE: b"\x02",
0xD4F0E: b"\x00",
0xD4F92: b"\x02",
0xD4FB6: b"\x01",
0xD503A: b"\x03",
0xD5646: b"\x01",
0xD5682: b"\x02",
0xD57C6: b"\x02",
0xD57D2: b"\x02",
0xD58F2: b"\x00",
0xD5922: b"\x01",
0xD5B9E: b"\x02",
0xD5E26: b"\x01",
0xD5E56: b"\x02",
0xD5E7A: b"\x02",
0xD5F5E: b"\x00",
0xD69EA: b"\x02",
0xD69F6: b"\x01",
0xD6A02: b"\x00",
0xD6A0E: b"\x04",
0xD6A1A: b"\x03",
0xD6BE2: b"\x00",
0xD6CBA: b"\x01",
0xD6CDE: b"\x02",
0xD6EEE: b"\x00",
0xD6F1E: b"\x02",
0xD6F42: b"\x01",
0xD6FC6: b"\x04",
0xD706E: b"\x00",
0xD716A: b"\x02",
0xD72AE: b"\x01",
0xD75BA: b"\x03",
0xD76AA: b"\x04",
0xD76B6: b"\x00",
0xD76C2: b"\x01",
0xD76CE: b"\x02",
0xD76DA: b"\x03",
0xD7D46: b"\x00",
0xD7D52: b"\x00",
}
LOW_ITEMS = [
41, # Potion
42, # Meat
48, # Mind Restore
51, # Heart
46, # Antidote
47, # Cure Curse
17, # Cotton Clothes
18, # Prison Garb
12, # Cotton Robe
1, # Leather Armor
2, # Bronze Armor
3, # Gold Armor
39, # Toy Ring
40, # Bear Ring
34, # Wristband
36, # Arm Guard
37, # Magic Gauntlet
38, # Miracle Armband
35, # Gauntlet
]
MID_ITEMS = [
43, # Spiced Meat
49, # Mind High
52, # Heart High
19, # Stylish Suit
20, # Night Suit
13, # Silk Robe
14, # Rainbow Robe
4, # Chainmail
5, # Steel Armor
6, # Platinum Armor
24, # Star Bracelet
29, # Cursed Ring
25, # Strength Ring
26, # Hard Ring
27, # Intelligence Ring
28, # Luck Ring
23, # Double Grips
]
HIGH_ITEMS = [
44, # Potion High
45, # Potion Ex
50, # Mind Ex
53, # Heart Ex
54, # Heart Mega
21, # Ninja Garb
22, # Soldier Fatigues
15, # Magic Robe
16, # Sage Robe
7, # Diamond Armor
8, # Mirror Armor
9, # Needle Armor
10, # Dark Armor
30, # Strength Armband
31, # Defense Armband
32, # Sage Armband
33, # Gambler Armband
]
COMMON_ITEMS = LOW_ITEMS + MID_ITEMS
RARE_ITEMS = LOW_ITEMS + MID_ITEMS + HIGH_ITEMS
class CVCotMEnemyData(NamedTuple):
name: str
hp: int
attack: int
defense: int
exp: int
type: Optional[str] = None
cvcotm_enemy_info: List[CVCotMEnemyData] = [
# Name HP ATK DEF EXP
CVCotMEnemyData("Medusa Head", 6, 120, 60, 2),
CVCotMEnemyData("Zombie", 48, 70, 20, 2),
CVCotMEnemyData("Ghoul", 100, 190, 79, 3),
CVCotMEnemyData("Wight", 110, 235, 87, 4),
CVCotMEnemyData("Clinking Man", 80, 135, 25, 21),
CVCotMEnemyData("Zombie Thief", 120, 185, 30, 58),
CVCotMEnemyData("Skeleton", 25, 65, 45, 4),
CVCotMEnemyData("Skeleton Bomber", 20, 50, 40, 4),
CVCotMEnemyData("Electric Skeleton", 42, 80, 50, 30),
CVCotMEnemyData("Skeleton Spear", 30, 65, 46, 6),
CVCotMEnemyData("Skeleton Boomerang", 60, 170, 90, 112),
CVCotMEnemyData("Skeleton Soldier", 35, 90, 60, 16),
CVCotMEnemyData("Skeleton Knight", 50, 140, 80, 39),
CVCotMEnemyData("Bone Tower", 84, 201, 280, 160),
CVCotMEnemyData("Fleaman", 60, 142, 45, 29),
CVCotMEnemyData("Poltergeist", 105, 360, 380, 510),
CVCotMEnemyData("Bat", 5, 50, 15, 4),
CVCotMEnemyData("Spirit", 9, 55, 17, 1),
CVCotMEnemyData("Ectoplasm", 12, 165, 51, 2),
CVCotMEnemyData("Specter", 15, 295, 95, 3),
CVCotMEnemyData("Axe Armor", 55, 120, 130, 31),
CVCotMEnemyData("Flame Armor", 160, 320, 300, 280),
CVCotMEnemyData("Flame Demon", 300, 315, 270, 600),
CVCotMEnemyData("Ice Armor", 240, 470, 520, 1500),
CVCotMEnemyData("Thunder Armor", 204, 340, 320, 800),
CVCotMEnemyData("Wind Armor", 320, 500, 460, 1800),
CVCotMEnemyData("Earth Armor", 130, 230, 280, 240),
CVCotMEnemyData("Poison Armor", 260, 382, 310, 822),
CVCotMEnemyData("Forest Armor", 370, 390, 390, 1280),
CVCotMEnemyData("Stone Armor", 90, 220, 320, 222),
CVCotMEnemyData("Ice Demon", 350, 492, 510, 4200),
CVCotMEnemyData("Holy Armor", 350, 420, 450, 1700),
CVCotMEnemyData("Thunder Demon", 180, 270, 230, 450),
CVCotMEnemyData("Dark Armor", 400, 680, 560, 3300),
CVCotMEnemyData("Wind Demon", 400, 540, 490, 3600),
CVCotMEnemyData("Bloody Sword", 30, 220, 500, 200),
CVCotMEnemyData("Golem", 650, 520, 700, 1400),
CVCotMEnemyData("Earth Demon", 150, 90, 85, 25),
CVCotMEnemyData("Were-wolf", 160, 265, 110, 140),
CVCotMEnemyData("Man Eater", 400, 330, 233, 700),
CVCotMEnemyData("Devil Tower", 10, 140, 200, 17),
CVCotMEnemyData("Skeleton Athlete", 100, 100, 50, 25),
CVCotMEnemyData("Harpy", 120, 275, 200, 271),
CVCotMEnemyData("Siren", 160, 443, 300, 880),
CVCotMEnemyData("Imp", 90, 220, 99, 103),
CVCotMEnemyData("Mudman", 25, 79, 30, 2),
CVCotMEnemyData("Gargoyle", 60, 160, 66, 3),
CVCotMEnemyData("Slime", 40, 102, 18, 11),
CVCotMEnemyData("Frozen Shade", 112, 490, 560, 1212),
CVCotMEnemyData("Heat Shade", 80, 240, 200, 136),
CVCotMEnemyData("Poison Worm", 120, 30, 20, 12),
CVCotMEnemyData("Myconid", 50, 250, 114, 25),
CVCotMEnemyData("Will O'Wisp", 11, 110, 16, 9),
CVCotMEnemyData("Spearfish", 40, 360, 450, 280),
CVCotMEnemyData("Merman", 60, 303, 301, 10),
CVCotMEnemyData("Minotaur", 410, 520, 640, 2000),
CVCotMEnemyData("Were-horse", 400, 540, 360, 1970),
CVCotMEnemyData("Marionette", 80, 160, 150, 127),
CVCotMEnemyData("Gremlin", 30, 80, 33, 2),
CVCotMEnemyData("Hopper", 40, 87, 35, 8),
CVCotMEnemyData("Evil Pillar", 20, 460, 800, 480),
CVCotMEnemyData("Were-panther", 200, 300, 130, 270),
CVCotMEnemyData("Were-jaguar", 270, 416, 170, 760),
CVCotMEnemyData("Bone Head", 24, 60, 80, 7),
CVCotMEnemyData("Fox Archer", 75, 130, 59, 53),
CVCotMEnemyData("Fox Hunter", 100, 290, 140, 272),
CVCotMEnemyData("Were-bear", 265, 250, 140, 227),
CVCotMEnemyData("Grizzly", 600, 380, 200, 960),
CVCotMEnemyData("Cerberus", 600, 150, 100, 500, "boss"),
CVCotMEnemyData("Beast Demon", 150, 330, 250, 260),
CVCotMEnemyData("Arch Demon", 320, 505, 400, 1000),
CVCotMEnemyData("Demon Lord", 460, 660, 500, 1950),
CVCotMEnemyData("Gorgon", 230, 215, 165, 219),
CVCotMEnemyData("Catoblepas", 550, 500, 430, 1800),
CVCotMEnemyData("Succubus", 150, 400, 350, 710),
CVCotMEnemyData("Fallen Angel", 370, 770, 770, 6000),
CVCotMEnemyData("Necromancer", 500, 200, 250, 2500, "boss"),
CVCotMEnemyData("Hyena", 93, 140, 70, 105),
CVCotMEnemyData("Fishhead", 80, 320, 504, 486),
CVCotMEnemyData("Dryad", 120, 300, 360, 300),
CVCotMEnemyData("Mimic Candle", 990, 600, 600, 6600, "candle"),
CVCotMEnemyData("Brain Float", 20, 50, 25, 10),
CVCotMEnemyData("Evil Hand", 52, 150, 120, 63),
CVCotMEnemyData("Abiondarg", 88, 388, 188, 388),
CVCotMEnemyData("Iron Golem", 640, 290, 450, 8000, "boss"),
CVCotMEnemyData("Devil", 1080, 800, 900, 10000),
CVCotMEnemyData("Witch", 144, 330, 290, 600),
CVCotMEnemyData("Mummy", 100, 100, 35, 3),
CVCotMEnemyData("Hipogriff", 300, 500, 210, 740),
CVCotMEnemyData("Adramelech", 1800, 380, 360, 16000, "boss"),
CVCotMEnemyData("Arachne", 330, 420, 288, 1300),
CVCotMEnemyData("Death Mantis", 200, 318, 240, 400),
CVCotMEnemyData("Alraune", 774, 490, 303, 2500),
CVCotMEnemyData("King Moth", 140, 290, 160, 150),
CVCotMEnemyData("Killer Bee", 8, 308, 108, 88),
CVCotMEnemyData("Dragon Zombie", 1400, 390, 440, 15000, "boss"),
CVCotMEnemyData("Lizardman", 100, 345, 400, 800),
CVCotMEnemyData("Franken", 1200, 700, 350, 2100),
CVCotMEnemyData("Legion", 420, 610, 375, 1590),
CVCotMEnemyData("Dullahan", 240, 550, 440, 2200),
CVCotMEnemyData("Death", 880, 600, 800, 60000, "boss"),
CVCotMEnemyData("Camilla", 1500, 650, 700, 80000, "boss"),
CVCotMEnemyData("Hugh", 1400, 570, 750, 120000, "boss"),
CVCotMEnemyData("Dracula", 1100, 805, 850, 150000, "boss"),
CVCotMEnemyData("Dracula", 3000, 1000, 1000, 0, "final boss"),
CVCotMEnemyData("Skeleton Medalist", 250, 100, 100, 1500),
CVCotMEnemyData("Were-jaguar", 320, 518, 260, 1200, "battle arena"),
CVCotMEnemyData("Were-wolf", 340, 525, 180, 1100, "battle arena"),
CVCotMEnemyData("Catoblepas", 560, 510, 435, 2000, "battle arena"),
CVCotMEnemyData("Hipogriff", 500, 620, 280, 1900, "battle arena"),
CVCotMEnemyData("Wind Demon", 490, 600, 540, 4000, "battle arena"),
CVCotMEnemyData("Witch", 210, 480, 340, 1000, "battle arena"),
CVCotMEnemyData("Stone Armor", 260, 585, 750, 3000, "battle arena"),
CVCotMEnemyData("Devil Tower", 50, 560, 700, 600, "battle arena"),
CVCotMEnemyData("Skeleton", 150, 400, 200, 500, "battle arena"),
CVCotMEnemyData("Skeleton Bomber", 150, 400, 200, 550, "battle arena"),
CVCotMEnemyData("Electric Skeleton", 150, 400, 200, 700, "battle arena"),
CVCotMEnemyData("Skeleton Spear", 150, 400, 200, 580, "battle arena"),
CVCotMEnemyData("Flame Demon", 680, 650, 600, 4500, "battle arena"),
CVCotMEnemyData("Bone Tower", 120, 500, 650, 800, "battle arena"),
CVCotMEnemyData("Fox Hunter", 160, 510, 220, 600, "battle arena"),
CVCotMEnemyData("Poison Armor", 380, 680, 634, 3600, "battle arena"),
CVCotMEnemyData("Bloody Sword", 55, 600, 1200, 2000, "battle arena"),
CVCotMEnemyData("Abiondarg", 188, 588, 288, 588, "battle arena"),
CVCotMEnemyData("Legion", 540, 760, 480, 2900, "battle arena"),
CVCotMEnemyData("Marionette", 200, 420, 400, 1200, "battle arena"),
CVCotMEnemyData("Minotaur", 580, 700, 715, 4100, "battle arena"),
CVCotMEnemyData("Arachne", 430, 590, 348, 2400, "battle arena"),
CVCotMEnemyData("Succubus", 300, 670, 630, 3100, "battle arena"),
CVCotMEnemyData("Demon Lord", 590, 800, 656, 4200, "battle arena"),
CVCotMEnemyData("Alraune", 1003, 640, 450, 5000, "battle arena"),
CVCotMEnemyData("Hyena", 210, 408, 170, 1000, "battle arena"),
CVCotMEnemyData("Devil Armor", 500, 804, 714, 6600),
CVCotMEnemyData("Evil Pillar", 55, 655, 900, 1500, "battle arena"),
CVCotMEnemyData("White Armor", 640, 770, 807, 7000),
CVCotMEnemyData("Devil", 1530, 980, 1060, 30000, "battle arena"),
CVCotMEnemyData("Scary Candle", 150, 300, 300, 900, "candle"),
CVCotMEnemyData("Trick Candle", 200, 400, 400, 1400, "candle"),
CVCotMEnemyData("Nightmare", 250, 550, 550, 2000),
CVCotMEnemyData("Lilim", 400, 800, 800, 8000),
CVCotMEnemyData("Lilith", 660, 960, 960, 20000),
]
# NOTE: Coffin is omitted from the end of this, as its presence doesn't
# actually impact the randomizer (all stats and drops inherited from Mummy).
BOSS_IDS = [enemy_id for enemy_id in range(len(cvcotm_enemy_info)) if cvcotm_enemy_info[enemy_id].type == "boss"]
ENEMY_TABLE_START = 0xCB2C4
NUMBER_ITEMS = 55
COUNTDOWN_TABLE_ADDR = 0x673400
ITEM_ID_SHINNING_ARMOR = 11
def shuffle_sub_weapons(world: "CVCotMWorld") -> Dict[int, bytes]:
"""Shuffles the sub-weapons amongst themselves."""
sub_bytes = list(rom_sub_weapon_offsets.values())
world.random.shuffle(sub_bytes)
return dict(zip(rom_sub_weapon_offsets, sub_bytes))
def get_countdown_flags(world: "CVCotMWorld", active_locations: Iterable[Location]) -> Dict[int, bytes]:
"""Figures out which Countdown numbers to increase for each Location after verifying the Item on the Location should
count towards a number.
Which number to increase is determined by the Location's "countdown" attr in its CVCotMLocationData."""
next_pos = COUNTDOWN_TABLE_ADDR + 0x40
countdown_flags: List[List[int]] = [[] for _ in range(16)]
countdown_dict = {}
ptr_offset = COUNTDOWN_TABLE_ADDR
# Loop over every Location.
for loc in active_locations:
# If the Location's Item is not Progression/Useful-classified with the "Majors" Countdown being used, or if the
# Location is the Iron Maiden switch with the vanilla Iron Maiden behavior, skip adding its flag to the arrays.
if (not loc.item.classification & MAJORS_CLASSIFICATIONS and world.options.countdown ==
Countdown.option_majors):
continue
countdown_index = cvcotm_location_info[loc.name].countdown
# Take the Location's address if the above condition is satisfied, and get the flag value out of it.
countdown_flags[countdown_index] += [loc.address & 0xFF, 0]
# Write the Countdown flag arrays and array pointers correctly. Each flag list should end with a 0xFFFF to indicate
# the end of an area's list.
for area_flags in countdown_flags:
countdown_dict[ptr_offset] = int.to_bytes(next_pos | 0x08000000, 4, "little")
countdown_dict[next_pos] = bytes(area_flags + [0xFF, 0xFF])
ptr_offset += 4
next_pos += len(area_flags) + 2
return countdown_dict
def get_location_data(world: "CVCotMWorld", active_locations: Iterable[Location]) -> Dict[int, bytes]:
"""Gets ALL the Item data to go into the ROM. Items consist of four bytes; the first two represent the object ID
for the "category" of item that it belongs to, the third is the sub-value for which item within that "category" it
is, and the fourth controls the appearance it takes."""
location_bytes = {}
for loc in active_locations:
# Figure out the item ID bytes to put in each Location's offset here.
# If it's a CotM Item, always write the Item's primary type byte.
if loc.item.game == "Castlevania - Circle of the Moon":
type_byte = cvcotm_item_info[loc.item.name].code >> 8
# If the Item is for this player, set the subtype to actually be that Item.
# Otherwise, set a dummy subtype value that is different for every item type.
if loc.item.player == world.player:
subtype_byte = cvcotm_item_info[loc.item.name].code & 0xFF
else:
subtype_byte = other_player_subtype_bytes[type_byte]
# If it's a DSS Card, set the appearance based on whether it's progression or not; freeze combo cards should
# all appear blue in color while the others are standard purple/yellow. Otherwise, set the appearance the
# same way as the subtype for local items regardless of whether it's actually local or not.
if type_byte == 0xE6:
if loc.item.advancement:
appearance_byte = 1
else:
appearance_byte = 0
else:
appearance_byte = cvcotm_item_info[loc.item.name].code & 0xFF
# If it's not a CotM Item at all, always set the primary type to that of a Magic Item and the subtype to that of
# a dummy item. The AP Items are all under Magic Items.
else:
type_byte = 0xE8
subtype_byte = 0x0A
# Decide which AP Item to use to represent the other game item.
if loc.item.classification & ItemClassification.progression and \
loc.item.classification & ItemClassification.useful:
appearance_byte = 0x0E # Progression + Useful
elif loc.item.classification & ItemClassification.progression:
appearance_byte = 0x0C # Progression
elif loc.item.classification & ItemClassification.useful:
appearance_byte = 0x0B # Useful
elif loc.item.classification & ItemClassification.trap:
appearance_byte = 0x0D # Trap
else:
appearance_byte = 0x0A # Filler
# Check if the Item's game is in the other game item appearances' dict, and if so, if the Item is under that
# game's name. If it is, change the appearance accordingly.
# Right now, only SotN and Timespinner stat ups are supported.
other_game_name = world.multiworld.worlds[loc.item.player].game
if other_game_name in other_game_item_appearances:
if loc.item.name in other_game_item_appearances[other_game_name]:
type_byte = other_game_item_appearances[other_game_name][loc.item.name]["type"]
subtype_byte = other_player_subtype_bytes[type_byte]
appearance_byte = other_game_item_appearances[other_game_name][loc.item.name]["appearance"]
# Create the correct bytes object for the Item on that Location.
location_bytes[cvcotm_location_info[loc.name].offset] = bytes([type_byte, 1, subtype_byte, appearance_byte])
return location_bytes
def populate_enemy_drops(world: "CVCotMWorld") -> Dict[int, bytes]:
"""Randomizes the enemy-dropped items throughout the game within each other. There are three tiers of item drops:
Low, Mid, and High. Each enemy has two item slots that can both drop its own item; a Common slot and a Rare one.
On Normal item randomization, easy enemies (below 61 HP) will only have Low-tier drops in both of their stats,
bosses and candle enemies will be guaranteed to have High drops in one or both of their slots respectively (bosses
are made to only drop one slot 100% of the time), and everything else can have a Low or Mid-tier item in its Common
drop slot and a Low, Mid, OR High-tier item in its Rare drop slot.
If Item Drop Randomization is set to Tiered, the HP threshold for enemies being considered "easily" will raise to
below 144, enemies in the 144-369 HP range (inclusive) will have a Low-tier item in its Common slot and a Mid-tier
item in its rare slot, and enemies with more than 369 HP will have a Mid-tier in its Common slot and a High-tier in
its Rare slot. Candles and bosses still have Rares in all their slots, but now the guaranteed drops that land on
bosses will be exclusive to them; no other enemy in the game will have their item.
This and select_drop are the most directly adapted code from upstream CotMR in this package by far. Credit where
it's due to Spooky for writing the original, and Malaert64 for further refinements and updating what used to be
Random Item Hardmode to instead be Tiered Item Mode. The original C code this was adapted from can be found here:
https://github.com/calm-palm/cotm-randomizer/blob/master/Program/randomizer.c#L1028"""
placed_low_items = [0] * len(LOW_ITEMS)
placed_mid_items = [0] * len(MID_ITEMS)
placed_high_items = [0] * len(HIGH_ITEMS)
placed_common_items = [0] * len(COMMON_ITEMS)
placed_rare_items = [0] * len(RARE_ITEMS)
regular_drops = [0] * len(cvcotm_enemy_info)
regular_drop_chances = [0] * len(cvcotm_enemy_info)
rare_drops = [0] * len(cvcotm_enemy_info)
rare_drop_chances = [0] * len(cvcotm_enemy_info)
# Set boss items first to prevent boss drop duplicates.
# If Tiered mode is enabled, make these items exclusive to these enemies by adding an arbitrary integer larger
# than could be reached normally (e.g.the total number of enemies) and use the placed high items array instead of
# the placed rare items one.
if world.options.item_drop_randomization == ItemDropRandomization.option_tiered:
for boss_id in BOSS_IDS:
regular_drops[boss_id] = select_drop(world, HIGH_ITEMS, placed_high_items, True)
else:
for boss_id in BOSS_IDS:
regular_drops[boss_id] = select_drop(world, RARE_ITEMS, placed_rare_items, start_index=len(COMMON_ITEMS))
# Setting drop logic for all enemies.
for i in range(len(cvcotm_enemy_info)):
# Give Dracula II Shining Armor occasionally as a joke.
if cvcotm_enemy_info[i].type == "final boss":
regular_drops[i] = rare_drops[i] = ITEM_ID_SHINNING_ARMOR
regular_drop_chances[i] = rare_drop_chances[i] = 5000
# Set bosses' secondary item to none since we already set the primary item earlier.
elif cvcotm_enemy_info[i].type == "boss":
# Set rare drop to none.
rare_drops[i] = 0
# Max out rare boss drops (normally, drops are capped to 50% and 25% for common and rare respectively, but
# Fuse's patch AllowAlwaysDrop.ips allows setting the regular item drop chance to 10000 to force a drop
# always)
regular_drop_chances[i] = 10000
rare_drop_chances[i] = 0
# Candle enemies use a similar placement logic to the bosses, except items that land on them are NOT exclusive
# to them on Tiered mode.
elif cvcotm_enemy_info[i].type == "candle":
if world.options.item_drop_randomization == ItemDropRandomization.option_tiered:
regular_drops[i] = select_drop(world, HIGH_ITEMS, placed_high_items)
rare_drops[i] = select_drop(world, HIGH_ITEMS, placed_high_items)
else:
regular_drops[i] = select_drop(world, RARE_ITEMS, placed_rare_items, start_index=len(COMMON_ITEMS))
rare_drops[i] = select_drop(world, RARE_ITEMS, placed_rare_items, start_index=len(COMMON_ITEMS))
# Set base drop chances at 20-30% for common and 15-20% for rare.
regular_drop_chances[i] = 2000 + world.random.randint(0, 1000)
rare_drop_chances[i] = 1500 + world.random.randint(0, 500)
# On All Bosses and Battle Arena Required, the Shinning Armor at the end of Battle Arena is removed.
# We compensate for this by giving the Battle Arena Devil a 100% chance to drop Shinning Armor.
elif cvcotm_enemy_info[i].name == "Devil" and cvcotm_enemy_info[i].type == "battle arena" and \
world.options.required_skirmishes == RequiredSkirmishes.option_all_bosses_and_arena:
regular_drops[i] = ITEM_ID_SHINNING_ARMOR
rare_drops[i] = 0
regular_drop_chances[i] = 10000
rare_drop_chances[i] = 0
# Low-tier items drop from enemies that are trivial to farm (60 HP or less)
# on Normal drop logic, or enemies under 144 HP on Tiered logic.
elif (world.options.item_drop_randomization == ItemDropRandomization.option_normal and
cvcotm_enemy_info[i].hp <= 60) or \
(world.options.item_drop_randomization == ItemDropRandomization.option_tiered and
cvcotm_enemy_info[i].hp <= 143):
# Low-tier enemy drops.
regular_drops[i] = select_drop(world, LOW_ITEMS, placed_low_items)
rare_drops[i] = select_drop(world, LOW_ITEMS, placed_low_items)
# Set base drop chances at 6-10% for common and 3-6% for rare.
regular_drop_chances[i] = 600 + world.random.randint(0, 400)
rare_drop_chances[i] = 300 + world.random.randint(0, 300)
# Rest of Tiered logic, by Malaert64.
elif world.options.item_drop_randomization == ItemDropRandomization.option_tiered:
# If under 370 HP, mid-tier enemy.
if cvcotm_enemy_info[i].hp <= 369:
regular_drops[i] = select_drop(world, LOW_ITEMS, placed_low_items)
rare_drops[i] = select_drop(world, MID_ITEMS, placed_mid_items)
# Otherwise, enemy HP is 370+, thus high-tier enemy.
else:
regular_drops[i] = select_drop(world, MID_ITEMS, placed_mid_items)
rare_drops[i] = select_drop(world, HIGH_ITEMS, placed_high_items)
# Set base drop chances at 6-10% for common and 3-6% for rare.
regular_drop_chances[i] = 600 + world.random.randint(0, 400)
rare_drop_chances[i] = 300 + world.random.randint(0, 300)
# Regular enemies outside Tiered logic.
else:
# Select a random regular and rare drop for every enemy from their respective lists.
regular_drops[i] = select_drop(world, COMMON_ITEMS, placed_common_items)
rare_drops[i] = select_drop(world, RARE_ITEMS, placed_rare_items)
# Set base drop chances at 6-10% for common and 3-6% for rare.
regular_drop_chances[i] = 600 + world.random.randint(0, 400)
rare_drop_chances[i] = 300 + world.random.randint(0, 300)
# Return the randomized drop data as bytes with their respective offsets.
enemy_address = ENEMY_TABLE_START
drop_data = {}
for i, enemy_info in enumerate(cvcotm_enemy_info):
drop_data[enemy_address] = bytes([regular_drops[i], 0, regular_drop_chances[i] & 0xFF,
regular_drop_chances[i] >> 8, rare_drops[i], 0, rare_drop_chances[i] & 0xFF,
rare_drop_chances[i] >> 8])
enemy_address += 20
return drop_data
def select_drop(world: "CVCotMWorld", drop_list: List[int], drops_placed: List[int], exclusive_drop: bool = False,
start_index: int = 0) -> int:
"""Chooses a drop from a given list of drops based on another given list of how many drops from that list were
selected before. In order to ensure an even number of drops are distributed, drops that were selected the least are
the ones that will be picked from.
Calling this with exclusive_drop param being True will force the number of the chosen item really high to ensure it
will never be picked again."""
# Take the list of placed item drops beginning from the starting index.
drops_from_start_index = drops_placed[start_index:]
# Determine the lowest drop counts and the indices with that drop count.
lowest_number = min(drops_from_start_index)
indices_with_lowest_number = [index for index, placed in enumerate(drops_from_start_index) if
placed == lowest_number]
random_index = world.random.choice(indices_with_lowest_number)
random_index += start_index # Add start_index back on
# Increment the number of this item placed, unless it should be exclusive to the boss / candle, in which case
# set it to an arbitrarily large number to make it exclusive.
if exclusive_drop:
drops_placed[random_index] += 999
else:
drops_placed[random_index] += 1
# Return the in-game item ID of the chosen item.
return drop_list[random_index]
def get_start_inventory_data(world: "CVCotMWorld") -> Tuple[Dict[int, bytes], bool]:
"""Calculate and return the starting inventory arrays. Different items go into different arrays, so they all have
to be handled accordingly."""
start_inventory_data = {}
magic_items_array = [0 for _ in range(8)]
cards_array = [0 for _ in range(20)]
extra_stats = {"extra health": 0,
"extra magic": 0,
"extra hearts": 0}
start_with_detonator = False
# If the Iron Maiden Behavior option is set to Start Broken, consider ourselves starting with the Maiden Detonator.
if world.options.iron_maiden_behavior == IronMaidenBehavior.option_start_broken:
start_with_detonator = True
# Always start with the Dash Boots.
magic_items_array[0] = 1
for item in world.multiworld.precollected_items[world.player]:
array_offset = item.code & 0xFF
# If it's a Maiden Detonator we're starting with, set the boolean for it to True.
if item.name == iname.ironmaidens:
start_with_detonator = True
# If it's a Max Up we're starting with, check if increasing the extra amount of that stat will put us over the
# max amount of the stat allowed. If it will, set the current extra amount to the max. Otherwise, increase it by
# the amount that it should.
elif "Max Up" in item.name:
info = extra_starting_stat_info[item.name]
if extra_stats[info["variable"]] + info["amount_per"] > info["max_allowed"]:
extra_stats[info["variable"]] = info["max_allowed"]
else:
extra_stats[info["variable"]] += info["amount_per"]
# If it's a DSS card we're starting with, set that card's value in the cards array.
elif "Card" in item.name:
cards_array[array_offset] = 1
# If it's none of the above, it has to be a regular Magic Item.
# Increase that Magic Item's value in the Magic Items array if it's not greater than 240. Last Keys are the only
# Magic Item wherein having more than one is relevant.
else:
# Decrease the Magic Item array offset by 1 if it's higher than the unused Map's item value.
if array_offset > 5:
array_offset -= 1
if magic_items_array[array_offset] < 240:
magic_items_array[array_offset] += 1
# Add the start inventory arrays to the offset data in bytes form.
start_inventory_data[0x680080] = bytes(magic_items_array)
start_inventory_data[0x6800A0] = bytes(cards_array)
# Add the extra max HP/MP/Hearts to all classes' base stats. Doing it this way makes us less likely to hit the max
# possible Max Ups.
# Vampire Killer
start_inventory_data[0xE08C6] = int.to_bytes(100 + extra_stats["extra health"], 2, "little")
start_inventory_data[0xE08CE] = int.to_bytes(100 + extra_stats["extra magic"], 2, "little")
start_inventory_data[0xE08D4] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little")
# Magician
start_inventory_data[0xE090E] = int.to_bytes(50 + extra_stats["extra health"], 2, "little")
start_inventory_data[0xE0916] = int.to_bytes(400 + extra_stats["extra magic"], 2, "little")
start_inventory_data[0xE091C] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little")
# Fighter
start_inventory_data[0xE0932] = int.to_bytes(200 + extra_stats["extra health"], 2, "little")
start_inventory_data[0xE093A] = int.to_bytes(50 + extra_stats["extra magic"], 2, "little")
start_inventory_data[0xE0940] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little")
# Shooter
start_inventory_data[0xE0832] = int.to_bytes(50 + extra_stats["extra health"], 2, "little")
start_inventory_data[0xE08F2] = int.to_bytes(100 + extra_stats["extra magic"], 2, "little")
start_inventory_data[0xE08F8] = int.to_bytes(250 + extra_stats["extra hearts"], 2, "little")
# Thief
start_inventory_data[0xE0956] = int.to_bytes(50 + extra_stats["extra health"], 2, "little")
start_inventory_data[0xE095E] = int.to_bytes(50 + extra_stats["extra magic"], 2, "little")
start_inventory_data[0xE0964] = int.to_bytes(50 + extra_stats["extra hearts"], 2, "little")
return start_inventory_data, start_with_detonator

563
worlds/cvcotm/client.py Normal file
View File

@@ -0,0 +1,563 @@
from typing import TYPE_CHECKING, Set
from .locations import BASE_ID, get_location_names_to_ids
from .items import cvcotm_item_info, MAJORS_CLASSIFICATIONS
from .locations import cvcotm_location_info
from .cvcotm_text import cvcotm_string_to_bytearray
from .options import CompletionGoal, CVCotMDeathLink, IronMaidenBehavior
from .rom import ARCHIPELAGO_IDENTIFIER_START, ARCHIPELAGO_IDENTIFIER, AUTH_NUMBER_START, QUEUED_TEXT_STRING_START
from .data import iname, lname
from BaseClasses import ItemClassification
from NetUtils import ClientStatus
import worlds._bizhawk as bizhawk
import base64
from worlds._bizhawk.client import BizHawkClient
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext
CURRENT_STATUS_ADDRESS = 0xD0
POISON_TIMER_TILL_DAMAGE_ADDRESS = 0xD8
POISON_DAMAGE_VALUE_ADDRESS = 0xDE
GAME_STATE_ADDRESS = 0x45D8
FLAGS_ARRAY_START = 0x25374
CARDS_ARRAY_START = 0x25674
NUM_RECEIVED_ITEMS_ADDRESS = 0x253D0
MAX_UPS_ARRAY_START = 0x2572C
MAGIC_ITEMS_ARRAY_START = 0x2572F
QUEUED_TEXTBOX_1_ADDRESS = 0x25300
QUEUED_TEXTBOX_2_ADDRESS = 0x25302
QUEUED_MSG_DELAY_TIMER_ADDRESS = 0x25304
QUEUED_SOUND_ID_ADDRESS = 0x25306
DELAY_TIMER_ADDRESS = 0x25308
CURRENT_CUTSCENE_ID_ADDRESS = 0x26000
NATHAN_STATE_ADDRESS = 0x50
CURRENT_HP_ADDRESS = 0x2562E
CURRENT_MP_ADDRESS = 0x25636
CURRENT_HEARTS_ADDRESS = 0x2563C
CURRENT_LOCATION_VALUES_START = 0x253FC
ROM_NAME_START = 0xA0
AREA_SEALED_ROOM = 0x00
AREA_BATTLE_ARENA = 0x0E
GAME_STATE_GAMEPLAY = 0x06
GAME_STATE_CREDITS = 0x21
NATHAN_STATE_SAVING = 0x34
STATUS_POISON = b"\x02"
TEXT_ID_DSS_TUTORIAL = b"\x1D\x82"
TEXT_ID_MULTIWORLD_MESSAGE = b"\xF2\x84"
SOUND_ID_UNUSED_SIMON_FANFARE = b"\x04"
SOUND_ID_MAIDEN_BREAKING = b"\x79"
# SOUND_ID_NATHAN_FREEZING = b"\x7A"
SOUND_ID_BAD_CONFIG = b"\x2D\x01"
SOUND_ID_DRACULA_CHARGE = b"\xAB\x01"
SOUND_ID_MINOR_PICKUP = b"\xB3\x01"
SOUND_ID_MAJOR_PICKUP = b"\xB4\x01"
ITEM_NAME_LIMIT = 300
PLAYER_NAME_LIMIT = 50
FLAG_HIT_IRON_MAIDEN_SWITCH = 0x2A
FLAG_SAW_DSS_TUTORIAL = 0xB1
FLAG_WON_BATTLE_ARENA = 0xB2
FLAG_DEFEATED_DRACULA_II = 0xBC
# These flags are communicated to the tracker as a bitfield using this order.
# Modifying the order will cause undetectable autotracking issues.
EVENT_FLAG_MAP = {
FLAG_HIT_IRON_MAIDEN_SWITCH: "FLAG_HIT_IRON_MAIDEN_SWITCH",
FLAG_WON_BATTLE_ARENA: "FLAG_WON_BATTLE_ARENA",
0xB3: "FLAG_DEFEATED_CERBERUS",
0xB4: "FLAG_DEFEATED_NECROMANCER",
0xB5: "FLAG_DEFEATED_IRON_GOLEM",
0xB6: "FLAG_DEFEATED_ADRAMELECH",
0xB7: "FLAG_DEFEATED_DRAGON_ZOMBIES",
0xB8: "FLAG_DEFEATED_DEATH",
0xB9: "FLAG_DEFEATED_CAMILLA",
0xBA: "FLAG_DEFEATED_HUGH",
0xBB: "FLAG_DEFEATED_DRACULA_I",
FLAG_DEFEATED_DRACULA_II: "FLAG_DEFEATED_DRACULA_II"
}
DEATHLINK_AREA_NAMES = ["Sealed Room", "Catacomb", "Abyss Staircase", "Audience Room", "Triumph Hallway",
"Machine Tower", "Eternal Corridor", "Chapel Tower", "Underground Warehouse",
"Underground Gallery", "Underground Waterway", "Outer Wall", "Observation Tower",
"Ceremonial Room", "Battle Arena"]
class CastlevaniaCotMClient(BizHawkClient):
game = "Castlevania - Circle of the Moon"
system = "GBA"
patch_suffix = ".apcvcotm"
sent_initial_packets: bool
self_induced_death: bool
local_checked_locations: Set[int]
client_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()}
killed_dracula_2: bool
won_battle_arena: bool
sent_message_queue: list
death_causes: list
currently_dead: bool
synced_set_events: bool
saw_arena_win_message: bool
saw_dss_tutorial: bool
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
from CommonClient import logger
try:
# Check ROM name/patch version
game_names = await bizhawk.read(ctx.bizhawk_ctx, [(ROM_NAME_START, 0xC, "ROM"),
(ARCHIPELAGO_IDENTIFIER_START, 12, "ROM")])
if game_names[0].decode("ascii") != "DRACULA AGB1":
return False
if game_names[1] == b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00':
logger.info("ERROR: You appear to be running an unpatched version of Castlevania: Circle of the Moon. "
"You need to generate a patch file and use it to create a patched ROM.")
return False
if game_names[1].decode("ascii") != ARCHIPELAGO_IDENTIFIER:
logger.info("ERROR: The patch file used to create this ROM is not compatible with "
"this client. Double check your client version against the version being "
"used by the generator.")
return False
except UnicodeDecodeError:
return False
except bizhawk.RequestFailedError:
return False # Should verify on the next pass
ctx.game = self.game
ctx.items_handling = 0b001
ctx.want_slot_data = True
ctx.watcher_timeout = 0.125
return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
auth_raw = (await bizhawk.read(ctx.bizhawk_ctx, [(AUTH_NUMBER_START, 16, "ROM")]))[0]
ctx.auth = base64.b64encode(auth_raw).decode("utf-8")
# Initialize all the local client attributes here so that nothing will be carried over from a previous CotM if
# the player tried changing CotM ROMs without resetting their Bizhawk Client instance.
self.sent_initial_packets = False
self.local_checked_locations = set()
self.self_induced_death = False
self.client_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()}
self.killed_dracula_2 = False
self.won_battle_arena = False
self.sent_message_queue = []
self.death_causes = []
self.currently_dead = False
self.synced_set_events = False
self.saw_arena_win_message = False
self.saw_dss_tutorial = False
def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict) -> None:
if cmd != "Bounced":
return
if "tags" not in args:
return
if ctx.slot is None:
return
if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name:
if "cause" in args["data"]:
cause = args["data"]["cause"]
if cause == "":
cause = f"{args['data']['source']} killed you without a word!"
if len(cause) > ITEM_NAME_LIMIT + PLAYER_NAME_LIMIT:
cause = cause[:ITEM_NAME_LIMIT + PLAYER_NAME_LIMIT]
else:
cause = f"{args['data']['source']} killed you without a word!"
# Highlight the player that killed us in the game's orange text.
if args['data']['source'] in cause:
words = cause.split(args['data']['source'], 1)
cause = words[0] + "" + args['data']['source'] + "" + words[1]
self.death_causes += [cause]
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None or ctx.server.socket.closed or ctx.slot_data is None or ctx.slot is None:
return
try:
# Scout all Locations and get our Set events upon initial connection.
if not self.sent_initial_packets:
await ctx.send_msgs([{
"cmd": "LocationScouts",
"locations": [code for name, code in get_location_names_to_ids().items()
if code in ctx.server_locations],
"create_as_hint": 0
}])
await ctx.send_msgs([{
"cmd": "Get",
"keys": [f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"]
}])
self.sent_initial_packets = True
read_state = await bizhawk.read(ctx.bizhawk_ctx, [(GAME_STATE_ADDRESS, 1, "EWRAM"),
(FLAGS_ARRAY_START, 32, "EWRAM"),
(CARDS_ARRAY_START, 20, "EWRAM"),
(NUM_RECEIVED_ITEMS_ADDRESS, 2, "EWRAM"),
(MAX_UPS_ARRAY_START, 3, "EWRAM"),
(MAGIC_ITEMS_ARRAY_START, 8, "EWRAM"),
(QUEUED_TEXTBOX_1_ADDRESS, 2, "EWRAM"),
(DELAY_TIMER_ADDRESS, 2, "EWRAM"),
(CURRENT_CUTSCENE_ID_ADDRESS, 1, "EWRAM"),
(NATHAN_STATE_ADDRESS, 1, "EWRAM"),
(CURRENT_HP_ADDRESS, 18, "EWRAM"),
(CURRENT_LOCATION_VALUES_START, 2, "EWRAM")])
game_state = int.from_bytes(read_state[0], "little")
event_flags_array = read_state[1]
cards_array = list(read_state[2])
max_ups_array = list(read_state[4])
magic_items_array = list(read_state[5])
num_received_items = int.from_bytes(bytearray(read_state[3]), "little")
queued_textbox = int.from_bytes(bytearray(read_state[6]), "little")
delay_timer = int.from_bytes(bytearray(read_state[7]), "little")
cutscene = int.from_bytes(bytearray(read_state[8]), "little")
nathan_state = int.from_bytes(bytearray(read_state[9]), "little")
health_stats_array = bytearray(read_state[10])
area = int.from_bytes(bytearray(read_state[11][0:1]), "little")
room = int.from_bytes(bytearray(read_state[11][1:]), "little")
# Get out each of the individual health/magic/heart values.
hp = int.from_bytes(health_stats_array[0:2], "little")
max_hp = int.from_bytes(health_stats_array[4:6], "little")
# mp = int.from_bytes(health_stats_array[8:10], "little") Not used. But it's here if it's ever needed!
max_mp = int.from_bytes(health_stats_array[12:14], "little")
hearts = int.from_bytes(health_stats_array[14:16], "little")
max_hearts = int.from_bytes(health_stats_array[16:], "little")
# If there's no textbox already queued, the delay timer is 0, we are not in a cutscene, and Nathan's current
# state value is not 0x34 (using a save room), it should be safe to inject a textbox message.
ok_to_inject = not queued_textbox and not delay_timer and not cutscene \
and nathan_state != NATHAN_STATE_SAVING
# Make sure we are in the Gameplay or Credits states before detecting sent locations.
# If we are in any other state, such as the Game Over state, reset the textbox buffers back to 0 so that we
# don't receive the most recent item upon loading back in.
#
# If the intro cutscene floor broken flag is not set, then assume we are in the demo; at no point during
# regular gameplay will this flag not be set.
if game_state not in [GAME_STATE_GAMEPLAY, GAME_STATE_CREDITS] or not event_flags_array[6] & 0x02:
self.currently_dead = False
await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, [0 for _ in range(12)], "EWRAM")])
return
# Enable DeathLink if it's in our slot_data.
if "DeathLink" not in ctx.tags and ctx.slot_data["death_link"]:
await ctx.update_death_link(True)
# Send a DeathLink if we died on our own independently of receiving another one.
if "DeathLink" in ctx.tags and hp == 0 and not self.currently_dead:
self.currently_dead = True
# Check if we are in Dracula II's arena. The game considers this part of the Sealed Room area,
# which I don't think makes sense to be player-facing like this.
if area == AREA_SEALED_ROOM and room == 2:
area_of_death = "Dracula's realm"
# If we aren't in Dracula II's arena, then take the name of whatever area the player is currently in.
else:
area_of_death = DEATHLINK_AREA_NAMES[area]
await ctx.send_death(f"{ctx.player_names[ctx.slot]} perished in {area_of_death}. Dracula has won!")
# Update the Dracula II and Battle Arena events already being done on past separate sessions for if the
# player is running the Battle Arena and Dracula goal.
if f"castlevania_cotm_events_{ctx.team}_{ctx.slot}" in ctx.stored_data:
if ctx.stored_data[f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] is not None:
if ctx.stored_data[f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] & 0x2:
self.won_battle_arena = True
if ctx.stored_data[f"castlevania_cotm_events_{ctx.team}_{ctx.slot}"] & 0x800:
self.killed_dracula_2 = True
# If we won the Battle Arena, haven't seen the win message yet, and are in the Arena at the moment, pop up
# the win message while playing the game's unused Theme of Simon Belmont fanfare.
if self.won_battle_arena and not self.saw_arena_win_message and area == AREA_BATTLE_ARENA \
and ok_to_inject and not self.currently_dead:
win_message = cvcotm_string_to_bytearray(" A 「WINNER」 IS 「YOU」!▶", "little middle", 0,
wrap=False)
await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, TEXT_ID_MULTIWORLD_MESSAGE, "EWRAM"),
(QUEUED_SOUND_ID_ADDRESS, SOUND_ID_UNUSED_SIMON_FANFARE, "EWRAM"),
(QUEUED_TEXT_STRING_START, win_message, "ROM")])
self.saw_arena_win_message = True
# If we have any queued death causes, handle DeathLink giving here.
elif self.death_causes and ok_to_inject and not self.currently_dead:
# Inject the oldest cause as a textbox message and play the Dracula charge attack sound.
death_text = self.death_causes[0]
death_writes = [(QUEUED_TEXTBOX_1_ADDRESS, TEXT_ID_MULTIWORLD_MESSAGE, "EWRAM"),
(QUEUED_SOUND_ID_ADDRESS, SOUND_ID_DRACULA_CHARGE, "EWRAM")]
# If we are in the Battle Arena and are not using the On Including Arena DeathLink option, extend the
# DeathLink message and don't actually kill Nathan.
if ctx.slot_data["death_link"] != CVCotMDeathLink.option_arena_on and area == AREA_BATTLE_ARENA:
death_text += "◊The Battle Arena nullified the DeathLink. Go fight fair and square!"
else:
# Otherwise, kill Nathan by giving him a 9999 damage-dealing poison status that hurts him as soon as
# the death cause textbox is dismissed.
death_writes += [(CURRENT_STATUS_ADDRESS, STATUS_POISON, "EWRAM"),
(POISON_TIMER_TILL_DAMAGE_ADDRESS, b"\x38", "EWRAM"),
(POISON_DAMAGE_VALUE_ADDRESS, b"\x0F\x27", "EWRAM")]
# Add the final death text and write the whole shebang.
death_writes += [(QUEUED_TEXT_STRING_START,
bytes(cvcotm_string_to_bytearray(death_text + "", "big middle", 0)), "ROM")]
await bizhawk.write(ctx.bizhawk_ctx, death_writes)
# Delete the oldest death cause that we just wrote and set currently_dead to True so the client doesn't
# think we just died on our own on the subsequent frames before the Game Over state.
del(self.death_causes[0])
self.currently_dead = True
# If we have a queue of Locations to inject "sent" messages with, do so before giving any subsequent Items.
elif self.sent_message_queue and ok_to_inject and not self.currently_dead and ctx.locations_info:
loc = self.sent_message_queue[0]
# Truncate the Item name. ArchipIDLE's FFXIV Item is 214 characters, for comparison.
item_name = ctx.item_names.lookup_in_slot(ctx.locations_info[loc].item, ctx.locations_info[loc].player)
if len(item_name) > ITEM_NAME_LIMIT:
item_name = item_name[:ITEM_NAME_LIMIT]
# Truncate the player name. Player names are normally capped at 16 characters, but there is no limit on
# ItemLink group names.
player_name = ctx.player_names[ctx.locations_info[loc].player]
if len(player_name) > PLAYER_NAME_LIMIT:
player_name = player_name[:PLAYER_NAME_LIMIT]
sent_text = cvcotm_string_to_bytearray(f"{item_name}」 sent to 「{player_name}」◊", "big middle", 0)
# Set the correct sound to play depending on the Item's classification.
if item_name == iname.ironmaidens and \
ctx.slot_info[ctx.locations_info[loc].player].game == "Castlevania - Circle of the Moon":
mssg_sfx_id = SOUND_ID_MAIDEN_BREAKING
sent_text = cvcotm_string_to_bytearray(f"「Iron Maidens」 broken for 「{player_name}」◊",
"big middle", 0)
elif ctx.locations_info[loc].flags & MAJORS_CLASSIFICATIONS:
mssg_sfx_id = SOUND_ID_MAJOR_PICKUP
elif ctx.locations_info[loc].flags & ItemClassification.trap:
mssg_sfx_id = SOUND_ID_BAD_CONFIG
else: # Filler
mssg_sfx_id = SOUND_ID_MINOR_PICKUP
await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, TEXT_ID_MULTIWORLD_MESSAGE, "EWRAM"),
(QUEUED_SOUND_ID_ADDRESS, mssg_sfx_id, "EWRAM"),
(QUEUED_TEXT_STRING_START, sent_text, "ROM")])
del(self.sent_message_queue[0])
# If the game hasn't received all items yet, it's ok to inject, and the current number of received items
# still matches what we read before, then write the next incoming item into the inventory and, separately,
# the textbox ID to trigger the multiworld textbox, sound effect to play when the textbox opens, number to
# increment the received items count by, and the text to go into the multiworld textbox. The game will then
# do the rest when it's able to.
elif num_received_items < len(ctx.items_received) and ok_to_inject and not self.currently_dead:
next_item = ctx.items_received[num_received_items]
# Figure out what inventory array and offset from said array to increment based on what we are
# receiving.
flag_index = 0
flag_array = b""
inv_array = []
inv_array_start = 0
text_id_2 = b"\x00\x00"
item_type = next_item.item & 0xFF00
inv_array_index = next_item.item & 0xFF
if item_type == 0xE600: # Card
inv_array_start = CARDS_ARRAY_START
inv_array = cards_array
mssg_sfx_id = SOUND_ID_MAJOR_PICKUP
# If skip_tutorials is off and the saw DSS tutorial flag is not set, set the flag and display it
# for the second textbox.
if not self.saw_dss_tutorial and not ctx.slot_data["skip_tutorials"]:
flag_index = FLAG_SAW_DSS_TUTORIAL
flag_array = event_flags_array
text_id_2 = TEXT_ID_DSS_TUTORIAL
elif item_type == 0xE800 and inv_array_index == 0x09: # Maiden Detonator
flag_index = FLAG_HIT_IRON_MAIDEN_SWITCH
flag_array = event_flags_array
mssg_sfx_id = SOUND_ID_MAIDEN_BREAKING
elif item_type == 0xE800: # Any other Magic Item
inv_array_start = MAGIC_ITEMS_ARRAY_START
inv_array = magic_items_array
mssg_sfx_id = SOUND_ID_MAJOR_PICKUP
if inv_array_index > 5: # The unused Map's index is skipped over.
inv_array_index -= 1
else: # Max Up
inv_array_start = MAX_UPS_ARRAY_START
mssg_sfx_id = SOUND_ID_MINOR_PICKUP
inv_array = max_ups_array
item_name = ctx.item_names.lookup_in_slot(next_item.item)
player_name = ctx.player_names[next_item.player]
# Truncate the player name.
if len(player_name) > PLAYER_NAME_LIMIT:
player_name = player_name[:PLAYER_NAME_LIMIT]
# If the Item came from a different player, display a custom received message. Otherwise, display the
# vanilla received message for that Item.
if next_item.player != ctx.slot:
text_id_1 = TEXT_ID_MULTIWORLD_MESSAGE
if item_name == iname.ironmaidens:
received_text = cvcotm_string_to_bytearray(f"「Iron Maidens」 broken by "
f"{player_name}」◊", "big middle", 0)
else:
received_text = cvcotm_string_to_bytearray(f"{item_name}」 received from "
f"{player_name}」◊", "big middle", 0)
text_write = [(QUEUED_TEXT_STRING_START, bytes(received_text), "ROM")]
# If skip_tutorials is off, display the Item's tutorial for the second textbox (if it has one).
if not ctx.slot_data["skip_tutorials"] and cvcotm_item_info[item_name].tutorial_id is not None:
text_id_2 = cvcotm_item_info[item_name].tutorial_id
else:
text_id_1 = cvcotm_item_info[item_name].text_id
text_write = []
# Check if the player has 255 of the item being received. If they do, don't increment that counter
# further.
refill_write = []
count_write = []
flag_write = []
count_guard = []
flag_guard = []
# If there's a value to increment in an inventory array, do so here after checking to see if we can.
if inv_array_start:
if inv_array[inv_array_index] + 1 > 0xFF:
# If it's a stat max up being received, manually give a refill of that item's stat.
# Normally, the game does this automatically by incrementing the number of that max up.
if item_name == iname.hp_max:
refill_write = [(CURRENT_HP_ADDRESS, int.to_bytes(max_hp, 2, "little"), "EWRAM")]
elif item_name == iname.mp_max:
refill_write = [(CURRENT_MP_ADDRESS, int.to_bytes(max_mp, 2, "little"), "EWRAM")]
elif item_name == iname.heart_max:
# If adding +6 Hearts doesn't put us over the player's current max Hearts, do so.
# Otherwise, set the player's current Hearts to the current max.
if hearts + 6 > max_hearts:
new_hearts = max_hearts
else:
new_hearts = hearts + 6
refill_write = [(CURRENT_HEARTS_ADDRESS, int.to_bytes(new_hearts, 2, "little"), "EWRAM")]
else:
# If our received count of that item is not more than 255, increment it normally.
inv_address = inv_array_start + inv_array_index
count_guard = [(inv_address, int.to_bytes(inv_array[inv_array_index], 1, "little"), "EWRAM")]
count_write = [(inv_address, int.to_bytes(inv_array[inv_array_index] + 1, 1, "little"),
"EWRAM")]
# If there's a flag value to set, do so here.
if flag_index:
flag_bytearray_index = flag_index // 8
flag_address = FLAGS_ARRAY_START + flag_bytearray_index
flag_guard = [(flag_address, int.to_bytes(flag_array[flag_bytearray_index], 1, "little"), "EWRAM")]
flag_write = [(flag_address, int.to_bytes(flag_array[flag_bytearray_index] |
(0x01 << (flag_index % 8)), 1, "little"), "EWRAM")]
await bizhawk.guarded_write(ctx.bizhawk_ctx,
[(QUEUED_TEXTBOX_1_ADDRESS, text_id_1, "EWRAM"),
(QUEUED_TEXTBOX_2_ADDRESS, text_id_2, "EWRAM"),
(QUEUED_MSG_DELAY_TIMER_ADDRESS, b"\x01", "EWRAM"),
(QUEUED_SOUND_ID_ADDRESS, mssg_sfx_id, "EWRAM")]
+ count_write + flag_write + text_write + refill_write,
# Make sure the number of received items and number to overwrite are still
# what we expect them to be.
[(NUM_RECEIVED_ITEMS_ADDRESS, read_state[3], "EWRAM")]
+ count_guard + flag_guard),
locs_to_send = set()
# Check each bit in each flag byte for set Location and event flags.
checked_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()}
for byte_index, byte in enumerate(event_flags_array):
for i in range(8):
and_value = 0x01 << i
if byte & and_value != 0:
flag_id = byte_index * 8 + i
location_id = flag_id + BASE_ID
if location_id in ctx.server_locations:
locs_to_send.add(location_id)
# If the flag for pressing the Iron Maiden switch is set, and the Iron Maiden behavior is
# vanilla (meaning we really pressed the switch), send the Iron Maiden switch as checked.
if flag_id == FLAG_HIT_IRON_MAIDEN_SWITCH and ctx.slot_data["iron_maiden_behavior"] == \
IronMaidenBehavior.option_vanilla:
locs_to_send.add(cvcotm_location_info[lname.ct21].code + BASE_ID)
# If the DSS tutorial flag is set, let the client know, so it's not shown again for
# subsequently-received cards.
if flag_id == FLAG_SAW_DSS_TUTORIAL:
self.saw_dss_tutorial = True
if flag_id in EVENT_FLAG_MAP:
checked_set_events[EVENT_FLAG_MAP[flag_id]] = True
# Update the client's statuses for the Battle Arena and Dracula goals.
if flag_id == FLAG_WON_BATTLE_ARENA:
self.won_battle_arena = True
if flag_id == FLAG_DEFEATED_DRACULA_II:
self.killed_dracula_2 = True
# Send Locations if there are any to send.
if locs_to_send != self.local_checked_locations:
self.local_checked_locations = locs_to_send
if locs_to_send is not None:
# Capture all the Locations with non-local Items to send that are in ctx.missing_locations
# (the ones that were definitely never sent before).
if ctx.locations_info:
self.sent_message_queue += [loc for loc in locs_to_send if loc in ctx.missing_locations and
ctx.locations_info[loc].player != ctx.slot]
# If we still don't have the locations info at this point, send another LocationScout packet just
# in case something went wrong, and we never received the initial LocationInfo packet.
else:
await ctx.send_msgs([{
"cmd": "LocationScouts",
"locations": [code for name, code in get_location_names_to_ids().items()
if code in ctx.server_locations],
"create_as_hint": 0
}])
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": list(locs_to_send)
}])
# Check the win condition depending on what our completion goal is.
# The Dracula option requires the "killed Dracula II" flag to be set or being in the credits state.
# The Battle Arena option requires the Shinning Armor pickup flag to be set.
# Otherwise, the Battle Arena and Dracula option requires both of the above to be satisfied simultaneously.
if ctx.slot_data["completion_goal"] == CompletionGoal.option_dracula:
win_condition = self.killed_dracula_2
elif ctx.slot_data["completion_goal"] == CompletionGoal.option_battle_arena:
win_condition = self.won_battle_arena
else:
win_condition = self.killed_dracula_2 and self.won_battle_arena
# Send game clear if we've satisfied the win condition.
if not ctx.finished_game and win_condition:
ctx.finished_game = True
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL
}])
# Update the tracker event flags
if checked_set_events != self.client_set_events and ctx.slot is not None:
event_bitfield = 0
for index, (flag, flag_name) in enumerate(EVENT_FLAG_MAP.items()):
if checked_set_events[flag_name]:
event_bitfield |= 1 << index
await ctx.send_msgs([{
"cmd": "Set",
"key": f"castlevania_cotm_events_{ctx.team}_{ctx.slot}",
"default": 0,
"want_reply": False,
"operations": [{"operation": "or", "value": event_bitfield}],
}])
self.client_set_events = checked_set_events
except bizhawk.RequestFailedError:
# Exit handler and return to main loop to reconnect.
pass

View File

@@ -0,0 +1,178 @@
from typing import Literal
cvcotm_char_dict = {"\n": 0x09, " ": 0x26, "!": 0x4A, '"': 0x78, "#": 0x79, "$": 0x7B, "%": 0x68, "&": 0x73, "'": 0x51,
"(": 0x54, ")": 0x55, "*": 0x7A, "+": 0x50, ",": 0x4C, "-": 0x58, ".": 0x35, "/": 0x70, "0": 0x64,
"1": 0x6A, "2": 0x63, "3": 0x6C, "4": 0x71, "5": 0x69, "6": 0x7C, "7": 0x7D, "8": 0x72, "9": 0x85,
":": 0x86, ";": 0x87, "<": 0x8F, "=": 0x90, ">": 0x91, "?": 0x48, "@": 0x98, "A": 0x3E, "B": 0x4D,
"C": 0x44, "D": 0x45, "E": 0x4E, "F": 0x56, "G": 0x4F, "H": 0x40, "I": 0x43, "J": 0x6B, "K": 0x66,
"L": 0x5F, "M": 0x42, "N": 0x52, "O": 0x67, "P": 0x4B, "Q": 0x99, "R": 0x46, "S": 0x41, "T": 0x47,
"U": 0x60, "V": 0x6E, "W": 0x49, "X": 0x6D, "Y": 0x53, "Z": 0x6F, "[": 0x59, "\\": 0x9A, "]": 0x5A,
"^": 0x9B, "_": 0xA1, "a": 0x29, "b": 0x3C, "c": 0x33, "d": 0x32, "e": 0x28, "f": 0x3A, "g": 0x39,
"h": 0x31, "i": 0x2D, "j": 0x62, "k": 0x3D, "l": 0x30, "m": 0x36, "n": 0x2E, "o": 0x2B, "p": 0x38,
"q": 0x61, "r": 0x2C, "s": 0x2F, "t": 0x2A, "u": 0x34, "v": 0x3F, "w": 0x37, "x": 0x57, "y": 0x3B,
"z": 0x65, "{": 0xA3, "|": 0xA4, "}": 0xA5, "`": 0xA2, "~": 0xAC,
# Special command characters
"": 0x02, # Press A with prompt arrow.
"": 0x03, # Press A without prompt arrow.
"\t": 0x01, # Clear the text buffer; usually after pressing A to advance.
"\b": 0x0A, # Reset text alignment; usually after pressing A.
"": 0x06, # Start orange text
"": 0x07, # End orange text
}
# Characters that do not contribute to the line length.
weightless_chars = {"\n", "", "", "\b", "\t", "", ""}
def cvcotm_string_to_bytearray(cvcotm_text: str, textbox_type: Literal["big top", "big middle", "little middle"],
speed: int, portrait: int = 0xFF, wrap: bool = True,
skip_textbox_controllers: bool = False) -> bytearray:
"""Converts a string into a textbox bytearray following CVCotM's string format."""
text_bytes = bytearray(0)
if portrait == 0xFF and textbox_type != "little middle":
text_bytes.append(0x0C) # Insert the character to convert a 3-line named textbox into a 4-line nameless one.
# Figure out the start and end params for the textbox based on what type it is.
if textbox_type == "little middle":
main_control_start_param = 0x10
main_control_end_param = 0x20
elif textbox_type == "big top":
main_control_start_param = 0x40
main_control_end_param = 0xC0
else:
main_control_start_param = 0x80
main_control_end_param = 0xC0
# Figure out the number of lines and line length limit.
if textbox_type == "little middle":
total_lines = 1
len_limit = 29
elif textbox_type != "little middle" and portrait != 0xFF:
total_lines = 3
len_limit = 21
else:
total_lines = 4
len_limit = 23
# Wrap the text if we are opting to do so.
if wrap:
refined_text = cvcotm_text_wrap(cvcotm_text, len_limit, total_lines)
else:
refined_text = cvcotm_text
# Add the textbox control characters if we are opting to add them.
if not skip_textbox_controllers:
text_bytes.extend([0x1D, main_control_start_param + (speed & 0xF)]) # Speed should be a value between 0 and 15.
# Add the portrait (if we are adding one).
if portrait != 0xFF and textbox_type != "little middle":
text_bytes.extend([0x1E, portrait & 0xFF])
for i, char in enumerate(refined_text):
if char in cvcotm_char_dict:
text_bytes.extend([cvcotm_char_dict[char]])
# If we're pressing A to advance, add the text clear and reset alignment characters.
if char in ["", ""] and not skip_textbox_controllers:
text_bytes.extend([0x01, 0x0A])
else:
text_bytes.extend([0x48])
# Add the characters indicating the end of the whole message.
if not skip_textbox_controllers:
text_bytes.extend([0x1D, main_control_end_param, 0x00])
else:
text_bytes.extend([0x00])
return text_bytes
def cvcotm_text_truncate(cvcotm_text: str, textbox_len_limit: int) -> str:
"""Truncates a string at a given in-game text line length."""
line_len = 0
for i in range(len(cvcotm_text)):
if cvcotm_text[i] not in weightless_chars:
line_len += 1
if line_len > textbox_len_limit:
return cvcotm_text[0x00:i]
return cvcotm_text
def cvcotm_text_wrap(cvcotm_text: str, textbox_len_limit: int, total_lines: int = 4) -> str:
"""Rebuilds a string with some of its spaces replaced with newlines to ensure the text wraps properly in an in-game
textbox of a given length. If the number of lines allowed per textbox is exceeded, an A prompt will be placed
instead of a newline."""
words = cvcotm_text.split(" ")
new_text = ""
line_len = 0
num_lines = 1
for word_index, word in enumerate(words):
# Reset the word length to 0 on every word iteration and make its default divider a space.
word_len = 0
word_divider = " "
# Check if we're at the very beginning of a line and handle the situation accordingly by increasing the current
# line length to account for the space if we are not. Otherwise, the word divider should be nothing.
if line_len != 0:
line_len += 1
else:
word_divider = ""
new_word = ""
for char_index, char in enumerate(word):
# Check if the current character contributes to the line length.
if char not in weightless_chars:
line_len += 1
word_len += 1
# If we're looking at a manually-placed newline, add +1 to the lines counter and reset the length counters.
if char == "\n":
word_len = 0
line_len = 0
num_lines += 1
# If this puts us over the line limit, insert the A advance prompt character.
if num_lines > total_lines:
num_lines = 1
new_word += ""
# If we're looking at a manually-placed A advance prompt, reset the lines and length counters.
if char in ["", ""]:
word_len = 0
line_len = 0
num_lines = 1
# If the word alone is long enough to exceed the line length, wrap without moving the entire word.
if word_len > textbox_len_limit:
word_len = 1
line_len = 1
num_lines += 1
word_splitter = "\n"
# If this puts us over the line limit, replace the newline with the A advance prompt character.
if num_lines > total_lines:
num_lines = 1
word_splitter = ""
new_word += word_splitter
# If the total length of the current line exceeds the line length, wrap the current word to the next line.
if line_len > textbox_len_limit:
word_divider = "\n"
line_len = word_len
num_lines += 1
# If we're over the allowed number of lines to be displayed in the textbox, insert the A advance
# character instead.
if num_lines > total_lines:
num_lines = 1
word_divider = ""
# Add the character to the new word if the character is not a newline immediately following up an A advance.
if char != "\n" or new_word[len(new_word)-1] not in ["", ""]:
new_word += char
new_text += word_divider + new_word
return new_text

View File

@@ -0,0 +1,36 @@
double = "Double"
tackle = "Tackle"
kick_boots = "Kick Boots"
heavy_ring = "Heavy Ring"
cleansing = "Cleansing"
roc_wing = "Roc Wing"
last_key = "Last Key"
ironmaidens = "Maiden Detonator"
heart_max = "Heart Max Up"
mp_max = "MP Max Up"
hp_max = "HP Max Up"
salamander = "Salamander Card"
serpent = "Serpent Card"
mandragora = "Mandragora Card"
golem = "Golem Card"
cockatrice = "Cockatrice Card"
manticore = "Manticore Card"
griffin = "Griffin Card"
thunderbird = "Thunderbird Card"
unicorn = "Unicorn Card"
black_dog = "Black Dog Card"
mercury = "Mercury Card"
venus = "Venus Card"
jupiter = "Jupiter Card"
mars = "Mars Card"
diana = "Diana Card"
apollo = "Apollo Card"
neptune = "Neptune Card"
saturn = "Saturn Card"
uranus = "Uranus Card"
pluto = "Pluto Card"
dracula = "The Count Downed"
shinning_armor = "Where's My Super Suit?"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

128
worlds/cvcotm/data/lname.py Normal file
View File

@@ -0,0 +1,128 @@
sr3 = "Sealed Room: Main shaft left fake wall"
cc1 = "Catacomb: Push crate treasure room"
cc3 = "Catacomb: Fleamen brain room - Lower"
cc3b = "Catacomb: Fleamen brain room - Upper"
cc4 = "Catacomb: Earth Demon dash room"
cc5 = "Catacomb: Tackle block treasure room"
cc8 = "Catacomb: Earth Demon bone pit - Lower"
cc8b = "Catacomb: Earth Demon bone pit - Upper"
cc9 = "Catacomb: Below right column save room"
cc10 = "Catacomb: Right column fake wall"
cc13 = "Catacomb: Right column Spirit room"
cc14 = "Catacomb: Muddy Mudman platforms room - Lower"
cc14b = "Catacomb: Muddy Mudman platforms room - Upper"
cc16 = "Catacomb: Slide space zone"
cc20 = "Catacomb: Pre-Cerberus lone Skeleton room"
cc22 = "Catacomb: Pre-Cerberus Hopper treasure room"
cc24 = "Catacomb: Behind Cerberus"
cc25 = "Catacomb: Mummies' fake wall"
as2 = "Abyss Staircase: Lower fake wall"
as3 = "Abyss Staircase: Loopback drop"
as4 = "Abyss Staircase: Roc ledge"
as9 = "Abyss Staircase: Upper fake wall"
ar4 = "Audience Room: Skeleton foyer fake wall"
ar7 = "Audience Room: Main gallery fake wall"
ar8 = "Audience Room: Below coyote jump"
ar9 = "Audience Room: Push crate gallery"
ar10 = "Audience Room: Past coyote jump"
ar11 = "Audience Room: Tackle block gallery"
ar14 = "Audience Room: Wicked roc chamber - Lower"
ar14b = "Audience Room: Wicked roc chamber - Upper"
ar16 = "Audience Room: Upper Devil Tower hallway"
ar17 = "Audience Room: Right exterior - Lower"
ar17b = "Audience Room: Right exterior - Upper"
ar18 = "Audience Room: Right exterior fake wall"
ar19 = "Audience Room: 100 meter skelly dash hallway"
ar21 = "Audience Room: Lower Devil Tower hallway fake wall"
ar25 = "Audience Room: Behind Necromancer"
ar26 = "Audience Room: Below Machine Tower roc ledge"
ar27 = "Audience Room: Below Machine Tower push crate room"
ar30 = "Audience Room: Roc horse jaguar armory - Left"
ar30b = "Audience Room: Roc horse jaguar armory - Right"
ow0 = "Outer Wall: Left roc ledge"
ow1 = "Outer Wall: Right-brained ledge"
ow2 = "Outer Wall: Fake Nightmare floor"
th1 = "Triumph Hallway: Skeleton slopes fake wall"
th3 = "Triumph Hallway: Entrance Flame Armor climb"
mt0 = "Machine Tower: Foxy platforms ledge"
mt2 = "Machine Tower: Knight fox meeting room"
mt3 = "Machine Tower: Boneheaded argument wall kicks room"
mt4 = "Machine Tower: Foxy fake wall"
mt6 = "Machine Tower: Skelly-rang wall kicks room"
mt8 = "Machine Tower: Fake Lilim wall"
mt10 = "Machine Tower: Thunderous zone fake wall"
mt11 = "Machine Tower: Thunderous zone lone Stone Armor room"
mt13 = "Machine Tower: Top lone Stone Armor room"
mt14 = "Machine Tower: Stone fox hallway"
mt17 = "Machine Tower: Pre-Iron Golem fake wall"
mt19 = "Machine Tower: Behind Iron Golem"
ec5 = "Eternal Corridor: Midway fake wall"
ec7 = "Eternal Corridor: Skelly-rang wall kicks room"
ec9 = "Eternal Corridor: Skelly-rang fake wall"
ct1 = "Chapel Tower: Flame Armor climb room"
ct4 = "Chapel Tower: Lower chapel push crate room"
ct5 = "Chapel Tower: Lower chapel fake wall"
ct6 = "Chapel Tower: Beastly wall kicks room - Brain side"
ct6b = "Chapel Tower: Beastly wall kicks room - Brawn side"
ct8 = "Chapel Tower: Middle chapel fake wall"
ct10 = "Chapel Tower: Middle chapel push crate room"
ct13 = "Chapel Tower: Sharp mind climb room"
ct15 = "Chapel Tower: Upper chapel fake wall"
ct16 = "Chapel Tower: Upper chapel Marionette wall kicks"
ct18 = "Chapel Tower: Upper belfry fake wall"
ct21 = "Chapel Tower: Iron maiden switch"
ct22 = "Chapel Tower: Behind Adramelech iron maiden"
ct26 = "Chapel Tower: Outside Battle Arena - Upper"
ct26b = "Chapel Tower: Outside Battle Arena - Lower"
ug0 = "Underground Gallery: Conveyor platform ride"
ug1 = "Underground Gallery: Conveyor upper push crate room"
ug2 = "Underground Gallery: Conveyor lower push crate room"
ug3 = "Underground Gallery: Harpy climb room - Lower"
ug3b = "Underground Gallery: Harpy climb room - Upper"
ug8 = "Underground Gallery: Harpy mantis tackle hallway"
ug10 = "Underground Gallery: Handy bee hallway"
ug13 = "Underground Gallery: Myconid fake wall"
ug15 = "Underground Gallery: Crumble bridge fake wall"
ug20 = "Underground Gallery: Behind Dragon Zombies"
uw1 = "Underground Warehouse: Entrance push crate room"
uw6 = "Underground Warehouse: Forever pushing room"
uw8 = "Underground Warehouse: Crate-nudge fox room"
uw9 = "Underground Warehouse: Crate-nudge fake wall"
uw10 = "Underground Warehouse: Succubus shaft roc ledge"
uw11 = "Underground Warehouse: Fake Lilith wall"
uw14 = "Underground Warehouse: Optional puzzle ceiling fake wall"
uw16 = "Underground Warehouse: Holy fox hideout - Left"
uw16b = "Underground Warehouse: Holy fox hideout - Right roc ledge"
uw19 = "Underground Warehouse: Forest Armor's domain fake wall"
uw23 = "Underground Warehouse: Behind Death"
uw24 = "Underground Warehouse: Behind Death fake wall"
uw25 = "Underground Warehouse: Dryad expulsion chamber"
uy1 = "Underground Waterway: Entrance fake wall"
uy3 = "Underground Waterway: Before illusory wall"
uy3b = "Underground Waterway: Beyond illusory wall"
uy4 = "Underground Waterway: Ice Armor's domain fake wall"
uy5 = "Underground Waterway: Brain freeze room"
uy7 = "Underground Waterway: Middle lone Ice Armor room"
uy8 = "Underground Waterway: Roc fake ceiling"
uy9 = "Underground Waterway: Wicked Fishhead moat - Bottom"
uy9b = "Underground Waterway: Wicked Fishhead moat - Top"
uy12 = "Underground Waterway: Lizard-man turf - Bottom"
uy12b = "Underground Waterway: Lizard-man turf - Top"
uy13 = "Underground Waterway: Roc exit shaft"
uy17 = "Underground Waterway: Behind Camilla"
uy18 = "Underground Waterway: Roc exit shaft fake wall"
ot1 = "Observation Tower: Wind Armor rampart"
ot2 = "Observation Tower: Legion plaza fake wall"
ot3 = "Observation Tower: Legion plaza Minotaur hallway"
ot5 = "Observation Tower: Siren balcony fake wall"
ot8 = "Observation Tower: Evil Pillar pit fake wall"
ot9 = "Observation Tower: Alraune garden"
ot12 = "Observation Tower: Dark Armor's domain fake wall"
ot13 = "Observation Tower: Catoblepeas hallway"
ot16 = "Observation Tower: Near warp room fake wall"
ot20 = "Observation Tower: Behind Hugh"
cr1 = "Ceremonial Room: Fake floor"
ba24 = "Battle Arena: End reward"
arena_victory = "Arena Victory"
dracula = "Dracula"

View File

@@ -0,0 +1,431 @@
remote_textbox_shower = [
# Pops up the textbox(s) of whatever textbox IDs is written at 0x02025300 and 0x02025302 and increments the current
# received item index at 0x020253D0 if a number to increment it by is written at 0x02025304. Also plays the sound
# effect of the ID written at 0x02025306, if one is written there. This will NOT give any items on its own; the item
# has to be written by the client into the inventory alongside writing the above-mentioned things.
# Make sure we didn't hit the lucky one frame before room transitioning wherein Nathan is on top of the room
# transition tile.
0x0C, 0x88, # ldrh r4, [r1]
0x80, 0x20, # movs r0, #0x80
0x20, 0x40, # ands r0, r4
0x00, 0x28, # cmp r0, #0
0x2F, 0xD1, # bne 0x87FFF8A
0x11, 0xB4, # push r0, r4
# Check the cutscene value to make sure we are not in a cutscene; forcing a textbox while there's already another
# textbox on-screen messes things up.
0x1E, 0x4A, # ldr r2, =0x2026000
0x13, 0x78, # ldrb r3, [r2]
0x00, 0x2B, # cmp r0, #0
0x29, 0xD1, # bne 0x87FFF88
# Check our "delay" timer buffer for a non-zero. If it is, decrement it by one and skip straight to the return part
# of this code, as we may have received an item on a frame wherein it's "unsafe" to pop the item textbox.
0x16, 0x4A, # ldr r2, =0x2025300
0x13, 0x89, # ldrh r3, [r2, #8]
0x00, 0x2B, # cmp r0, #0
0x02, 0xD0, # beq 0x87FFF42
0x01, 0x3B, # subs r3, #1
0x13, 0x81, # strh r3, [r2, #8]
0x22, 0xE0, # beq 0x87FFF88
# Check our first custom "textbox ID" buffers for a non-zero number.
0x10, 0x88, # ldrh r0, [r2]
0x00, 0x28, # cmp r0, #0
0x12, 0xD0, # beq 0x87FFF6E
# Increase the "received item index" by the specified number in our "item index amount to increase" buffer.
0x93, 0x88, # ldrh r3, [r2, #4]
0xD0, 0x32, # adds r2, #0xD0
0x11, 0x88, # ldrh r1, [r2]
0xC9, 0x18, # adds r1, r1, r3
0x11, 0x80, # strh r1, [r2]
# Check our second custom "textbox ID" buffers for a non-zero number.
0xD0, 0x3A, # subs r2, #0xD0
0x51, 0x88, # ldrh r1, [r2, #2]
0x00, 0x29, # cmp r1, #0
0x01, 0xD0, # beq 0x87FFF5E
# If we have a second textbox ID, run the "display two textboxes" function.
# Otherwise, run the "display one textbox" function.
0x0E, 0x4A, # ldr r2, =0x805F104
0x00, 0xE0, # b 0x87FFF60
0x0E, 0x4A, # ldr r2, =0x805F0C8
0x7B, 0x46, # mov r3, r15
0x05, 0x33, # adds r3, #5
0x9E, 0x46, # mov r14, r3
0x97, 0x46, # mov r15, r2
0x09, 0x48, # ldr r0, =0x2025300
0x02, 0x21, # movs r1, #2
0x01, 0x81, # strh r1, [r0, #8]
# Check our "sound effect ID" buffer and run the "play sound" function if it's a non-zero number.
0x08, 0x48, # ldr r0, =0x2025300
0xC0, 0x88, # ldrh r0, [r0, #6]
0x00, 0x28, # cmp r0, #0
0x04, 0xD0, # beq 0x87FFF7E
0x0B, 0x4A, # ldr r2, =0x8005E80
0x7B, 0x46, # mov r3, r15
0x05, 0x33, # adds r3, #5
0x9E, 0x46, # mov r14, r3
0x97, 0x46, # mov r15, r2
# Clear all our buffers and return to the "check for Nathan being in a room transition" function we've hooked into.
0x03, 0x48, # ldr r0, =0x2025300
0x00, 0x21, # movs r1, #0
0x01, 0x60, # str r1, [r0]
0x41, 0x60, # str r1, [r0, #4]
0x11, 0xBC, # pop r0, r4
0x04, 0x4A, # ldr r2, =0x8007D68
0x00, 0x28, # cmp r0, #0
0x97, 0x46, # mov r15, r2
# LDR number pool
0x00, 0x53, 0x02, 0x02,
0x04, 0xF1, 0x05, 0x08,
0xC8, 0xF0, 0x05, 0x08,
0x68, 0x7D, 0x00, 0x08,
0x90, 0x1E, 0x02, 0x02,
0x80, 0x5E, 0x00, 0x08,
0x00, 0x60, 0x02, 0x02
]
transition_textbox_delayer = [
# Sets the remote item textbox delay timer whenever the player screen transitions to ensure the item textbox won't
# pop during said transition.
0x40, 0x78, # ldrb r0, [r0, #1]
0x28, 0x70, # strb r0, [r5]
0xF8, 0x6D, # ldr r0, [r7, #0x5C]
0x20, 0x18, # adds r0, r4, r0
0x02, 0x4A, # ldr r2, =0x2025300
0x10, 0x23, # movs r3, #0x10
0x13, 0x80, # strh r3, [r2]
0x02, 0x4A, # ldr r2, =0x806CE1C
0x97, 0x46, # mov r15, r2
0x00, 0x00,
# LDR number pool
0x08, 0x53, 0x02, 0x02,
0x1C, 0xCE, 0x06, 0x08,
]
magic_item_sfx_customizer = [
# Enables a different sound to be played depending on which Magic Item was picked up. The array starting at 086797C0
# contains each 2-byte sound ID for each Magic Item. Putting 0000 for a sound will cause no sound to play; this is
# currently used for the dummy AP Items as their sound is played by the "sent" textbox instead.
0x70, 0x68, # ldr r0, [r6, #4]
0x80, 0x79, # ldrb r0, [r0, #6]
0x40, 0x00, # lsl r0, r0, 1
0x07, 0x49, # ldr r1, =0x86797C0
0x08, 0x5A, # ldrh r0, [r1, r0]
0x00, 0x28, # cmp r0, 0
0x04, 0xD0, # beq 0x8679818
0x03, 0x4A, # ldr r2, =0x8005E80
0x7B, 0x46, # mov r3, r15
0x05, 0x33, # adds r3, #5
0x9E, 0x46, # mov r14, r3
0x97, 0x46, # mov r15, r2
0x01, 0x48, # ldr r0, =0x8095BEC
0x87, 0x46, # mov r15, r0
# LDR number pool
0x80, 0x5E, 0x00, 0x08,
0xEC, 0x5B, 0x09, 0x08,
0xC0, 0x97, 0x67, 0x08,
]
start_inventory_giver = [
# This replaces AutoDashBoots.ips from standalone CotMR by allowing the player to start with any set of items, not
# just the Dash Boots. If playing Magician Mode, they will be given all cards that were not put into the starting
# inventory right after this code runs.
# Magic Items
0x13, 0x48, # ldr r0, =0x202572F
0x14, 0x49, # ldr r1, =0x8680080
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x08, 0x2A, # cmp r2, #8
0xFA, 0xDB, # blt 0x8680006
# Max Ups
0x11, 0x48, # ldr r0, =0x202572C
0x12, 0x49, # ldr r1, =0x8680090
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x03, 0x2A, # cmp r2, #3
0xFA, 0xDB, # blt 0x8680016
# Cards
0x0F, 0x48, # ldr r0, =0x2025674
0x10, 0x49, # ldr r1, =0x86800A0
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x14, 0x2A, # cmp r2, #0x14
0xFA, 0xDB, # blt 0x8680026
# Inventory Items (not currently supported)
0x0D, 0x48, # ldr r0, =0x20256ED
0x0E, 0x49, # ldr r1, =0x86800C0
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x36, 0x2A, # cmp r2, #36
0xFA, 0xDB, # blt 0x8680036
# Return to the function that checks for Magician Mode.
0xBA, 0x21, # movs r1, #0xBA
0x89, 0x00, # lsls r1, r1, #2
0x70, 0x18, # adds r0, r6, r1
0x04, 0x70, # strb r4, [r0]
0x00, 0x4A, # ldr r2, =0x8007F78
0x97, 0x46, # mov r15, r2
# LDR number pool
0x78, 0x7F, 0x00, 0x08,
0x2F, 0x57, 0x02, 0x02,
0x80, 0x00, 0x68, 0x08,
0x2C, 0x57, 0x02, 0x02,
0x90, 0x00, 0x68, 0x08,
0x74, 0x56, 0x02, 0x02,
0xA0, 0x00, 0x68, 0x08,
0xED, 0x56, 0x02, 0x02,
0xC0, 0x00, 0x68, 0x08,
]
max_max_up_checker = [
# Whenever the player picks up a Max Up, this will check to see if they currently have 255 of that particular Max Up
# and only increment the number further if they don't. This is necessary for extreme Item Link seeds, as going over
# 255 of any Max Up will reset the counter to 0.
0x08, 0x78, # ldrb r0, [r1]
0xFF, 0x28, # cmp r0, 0xFF
0x17, 0xD1, # bne 0x86A0036
# If it's an HP Max, refill our HP.
0xFF, 0x23, # mov r3, #0xFF
0x0B, 0x40, # and r3, r1
0x2D, 0x2B, # cmp r3, 0x2D
0x03, 0xD1, # bne 0x86A0016
0x0D, 0x4A, # ldr r2, =0x202562E
0x93, 0x88, # ldrh r3, [r2, #4]
0x13, 0x80, # strh r3, [r2]
0x11, 0xE0, # b 0x86A003A
# If it's an MP Max, refill our MP.
0x2E, 0x2B, # cmp r3, 0x2E
0x03, 0xD1, # bne 0x86A0022
0x0B, 0x4A, # ldr r2, =0x2025636
0x93, 0x88, # ldrh r3, [r2, #4]
0x13, 0x80, # strh r3, [r2]
0x0B, 0xE0, # b 0x86A003A
# Else, meaning it's a Hearts Max, add +6 Hearts. If adding +6 Hearts would put us over our current max, set our
# current amount to said current max instead.
0x0A, 0x4A, # ldr r2, =0x202563C
0x13, 0x88, # ldrh r3, [r2]
0x06, 0x33, # add r3, #6
0x51, 0x88, # ldrh r1, [r2, #2]
0x8B, 0x42, # cmp r3, r1
0x00, 0xDB, # blt 0x86A0030
0x0B, 0x1C, # add r3, r1, #0
0x13, 0x80, # strh r3, [r2]
0x02, 0xE0, # b 0x86A003A
0x00, 0x00,
# Increment the Max Up count like normal. Should only get here if the Max Up count was determined to be less than
# 255, branching past if not the case.
0x01, 0x30, # adds r0, #1
0x08, 0x70, # strb r0, [r1]
# Return to the function that gives Max Ups normally.
0x05, 0x48, # ldr r0, =0x1B3
0x00, 0x4A, # ldr r2, =0x805E170
0x97, 0x46, # mov r15, r2
# LDR number pool
0x78, 0xE1, 0x05, 0x08,
0x2E, 0x56, 0x02, 0x02,
0x36, 0x56, 0x02, 0x02,
0x3C, 0x56, 0x02, 0x02,
0xB3, 0x01, 0x00, 0x00,
]
maiden_detonator = [
# Detonates the iron maidens upon picking up the Maiden Detonator item by setting the "broke iron maidens" flag.
0x2A, 0x20, # mov r0, #0x2A
0x03, 0x4A, # ldr r2, =0x8007E24
0x7B, 0x46, # mov r3, r15
0x05, 0x33, # adds r3, #5
0x9E, 0x46, # mov r14, r3
0x97, 0x46, # mov r15, r2
0x01, 0x4A, # ldr r2, =0x8095BE4
0x97, 0x46, # mov r15, r2
# LDR number pool
0x24, 0x7E, 0x00, 0x08,
0xE4, 0x5B, 0x09, 0x08,
]
doubleless_roc_midairs_preventer = [
# Prevents being able to Roc jump in midair without the Double. Will pass if the jump counter is 0 or if Double is
# in the inventory.
# Check for Roc Wing in the inventory normally.
0x58, 0x18, # add r0, r3, r1
0x00, 0x78, # ldrb r0, [r0]
0x00, 0x28, # cmp r0, 0
0x11, 0xD0, # beq 0x8679A2C
# Check the "jumps since last on the ground" counter. Is it 0?
# If so, then we are on the ground and can advance to the Kick Boots question. If not, advance to the Double check.
0x0B, 0x48, # ldr r0, =0x2000080
0x01, 0x78, # ldrb r1, [r0]
0x00, 0x29, # cmp r1, 0
0x03, 0xD0, # beq 0x8679A18
# Check for Double in the inventory. Is it there?
# If not, then it's not time to Roc! Otherwise, advance to the next check.
0x0A, 0x4A, # ldr r2, =0x202572F
0x52, 0x78, # ldrb r2, [r2, 1]
0x00, 0x2A, # cmp r2, 0
0x09, 0xD0, # beq 0x8679A2C
# Check for Kick Boots in the inventory. Are they there?
# If they are, then we can definitely Roc! If they aren't, however, then on to the next question...
0x08, 0x4A, # ldr r2, =0x202572F
0xD2, 0x78, # ldrb r2, [r2, 3]
0x00, 0x2A, # cmp r2, 0
0x03, 0xD1, # bne 0x8679A28
# Is our "jumps since last on the ground" counter 2?
# If it is, then we already Double jumped and should not Roc jump as well.
# Should always pass if we came here from the "on the ground" 0 check.
0x02, 0x29, # cmp r1, 2
0x03, 0xD0, # beq 0x8679A2C
# If we did not Double jump yet, then set the above-mentioned counter to 2, and now we can finally Roc on!
0x02, 0x21, # mov r1, 2
0x01, 0x70, # strb r1, [r0]
# Go to the "Roc jump" code.
0x01, 0x4A, # ldr r2, =0x806B8A8
0x97, 0x46, # mov r15, r2
# Go to the "don't Roc jump" code.
0x01, 0x4A, # ldr r2, =0x806B93C
0x97, 0x46, # mov r15, r2
# LDR number pool
0xA8, 0xB8, 0x06, 0x08,
0x3C, 0xB9, 0x06, 0x08,
0x80, 0x00, 0x00, 0x02,
0x2F, 0x57, 0x02, 0x02,
]
kickless_roc_height_shortener = [
# Shortens the amount of time spent rising with Roc Wing if the player doesn't have Kick Boots.
0x06, 0x49, # ldr r1, =0x202572F
0xC9, 0x78, # ldrb r1, [r1, 3]
0x00, 0x29, # cmp r1, 0
0x00, 0xD1, # bne 0x8679A6A
0x10, 0x20, # mov r0, 0x12
0xA8, 0x65, # str r0, [r5, 0x58]
# Go back to the Roc jump code.
0x00, 0x24, # mov r4, 0
0x2C, 0x64, # str r4, [r5, 0x40]
0x03, 0x49, # ldr r1, =0x80E03A0
0x01, 0x4A, # ldr r2, =0x806B8BC
0x97, 0x46, # mov r15, r2
0x00, 0x00,
# LDR number pool
0xBC, 0xB8, 0x06, 0x08,
0x2F, 0x57, 0x02, 0x02,
0xA0, 0x03, 0x0E, 0x08
]
missing_char_data = {
# The pixel data for all ASCII characters missing from the game's dialogue textbox font.
# Each character consists of 8 bytes, with each byte representing one row of pixels in the character. The bytes are
# arranged from top to bottom row going from left to right.
# Each bit within each byte represents the following pixels within that row:
# 8- = -+------
# 4- = +-------
# 2- = ---+----
# 1- = --+-----
# -8 = -----+--
# -4 = ----+---
# -2 = -------+
# -1 = ------+-
0x396C54: [0x00, 0x9C, 0x9C, 0x18, 0x84, 0x00, 0x00, 0x00], # "
0x396C5C: [0x00, 0x18, 0xBD, 0x18, 0x18, 0x18, 0xBD, 0x18], # #
0x396C64: [0x00, 0x0C, 0x2D, 0x0C, 0x21, 0x00, 0x00, 0x00], # *
0x396C6C: [0x00, 0x20, 0x3C, 0xA0, 0x34, 0x28, 0xB4, 0x20], # $
0x396C74: [0x00, 0x34, 0x88, 0x80, 0xB4, 0x88, 0x88, 0x34], # 6
0x396C7C: [0x00, 0xBC, 0x88, 0x04, 0x04, 0x20, 0x20, 0x20], # 7
0x396CBC: [0x00, 0x34, 0x88, 0x88, 0x3C, 0x08, 0x88, 0x34], # 9
0x396CC4: [0x00, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0xC0, 0xC0], # :
0x396CCC: [0x00, 0xC0, 0xC0, 0x00, 0xC0, 0xC0, 0x80, 0x40], # ;
0x396D0C: [0x00, 0x00, 0x09, 0x24, 0x90, 0x24, 0x09, 0x00], # <
0x396D14: [0x00, 0x00, 0xFD, 0x00, 0x00, 0x00, 0xFD, 0x00], # =
0x396D1C: [0x00, 0x00, 0xC0, 0x30, 0x0C, 0x30, 0xC0, 0x00], # >
0x396D54: [0x00, 0x34, 0x88, 0xAC, 0xA8, 0xAC, 0x80, 0x34], # @
0x396D5C: [0x00, 0x34, 0x88, 0x88, 0xA8, 0x8C, 0x88, 0x35], # Q
0x396D64: [0x00, 0x40, 0x80, 0x10, 0x20, 0x04, 0x08, 0x01], # \
0x396D6C: [0x00, 0x20, 0x14, 0x88, 0x00, 0x00, 0x00, 0x00], # ^
0x396D9C: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFD], # _
0x396DA4: [0x00, 0x90, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00], # `
0x396DAC: [0x00, 0x08, 0x04, 0x04, 0x20, 0x04, 0x04, 0x08], # {
0x396DB4: [0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20], # |
0x396DBC: [0x00, 0x80, 0x10, 0x10, 0x20, 0x10, 0x10, 0x80], # }
0x396DF4: [0x00, 0x00, 0x00, 0x90, 0x61, 0x0C, 0x00, 0x00], # ~
}
extra_item_sprites = [
# The VRAM data for all the extra item sprites, including the Archipelago Items.
# NOTE: The Archipelago logo is © 2022 by Krista Corkos and Christopher Wilson
# and licensed under Attribution-NonCommercial 4.0 International.
# See LICENSES.txt at the root of this apworld's directory for more licensing information.
# Maiden Detonator
0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x10, 0xCC, 0x00, 0x00, 0xC1, 0xBB, 0x00, 0x10, 0x1C, 0xB8,
0x00, 0x10, 0x1C, 0xB1, 0x00, 0x10, 0xBC, 0xBB, 0x00, 0x00, 0x11, 0x11, 0x00, 0x10, 0xCC, 0xBB,
0x11, 0x00, 0x00, 0x00, 0xCC, 0x01, 0x00, 0x00, 0xBB, 0x1C, 0x00, 0x00, 0x8B, 0xC1, 0x01, 0x00,
0x1B, 0xC1, 0x01, 0x00, 0xBB, 0xCB, 0x01, 0x00, 0x11, 0x11, 0x00, 0x00, 0xBB, 0xCC, 0x01, 0x00,
0x00, 0x10, 0x11, 0x11, 0x00, 0xC1, 0xBC, 0x1B, 0x00, 0x10, 0x11, 0x11, 0x00, 0xC1, 0xBC, 0x1B,
0x00, 0x10, 0x11, 0x11, 0x00, 0xC1, 0xBC, 0x1B, 0x00, 0xC1, 0xBC, 0x1B, 0x00, 0x10, 0x11, 0x01,
0x11, 0x11, 0x01, 0x00, 0xB1, 0xCB, 0x1C, 0x00, 0x11, 0x11, 0x01, 0x00, 0xB1, 0xCB, 0x1C, 0x00,
0x11, 0x11, 0x01, 0x00, 0xB1, 0xCB, 0x1C, 0x00, 0xB1, 0xCB, 0x1C, 0x00, 0x10, 0x11, 0x01, 0x00,
# Archipelago Filler
0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88,
0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00,
0x02, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x82, 0x22, 0x02, 0x00, 0x28, 0xCC, 0x2C, 0x00,
0xC2, 0xCC, 0xC2, 0x02, 0xC2, 0xCC, 0xCC, 0x02, 0xC2, 0x22, 0xC2, 0x02, 0x20, 0xFF, 0x2F, 0x00,
0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77,
0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22,
0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x72, 0xF2, 0x2F, 0x00,
0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
# Archipelago Useful
0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88,
0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00,
0x02, 0xAA, 0x0A, 0x00, 0x28, 0x9A, 0x0A, 0x00, 0xAA, 0x9A, 0xAA, 0x0A, 0x9A, 0x99, 0x99, 0x0A,
0xAA, 0x9A, 0xAA, 0x0A, 0xC2, 0x9A, 0xCA, 0x02, 0xC2, 0xAA, 0xCA, 0x02, 0x20, 0xFF, 0x2F, 0x00,
0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77,
0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22,
0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x72, 0xF2, 0x2F, 0x00,
0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
# Archipelago Progression
0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88,
0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00,
0x02, 0x10, 0x00, 0x00, 0x28, 0x71, 0x01, 0x00, 0x12, 0x77, 0x17, 0x00, 0x71, 0x77, 0x77, 0x01,
0x11, 0x77, 0x17, 0x01, 0x12, 0x77, 0x17, 0x02, 0x12, 0x11, 0x11, 0x02, 0x20, 0xFF, 0x2F, 0x00,
0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77,
0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22,
0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x72, 0xF2, 0x2F, 0x00,
0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
# Archipelago Trap
0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x82, 0x20, 0x66, 0x26, 0x88,
0x62, 0x62, 0x66, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00,
0x02, 0x10, 0x00, 0x00, 0x28, 0x71, 0x01, 0x00, 0x18, 0x77, 0x17, 0x00, 0x71, 0x77, 0x77, 0x01,
0x11, 0x77, 0x17, 0x01, 0x12, 0x77, 0x17, 0x02, 0x12, 0x11, 0x11, 0x02, 0x20, 0xFF, 0x2F, 0x00,
0xA2, 0xA2, 0xAA, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x72,
0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22,
0xF2, 0xF2, 0xFF, 0x02, 0xF2, 0xFF, 0xFF, 0x02, 0x27, 0xFF, 0xFF, 0x02, 0x77, 0xF2, 0x2F, 0x00,
0x77, 0x22, 0x02, 0x00, 0x77, 0x02, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
# Archipelago Progression + Useful
0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x20, 0x88, 0x00, 0x22, 0x82, 0x88, 0x20, 0x66, 0x26, 0x88,
0x62, 0x66, 0x62, 0x82, 0x62, 0x66, 0x66, 0x82, 0x62, 0x22, 0x62, 0x22, 0x20, 0xAA, 0x2A, 0x00,
0x02, 0x10, 0x00, 0x00, 0x28, 0x71, 0x01, 0x00, 0x12, 0x77, 0x17, 0x00, 0x71, 0x77, 0x77, 0x01,
0x11, 0x77, 0x17, 0x01, 0x12, 0x77, 0x17, 0x02, 0x12, 0x11, 0x11, 0x02, 0x20, 0xFF, 0x2F, 0x00,
0xA2, 0xAA, 0xA2, 0x02, 0xA2, 0xAA, 0xAA, 0x22, 0xA2, 0xAA, 0x2A, 0x77, 0x20, 0xAA, 0x72, 0x77,
0x00, 0x22, 0x72, 0x77, 0x00, 0x00, 0x72, 0x77, 0x00, 0x00, 0x20, 0x77, 0x00, 0x00, 0x00, 0x22,
0xF2, 0xFF, 0xF2, 0x02, 0xF2, 0xAA, 0xFA, 0x02, 0x27, 0x9A, 0xFA, 0x02, 0xAA, 0x9A, 0xAA, 0x0A,
0x9A, 0x99, 0x99, 0x0A, 0xAA, 0x9A, 0xAA, 0x0A, 0x27, 0x9A, 0x0A, 0x00, 0x02, 0xAA, 0x0A, 0x00,
# Hourglass (Specifically used to represent Max Sand from Timespinner)
0x00, 0x00, 0xFF, 0xFF, 0x00, 0xF0, 0xEE, 0xCC, 0x00, 0xF0, 0x43, 0x42, 0x00, 0xF0, 0x12, 0x11,
0x00, 0x00, 0x1F, 0x11, 0x00, 0x00, 0x2F, 0x88, 0x00, 0x00, 0xF0, 0x82, 0x00, 0x00, 0x00, 0x1F,
0xFF, 0xFF, 0x00, 0x00, 0xCC, 0xEE, 0x0F, 0x00, 0x42, 0x34, 0x0F, 0x00, 0x11, 0x21, 0x0F, 0x00,
0x11, 0xF1, 0x00, 0x00, 0x98, 0xF2, 0x00, 0x00, 0x29, 0x0F, 0x00, 0x00, 0xF9, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0xF0, 0x81, 0x00, 0x00, 0x2F, 0x81, 0x00, 0x00, 0x1F, 0x88,
0x00, 0xF0, 0x12, 0xA9, 0x00, 0xF0, 0x43, 0x24, 0x00, 0xF0, 0xEE, 0xCC, 0x00, 0x00, 0xFF, 0xFF,
0xF9, 0x00, 0x00, 0x00, 0x19, 0x0F, 0x00, 0x00, 0x99, 0xF2, 0x00, 0x00, 0xA9, 0xF1, 0x00, 0x00,
0xAA, 0x21, 0x0F, 0x00, 0x42, 0x34, 0x0F, 0x00, 0xCC, 0xEE, 0x0F, 0x00, 0xFF, 0xFF, 0x00, 0x00,
]

View File

@@ -0,0 +1,169 @@
# Castlevania: Circle of the Moon
## Quick Links
- [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en)
- [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options)
- [PopTracker Pack](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest)
- [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer)
- [Web version of the above randomizer](https://rando.circleofthemoon.com/)
- [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing)
This Game Page is focused more specifically on the Archipelago functionality. If you have a more general Circle of the Moon-related
question that is not answered here, try the above guide.
## What does randomization do to this game?
Almost all items that you would normally find on pedestals throughout the game have had their locations changed. In addition to
Magic Items (barring the Dash Boots which you always start with) and stat max ups, the DSS Cards have been added to the
item pool as well; you will now find these as randomized items rather than by farming them via enemy drops.
## Can I use any of the alternate modes?
Yes. All alternate modes (Magician, Fighter, Shooter, and Thief Mode) are all unlocked and usable from the start by registering
the name password shown on the Data Select screen for the mode of your choice.
If you intend to play Magician Mode, putting all of your cards in "Start Inventory from Pool" is recommended due to the fact
that it naturally starts with all cards. In Fighter Mode, unlike in the regular game, you will be able to receive and use
DSS Cards like in all other modes.
## What is the goal of Castlevania: Circle of the Moon when randomized?
Depending on what was chosen for the "Completion Goal" option, your goal may be to defeat Dracula, complete the Battle Arena, or both.
- "Dracula": Make it to the Ceremonial Room and kill Dracula's first and second forms to view the credits. The door to the
Ceremonial Room can be set to require anywhere between 0-9 Last Keys to open it.
- "Battle Arena": Survive every room in the Battle Arena and pick up the Shinning Armor <sup>sic</sup> on the pedestal at the end. To make it
easier, the "Disable Battle Arena Mp Drain" option can be enabled to make the Battle Arena not drain your MP to 0, allowing
DSS to be used. Reaching the Battle Arena in the first place requires finding the Heavy Ring and Roc Wing (as well as Double or Kick Boots
if "Nerf Roc Wing" is on).
- "Battle Arena And Dracula": Complete both of the above-mentioned objectives. The server will remember which ones (if any) were
already completed on previous sessions upon connecting.
NOTE: If "All Bosses" was chosen for the "Required Skirmishes" option, 8 Last Keys will be required, and they will be guaranteed
to be placed behind all 8 bosses (that are not Dracula). If "All Bosses And Arena" was chosen for the option, an additional
required 9th Last Key will be placed on the Shinning Armor <sup>sic</sup> pedestal at the end of the Battle Arena in addition to
the 8 that will be behind all the bosses.
If you aren't sure what goal you have, there are two in-game ways you can check:
- Pause the game, go to the Magic Item menu, and view the Dash Boots tutorial.
- Approach the door to the first Battle Arena combat room and the textbox that normally explains how the place works will tell you.
There are also two in-game ways to see how many Last Keys are in the item pool for the slot:
- Pause the game, go to the Magic Item menu, and view the Last Key tutorial.
- If you don't have any keys, touch the Ceremonial Room door before acquiring the necessary amount.
## What items and locations get shuffled?
Stat max ups, Magic Items, and DSS Cards are all randomized into the item pool, and the check locations are the pedestals
that you would normally find the first two types of items on.
The sole exception is the pedestal at the end of the Battle Arena. This location, most of the time, will always have
Shinning Armor <sup>sic</sup> unless "Required Skirmishes" is set to "All Bosses And Arena", in which case it will have a Last Key instead.
## Which items can be in another player's world?
Stat max ups, Magic Items, and DSS Cards can all be placed into another player's world.
The Dash Boots and Shinning Armor <sup>sic</sup> are not randomized in the item pool; the former you will always start with and the
latter will always be found at the end of the Battle Arena in your own world. And depending on your goal, you may or may
not be required to pick it up.
## What does another world's item look like in Castlevania: Circle of the Moon?
Items for other Circle of the Moon players will show up in your game as that item, though you won't receive it yourself upon
picking it up. Items for non-Circle of the Moon players will show up as one of four Archipelago Items depending on how its
classified:
* "Filler": Just the six spheres, nothing extra.
* "Useful": Blue plus sign in the top-right corner.
* "Progression": Orange up arrow in the top-right corner.
* "Progression" and "Useful": Orange up arrow in the top-right corner, blue plus sign in the bottom-right corner.
* "Trap": Reports from the local residents of the remote Austrian village of \[REDACTED], Styria claim that they disguise themselves
as Progression but with the important difference of \[DATA EXPUNGED]. Verification of these claims are currently pending...
Upon sending an item, a textbox announcing the item being sent and the player who it's for will show up on-screen, accompanied
by a sound depending on whether the item is filler-, progression-/useful-, or trap-classified.
## When the player receives an item, what happens?
A textbox announcing the item being received and the player who sent it will pop up on-screen, and it will be given.
Similar to the outgoing item textbox, it will be accompanied by a sound depending on the item received being filler or progression/useful.
## What are the item name groups?
When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a group
of items. Hinting for a group will choose a random item from the group that you do not currently have and hint for it. The
groups you can use for Castlevania: Circle of the Moon are as follows:
* "DSS" or "Card": Any DSS Card of either type.
* "Action" or "Action Card": Any Action Card.
* "Attribute" or "Attribute Card": Any Attribute Card.
* "Freeze": Any card that logically lets you freeze enemies to use as platforms.
* "Action Freeze": Either Action Card that logically lets you freeze enemies.
* "Attribute Freeze": Either Attribute Card that logically lets you freeze enemies.
## What are the location name groups?
In Castlevania: Circle of the Moon, every location is part of a location group under that location's area name.
So if you want to exclude all of, say, Underground Waterway from having progression, you can do so by just excluding
"Underground Waterway" as a whole.
In addition to the area location groups, the following groups also exist:
* "Breakable Secrets": All locations behind the secret breakable walls, floors, and ceilings.
* "Bosses": All the primary locations behind bosses that Last Keys normally get forced onto when bosses are required. If you want
to prioritize every boss to be guarding a progression item for someone, this is the group for you!
## How does the item drop randomization work?
There are three tiers of item drops: Low, Mid, and High. Each enemy has two item "slots" that can both drop its own item; a Common slot and a Rare one.
On Normal item randomization, "easy" enemies (below 61 HP) will only have Low-tier drops in both of their slots, bosses
and candle enemies will be guaranteed to have High drops in one or both of their slots respectively (bosses are made to
only drop one slot 100% of the time), and everything else can have a Low or Mid-tier item in its Common drop slot and a
Low, Mid, OR High-tier item in its Rare drop slot.
If Item Drop Randomization is set to Tiered, the HP threshold for enemies being considered "easy" will raise to below
144, enemies in the 144-369 HP range (inclusive) will have a Low-tier item in its Common slot and a Mid-tier item in
its rare slot, and enemies with more than 369 HP will have a Mid-tier in its Common slot and a High-tier in its Rare
slot, making them more worthwhile to go after. Candles and bosses still have Rares in all their slots, but now the guaranteed
drops that land on bosses will be exclusive to them; no other enemy in the game will have their item.
Note that the Shinning Armor <sup>sic</sup> can never be placed randomly onto a normal enemy; you can only receive it by completing the Battle Arena.
If "Required Skirmishes" is set to "All Bosses And Arena", which replaces the Shinning Armor <sup>sic</sup> on the pedestal at the end with
a Last Key, the Devil fought in the last room before the end pedestal will drop Shinning Armor <sup>sic</sup> 100% of the time upon defeat.
For more information and an exact breakdown of what items are considered which tier, see Malaert64's guide
[here](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view#heading=h.5iz6ytaji08m).
## Is it just me, or does the Countdown seem inaccurate to the number of checks in the area?
Some Countdown regions are funny because of how the developers of the game decided what rooms belong to which areas in spite of
what most players might think. For instance, the Skeleton Athlete room is actually part of the Chapel Tower area, not the Audience Room.
And the Outer Wall very notably has several rooms isolated from its "main" area, like the Were-Horse/Jaguar Armory.
See [this map](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view#heading=h.scu4u49kvcd4)
to know exactly which rooms make up which Countdown regions.
## Will the Castlevania Advance Collection and/or Wii U Virtual Console versions work?
The Castlevania Advance Collection ROM is tested and known to work. However, there are some major caveats when playing with the
Advance Collection ROM; most notably the fact that the audio does not function when played in an emulator outside the collection,
which is currently a requirement to connect to a multiworld. This happens because all audio code was stripped
from the ROM, and all sound is instead played by the collection through external means.
For this reason, it is most recommended to obtain the ROM by dumping it from an original cartridge of the game that you legally own.
Though, the Advance Collection *can* still technically be an option if you cannot do that and don't mind the lack of sound.
The Wii U Virtual Console version is currently untested. If you happen to have purchased it before the Wii U eShop shut down, you can try
dumping and playing with it. However, at the moment, we cannot guarantee that it will work well due to it being untested.
Regardless of which released ROM you intend to try playing with, the US version of the game is required.
## What are the odds of a pentabone?
The odds of skeleton Nathan throwing a big bone instead of a little one, verified by looking at the code itself, is <sup>1</sup>&frasl;<sub>8</sub>, or 12.5%.
Soooooooooo, to throw 5 big bones back-to-back...
(<sup>1</sup>&frasl;<sub>8</sub>)<sup>5</sup> = <sup>1</sup>&frasl;<sub>32768</sub>, or 0.0030517578125%. Good luck, you're gonna need it!

View File

@@ -0,0 +1,72 @@
# Castlevania: Circle of the Moon Setup Guide
## Required Software
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
- A Castlevania: Circle of the Moon ROM of the US version specifically. The Archipelago community cannot provide this.
The Castlevania Advance Collection ROM can technically be used, but it has no audio. The Wii U Virtual Console ROM is untested.
- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later.
### Configuring BizHawk
Once you have installed BizHawk, open `EmuHawk.exe` and change the following settings:
- If you're using BizHawk 2.7 or 2.8, go to `Config > Customize`. On the Advanced tab, switch the Lua Core from
`NLua+KopiLua` to `Lua+LuaInterface`, then restart EmuHawk. (If you're using BizHawk 2.9, you can skip this step.)
- Under `Config > Customize`, check the "Run in background" option to prevent disconnecting from the client while you're
tabbed out of EmuHawk.
- Open a `.gba` file in EmuHawk and go to `Config > Controllers…` to configure your inputs. If you can't click
`Controllers…`, load any `.gba` ROM first.
- Consider clearing keybinds in `Config > Hotkeys…` if you don't intend to use them. Select the keybind and press Esc to
clear it.
## Optional Software
- [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
## Generating and Patching a Game
1. Create your settings file (YAML). You can make one on the [Castlevania: Circle of the Moon options page](../../../games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options).
2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game).
This will generate an output file for you. Your patch file will have the `.apcvcotm` file extension.
3. Open `ArchipelagoLauncher.exe`.
4. Select "Open Patch" on the left side and select your patch file.
5. If this is your first time patching, you will be prompted to locate your vanilla ROM.
6. A patched `.gba` file will be created in the same place as the patch file.
7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your
BizHawk install.
If you're playing a single-player seed, and you don't care about hints, you can stop here, close the client, and load
the patched ROM in any emulator of your choice. However, for multiworlds and other Archipelago features,
continue below using BizHawk as your emulator.
## Connecting to a Server
By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just
in case you have to close and reopen a window mid-game for some reason.
1. Castlevania: Circle of the Moon uses Archipelago's BizHawk Client. If the client isn't still open from when you patched your game,
you can re-open it from the launcher.
2. Ensure EmuHawk is running the patched ROM.
3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing.
4. In the Lua Console window, go to `Script > Open Script…`.
5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`.
6. The emulator may freeze every few seconds until it manages to connect to the client. This is expected. The BizHawk
Client window should indicate that it connected and recognized Castlevania: Circle of the Moon.
7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the
top text field of the client and click Connect.
You should now be able to receive and send items. You'll need to do these steps every time you want to reconnect. It is
perfectly safe to make progress offline; everything will re-sync when you reconnect.
## Auto-Tracking
Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking.
1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Put the tracker pack into `packs/` in your PopTracker install.
3. Open PopTracker, and load the Castlevania: Circle of the Moon pack.
4. For autotracking, click on the "AP" symbol at the top.
5. Enter the Archipelago server address (the one you connected your client to), slot name, and password.

211
worlds/cvcotm/items.py Normal file
View File

@@ -0,0 +1,211 @@
import logging
from BaseClasses import Item, ItemClassification
from .data import iname
from .locations import BASE_ID
from .options import IronMaidenBehavior
from typing import TYPE_CHECKING, Dict, NamedTuple, Optional
from collections import Counter
if TYPE_CHECKING:
from . import CVCotMWorld
class CVCotMItem(Item):
game: str = "Castlevania - Circle of the Moon"
class CVCotMItemData(NamedTuple):
code: Optional[int]
text_id: Optional[bytes]
default_classification: ItemClassification
tutorial_id: Optional[bytes] = None
# "code" = The unique part of the Item's AP code attribute, as well as the value to call the in-game "prepare item
# textbox" function with to give the Item in-game. Add this + base_id to get the actual AP code.
# "text_id" = The textbox ID for the vanilla message for receiving the Item. Used when receiving an Item through the
# client that was not sent by a different player.
# "default_classification" = The AP Item Classification that gets assigned to instances of that Item in create_item
# by default, unless I deliberately override it (as is the case for the Cleansing on the
# Ignore Cleansing option).
# "tutorial_id" = The textbox ID for the item's tutorial. Used by the client if tutorials are not skipped.
cvcotm_item_info: Dict[str, CVCotMItemData] = {
iname.heart_max: CVCotMItemData(0xE400, b"\x57\x81", ItemClassification.filler),
iname.hp_max: CVCotMItemData(0xE401, b"\x55\x81", ItemClassification.filler),
iname.mp_max: CVCotMItemData(0xE402, b"\x56\x81", ItemClassification.filler),
iname.salamander: CVCotMItemData(0xE600, b"\x1E\x82", ItemClassification.useful),
iname.serpent: CVCotMItemData(0xE601, b"\x1F\x82", ItemClassification.useful |
ItemClassification.progression),
iname.mandragora: CVCotMItemData(0xE602, b"\x20\x82", ItemClassification.useful),
iname.golem: CVCotMItemData(0xE603, b"\x21\x82", ItemClassification.useful),
iname.cockatrice: CVCotMItemData(0xE604, b"\x22\x82", ItemClassification.useful |
ItemClassification.progression),
iname.manticore: CVCotMItemData(0xE605, b"\x23\x82", ItemClassification.useful),
iname.griffin: CVCotMItemData(0xE606, b"\x24\x82", ItemClassification.useful),
iname.thunderbird: CVCotMItemData(0xE607, b"\x25\x82", ItemClassification.useful),
iname.unicorn: CVCotMItemData(0xE608, b"\x26\x82", ItemClassification.useful),
iname.black_dog: CVCotMItemData(0xE609, b"\x27\x82", ItemClassification.useful),
iname.mercury: CVCotMItemData(0xE60A, b"\x28\x82", ItemClassification.useful |
ItemClassification.progression),
iname.venus: CVCotMItemData(0xE60B, b"\x29\x82", ItemClassification.useful),
iname.jupiter: CVCotMItemData(0xE60C, b"\x2A\x82", ItemClassification.useful),
iname.mars: CVCotMItemData(0xE60D, b"\x2B\x82", ItemClassification.useful |
ItemClassification.progression),
iname.diana: CVCotMItemData(0xE60E, b"\x2C\x82", ItemClassification.useful),
iname.apollo: CVCotMItemData(0xE60F, b"\x2D\x82", ItemClassification.useful),
iname.neptune: CVCotMItemData(0xE610, b"\x2E\x82", ItemClassification.useful),
iname.saturn: CVCotMItemData(0xE611, b"\x2F\x82", ItemClassification.useful),
iname.uranus: CVCotMItemData(0xE612, b"\x30\x82", ItemClassification.useful),
iname.pluto: CVCotMItemData(0xE613, b"\x31\x82", ItemClassification.useful),
# Dash Boots
iname.double: CVCotMItemData(0xE801, b"\x59\x81", ItemClassification.useful |
ItemClassification.progression, b"\xF4\x84"),
iname.tackle: CVCotMItemData(0xE802, b"\x5A\x81", ItemClassification.progression, b"\xF5\x84"),
iname.kick_boots: CVCotMItemData(0xE803, b"\x5B\x81", ItemClassification.progression, b"\xF6\x84"),
iname.heavy_ring: CVCotMItemData(0xE804, b"\x5C\x81", ItemClassification.progression, b"\xF7\x84"),
# Map
iname.cleansing: CVCotMItemData(0xE806, b"\x5D\x81", ItemClassification.progression, b"\xF8\x84"),
iname.roc_wing: CVCotMItemData(0xE807, b"\x5E\x81", ItemClassification.useful |
ItemClassification.progression, b"\xF9\x84"),
iname.last_key: CVCotMItemData(0xE808, b"\x5F\x81", ItemClassification.progression_skip_balancing,
b"\xFA\x84"),
iname.ironmaidens: CVCotMItemData(0xE809, b"\xF1\x84", ItemClassification.progression),
iname.dracula: CVCotMItemData(None, None, ItemClassification.progression),
iname.shinning_armor: CVCotMItemData(None, None, ItemClassification.progression),
}
ACTION_CARDS = {iname.mercury, iname.venus, iname.jupiter, iname.mars, iname.diana, iname.apollo, iname.neptune,
iname.saturn, iname.uranus, iname.pluto}
ATTRIBUTE_CARDS = {iname.salamander, iname.serpent, iname.mandragora, iname.golem, iname.cockatrice, iname.griffin,
iname.manticore, iname.thunderbird, iname.unicorn, iname.black_dog}
FREEZE_ACTIONS = [iname.mercury, iname.mars]
FREEZE_ATTRS = [iname.serpent, iname.cockatrice]
FILLER_ITEM_NAMES = [iname.heart_max, iname.hp_max, iname.mp_max]
MAJORS_CLASSIFICATIONS = ItemClassification.progression | ItemClassification.useful
def get_item_names_to_ids() -> Dict[str, int]:
return {name: cvcotm_item_info[name].code + BASE_ID for name in cvcotm_item_info
if cvcotm_item_info[name].code is not None}
def get_item_counts(world: "CVCotMWorld") -> Dict[ItemClassification, Dict[str, int]]:
item_counts: Dict[ItemClassification, Counter[str, int]] = {
ItemClassification.progression: Counter(),
ItemClassification.progression_skip_balancing: Counter(),
ItemClassification.useful | ItemClassification.progression: Counter(),
ItemClassification.useful: Counter(),
ItemClassification.filler: Counter(),
}
total_items = 0
# Items to be skipped over in the main Item creation loop.
excluded_items = [iname.hp_max, iname.mp_max, iname.heart_max, iname.last_key]
# If Halve DSS Cards Placed is on, determine which cards we will exclude here.
if world.options.halve_dss_cards_placed:
excluded_cards = list(ACTION_CARDS.union(ATTRIBUTE_CARDS))
has_freeze_action = False
has_freeze_attr = False
start_card_cap = 8
# Get out all cards from start_inventory_from_pool that the player isn't starting with 0 of.
start_cards = [item for item in world.options.start_inventory_from_pool.value if "Card" in item]
# Check for ice/stone cards that are in the player's starting cards. Increase the starting card capacity by 1
# for each card type satisfied.
for card in start_cards:
if card in FREEZE_ACTIONS and not has_freeze_action:
has_freeze_action = True
start_card_cap += 1
if card in FREEZE_ATTRS and not has_freeze_attr:
has_freeze_attr = True
start_card_cap += 1
# If we are over our starting card capacity, some starting cards will need to be removed...
if len(start_cards) > start_card_cap:
# Ice/stone cards will be kept no matter what. As for the others, put them in a list of possible candidates
# to remove.
kept_start_cards = []
removal_candidates = []
for card in start_cards:
if card in FREEZE_ACTIONS + FREEZE_ATTRS:
kept_start_cards.append(card)
else:
removal_candidates.append(card)
# Add a random sample of the removal candidate cards to our kept cards list.
kept_start_cards += world.random.sample(removal_candidates, start_card_cap - len(kept_start_cards))
# Make a list of the cards we are not keeping.
removed_start_cards = [card for card in removal_candidates if card not in kept_start_cards]
# Remove the cards we're not keeping from start_inventory_from_pool.
for card in removed_start_cards:
del world.options.start_inventory_from_pool.value[card]
logging.warning(f"[{world.player_name}] Too many DSS Cards in "
f"Start Inventory from Pool to satisfy the Halve DSS Cards Placed option. The following "
f"{len(removed_start_cards)} card(s) were removed: {removed_start_cards}")
start_cards = kept_start_cards
# Remove the starting cards from the excluded cards.
for card in ACTION_CARDS.union(ATTRIBUTE_CARDS):
if card in start_cards:
excluded_cards.remove(card)
# Remove a valid ice/stone action and/or attribute card if the player isn't starting with one.
if not has_freeze_action:
excluded_cards.remove(world.random.choice(FREEZE_ACTIONS))
if not has_freeze_attr:
excluded_cards.remove(world.random.choice(FREEZE_ATTRS))
# Remove 10 random cards from the exclusions.
excluded_items += world.random.sample(excluded_cards, 10)
# Exclude the Maiden Detonator from creation if the maidens start broken.
if world.options.iron_maiden_behavior == IronMaidenBehavior.option_start_broken:
excluded_items += [iname.ironmaidens]
# Add one of each Item to the pool that is not filler or progression skip balancing.
for item in cvcotm_item_info:
classification = cvcotm_item_info[item].default_classification
code = cvcotm_item_info[item].code
# Skip event Items and Items that are excluded from creation.
if code is None or item in excluded_items:
continue
# Classify the Cleansing as Useful instead of Progression if Ignore Cleansing is on.
if item == iname.cleansing and world.options.ignore_cleansing:
classification = ItemClassification.useful
# Classify the Kick Boots as Progression + Useful if Nerf Roc Wing is on.
if item == iname.kick_boots and world.options.nerf_roc_wing:
classification |= ItemClassification.useful
item_counts[classification][item] = 1
total_items += 1
# Add the total Last Keys if no skirmishes are required (meaning they're not forced anywhere).
if not world.options.required_skirmishes:
item_counts[ItemClassification.progression_skip_balancing][iname.last_key] = \
world.options.available_last_keys.value
total_items += world.options.available_last_keys.value
# Add filler items at random until the total Items = the total Locations.
while total_items < len(world.multiworld.get_unfilled_locations(world.player)):
filler_to_add = world.random.choice(FILLER_ITEM_NAMES)
item_counts[ItemClassification.filler][filler_to_add] += 1
total_items += 1
return item_counts

265
worlds/cvcotm/locations.py Normal file
View File

@@ -0,0 +1,265 @@
from BaseClasses import Location
from .data import lname, iname
from .options import CVCotMOptions, CompletionGoal, IronMaidenBehavior, RequiredSkirmishes
from typing import Dict, List, Union, Tuple, Optional, Set, NamedTuple
BASE_ID = 0xD55C0000
class CVCotMLocation(Location):
game: str = "Castlevania - Circle of the Moon"
class CVCotMLocationData(NamedTuple):
code: Union[int, str]
offset: Optional[int]
countdown: Optional[int]
type: Optional[str] = None
# code = The unique part of the Location's AP code attribute, as well as the in-game bitflag index starting from
# 0x02025374 that indicates the Location has been checked. Add this + base_id to get the actual AP code.
# If we put an Item name string here instead of an int, then it is an event Location and that Item should be
# forced on it while calling the actual code None.
# offset = The offset in the ROM to overwrite to change the Item on that Location.
# countdown = The index of the Countdown number region it contributes to.
# rule = What rule should be applied to the Location during set_rules, as defined in self.rules in the CVCotMRules class
# definition in rules.py.
# event = What event Item to place on that Location, for Locations that are events specifically.
# type = Anything special about this Location that should be considered, whether it be a boss Location, etc.
cvcotm_location_info: Dict[str, CVCotMLocationData] = {
# Sealed Room
lname.sr3: CVCotMLocationData(0x35, 0xD0310, 0),
# Catacombs
lname.cc1: CVCotMLocationData(0x37, 0xD0658, 1),
lname.cc3: CVCotMLocationData(0x43, 0xD0370, 1),
lname.cc3b: CVCotMLocationData(0x36, 0xD0364, 1),
lname.cc4: CVCotMLocationData(0xA8, 0xD0934, 1, type="magic item"),
lname.cc5: CVCotMLocationData(0x38, 0xD0DE4, 1),
lname.cc8: CVCotMLocationData(0x3A, 0xD1078, 1),
lname.cc8b: CVCotMLocationData(0x3B, 0xD1084, 1),
lname.cc9: CVCotMLocationData(0x40, 0xD0F94, 1),
lname.cc10: CVCotMLocationData(0x39, 0xD12C4, 1),
lname.cc13: CVCotMLocationData(0x41, 0xD0DA8, 1),
lname.cc14: CVCotMLocationData(0x3C, 0xD1168, 1),
lname.cc14b: CVCotMLocationData(0x3D, 0xD1174, 1),
lname.cc16: CVCotMLocationData(0x3E, 0xD0C40, 1),
lname.cc20: CVCotMLocationData(0x42, 0xD103C, 1),
lname.cc22: CVCotMLocationData(0x3F, 0xD07C0, 1),
lname.cc24: CVCotMLocationData(0xA9, 0xD1288, 1, type="boss"),
lname.cc25: CVCotMLocationData(0x44, 0xD12A0, 1),
# Abyss Staircase
lname.as2: CVCotMLocationData(0x47, 0xD181C, 2),
lname.as3: CVCotMLocationData(0x45, 0xD1774, 2),
lname.as4: CVCotMLocationData(0x46, 0xD1678, 2),
lname.as9: CVCotMLocationData(0x48, 0xD17EC, 2),
# Audience Room
lname.ar4: CVCotMLocationData(0x53, 0xD2344, 3),
lname.ar7: CVCotMLocationData(0x54, 0xD2368, 3),
lname.ar8: CVCotMLocationData(0x51, 0xD1BF4, 3),
lname.ar9: CVCotMLocationData(0x4B, 0xD1E1C, 3),
lname.ar10: CVCotMLocationData(0x4A, 0xD1DE0, 3),
lname.ar11: CVCotMLocationData(0x49, 0xD1E58, 3),
lname.ar14: CVCotMLocationData(0x4D, 0xD2158, 3),
lname.ar14b: CVCotMLocationData(0x4C, 0xD214C, 3),
lname.ar16: CVCotMLocationData(0x52, 0xD20BC, 3),
lname.ar17: CVCotMLocationData(0x50, 0xD2290, 3),
lname.ar17b: CVCotMLocationData(0x4F, 0xD2284, 3),
lname.ar18: CVCotMLocationData(0x4E, 0xD1FA8, 3),
lname.ar19: CVCotMLocationData(0x6A, 0xD44A4, 7),
lname.ar21: CVCotMLocationData(0x55, 0xD238C, 3),
lname.ar25: CVCotMLocationData(0xAA, 0xD1E04, 3, type="boss"),
lname.ar26: CVCotMLocationData(0x59, 0xD3370, 5),
lname.ar27: CVCotMLocationData(0x58, 0xD34E4, 5),
lname.ar30: CVCotMLocationData(0x99, 0xD6A24, 11),
lname.ar30b: CVCotMLocationData(0x9A, 0xD6A30, 11),
# Outer Wall
lname.ow0: CVCotMLocationData(0x97, 0xD6BEC, 11),
lname.ow1: CVCotMLocationData(0x98, 0xD6CE8, 11),
lname.ow2: CVCotMLocationData(0x9E, 0xD6DE4, 11),
# Triumph Hallway
lname.th1: CVCotMLocationData(0x57, 0xD26D4, 4),
lname.th3: CVCotMLocationData(0x56, 0xD23C8, 4),
# Machine Tower
lname.mt0: CVCotMLocationData(0x61, 0xD307C, 5),
lname.mt2: CVCotMLocationData(0x62, 0xD32A4, 5),
lname.mt3: CVCotMLocationData(0x5B, 0xD3244, 5),
lname.mt4: CVCotMLocationData(0x5A, 0xD31FC, 5),
lname.mt6: CVCotMLocationData(0x5F, 0xD2F38, 5),
lname.mt8: CVCotMLocationData(0x5E, 0xD2EC0, 5),
lname.mt10: CVCotMLocationData(0x63, 0xD3550, 5),
lname.mt11: CVCotMLocationData(0x5D, 0xD2D88, 5),
lname.mt13: CVCotMLocationData(0x5C, 0xD3580, 5),
lname.mt14: CVCotMLocationData(0x60, 0xD2A64, 5),
lname.mt17: CVCotMLocationData(0x64, 0xD3520, 5),
lname.mt19: CVCotMLocationData(0xAB, 0xD283C, 5, type="boss"),
# Eternal Corridor
lname.ec5: CVCotMLocationData(0x66, 0xD3B50, 6),
lname.ec7: CVCotMLocationData(0x65, 0xD3A90, 6),
lname.ec9: CVCotMLocationData(0x67, 0xD3B98, 6),
# Chapel Tower
lname.ct1: CVCotMLocationData(0x68, 0xD40F0, 7),
lname.ct4: CVCotMLocationData(0x69, 0xD4630, 7),
lname.ct5: CVCotMLocationData(0x72, 0xD481C, 7),
lname.ct6: CVCotMLocationData(0x6B, 0xD4294, 7),
lname.ct6b: CVCotMLocationData(0x6C, 0xD42A0, 7),
lname.ct8: CVCotMLocationData(0x6D, 0xD4330, 7),
lname.ct10: CVCotMLocationData(0x6E, 0xD415C, 7),
lname.ct13: CVCotMLocationData(0x6F, 0xD4060, 7),
lname.ct15: CVCotMLocationData(0x73, 0xD47F8, 7),
lname.ct16: CVCotMLocationData(0x70, 0xD3DA8, 7),
lname.ct18: CVCotMLocationData(0x74, 0xD47C8, 7),
lname.ct21: CVCotMLocationData(0xF0, 0xD47B0, 7, type="maiden switch"),
lname.ct22: CVCotMLocationData(0x71, 0xD3CF4, 7, type="max up boss"),
lname.ct26: CVCotMLocationData(0x9C, 0xD6ACC, 11),
lname.ct26b: CVCotMLocationData(0x9B, 0xD6AC0, 11),
# Underground Gallery
lname.ug0: CVCotMLocationData(0x82, 0xD5944, 9),
lname.ug1: CVCotMLocationData(0x83, 0xD5890, 9),
lname.ug2: CVCotMLocationData(0x81, 0xD5A1C, 9),
lname.ug3: CVCotMLocationData(0x85, 0xD56A4, 9),
lname.ug3b: CVCotMLocationData(0x84, 0xD5698, 9),
lname.ug8: CVCotMLocationData(0x86, 0xD5E30, 9),
lname.ug10: CVCotMLocationData(0x87, 0xD5F68, 9),
lname.ug13: CVCotMLocationData(0x88, 0xD5AB8, 9),
lname.ug15: CVCotMLocationData(0x89, 0xD5BD8, 9),
lname.ug20: CVCotMLocationData(0xAC, 0xD5470, 9, type="boss"),
# Underground Warehouse
lname.uw1: CVCotMLocationData(0x75, 0xD48DC, 8),
lname.uw6: CVCotMLocationData(0x76, 0xD4D20, 8),
lname.uw8: CVCotMLocationData(0x77, 0xD4BA0, 8),
lname.uw9: CVCotMLocationData(0x7E, 0xD53EC, 8),
lname.uw10: CVCotMLocationData(0x78, 0xD4C84, 8),
lname.uw11: CVCotMLocationData(0x79, 0xD4EC4, 8),
lname.uw14: CVCotMLocationData(0x7F, 0xD5410, 8),
lname.uw16: CVCotMLocationData(0x7A, 0xD5050, 8),
lname.uw16b: CVCotMLocationData(0x7B, 0xD505C, 8),
lname.uw19: CVCotMLocationData(0x7C, 0xD5344, 8),
lname.uw23: CVCotMLocationData(0xAE, 0xD53B0, 8, type="boss"),
lname.uw24: CVCotMLocationData(0x80, 0xD5434, 8),
lname.uw25: CVCotMLocationData(0x7D, 0xD4FC0, 8),
# Underground Waterway
lname.uy1: CVCotMLocationData(0x93, 0xD5F98, 10),
lname.uy3: CVCotMLocationData(0x8B, 0xD5FEC, 10),
lname.uy3b: CVCotMLocationData(0x8A, 0xD5FE0, 10),
lname.uy4: CVCotMLocationData(0x94, 0xD697C, 10),
lname.uy5: CVCotMLocationData(0x8C, 0xD6214, 10),
lname.uy7: CVCotMLocationData(0x8D, 0xD65A4, 10),
lname.uy8: CVCotMLocationData(0x95, 0xD69A0, 10),
lname.uy9: CVCotMLocationData(0x8E, 0xD640C, 10),
lname.uy9b: CVCotMLocationData(0x8F, 0xD6418, 10),
lname.uy12: CVCotMLocationData(0x90, 0xD6730, 10),
lname.uy12b: CVCotMLocationData(0x91, 0xD673C, 10),
lname.uy13: CVCotMLocationData(0x92, 0xD685C, 10),
lname.uy17: CVCotMLocationData(0xAF, 0xD6940, 10, type="boss"),
lname.uy18: CVCotMLocationData(0x96, 0xD69C4, 10),
# Observation Tower
lname.ot1: CVCotMLocationData(0x9D, 0xD6B38, 11),
lname.ot2: CVCotMLocationData(0xA4, 0xD760C, 12),
lname.ot3: CVCotMLocationData(0x9F, 0xD72E8, 12),
lname.ot5: CVCotMLocationData(0xA5, 0xD75E8, 12),
lname.ot8: CVCotMLocationData(0xA0, 0xD71EC, 12),
lname.ot9: CVCotMLocationData(0xA2, 0xD6FE8, 12),
lname.ot12: CVCotMLocationData(0xA6, 0xD75C4, 12),
lname.ot13: CVCotMLocationData(0xA3, 0xD6F64, 12),
lname.ot16: CVCotMLocationData(0xA1, 0xD751C, 12),
lname.ot20: CVCotMLocationData(0xB0, 0xD6E20, 12, type="boss"),
# Ceremonial Room
lname.cr1: CVCotMLocationData(0xA7, 0xD7690, 13),
lname.dracula: CVCotMLocationData(iname.dracula, None, None),
# Battle Arena
lname.ba24: CVCotMLocationData(0xB2, 0xD7D20, 14, type="arena"),
lname.arena_victory: CVCotMLocationData(iname.shinning_armor, None, None),
}
def get_location_names_to_ids() -> Dict[str, int]:
return {name: cvcotm_location_info[name].code+BASE_ID for name in cvcotm_location_info
if isinstance(cvcotm_location_info[name].code, int)}
def get_location_name_groups() -> Dict[str, Set[str]]:
loc_name_groups: Dict[str, Set[str]] = {"Breakable Secrets": set(),
"Bosses": set()}
for loc_name in cvcotm_location_info:
# If we are looking at an event Location, don't include it.
if isinstance(cvcotm_location_info[loc_name].code, str):
continue
# The part of the Location name's string before the colon is its area name.
area_name = loc_name.split(":")[0]
# Add each Location to its corresponding area name group.
if area_name not in loc_name_groups:
loc_name_groups[area_name] = {loc_name}
else:
loc_name_groups[area_name].add(loc_name)
# If the word "fake" is in the Location's name, add it to the "Breakable Walls" Location group.
if "fake" in loc_name.casefold():
loc_name_groups["Breakable Secrets"].add(loc_name)
# If it's a behind boss Location, add it to the "Bosses" Location group.
if cvcotm_location_info[loc_name].type in ["boss", "max up boss"]:
loc_name_groups["Bosses"].add(loc_name)
return loc_name_groups
def get_named_locations_data(locations: List[str], options: CVCotMOptions) -> \
Tuple[Dict[str, Optional[int]], Dict[str, str]]:
locations_with_ids = {}
locked_pairs = {}
locked_key_types = []
# Decide which Location types should have locked Last Keys placed on them, if skirmishes are required.
# If the Maiden Detonator is in the pool, Adramelech's key should be on the switch instead of behind the maiden.
if options.required_skirmishes:
locked_key_types += ["boss"]
if options.iron_maiden_behavior == IronMaidenBehavior.option_detonator_in_pool:
locked_key_types += ["maiden switch"]
else:
locked_key_types += ["max up boss"]
# If all bosses and the Arena is required, the Arena end reward should have a Last Key as well.
if options.required_skirmishes == RequiredSkirmishes.option_all_bosses_and_arena:
locked_key_types += ["arena"]
for loc in locations:
if loc == lname.ct21:
# If the maidens are pre-broken, don't create the iron maiden switch Location at all.
if options.iron_maiden_behavior == IronMaidenBehavior.option_start_broken:
continue
# If the maiden behavior is vanilla, lock the Maiden Detonator on this Location.
if options.iron_maiden_behavior == IronMaidenBehavior.option_vanilla:
locked_pairs[loc] = iname.ironmaidens
# Don't place the Dracula Location if our Completion Goal is the Battle Arena only.
if loc == lname.dracula and options.completion_goal == CompletionGoal.option_battle_arena:
continue
# Don't place the Battle Arena normal Location if the Arena is not required by the Skirmishes option.
if loc == lname.ba24 and options.required_skirmishes != RequiredSkirmishes.option_all_bosses_and_arena:
continue
# Don't place the Battle Arena event Location if our Completion Goal is Dracula only.
if loc == lname.arena_victory and options.completion_goal == CompletionGoal.option_dracula:
continue
loc_code = cvcotm_location_info[loc].code
# If we are looking at an event Location, add its associated event Item to the events' dict.
# Otherwise, add the base_id to the Location's code.
if isinstance(loc_code, str):
locked_pairs[loc] = loc_code
locations_with_ids.update({loc: None})
else:
loc_code += BASE_ID
locations_with_ids.update({loc: loc_code})
# Place a locked Last Key on this Location if its of a type that should have one.
if cvcotm_location_info[loc].type in locked_key_types:
locked_pairs[loc] = iname.last_key
return locations_with_ids, locked_pairs

265
worlds/cvcotm/lz10.py Normal file
View File

@@ -0,0 +1,265 @@
from collections import defaultdict
from operator import itemgetter
import struct
from typing import Union
ByteString = Union[bytes, bytearray, memoryview]
"""
Taken from the Archipelago Metroid: Zero Mission implementation by Lil David at:
https://github.com/lilDavid/Archipelago-Metroid-Zero-Mission/blob/main/lz10.py
Tweaked version of nlzss modified to work with raw data and return bytes instead of operating on whole files.
LZ11 functionality has been removed since it is not necessary for Zero Mission nor Circle of the Moon.
https://github.com/magical/nlzss
"""
def decompress(data: ByteString):
"""Decompress LZSS-compressed bytes. Returns a bytearray containing the decompressed data."""
header = data[:4]
if header[0] == 0x10:
decompress_raw = decompress_raw_lzss10
else:
raise DecompressionError("not as lzss-compressed file")
decompressed_size = int.from_bytes(header[1:], "little")
data = data[4:]
return decompress_raw(data, decompressed_size)
def compress(data: bytearray):
byteOut = bytearray()
# header
byteOut.extend(struct.pack("<L", (len(data) << 8) + 0x10))
# body
length = 0
for tokens in chunkit(_compress(data), 8):
flags = [type(t) is tuple for t in tokens]
byteOut.extend(struct.pack(">B", packflags(flags)))
for t in tokens:
if type(t) is tuple:
count, disp = t
count -= 3
disp = (-disp) - 1
assert 0 <= disp < 4096
sh = (count << 12) | disp
byteOut.extend(struct.pack(">H", sh))
else:
byteOut.extend(struct.pack(">B", t))
length += 1
length += sum(2 if f else 1 for f in flags)
# padding
padding = 4 - (length % 4 or 4)
if padding:
byteOut.extend(b'\xff' * padding)
return byteOut
class SlidingWindow:
# The size of the sliding window
size = 4096
# The minimum displacement.
disp_min = 2
# The hard minimum — a disp less than this can't be represented in the
# compressed stream.
disp_start = 1
# The minimum length for a successful match in the window
match_min = 3
# The maximum length of a successful match, inclusive.
match_max = 3 + 0xf
def __init__(self, buf):
self.data = buf
self.hash = defaultdict(list)
self.full = False
self.start = 0
self.stop = 0
# self.index = self.disp_min - 1
self.index = 0
assert self.match_max is not None
def next(self):
if self.index < self.disp_start - 1:
self.index += 1
return
if self.full:
olditem = self.data[self.start]
assert self.hash[olditem][0] == self.start
self.hash[olditem].pop(0)
item = self.data[self.stop]
self.hash[item].append(self.stop)
self.stop += 1
self.index += 1
if self.full:
self.start += 1
else:
if self.size <= self.stop:
self.full = True
def advance(self, n=1):
"""Advance the window by n bytes"""
for _ in range(n):
self.next()
def search(self):
match_max = self.match_max
match_min = self.match_min
counts = []
indices = self.hash[self.data[self.index]]
for i in indices:
matchlen = self.match(i, self.index)
if matchlen >= match_min:
disp = self.index - i
if self.disp_min <= disp:
counts.append((matchlen, -disp))
if matchlen >= match_max:
return counts[-1]
if counts:
match = max(counts, key=itemgetter(0))
return match
return None
def match(self, start, bufstart):
size = self.index - start
if size == 0:
return 0
matchlen = 0
it = range(min(len(self.data) - bufstart, self.match_max))
for i in it:
if self.data[start + (i % size)] == self.data[bufstart + i]:
matchlen += 1
else:
break
return matchlen
def _compress(input, windowclass=SlidingWindow):
"""Generates a stream of tokens. Either a byte (int) or a tuple of (count,
displacement)."""
window = windowclass(input)
i = 0
while True:
if len(input) <= i:
break
match = window.search()
if match:
yield match
window.advance(match[0])
i += match[0]
else:
yield input[i]
window.next()
i += 1
def packflags(flags):
n = 0
for i in range(8):
n <<= 1
try:
if flags[i]:
n |= 1
except IndexError:
pass
return n
def chunkit(it, n):
buf = []
for x in it:
buf.append(x)
if n <= len(buf):
yield buf
buf = []
if buf:
yield buf
def bits(byte):
return ((byte >> 7) & 1,
(byte >> 6) & 1,
(byte >> 5) & 1,
(byte >> 4) & 1,
(byte >> 3) & 1,
(byte >> 2) & 1,
(byte >> 1) & 1,
byte & 1)
def decompress_raw_lzss10(indata, decompressed_size, _overlay=False):
"""Decompress LZSS-compressed bytes. Returns a bytearray."""
data = bytearray()
it = iter(indata)
if _overlay:
disp_extra = 3
else:
disp_extra = 1
def writebyte(b):
data.append(b)
def readbyte():
return next(it)
def readshort():
# big-endian
a = next(it)
b = next(it)
return (a << 8) | b
def copybyte():
data.append(next(it))
while len(data) < decompressed_size:
b = readbyte()
flags = bits(b)
for flag in flags:
if flag == 0:
copybyte()
elif flag == 1:
sh = readshort()
count = (sh >> 0xc) + 3
disp = (sh & 0xfff) + disp_extra
for _ in range(count):
writebyte(data[-disp])
else:
raise ValueError(flag)
if decompressed_size <= len(data):
break
if len(data) != decompressed_size:
raise DecompressionError("decompressed size does not match the expected size")
return data
class DecompressionError(ValueError):
pass

282
worlds/cvcotm/options.py Normal file
View File

@@ -0,0 +1,282 @@
from dataclasses import dataclass
from Options import OptionGroup, Choice, Range, Toggle, PerGameCommonOptions, StartInventoryPool, DeathLink
class IgnoreCleansing(Toggle):
"""
Removes the logical requirement for the Cleansing to go beyond the first Underground Waterway rooms from either of the area's sides. You may be required to brave the harmful water without it.
"""
display_name = "Ignore Cleansing"
class AutoRun(Toggle):
"""
Makes Nathan always run when pressing left or right without needing to double-tap.
"""
display_name = "Auto Run"
class DSSPatch(Toggle):
"""
Patches out being able to pause during the DSS startup animation and switch the cards in the menu to use any combos you don't currently have, as well as changing the element of a summon to one you don't currently have.
"""
display_name = "DSS Patch"
class AlwaysAllowSpeedDash(Toggle):
"""
Allows activating the speed dash combo (Pluto + Griffin) without needing the respective cards first.
"""
display_name = "Always Allow Speed Dash"
class IronMaidenBehavior(Choice):
"""
Sets how the iron maiden barriers blocking the entrances to Underground Gallery and Waterway will behave.
Vanilla: Vanilla behavior. Must press the button guarded by Adramelech to break them.
Start Broken: The maidens will be broken from the start.
Detonator In Pool: Adds a Maiden Detonator item in the pool that will detonate the maidens when found. Adramelech will guard an extra check.
"""
display_name = "Iron Maiden Behavior"
option_vanilla = 0
option_start_broken = 1
option_detonator_in_pool = 2
class RequiredLastKeys(Range):
"""
How many Last Keys are needed to open the door to the Ceremonial Room. This will lower if higher than Available Last Keys.
"""
range_start = 0
range_end = 9
default = 1
display_name = "Required Last Keys"
class AvailableLastKeys(Range):
"""
How many Last Keys are in the pool in total.
To see this in-game, select the Last Key in the Magic Item menu (when you have at least one) or touch the Ceremonial Room door.
"""
range_start = 0
range_end = 9
default = 1
display_name = "Available Last Keys"
class BuffRangedFamiliars(Toggle):
"""
Makes Familiar projectiles deal double damage to enemies.
"""
display_name = "Buff Ranged Familiars"
class BuffSubWeapons(Toggle):
"""
Increases damage dealt by sub-weapons and item crushes in Shooter and non-Shooter Modes.
"""
display_name = "Buff Sub-weapons"
class BuffShooterStrength(Toggle):
"""
Increases Nathan's strength in Shooter Mode to match his strength in Vampire Killer Mode.
"""
display_name = "Buff Shooter Strength"
class ItemDropRandomization(Choice):
"""
Randomizes what enemies drop what items as well as the drop rates for said items.
Bosses and candle enemies will be guaranteed to have high-tier items in all of their drop slots, and "easy" enemies (below 61 HP) will only drop low-tier items in all of theirs.
All other enemies will drop a low or mid-tier item in their common drop slot, and a low, mid, or high-tier item in their rare drop slot.
The common slot item has a 6-10% base chance of appearing, and the rare has a 3-6% chance.
If Tiered is chosen, all enemies below 144 (instead of 61) HP will be considered "easy", rare items that land on bosses will be exclusive to them, enemies with 144-369 HP will have a low-tier in its common slot and a mid-tier in its rare slot, and enemies with more than 369 HP will have a mid-tier in its common slot and a high-tier in its rare slot.
See the Game Page for more info.
"""
display_name = "Item Drop Randomization"
option_disabled = 0
option_normal = 1
option_tiered = 2
default = 1
class HalveDSSCardsPlaced(Toggle):
"""
Places only half of the DSS Cards in the item pool.
A valid combo that lets you freeze or petrify enemies to use as platforms will always be placed.
"""
display_name = "Halve DSS Cards Placed"
class Countdown(Choice):
"""
Displays, below and near the right side of the MP bar, the number of un-found progression/useful-marked items or the total check locations remaining in the area you are currently in.
"""
display_name = "Countdown"
option_none = 0
option_majors = 1
option_all_locations = 2
default = 0
class SubWeaponShuffle(Toggle):
"""
Randomizes which sub-weapon candles have which sub-weapons.
The total available count of each sub-weapon will be consistent with that of the vanilla game.
"""
display_name = "Sub-weapon Shuffle"
class DisableBattleArenaMPDrain(Toggle):
"""
Makes the Battle Arena not drain Nathan's MP, so that DSS combos can be used like normal.
"""
display_name = "Disable Battle Arena MP Drain"
class RequiredSkirmishes(Choice):
"""
Forces a Last Key after every boss or after every boss and the Battle Arena and forces the required Last Keys to enter the Ceremonial Room to 8 or 9 for All Bosses and All Bosses And Arena respectively.
The Available and Required Last Keys options will be overridden to the respective values.
"""
display_name = "Required Skirmishes"
option_none = 0
option_all_bosses = 1
option_all_bosses_and_arena = 2
default = 0
class EarlyEscapeItem(Choice):
"""
Ensures the chosen Catacomb escape item will be placed in a starting location within your own game, accessible with nothing.
"""
display_name = "Early Escape Item"
option_none = 0
option_double = 1
option_roc_wing = 2
option_double_or_roc_wing = 3
default = 1
class NerfRocWing(Toggle):
"""
Initially nerfs the Roc Wing by removing its ability to jump infinitely and reducing its jump height. You can power it back up to its vanilla behavior by obtaining the following:
Double: Allows one jump in midair, using your double jump.
Kick Boots: Restores its vanilla jump height.
Both: Enables infinite midair jumping.
Note that holding A while Roc jumping will cause you to rise slightly higher; this is accounted for in logic.
"""
display_name = "Nerf Roc Wing"
class PlutoGriffinAirSpeed(Toggle):
"""
Increases Nathan's air speeds with the Pluto + Griffin combo active to be the same as his ground speeds. Anything made possible with the increased air speed is out of logic.
"""
display_name = "DSS Pluto and Griffin Run Speed in Air"
class SkipDialogues(Toggle):
"""
Skips all cutscene dialogue besides the ending.
"""
display_name = "Skip Cutscene Dialogue"
class SkipTutorials(Toggle):
"""
Skips all Magic Item and DSS-related tutorial textboxes.
"""
display_name = "Skip Magic Item Tutorials"
class BattleArenaMusic(Choice):
"""
Enables any looping song from the game to play inside the Battle Arena instead of it being silent the whole time.
"""
display_name = "Battle Arena Music"
option_nothing = 0
option_requiem = 1
option_a_vision_of_dark_secrets = 2
option_inversion = 3
option_awake = 4
option_the_sinking_old_sanctuary = 5
option_clockwork = 6
option_shudder = 7
option_fate_to_despair = 8
option_aquarius = 9
option_clockwork_mansion = 10
option_big_battle = 11
option_nightmare = 12
option_vampire_killer = 13
option_illusionary_dance = 14
option_proof_of_blood = 15
option_repose_of_souls = 16
option_circle_of_the_moon = 17
default = 0
class CVCotMDeathLink(Choice):
__doc__ = (DeathLink.__doc__ +
"\n\n Received DeathLinks will not kill you in the Battle Arena unless Arena On is chosen.")
display_name = "Death Link"
option_off = 0
alias_false = 0
alias_no = 0
option_on = 1
alias_true = 1
alias_yes = 1
option_arena_on = 2
default = 0
class CompletionGoal(Choice):
"""
The goal for game completion. Can be defeating Dracula, winning in the Battle Arena, or both.
If you aren't sure which one you have while playing, select the Dash Boots in the Magic Item menu.
"""
display_name = "Completion Goal"
option_dracula = 0
option_battle_arena = 1
option_battle_arena_and_dracula = 2
default = 0
@dataclass
class CVCotMOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
completion_goal: CompletionGoal
ignore_cleansing: IgnoreCleansing
auto_run: AutoRun
dss_patch: DSSPatch
always_allow_speed_dash: AlwaysAllowSpeedDash
iron_maiden_behavior: IronMaidenBehavior
required_last_keys: RequiredLastKeys
available_last_keys: AvailableLastKeys
buff_ranged_familiars: BuffRangedFamiliars
buff_sub_weapons: BuffSubWeapons
buff_shooter_strength: BuffShooterStrength
item_drop_randomization: ItemDropRandomization
halve_dss_cards_placed: HalveDSSCardsPlaced
countdown: Countdown
sub_weapon_shuffle: SubWeaponShuffle
disable_battle_arena_mp_drain: DisableBattleArenaMPDrain
required_skirmishes: RequiredSkirmishes
pluto_griffin_air_speed: PlutoGriffinAirSpeed
skip_dialogues: SkipDialogues
skip_tutorials: SkipTutorials
nerf_roc_wing: NerfRocWing
early_escape_item: EarlyEscapeItem
battle_arena_music: BattleArenaMusic
death_link: CVCotMDeathLink
cvcotm_option_groups = [
OptionGroup("difficulty", [
BuffRangedFamiliars, BuffSubWeapons, BuffShooterStrength, ItemDropRandomization, IgnoreCleansing,
HalveDSSCardsPlaced, SubWeaponShuffle, EarlyEscapeItem, CVCotMDeathLink]),
OptionGroup("quality of life", [
AutoRun, DSSPatch, AlwaysAllowSpeedDash, PlutoGriffinAirSpeed, Countdown, DisableBattleArenaMPDrain,
SkipDialogues, SkipTutorials, BattleArenaMusic])
]

190
worlds/cvcotm/presets.py Normal file
View File

@@ -0,0 +1,190 @@
from typing import Any, Dict
from Options import Accessibility, ProgressionBalancing
from .options import IgnoreCleansing, AutoRun, DSSPatch, AlwaysAllowSpeedDash, IronMaidenBehavior, BuffRangedFamiliars,\
BuffSubWeapons, BuffShooterStrength, ItemDropRandomization, HalveDSSCardsPlaced, Countdown, SubWeaponShuffle,\
DisableBattleArenaMPDrain, RequiredSkirmishes, EarlyEscapeItem, CVCotMDeathLink, CompletionGoal, SkipDialogues,\
NerfRocWing, SkipTutorials, BattleArenaMusic, PlutoGriffinAirSpeed
all_random_options = {
"progression_balancing": "random",
"accessibility": "random",
"ignore_cleansing": "random",
"auto_run": "random",
"dss_patch": "random",
"always_allow_speed_dash": "random",
"iron_maiden_behavior": "random",
"required_last_keys": "random",
"available_last_keys": "random",
"buff_ranged_familiars": "random",
"buff_sub_weapons": "random",
"buff_shooter_strength": "random",
"item_drop_randomization": "random",
"halve_dss_cards_placed": "random",
"countdown": "random",
"sub_weapon_shuffle": "random",
"disable_battle_arena_mp_drain": "random",
"required_skirmishes": "random",
"pluto_griffin_air_speed": "random",
"skip_dialogues": "random",
"skip_tutorials": "random",
"nerf_roc_wing": "random",
"early_escape_item": "random",
"battle_arena_music": "random",
"death_link": CVCotMDeathLink.option_off,
"completion_goal": "random",
}
beginner_mode_options = {
"progression_balancing": ProgressionBalancing.default,
"accessibility": Accessibility.option_full,
"ignore_cleansing": IgnoreCleansing.option_false,
"auto_run": AutoRun.option_true,
"dss_patch": DSSPatch.option_true,
"always_allow_speed_dash": AlwaysAllowSpeedDash.option_true,
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"required_last_keys": 3,
"available_last_keys": 6,
"buff_ranged_familiars": BuffRangedFamiliars.option_true,
"buff_sub_weapons": BuffSubWeapons.option_true,
"buff_shooter_strength": BuffShooterStrength.option_true,
"item_drop_randomization": ItemDropRandomization.option_normal,
"halve_dss_cards_placed": HalveDSSCardsPlaced.option_false,
"countdown": Countdown.option_majors,
"sub_weapon_shuffle": SubWeaponShuffle.option_false,
"disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_true,
"required_skirmishes": RequiredSkirmishes.option_none,
"pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false,
"skip_dialogues": SkipDialogues.option_false,
"skip_tutorials": SkipTutorials.option_false,
"nerf_roc_wing": NerfRocWing.option_false,
"early_escape_item": EarlyEscapeItem.option_double,
"battle_arena_music": BattleArenaMusic.option_nothing,
"death_link": CVCotMDeathLink.option_off,
"completion_goal": CompletionGoal.option_dracula,
}
standard_competitive_options = {
"progression_balancing": ProgressionBalancing.default,
"accessibility": Accessibility.option_full,
"ignore_cleansing": IgnoreCleansing.option_false,
"auto_run": AutoRun.option_false,
"dss_patch": DSSPatch.option_true,
"always_allow_speed_dash": AlwaysAllowSpeedDash.option_true,
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"required_last_keys": 3,
"available_last_keys": 5,
"buff_ranged_familiars": BuffRangedFamiliars.option_true,
"buff_sub_weapons": BuffSubWeapons.option_true,
"buff_shooter_strength": BuffShooterStrength.option_false,
"item_drop_randomization": ItemDropRandomization.option_normal,
"halve_dss_cards_placed": HalveDSSCardsPlaced.option_true,
"countdown": Countdown.option_majors,
"sub_weapon_shuffle": SubWeaponShuffle.option_true,
"disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false,
"required_skirmishes": RequiredSkirmishes.option_none,
"pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false,
"skip_dialogues": SkipDialogues.option_true,
"skip_tutorials": SkipTutorials.option_true,
"nerf_roc_wing": NerfRocWing.option_false,
"early_escape_item": EarlyEscapeItem.option_double,
"battle_arena_music": BattleArenaMusic.option_nothing,
"death_link": CVCotMDeathLink.option_off,
"completion_goal": CompletionGoal.option_dracula,
}
randomania_2023_options = {
"progression_balancing": ProgressionBalancing.default,
"accessibility": Accessibility.option_full,
"ignore_cleansing": IgnoreCleansing.option_false,
"auto_run": AutoRun.option_false,
"dss_patch": DSSPatch.option_true,
"always_allow_speed_dash": AlwaysAllowSpeedDash.option_true,
"iron_maiden_behavior": IronMaidenBehavior.option_vanilla,
"required_last_keys": 3,
"available_last_keys": 5,
"buff_ranged_familiars": BuffRangedFamiliars.option_true,
"buff_sub_weapons": BuffSubWeapons.option_true,
"buff_shooter_strength": BuffShooterStrength.option_false,
"item_drop_randomization": ItemDropRandomization.option_normal,
"halve_dss_cards_placed": HalveDSSCardsPlaced.option_false,
"countdown": Countdown.option_majors,
"sub_weapon_shuffle": SubWeaponShuffle.option_true,
"disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false,
"required_skirmishes": RequiredSkirmishes.option_none,
"pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false,
"skip_dialogues": SkipDialogues.option_false,
"skip_tutorials": SkipTutorials.option_false,
"nerf_roc_wing": NerfRocWing.option_false,
"early_escape_item": EarlyEscapeItem.option_double,
"battle_arena_music": BattleArenaMusic.option_nothing,
"death_link": CVCotMDeathLink.option_off,
"completion_goal": CompletionGoal.option_dracula,
}
competitive_all_bosses_options = {
"progression_balancing": ProgressionBalancing.default,
"accessibility": Accessibility.option_full,
"ignore_cleansing": IgnoreCleansing.option_false,
"auto_run": AutoRun.option_false,
"dss_patch": DSSPatch.option_true,
"always_allow_speed_dash": AlwaysAllowSpeedDash.option_true,
"iron_maiden_behavior": IronMaidenBehavior.option_vanilla,
"required_last_keys": 8,
"available_last_keys": 8,
"buff_ranged_familiars": BuffRangedFamiliars.option_true,
"buff_sub_weapons": BuffSubWeapons.option_true,
"buff_shooter_strength": BuffShooterStrength.option_false,
"item_drop_randomization": ItemDropRandomization.option_tiered,
"halve_dss_cards_placed": HalveDSSCardsPlaced.option_true,
"countdown": Countdown.option_none,
"sub_weapon_shuffle": SubWeaponShuffle.option_true,
"disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false,
"required_skirmishes": RequiredSkirmishes.option_all_bosses,
"pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false,
"skip_dialogues": SkipDialogues.option_true,
"skip_tutorials": SkipTutorials.option_true,
"nerf_roc_wing": NerfRocWing.option_false,
"early_escape_item": EarlyEscapeItem.option_double,
"battle_arena_music": BattleArenaMusic.option_nothing,
"death_link": CVCotMDeathLink.option_off,
"completion_goal": CompletionGoal.option_dracula,
}
hardcore_mode_options = {
"progression_balancing": ProgressionBalancing.default,
"accessibility": Accessibility.option_minimal,
"ignore_cleansing": IgnoreCleansing.option_true,
"auto_run": AutoRun.option_false,
"dss_patch": DSSPatch.option_true,
"always_allow_speed_dash": AlwaysAllowSpeedDash.option_false,
"iron_maiden_behavior": IronMaidenBehavior.option_vanilla,
"required_last_keys": 9,
"available_last_keys": 9,
"buff_ranged_familiars": BuffRangedFamiliars.option_false,
"buff_sub_weapons": BuffSubWeapons.option_false,
"buff_shooter_strength": BuffShooterStrength.option_false,
"item_drop_randomization": ItemDropRandomization.option_tiered,
"halve_dss_cards_placed": HalveDSSCardsPlaced.option_true,
"countdown": Countdown.option_none,
"sub_weapon_shuffle": SubWeaponShuffle.option_true,
"disable_battle_arena_mp_drain": DisableBattleArenaMPDrain.option_false,
"required_skirmishes": RequiredSkirmishes.option_none,
"pluto_griffin_air_speed": PlutoGriffinAirSpeed.option_false,
"skip_dialogues": SkipDialogues.option_false,
"skip_tutorials": SkipTutorials.option_false,
"nerf_roc_wing": NerfRocWing.option_false,
"early_escape_item": EarlyEscapeItem.option_double,
"battle_arena_music": BattleArenaMusic.option_nothing,
"death_link": CVCotMDeathLink.option_off,
"completion_goal": CompletionGoal.option_battle_arena_and_dracula,
}
cvcotm_options_presets: Dict[str, Dict[str, Any]] = {
"All Random": all_random_options,
"Beginner Mode": beginner_mode_options,
"Standard Competitive": standard_competitive_options,
"Randomania 2023": randomania_2023_options,
"Competitive All Bosses": competitive_all_bosses_options,
"Hardcore Mode": hardcore_mode_options,
}

189
worlds/cvcotm/regions.py Normal file
View File

@@ -0,0 +1,189 @@
from .data import lname
from typing import Dict, List, Optional, TypedDict, Union
class RegionInfo(TypedDict, total=False):
locations: List[str]
entrances: Dict[str, str]
# # # KEY # # #
# "locations" = A list of the Locations to add to that Region when adding said Region.
# "entrances" = A dict of the connecting Regions to the Entrances' names to add to that Region when adding said Region.
cvcotm_region_info: Dict[str, RegionInfo] = {
"Catacomb": {"locations": [lname.sr3,
lname.cc1,
lname.cc3,
lname.cc3b,
lname.cc4,
lname.cc5,
lname.cc8,
lname.cc8b,
lname.cc9,
lname.cc10,
lname.cc13,
lname.cc14,
lname.cc14b,
lname.cc16,
lname.cc20,
lname.cc22,
lname.cc24,
lname.cc25],
"entrances": {"Abyss Stairway": "Catacomb to Stairway"}},
"Abyss Stairway": {"locations": [lname.as2,
lname.as3],
"entrances": {"Audience Room": "Stairway to Audience"}},
"Audience Room": {"locations": [lname.as4,
lname.as9,
lname.ar4,
lname.ar7,
lname.ar8,
lname.ar9,
lname.ar10,
lname.ar11,
lname.ar14,
lname.ar14b,
lname.ar16,
lname.ar17,
lname.ar17b,
lname.ar18,
lname.ar19,
lname.ar21,
lname.ar25,
lname.ar26,
lname.ar27,
lname.ar30,
lname.ar30b,
lname.ow0,
lname.ow1,
lname.ow2,
lname.th1,
lname.th3],
"entrances": {"Machine Tower Bottom": "Audience to Machine Bottom",
"Machine Tower Top": "Audience to Machine Top",
"Chapel Tower Bottom": "Audience to Chapel",
"Underground Gallery Lower": "Audience to Gallery",
"Underground Warehouse Start": "Audience to Warehouse",
"Underground Waterway Start": "Audience to Waterway",
"Observation Tower": "Audience to Observation",
"Ceremonial Room": "Ceremonial Door"}},
"Machine Tower Bottom": {"locations": [lname.mt0,
lname.mt2,
lname.mt3,
lname.mt4,
lname.mt6,
lname.mt8,
lname.mt10,
lname.mt11],
"entrances": {"Machine Tower Top": "Machine Bottom to Top"}},
"Machine Tower Top": {"locations": [lname.mt13,
lname.mt14,
lname.mt17,
lname.mt19]},
"Eternal Corridor Pit": {"locations": [lname.ec5],
"entrances": {"Underground Gallery Upper": "Corridor to Gallery",
"Chapel Tower Bottom": "Escape the Gallery Pit"}},
"Chapel Tower Bottom": {"locations": [lname.ec7,
lname.ec9,
lname.ct1,
lname.ct4,
lname.ct5,
lname.ct6,
lname.ct6b,
lname.ct8,
lname.ct10,
lname.ct13,
lname.ct15],
"entrances": {"Eternal Corridor Pit": "Into the Corridor Pit",
"Underground Waterway End": "Dip Into Waterway End",
"Chapel Tower Top": "Climb to Chapel Top"}},
"Chapel Tower Top": {"locations": [lname.ct16,
lname.ct18,
lname.ct21,
lname.ct22],
"entrances": {"Battle Arena": "Arena Passage"}},
"Battle Arena": {"locations": [lname.ct26,
lname.ct26b,
lname.ba24,
lname.arena_victory]},
"Underground Gallery Upper": {"locations": [lname.ug0,
lname.ug1,
lname.ug2,
lname.ug3,
lname.ug3b],
"entrances": {"Eternal Corridor Pit": "Gallery to Corridor",
"Underground Gallery Lower": "Gallery Upper to Lower"}},
"Underground Gallery Lower": {"locations": [lname.ug8,
lname.ug10,
lname.ug13,
lname.ug15,
lname.ug20],
"entrances": {"Underground Gallery Upper": "Gallery Lower to Upper"}},
"Underground Warehouse Start": {"locations": [lname.uw1],
"entrances": {"Underground Warehouse Main": "Into Warehouse Main"}},
"Underground Warehouse Main": {"locations": [lname.uw6,
lname.uw8,
lname.uw9,
lname.uw10,
lname.uw11,
lname.uw14,
lname.uw16,
lname.uw16b,
lname.uw19,
lname.uw23,
lname.uw24,
lname.uw25]},
"Underground Waterway Start": {"locations": [lname.uy1],
"entrances": {"Underground Waterway Main": "Into Waterway Main"}},
"Underground Waterway Main": {"locations": [lname.uy3,
lname.uy3b,
lname.uy4,
lname.uy5,
lname.uy7,
lname.uy8,
lname.uy9,
lname.uy9b,
lname.uy12],
"entrances": {"Underground Waterway End": "Onward to Waterway End"}},
"Underground Waterway End": {"locations": [lname.uy12b,
lname.uy13,
lname.uy17,
lname.uy18]},
"Observation Tower": {"locations": [lname.ot1,
lname.ot2,
lname.ot3,
lname.ot5,
lname.ot8,
lname.ot9,
lname.ot12,
lname.ot13,
lname.ot16,
lname.ot20]},
"Ceremonial Room": {"locations": [lname.cr1,
lname.dracula]},
}
def get_region_info(region: str, info: str) -> Optional[Union[List[str], Dict[str, str]]]:
return cvcotm_region_info[region].get(info, None)
def get_all_region_names() -> List[str]:
return [reg_name for reg_name in cvcotm_region_info]

600
worlds/cvcotm/rom.py Normal file
View File

@@ -0,0 +1,600 @@
import Utils
import logging
import json
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes, APPatchExtension
from typing import Dict, Optional, Collection, TYPE_CHECKING
import hashlib
import os
import pkgutil
from .data import patches
from .locations import cvcotm_location_info
from .cvcotm_text import cvcotm_string_to_bytearray
from .options import CompletionGoal, IronMaidenBehavior, RequiredSkirmishes
from .lz10 import decompress
from settings import get_settings
if TYPE_CHECKING:
from . import CVCotMWorld
CVCOTM_CT_US_HASH = "50a1089600603a94e15ecf287f8d5a1f" # Original GBA cartridge ROM
CVCOTM_AC_US_HASH = "87a1bd6577b6702f97a60fc55772ad74" # Castlevania Advance Collection ROM
CVCOTM_VC_US_HASH = "2cc38305f62b337281663bad8c901cf9" # Wii U Virtual Console ROM
# NOTE: The Wii U VC version is untested as of when this comment was written. I am only including its hash in case it
# does work. If someone who has it can confirm it does indeed work, this comment should be removed. If it doesn't, the
# hash should be removed in addition. See the Game Page for more information about supported versions.
ARCHIPELAGO_IDENTIFIER_START = 0x7FFF00
ARCHIPELAGO_IDENTIFIER = "ARCHIPELAG03"
AUTH_NUMBER_START = 0x7FFF10
QUEUED_TEXT_STRING_START = 0x7CEB00
MULTIWORLD_TEXTBOX_POINTERS_START = 0x671C10
BATTLE_ARENA_SONG_IDS = [0x01, 0x03, 0x12, 0x06, 0x08, 0x09, 0x07, 0x0A, 0x0B,
0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x13, 0x14]
class RomData:
def __init__(self, file: bytes, name: Optional[str] = None) -> None:
self.file = bytearray(file)
self.name = name
def read_byte(self, offset: int) -> int:
return self.file[offset]
def read_bytes(self, offset: int, length: int) -> bytes:
return self.file[offset:offset + length]
def write_byte(self, offset: int, value: int) -> None:
self.file[offset] = value
def write_bytes(self, offset: int, values: Collection[int]) -> None:
self.file[offset:offset + len(values)] = values
def get_bytes(self) -> bytes:
return bytes(self.file)
def apply_ips(self, filename: str) -> None:
# Try loading the IPS file.
try:
ips_file = pkgutil.get_data(__name__, "data/ips/" + filename)
except IOError:
raise Exception(f"{filename} is not present in the ips folder. If it was removed, please replace it.")
# Verify that the IPS patch is, indeed, an IPS patch.
if ips_file[0:5].decode("ascii") != "PATCH":
logging.error(filename + " does not appear to be an IPS patch...")
return
file_pos = 5
while True:
# Get the ROM offset bytes of the current record.
rom_offset = int.from_bytes(ips_file[file_pos:file_pos + 3], "big")
# If we've hit the "EOF" codeword (aka 0x454F46), stop iterating because we've reached the end of the patch.
if rom_offset == 0x454F46:
return
# Get the size bytes of the current record.
bytes_size = int.from_bytes(ips_file[file_pos + 3:file_pos + 5], "big")
if bytes_size != 0:
# Write the bytes to the ROM.
self.write_bytes(rom_offset, ips_file[file_pos + 5:file_pos + 5 + bytes_size])
# Increase our position in the IPS patch to the start of the next record.
file_pos += 5 + bytes_size
else:
# If the size is 0, we are looking at an RLE record.
# Get the size of the RLE.
rle_size = int.from_bytes(ips_file[file_pos + 5:file_pos + 7], "big")
# Get the byte to be written over and over.
rle_byte = int.from_bytes(ips_file[file_pos + 7:file_pos + 8], "big")
# Write the RLE byte to the ROM the RLE size times over.
self.write_bytes(rom_offset, [rle_byte for _ in range(rle_size)])
# Increase our position in the IPS patch to the start of the next record.
file_pos += 8
class CVCotMPatchExtensions(APPatchExtension):
game = "Castlevania - Circle of the Moon"
@staticmethod
def apply_patches(caller: APProcedurePatch, rom: bytes, options_file: str) -> bytes:
"""Applies every patch to mod the game into its rando state, both CotMR's pre-made IPS patches and some
additional byte writes. Each patch is credited to its author."""
rom_data = RomData(rom)
options = json.loads(caller.get_file(options_file).decode("utf-8"))
# Check to see if the patch was generated on a compatible APWorld version.
if "compat_identifier" not in options:
raise Exception("Incompatible patch/APWorld version. Make sure the Circle of the Moon APWorlds of both you "
"and the person who generated are matching (and preferably up-to-date).")
if options["compat_identifier"] != ARCHIPELAGO_IDENTIFIER:
raise Exception("Incompatible patch/APWorld version. Make sure the Circle of the Moon APWorlds of both you "
"and the person who generated are matching (and preferably up-to-date).")
# This patch allows placing DSS cards on pedestals, prevents them from timing out, and removes them from enemy
# drop tables. Created by DevAnj originally as a standalone hack known as Card Mode, it has been modified for
# this randomizer's purposes by stripping out additional things like drop and pedestal item replacements.
# Further modified by Liquid Cat to make placed cards set their flags upon pickup (instead of relying on whether
# the card is in the player's inventory when determining to spawn it or not), enable placing dummy DSS Cards to
# represent other players' Cards in a multiworld setting, and turn specific cards blue to visually indicate
# their status as valid ice/stone combo cards.
rom_data.apply_ips("CardUp_v3_Custom2.ips")
# This patch replaces enemy drops that included DSS cards. Created by DevAnj as part of the Card Up patch but
# modified for different replacement drops (Lowered rate, Potion instead of Meat, and no Shinning Armor change
# on Devil).
rom_data.apply_ips("NoDSSDrops.ips")
# This patch reveals card combination descriptions instead of showing "???" until the combination is used.
# Created by DevAnj.
rom_data.apply_ips("CardCombosRevealed.ips")
# In lategame, the Trick Candle and Scary Candle load in the Cerberus and Iron Golem boss rooms after defeating
# Camilla and Twin Dragon Zombies respectively. If the former bosses have not yet been cleared (i.e., we have
# sequence broken the game and returned to the earlier boss rooms to fight them), the candle enemies will cause
# the bosses to fail to load and soft lock the game. This patches the candles to appear after the early boss is
# completed instead.
# Created by DevAnj.
rom_data.apply_ips("CandleFix.ips")
# A Tackle block in Machine Tower will cause a softlock if you access the Machine Tower from the Audience Room
# using the stone tower route with Kick Boots and not Double. This is a small level edit that moves that block
# slightly, removing the potential for a softlock.
# Created by DevAnj.
rom_data.apply_ips("SoftlockBlockFix.ips")
# Normally, the MP boosting card combination is useless since it depletes more MP than it gains. This patch
# makes it consume zero MP.
# Created by DevAnj.
rom_data.apply_ips("MPComboFix.ips")
# Normally, you must clear the game with each mode to unlock subsequent modes, and complete the game at least
# once to be able to skip the introductory text crawl. This allows all game modes to be selected and the
# introduction to be skipped even without game/mode completion.
# Created by DevAnj.
rom_data.apply_ips("GameClearBypass.ips")
# This patch adds custom mapping in Underground Gallery and Underground Waterway to avoid softlocking/Kick Boots
# requirements.
# Created by DevAnj.
rom_data.apply_ips("MapEdits.ips")
# Prevents demos on the main title screen after the first one from being displayed to avoid pedestal item
# reconnaissance from the menu.
# Created by Fusecavator.
rom_data.apply_ips("DemoForceFirst.ips")
# Used internally in the item randomizer to allow setting drop rate to 10000 (100%) and actually drop the item
# 100% of the time. Normally, it is hard capped at 50% for common drops and 25% for rare drops.
# Created by Fusecavator.
rom_data.apply_ips("AllowAlwaysDrop.ips")
# Displays the seed on the pause menu. Originally created by Fusecavator and modified by Liquid Cat to display a
# 20-digit seed (which AP seeds most commonly are).
rom_data.apply_ips("SeedDisplay20Digits.ips")
# Write the seed. Upwards of 20 digits can be displayed for the seed number.
curr_seed_addr = 0x672152
total_digits = 0
while options["seed"] and total_digits < 20:
seed_digit = (options["seed"] % 10) + 0x511C
rom_data.write_bytes(curr_seed_addr, int.to_bytes(seed_digit, 2, "little"))
curr_seed_addr -= 2
total_digits += 1
options["seed"] //= 10
# Optional patch created by Fusecavator. Permanent dash effect without double tapping.
if options["auto_run"]:
rom_data.apply_ips("PermanentDash.ips")
# Optional patch created by Fusecavator. Prohibits the DSS glitch. You will not be able to update the active
# effect unless the card combination switched to is obtained. For example, if you switch to another DSS
# combination that you have not obtained during DSS startup, you will still have the effect of the original
# combination you had selected when you started the DSS activation. In addition, you will not be able to
# increase damage and/or change the element of a summon attack unless you possess the cards you swap to.
if options["dss_patch"]:
rom_data.apply_ips("DSSGlitchFix.ips")
# Optional patch created by DevAnj. Breaks the iron maidens blocking access to the Underground Waterway,
# Underground Gallery, and the room beyond the Adramelech boss room from the beginning of the game.
if options["break_iron_maidens"]:
rom_data.apply_ips("BrokenMaidens.ips")
# Optional patch created by Fusecavator. Changes game behavior to add instead of set Last Key values, and check
# for a specific value of Last Keys on the door to the Ceremonial Room, allowing multiple keys to be required to
# complete the game. Relies on the program to set required key values.
if options["required_last_keys"] != 1:
rom_data.apply_ips("MultiLastKey.ips")
rom_data.write_byte(0x96C1E, options["required_last_keys"])
rom_data.write_byte(0xDFB4, options["required_last_keys"])
rom_data.write_byte(0xCB84, options["required_last_keys"])
# Optional patch created by Fusecavator. Doubles the damage dealt by projectiles fired by ranged familiars.
if options["buff_ranged_familiars"]:
rom_data.apply_ips("BuffFamiliars.ips")
# Optional patch created by Fusecavator. Increases the base damage dealt by some sub-weapons.
# Changes below (normal multiplier on left/shooter on right):
# Original: Changed:
# Dagger: 45 / 141 ----> 100 / 141 (Non-Shooter buffed)
# Dagger crush: 32 / 45 ----> 100 / 141 (Both buffed to match non-crush values)
# Axe: 89 / 158 ----> 125 / 158 (Non-Shooter somewhat buffed)
# Axe crush: 89 / 126 ----> 125 / 158 (Both buffed to match non-crush values)
# Holy water: 63 / 100 ----> 63 / 100 (Unchanged)
# Holy water crush: 45 / 63 ----> 63 / 100 (Large buff to Shooter, non-Shooter slightly buffed)
# Cross: 110 / 173 ----> 110 / 173 (Unchanged)
# Cross crush: 100 / 141 ----> 110 / 173 (Slightly buffed to match non-crush values)
if options["buff_sub_weapons"]:
rom_data.apply_ips("BuffSubweapons.ips")
# Optional patch created by Fusecavator. Increases the Shooter gamemode base strength and strength per level to
# match Vampire Killer.
if options["buff_shooter_strength"]:
rom_data.apply_ips("ShooterStrength.ips")
# Optional patch created by Fusecavator. Allows using the Pluto + Griffin combination for the speed boost with
# or without the cards being obtained.
if options["always_allow_speed_dash"]:
rom_data.apply_ips("AllowSpeedDash.ips")
# Optional patch created by fuse. Displays a counter on the HUD showing the number of magic items and cards
# remaining in the current area. Requires a lookup table generated by the randomizer to function.
if options["countdown"]:
rom_data.apply_ips("Countdown.ips")
# This patch disables the MP drain effect in the Battle Arena.
# Created by Fusecavator.
if options["disable_battle_arena_mp_drain"]:
rom_data.apply_ips("NoMPDrain.ips")
# Patch created by Fusecavator. Makes various changes to dropped item graphics to avoid garbled Magic Items and
# allow displaying arbitrary items on pedestals. Modified by Liquid Cat for the purposes of changing the
# appearances of items regardless of what they really are, as well as allowing additional Magic Items.
rom_data.apply_ips("DropReworkMultiEdition.ips")
# Decompress the Magic Item graphics and reinsert them (decompressed) where the patch expects them.
# Doing it this way is more copyright-safe.
rom_data.write_bytes(0x678C00, decompress(rom_data.read_bytes(0x630690, 0x605))[0x300:])
# Everything past here was added by Liquid Cat.
# Makes the Pluto + Griffin speed increase apply even while in the air, instead of losing it.
if options["pluto_griffin_air_speed"]:
rom_data.apply_ips("DSSRunSpeed.ips")
# Move the item sprite info table.
rom_data.write_bytes(0x678A00, rom_data.read_bytes(0x630B98, 0x98))
# Update the ldr numbers pointing to the above item sprite table.
rom_data.write_bytes(0x95A08, [0x00, 0x8A, 0x67, 0x08])
rom_data.write_bytes(0x100380, [0x00, 0x8A, 0x67, 0x08])
# Move the magic item text ID table.
rom_data.write_bytes(0x6788B0, rom_data.read_bytes(0x100A7E, 0x48))
# Update the ldr numbers pointing to the above magic item text ID table.
rom_data.write_bytes(0x95C10, [0xB0, 0x88, 0x67, 0x08])
rom_data.write_bytes(0x95CE0, [0xB0, 0x88, 0x67, 0x08])
# Move the magic item pickup function jump table.
rom_data.write_bytes(0x678B20, rom_data.read_bytes(0x95B80, 0x24))
# Update the ldr number point to the above jump table.
rom_data.write_bytes(0x95B7C, [0x20, 0x8B, 0x67, 0x08])
rom_data.write_byte(0x95B6A, 0x09) # Raise the magic item function index limit.
# Make the Maiden Detonator detonate the maidens when picked up.
rom_data.write_bytes(0x678B44, [0x90, 0x1F, 0x67, 0x08])
rom_data.write_bytes(0x671F90, patches.maiden_detonator)
# Add the text for detonating the maidens.
rom_data.write_bytes(0x671C0C, [0xC0, 0x1F, 0x67, 0x08])
rom_data.write_bytes(0x671FC0, cvcotm_string_to_bytearray(" 「Iron Maidens」 broken◊", "little middle", 0,
wrap=False))
# Put the new text string IDs for all our new items.
rom_data.write_bytes(0x6788F8, [0xF1, 0x84, 0xF1, 0x84, 0xF1, 0x84, 0xF1, 0x84,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
# Have the game get the entry in that table to use by adding the item's parameter.
rom_data.write_bytes(0x95980, [0x0A, 0x30, 0x00, 0x00, 0x00, 0x00])
# Add the AP Item sprites and their associated info.
rom_data.write_bytes(0x679080, patches.extra_item_sprites)
rom_data.write_bytes(0x678A98, [0xF8, 0xFF, 0xF8, 0xFF, 0xFC, 0x21, 0x45, 0x00,
0xF8, 0xFF, 0xF8, 0xFF, 0x00, 0x22, 0x45, 0x00,
0xF8, 0xFF, 0xF8, 0xFF, 0x04, 0x22, 0x45, 0x00,
0xF8, 0xFF, 0xF8, 0xFF, 0x08, 0x22, 0x45, 0x00,
0xF8, 0xFF, 0xF8, 0xFF, 0x0C, 0x22, 0x45, 0x00,
0xF8, 0xFF, 0xF8, 0xFF, 0x10, 0x22, 0x45, 0x00,
0xF8, 0xFF, 0xF8, 0xFF, 0x14, 0x32, 0x45, 0x00])
# Enable changing the Magic Item appearance separately from what it really is.
# Change these ldrh's to ldrb's to read only the high or low byte of the object list entry's parameter field.
rom_data.write_bytes(0x9597A, [0xC1, 0x79])
rom_data.write_bytes(0x95B64, [0x80, 0x79])
rom_data.write_bytes(0x95BF0, [0x81, 0x79])
rom_data.write_bytes(0x95CBE, [0x82, 0x79])
# Enable changing the Max Up appearance separately from what it really is.
rom_data.write_bytes(0x5DE98, [0xC1, 0x79])
rom_data.write_byte(0x5E152, 0x13)
rom_data.write_byte(0x5E15C, 0x0E)
rom_data.write_byte(0x5E20A, 0x0B)
# Set the 0xF0 flag on the iron maiden switch if we're placing an Item on it.
if options["iron_maiden_behavior"] == IronMaidenBehavior.option_detonator_in_pool:
rom_data.write_byte(0xD47B4, 0xF0)
if options["nerf_roc_wing"]:
# Prevent Roc jumping in midair if the Double is not in the player's inventory.
rom_data.write_bytes(0x6B8A0, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x9A, 0x67, 0x08])
rom_data.write_bytes(0x679A00, patches.doubleless_roc_midairs_preventer)
# Make Roc Wing not jump as high if Kick Boots isn't in the inventory.
rom_data.write_bytes(0x6B8B4, [0x00, 0x49, 0x8F, 0x46, 0x60, 0x9A, 0x67, 0x08])
rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener)
# Give the player their Start Inventory upon entering their name on a new file.
rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x68, 0x08])
rom_data.write_bytes(0x680000, patches.start_inventory_giver)
# Prevent Max Ups from exceeding 255.
rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08])
rom_data.write_bytes(0x6A0000, patches.max_max_up_checker)
# Write the textbox messaging system code.
rom_data.write_bytes(0x7D60, [0x00, 0x48, 0x87, 0x46, 0x20, 0xFF, 0x7F, 0x08])
rom_data.write_bytes(0x7FFF20, patches.remote_textbox_shower)
# Write the code that sets the screen transition delay timer.
rom_data.write_bytes(0x6CE14, [0x00, 0x4A, 0x97, 0x46, 0xC0, 0xFF, 0x7F, 0x08])
rom_data.write_bytes(0x7FFFC0, patches.transition_textbox_delayer)
# Write the code that allows any sound to be played with any Magic Item.
rom_data.write_bytes(0x95BE4, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x98, 0x67, 0x08])
rom_data.write_bytes(0x679800, patches.magic_item_sfx_customizer)
# Array of sound IDs for each Magic Item.
rom_data.write_bytes(0x6797C0, [0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01,
0xB4, 0x01, 0xB4, 0x01, 0xB4, 0x01, 0x79, 0x00])
# Write all the data for the missing ASCII text characters.
for offset, data in patches.missing_char_data.items():
rom_data.write_bytes(offset, data)
# Change all the menu item name strings that use the overwritten character IDs to use a different, equivalent
# space character ID.
rom_data.write_bytes(0x391A1B, [0xAD, 0xAD, 0xAD, 0xAD, 0xAD, 0xAD])
rom_data.write_bytes(0x391CB6, [0xAD, 0xAD, 0xAD])
rom_data.write_bytes(0x391CC1, [0xAD, 0xAD, 0xAD])
rom_data.write_bytes(0x391CCB, [0xAD, 0xAD, 0xAD, 0xAD])
rom_data.write_bytes(0x391CD5, [0xAD, 0xAD, 0xAD, 0xAD, 0xAD])
rom_data.write_byte(0x391CE1, 0xAD)
# Put the unused bottom-of-screen textbox in the middle of the screen instead.
# Its background's new y position will be 0x28 instead of 0x50.
rom_data.write_byte(0xBEDEA, 0x28)
# Change all the hardcoded checks for the 0x50 position to instead check for 0x28.
rom_data.write_byte(0xBF398, 0x28)
rom_data.write_byte(0xBF41C, 0x28)
rom_data.write_byte(0xBF4CC, 0x28)
# Change all the hardcoded checks for greater than 0x48 to instead check for 0x28 specifically.
rom_data.write_byte(0xBF4A4, 0x28)
rom_data.write_byte(0xBF4A7, 0xD0)
rom_data.write_byte(0xBF37E, 0x28)
rom_data.write_byte(0xBF381, 0xD0)
rom_data.write_byte(0xBF40A, 0x28)
rom_data.write_byte(0xBF40D, 0xD0)
# Change the y position of the contents within the textbox from 0xA0 to 0xB4.
# KCEK didn't program hardcoded checks for these, thankfully!
rom_data.write_byte(0xBF3BC, 0xB4)
# Insert the multiworld message pointer at the end of the text pointers.
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START, int.to_bytes(QUEUED_TEXT_STRING_START + 0x8000000,
4, "little"))
# Insert pointers for every item tutorial.
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 4, [0x8E, 0x3B, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 8, [0xDF, 0x3B, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 12, [0x35, 0x3C, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 16, [0xC4, 0x3C, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 20, [0x41, 0x3D, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 24, [0x88, 0x3D, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 28, [0xF7, 0x3D, 0x39, 0x08])
rom_data.write_bytes(MULTIWORLD_TEXTBOX_POINTERS_START + 32, [0x67, 0x3E, 0x39, 0x08])
# Write the completion goal messages over the menu Dash Boots tutorial and Battle Arena's explanation message.
if options["completion_goal"] == CompletionGoal.option_dracula:
dash_tutorial_message = "Your goal is:\n Dracula◊"
if options["required_skirmishes"] == RequiredSkirmishes.option_all_bosses_and_arena:
arena_goal_message = "Your goal is:\n「Dracula」▶" \
"A required 「Last Key」 is waiting for you at the end of the Arena. Good luck!◊"
else:
arena_goal_message = "Your goal is:\n「Dracula」▶" \
"You don't have to win the Arena, but you are certainly welcome to try!◊"
elif options["completion_goal"] == CompletionGoal.option_battle_arena:
dash_tutorial_message = "Your goal is:\n Battle Arena◊"
arena_goal_message = "Your goal is:\n「Battle Arena」▶" \
"Win the Arena, and your goal will send. Good luck!◊"
else:
dash_tutorial_message = "Your goal is:\n Arena and Dracula◊"
arena_goal_message = "Your goal is:\n「Battle Arena & Dracula」▶" \
"Your goal will send once you've both won the Arena and beaten Dracula. Good luck!◊"
rom_data.write_bytes(0x393EAE, cvcotm_string_to_bytearray(dash_tutorial_message, "big top", 4,
skip_textbox_controllers=True))
rom_data.write_bytes(0x393A0C, cvcotm_string_to_bytearray(arena_goal_message, "big top", 4))
# Change the pointer to the Ceremonial Room locked door text.
rom_data.write_bytes(0x670D94, [0xE0, 0xE9, 0x7C, 0x08])
# Write the Ceremonial Room door and menu Last Key tutorial messages telling the player's Last Key options.
door_message = f"Hmmmmmm...\nI need 「{options['required_last_keys']}」/" \
f"{options['available_last_keys']}」 Last Keys.◊"
key_tutorial_message = f"You need {options['required_last_keys']}/{options['available_last_keys']} keys.◊"
rom_data.write_bytes(0x7CE9E0, cvcotm_string_to_bytearray(door_message, "big top", 4, 0))
rom_data.write_bytes(0x394098, cvcotm_string_to_bytearray(key_tutorial_message, "big top", 4,
skip_textbox_controllers=True))
# Nuke all the tutorial-related text if Skip Tutorials is enabled.
if options["skip_tutorials"]:
rom_data.write_byte(0x5EB55, 0xE0) # DSS
rom_data.write_byte(0x393B8C, 0x00) # Dash Boots
rom_data.write_byte(0x393BDD, 0x00) # Double
rom_data.write_byte(0x393C33, 0x00) # Tackle
rom_data.write_byte(0x393CC2, 0x00) # Kick Boots
rom_data.write_byte(0x393D41, 0x00) # Heavy Ring
rom_data.write_byte(0x393D86, 0x00) # Cleansing
rom_data.write_byte(0x393DF5, 0x00) # Roc Wing
rom_data.write_byte(0x393E65, 0x00) # Last Key
# Nuke all the cutscene dialogue before the ending if Skip Dialogues is enabled.
if options["skip_dialogues"]:
rom_data.write_byte(0x392372, 0x00)
rom_data.write_bytes(0x3923C9, [0x20, 0x80, 0x00])
rom_data.write_bytes(0x3924EE, [0x20, 0x81, 0x00])
rom_data.write_byte(0x392621, 0x00)
rom_data.write_bytes(0x392650, [0x20, 0x81, 0x00])
rom_data.write_byte(0x392740, 0x00)
rom_data.write_byte(0x3933C8, 0x00)
rom_data.write_byte(0x39346E, 0x00)
rom_data.write_byte(0x393670, 0x00)
rom_data.write_bytes(0x393698, [0x20, 0x80, 0x00])
rom_data.write_byte(0x3936A6, 0x00)
rom_data.write_byte(0x393741, 0x00)
rom_data.write_byte(0x392944, 0x00)
rom_data.write_byte(0x392FFB, 0x00)
rom_data.write_byte(0x39305D, 0x00)
rom_data.write_byte(0x393114, 0x00)
rom_data.write_byte(0x392771, 0x00)
rom_data.write_byte(0x3928E9, 0x00)
rom_data.write_byte(0x392A3C, 0x00)
rom_data.write_byte(0x392A55, 0x00)
rom_data.write_byte(0x392A8B, 0x00)
rom_data.write_byte(0x392AA4, 0x00)
rom_data.write_byte(0x392AF4, 0x00)
rom_data.write_byte(0x392B3F, 0x00)
rom_data.write_byte(0x392C4D, 0x00)
rom_data.write_byte(0x392DEA, 0x00)
rom_data.write_byte(0x392E65, 0x00)
rom_data.write_byte(0x392F09, 0x00)
rom_data.write_byte(0x392FE4, 0x00)
# Make the Battle Arena play the player's chosen track.
if options["battle_arena_music"]:
arena_track_id = BATTLE_ARENA_SONG_IDS[options["battle_arena_music"] - 1]
rom_data.write_bytes(0xEDEF0, [0xFC, 0xFF, arena_track_id])
rom_data.write_bytes(0xEFA50, [0xFC, 0xFF, arena_track_id])
rom_data.write_bytes(0xF24F0, [0xFC, 0xFF, arena_track_id])
rom_data.write_bytes(0xF3420, [0xF5, 0xFF])
rom_data.write_bytes(0xF3430, [0xFC, 0xFF, arena_track_id])
return rom_data.get_bytes()
@staticmethod
def fix_item_positions(caller: APProcedurePatch, rom: bytes) -> bytes:
"""After writing all the items into the ROM via token application, translates Magic Items in non-Magic Item
Locations up by 8 units and the reverse down by 8 units. This is necessary for them to look properly placed,
as Magic Items are offset differently on the Y axis from the other item types."""
rom_data = RomData(rom)
for loc in cvcotm_location_info:
offset = cvcotm_location_info[loc].offset
if offset is None:
continue
item_type = rom_data.read_byte(offset)
# Magic Items in non-Magic Item Locations should have their Y position decreased by 8.
if item_type == 0xE8 and cvcotm_location_info[loc].type not in ["magic item", "boss"]:
y_pos = int.from_bytes(rom_data.read_bytes(offset-2, 2), "little")
y_pos -= 8
rom_data.write_bytes(offset-2, int.to_bytes(y_pos, 2, "little"))
# Non-Magic Items in Magic Item Locations should have their Y position increased by 8.
if item_type != 0xE8 and cvcotm_location_info[loc].type in ["magic item", "boss"]:
y_pos = int.from_bytes(rom_data.read_bytes(offset - 2, 2), "little")
y_pos += 8
rom_data.write_bytes(offset - 2, int.to_bytes(y_pos, 2, "little"))
return rom_data.get_bytes()
class CVCotMProcedurePatch(APProcedurePatch, APTokenMixin):
hash = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]
patch_file_ending: str = ".apcvcotm"
result_file_ending: str = ".gba"
game = "Castlevania - Circle of the Moon"
procedure = [
("apply_patches", ["options.json"]),
("apply_tokens", ["token_data.bin"]),
("fix_item_positions", [])
]
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes()
def patch_rom(world: "CVCotMWorld", patch: CVCotMProcedurePatch, offset_data: Dict[int, bytes],
start_with_detonator: bool) -> None:
# Write all the new item values
for offset, data in offset_data.items():
patch.write_token(APTokenTypes.WRITE, offset, data)
# Write the secondary name the client will use to distinguish a vanilla ROM from an AP one.
patch.write_token(APTokenTypes.WRITE, ARCHIPELAGO_IDENTIFIER_START, ARCHIPELAGO_IDENTIFIER.encode("utf-8"))
# Write the slot authentication
patch.write_token(APTokenTypes.WRITE, AUTH_NUMBER_START, bytes(world.auth))
patch.write_file("token_data.bin", patch.get_token_binary())
# Write these slot options to a JSON.
options_dict = {
"auto_run": world.options.auto_run.value,
"dss_patch": world.options.dss_patch.value,
"break_iron_maidens": start_with_detonator,
"iron_maiden_behavior": world.options.iron_maiden_behavior.value,
"required_last_keys": world.required_last_keys,
"available_last_keys": world.options.available_last_keys.value,
"required_skirmishes": world.options.required_skirmishes.value,
"buff_ranged_familiars": world.options.buff_ranged_familiars.value,
"buff_sub_weapons": world.options.buff_sub_weapons.value,
"buff_shooter_strength": world.options.buff_shooter_strength.value,
"always_allow_speed_dash": world.options.always_allow_speed_dash.value,
"countdown": world.options.countdown.value,
"disable_battle_arena_mp_drain": world.options.disable_battle_arena_mp_drain.value,
"completion_goal": world.options.completion_goal.value,
"skip_dialogues": world.options.skip_dialogues.value,
"skip_tutorials": world.options.skip_tutorials.value,
"nerf_roc_wing": world.options.nerf_roc_wing.value,
"pluto_griffin_air_speed": world.options.pluto_griffin_air_speed.value,
"battle_arena_music": world.options.battle_arena_music.value,
"seed": world.multiworld.seed,
"compat_identifier": ARCHIPELAGO_IDENTIFIER
}
patch.write_file("options.json", json.dumps(options_dict).encode('utf-8'))
def get_base_rom_bytes(file_name: str = "") -> bytes:
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:
file_name = get_base_rom_path(file_name)
base_rom_bytes = bytes(open(file_name, "rb").read())
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]:
raise Exception("Supplied Base ROM does not match known MD5s for Castlevania: Circle of the Moon USA."
"Get the correct game and version, then dump it.")
setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes)
return base_rom_bytes
def get_base_rom_path(file_name: str = "") -> str:
if not file_name:
file_name = get_settings()["cvcotm_options"]["rom_file"]
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name

203
worlds/cvcotm/rules.py Normal file
View File

@@ -0,0 +1,203 @@
from typing import Dict, TYPE_CHECKING
from BaseClasses import CollectionState
from worlds.generic.Rules import CollectionRule
from .data import iname, lname
from .options import CompletionGoal, IronMaidenBehavior
if TYPE_CHECKING:
from . import CVCotMWorld
class CVCotMRules:
player: int
world: "CVCotMWorld"
rules: Dict[str, CollectionRule]
required_last_keys: int
iron_maiden_behavior: int
nerf_roc_wing: int
ignore_cleansing: int
completion_goal: int
def __init__(self, world: "CVCotMWorld") -> None:
self.player = world.player
self.world = world
self.required_last_keys = world.required_last_keys
self.iron_maiden_behavior = world.options.iron_maiden_behavior.value
self.nerf_roc_wing = world.options.nerf_roc_wing.value
self.ignore_cleansing = world.options.ignore_cleansing.value
self.completion_goal = world.options.completion_goal.value
self.location_rules = {
# Sealed Room
lname.sr3: self.has_jump_level_5,
# Catacomb
lname.cc1: self.has_push,
lname.cc3: self.has_jump_level_1,
lname.cc3b: lambda state:
(self.has_jump_level_1(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state),
lname.cc5: self.has_tackle,
lname.cc8b: lambda state: self.has_jump_level_3(state) or self.has_kick(state),
lname.cc14b: lambda state: self.has_jump_level_1(state) or self.has_kick(state),
lname.cc25: self.has_jump_level_1,
# Abyss Staircase
lname.as4: self.has_jump_level_4,
# Audience Room
lname.ar9: self.has_push,
lname.ar11: self.has_tackle,
lname.ar14b: self.has_jump_level_4,
lname.ar17b: lambda state: self.has_jump_level_2(state) or self.has_kick(state),
lname.ar19: lambda state: self.has_jump_level_2(state) or self.has_kick(state),
lname.ar26: lambda state: self.has_tackle(state) and self.has_jump_level_5(state),
lname.ar27: lambda state: self.has_tackle(state) and self.has_push(state),
lname.ar30: lambda state:
(self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state),
lname.ar30b: lambda state:
(self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state),
# Outer Wall
lname.ow0: self.has_jump_level_4,
lname.ow1: lambda state: self.has_jump_level_5(state) or self.has_ice_or_stone(state),
# Triumph Hallway
lname.th3: lambda state:
(self.has_kick(state) and self.has_ice_or_stone(state)) or self.has_jump_level_2(state),
# Machine Tower
lname.mt3: lambda state: self.has_jump_level_2(state) or self.has_kick(state),
lname.mt6: lambda state: self.has_jump_level_2(state) or self.has_kick(state),
lname.mt14: self.has_tackle,
# Chapel Tower
lname.ct1: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state),
lname.ct4: self.has_push,
lname.ct10: self.has_push,
lname.ct13: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state),
lname.ct22: self.broke_iron_maidens,
lname.ct26: lambda state:
(self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state),
lname.ct26b: lambda state:
(self.has_jump_level_3(state) and self.has_ice_or_stone(state)) or self.has_jump_level_4(state),
# Underground Gallery
lname.ug1: self.has_push,
lname.ug2: self.has_push,
lname.ug3: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state),
lname.ug3b: lambda state: self.has_jump_level_4(state) or self.has_ice_or_stone(state),
lname.ug8: self.has_tackle,
# Underground Warehouse
lname.uw10: lambda state:
(self.has_jump_level_4(state) and self.has_ice_or_stone(state)) or self.has_jump_level_5(state),
lname.uw14: lambda state: self.has_jump_level_2(state) or self.has_ice_or_stone(state),
lname.uw16b: lambda state:
(self.has_jump_level_2(state) and self.has_ice_or_stone(state)) or self.has_jump_level_3(state),
# Underground Waterway
lname.uy5: lambda state: self.has_jump_level_3(state) or self.has_ice_or_stone(state),
lname.uy8: self.has_jump_level_2,
lname.uy12b: self.can_touch_water,
lname.uy17: self.can_touch_water,
lname.uy13: self.has_jump_level_3,
lname.uy18: self.has_jump_level_3,
# Ceremonial Room
lname.cr1: lambda state: self.has_jump_level_2(state) or self.has_kick(state),
lname.dracula: self.has_jump_level_2,
}
self.entrance_rules = {
"Catacomb to Stairway": lambda state: self.has_jump_level_1(state) or self.has_kick(state),
"Stairway to Audience": self.has_jump_level_1,
"Audience to Machine Bottom": self.has_tackle,
"Audience to Machine Top": lambda state: self.has_jump_level_2(state) or self.has_kick(state),
"Audience to Chapel": lambda state:
(self.has_jump_level_2(state) and self.has_ice_or_stone(state)) or self.has_jump_level_3(state)
or self.has_kick(state),
"Audience to Gallery": lambda state: self.broke_iron_maidens(state) and self.has_push(state),
"Audience to Warehouse": self.has_push,
"Audience to Waterway": self.broke_iron_maidens,
"Audience to Observation": self.has_jump_level_5,
"Ceremonial Door": self.can_open_ceremonial_door,
"Corridor to Gallery": self.broke_iron_maidens,
"Escape the Gallery Pit": lambda state: self.has_jump_level_2(state) or self.has_kick(state),
"Climb to Chapel Top": lambda state: self.has_jump_level_3(state) or self.has_kick(state),
"Arena Passage": lambda state: self.has_push(state) and self.has_jump_level_2(state),
"Dip Into Waterway End": self.has_jump_level_3,
"Gallery Upper to Lower": self.has_tackle,
"Gallery Lower to Upper": self.has_tackle,
"Into Warehouse Main": self.has_tackle,
"Into Waterway Main": self.can_touch_water,
}
def has_jump_level_1(self, state: CollectionState) -> bool:
"""Double or Roc Wing, regardless of Roc being nerfed or not."""
return state.has_any([iname.double, iname.roc_wing], self.player)
def has_jump_level_2(self, state: CollectionState) -> bool:
"""Specifically Roc Wing, regardless of Roc being nerfed or not."""
return state.has(iname.roc_wing, self.player)
def has_jump_level_3(self, state: CollectionState) -> bool:
"""Roc Wing and Double OR Kick Boots if Roc is nerfed. Otherwise, just Roc."""
if self.nerf_roc_wing:
return state.has(iname.roc_wing, self.player) and \
state.has_any([iname.double, iname.kick_boots], self.player)
else:
return state.has(iname.roc_wing, self.player)
def has_jump_level_4(self, state: CollectionState) -> bool:
"""Roc Wing and Kick Boots specifically if Roc is nerfed. Otherwise, just Roc."""
if self.nerf_roc_wing:
return state.has_all([iname.roc_wing, iname.kick_boots], self.player)
else:
return state.has(iname.roc_wing, self.player)
def has_jump_level_5(self, state: CollectionState) -> bool:
"""Roc Wing, Double, AND Kick Boots if Roc is nerfed. Otherwise, just Roc."""
if self.nerf_roc_wing:
return state.has_all([iname.roc_wing, iname.double, iname.kick_boots], self.player)
else:
return state.has(iname.roc_wing, self.player)
def has_tackle(self, state: CollectionState) -> bool:
return state.has(iname.tackle, self.player)
def has_push(self, state: CollectionState) -> bool:
return state.has(iname.heavy_ring, self.player)
def has_kick(self, state: CollectionState) -> bool:
return state.has(iname.kick_boots, self.player)
def has_ice_or_stone(self, state: CollectionState) -> bool:
"""Valid DSS combo that allows freezing or petrifying enemies to use as platforms."""
return state.has_any([iname.serpent, iname.cockatrice], self.player) and \
state.has_any([iname.mercury, iname.mars], self.player)
def can_touch_water(self, state: CollectionState) -> bool:
"""Cleansing unless it's ignored, in which case this will always return True."""
return self.ignore_cleansing or state.has(iname.cleansing, self.player)
def broke_iron_maidens(self, state: CollectionState) -> bool:
"""Maiden Detonator unless the Iron Maidens start broken, in which case this will always return True."""
return (self.iron_maiden_behavior == IronMaidenBehavior.option_start_broken
or state.has(iname.ironmaidens, self.player))
def can_open_ceremonial_door(self, state: CollectionState) -> bool:
"""The required number of Last Keys. If 0 keys are required, this should always return True."""
return state.has(iname.last_key, self.player, self.required_last_keys)
def set_cvcotm_rules(self) -> None:
multiworld = self.world.multiworld
for region in multiworld.get_regions(self.player):
# Set each Entrance's rule if it should have one.
for ent in region.entrances:
if ent.name in self.entrance_rules:
ent.access_rule = self.entrance_rules[ent.name]
# Set each Location's rule if it should have one.
for loc in region.locations:
if loc.name in self.location_rules:
loc.access_rule = self.location_rules[loc.name]
# Set the World's completion condition depending on what its Completion Goal option is.
if self.completion_goal == CompletionGoal.option_dracula:
multiworld.completion_condition[self.player] = lambda state: state.has(iname.dracula, self.player)
elif self.completion_goal == CompletionGoal.option_battle_arena:
multiworld.completion_condition[self.player] = lambda state: state.has(iname.shinning_armor, self.player)
else:
multiworld.completion_condition[self.player] = \
lambda state: state.has_all([iname.dracula, iname.shinning_armor], self.player)

View File

@@ -0,0 +1,5 @@
from test.bases import WorldTestBase
class CVCotMTestBase(WorldTestBase):
game = "Castlevania - Circle of the Moon"

View File

@@ -0,0 +1,811 @@
from . import CVCotMTestBase
from ..data import iname, lname
from ..options import IronMaidenBehavior
class CatacombSphere1Test(CVCotMTestBase):
def test_always_accessible(self) -> None:
self.assertTrue(self.can_reach_location(lname.cc4))
self.assertTrue(self.can_reach_location(lname.cc8))
self.assertTrue(self.can_reach_location(lname.cc9))
self.assertTrue(self.can_reach_location(lname.cc10))
self.assertTrue(self.can_reach_location(lname.cc13))
self.assertTrue(self.can_reach_location(lname.cc14))
self.assertTrue(self.can_reach_location(lname.cc16))
self.assertTrue(self.can_reach_location(lname.cc20))
self.assertTrue(self.can_reach_location(lname.cc22))
self.assertTrue(self.can_reach_location(lname.cc24))
class DoubleTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"nerf_roc_wing": True,
"ignore_cleansing": True
}
def test_double_only(self) -> None:
self.assertFalse(self.can_reach_location(lname.cc3))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.cc14b))
self.assertFalse(self.can_reach_location(lname.cc25))
self.assertFalse(self.can_reach_entrance("Catacomb to Stairway"))
self.assertFalse(self.can_reach_entrance("Stairway to Audience"))
self.collect_by_name([iname.double])
self.assertTrue(self.can_reach_location(lname.cc3))
self.assertTrue(self.can_reach_location(lname.cc14b))
self.assertTrue(self.can_reach_location(lname.cc25))
self.assertTrue(self.can_reach_entrance("Catacomb to Stairway"))
self.assertTrue(self.can_reach_entrance("Stairway to Audience"))
# Jump-locked things that Double still shouldn't be able to reach.
self.assertFalse(self.can_reach_location(lname.sr3))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar17b))
self.assertFalse(self.can_reach_location(lname.ar19))
self.assertFalse(self.can_reach_location(lname.ar26))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.ow1))
self.assertFalse(self.can_reach_location(lname.th3))
self.assertFalse(self.can_reach_entrance("Audience to Machine Top"))
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_entrance("Audience to Observation"))
self.collect_by_name([iname.heavy_ring, iname.tackle])
self.assertFalse(self.can_reach_entrance("Escape the Gallery Pit"))
def test_double_with_freeze(self) -> None:
self.collect_by_name([iname.mercury, iname.serpent])
self.assertFalse(self.can_reach_location(lname.cc3b))
self.collect_by_name([iname.double])
self.assertTrue(self.can_reach_location(lname.cc3b))
def test_nerfed_roc_double_path(self) -> None:
self.collect_by_name([iname.roc_wing, iname.tackle, iname.heavy_ring])
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_entrance("Arena Passage"))
self.assertFalse(self.can_reach_entrance("Dip Into Waterway End"))
self.assertFalse(self.can_reach_entrance("Climb to Chapel Top"))
self.assertFalse(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.assertFalse(self.can_reach_location(lname.uw16b))
self.assertFalse(self.can_reach_location(lname.uy5))
self.assertFalse(self.can_reach_location(lname.uy13))
self.assertFalse(self.can_reach_location(lname.uy18))
self.collect_by_name([iname.double])
self.assertTrue(self.can_reach_entrance("Audience to Chapel"))
self.assertTrue(self.can_reach_entrance("Arena Passage"))
self.assertTrue(self.can_reach_entrance("Dip Into Waterway End"))
self.assertTrue(self.can_reach_entrance("Climb to Chapel Top"))
self.assertTrue(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.assertTrue(self.can_reach_location(lname.uw16b))
self.assertTrue(self.can_reach_location(lname.uy5))
self.assertTrue(self.can_reach_location(lname.uy13))
self.assertTrue(self.can_reach_location(lname.uy18))
self.assertFalse(self.can_reach_entrance("Audience to Observation"))
self.assertFalse(self.can_reach_location(lname.sr3))
self.assertFalse(self.can_reach_location(lname.ar26))
self.assertFalse(self.can_reach_location(lname.ow1))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_entrance("Arena Passage"))
self.assertTrue(self.can_reach_location(lname.cc3b))
self.assertTrue(self.can_reach_location(lname.as4))
self.assertTrue(self.can_reach_location(lname.ar14b))
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ow0))
self.assertTrue(self.can_reach_location(lname.ct26))
self.assertTrue(self.can_reach_location(lname.ct26b))
self.assertTrue(self.can_reach_location(lname.ug3b))
self.assertTrue(self.can_reach_location(lname.uw10))
self.assertTrue(self.can_reach_entrance("Audience to Observation"))
self.assertTrue(self.can_reach_location(lname.sr3))
self.assertTrue(self.can_reach_location(lname.ar26))
self.assertTrue(self.can_reach_location(lname.ow1))
class TackleTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
}
def test_tackle_only_in_catacomb(self) -> None:
self.assertFalse(self.can_reach_location(lname.cc5))
self.collect_by_name([iname.tackle])
self.assertTrue(self.can_reach_location(lname.cc5))
def test_tackle_only_in_audience_room(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_location(lname.ar11))
self.assertFalse(self.can_reach_entrance("Audience to Machine Bottom"))
self.collect_by_name([iname.tackle])
self.assertTrue(self.can_reach_location(lname.ar11))
self.assertTrue(self.can_reach_entrance("Audience to Machine Bottom"))
def test_tackle_with_kick_boots(self) -> None:
self.collect_by_name([iname.double, iname.kick_boots])
self.assertFalse(self.can_reach_location(lname.mt14))
self.assertFalse(self.can_reach_entrance("Gallery Upper to Lower"))
self.collect_by_name([iname.tackle])
self.assertTrue(self.can_reach_location(lname.mt14))
self.assertTrue(self.can_reach_entrance("Gallery Upper to Lower"))
def test_tackle_with_heavy_ring(self) -> None:
self.collect_by_name([iname.double, iname.heavy_ring])
self.assertFalse(self.can_reach_location(lname.ar27))
self.assertFalse(self.can_reach_location(lname.ug8))
self.assertFalse(self.can_reach_entrance("Into Warehouse Main"))
self.assertFalse(self.can_reach_entrance("Gallery Lower to Upper"))
self.collect_by_name([iname.tackle])
self.assertTrue(self.can_reach_location(lname.ar27))
self.assertTrue(self.can_reach_location(lname.ug8))
self.assertTrue(self.can_reach_entrance("Into Warehouse Main"))
self.assertTrue(self.can_reach_entrance("Gallery Lower to Upper"))
def test_tackle_with_roc_wing(self) -> None:
self.collect_by_name([iname.roc_wing])
self.assertFalse(self.can_reach_location(lname.ar26))
self.collect_by_name([iname.tackle])
self.assertTrue(self.can_reach_location(lname.ar26))
class KickBootsTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"nerf_roc_wing": True,
"ignore_cleansing": True,
}
def test_kick_boots_only_in_catacomb(self) -> None:
self.assertFalse(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.cc14b))
self.assertFalse(self.can_reach_entrance("Catacomb to Stairway"))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_location(lname.cc8b))
self.assertTrue(self.can_reach_location(lname.cc14b))
self.assertTrue(self.can_reach_entrance("Catacomb to Stairway"))
def test_kick_boots_only_in_audience_room(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_location(lname.ar17b))
self.assertFalse(self.can_reach_location(lname.ar19))
self.assertFalse(self.can_reach_entrance("Audience to Machine Top"))
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_entrance("Climb to Chapel Top"))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_location(lname.ar17b))
self.assertTrue(self.can_reach_location(lname.ar19))
self.assertTrue(self.can_reach_entrance("Audience to Machine Top"))
self.assertTrue(self.can_reach_entrance("Audience to Chapel"))
self.assertTrue(self.can_reach_entrance("Climb to Chapel Top"))
def test_kick_boots_with_tackle(self) -> None:
self.collect_by_name([iname.double, iname.tackle])
self.assertFalse(self.can_reach_location(lname.mt3))
self.assertFalse(self.can_reach_location(lname.mt6))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_location(lname.mt3))
self.assertTrue(self.can_reach_location(lname.mt6))
def test_kick_boots_with_freeze(self) -> None:
self.collect_by_name([iname.double, iname.mars, iname.cockatrice])
self.assertFalse(self.can_reach_region("Underground Gallery Upper"))
self.assertFalse(self.can_reach_location(lname.th3))
self.assertFalse(self.can_reach_location(lname.ug3))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_region("Underground Gallery Upper"))
self.assertTrue(self.can_reach_location(lname.th3))
self.assertTrue(self.can_reach_location(lname.ug3))
self.assertTrue(self.can_reach_location(lname.ug3b))
def test_kick_boots_with_last_key(self) -> None:
self.collect_by_name([iname.double, iname.last_key])
self.assertFalse(self.can_reach_location(lname.cr1))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_location(lname.cr1))
def test_nerfed_roc_kick_boots_path(self) -> None:
self.collect_by_name([iname.roc_wing, iname.tackle, iname.heavy_ring])
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_entrance("Arena Passage"))
self.assertFalse(self.can_reach_entrance("Dip Into Waterway End"))
self.assertFalse(self.can_reach_entrance("Climb to Chapel Top"))
self.assertFalse(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.assertFalse(self.can_reach_location(lname.uw16b))
self.assertFalse(self.can_reach_location(lname.uy5))
self.assertFalse(self.can_reach_location(lname.uy13))
self.assertFalse(self.can_reach_location(lname.uy18))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_entrance("Audience to Chapel"))
self.assertTrue(self.can_reach_entrance("Arena Passage"))
self.assertTrue(self.can_reach_entrance("Dip Into Waterway End"))
self.assertTrue(self.can_reach_entrance("Climb to Chapel Top"))
self.assertTrue(self.can_reach_location(lname.cc8b))
self.assertTrue(self.can_reach_location(lname.cc3b))
self.assertTrue(self.can_reach_location(lname.as4))
self.assertTrue(self.can_reach_location(lname.ar14b))
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ow0))
self.assertTrue(self.can_reach_location(lname.ct26))
self.assertTrue(self.can_reach_location(lname.ct26b))
self.assertTrue(self.can_reach_location(lname.ug3b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.assertTrue(self.can_reach_location(lname.uw16b))
self.assertTrue(self.can_reach_location(lname.uy5))
self.assertTrue(self.can_reach_location(lname.uy13))
self.assertTrue(self.can_reach_location(lname.uy18))
self.assertFalse(self.can_reach_entrance("Audience to Observation"))
self.assertFalse(self.can_reach_location(lname.sr3))
self.assertFalse(self.can_reach_location(lname.ar26))
self.assertFalse(self.can_reach_location(lname.ow1))
self.collect_by_name([iname.double])
self.assertTrue(self.can_reach_location(lname.uw10))
self.assertTrue(self.can_reach_entrance("Audience to Observation"))
self.assertTrue(self.can_reach_location(lname.sr3))
self.assertTrue(self.can_reach_location(lname.ar26))
self.assertTrue(self.can_reach_location(lname.ow1))
class HeavyRingTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken
}
def test_heavy_ring_only_in_catacomb(self) -> None:
self.assertFalse(self.can_reach_location(lname.cc1))
self.collect_by_name([iname.heavy_ring])
self.assertTrue(self.can_reach_location(lname.cc1))
def test_heavy_ring_only_in_audience_room(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_location(lname.ar9))
self.assertFalse(self.can_reach_entrance("Audience to Gallery"))
self.assertFalse(self.can_reach_entrance("Audience to Warehouse"))
self.collect_by_name([iname.heavy_ring])
self.assertTrue(self.can_reach_location(lname.ar9))
self.assertTrue(self.can_reach_entrance("Audience to Gallery"))
self.assertTrue(self.can_reach_entrance("Audience to Warehouse"))
def test_heavy_ring_with_tackle(self) -> None:
self.collect_by_name([iname.double, iname.tackle])
self.assertFalse(self.can_reach_location(lname.ar27))
self.assertFalse(self.can_reach_entrance("Into Warehouse Main"))
self.collect_by_name([iname.heavy_ring])
self.assertTrue(self.can_reach_location(lname.ar27))
self.assertTrue(self.can_reach_entrance("Into Warehouse Main"))
def test_heavy_ring_with_kick_boots(self) -> None:
self.collect_by_name([iname.double, iname.kick_boots])
self.assertFalse(self.can_reach_location(lname.ct4))
self.assertFalse(self.can_reach_location(lname.ct10))
self.assertFalse(self.can_reach_location(lname.ug1))
self.assertFalse(self.can_reach_location(lname.ug2))
self.collect_by_name([iname.heavy_ring])
self.assertTrue(self.can_reach_location(lname.ct4))
self.assertTrue(self.can_reach_location(lname.ct10))
self.assertTrue(self.can_reach_location(lname.ug1))
self.assertTrue(self.can_reach_location(lname.ug2))
def test_heavy_ring_with_roc_wing(self) -> None:
self.collect_by_name([iname.roc_wing])
self.assertFalse(self.can_reach_entrance("Arena Passage"))
self.collect_by_name([iname.heavy_ring])
self.assertTrue(self.can_reach_entrance("Arena Passage"))
class CleansingTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken
}
def test_cleansing_only(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_entrance("Into Waterway Main"))
self.collect_by_name([iname.cleansing])
self.assertTrue(self.can_reach_entrance("Into Waterway Main"))
def test_cleansing_with_roc(self) -> None:
self.collect_by_name([iname.roc_wing])
self.assertFalse(self.can_reach_location(lname.uy12b))
self.assertFalse(self.can_reach_location(lname.uy17))
self.assertTrue(self.can_reach_entrance("Dip Into Waterway End"))
self.collect_by_name([iname.cleansing])
self.assertTrue(self.can_reach_location(lname.uy12b))
self.assertTrue(self.can_reach_location(lname.uy17))
class IgnoredCleansingTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"ignore_cleansing": True
}
def test_ignored_cleansing(self) -> None:
self.assertFalse(self.can_reach_entrance("Into Waterway Main"))
self.assertFalse(self.can_reach_location(lname.uy12b))
self.assertFalse(self.can_reach_location(lname.uy17))
self.collect_by_name([iname.double])
self.assertTrue(self.can_reach_entrance("Into Waterway Main"))
self.assertTrue(self.can_reach_location(lname.uy12b))
self.assertTrue(self.can_reach_location(lname.uy17))
class UnNerfedRocTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken
}
def test_roc_wing_only(self) -> None:
self.assertFalse(self.can_reach_location(lname.sr3))
self.assertFalse(self.can_reach_location(lname.cc3))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.cc14b))
self.assertFalse(self.can_reach_location(lname.cc25))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar17b))
self.assertFalse(self.can_reach_location(lname.ar19))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.ow1))
self.assertFalse(self.can_reach_location(lname.th3))
self.assertFalse(self.can_reach_location(lname.ct1))
self.assertFalse(self.can_reach_location(lname.ct13))
self.assertFalse(self.can_reach_location(lname.ug3))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.assertFalse(self.can_reach_entrance("Catacomb to Stairway"))
self.assertFalse(self.can_reach_entrance("Stairway to Audience"))
self.assertFalse(self.can_reach_entrance("Audience to Machine Top"))
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_entrance("Audience to Observation"))
self.assertFalse(self.can_reach_entrance("Dip Into Waterway End"))
self.collect_by_name([iname.roc_wing])
self.assertTrue(self.can_reach_location(lname.sr3))
self.assertTrue(self.can_reach_location(lname.cc3))
self.assertTrue(self.can_reach_location(lname.cc3b))
self.assertTrue(self.can_reach_location(lname.cc8b))
self.assertTrue(self.can_reach_location(lname.cc14b))
self.assertTrue(self.can_reach_location(lname.cc25))
self.assertTrue(self.can_reach_location(lname.as4))
self.assertTrue(self.can_reach_location(lname.ar14b))
self.assertTrue(self.can_reach_location(lname.ar17b))
self.assertTrue(self.can_reach_location(lname.ar19))
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ow0))
self.assertTrue(self.can_reach_location(lname.ow1))
self.assertTrue(self.can_reach_location(lname.th3))
self.assertTrue(self.can_reach_location(lname.ct1))
self.assertTrue(self.can_reach_location(lname.ct13))
self.assertTrue(self.can_reach_location(lname.ug3))
self.assertTrue(self.can_reach_location(lname.ug3b))
self.assertTrue(self.can_reach_entrance("Catacomb to Stairway"))
self.assertTrue(self.can_reach_entrance("Stairway to Audience"))
self.assertTrue(self.can_reach_entrance("Audience to Machine Top"))
self.assertTrue(self.can_reach_entrance("Audience to Chapel"))
self.assertTrue(self.can_reach_entrance("Audience to Observation"))
self.assertTrue(self.can_reach_entrance("Dip Into Waterway End"))
self.assertFalse(self.can_reach_entrance("Arena Passage"))
def test_roc_wing_exclusive_accessibility(self) -> None:
self.collect_by_name([iname.double, iname.tackle, iname.kick_boots, iname.heavy_ring, iname.cleansing,
iname.last_key, iname.mercury, iname.cockatrice])
self.assertFalse(self.can_reach_location(lname.sr3))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar26))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.uw10))
self.assertFalse(self.can_reach_location(lname.uw16b))
self.assertFalse(self.can_reach_location(lname.uy8))
self.assertFalse(self.can_reach_location(lname.uy13))
self.assertFalse(self.can_reach_location(lname.uy18))
self.assertFalse(self.can_reach_location(lname.dracula))
self.assertFalse(self.can_reach_entrance("Audience to Observation"))
self.assertFalse(self.can_reach_entrance("Arena Passage"))
self.assertFalse(self.can_reach_entrance("Dip Into Waterway End"))
self.collect_by_name([iname.roc_wing])
self.assertTrue(self.can_reach_location(lname.sr3))
self.assertTrue(self.can_reach_location(lname.as4))
self.assertTrue(self.can_reach_location(lname.ar14b))
self.assertTrue(self.can_reach_location(lname.ar26))
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ow0))
self.assertTrue(self.can_reach_location(lname.uw10))
self.assertTrue(self.can_reach_location(lname.uw16b))
self.assertTrue(self.can_reach_location(lname.uy8))
self.assertTrue(self.can_reach_location(lname.uy13))
self.assertTrue(self.can_reach_location(lname.uy18))
self.assertTrue(self.can_reach_location(lname.dracula))
self.assertTrue(self.can_reach_entrance("Audience to Observation"))
self.assertTrue(self.can_reach_entrance("Arena Passage"))
self.assertTrue(self.can_reach_entrance("Dip Into Waterway End"))
class NerfedRocTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"nerf_roc_wing": True,
"ignore_cleansing": True
}
def test_nerfed_roc_without_double_or_kick(self) -> None:
self.collect_by_name([iname.tackle, iname.heavy_ring, iname.last_key])
self.assertFalse(self.can_reach_location(lname.cc3))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.cc14b))
self.assertFalse(self.can_reach_location(lname.cc25))
self.assertFalse(self.can_reach_entrance("Catacomb to Stairway"))
self.assertFalse(self.can_reach_entrance("Stairway to Audience"))
self.collect_by_name([iname.roc_wing])
# Jump-locked things inside Catacomb that just Roc Wing should be able to reach while nerfed.
self.assertTrue(self.can_reach_location(lname.cc3))
self.assertTrue(self.can_reach_location(lname.cc14b))
self.assertTrue(self.can_reach_location(lname.cc25))
self.assertTrue(self.can_reach_entrance("Catacomb to Stairway"))
self.assertTrue(self.can_reach_entrance("Stairway to Audience"))
# Jump-locked things outside Catacomb that just Roc Wing should be able to reach while nerfed.
self.assertTrue(self.can_reach_location(lname.ar17b))
self.assertTrue(self.can_reach_location(lname.ar19))
self.assertTrue(self.can_reach_location(lname.th3))
self.assertTrue(self.can_reach_location(lname.mt3))
self.assertTrue(self.can_reach_location(lname.mt6))
self.assertTrue(self.can_reach_location(lname.ct1))
self.assertTrue(self.can_reach_location(lname.ct13))
self.assertTrue(self.can_reach_location(lname.ug3))
self.assertTrue(self.can_reach_location(lname.uw14))
self.assertTrue(self.can_reach_location(lname.uy8))
self.assertTrue(self.can_reach_location(lname.cr1))
self.assertTrue(self.can_reach_location(lname.dracula))
self.assertTrue(self.can_reach_entrance("Audience to Machine Top"))
self.assertTrue(self.can_reach_entrance("Escape the Gallery Pit"))
# Jump-locked things outside Catacomb that just Roc Wing shouldn't be able to reach while nerfed.
self.assertFalse(self.can_reach_location(lname.sr3))
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.cc8b))
self.assertFalse(self.can_reach_location(lname.as4))
self.assertFalse(self.can_reach_location(lname.ar14b))
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ow0))
self.assertFalse(self.can_reach_location(lname.ow1))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.assertFalse(self.can_reach_location(lname.uw16b))
self.assertFalse(self.can_reach_location(lname.uy5))
self.assertFalse(self.can_reach_location(lname.uy13))
self.assertFalse(self.can_reach_location(lname.uy18))
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_entrance("Audience to Observation"))
self.assertFalse(self.can_reach_entrance("Climb to Chapel Top"))
self.collect_by_name([iname.double, iname.kick_boots])
self.assertTrue(self.can_reach_location(lname.sr3))
self.assertTrue(self.can_reach_location(lname.cc3b))
self.assertTrue(self.can_reach_location(lname.cc8b))
self.assertTrue(self.can_reach_location(lname.as4))
self.assertTrue(self.can_reach_location(lname.ar14b))
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ow0))
self.assertTrue(self.can_reach_location(lname.ow1))
self.assertTrue(self.can_reach_location(lname.ct26))
self.assertTrue(self.can_reach_location(lname.ct26b))
self.assertTrue(self.can_reach_location(lname.ug3b))
self.assertTrue(self.can_reach_location(lname.uw10))
self.assertTrue(self.can_reach_location(lname.uw16b))
self.assertTrue(self.can_reach_location(lname.uy5))
self.assertTrue(self.can_reach_location(lname.uy13))
self.assertTrue(self.can_reach_location(lname.uy18))
self.assertTrue(self.can_reach_entrance("Audience to Chapel"))
self.assertTrue(self.can_reach_entrance("Audience to Observation"))
self.assertTrue(self.can_reach_entrance("Climb to Chapel Top"))
class LastKeyTest(CVCotMTestBase):
options = {
"required_last_keys": 9,
"available_last_keys": 9
}
def test_last_keys(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_entrance("Ceremonial Door"))
self.collect([self.get_item_by_name(iname.last_key)] * 1)
self.assertFalse(self.can_reach_entrance("Ceremonial Door"))
self.collect([self.get_item_by_name(iname.last_key)] * 7)
self.assertFalse(self.can_reach_entrance("Ceremonial Door"))
self.collect([self.get_item_by_name(iname.last_key)] * 1)
self.assertTrue(self.can_reach_entrance("Ceremonial Door"))
class FreezeTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_start_broken,
"nerf_roc_wing": True
}
def test_freeze_only_in_audience_room(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_location(lname.cc3b))
self.assertFalse(self.can_reach_location(lname.ow1))
self.collect_by_name([iname.mars, iname.serpent])
self.assertTrue(self.can_reach_location(lname.cc3b))
self.assertTrue(self.can_reach_location(lname.ow1))
def test_freeze_with_kick_boots(self) -> None:
self.collect_by_name([iname.double, iname.kick_boots])
self.assertFalse(self.can_reach_location(lname.th3))
self.assertFalse(self.can_reach_location(lname.ct1))
self.assertFalse(self.can_reach_location(lname.ct13))
self.assertFalse(self.can_reach_location(lname.ug3))
self.assertFalse(self.can_reach_location(lname.ug3b))
self.collect_by_name([iname.mercury, iname.serpent])
self.assertTrue(self.can_reach_location(lname.th3))
self.assertTrue(self.can_reach_location(lname.ct1))
self.assertTrue(self.can_reach_location(lname.ct13))
self.assertTrue(self.can_reach_location(lname.ug3))
self.assertTrue(self.can_reach_location(lname.ug3b))
def test_freeze_with_heavy_ring_and_tackle(self) -> None:
self.collect_by_name([iname.double, iname.heavy_ring, iname.tackle])
self.assertFalse(self.can_reach_location(lname.uw14))
self.collect_by_name([iname.mercury, iname.cockatrice])
self.assertTrue(self.can_reach_location(lname.uw14))
def test_freeze_with_cleansing(self) -> None:
self.collect_by_name([iname.double, iname.cleansing])
self.assertFalse(self.can_reach_location(lname.uy5))
self.collect_by_name([iname.mercury, iname.serpent])
self.assertTrue(self.can_reach_location(lname.uy5))
def test_freeze_with_nerfed_roc(self) -> None:
self.collect_by_name([iname.roc_wing, iname.heavy_ring, iname.tackle])
self.assertFalse(self.can_reach_entrance("Audience to Chapel"))
self.assertFalse(self.can_reach_location(lname.uw16b))
self.collect_by_name([iname.mercury, iname.cockatrice])
self.assertTrue(self.can_reach_entrance("Audience to Chapel"))
self.assertTrue(self.can_reach_location(lname.uw16b))
# Freeze spots requiring Double
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.collect_by_name([iname.double])
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ct26))
self.assertTrue(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.remove_by_name([iname.double])
# Freeze spots requiring Kick Boots
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.assertFalse(self.can_reach_location(lname.uw10))
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ct26))
self.assertTrue(self.can_reach_location(lname.ct26b))
self.assertTrue(self.can_reach_location(lname.uw10))
def test_freeze_with_nerfed_roc_and_double(self) -> None:
self.collect_by_name([iname.roc_wing, iname.heavy_ring, iname.tackle, iname.double])
self.assertFalse(self.can_reach_location(lname.ar30))
self.assertFalse(self.can_reach_location(lname.ar30b))
self.assertFalse(self.can_reach_location(lname.ct26))
self.assertFalse(self.can_reach_location(lname.ct26b))
self.collect_by_name([iname.mars, iname.cockatrice])
self.assertTrue(self.can_reach_location(lname.ar30))
self.assertTrue(self.can_reach_location(lname.ar30b))
self.assertTrue(self.can_reach_location(lname.ct26))
self.assertTrue(self.can_reach_location(lname.ct26b))
def test_freeze_with_nerfed_roc_and_kick_boots(self) -> None:
self.collect_by_name([iname.roc_wing, iname.heavy_ring, iname.tackle, iname.kick_boots])
self.assertFalse(self.can_reach_location(lname.uw10))
self.collect_by_name([iname.mars, iname.serpent])
self.assertTrue(self.can_reach_location(lname.uw10))
class VanillaMaidensTest(CVCotMTestBase):
def test_waterway_and_right_gallery_maidens(self) -> None:
self.collect_by_name([iname.double])
self.assertFalse(self.can_reach_entrance("Audience to Waterway"))
self.assertFalse(self.can_reach_entrance("Corridor to Gallery"))
# Gives access to Chapel Tower wherein we collect the locked Maiden Detonator item.
self.collect_by_name([iname.kick_boots])
self.assertTrue(self.can_reach_entrance("Audience to Waterway"))
self.assertTrue(self.can_reach_entrance("Corridor to Gallery"))
def test_left_gallery_maiden(self) -> None:
self.collect_by_name([iname.double, iname.heavy_ring])
self.assertFalse(self.can_reach_entrance("Audience to Gallery"))
self.collect_by_name([iname.roc_wing])
self.assertTrue(self.can_reach_entrance("Audience to Gallery"))
class MaidenDetonatorInPoolTest(CVCotMTestBase):
options = {
"iron_maiden_behavior": IronMaidenBehavior.option_detonator_in_pool
}
def test_maiden_detonator(self) -> None:
self.collect_by_name([iname.double, iname.heavy_ring, iname.kick_boots])
self.assertFalse(self.can_reach_entrance("Audience to Waterway"))
self.assertFalse(self.can_reach_entrance("Corridor to Gallery"))
self.assertFalse(self.can_reach_entrance("Audience to Gallery"))
self.collect_by_name([iname.ironmaidens])
self.assertTrue(self.can_reach_entrance("Audience to Waterway"))
self.assertTrue(self.can_reach_entrance("Corridor to Gallery"))
self.assertTrue(self.can_reach_entrance("Audience to Gallery"))

View File

@@ -294,6 +294,10 @@ class RandomCharmCosts(NamedRange):
return charms
class CharmCost(Range):
range_end = 6
class PlandoCharmCosts(OptionDict):
"""Allows setting a Charm's Notch costs directly, mapping {name: cost}.
This is set after any random Charm Notch costs, if applicable."""
@@ -303,6 +307,27 @@ class PlandoCharmCosts(OptionDict):
Optional(name): And(int, lambda n: 6 >= n >= 0, error="Charm costs must be integers in the range 0-6.") for name in charm_names
})
def __init__(self, value):
# To handle keys of random like other options, create an option instance from their values
# Additionally a vanilla keyword is added to plando individual charms to vanilla costs
# and default is disabled so as to not cause confusion
self.value = {}
for key, data in value.items():
if isinstance(data, str):
if data.lower() == "vanilla" and key in self.valid_keys:
self.value[key] = vanilla_costs[charm_names.index(key)]
continue
elif data.lower() == "default":
# default is too easily confused with vanilla but actually 0
# skip CharmCost resolution to fail schema afterwords
self.value[key] = data
continue
try:
self.value[key] = CharmCost.from_any(data).value
except ValueError as ex:
# will fail schema afterwords
self.value[key] = data
def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]:
for name, cost in self.value.items():
charm_costs[charm_names.index(name)] = cost

View File

@@ -0,0 +1,531 @@
BLOCKED_ASSOCIATIONS = [
# MAX_ARROWS_UPGRADE, MAX_BOMBS_UPGRADE, MAX_POWDER_UPGRADE
# arrows and bombs will be matched to arrow and bomb respectively through pluralization
"ARROWS",
"BOMBS",
"MAX",
"UPGRADE",
"TAIL", # TAIL_KEY
"ANGLER", # ANGLER_KEY
"FACE", # FACE_KEY
"BIRD", # BIRD_KEY
"SLIME", # SLIME_KEY
"NIGHTMARE",# NIGHTMARE_KEY
"BLUE", # BLUE_TUNIC
"RED", # RED_TUNIC
"TRADING", # TRADING_ITEM_*
"ITEM", # TRADING_ITEM_*
"BAD", # BAD_HEART_CONTAINER
"GOLD", # GOLD_LEAF
"MAGIC", # MAGIC_POWDER, MAGIC_ROD
"MESSAGE", # MESSAGE (Master Stalfos' Message)
"PEGASUS", # PEGASUS_BOOTS
"PIECE", # HEART_PIECE, PIECE_OF_POWER
"POWER", # POWER_BRACELET, PIECE_OF_POWER
"SINGLE", # SINGLE_ARROW
"STONE", # STONE_BEAK
"BEAK1",
"BEAK2",
"BEAK3",
"BEAK4",
"BEAK5",
"BEAK6",
"BEAK7",
"BEAK8",
"COMPASS1",
"COMPASS2",
"COMPASS3",
"COMPASS4",
"COMPASS5",
"COMPASS6",
"COMPASS7",
"COMPASS8",
"MAP1",
"MAP2",
"MAP3",
"MAP4",
"MAP5",
"MAP6",
"MAP7",
"MAP8",
]
# Single word synonyms for Link's Awakening items, for generic matching.
SYNONYMS = {
# POWER_BRACELET
'ANKLET': 'POWER_BRACELET',
'ARMLET': 'POWER_BRACELET',
'BAND': 'POWER_BRACELET',
'BANGLE': 'POWER_BRACELET',
'BRACER': 'POWER_BRACELET',
'CARRY': 'POWER_BRACELET',
'CIRCLET': 'POWER_BRACELET',
'CROISSANT': 'POWER_BRACELET',
'GAUNTLET': 'POWER_BRACELET',
'GLOVE': 'POWER_BRACELET',
'RING': 'POWER_BRACELET',
'STRENGTH': 'POWER_BRACELET',
# SHIELD
'AEGIS': 'SHIELD',
'BUCKLER': 'SHIELD',
'SHLD': 'SHIELD',
# BOW
'BALLISTA': 'BOW',
# HOOKSHOT
'GRAPPLE': 'HOOKSHOT',
'GRAPPLING': 'HOOKSHOT',
'ROPE': 'HOOKSHOT',
# MAGIC_ROD
'BEAM': 'MAGIC_ROD',
'CANE': 'MAGIC_ROD',
'STAFF': 'MAGIC_ROD',
'WAND': 'MAGIC_ROD',
# PEGASUS_BOOTS
'BOOT': 'PEGASUS_BOOTS',
'GREAVES': 'PEGASUS_BOOTS',
'RUN': 'PEGASUS_BOOTS',
'SHOE': 'PEGASUS_BOOTS',
'SPEED': 'PEGASUS_BOOTS',
# OCARINA
'FLUTE': 'OCARINA',
'RECORDER': 'OCARINA',
# FEATHER
'JUMP': 'FEATHER',
'PLUME': 'FEATHER',
'WING': 'FEATHER',
# SHOVEL
'DIG': 'SHOVEL',
# MAGIC_POWDER
'BAG': 'MAGIC_POWDER',
'CASE': 'MAGIC_POWDER',
'DUST': 'MAGIC_POWDER',
'POUCH': 'MAGIC_POWDER',
'SACK': 'MAGIC_POWDER',
# BOMB
'BLAST': 'BOMB',
'BOMBCHU': 'BOMB',
'FIRECRACKER': 'BOMB',
'TNT': 'BOMB',
# SWORD
'BLADE': 'SWORD',
'CUT': 'SWORD',
'DAGGER': 'SWORD',
'DIRK': 'SWORD',
'EDGE': 'SWORD',
'EPEE': 'SWORD',
'EXCALIBUR': 'SWORD',
'FALCHION': 'SWORD',
'KATANA': 'SWORD',
'KNIFE': 'SWORD',
'MACHETE': 'SWORD',
'MASAMUNE': 'SWORD',
'MURASAME': 'SWORD',
'SABER': 'SWORD',
'SABRE': 'SWORD',
'SCIMITAR': 'SWORD',
'SLASH': 'SWORD',
# FLIPPERS
'FLIPPER': 'FLIPPERS',
'SWIM': 'FLIPPERS',
# MEDICINE
'BOTTLE': 'MEDICINE',
'FLASK': 'MEDICINE',
'LEMONADE': 'MEDICINE',
'POTION': 'MEDICINE',
'TEA': 'MEDICINE',
# TAIL_KEY
# ANGLER_KEY
# FACE_KEY
# BIRD_KEY
# SLIME_KEY
# GOLD_LEAF
'HERB': 'GOLD_LEAF',
# RUPEES_20
'COIN': 'RUPEES_20',
'MONEY': 'RUPEES_20',
'RUPEE': 'RUPEES_20',
# RUPEES_50
# RUPEES_100
# RUPEES_200
# RUPEES_500
'GEM': 'RUPEES_500',
'JEWEL': 'RUPEES_500',
# SEASHELL
'CARAPACE': 'SEASHELL',
'CONCH': 'SEASHELL',
'SHELL': 'SEASHELL',
# MESSAGE (master stalfos message)
'NOTHING': 'MESSAGE',
'TRAP': 'MESSAGE',
# BOOMERANG
'BOOMER': 'BOOMERANG',
# HEART_PIECE
# BOWWOW
'BEAST': 'BOWWOW',
'PET': 'BOWWOW',
# ARROWS_10
# SINGLE_ARROW
'MISSILE': 'SINGLE_ARROW',
'QUIVER': 'SINGLE_ARROW',
# ROOSTER
'BIRD': 'ROOSTER',
'CHICKEN': 'ROOSTER',
'CUCCO': 'ROOSTER',
'FLY': 'ROOSTER',
'GRIFFIN': 'ROOSTER',
'GRYPHON': 'ROOSTER',
# MAX_POWDER_UPGRADE
# MAX_BOMBS_UPGRADE
# MAX_ARROWS_UPGRADE
# RED_TUNIC
# BLUE_TUNIC
'ARMOR': 'BLUE_TUNIC',
'MAIL': 'BLUE_TUNIC',
'SUIT': 'BLUE_TUNIC',
# HEART_CONTAINER
'TANK': 'HEART_CONTAINER',
# TOADSTOOL
'FUNGAL': 'TOADSTOOL',
'FUNGUS': 'TOADSTOOL',
'MUSHROOM': 'TOADSTOOL',
'SHROOM': 'TOADSTOOL',
# GUARDIAN_ACORN
'NUT': 'GUARDIAN_ACORN',
'SEED': 'GUARDIAN_ACORN',
# KEY
'DOOR': 'KEY',
'GATE': 'KEY',
'KEY': 'KEY', # Without this, foreign keys show up as nightmare keys
'LOCK': 'KEY',
'PANEL': 'KEY',
'UNLOCK': 'KEY',
# NIGHTMARE_KEY
# MAP
# COMPASS
# STONE_BEAK
'FOSSIL': 'STONE_BEAK',
'RELIC': 'STONE_BEAK',
# SONG1
'BOLERO': 'SONG1',
'LULLABY': 'SONG1',
'MELODY': 'SONG1',
'MINUET': 'SONG1',
'NOCTURNE': 'SONG1',
'PRELUDE': 'SONG1',
'REQUIEM': 'SONG1',
'SERENADE': 'SONG1',
'SONG': 'SONG1',
# SONG2
'FISH': 'SONG2',
'SURF': 'SONG2',
# SONG3
'FROG': 'SONG3',
# INSTRUMENT1
'CELLO': 'INSTRUMENT1',
'GUITAR': 'INSTRUMENT1',
'LUTE': 'INSTRUMENT1',
'VIOLIN': 'INSTRUMENT1',
# INSTRUMENT2
'HORN': 'INSTRUMENT2',
# INSTRUMENT3
'BELL': 'INSTRUMENT3',
'CHIME': 'INSTRUMENT3',
# INSTRUMENT4
'HARP': 'INSTRUMENT4',
'KANTELE': 'INSTRUMENT4',
# INSTRUMENT5
'MARIMBA': 'INSTRUMENT5',
'XYLOPHONE': 'INSTRUMENT5',
# INSTRUMENT6 (triangle)
# INSTRUMENT7
'KEYBOARD': 'INSTRUMENT7',
'ORGAN': 'INSTRUMENT7',
'PIANO': 'INSTRUMENT7',
# INSTRUMENT8
'DRUM': 'INSTRUMENT8',
# TRADING_ITEM_YOSHI_DOLL
'DINOSAUR': 'TRADING_ITEM_YOSHI_DOLL',
'DRAGON': 'TRADING_ITEM_YOSHI_DOLL',
'TOY': 'TRADING_ITEM_YOSHI_DOLL',
# TRADING_ITEM_RIBBON
'HAIRBAND': 'TRADING_ITEM_RIBBON',
'HAIRPIN': 'TRADING_ITEM_RIBBON',
# TRADING_ITEM_DOG_FOOD
'CAN': 'TRADING_ITEM_DOG_FOOD',
# TRADING_ITEM_BANANAS
'BANANA': 'TRADING_ITEM_BANANAS',
# TRADING_ITEM_STICK
'BRANCH': 'TRADING_ITEM_STICK',
'TWIG': 'TRADING_ITEM_STICK',
# TRADING_ITEM_HONEYCOMB
'BEEHIVE': 'TRADING_ITEM_HONEYCOMB',
'HIVE': 'TRADING_ITEM_HONEYCOMB',
'HONEY': 'TRADING_ITEM_HONEYCOMB',
# TRADING_ITEM_PINEAPPLE
'FOOD': 'TRADING_ITEM_PINEAPPLE',
'FRUIT': 'TRADING_ITEM_PINEAPPLE',
'GOURD': 'TRADING_ITEM_PINEAPPLE',
# TRADING_ITEM_HIBISCUS
'FLOWER': 'TRADING_ITEM_HIBISCUS',
'PETAL': 'TRADING_ITEM_HIBISCUS',
# TRADING_ITEM_LETTER
'CARD': 'TRADING_ITEM_LETTER',
'MESSAGE': 'TRADING_ITEM_LETTER',
# TRADING_ITEM_BROOM
'SWEEP': 'TRADING_ITEM_BROOM',
# TRADING_ITEM_FISHING_HOOK
'CLAW': 'TRADING_ITEM_FISHING_HOOK',
# TRADING_ITEM_NECKLACE
'AMULET': 'TRADING_ITEM_NECKLACE',
'BEADS': 'TRADING_ITEM_NECKLACE',
'PEARLS': 'TRADING_ITEM_NECKLACE',
'PENDANT': 'TRADING_ITEM_NECKLACE',
'ROSARY': 'TRADING_ITEM_NECKLACE',
# TRADING_ITEM_SCALE
# TRADING_ITEM_MAGNIFYING_GLASS
'FINDER': 'TRADING_ITEM_MAGNIFYING_GLASS',
'LENS': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'SCOPE': 'TRADING_ITEM_MAGNIFYING_GLASS',
'XRAY': 'TRADING_ITEM_MAGNIFYING_GLASS',
# PIECE_OF_POWER
'TRIANGLE': 'PIECE_OF_POWER',
'POWER': 'PIECE_OF_POWER',
'TRIFORCE': 'PIECE_OF_POWER',
}
# For generic multi-word matches.
PHRASES = {
'BIG KEY': 'NIGHTMARE_KEY',
'BOSS KEY': 'NIGHTMARE_KEY',
'HEART PIECE': 'HEART_PIECE',
'PIECE OF HEART': 'HEART_PIECE',
}
# All following will only be used to match items for the specific game.
# Item names will be uppercased when comparing.
# Can be multi-word.
GAME_SPECIFIC_PHRASES = {
'Final Fantasy': {
'OXYALE': 'MEDICINE',
'VORPAL': 'SWORD',
'XCALBER': 'SWORD',
},
'The Legend of Zelda': {
'WATER OF LIFE': 'MEDICINE',
},
'The Legend of Zelda - Oracle of Seasons': {
'RARE PEACH STONE': 'HEART_PIECE',
},
'Noita': {
'ALL-SEEING EYE': 'TRADING_ITEM_MAGNIFYING_GLASS', # lets you find secrets
},
'Ocarina of Time': {
'COJIRO': 'ROOSTER',
},
'SMZ3': {
'BIGKEY': 'NIGHTMARE_KEY',
'BYRNA': 'MAGIC_ROD',
'HEARTPIECE': 'HEART_PIECE',
'POWERBOMB': 'BOMB',
'SOMARIA': 'MAGIC_ROD',
'SUPER': 'SINGLE_ARROW',
},
'Sonic Adventure 2 Battle': {
'CHAOS EMERALD': 'PIECE_OF_POWER',
},
'Super Mario 64': {
'POWER STAR': 'PIECE_OF_POWER',
},
'Super Mario World': {
'P-BALLOON': 'FEATHER',
},
'Super Metroid': {
'POWER BOMB': 'BOMB',
},
'The Witness': {
'BONK': 'BOMB',
'BUNKER LASER': 'INSTRUMENT4',
'DESERT LASER': 'INSTRUMENT5',
'JUNGLE LASER': 'INSTRUMENT4',
'KEEP LASER': 'INSTRUMENT7',
'MONASTERY LASER': 'INSTRUMENT1',
'POWER SURGE': 'BOMB',
'PUZZLE SKIP': 'GOLD_LEAF',
'QUARRY LASER': 'INSTRUMENT8',
'SHADOWS LASER': 'INSTRUMENT1',
'SHORTCUTS': 'KEY',
'SLOWNESS': 'BOMB',
'SWAMP LASER': 'INSTRUMENT2',
'SYMMETRY LASER': 'INSTRUMENT6',
'TOWN LASER': 'INSTRUMENT3',
'TREEHOUSE LASER': 'INSTRUMENT2',
'WATER PUMPS': 'KEY',
},
'TUNIC': {
"AURA'S GEM": 'SHIELD', # card that enhances the shield
'DUSTY': 'TRADING_ITEM_BROOM', # a broom
'HERO RELIC - HP': 'TRADING_ITEM_HIBISCUS',
'HERO RELIC - MP': 'TOADSTOOL',
'HERO RELIC - SP': 'FEATHER',
'HP BERRY': 'GUARDIAN_ACORN',
'HP OFFERING': 'TRADING_ITEM_HIBISCUS', # a flower
'LUCKY CUP': 'HEART_CONTAINER', # card with a heart on it
'INVERTED ASH': 'MEDICINE', # card with a potion on it
'MAGIC ORB': 'HOOKSHOT',
'MP BERRY': 'GUARDIAN_ACORN',
'MP OFFERING': 'TOADSTOOL', # a mushroom
'QUESTAGON': 'PIECE_OF_POWER', # triforce piece equivalent
'SP OFFERING': 'FEATHER', # a feather
'SPRING FALLS': 'TRADING_ITEM_HIBISCUS', # a flower
},
'FNaFW': {
'Freddy': 'TRADING_ITEM_YOSHI_DOLL', # all of these are animatronics, aka dolls.
'Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Toy Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Toy Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Toy Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Mangle': 'TRADING_ITEM_YOSHI_DOLL',
'Balloon Boy': 'TRADING_ITEM_YOSHI_DOLL',
'JJ': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom BB': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Mangle': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Shadow Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Marionette': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Marionette': 'TRADING_ITEM_YOSHI_DOLL',
'Golden Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Paperpals': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Endo 01': 'TRADING_ITEM_YOSHI_DOLL',
'Endo 02': 'TRADING_ITEM_YOSHI_DOLL',
'Plushtrap': 'TRADING_ITEM_YOSHI_DOLL',
'Endoplush': 'TRADING_ITEM_YOSHI_DOLL',
'Springtrap': 'TRADING_ITEM_YOSHI_DOLL',
'RWQFSFASXC': 'TRADING_ITEM_YOSHI_DOLL',
'Crying Child': 'TRADING_ITEM_YOSHI_DOLL',
'Funtime Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Fredbear': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare': 'TRADING_ITEM_YOSHI_DOLL',
'Fredbear': 'TRADING_ITEM_YOSHI_DOLL',
'Spring Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Jack-O-Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare BB': 'TRADING_ITEM_YOSHI_DOLL',
'Coffee': 'TRADING_ITEM_YOSHI_DOLL',
'Jack-O-Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Purpleguy': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmarionne': 'TRADING_ITEM_YOSHI_DOLL',
'Mr. Chipper': 'TRADING_ITEM_YOSHI_DOLL',
'Animdude': 'TRADING_ITEM_YOSHI_DOLL',
'Progressive Endoskeleton': 'BLUE_TUNIC', # basically armor you wear to give you more defense
'25 Tokens': 'RUPEES_20', # money
'50 Tokens': 'RUPEES_50',
'100 Tokens': 'RUPEES_100',
'250 Tokens': 'RUPEES_200',
'500 Tokens': 'RUPEES_500',
'1000 Tokens': 'RUPEES_500',
'2500 Tokens': 'RUPEES_500',
'5000 Tokens': 'RUPEES_500',
},
}

View File

@@ -98,6 +98,7 @@ class ItemName:
HEART_CONTAINER = "Heart Container"
BAD_HEART_CONTAINER = "Bad Heart Container"
TOADSTOOL = "Toadstool"
GUARDIAN_ACORN = "Guardian Acorn"
KEY = "Key"
KEY1 = "Small Key (Tail Cave)"
KEY2 = "Small Key (Bottle Grotto)"
@@ -173,6 +174,7 @@ class ItemName:
TRADING_ITEM_NECKLACE = "Necklace"
TRADING_ITEM_SCALE = "Scale"
TRADING_ITEM_MAGNIFYING_GLASS = "Magnifying Glass"
PIECE_OF_POWER = "Piece Of Power"
trade_item_prog = ItemClassification.progression
@@ -219,6 +221,7 @@ links_awakening_items = [
ItemData(ItemName.HEART_CONTAINER, "HEART_CONTAINER", ItemClassification.useful),
#ItemData(ItemName.BAD_HEART_CONTAINER, "BAD_HEART_CONTAINER", ItemClassification.trap),
ItemData(ItemName.TOADSTOOL, "TOADSTOOL", ItemClassification.progression),
ItemData(ItemName.GUARDIAN_ACORN, "GUARDIAN_ACORN", ItemClassification.filler),
DungeonItemData(ItemName.KEY, "KEY", ItemClassification.progression),
DungeonItemData(ItemName.KEY1, "KEY1", ItemClassification.progression),
DungeonItemData(ItemName.KEY2, "KEY2", ItemClassification.progression),
@@ -293,7 +296,8 @@ links_awakening_items = [
TradeItemData(ItemName.TRADING_ITEM_FISHING_HOOK, "TRADING_ITEM_FISHING_HOOK", trade_item_prog, "Grandma (Animal Village)"),
TradeItemData(ItemName.TRADING_ITEM_NECKLACE, "TRADING_ITEM_NECKLACE", trade_item_prog, "Fisher (Martha's Bay)"),
TradeItemData(ItemName.TRADING_ITEM_SCALE, "TRADING_ITEM_SCALE", trade_item_prog, "Mermaid (Martha's Bay)"),
TradeItemData(ItemName.TRADING_ITEM_MAGNIFYING_GLASS, "TRADING_ITEM_MAGNIFYING_GLASS", trade_item_prog, "Mermaid Statue (Martha's Bay)")
TradeItemData(ItemName.TRADING_ITEM_MAGNIFYING_GLASS, "TRADING_ITEM_MAGNIFYING_GLASS", trade_item_prog, "Mermaid Statue (Martha's Bay)"),
ItemData(ItemName.PIECE_OF_POWER, "PIECE_OF_POWER", ItemClassification.filler),
]
ladxr_item_to_la_item_name = {

View File

@@ -87,6 +87,8 @@ CHEST_ITEMS = {
TOADSTOOL: 0x50,
GUARDIAN_ACORN: 0x51,
HEART_PIECE: 0x80,
BOWWOW: 0x81,
ARROWS_10: 0x82,
@@ -128,4 +130,6 @@ CHEST_ITEMS = {
TRADING_ITEM_NECKLACE: 0xA2,
TRADING_ITEM_SCALE: 0xA3,
TRADING_ITEM_MAGNIFYING_GLASS: 0xA4,
PIECE_OF_POWER: 0xA5,
}

View File

@@ -44,6 +44,8 @@ BAD_HEART_CONTAINER = "BAD_HEART_CONTAINER"
TOADSTOOL = "TOADSTOOL"
GUARDIAN_ACORN = "GUARDIAN_ACORN"
KEY = "KEY"
KEY1 = "KEY1"
KEY2 = "KEY2"
@@ -124,3 +126,5 @@ TRADING_ITEM_FISHING_HOOK = "TRADING_ITEM_FISHING_HOOK"
TRADING_ITEM_NECKLACE = "TRADING_ITEM_NECKLACE"
TRADING_ITEM_SCALE = "TRADING_ITEM_SCALE"
TRADING_ITEM_MAGNIFYING_GLASS = "TRADING_ITEM_MAGNIFYING_GLASS"
PIECE_OF_POWER = "PIECE_OF_POWER"

View File

@@ -835,6 +835,7 @@ ItemSpriteTable:
db $46, $1C ; NIGHTMARE_KEY8
db $46, $1C ; NIGHTMARE_KEY9
db $4C, $1C ; Toadstool
db $AE, $14 ; Guardian Acorn
LargeItemSpriteTable:
db $AC, $02, $AC, $22 ; heart piece
@@ -874,6 +875,7 @@ LargeItemSpriteTable:
db $D8, $0D, $DA, $0D ; TradeItem12
db $DC, $0D, $DE, $0D ; TradeItem13
db $E0, $0D, $E2, $0D ; TradeItem14
db $14, $42, $14, $62 ; Piece Of Power
ItemMessageTable:
db $90, $3D, $89, $93, $94, $95, $96, $97, $98, $99, $9A, $9B, $9C, $9D, $D9, $A2
@@ -888,7 +890,7 @@ ItemMessageTable:
; $80
db $4F, $C8, $CA, $CB, $E2, $E3, $E4, $CC, $CD, $2A, $2B, $C9, $C9, $C9, $C9, $C9
db $C9, $C9, $C9, $C9, $C9, $C9, $B8, $44, $C9, $C9, $C9, $C9, $C9, $C9, $C9, $C9
db $C9, $C9, $C9, $C9, $9D
db $C9, $C9, $C9, $C9, $9D, $C9
RenderDroppedKey:
;TODO: See EntityInitKeyDropPoint for a few special cases to unload.

View File

@@ -170,7 +170,7 @@ ItemNamePointers:
dw ItemNameNightmareKey8
dw ItemNameNightmareKey9
dw ItemNameToadstool
dw ItemNameNone ; 0x51
dw ItemNameGuardianAcorn
dw ItemNameNone ; 0x52
dw ItemNameNone ; 0x53
dw ItemNameNone ; 0x54
@@ -254,6 +254,7 @@ ItemNamePointers:
dw ItemTradeQuest12
dw ItemTradeQuest13
dw ItemTradeQuest14
dw ItemPieceOfPower
ItemNameNone:
db m"NONE", $ff
@@ -418,6 +419,8 @@ ItemNameNightmareKey9:
db m"Got the {NIGHTMARE_KEY9}", $ff
ItemNameToadstool:
db m"Got the {TOADSTOOL}", $ff
ItemNameGuardianAcorn:
db m"Got a Guardian Acorn", $ff
ItemNameHeartPiece:
db m"Got the {HEART_PIECE}", $ff
@@ -496,5 +499,8 @@ ItemTradeQuest13:
db m"You've got the Scale", $ff
ItemTradeQuest14:
db m"You've got the Magnifying Lens", $ff
ItemPieceOfPower:
db m"You've got a Piece of Power", $ff
MultiNamePointers:

View File

@@ -24,14 +24,10 @@ notSpecialSideView:
ld a, $06 ; giveItemMultiworld
rst 8
ldh a, [$F1] ; Load active sprite variant to see if this is just a normal small key
cp $1A
jr z, isAKey
;Show message (if not a key)
;Show message
ld a, $0A ; showMessageMultiworld
rst 8
isAKey:
ret
"""))
rom.patch(0x03, 0x24B7, "3E", "3E") # sanity check

View File

@@ -505,6 +505,19 @@ class InGameHints(DefaultOnToggle):
display_name = "In-game Hints"
class ForeignItemIcons(Choice):
"""
Choose how to display foreign items.
[Guess By Name] Foreign items can look like any Link's Awakening item.
[Indicate Progression] Foreign items are either a Piece of Power (progression) or Guardian Acorn (non-progression).
"""
display_name = "Foreign Item Icons"
option_guess_by_name = 0
option_indicate_progression = 1
default = option_guess_by_name
ladx_option_groups = [
OptionGroup("Goal Options", [
Goal,
@@ -537,6 +550,7 @@ ladx_option_groups = [
LinkPalette,
Palette,
TextShuffle,
ForeignItemIcons,
APTitleScreen,
GfxMod,
Music,
@@ -571,6 +585,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
gfxmod: GfxMod
palette: Palette
text_shuffle: TextShuffle
foreign_item_icons: ForeignItemIcons
shuffle_nightmare_keys: ShuffleNightmareKeys
shuffle_small_keys: ShuffleSmallKeys
shuffle_maps: ShuffleMaps

View File

@@ -4,6 +4,7 @@ import os
import pkgutil
import tempfile
import typing
import re
import bsdiff4
@@ -12,6 +13,7 @@ from BaseClasses import Entrance, Item, ItemClassification, Location, Tutorial,
from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World
from .Common import *
from . import ItemIconGuessing
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name,
links_awakening_item_name_groups)
@@ -380,66 +382,36 @@ class LinksAwakeningWorld(World):
name_cache = {}
# Tries to associate an icon from another game with an icon we have
def guess_icon_for_other_world(self, other):
def guess_icon_for_other_world(self, foreign_item):
if not self.name_cache:
forbidden = [
"TRADING",
"ITEM",
"BAD",
"SINGLE",
"UPGRADE",
"BLUE",
"RED",
"NOTHING",
"MESSAGE",
]
for item in ladxr_item_to_la_item_name.keys():
self.name_cache[item] = item
splits = item.split("_")
self.name_cache["".join(splits)] = item
if 'RUPEES' in splits:
self.name_cache["".join(reversed(splits))] = item
for word in item.split("_"):
if word not in forbidden and not word.isnumeric():
if word not in ItemIconGuessing.BLOCKED_ASSOCIATIONS and not word.isnumeric():
self.name_cache[word] = item
others = {
'KEY': 'KEY',
'COMPASS': 'COMPASS',
'BIGKEY': 'NIGHTMARE_KEY',
'MAP': 'MAP',
'FLUTE': 'OCARINA',
'SONG': 'OCARINA',
'MUSHROOM': 'TOADSTOOL',
'GLOVE': 'POWER_BRACELET',
'BOOT': 'PEGASUS_BOOTS',
'SHOE': 'PEGASUS_BOOTS',
'SHOES': 'PEGASUS_BOOTS',
'SANCTUARYHEARTCONTAINER': 'HEART_CONTAINER',
'BOSSHEARTCONTAINER': 'HEART_CONTAINER',
'HEARTCONTAINER': 'HEART_CONTAINER',
'ENERGYTANK': 'HEART_CONTAINER',
'MISSILE': 'SINGLE_ARROW',
'BOMBS': 'BOMB',
'BLUEBOOMERANG': 'BOOMERANG',
'MAGICMIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MESSAGE': 'TRADING_ITEM_LETTER',
# TODO: Also use AP item name
}
for name in others.values():
for name in ItemIconGuessing.SYNONYMS.values():
assert name in self.name_cache, name
assert name in CHEST_ITEMS, name
self.name_cache.update(others)
uppered = other.upper()
if "BIG KEY" in uppered:
return 'NIGHTMARE_KEY'
possibles = other.upper().split(" ")
rejoined = "".join(possibles)
if rejoined in self.name_cache:
return self.name_cache[rejoined]
self.name_cache.update(ItemIconGuessing.SYNONYMS)
pluralizations = {k + "S": v for k, v in self.name_cache.items()}
self.name_cache = pluralizations | self.name_cache
uppered = foreign_item.name.upper()
foreign_game = self.multiworld.game[foreign_item.player]
phrases = ItemIconGuessing.PHRASES.copy()
if foreign_game in ItemIconGuessing.GAME_SPECIFIC_PHRASES:
phrases.update(ItemIconGuessing.GAME_SPECIFIC_PHRASES[foreign_game])
for phrase, icon in phrases.items():
if phrase in uppered:
return icon
# pattern for breaking down camelCase, also separates out digits
pattern = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=\d)")
possibles = pattern.sub(' ', foreign_item.name).upper()
for ch in "[]()_":
possibles = possibles.replace(ch, " ")
possibles = possibles.split()
for name in possibles:
if name in self.name_cache:
return self.name_cache[name]
@@ -465,8 +437,15 @@ class LinksAwakeningWorld(World):
# If the item name contains "sword", use a sword icon, etc
# Otherwise, use a cute letter as the icon
elif self.options.foreign_item_icons == 'guess_by_name':
loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item)
loc.ladxr_item.custom_item_name = loc.item.name
else:
loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item.name)
if loc.item.advancement:
loc.ladxr_item.item = 'PIECE_OF_POWER'
else:
loc.ladxr_item.item = 'GUARDIAN_ACORN'
loc.ladxr_item.custom_item_name = loc.item.name
if loc.item:

Binary file not shown.

View File

@@ -1,7 +1,7 @@
import os
import unittest
from ..static_logic import HASHES
from ..static_logic import HASHES, PANELS_BY_ROOM
from ..utils.pickle_static_data import hash_file
@@ -14,3 +14,8 @@ class TestDatafile(unittest.TestCase):
"LL1.yaml hash does not match generated.dat. Please regenerate using 'python worlds/lingo/utils/pickle_static_data.py'")
self.assertEqual(ids_file_hash, HASHES["ids.yaml"],
"ids.yaml hash does not match generated.dat. Please regenerate using 'python worlds/lingo/utils/pickle_static_data.py'")
def test_panel_doors_are_set(self) -> None:
# This panel is defined earlier in the file than the panel door, so we want to check that the panel door is
# correctly applied.
self.assertNotEqual(PANELS_BY_ROOM["Outside The Agreeable"]["FIVE (1)"].panel_door, None)

View File

@@ -111,6 +111,16 @@ def load_static_data(ll1_path, ids_path):
with open(ll1_path, "r") as file:
config = Utils.parse_yaml(file)
# We have to process all panel doors first so that panels can see what panel doors they're in even if they're
# defined earlier in the file than the panel door.
for room_name, room_data in config.items():
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)
# Process the rest of the room.
for room_name, room_data in config.items():
process_room(room_name, room_data)
@@ -515,12 +525,6 @@ 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()

View File

@@ -11,7 +11,6 @@ class DLCMusicPacks(OptionSet):
Note: The [Just As Planned] DLC contains all [Muse Plus] songs.
"""
display_name = "DLC Packs"
default = {}
valid_keys = [dlc for dlc in MuseDashCollections.DLC]
@@ -142,7 +141,6 @@ class ChosenTraps(OptionSet):
Note: SFX traps are only available if [Just as Planned] DLC songs are enabled.
"""
display_name = "Chosen Traps"
default = {}
valid_keys = {trap for trap in MuseDashCollections.trap_items.keys()}

View File

@@ -397,13 +397,13 @@ def _init() -> None:
label = []
for word in map_name[4:].split("_"):
# 1F, B1F, 2R, etc.
re_match = re.match("^B?\d+[FRP]$", word)
re_match = re.match(r"^B?\d+[FRP]$", word)
if re_match:
label.append(word)
continue
# Route 103, Hall 1, House 5, etc.
re_match = re.match("^([A-Z]+)(\d+)$", word)
re_match = re.match(r"^([A-Z]+)(\d+)$", word)
if re_match:
label.append(re_match.group(1).capitalize())
label.append(re_match.group(2).lstrip("0"))
@@ -1459,9 +1459,6 @@ def _init() -> None:
for warp, destination in extracted_data["warps"].items():
data.warp_map[warp] = None if destination == "" else destination
if encoded_warp not in data.warp_map:
data.warp_map[encoded_warp] = None
# Create trainer data
for i, trainer_json in enumerate(extracted_data["trainers"]):
party_json = trainer_json["party"]

View File

@@ -416,13 +416,16 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
)
# Dewford Town
entrance = get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH")
set_rule(
get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH"),
entrance,
lambda state:
state.can_reach("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN", "Entrance", world.player)
and state.has("EVENT_TALK_TO_MR_STONE", world.player)
and state.has("EVENT_DELIVER_LETTER", world.player)
)
world.multiworld.register_indirect_condition(
get_entrance("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN").parent_region, entrance)
set_rule(
get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN"),
lambda state:
@@ -451,14 +454,17 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
)
# Route 109
entrance = get_entrance("REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN")
set_rule(
get_entrance("REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN"),
entrance,
lambda state:
state.can_reach("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN", "Entrance", world.player)
and state.can_reach("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH", "Entrance", world.player)
and state.has("EVENT_TALK_TO_MR_STONE", world.player)
and state.has("EVENT_DELIVER_LETTER", world.player)
)
world.multiworld.register_indirect_condition(
get_entrance("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN").parent_region, entrance)
set_rule(
get_entrance("REGION_ROUTE109/BEACH -> REGION_ROUTE109/SEA"),
hm_rules["HM03 Surf"]

View File

@@ -3,10 +3,21 @@ from dataclasses import dataclass
from Options import DefaultOnToggle, Range, Toggle, DeathLink, Choice, PerGameCommonOptions, OptionSet, OptionGroup
from .Items import action_item_table
class EnableCoinStars(DefaultOnToggle):
"""Disable to Ignore 100 Coin Stars. You can still collect them, but they don't do anything.
Removes 15 locations from the pool."""
class EnableCoinStars(Choice):
"""
Determine logic for 100 Coin Stars.
Off - Removed from pool. You can still collect them, but they don't do anything.
Optimal for ignoring 100 Coin Stars entirely. Removes 15 locations from the pool.
On - Kept in pool, potentially randomized.
Vanilla - Kept in pool, but NOT randomized.
"""
display_name = "Enable 100 Coin Stars"
option_off = 0
option_on = 1
option_vanilla = 2
class StrictCapRequirements(DefaultOnToggle):

View File

@@ -104,7 +104,11 @@ class SM64World(World):
# 1Up Mushrooms
self.multiworld.itempool += [self.create_item("1Up Mushroom") for i in range(0,self.filler_count)]
# Power Stars
self.multiworld.itempool += [self.create_item("Power Star") for i in range(0,self.number_of_stars)]
star_range = self.number_of_stars
# Vanilla 100 Coin stars have to removed from the pool if other max star increasing options are active.
if self.options.enable_coin_stars == "vanilla":
star_range -= 15
self.multiworld.itempool += [self.create_item("Power Star") for i in range(0,star_range)]
# Keys
if (not self.options.progressive_keys):
key1 = self.create_item("Basement Key")
@@ -166,6 +170,23 @@ class SM64World(World):
self.multiworld.get_location("Wing Mario Over the Rainbow 1Up Block", self.player).place_locked_item(self.create_item("1Up Mushroom"))
self.multiworld.get_location("Bowser in the Sky 1Up Block", self.player).place_locked_item(self.create_item("1Up Mushroom"))
if (self.options.enable_coin_stars == "vanilla"):
self.multiworld.get_location("BoB: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("WF: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("JRB: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("CCM: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("BBH: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("HMC: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("LLL: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("SSL: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("DDD: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("SL: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("WDW: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("TTM: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("THI: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("TTC: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
self.multiworld.get_location("RR: 100 Coins", self.player).place_locked_item(self.create_item("Power Star"))
def get_filler_item_name(self) -> str:
return "1Up Mushroom"

View File

@@ -281,7 +281,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Material.coal: self.mine.can_mine_in_the_mines_floor_41_80() | self.tool.has_tool(Tool.pan),
Material.fiber: True_(),
Material.hardwood: self.tool.has_tool(Tool.axe, ToolMaterial.copper) & (self.region.can_reach(Region.secret_woods) | self.region.can_reach(Region.island_west)),
Material.moss: True_(),
Material.moss: self.season.has_any_not_winter() & (self.tool.has_tool(Tool.scythe) | self.combat.has_any_weapon) & self.region.can_reach(Region.forest),
Material.sap: self.ability.can_chop_trees(),
Material.stone: self.tool.has_tool(Tool.pickaxe),
Material.wood: self.tool.has_tool(Tool.axe),

View File

@@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
def force_change_options_if_incompatible(world_options: options.StardewValleyOptions, player: int, player_name: str) -> None:
force_ginger_island_inclusion_when_goal_is_ginger_island_related(world_options, player, player_name)
force_walnutsanity_deactivation_when_ginger_island_is_excluded(world_options, player, player_name)
force_qi_special_orders_deactivation_when_ginger_island_is_excluded(world_options, player, player_name)
force_accessibility_to_full_when_goal_requires_all_locations(player, player_name, world_options)
@@ -35,6 +36,17 @@ def force_walnutsanity_deactivation_when_ginger_island_is_excluded(world_options
f"Ginger Island was excluded from {player} ({player_name})'s world, so walnutsanity was force disabled")
def force_qi_special_orders_deactivation_when_ginger_island_is_excluded(world_options: options.StardewValleyOptions, player: int, player_name: str):
ginger_island_is_excluded = world_options.exclude_ginger_island == options.ExcludeGingerIsland.option_true
qi_board_is_active = world_options.special_order_locations.value & options.SpecialOrderLocations.value_qi
if ginger_island_is_excluded and qi_board_is_active:
original_option_name = world_options.special_order_locations.current_option_name
world_options.special_order_locations.value -= options.SpecialOrderLocations.value_qi
logger.warning(f"Mr. Qi's Special Orders requires Ginger Island. "
f"Ginger Island was excluded from {player} ({player_name})'s world, so Special Order Locations was changed from {original_option_name} to {world_options.special_order_locations.current_option_name}")
def force_accessibility_to_full_when_goal_requires_all_locations(player, player_name, world_options):
goal_is_allsanity = world_options.goal == options.Goal.option_allsanity
goal_is_perfection = world_options.goal == options.Goal.option_perfection

View File

@@ -82,3 +82,34 @@ class TestGingerIslandExclusionOverridesWalnutsanity(unittest.TestCase):
force_change_options_if_incompatible(world_options, 1, "Tester")
self.assertEqual(world_options.walnutsanity.value, original_walnutsanity_choice)
class TestGingerIslandExclusionOverridesQisSpecialOrders(unittest.TestCase):
def test_given_ginger_island_excluded_when_generate_then_qis_special_orders_are_forced_disabled(self):
special_order_options = options.SpecialOrderLocations.options
for special_order in special_order_options.keys():
with self.subTest(f"Special order: {special_order}"):
world_options = fill_dataclass_with_default({
options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true,
options.SpecialOrderLocations: special_order
})
force_change_options_if_incompatible(world_options, 1, "Tester")
self.assertEqual(world_options.special_order_locations.value & options.SpecialOrderLocations.value_qi, 0)
def test_given_ginger_island_related_goal_and_ginger_island_excluded_when_generate_then_special_orders_is_not_changed(self):
for goal in [options.Goal.option_greatest_walnut_hunter, options.Goal.option_perfection]:
special_order_options = options.SpecialOrderLocations.options
for special_order, original_special_order_value in special_order_options.items():
with self.subTest(f"Special order: {special_order}"):
world_options = fill_dataclass_with_default({
options.Goal: goal,
options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true,
options.SpecialOrderLocations: special_order
})
force_change_options_if_incompatible(world_options, 1, "Tester")
self.assertEqual(world_options.special_order_locations.value, original_special_order_value)

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
@@ -10,6 +11,7 @@ from .er_scripts import create_er_regions
from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage)
from .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection
from decimal import Decimal, ROUND_HALF_UP
@@ -127,11 +129,21 @@ class TunicWorld(World):
self.options.shuffle_ladders.value = passthrough["shuffle_ladders"]
self.options.fixed_shop.value = self.options.fixed_shop.option_false
self.options.laurels_location.value = self.options.laurels_location.option_anywhere
self.options.combat_logic.value = passthrough["combat_logic"]
@classmethod
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
@@ -190,10 +202,12 @@ class TunicWorld(World):
def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem:
item_data = item_table[name]
return TunicItem(name, classification or item_data.classification, self.item_name_to_id[name], self.player)
# if item_data.combat_ic is None, it'll take item_data.classification instead
itemclass: ItemClassification = ((item_data.combat_ic if self.options.combat_logic else None)
or item_data.classification)
return TunicItem(name, classification or itemclass, self.item_name_to_id[name], self.player)
def create_items(self) -> None:
tunic_items: List[TunicItem] = []
self.slot_data_items = []
@@ -322,15 +336,15 @@ class TunicWorld(World):
self.ability_unlocks["Pages 42-43 (Holy Cross)"] = passthrough["Hexagon Quest Holy Cross"]
self.ability_unlocks["Pages 52-53 (Icebolt)"] = passthrough["Hexagon Quest Icebolt"]
# ladder rando uses ER with vanilla connections, so that we're not managing more rules files
if self.options.entrance_rando or self.options.shuffle_ladders:
# Ladders and Combat Logic uses ER rules with vanilla connections for easier maintenance
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
portal_pairs = create_er_regions(self)
if self.options.entrance_rando:
# these get interpreted by the game to tell it which entrances to connect
for portal1, portal2 in portal_pairs.items():
self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination()
else:
# for non-ER, non-ladders
# uses the original rules, easier to navigate and reference
for region_name in tunic_regions:
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
@@ -351,7 +365,8 @@ class TunicWorld(World):
victory_region.locations.append(victory_location)
def set_rules(self) -> None:
if self.options.entrance_rando or self.options.shuffle_ladders:
# same reason as in create_regions, could probably be put into create_regions
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
set_er_location_rules(self)
else:
set_region_rules(self)
@@ -360,6 +375,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: {}})
@@ -426,6 +454,7 @@ class TunicWorld(World):
"maskless": self.options.maskless.value,
"entrance_rando": int(bool(self.options.entrance_rando.value)),
"shuffle_ladders": self.options.shuffle_ladders.value,
"combat_logic": self.options.combat_logic.value,
"Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"],
"Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"],
"Hexagon Quest Icebolt": self.ability_unlocks["Pages 52-53 (Icebolt)"],

View File

@@ -0,0 +1,422 @@
from typing import Dict, List, NamedTuple, Tuple, Optional
from enum import IntEnum
from collections import defaultdict
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
class AreaStats(NamedTuple):
att_level: int
def_level: int
potion_level: int # all 3 are before your first bonfire after getting the upgrade page, third costs 1k
hp_level: int
sp_level: int
mp_level: int
potion_count: int
equipment: List[str] = []
is_boss: bool = False
# the vanilla upgrades/equipment you would have
area_data: Dict[str, AreaStats] = {
"Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]),
"East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]),
"Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]),
# learn how to upgrade
"Beneath the Well": AreaStats(2, 1, 3, 3, 1, 1, 3, ["Sword", "Shield"]),
"Dark Tomb": AreaStats(2, 2, 3, 3, 1, 1, 3, ["Sword", "Shield"]),
"West Garden": AreaStats(2, 3, 3, 3, 1, 1, 4, ["Sword", "Shield"]),
"Garden Knight": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield"], is_boss=True),
# get the wand here
"Beneath the Vault": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield", "Magic"]),
"Eastern Vault Fortress": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"]),
"Siege Engine": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"], is_boss=True),
"Frog's Domain": AreaStats(3, 4, 3, 5, 3, 3, 4, ["Sword", "Shield", "Magic"]),
# the second half of Atoll is the part you need the stats for, so putting it after frogs
"Ruined Atoll": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]),
"The Librarian": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"], is_boss=True),
"Quarry": AreaStats(5, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]),
"Rooted Ziggurat": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"]),
"Boss Scavenger": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"], is_boss=True),
"Swamp": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]),
"Cathedral": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]),
# marked as boss because the garden knights can't get hurt by stick
"Gauntlet": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"], is_boss=True),
"The Heir": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic", "Laurels"], is_boss=True),
}
# 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] = False
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] = False
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
extra_mp_needed = 0
has_magic = state.has_any({"Magic Wand", "Gun"}, player)
stick_bool = False
sword_bool = False
for item in data.equipment:
if item == "Stick":
if not has_melee(state, player):
if has_magic:
# magic can make up for the lack of stick
extra_mp_needed += 2
extra_att_needed -= 16
else:
return False
else:
stick_bool = True
elif item == "Sword":
if not has_sword(state, player):
# need sword for bosses
if data.is_boss:
return False
if has_magic:
# +4 mp pretty much makes up for the lack of sword, at least in Quarry
extra_mp_needed += 4
# stick is a backup plan, and doesn't scale well, so let's require a little less
extra_att_needed -= 2
elif has_melee(state, player):
# may revise this later based on feedback
extra_att_needed += 3
extra_def_needed += 2
else:
return False
else:
sword_bool = True
elif item == "Shield":
if not state.has("Shield", player):
extra_def_needed += 2
elif item == "Laurels":
if not state.has("Hero's Laurels", player):
# these are entirely based on vibes
extra_att_needed += 2
extra_def_needed += 3
elif item == "Magic":
if not has_magic:
extra_att_needed += 2
extra_def_needed += 2
extra_mp_needed -= 16
modified_stats = AreaStats(data.att_level + extra_att_needed, data.def_level + extra_def_needed, data.potion_level,
data.hp_level, data.sp_level, data.mp_level + extra_mp_needed, data.potion_count)
if not has_required_stats(modified_stats, state, player):
# we may need to check if you would have the required stats if you were missing a weapon
# it's kinda janky, but these only get hit in less than once per 100 generations, so whatever
if sword_bool and "Sword" in data.equipment and "Magic" in data.equipment:
# we need to check if you would have the required stats if you didn't have melee
equip_list = [item for item in data.equipment if item != "Sword"]
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 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
equip_list = [item for item in data.equipment if item != "Magic"]
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 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
equip_list = [item for item in data.equipment if item != "Stick"]
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 check_combat_reqs("none", state, player, more_modified_stats):
return True
return False
else:
return False
return True
# check if you have the required stats, and the money to afford them
# it may be innaccurate due to poor spending, and it may even require you to "spend poorly"
# but that's fine -- it's already pretty generous to begin with
def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool:
money_required = 0
player_att = 0
# check if we actually need the stat before checking state
if data.att_level > 1:
player_att, att_offerings = get_att_level(state, player)
if player_att < data.att_level:
return False
else:
extra_att = player_att - data.att_level
paid_att = max(0, att_offerings - extra_att)
# attack upgrades cost 100 for the first, +50 for each additional
money_per_att = 100
for _ in range(paid_att):
money_required += money_per_att
money_per_att += 50
# adding defense and sp together since they accomplish similar things: making you take less damage
if data.def_level + data.sp_level > 2:
player_def, def_offerings = get_def_level(state, player)
player_sp, sp_offerings = get_sp_level(state, player)
if player_def + player_sp < data.def_level + data.sp_level:
return False
else:
free_def = player_def - def_offerings
free_sp = player_sp - sp_offerings
paid_stats = data.def_level + data.sp_level - free_def - free_sp
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
def_to_buy = 0
elif paid_stats <= def_offerings:
# get the amount needed to buy these def offerings
def_to_buy = paid_stats
else:
def_to_buy = def_offerings
sp_to_buy = max(0, paid_stats - def_offerings)
# if you have to buy more than 3 def, it's cheaper to buy 1 extra sp
if def_to_buy > 3 and sp_offerings > 0:
def_to_buy -= 1
sp_to_buy += 1
# def costs 100 for the first, +50 for each additional
money_per_def = 100
for _ in range(def_to_buy):
money_required += money_per_def
money_per_def += 50
# sp costs 200 for the first, +200 for each additional
money_per_sp = 200
for _ in range(sp_to_buy):
money_required += money_per_sp
money_per_sp += 200
# if you have 2 more attack than needed, we can forego needing mp
if data.mp_level > 1 and player_att < data.att_level + 2:
player_mp, mp_offerings = get_mp_level(state, player)
if player_mp < data.mp_level:
return False
else:
extra_mp = player_mp - data.mp_level
paid_mp = max(0, mp_offerings - extra_mp)
# mp costs 300 for the first, +50 for each additional
money_per_mp = 300
for _ in range(paid_mp):
money_required += money_per_mp
money_per_mp += 50
req_effective_hp = calc_effective_hp(data.hp_level, data.potion_level, data.potion_count)
player_potion, potion_offerings = get_potion_level(state, player)
player_hp, hp_offerings = get_hp_level(state, player)
player_potion_count = get_potion_count(state, player)
player_effective_hp = calc_effective_hp(player_hp, player_potion, player_potion_count)
if player_effective_hp < req_effective_hp:
return False
else:
# need a way to determine which of potion offerings or hp offerings you can reduce
# your level if you didn't pay for offerings
free_potion = player_potion - potion_offerings
free_hp = player_hp - hp_offerings
paid_hp_count = 0
paid_potion_count = 0
if calc_effective_hp(free_hp, free_potion, player_potion_count) >= req_effective_hp:
# you don't need to buy upgrades
pass
# if you have no potions, or no potion upgrades, you only need to check your hp upgrades
elif player_potion_count == 0 or potion_offerings == 0:
# check if you have enough hp at each paid hp offering
for i in range(hp_offerings):
paid_hp_count = i + 1
if calc_effective_hp(paid_hp_count, 0, player_potion_count) > req_effective_hp:
break
else:
for i in range(potion_offerings):
paid_potion_count = i + 1
if calc_effective_hp(free_hp, free_potion + paid_potion_count, player_potion_count) > req_effective_hp:
break
for j in range(hp_offerings):
paid_hp_count = j + 1
if (calc_effective_hp(free_hp + paid_hp_count, free_potion + paid_potion_count, player_potion_count)
> req_effective_hp):
break
# hp costs 200 for the first, +50 for each additional
money_per_hp = 200
for _ in range(paid_hp_count):
money_required += money_per_hp
money_per_hp += 50
# potion costs 100 for the first, 300 for the second, 1,000 for the third, and +200 for each additional
# currently we assume you will not buy past the second potion upgrade, but we might change our minds later
money_per_potion = 100
for _ in range(paid_potion_count):
money_required += money_per_potion
if money_per_potion == 100:
money_per_potion = 300
elif money_per_potion == 300:
money_per_potion = 1000
else:
money_per_potion += 200
if money_required > get_money_count(state, player):
return False
return True
# 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_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_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_offerings = state.count("DEF Offering", player)
# defense falls off, can just cap it at 8 for simplicity
return (min(8, 1 + def_offerings
+ state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player)),
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_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_offerings
+ state.count_from_list({"Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"}, player),
potion_offerings)
# returns a tuple of your max hp level, the number of hp offerings
def get_hp_level(state: CollectionState, player: int) -> Tuple[int, int]:
hp_offerings = state.count("HP Offering", player)
return 1 + hp_offerings + state.count("Hero Relic - HP", player), hp_offerings
# returns a tuple of your max sp level, the number of sp offerings
def get_sp_level(state: CollectionState, player: int) -> Tuple[int, int]:
sp_offerings = state.count("SP Offering", player)
return (1 + sp_offerings
+ state.count_from_list({"Hero Relic - SP", "Mr Mayor", "Power Up",
"Regal Weasel", "Forever Friend"}, player),
sp_offerings)
def get_mp_level(state: CollectionState, player: int) -> Tuple[int, int]:
mp_offerings = state.count("MP Offering", player)
return (1 + mp_offerings
+ state.count_from_list({"Hero Relic - MP", "Sacred Geometry", "Vintage", "Dusty"}, player),
mp_offerings)
def get_potion_count(state: CollectionState, player: int) -> int:
return state.count("Potion Flask", player) + state.count("Flask Shard", player) // 3
def calc_effective_hp(hp_level: int, potion_level: int, potion_count: int) -> int:
player_hp = 60 + hp_level * 20
# since you don't tend to use potions efficiently all the time, scale healing by .75
total_healing = int(.75 * potion_count * min(player_hp, 20 + 10 * potion_level))
return player_hp + total_healing
# returns the total amount of progression money the player has
def get_money_count(state: CollectionState, player: int) -> int:
money: int = 0
# this could be done with something to parse the money count at the end of the string, but I don't wanna
money += state.count("Money x255", player) * 255 # 1 in pool
money += state.count("Money x200", player) * 200 # 1 in pool
money += state.count("Money x128", player) * 128 # 3 in pool
# total from regular money: 839
# first effigy is 8, doubles until it reaches 512 at number 7, after effigy 28 they stop dropping money
# with the vanilla count of 12, you get 3,576 money from effigies
effigy_count = min(28, state.count("Effigy", player)) # 12 in pool
money_per_break = 8
for _ in range(effigy_count):
money += money_per_break
money_per_break = min(512, money_per_break * 2)
return money
class TunicState(LogicMixin):
tunic_need_to_reset_combat_from_collect: Dict[int, bool]
tunic_need_to_reset_combat_from_remove: Dict[int, bool]
tunic_area_combat_state: Dict[int, Dict[str, int]]
def init_mixin(self, _):
# the per-player need to reset the combat state when collecting a combat item
self.tunic_need_to_reset_combat_from_collect = defaultdict(lambda: False)
# the per-player need to reset the combat state when removing a combat item
self.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False)
# the per-player, per-area state of combat checking -- unchecked, failed, or succeeded
self.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked))

View File

@@ -235,12 +235,12 @@ portal_mapping: List[Portal] = [
destination="Sewer_Boss", tag="_"),
Portal(name="Well Exit towards Furnace", region="Beneath the Well Back",
destination="Overworld Redux", tag="_west_aqueduct"),
Portal(name="Well Boss to Well", region="Well Boss",
destination="Sewer", tag="_"),
Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint",
destination="Crypt Redux", tag="_"),
Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point",
destination="Overworld Redux", tag="_"),
Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit",
@@ -248,13 +248,13 @@ portal_mapping: List[Portal] = [
Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point",
destination="Sewer_Boss", tag="_"),
Portal(name="West Garden Exit near Hero's Grave", region="West Garden",
Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry",
destination="Overworld Redux", tag="_lower"),
Portal(name="West Garden to Magic Dagger House", region="West Garden",
Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House",
destination="archipelagos_house", tag="_"),
Portal(name="West Garden Exit after Boss", region="West Garden after Boss",
destination="Overworld Redux", tag="_upper"),
Portal(name="West Garden Shop", region="West Garden",
Portal(name="West Garden Shop", region="West Garden before Terry",
destination="Shop", tag="_"),
Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region",
destination="Overworld Redux", tag="_lowest"),
@@ -262,7 +262,7 @@ portal_mapping: List[Portal] = [
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="West Garden to Far Shore", region="West Garden Portal",
destination="Transit", tag="_teleporter_archipelagos_teleporter"),
Portal(name="Magic Dagger House Exit", region="Magic Dagger House",
destination="Archipelagos Redux", tag="_"),
@@ -308,7 +308,7 @@ portal_mapping: List[Portal] = [
Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper",
destination="Fortress Main", tag="_upper"),
Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path",
Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry",
destination="Fortress Courtyard", tag="_Lower"),
Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"),
@@ -339,7 +339,7 @@ portal_mapping: List[Portal] = [
destination="Frog Stairs", tag="_eye"),
Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth",
destination="Frog Stairs", tag="_mouth"),
Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit",
destination="Atoll Redux", tag="_eye"),
Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper",
@@ -348,39 +348,39 @@ portal_mapping: List[Portal] = [
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"),
Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry",
destination="Frog Stairs", tag="_Entrance"),
Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back",
destination="Frog Stairs", tag="_Exit"),
Portal(name="Library Exterior Tree", region="Library Exterior Tree Region",
destination="Atoll Redux", tag="_"),
Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region",
destination="Library Hall", tag="_"),
Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf",
destination="Library Exterior", tag="_"),
Portal(name="Library Hero's Grave", region="Library Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda",
destination="Library Rotunda", tag="_"),
Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall",
destination="Library Hall", tag="_"),
Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab",
destination="Library Lab", tag="_"),
Portal(name="Library Lab to Rotunda", region="Library Lab Lower",
destination="Library Rotunda", tag="_"),
Portal(name="Library to Far Shore", region="Library Portal",
destination="Transit", tag="_teleporter_library teleporter"),
Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian",
destination="Library Arena", tag="_"),
Portal(name="Librarian Arena Exit", region="Library Arena",
destination="Library Lab", tag="_"),
Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs",
destination="Mountaintop", tag="_"),
Portal(name="Mountain to Quarry", region="Lower Mountain",
@@ -433,7 +433,7 @@ portal_mapping: List[Portal] = [
Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom",
destination="ziggurat2020_3", tag="_"),
Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Front",
Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry",
destination="ziggurat2020_2", tag="_"),
Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance",
destination="ziggurat2020_FTRoom", tag="_"),
@@ -461,7 +461,7 @@ portal_mapping: List[Portal] = [
Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Cathedral Main Exit", region="Cathedral",
Portal(name="Cathedral Main Exit", region="Cathedral Entry",
destination="Swamp Redux 2", tag="_main"),
Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet",
destination="Cathedral Arena", tag="_"),
@@ -523,7 +523,6 @@ class RegionInfo(NamedTuple):
game_scene: str # the name of the scene in the actual game
dead_end: int = 0 # if a region has only one exit
outlet_region: Optional[str] = None
is_fake_region: bool = False
# gets the outlet region name if it exists, the region if it doesn't
@@ -563,6 +562,8 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Overworld to West Garden Upper": RegionInfo("Overworld Redux"), # usually leads to garden knight
"Overworld to West Garden from Furnace": RegionInfo("Overworld Redux"), # isolated stairway with one chest
"Overworld Well Ladder": RegionInfo("Overworld Redux"), # just the ladder entrance itself as a region
"Overworld Well Entry Area": RegionInfo("Overworld Redux"), # the page, the bridge, etc.
"Overworld Tunnel to Beach": RegionInfo("Overworld Redux"), # the tunnel with the chest
"Overworld Beach": RegionInfo("Overworld Redux"), # from the two turrets to invisble maze, and lower atoll entry
"Overworld Tunnel Turret": RegionInfo("Overworld Redux"), # the tunnel turret by the southwest beach ladder
"Overworld to Atoll Upper": RegionInfo("Overworld Redux"), # the little ledge before the ladder
@@ -624,14 +625,18 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Beneath the Well Front": RegionInfo("Sewer"), # the front, to separate it from the weapon requirement in the mid
"Beneath the Well Main": RegionInfo("Sewer"), # the main section of it, requires a weapon
"Beneath the Well Back": RegionInfo("Sewer"), # the back two portals, and all 4 upper chests
"West Garden": RegionInfo("Archipelagos Redux"),
"West Garden before Terry": RegionInfo("Archipelagos Redux"), # the lower entry point, near hero grave
"West Garden after Terry": RegionInfo("Archipelagos Redux"), # after Terry, up until next chompignons
"West Garden at Dagger House": RegionInfo("Archipelagos Redux"), # just outside magic dagger house
"West Garden South Checkpoint": RegionInfo("Archipelagos Redux"),
"Magic Dagger House": RegionInfo("archipelagos_house", dead_end=DeadEnd.all_cats),
"West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted, outlet_region="West Garden by Portal"),
"West Garden by Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Portal Item": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Laurels Exit Region": RegionInfo("Archipelagos Redux"),
"West Garden before Boss": RegionInfo("Archipelagos Redux"), # main west garden
"West Garden after Boss": RegionInfo("Archipelagos Redux"),
"West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden"),
"West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden before Terry"),
"Ruined Atoll": RegionInfo("Atoll Redux"),
"Ruined Atoll Lower Entry Area": RegionInfo("Atoll Redux"),
"Ruined Atoll Ladder Tops": RegionInfo("Atoll Redux"), # at the top of the 5 ladders in south Atoll
@@ -643,8 +648,9 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Frog Stairs Upper": RegionInfo("Frog Stairs"),
"Frog Stairs Lower": RegionInfo("Frog Stairs"),
"Frog Stairs to Frog's Domain": RegionInfo("Frog Stairs"),
"Frog's Domain Entry": RegionInfo("frog cave main"),
"Frog's Domain": RegionInfo("frog cave main"),
"Frog's Domain Entry": RegionInfo("frog cave main"), # just the ladder
"Frog's Domain Front": RegionInfo("frog cave main"), # before combat
"Frog's Domain Main": RegionInfo("frog cave main"),
"Frog's Domain Back": RegionInfo("frog cave main"),
"Library Exterior Tree Region": RegionInfo("Library Exterior", outlet_region="Library Exterior by Tree"),
"Library Exterior by Tree": RegionInfo("Library Exterior"),
@@ -658,8 +664,8 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Library Rotunda to Lab": RegionInfo("Library Rotunda"),
"Library Lab": RegionInfo("Library Lab"),
"Library Lab Lower": RegionInfo("Library Lab"),
"Library Portal": RegionInfo("Library Lab", outlet_region="Library Lab on Portal Pad"),
"Library Lab on Portal Pad": RegionInfo("Library Lab"),
"Library Portal": RegionInfo("Library Lab", outlet_region="Library Lab on Portal Pad"),
"Library Lab to Librarian": RegionInfo("Library Lab"),
"Library Arena": RegionInfo("Library Arena", dead_end=DeadEnd.all_cats),
"Fortress Exterior from East Forest": RegionInfo("Fortress Courtyard"),
@@ -675,10 +681,12 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Eastern Vault Fortress Gold Door": RegionInfo("Fortress Main"),
"Fortress East Shortcut Upper": RegionInfo("Fortress East"),
"Fortress East Shortcut Lower": RegionInfo("Fortress East"),
"Fortress Grave Path": RegionInfo("Fortress Reliquary"),
"Fortress Grave Path Entry": RegionInfo("Fortress Reliquary"),
"Fortress Grave Path Combat": RegionInfo("Fortress Reliquary"), # the combat is basically just a barrier here
"Fortress Grave Path by Grave": RegionInfo("Fortress Reliquary"),
"Fortress Grave Path Upper": RegionInfo("Fortress Reliquary", dead_end=DeadEnd.restricted),
"Fortress Grave Path Dusty Entrance Region": RegionInfo("Fortress Reliquary"),
"Fortress Hero's Grave Region": RegionInfo("Fortress Reliquary", outlet_region="Fortress Grave Path"),
"Fortress Hero's Grave Region": RegionInfo("Fortress Reliquary", outlet_region="Fortress Grave Path by Grave"),
"Fortress Leaf Piles": RegionInfo("Dusty", dead_end=DeadEnd.all_cats),
"Fortress Arena": RegionInfo("Fortress Arena"),
"Fortress Arena Portal": RegionInfo("Fortress Arena", outlet_region="Fortress Arena"),
@@ -697,6 +705,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Monastery Rope": RegionInfo("Quarry Redux"),
"Lower Quarry": RegionInfo("Quarry Redux"),
"Even Lower Quarry": RegionInfo("Quarry Redux"),
"Even Lower Quarry Isolated Chest": RegionInfo("Quarry Redux"), # a region for that one chest
"Lower Quarry Zig Door": RegionInfo("Quarry Redux"),
"Rooted Ziggurat Entry": RegionInfo("ziggurat2020_0"),
"Rooted Ziggurat Upper Entry": RegionInfo("ziggurat2020_1"),
@@ -704,13 +713,15 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Rooted Ziggurat Upper Back": RegionInfo("ziggurat2020_1"), # after the administrator
"Rooted Ziggurat Middle Top": RegionInfo("ziggurat2020_2"),
"Rooted Ziggurat Middle Bottom": RegionInfo("ziggurat2020_2"),
"Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the vanilla entry point side
"Rooted Ziggurat Lower Entry": RegionInfo("ziggurat2020_3"), # the vanilla entry point side
"Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic
"Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin
"Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side
"Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Front"), # the exit from zig skip, for use with fixed shop on
"Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry"), # for use with fixed shop on
"Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side
"Rooted Ziggurat Portal": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"),
"Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"),
"Rooted Ziggurat Portal Room Exit": RegionInfo("ziggurat2020_FTRoom"),
"Rooted Ziggurat Portal Room Exit": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"),
"Swamp Front": RegionInfo("Swamp Redux 2"), # from the main entry to the top of the ladder after south
"Swamp Mid": RegionInfo("Swamp Redux 2"), # from the bottom of the ladder to the cathedral door
"Swamp Ledge under Cathedral Door": RegionInfo("Swamp Redux 2"), # the ledge with the chest and secret door
@@ -719,7 +730,8 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Back of Swamp": RegionInfo("Swamp Redux 2"), # the area with hero grave and gauntlet entrance
"Swamp Hero's Grave Region": RegionInfo("Swamp Redux 2", outlet_region="Back of Swamp"),
"Back of Swamp Laurels Area": RegionInfo("Swamp Redux 2"), # the spots you need laurels to traverse
"Cathedral": RegionInfo("Cathedral Redux"),
"Cathedral Entry": RegionInfo("Cathedral Redux"), # the checkpoint and easily-accessible chests
"Cathedral Main": RegionInfo("Cathedral Redux"), # the majority of Cathedral
"Cathedral to Gauntlet": RegionInfo("Cathedral Redux"), # the elevator
"Cathedral Secret Legend Room": RegionInfo("Cathedral Redux", dead_end=DeadEnd.all_cats),
"Cathedral Gauntlet Checkpoint": RegionInfo("Cathedral Arena"),
@@ -741,7 +753,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Purgatory": RegionInfo("Purgatory"),
"Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats),
"Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats),
"Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats)
"Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats),
}
@@ -759,6 +771,8 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld": {
"Overworld Beach":
[],
"Overworld Tunnel to Beach":
[],
"Overworld to Atoll Upper":
[["Hyperdash"]],
"Overworld Belltower":
@@ -769,7 +783,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
[],
"Overworld Special Shop Entry":
[["Hyperdash"], ["LS1"]],
"Overworld Well Ladder":
"Overworld Well Entry Area":
[],
"Overworld Ruined Passage Door":
[],
@@ -847,6 +861,12 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
# "Overworld":
# [],
# },
"Overworld Tunnel to Beach": {
# "Overworld":
# [],
"Overworld Beach":
[],
},
"Overworld Beach": {
# "Overworld":
# [],
@@ -873,9 +893,15 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld Beach":
[],
},
"Overworld Well Ladder": {
"Overworld Well Entry Area": {
# "Overworld":
# [],
"Overworld Well Ladder":
[],
},
"Overworld Well Ladder": {
"Overworld Well Entry Area":
[],
},
"Overworld at Patrol Cave": {
"East Overworld":
@@ -954,6 +980,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld":
[],
},
"Old House Front": {
"Old House Back":
[],
@@ -962,6 +989,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Old House Front":
[["Hyperdash", "Zip"]],
},
"Furnace Fuse": {
"Furnace Ladder Area":
[["Hyperdash"]],
@@ -976,6 +1004,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Furnace Ladder Area":
[["Hyperdash"]],
},
"Sealed Temple": {
"Sealed Temple Rafters":
[],
@@ -984,10 +1013,12 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Sealed Temple":
[["Hyperdash"]],
},
"Hourglass Cave": {
"Hourglass Cave Tower":
[],
},
"Forest Belltower Upper": {
"Forest Belltower Main":
[],
@@ -996,6 +1027,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Forest Belltower Lower":
[],
},
"East Forest": {
"East Forest Dance Fox Spot":
[["Hyperdash"], ["IG1"], ["LS1"]],
@@ -1016,6 +1048,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"East Forest":
[],
},
"Guard House 1 East": {
"Guard House 1 West":
[],
@@ -1024,6 +1057,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Guard House 1 East":
[["Hyperdash"], ["LS1"]],
},
"Guard House 2 Upper": {
"Guard House 2 Lower":
[],
@@ -1032,6 +1066,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Guard House 2 Upper":
[],
},
"Forest Grave Path Main": {
"Forest Grave Path Upper":
[["Hyperdash"], ["LS2"], ["IG3"]],
@@ -1044,7 +1079,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
},
"Forest Grave Path by Grave": {
"Forest Hero's Grave":
[],
[],
"Forest Grave Path Main":
[["IG1"]],
},
@@ -1052,6 +1087,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Forest Grave Path by Grave":
[],
},
"Beneath the Well Ladder Exit": {
"Beneath the Well Front":
[],
@@ -1072,6 +1108,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Beneath the Well Main":
[],
},
"Well Boss": {
"Dark Tomb Checkpoint":
[],
@@ -1080,6 +1117,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Well Boss":
[["Hyperdash", "Zip"]],
},
"Dark Tomb Entry Point": {
"Dark Tomb Upper":
[],
@@ -1100,44 +1138,72 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Dark Tomb Main":
[],
},
"West Garden": {
"West Garden Laurels Exit Region":
[["Hyperdash"], ["LS1"]],
"West Garden after Boss":
[],
"West Garden before Terry": {
"West Garden after Terry":
[],
"West Garden Hero's Grave Region":
[],
},
"West Garden Hero's Grave Region": {
"West Garden before Terry":
[],
},
"West Garden after Terry": {
"West Garden before Terry":
[],
"West Garden South Checkpoint":
[],
"West Garden Laurels Exit Region":
[["LS1"]],
},
"West Garden South Checkpoint": {
"West Garden before Boss":
[],
"West Garden at Dagger House":
[],
"West Garden after Terry":
[],
},
"West Garden before Boss": {
"West Garden after Boss":
[],
"West Garden South Checkpoint":
[],
},
"West Garden after Boss": {
"West Garden before Boss":
[["Hyperdash"]],
},
"West Garden at Dagger House": {
"West Garden Laurels Exit Region":
[["Hyperdash"]],
"West Garden South Checkpoint":
[],
"West Garden Portal Item":
[["IG2"]],
},
"West Garden Laurels Exit Region": {
"West Garden":
[["Hyperdash"]],
},
"West Garden after Boss": {
"West Garden":
"West Garden at Dagger House":
[["Hyperdash"]],
},
"West Garden Portal Item": {
"West Garden":
"West Garden at Dagger House":
[["IG1"]],
"West Garden by Portal":
[["Hyperdash"]],
},
"West Garden by Portal": {
"West Garden Portal":
[["West Garden South Checkpoint"]],
"West Garden Portal Item":
[["Hyperdash"]],
"West Garden Portal":
[["West Garden"]],
},
"West Garden Portal": {
"West Garden by Portal":
[],
},
"West Garden Hero's Grave Region": {
"West Garden":
[],
},
"Ruined Atoll": {
"Ruined Atoll Lower Entry Area":
[["Hyperdash"], ["LS1"]],
@@ -1176,6 +1242,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Ruined Atoll":
[],
},
"Frog Stairs Eye Exit": {
"Frog Stairs Upper":
[],
@@ -1196,16 +1263,25 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Frog Stairs Lower":
[],
},
"Frog's Domain Entry": {
"Frog's Domain":
"Frog's Domain Front":
[],
},
"Frog's Domain": {
"Frog's Domain Front": {
"Frog's Domain Entry":
[],
"Frog's Domain Main":
[],
},
"Frog's Domain Main": {
"Frog's Domain Front":
[],
"Frog's Domain Back":
[],
},
# cannot get from frogs back to front
"Library Exterior Ladder Region": {
"Library Exterior by Tree":
[],
@@ -1220,6 +1296,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Library Exterior by Tree":
[],
},
"Library Hall Bookshelf": {
"Library Hall":
[],
@@ -1240,6 +1317,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Library Hall":
[],
},
"Library Rotunda to Hall": {
"Library Rotunda":
[],
@@ -1281,9 +1359,10 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Library Lab":
[],
},
"Fortress Exterior from East Forest": {
"Fortress Exterior from Overworld":
[],
[],
"Fortress Courtyard Upper":
[["LS2"]],
"Fortress Courtyard":
@@ -1291,9 +1370,9 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
},
"Fortress Exterior from Overworld": {
"Fortress Exterior from East Forest":
[["Hyperdash"]],
[["Hyperdash"]],
"Fortress Exterior near cave":
[],
[],
"Fortress Courtyard":
[["Hyperdash"], ["IG1"], ["LS1"]],
},
@@ -1321,6 +1400,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress Courtyard":
[],
},
"Beneath the Vault Ladder Exit": {
"Beneath the Vault Main":
[],
@@ -1337,6 +1417,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Beneath the Vault Ladder Exit":
[],
},
"Fortress East Shortcut Lower": {
"Fortress East Shortcut Upper":
[["IG1"]],
@@ -1345,6 +1426,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress East Shortcut Lower":
[],
},
"Eastern Vault Fortress": {
"Eastern Vault Fortress Gold Door":
[["IG2"], ["Fortress Exterior from Overworld", "Beneath the Vault Back", "Fortress Courtyard Upper"]],
@@ -1353,24 +1435,44 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Eastern Vault Fortress":
[["IG1"]],
},
"Fortress Grave Path": {
"Fortress Grave Path Entry": {
"Fortress Grave Path Combat":
[],
# redundant here, keeping a comment to show it's intentional
# "Fortress Grave Path Dusty Entrance Region":
# [["Hyperdash"]],
},
"Fortress Grave Path Combat": {
"Fortress Grave Path Entry":
[],
"Fortress Grave Path by Grave":
[],
},
"Fortress Grave Path by Grave": {
"Fortress Grave Path Entry":
[],
# unnecessary, you can just skip it
# "Fortress Grave Path Combat":
# [],
"Fortress Hero's Grave Region":
[],
[],
"Fortress Grave Path Dusty Entrance Region":
[["Hyperdash"]],
},
"Fortress Grave Path Upper": {
"Fortress Grave Path":
"Fortress Grave Path Entry":
[["IG1"]],
},
"Fortress Grave Path Dusty Entrance Region": {
"Fortress Grave Path":
"Fortress Grave Path by Grave":
[["Hyperdash"]],
},
"Fortress Hero's Grave Region": {
"Fortress Grave Path":
"Fortress Grave Path by Grave":
[],
},
"Fortress Arena": {
"Fortress Arena Portal":
[["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
@@ -1379,6 +1481,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress Arena":
[],
},
"Lower Mountain": {
"Lower Mountain Stairs":
[],
@@ -1387,6 +1490,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Lower Mountain":
[],
},
"Monastery Back": {
"Monastery Front":
[["Hyperdash", "Zip"]],
@@ -1401,6 +1505,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Monastery Back":
[],
},
"Quarry Entry": {
"Quarry Portal":
[["Quarry Connector"]],
@@ -1436,15 +1541,17 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
[],
"Quarry Monastery Entry":
[],
"Lower Quarry Zig Door":
[["IG3"]],
},
"Lower Quarry": {
"Even Lower Quarry":
[],
},
"Even Lower Quarry": {
"Lower Quarry":
"Even Lower Quarry Isolated Chest":
[],
},
"Even Lower Quarry Isolated Chest": {
"Even Lower Quarry":
[],
"Lower Quarry Zig Door":
[["Quarry", "Quarry Connector"], ["IG3"]],
@@ -1453,6 +1560,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Quarry Back":
[],
},
"Rooted Ziggurat Upper Entry": {
"Rooted Ziggurat Upper Front":
[],
@@ -1465,17 +1573,38 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Rooted Ziggurat Upper Front":
[["Hyperdash"]],
},
"Rooted Ziggurat Middle Top": {
"Rooted Ziggurat Middle Bottom":
[],
},
"Rooted Ziggurat Lower Entry": {
"Rooted Ziggurat Lower Front":
[],
# can zip through to the checkpoint
"Rooted Ziggurat Lower Mid Checkpoint":
[["Hyperdash"]],
},
"Rooted Ziggurat Lower Front": {
"Rooted Ziggurat Lower Entry":
[],
"Rooted Ziggurat Lower Mid Checkpoint":
[],
},
"Rooted Ziggurat Lower Mid Checkpoint": {
"Rooted Ziggurat Lower Entry":
[["Hyperdash"]],
"Rooted Ziggurat Lower Front":
[],
"Rooted Ziggurat Lower Back":
[],
},
"Rooted Ziggurat Lower Back": {
"Rooted Ziggurat Lower Front":
[["Hyperdash"], ["LS2"], ["IG1"]],
"Rooted Ziggurat Lower Entry":
[["LS2"]],
"Rooted Ziggurat Lower Mid Checkpoint":
[["Hyperdash"], ["IG1"]],
"Rooted Ziggurat Portal Room Entrance":
[],
},
@@ -1487,20 +1616,22 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Rooted Ziggurat Lower Back":
[],
},
"Rooted Ziggurat Portal Room Exit": {
"Rooted Ziggurat Portal Room":
[],
},
"Rooted Ziggurat Portal Room": {
"Rooted Ziggurat Portal":
[],
"Rooted Ziggurat Portal Room Exit":
[["Rooted Ziggurat Lower Back"]],
"Rooted Ziggurat Portal":
[],
},
"Rooted Ziggurat Portal": {
"Rooted Ziggurat Portal Room":
[],
},
"Swamp Front": {
"Swamp Mid":
[],
@@ -1557,14 +1688,26 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Back of Swamp":
[],
},
"Cathedral": {
"Cathedral Entry": {
"Cathedral to Gauntlet":
[],
"Cathedral Main":
[],
},
"Cathedral Main": {
"Cathedral Entry":
[],
"Cathedral to Gauntlet":
[],
},
"Cathedral to Gauntlet": {
"Cathedral":
"Cathedral Entry":
[],
"Cathedral Main":
[],
},
"Cathedral Gauntlet Checkpoint": {
"Cathedral Gauntlet":
[],
@@ -1577,6 +1720,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Cathedral Gauntlet":
[["Hyperdash"]],
},
"Far Shore": {
"Far Shore to Spawn Region":
[["Hyperdash"]],
@@ -1587,7 +1731,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Far Shore to Library Region":
[["Library Lab"]],
"Far Shore to West Garden Region":
[["West Garden"]],
[["West Garden South Checkpoint"]],
"Far Shore to Fortress Region":
[["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
},

View File

@@ -1,10 +1,11 @@
from typing import Dict, FrozenSet, Tuple, TYPE_CHECKING
from worlds.generic.Rules import set_rule, forbid_item
from .options import IceGrappling, LadderStorage
from .rules import (has_ability, has_sword, has_stick, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
from worlds.generic.Rules import set_rule, add_rule, forbid_item
from .options import IceGrappling, LadderStorage, CombatLogic
from .rules import (has_ability, has_sword, has_melee, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
laurels_zip, bomb_walls)
from .er_data import Portal, get_portal_outlet_region
from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls
from .combat_logic import has_combat_reqs
from BaseClasses import Region, CollectionState
if TYPE_CHECKING:
@@ -43,6 +44,24 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
player = world.player
options = world.options
# input scene destination tag, returns portal's name and paired portal's outlet region or region
def get_portal_info(portal_sd: str) -> Tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal1.name, get_portal_outlet_region(portal2, world)
if portal2.scene_destination() == portal_sd:
return portal2.name, get_portal_outlet_region(portal1, world)
raise Exception("No matches found in get_portal_info")
# input scene destination tag, returns paired portal's name and region
def get_paired_portal(portal_sd: str) -> Tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal2.name, portal2.region
if portal2.scene_destination() == portal_sd:
return portal1.name, portal1.region
raise Exception("no matches found in get_paired_portal")
regions["Menu"].connect(
connecting_region=regions["Overworld"])
@@ -56,10 +75,18 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Overworld Beach"],
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or state.has_any({laurels, grapple}, player))
# regions["Overworld Beach"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
# or state.has_any({laurels, grapple}, player))
# region for combat logic, no need to connect it to beach since it would be the same as the ow -> beach cxn
ow_tunnel_beach = regions["Overworld"].connect(
connecting_region=regions["Overworld Tunnel to Beach"])
regions["Overworld Beach"].connect(
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or state.has_any({laurels, grapple}, player))
connecting_region=regions["Overworld Tunnel to Beach"],
rule=lambda state: state.has(laurels, player) or has_ladder("Ladders in Overworld Town", state, world))
regions["Overworld Beach"].connect(
connecting_region=regions["Overworld West Garden Laurels Entry"],
@@ -277,11 +304,17 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["East Overworld"],
rule=lambda state: state.has(laurels, player))
regions["Overworld"].connect(
# region made for combat logic
ow_to_well_entry = regions["Overworld"].connect(
connecting_region=regions["Overworld Well Entry Area"])
regions["Overworld Well Entry Area"].connect(
connecting_region=regions["Overworld"])
regions["Overworld Well Entry Area"].connect(
connecting_region=regions["Overworld Well Ladder"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
regions["Overworld Well Ladder"].connect(
connecting_region=regions["Overworld"],
connecting_region=regions["Overworld Well Entry Area"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
# nmg: can ice grapple through the door
@@ -306,7 +339,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Overworld Fountain Cross Door"].connect(
connecting_region=regions["Overworld"])
regions["Overworld"].connect(
ow_to_town_portal = regions["Overworld"].connect(
connecting_region=regions["Overworld Town Portal"],
rule=lambda state: has_ability(prayer, state, world))
regions["Overworld Town Portal"].connect(
@@ -337,6 +370,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
# don't need the ice grapple rule since you can go from ow -> beach -> tunnel
regions["Overworld"].connect(
connecting_region=regions["Overworld Tunnel Turret"],
rule=lambda state: state.has(laurels, player))
@@ -473,29 +507,28 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Beneath the Well Ladder Exit"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
regions["Beneath the Well Front"].connect(
btw_front_main = regions["Beneath the Well Front"].connect(
connecting_region=regions["Beneath the Well Main"],
rule=lambda state: has_stick(state, player) or state.has(fire_wand, player))
rule=lambda state: has_melee(state, player) or state.has(fire_wand, player))
regions["Beneath the Well Main"].connect(
connecting_region=regions["Beneath the Well Front"],
rule=lambda state: has_stick(state, player) or state.has(fire_wand, player))
connecting_region=regions["Beneath the Well Front"])
regions["Beneath the Well Main"].connect(
connecting_region=regions["Beneath the Well Back"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
regions["Beneath the Well Back"].connect(
btw_back_main = regions["Beneath the Well Back"].connect(
connecting_region=regions["Beneath the Well Main"],
rule=lambda state: has_ladder("Ladders in Well", state, world)
and (has_stick(state, player) or state.has(fire_wand, player)))
and (has_melee(state, player) or state.has(fire_wand, player)))
regions["Well Boss"].connect(
well_boss_to_dt = regions["Well Boss"].connect(
connecting_region=regions["Dark Tomb Checkpoint"])
# can laurels through the gate, no setup needed
regions["Dark Tomb Checkpoint"].connect(
connecting_region=regions["Well Boss"],
rule=lambda state: laurels_zip(state, world))
regions["Dark Tomb Entry Point"].connect(
dt_entry_to_upper = regions["Dark Tomb Entry Point"].connect(
connecting_region=regions["Dark Tomb Upper"],
rule=lambda state: has_lantern(state, world))
regions["Dark Tomb Upper"].connect(
@@ -512,34 +545,57 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Dark Tomb Main"].connect(
connecting_region=regions["Dark Tomb Dark Exit"])
regions["Dark Tomb Dark Exit"].connect(
dt_exit_to_main = regions["Dark Tomb Dark Exit"].connect(
connecting_region=regions["Dark Tomb Main"],
rule=lambda state: has_lantern(state, world))
# West Garden
# combat logic regions
wg_before_to_after_terry = regions["West Garden before Terry"].connect(
connecting_region=regions["West Garden after Terry"])
wg_after_to_before_terry = regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden before Terry"])
regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden South Checkpoint"])
wg_checkpoint_to_after_terry = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden after Terry"])
wg_checkpoint_to_dagger = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden at Dagger House"])
regions["West Garden at Dagger House"].connect(
connecting_region=regions["West Garden South Checkpoint"])
wg_checkpoint_to_before_boss = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden before Boss"])
regions["West Garden before Boss"].connect(
connecting_region=regions["West Garden South Checkpoint"])
regions["West Garden Laurels Exit Region"].connect(
connecting_region=regions["West Garden"],
connecting_region=regions["West Garden at Dagger House"],
rule=lambda state: state.has(laurels, player))
regions["West Garden"].connect(
regions["West Garden at Dagger House"].connect(
connecting_region=regions["West Garden Laurels Exit Region"],
rule=lambda state: state.has(laurels, player))
# you can grapple Garden Knight to aggro it, then ledge it
regions["West Garden after Boss"].connect(
connecting_region=regions["West Garden"],
# laurels past, or ice grapple it off, or ice grapple to it then fight
after_gk_to_wg = regions["West Garden after Boss"].connect(
connecting_region=regions["West Garden before Boss"],
rule=lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)
and has_sword(state, player)))
# ice grapple push Garden Knight off the side
regions["West Garden"].connect(
wg_to_after_gk = regions["West Garden before Boss"].connect(
connecting_region=regions["West Garden after Boss"],
rule=lambda state: state.has(laurels, player) or has_sword(state, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["West Garden"].connect(
regions["West Garden before Terry"].connect(
connecting_region=regions["West Garden Hero's Grave Region"],
rule=lambda state: has_ability(prayer, state, world))
regions["West Garden Hero's Grave Region"].connect(
connecting_region=regions["West Garden"])
connecting_region=regions["West Garden before Terry"])
regions["West Garden Portal"].connect(
connecting_region=regions["West Garden by Portal"])
@@ -556,9 +612,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
# can ice grapple to and from the item behind the magic dagger house
regions["West Garden Portal Item"].connect(
connecting_region=regions["West Garden"],
connecting_region=regions["West Garden at Dagger House"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["West Garden"].connect(
regions["West Garden at Dagger House"].connect(
connecting_region=regions["West Garden Portal Item"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_medium, state, world))
@@ -596,7 +652,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Ruined Atoll Portal"].connect(
connecting_region=regions["Ruined Atoll"])
regions["Ruined Atoll"].connect(
atoll_statue = regions["Ruined Atoll"].connect(
connecting_region=regions["Ruined Atoll Statue"],
rule=lambda state: has_ability(prayer, state, world)
and (has_ladder("Ladders in South Atoll", state, world)
@@ -629,10 +685,13 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders to Frog's Domain", state, world))
regions["Frog's Domain Entry"].connect(
connecting_region=regions["Frog's Domain"],
connecting_region=regions["Frog's Domain Front"],
rule=lambda state: has_ladder("Ladders to Frog's Domain", state, world))
regions["Frog's Domain"].connect(
frogs_front_to_main = regions["Frog's Domain Front"].connect(
connecting_region=regions["Frog's Domain Main"])
regions["Frog's Domain Main"].connect(
connecting_region=regions["Frog's Domain Back"],
rule=lambda state: state.has(grapple, player))
@@ -752,7 +811,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["Fortress Courtyard Upper"].connect(
fort_upper_lower = regions["Fortress Courtyard Upper"].connect(
connecting_region=regions["Fortress Courtyard"])
# nmg: can ice grapple to the upper ledge
regions["Fortress Courtyard"].connect(
@@ -762,12 +821,12 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Fortress Courtyard Upper"].connect(
connecting_region=regions["Fortress Exterior from Overworld"])
regions["Beneath the Vault Ladder Exit"].connect(
btv_front_to_main = regions["Beneath the Vault Ladder Exit"].connect(
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, world)
and has_lantern(state, world)
# there's some boxes in the way
and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player)))
and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player)))
# on the reverse trip, you can lure an enemy over to break the boxes if needed
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Ladder Exit"],
@@ -775,11 +834,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Back"])
regions["Beneath the Vault Back"].connect(
btv_back_to_main = regions["Beneath the Vault Back"].connect(
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_lantern(state, world))
regions["Fortress East Shortcut Upper"].connect(
fort_east_upper_lower = regions["Fortress East Shortcut Upper"].connect(
connecting_region=regions["Fortress East Shortcut Lower"])
regions["Fortress East Shortcut Lower"].connect(
connecting_region=regions["Fortress East Shortcut Upper"],
@@ -794,21 +853,31 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Eastern Vault Fortress"],
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
regions["Fortress Grave Path"].connect(
connecting_region=regions["Fortress Grave Path Dusty Entrance Region"],
rule=lambda state: state.has(laurels, player))
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path"],
rule=lambda state: state.has(laurels, player))
fort_grave_entry_to_combat = regions["Fortress Grave Path Entry"].connect(
connecting_region=regions["Fortress Grave Path Combat"])
regions["Fortress Grave Path Combat"].connect(
connecting_region=regions["Fortress Grave Path Entry"])
regions["Fortress Grave Path"].connect(
regions["Fortress Grave Path Combat"].connect(
connecting_region=regions["Fortress Grave Path by Grave"])
# run past the enemies
regions["Fortress Grave Path by Grave"].connect(
connecting_region=regions["Fortress Grave Path Entry"])
regions["Fortress Grave Path by Grave"].connect(
connecting_region=regions["Fortress Hero's Grave Region"],
rule=lambda state: has_ability(prayer, state, world))
regions["Fortress Hero's Grave Region"].connect(
connecting_region=regions["Fortress Grave Path"])
connecting_region=regions["Fortress Grave Path by Grave"])
regions["Fortress Grave Path by Grave"].connect(
connecting_region=regions["Fortress Grave Path Dusty Entrance Region"],
rule=lambda state: state.has(laurels, player))
# reverse connection is conditionally made later, depending on whether combat logic is on, and the details of ER
regions["Fortress Grave Path Upper"].connect(
connecting_region=regions["Fortress Grave Path"],
connecting_region=regions["Fortress Grave Path Entry"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["Fortress Arena"].connect(
@@ -831,19 +900,19 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Quarry Portal"].connect(
connecting_region=regions["Quarry Entry"])
regions["Quarry Entry"].connect(
quarry_entry_to_main = regions["Quarry Entry"].connect(
connecting_region=regions["Quarry"],
rule=lambda state: state.has(fire_wand, player) or has_sword(state, player))
regions["Quarry"].connect(
connecting_region=regions["Quarry Entry"])
regions["Quarry Back"].connect(
quarry_back_to_main = regions["Quarry Back"].connect(
connecting_region=regions["Quarry"],
rule=lambda state: state.has(fire_wand, player) or has_sword(state, player))
regions["Quarry"].connect(
connecting_region=regions["Quarry Back"])
regions["Quarry Monastery Entry"].connect(
monastery_to_quarry_main = regions["Quarry Monastery Entry"].connect(
connecting_region=regions["Quarry"],
rule=lambda state: state.has(fire_wand, player) or has_sword(state, player))
regions["Quarry"].connect(
@@ -869,18 +938,24 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders in Lower Quarry", state, world)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
# nmg: bring a scav over, then ice grapple through the door, only with ER on to avoid soft lock
regions["Even Lower Quarry"].connect(
connecting_region=regions["Even Lower Quarry Isolated Chest"])
# you grappled down, might as well loot the rest too
lower_quarry_empty_to_combat = regions["Even Lower Quarry Isolated Chest"].connect(
connecting_region=regions["Even Lower Quarry"],
rule=lambda state: has_mask(state, world))
regions["Even Lower Quarry Isolated Chest"].connect(
connecting_region=regions["Lower Quarry Zig Door"],
rule=lambda state: state.has("Activate Quarry Fuse", player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
# nmg: use ice grapple to get from the beginning of Quarry to the door without really needing mask only with ER on
# don't need the mask for this either, please don't complain about not needing a mask here, you know what you did
regions["Quarry"].connect(
connecting_region=regions["Lower Quarry Zig Door"],
connecting_region=regions["Even Lower Quarry Isolated Chest"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_hard, state, world))
regions["Monastery Front"].connect(
monastery_front_to_back = regions["Monastery Front"].connect(
connecting_region=regions["Monastery Back"])
# laurels through the gate, no setup needed
regions["Monastery Back"].connect(
@@ -897,7 +972,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(
@@ -907,13 +982,23 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Middle Top"].connect(
connecting_region=regions["Rooted Ziggurat Middle Bottom"])
zig_low_entry_to_front = regions["Rooted Ziggurat Lower Entry"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Lower Front"].connect(
connecting_region=regions["Rooted Ziggurat Lower Entry"])
regions["Rooted Ziggurat Lower Front"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"])
zig_low_mid_to_front = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
zig_low_mid_to_back = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"],
rule=lambda state: state.has(laurels, player)
or (has_sword(state, player) and has_ability(prayer, state, world)))
# nmg: can ice grapple on the voidlings to the double admin fight, still need to pray at the fuse
regions["Rooted Ziggurat Lower Back"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"],
# can ice grapple to the voidlings to get to the double admin fight, still need to pray at the fuse
zig_low_back_to_mid = regions["Rooted Ziggurat Lower Back"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"],
rule=lambda state: (state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
and has_ability(prayer, state, world)
@@ -925,8 +1010,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Portal Room Entrance"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"])
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
# zig skip region only gets made if entrance rando and fewer shops are on
if options.entrance_rando and options.fixed_shop:
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Portal"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room"])
@@ -952,7 +1039,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
or state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
# a whole lot of stuff to basically say "you need to pray at the overworld fuse"
swamp_mid_to_cath = regions["Swamp Mid"].connect(
connecting_region=regions["Swamp to Cathedral Main Entrance Region"],
rule=lambda state: (has_ability(prayer, state, world)
@@ -965,7 +1051,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
"Ladder to Swamp",
"Ladders near Weathervane"}, player)
or (state.has("Ladder to Ruined Atoll", player)
and state.can_reach_region("Overworld Beach", player))))))
and state.can_reach_region("Overworld Beach", player)))))
and (not options.combat_logic
or has_combat_reqs("Swamp", state, player)))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
if options.ladder_storage >= LadderStorage.option_hard and options.shuffle_ladders:
@@ -1017,13 +1105,23 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Swamp Hero's Grave Region"].connect(
connecting_region=regions["Back of Swamp"])
regions["Cathedral"].connect(
cath_entry_to_elev = regions["Cathedral Entry"].connect(
connecting_region=regions["Cathedral to Gauntlet"],
rule=lambda state: (has_ability(prayer, state, world)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
or options.entrance_rando) # elevator is always there in ER
regions["Cathedral to Gauntlet"].connect(
connecting_region=regions["Cathedral"])
connecting_region=regions["Cathedral Entry"])
cath_entry_to_main = regions["Cathedral Entry"].connect(
connecting_region=regions["Cathedral Main"])
regions["Cathedral Main"].connect(
connecting_region=regions["Cathedral Entry"])
cath_elev_to_main = regions["Cathedral to Gauntlet"].connect(
connecting_region=regions["Cathedral Main"])
regions["Cathedral Main"].connect(
connecting_region=regions["Cathedral to Gauntlet"])
regions["Cathedral Gauntlet Checkpoint"].connect(
connecting_region=regions["Cathedral Gauntlet"])
@@ -1075,11 +1173,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Far Shore"])
# Misc
regions["Spirit Arena"].connect(
heir_fight = regions["Spirit Arena"].connect(
connecting_region=regions["Spirit Arena Victory"],
rule=lambda state: (state.has(gold_hexagon, player, world.options.hexagon_goal.value) if
world.options.hexagon_quest else
(state.has_all({red_hexagon, green_hexagon, blue_hexagon, "Unseal the Heir"}, player)
(state.has("Unseal the Heir", player)
and state.has_group_unique("Hero Relics", player, 6)
and has_sword(state, player))))
@@ -1219,6 +1317,192 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
for region in ladder_regions.values():
world.multiworld.regions.append(region)
# for combat logic, easiest to replace or add to existing rules
if world.options.combat_logic >= CombatLogic.option_bosses_only:
set_rule(wg_to_after_gk,
lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or has_combat_reqs("Garden Knight", state, player))
# laurels past, or ice grapple it off, or ice grapple to it and fight
set_rule(after_gk_to_wg,
lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)
and has_combat_reqs("Garden Knight", state, player)))
if not world.options.hexagon_quest:
add_rule(heir_fight,
lambda state: has_combat_reqs("The Heir", state, player))
if world.options.combat_logic == CombatLogic.option_on:
# these are redundant with combat logic off
regions["Fortress Grave Path Entry"].connect(
connecting_region=regions["Fortress Grave Path Dusty Entrance Region"],
rule=lambda state: state.has(laurels, player))
regions["Rooted Ziggurat Lower Entry"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"],
rule=lambda state: state.has(laurels, player))
regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Entry"],
rule=lambda state: state.has(laurels, player))
add_rule(ow_to_town_portal,
lambda state: has_combat_reqs("Before Well", state, player))
# need to fight through the rudelings and turret, or just laurels from near the windmill
set_rule(ow_to_well_entry,
lambda state: state.has(laurels, player)
or has_combat_reqs("East Forest", state, player))
set_rule(ow_tunnel_beach,
lambda state: has_combat_reqs("East Forest", state, player))
add_rule(atoll_statue,
lambda state: has_combat_reqs("Ruined Atoll", state, player))
set_rule(frogs_front_to_main,
lambda state: has_combat_reqs("Frog's Domain", state, player))
set_rule(btw_front_main,
lambda state: state.has(laurels, player) or has_combat_reqs("Beneath the Well", state, player))
set_rule(btw_back_main,
lambda state: has_ladder("Ladders in Well", state, world)
and (state.has(laurels, player) or has_combat_reqs("Beneath the Well", state, player)))
set_rule(well_boss_to_dt,
lambda state: has_combat_reqs("Beneath the Well", state, player)
or laurels_zip(state, world))
add_rule(dt_entry_to_upper,
lambda state: has_combat_reqs("Dark Tomb", state, player))
add_rule(dt_exit_to_main,
lambda state: has_combat_reqs("Dark Tomb", state, player))
set_rule(wg_before_to_after_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
or has_combat_reqs("West Garden", state, player))
set_rule(wg_after_to_before_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
or has_combat_reqs("West Garden", state, player))
# laurels through, probably to the checkpoint, or just fight
set_rule(wg_checkpoint_to_after_terry,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player))
set_rule(wg_checkpoint_to_before_boss,
lambda state: has_combat_reqs("West Garden", state, player))
add_rule(btv_front_to_main,
lambda state: has_combat_reqs("Beneath the Vault", state, player))
add_rule(btv_back_to_main,
lambda state: has_combat_reqs("Beneath the Vault", state, player))
add_rule(fort_upper_lower,
lambda state: state.has(ice_dagger, player)
or has_combat_reqs("Eastern Vault Fortress", state, player))
set_rule(fort_grave_entry_to_combat,
lambda state: has_combat_reqs("Eastern Vault Fortress", state, player))
set_rule(quarry_entry_to_main,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(quarry_back_to_main,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(monastery_to_quarry_main,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(monastery_front_to_back,
lambda state: has_combat_reqs("Quarry", state, player))
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,
lambda state: has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_mid_to_back,
lambda state: state.has(laurels, player)
or (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player)))
set_rule(zig_low_back_to_mid,
lambda state: (state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
and has_ability(prayer, state, world)
and has_combat_reqs("Rooted Ziggurat", state, player))
# only activating the fuse requires combat logic
set_rule(cath_entry_to_elev,
lambda state: options.entrance_rando
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ability(prayer, state, world) and has_combat_reqs("Cathedral", state, player)))
set_rule(cath_entry_to_main,
lambda state: has_combat_reqs("Cathedral", state, player))
set_rule(cath_elev_to_main,
lambda state: has_combat_reqs("Cathedral", state, player))
# for spots where you can go into and come out of an entrance to reset enemy aggro
if world.options.entrance_rando:
# for the chest outside of magic dagger house
dagger_entry_paired_name, dagger_entry_paired_region = (
get_paired_portal("Archipelagos Redux, archipelagos_house_"))
try:
dagger_entry_paired_entrance = world.get_entrance(dagger_entry_paired_name)
except KeyError:
# there is no paired entrance, so you must fight or dash past, which is done in the finally
pass
else:
set_rule(wg_checkpoint_to_dagger,
lambda state: dagger_entry_paired_entrance.can_reach(state))
world.multiworld.register_indirect_condition(region=regions["West Garden at Dagger House"],
entrance=dagger_entry_paired_entrance)
finally:
add_rule(wg_checkpoint_to_dagger,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player),
combine="or")
# zip past enemies in fortress grave path to enter the dusty entrance, then come back out
fort_dusty_paired_name, fort_dusty_paired_region = get_paired_portal("Fortress Reliquary, Dusty_")
try:
fort_dusty_paired_entrance = world.get_entrance(fort_dusty_paired_name)
except KeyError:
# there is no paired entrance, so you can't run past to deaggro
# the path to dusty can be done via combat, so no need to do anything here
pass
else:
# there is a paired entrance, so you can use that to deaggro enemies
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"],
rule=lambda state: state.has(laurels, player) and fort_dusty_paired_entrance.can_reach(state))
world.multiworld.register_indirect_condition(region=regions["Fortress Grave Path by Grave"],
entrance=fort_dusty_paired_entrance)
# for activating the ladder switch to get from fortress east upper to lower
fort_east_upper_right_paired_name, fort_east_upper_right_paired_region = (
get_paired_portal("Fortress East, Fortress Courtyard_"))
try:
fort_east_upper_right_paired_entrance = (
world.get_entrance(fort_east_upper_right_paired_name))
except KeyError:
# no paired entrance, so you must fight, which is done in the finally
pass
else:
set_rule(fort_east_upper_lower,
lambda state: fort_east_upper_right_paired_entrance.can_reach(state))
world.multiworld.register_indirect_condition(region=regions["Fortress East Shortcut Lower"],
entrance=fort_east_upper_right_paired_entrance)
finally:
add_rule(fort_east_upper_lower,
lambda state: has_combat_reqs("Eastern Vault Fortress", state, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world),
combine="or")
else:
# if combat logic is on and ER is off, we can make this entrance freely
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"],
rule=lambda state: state.has(laurels, player))
else:
# if combat logic is off, we can make this entrance freely
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"],
rule=lambda state: state.has(laurels, player))
def set_er_location_rules(world: "TunicWorld") -> None:
player = world.player
@@ -1315,6 +1599,11 @@ def set_er_location_rules(world: "TunicWorld") -> None:
set_rule(world.get_location("East Forest - Ice Rod Grapple Chest"), lambda state: (
state.has_all({grapple, ice_dagger, fire_wand}, player) and has_ability(icebolt, state, world)))
# Dark Tomb
# added to make combat logic smoother
set_rule(world.get_location("Dark Tomb - 2nd Laser Room"),
lambda state: has_lantern(state, world))
# West Garden
set_rule(world.get_location("West Garden - [North] Across From Page Pickup"),
lambda state: state.has(laurels, player))
@@ -1348,11 +1637,11 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Library Lab
set_rule(world.get_location("Library Lab - Page 1"),
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 2"),
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 3"),
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
# Eastern Vault Fortress
set_rule(world.get_location("Fortress Arena - Hexagon Red"),
@@ -1361,11 +1650,11 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have
# but really, I expect the player to just throw a bomb at them if they don't have melee
set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"),
lambda state: has_stick(state, player) or state.has(ice_dagger, player))
lambda state: has_melee(state, player) or state.has(ice_dagger, player))
# Beneath the Vault
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_stick(state, player) or state.has_any({laurels, fire_wand}, player))
lambda state: has_melee(state, player) or state.has_any({laurels, fire_wand}, player))
# Quarry
set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"),
@@ -1421,9 +1710,9 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Events
set_rule(world.get_location("Eastern Bell"),
lambda state: (has_stick(state, player) or state.has(fire_wand, player)))
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Western Bell"),
lambda state: (has_stick(state, player) or state.has(fire_wand, player)))
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Furnace Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("South and West Fortress Exterior Fuses"),
@@ -1447,6 +1736,9 @@ def set_er_location_rules(world: "TunicWorld") -> None:
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("Library Fuse"),
lambda state: has_ability(prayer, state, world) and has_ladder("Ladders in Library", state, world))
if not world.options.hexagon_quest:
set_rule(world.get_location("Place Questagons"),
lambda state: state.has_all((red_hexagon, blue_hexagon, green_hexagon), player))
# Bombable Walls
for location_name in bomb_walls:
@@ -1467,3 +1759,129 @@ def set_er_location_rules(world: "TunicWorld") -> None:
lambda state: has_sword(state, player))
set_rule(world.get_location("Shop - Coin 2"),
lambda state: has_sword(state, player))
def combat_logic_to_loc(loc_name: str, combat_req_area: str, set_instead: bool = False,
dagger: bool = False, laurel: bool = False) -> None:
# dagger means you can use magic dagger instead of combat for that check
# laurel means you can dodge the enemies freely with the laurels
if set_instead:
set_rule(world.get_location(loc_name),
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(world.get_location(loc_name),
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
combat_logic_to_loc("Fortress Arena - Siege Engine/Vault Key Pickup", "Siege Engine", set_instead=True)
combat_logic_to_loc("Librarian - Hexagon Green", "The Librarian", set_instead=True)
set_rule(world.get_location("Librarian - Hexagon Green"),
rule=lambda state: has_combat_reqs("The Librarian", state, player)
and has_ladder("Ladders in Library", state, world))
combat_logic_to_loc("Rooted Ziggurat Lower - Hexagon Blue", "Boss Scavenger", set_instead=True)
if world.options.ice_grappling >= IceGrappling.option_medium:
add_rule(world.get_location("Rooted Ziggurat Lower - Hexagon Blue"),
lambda state: has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
combat_logic_to_loc("Cathedral Gauntlet - Gauntlet Reward", "Gauntlet", set_instead=True)
if world.options.combat_logic == CombatLogic.option_on:
combat_logic_to_loc("Overworld - [Northeast] Flowers Holy Cross", "Garden Knight")
combat_logic_to_loc("Overworld - [Northwest] Chest Near Quarry Gate", "Before Well", dagger=True)
combat_logic_to_loc("Overworld - [Northeast] Chest Above Patrol Cave", "Garden Knight", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret", "Overworld", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret 2", "Overworld")
combat_logic_to_loc("Overworld - [Southwest] Bombable Wall Near Fountain", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] Fountain Holy Cross", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] South Chest Near Guard", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] Tunnel Guarded By Turret", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Northwest] Chest Near Turret", "Before Well")
add_rule(world.get_location("Hourglass Cave - Hourglass Chest"),
lambda state: has_sword(state, player) and (state.has("Shield", player)
# kill the turrets through the wall with a longer sword
or state.has("Sword Upgrade", player, 3)))
add_rule(world.get_location("Hourglass Cave - Holy Cross Chest"),
lambda state: has_sword(state, player) and (state.has("Shield", player)
or state.has("Sword Upgrade", player, 3)))
# the first spider chest they literally do not attack you until you open the chest
# the second one, you can still just walk past them, but I guess /something/ would be wanted
combat_logic_to_loc("East Forest - Beneath Spider Chest", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("East Forest - Golden Obelisk Holy Cross", "East Forest", dagger=True)
combat_logic_to_loc("East Forest - Dancing Fox Spirit Holy Cross", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("East Forest - From Guardhouse 1 Chest", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("East Forest - Above Save Point", "East Forest", dagger=True)
combat_logic_to_loc("East Forest - Above Save Point Obscured", "East Forest", dagger=True)
combat_logic_to_loc("Forest Grave Path - Above Gate", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("Forest Grave Path - Obscured Chest", "East Forest", dagger=True, laurel=True)
# most of beneath the well is covered by the region access rule
combat_logic_to_loc("Beneath the Well - [Entryway] Chest", "Beneath the Well")
combat_logic_to_loc("Beneath the Well - [Entryway] Obscured Behind Waterfall", "Beneath the Well")
combat_logic_to_loc("Beneath the Well - [Back Corridor] Left Secret", "Beneath the Well")
combat_logic_to_loc("Beneath the Well - [Side Room] Chest By Phrends", "Overworld")
# laurels past the enemies, then use the wand or gun to take care of the fairies that chased you
add_rule(world.get_location("West Garden - [West Lowlands] Tree Holy Cross Chest"),
lambda state: state.has_any({fire_wand, "Gun"}, player))
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Faeries", "West Garden")
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Save Point", "West Garden")
combat_logic_to_loc("West Garden - [West Highlands] Upper Left Walkway", "West Garden")
# with combat logic on, I presume the player will want to be able to see to avoid the spiders
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_lantern(state, world)
and (state.has_any({laurels, fire_wand, "Gun"}, player) or has_melee(state, player)))
combat_logic_to_loc("Eastern Vault Fortress - [West Wing] Candles Holy Cross", "Eastern Vault Fortress",
dagger=True)
# could just do the last two, but this outputs better in the spoiler log
# dagger is maybe viable here, but it's sketchy -- activate ladder switch, save to reset enemies, climb up
combat_logic_to_loc("Upper and Central Fortress Exterior Fuses", "Eastern Vault Fortress")
combat_logic_to_loc("Beneath the Vault Fuse", "Beneath the Vault")
combat_logic_to_loc("Eastern Vault West Fuses", "Eastern Vault Fortress")
# if you come in from the left, you only need to fight small crabs
add_rule(world.get_location("Ruined Atoll - [South] Near Birds"),
lambda state: has_melee(state, player) or state.has_any({laurels, "Gun"}, player))
# can get this one without fighting if you have laurels
add_rule(world.get_location("Frog's Domain - Above Vault"),
lambda state: state.has(laurels, player) or has_combat_reqs("Frog's Domain", state, player))
# with wand, you can get this chest. Non-ER, you need laurels to continue down. ER, you can just torch
set_rule(world.get_location("Rooted Ziggurat Upper - Near Bridge Switch"),
lambda state: (state.has(fire_wand, player)
and (state.has(laurels, player) or world.options.entrance_rando))
or has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(world.get_location("Rooted Ziggurat Lower - After Guarded Fuse"),
lambda state: has_ability(prayer, state, world)
and has_combat_reqs("Rooted Ziggurat", state, player))
# replace the sword rule with this one
combat_logic_to_loc("Swamp - [South Graveyard] 4 Orange Skulls", "Swamp", set_instead=True)
combat_logic_to_loc("Swamp - [South Graveyard] Guarded By Big Skeleton", "Swamp", dagger=True)
# don't really agree with this one but eh
combat_logic_to_loc("Swamp - [South Graveyard] Above Big Skeleton", "Swamp", dagger=True, laurel=True)
# the tentacles deal with everything else reasonably, and you can hide on the island, so no rule for it
add_rule(world.get_location("Swamp - [South Graveyard] Obscured Beneath Telescope"),
lambda state: state.has(laurels, player) # can dash from swamp mid to here and grab it
or has_combat_reqs("Swamp", state, player))
add_rule(world.get_location("Swamp - [Central] South Secret Passage"),
lambda state: state.has(laurels, player) # can dash from swamp front to here and grab it
or has_combat_reqs("Swamp", state, player))
combat_logic_to_loc("Swamp - [South Graveyard] Upper Walkway On Pedestal", "Swamp")
combat_logic_to_loc("Swamp - [Central] Beneath Memorial", "Swamp")
combat_logic_to_loc("Swamp - [Central] Near Ramps Up", "Swamp")
combat_logic_to_loc("Swamp - [Upper Graveyard] Near Telescope", "Swamp")
combat_logic_to_loc("Swamp - [Upper Graveyard] Near Shield Fleemers", "Swamp")
combat_logic_to_loc("Swamp - [Upper Graveyard] Obscured Behind Hill", "Swamp")
# zip through the rubble to sneakily grab this chest, or just fight to it
add_rule(world.get_location("Cathedral - [1F] Near Spikes"),
lambda state: laurels_zip(state, world) or has_combat_reqs("Cathedral", state, player))

View File

@@ -22,10 +22,19 @@ class TunicERLocation(Location):
def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
regions: Dict[str, Region] = {}
for region_name, region_data in world.er_regions.items():
regions[region_name] = Region(region_name, world.player, world.multiworld)
if world.options.entrance_rando:
for region_name, region_data in world.er_regions.items():
# if fewer shops is off, zig skip is not made
if region_name == "Zig Skip Exit":
# need to check if there's a seed group for this first
if world.options.entrance_rando.value not in EntranceRando.options.values():
if not world.seed_groups[world.options.entrance_rando.value]["fixed_shop"]:
continue
elif not world.options.fixed_shop:
continue
regions[region_name] = Region(region_name, world.player, world.multiworld)
portal_pairs = pair_portals(world, regions)
# output the entrances to the spoiler log here for convenience
@@ -33,16 +42,21 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player)
else:
for region_name, region_data in world.er_regions.items():
# filter out regions that are inaccessible in non-er
if region_name not in ["Zig Skip Exit", "Purgatory"]:
regions[region_name] = Region(region_name, world.player, world.multiworld)
portal_pairs = vanilla_portals(world, regions)
create_randomized_entrances(portal_pairs, regions)
set_er_region_rules(world, regions, portal_pairs)
for location_name, location_id in world.location_name_to_id.items():
region = regions[location_table[location_name].er_region]
location = TunicERLocation(world.player, location_name, location_id, region)
region.locations.append(location)
create_randomized_entrances(portal_pairs, regions)
for region in regions.values():
world.multiworld.regions.append(region)
@@ -70,7 +84,7 @@ tunic_events: Dict[str, str] = {
"Quarry Connector Fuse": "Quarry Connector",
"Quarry Fuse": "Quarry Entry",
"Ziggurat Fuse": "Rooted Ziggurat Lower Back",
"West Garden Fuse": "West Garden",
"West Garden Fuse": "West Garden South Checkpoint",
"Library Fuse": "Library Lab",
"Place Questagons": "Sealed Temple",
}
@@ -108,7 +122,8 @@ def create_shop_region(world: "TunicWorld", regions: Dict[str, Region]) -> None:
def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]:
portal_pairs: Dict[Portal, Portal] = {}
# we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here
portal_map = [portal for portal in portal_mapping if portal.name != "Ziggurat Lower Falling Entrance"]
portal_map = [portal for portal in portal_mapping if portal.name not in
["Ziggurat Lower Falling Entrance", "Purgatory Bottom Exit", "Purgatory Top Exit"]]
while portal_map:
portal1 = portal_map[0]
@@ -121,9 +136,6 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
destination="Previous Region", tag="_")
create_shop_region(world, regions)
elif portal2_sdt == "Purgatory, Purgatory_bottom":
portal2_sdt = "Purgatory, Purgatory_top"
for portal in portal_map:
if portal.scene_destination() == portal2_sdt:
portal2 = portal
@@ -414,6 +426,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
cr.add(portal.region)
if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks):
continue
# if not waterfall_plando, then we just want to pair secret gathering place now
elif portal.region != "Secret Gathering Place":
continue
portal2 = portal

View File

@@ -1,5 +1,5 @@
from itertools import groupby
from typing import Dict, List, Set, NamedTuple
from typing import Dict, List, Set, NamedTuple, Optional
from BaseClasses import ItemClassification as IC
@@ -8,6 +8,8 @@ class TunicItemData(NamedTuple):
quantity_in_item_pool: int
item_id_offset: int
item_group: str = ""
# classification if combat logic is on
combat_ic: Optional[IC] = None
item_base_id = 509342400
@@ -27,7 +29,7 @@ item_table: Dict[str, TunicItemData] = {
"Lure x2": TunicItemData(IC.filler, 1, 11, "Consumables"),
"Pepper x2": TunicItemData(IC.filler, 4, 12, "Consumables"),
"Ivy x3": TunicItemData(IC.filler, 2, 13, "Consumables"),
"Effigy": TunicItemData(IC.useful, 12, 14, "Money"),
"Effigy": TunicItemData(IC.useful, 12, 14, "Money", combat_ic=IC.progression),
"HP Berry": TunicItemData(IC.filler, 2, 15, "Consumables"),
"HP Berry x2": TunicItemData(IC.filler, 4, 16, "Consumables"),
"HP Berry x3": TunicItemData(IC.filler, 2, 17, "Consumables"),
@@ -44,32 +46,32 @@ item_table: Dict[str, TunicItemData] = {
"Hero's Laurels": TunicItemData(IC.progression | IC.useful, 1, 28),
"Lantern": TunicItemData(IC.progression, 1, 29),
"Gun": TunicItemData(IC.progression | IC.useful, 1, 30, "Weapons"),
"Shield": TunicItemData(IC.useful, 1, 31),
"Shield": TunicItemData(IC.useful, 1, 31, combat_ic=IC.progression | IC.useful),
"Dath Stone": TunicItemData(IC.useful, 1, 32),
"Hourglass": TunicItemData(IC.useful, 1, 33),
"Old House Key": TunicItemData(IC.progression, 1, 34, "Keys"),
"Key": TunicItemData(IC.progression, 2, 35, "Keys"),
"Fortress Vault Key": TunicItemData(IC.progression, 1, 36, "Keys"),
"Flask Shard": TunicItemData(IC.useful, 12, 37),
"Potion Flask": TunicItemData(IC.useful, 5, 38, "Flask"),
"Flask Shard": TunicItemData(IC.useful, 12, 37, combat_ic=IC.progression),
"Potion Flask": TunicItemData(IC.useful, 5, 38, "Flask", combat_ic=IC.progression),
"Golden Coin": TunicItemData(IC.progression, 17, 39),
"Card Slot": TunicItemData(IC.useful, 4, 40),
"Red Questagon": TunicItemData(IC.progression_skip_balancing, 1, 41, "Hexagons"),
"Green Questagon": TunicItemData(IC.progression_skip_balancing, 1, 42, "Hexagons"),
"Blue Questagon": TunicItemData(IC.progression_skip_balancing, 1, 43, "Hexagons"),
"Gold Questagon": TunicItemData(IC.progression_skip_balancing, 0, 44, "Hexagons"),
"ATT Offering": TunicItemData(IC.useful, 4, 45, "Offerings"),
"DEF Offering": TunicItemData(IC.useful, 4, 46, "Offerings"),
"Potion Offering": TunicItemData(IC.useful, 3, 47, "Offerings"),
"HP Offering": TunicItemData(IC.useful, 6, 48, "Offerings"),
"MP Offering": TunicItemData(IC.useful, 3, 49, "Offerings"),
"SP Offering": TunicItemData(IC.useful, 2, 50, "Offerings"),
"Hero Relic - ATT": TunicItemData(IC.progression_skip_balancing, 1, 51, "Hero Relics"),
"Hero Relic - DEF": TunicItemData(IC.progression_skip_balancing, 1, 52, "Hero Relics"),
"Hero Relic - HP": TunicItemData(IC.progression_skip_balancing, 1, 53, "Hero Relics"),
"Hero Relic - MP": TunicItemData(IC.progression_skip_balancing, 1, 54, "Hero Relics"),
"Hero Relic - POTION": TunicItemData(IC.progression_skip_balancing, 1, 55, "Hero Relics"),
"Hero Relic - SP": TunicItemData(IC.progression_skip_balancing, 1, 56, "Hero Relics"),
"ATT Offering": TunicItemData(IC.useful, 4, 45, "Offerings", combat_ic=IC.progression),
"DEF Offering": TunicItemData(IC.useful, 4, 46, "Offerings", combat_ic=IC.progression),
"Potion Offering": TunicItemData(IC.useful, 3, 47, "Offerings", combat_ic=IC.progression),
"HP Offering": TunicItemData(IC.useful, 6, 48, "Offerings", combat_ic=IC.progression),
"MP Offering": TunicItemData(IC.useful, 3, 49, "Offerings", combat_ic=IC.progression),
"SP Offering": TunicItemData(IC.useful, 2, 50, "Offerings", combat_ic=IC.progression),
"Hero Relic - ATT": TunicItemData(IC.progression_skip_balancing, 1, 51, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - DEF": TunicItemData(IC.progression_skip_balancing, 1, 52, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - HP": TunicItemData(IC.progression_skip_balancing, 1, 53, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - MP": TunicItemData(IC.progression_skip_balancing, 1, 54, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - POTION": TunicItemData(IC.progression_skip_balancing, 1, 55, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - SP": TunicItemData(IC.progression_skip_balancing, 1, 56, "Hero Relics", combat_ic=IC.progression),
"Orange Peril Ring": TunicItemData(IC.useful, 1, 57, "Cards"),
"Tincture": TunicItemData(IC.useful, 1, 58, "Cards"),
"Scavenger Mask": TunicItemData(IC.progression, 1, 59, "Cards"),
@@ -86,18 +88,18 @@ item_table: Dict[str, TunicItemData] = {
"Louder Echo": TunicItemData(IC.useful, 1, 70, "Cards"),
"Aura's Gem": TunicItemData(IC.useful, 1, 71, "Cards"),
"Bone Card": TunicItemData(IC.useful, 1, 72, "Cards"),
"Mr Mayor": TunicItemData(IC.useful, 1, 73, "Golden Treasures"),
"Secret Legend": TunicItemData(IC.useful, 1, 74, "Golden Treasures"),
"Sacred Geometry": TunicItemData(IC.useful, 1, 75, "Golden Treasures"),
"Vintage": TunicItemData(IC.useful, 1, 76, "Golden Treasures"),
"Just Some Pals": TunicItemData(IC.useful, 1, 77, "Golden Treasures"),
"Regal Weasel": TunicItemData(IC.useful, 1, 78, "Golden Treasures"),
"Spring Falls": TunicItemData(IC.useful, 1, 79, "Golden Treasures"),
"Power Up": TunicItemData(IC.useful, 1, 80, "Golden Treasures"),
"Back To Work": TunicItemData(IC.useful, 1, 81, "Golden Treasures"),
"Phonomath": TunicItemData(IC.useful, 1, 82, "Golden Treasures"),
"Dusty": TunicItemData(IC.useful, 1, 83, "Golden Treasures"),
"Forever Friend": TunicItemData(IC.useful, 1, 84, "Golden Treasures"),
"Mr Mayor": TunicItemData(IC.useful, 1, 73, "Golden Treasures", combat_ic=IC.progression),
"Secret Legend": TunicItemData(IC.useful, 1, 74, "Golden Treasures", combat_ic=IC.progression),
"Sacred Geometry": TunicItemData(IC.useful, 1, 75, "Golden Treasures", combat_ic=IC.progression),
"Vintage": TunicItemData(IC.useful, 1, 76, "Golden Treasures", combat_ic=IC.progression),
"Just Some Pals": TunicItemData(IC.useful, 1, 77, "Golden Treasures", combat_ic=IC.progression),
"Regal Weasel": TunicItemData(IC.useful, 1, 78, "Golden Treasures", combat_ic=IC.progression),
"Spring Falls": TunicItemData(IC.useful, 1, 79, "Golden Treasures", combat_ic=IC.progression),
"Power Up": TunicItemData(IC.useful, 1, 80, "Golden Treasures", combat_ic=IC.progression),
"Back To Work": TunicItemData(IC.useful, 1, 81, "Golden Treasures", combat_ic=IC.progression),
"Phonomath": TunicItemData(IC.useful, 1, 82, "Golden Treasures", combat_ic=IC.progression),
"Dusty": TunicItemData(IC.useful, 1, 83, "Golden Treasures", combat_ic=IC.progression),
"Forever Friend": TunicItemData(IC.useful, 1, 84, "Golden Treasures", combat_ic=IC.progression),
"Fool Trap": TunicItemData(IC.trap, 0, 85),
"Money x1": TunicItemData(IC.filler, 3, 86, "Money"),
"Money x10": TunicItemData(IC.filler, 1, 87, "Money"),
@@ -112,9 +114,9 @@ item_table: Dict[str, TunicItemData] = {
"Money x50": TunicItemData(IC.filler, 7, 96, "Money"),
"Money x64": TunicItemData(IC.filler, 1, 97, "Money"),
"Money x100": TunicItemData(IC.filler, 5, 98, "Money"),
"Money x128": TunicItemData(IC.useful, 3, 99, "Money"),
"Money x200": TunicItemData(IC.useful, 1, 100, "Money"),
"Money x255": TunicItemData(IC.useful, 1, 101, "Money"),
"Money x128": TunicItemData(IC.useful, 3, 99, "Money", combat_ic=IC.progression),
"Money x200": TunicItemData(IC.useful, 1, 100, "Money", combat_ic=IC.progression),
"Money x255": TunicItemData(IC.useful, 1, 101, "Money", combat_ic=IC.progression),
"Pages 0-1": TunicItemData(IC.useful, 1, 102, "Pages"),
"Pages 2-3": TunicItemData(IC.useful, 1, 103, "Pages"),
"Pages 4-5": TunicItemData(IC.useful, 1, 104, "Pages"),
@@ -206,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

@@ -78,9 +78,11 @@ easy_ls: List[LadderInfo] = [
# West Garden
# exit after Garden Knight
LadderInfo("West Garden", "Archipelagos Redux, Overworld Redux_upper"),
LadderInfo("West Garden before Boss", "Archipelagos Redux, Overworld Redux_upper"),
# West Garden laurels exit
LadderInfo("West Garden", "Archipelagos Redux, Overworld Redux_lowest"),
LadderInfo("West Garden after Terry", "Archipelagos Redux, Overworld Redux_lowest"),
# Magic dagger house, only relevant with combat logic on
LadderInfo("West Garden after Terry", "Archipelagos Redux, archipelagos_house_"),
# Atoll, use the little ladder you fix at the beginning
LadderInfo("Ruined Atoll", "Atoll Redux, Overworld Redux_lower"),
@@ -159,7 +161,8 @@ medium_ls: List[LadderInfo] = [
LadderInfo("Quarry Back", "Quarry Redux, Monastery_back"),
LadderInfo("Rooted Ziggurat Lower Back", "ziggurat2020_3, ziggurat2020_2_"),
LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Front", dest_is_region=True),
LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Entry", dest_is_region=True),
LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Mid Checkpoint", dest_is_region=True),
# Swamp to Overworld upper
LadderInfo("Swamp Mid", "Swamp Redux 2, Overworld Redux_wall", "Ladders in Swamp"),
@@ -172,9 +175,9 @@ hard_ls: List[LadderInfo] = [
LadderInfo("Beneath the Well Front", "Sewer, Overworld Redux_west_aqueduct", "Ladders in Well"),
LadderInfo("Beneath the Well Front", "Beneath the Well Back", "Ladders in Well", dest_is_region=True),
# go through the hexagon engraving above the vault door
LadderInfo("Frog's Domain", "frog cave main, Frog Stairs_Exit", "Ladders to Frog's Domain"),
LadderInfo("Frog's Domain Front", "frog cave main, Frog Stairs_Exit", "Ladders to Frog's Domain"),
# the turret at the end here is not affected by enemy rando
LadderInfo("Frog's Domain", "Frog's Domain Back", "Ladders to Frog's Domain", dest_is_region=True),
LadderInfo("Frog's Domain Front", "Frog's Domain Back", "Ladders to Frog's Domain", dest_is_region=True),
# todo: see if we can use that new laurels strat here
# LadderInfo("Rooted Ziggurat Lower Back", "ziggurat2020_3, ziggurat2020_FTRoom_"),
# go behind the cathedral to reach the door, pretty easily doable

View File

@@ -25,17 +25,17 @@ location_table: Dict[str, TunicLocationData] = {
"Beneath the Well - [Side Room] Chest By Phrends": TunicLocationData("Beneath the Well", "Beneath the Well Back"),
"Beneath the Well - [Second Room] Page": TunicLocationData("Beneath the Well", "Beneath the Well Main"),
"Dark Tomb Checkpoint - [Passage To Dark Tomb] Page Pickup": TunicLocationData("Overworld", "Dark Tomb Checkpoint"),
"Cathedral - [1F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Near Spikes": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Bird Room": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Entryway Upper Walkway": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Library": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Library": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Bird Room Secret": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Library Secret": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [1F] Near Spikes": TunicLocationData("Cathedral", "Cathedral Entry"), # entry because special rules
"Cathedral - [2F] Bird Room": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [2F] Entryway Upper Walkway": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [1F] Library": TunicLocationData("Cathedral", "Cathedral Entry"),
"Cathedral - [2F] Library": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [2F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [2F] Bird Room Secret": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [1F] Library Secret": TunicLocationData("Cathedral", "Cathedral Entry"),
"Dark Tomb - Spike Maze Near Exit": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - 2nd Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - 2nd Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Dark Exit"),
"Dark Tomb - 1st Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - Spike Maze Upper Walkway": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - Skulls Chest": TunicLocationData("Dark Tomb", "Dark Tomb Upper"),
@@ -81,25 +81,25 @@ location_table: Dict[str, TunicLocationData] = {
"Eastern Vault Fortress - [East Wing] Bombable Wall": TunicLocationData("Eastern Vault Fortress", "Eastern Vault Fortress"),
"Eastern Vault Fortress - [West Wing] Page Pickup": TunicLocationData("Eastern Vault Fortress", "Eastern Vault Fortress"),
"Fortress Grave Path - Upper Walkway": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path Upper"),
"Fortress Grave Path - Chest Right of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path"),
"Fortress Grave Path - Obscured Chest Left of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path"),
"Fortress Grave Path - Chest Right of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path by Grave"),
"Fortress Grave Path - Obscured Chest Left of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path by Grave"),
"Hero's Grave - Flowers Relic": TunicLocationData("Eastern Vault Fortress", "Hero Relic - Fortress"),
"Beneath the Fortress - Bridge": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Cell Chest 1": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Main"),
"Beneath the Fortress - Back Room Chest": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Cell Chest 2": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Slorm Room": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Slorm Room": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Escape Chest": TunicLocationData("Frog's Domain", "Frog's Domain Back"),
"Frog's Domain - Grapple Above Hot Tub": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Above Vault": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Main Room Top Floor": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Main Room Bottom Floor": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Side Room Secret Passage": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Side Room Chest": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Side Room Grapple Secret": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Magic Orb Pickup": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Grapple Above Hot Tub": TunicLocationData("Frog's Domain", "Frog's Domain Front"),
"Frog's Domain - Above Vault": TunicLocationData("Frog's Domain", "Frog's Domain Front"),
"Frog's Domain - Main Room Top Floor": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Main Room Bottom Floor": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Side Room Secret Passage": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Side Room Chest": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Side Room Grapple Secret": TunicLocationData("Frog's Domain", "Frog's Domain Front"),
"Frog's Domain - Magic Orb Pickup": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Librarian - Hexagon Green": TunicLocationData("Library", "Library Arena", location_group="Bosses"),
"Library Hall - Holy Cross Chest": TunicLocationData("Library", "Library Hall", location_group="Holy Cross"),
"Library Lab - Chest By Shrine 2": TunicLocationData("Library", "Library Lab"),
@@ -131,7 +131,7 @@ location_table: Dict[str, TunicLocationData] = {
"Overworld - [Southwest] West Beach Guarded By Turret": TunicLocationData("Overworld", "Overworld Beach"),
"Overworld - [Southwest] Chest Guarded By Turret": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Shadowy Corner Chest": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Southwest] Obscured In Tunnel To Beach": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Southwest] Obscured In Tunnel To Beach": TunicLocationData("Overworld", "Overworld Tunnel to Beach"),
"Overworld - [Southwest] Grapple Chest Over Walkway": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Chest Beneath Quarry Gate": TunicLocationData("Overworld", "Overworld after Envoy"),
"Overworld - [Southeast] Chest Near Swamp": TunicLocationData("Overworld", "Overworld Swamp Lower Entry"),
@@ -158,7 +158,7 @@ location_table: Dict[str, TunicLocationData] = {
"Overworld - [Northwest] Page on Pillar by Dark Tomb": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Fire Wand Pickup": TunicLocationData("Overworld", "Upper Overworld"),
"Overworld - [West] Page On Teleporter": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Page By Well": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Page By Well": TunicLocationData("Overworld", "Overworld Well Entry Area"),
"Patrol Cave - Normal Chest": TunicLocationData("Overworld", "Patrol Cave"),
"Ruined Shop - Chest 1": TunicLocationData("Overworld", "Ruined Shop"),
"Ruined Shop - Chest 2": TunicLocationData("Overworld", "Ruined Shop"),
@@ -233,17 +233,17 @@ location_table: Dict[str, TunicLocationData] = {
"Quarry - [Lowlands] Upper Walkway": TunicLocationData("Lower Quarry", "Even Lower Quarry"),
"Quarry - [West] Lower Area Below Bridge": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Quarry - [West] Lower Area Isolated Chest": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Quarry - [Lowlands] Near Elevator": TunicLocationData("Lower Quarry", "Even Lower Quarry"),
"Quarry - [Lowlands] Near Elevator": TunicLocationData("Lower Quarry", "Even Lower Quarry Isolated Chest"),
"Quarry - [West] Lower Area After Bridge": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Rooted Ziggurat Upper - Near Bridge Switch": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Upper Front"),
"Rooted Ziggurat Upper - Beneath Bridge To Administrator": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Upper Back"),
"Rooted Ziggurat Tower - Inside Tower": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Middle Top"),
"Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"),
"Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"),
"Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - Guarded By Double Turrets": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - Guarded By Double Turrets 2": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Hexagon Blue": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Back", location_group="Bosses"),
"Ruined Atoll - [West] Near Kevin Block": TunicLocationData("Ruined Atoll", "Ruined Atoll"),
@@ -290,26 +290,26 @@ location_table: Dict[str, TunicLocationData] = {
"Hero's Grave - Feathers Relic": TunicLocationData("Swamp", "Hero Relic - Swamp"),
"West Furnace - Chest": TunicLocationData("West Garden", "Furnace Walking Path"),
"Overworld - [West] Near West Garden Entrance": TunicLocationData("West Garden", "Overworld to West Garden from Furnace"),
"West Garden - [Central Highlands] Holy Cross (Blue Lines)": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"),
"West Garden - [West Lowlands] Tree Holy Cross Chest": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"),
"West Garden - [Southeast Lowlands] Outside Cave": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Chest Beneath Faeries": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Behind Holy Cross Door": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"),
"West Garden - [Central Highlands] Top of Ladder Before Boss": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Passage Beneath Bridge": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Across From Page Pickup": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Below Left Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [West] In Flooded Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [West] Past Flooded Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Obscured Beneath Hero's Memorial": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden"),
"West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Highlands] Holy Cross (Blue Lines)": TunicLocationData("West Garden", "West Garden before Boss", location_group="Holy Cross"),
"West Garden - [West Lowlands] Tree Holy Cross Chest": TunicLocationData("West Garden", "West Garden after Terry", location_group="Holy Cross"),
"West Garden - [Southeast Lowlands] Outside Cave": TunicLocationData("West Garden", "West Garden at Dagger House"),
"West Garden - [Central Lowlands] Chest Beneath Faeries": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [North] Behind Holy Cross Door": TunicLocationData("West Garden", "West Garden before Terry", location_group="Holy Cross"),
"West Garden - [Central Highlands] Top of Ladder Before Boss": TunicLocationData("West Garden", "West Garden before Boss"),
"West Garden - [Central Lowlands] Passage Beneath Bridge": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [North] Across From Page Pickup": TunicLocationData("West Garden", "West Garden before Terry"),
"West Garden - [Central Lowlands] Below Left Walkway": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West] In Flooded Walkway": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West] Past Flooded Walkway": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [North] Obscured Beneath Hero's Memorial": TunicLocationData("West Garden", "West Garden before Terry"),
"West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden before Boss"),
"West Garden - [Central Highlands] After Garden Knight": TunicLocationData("Overworld", "West Garden after Boss", location_group="Bosses"),
"West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden"),
"West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [East Lowlands] Page Behind Ice Dagger House": TunicLocationData("West Garden", "West Garden Portal Item"),
"West Garden - [North] Page Pickup": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Page Pickup": TunicLocationData("West Garden", "West Garden before Terry"),
"West Garden House - [Southeast Lowlands] Ice Dagger Pickup": TunicLocationData("West Garden", "Magic Dagger House"),
"Hero's Grave - Effigy Relic": TunicLocationData("West Garden", "Hero Relic - West Garden"),
}

View File

@@ -168,6 +168,22 @@ class TunicPlandoConnections(PlandoConnections):
duplicate_exits = True
class CombatLogic(Choice):
"""
If enabled, the player will logically require a combination of stat upgrade items and equipment to get some checks or navigate to some areas, with a goal of matching the vanilla combat difficulty.
The player may still be expected to run past enemies, reset aggro (by using a checkpoint or doing a scene transition), or find sneaky paths to checks.
This option marks many more items as progression and may force weapons much earlier than normal.
Bosses Only makes it so that additional combat logic is only added to the boss fights and the Gauntlet.
If disabled, the standard, looser logic is used. The standard logic does not include stat upgrades, just minimal weapon requirements, such as requiring a Sword or Magic Wand for Quarry, or not requiring a weapon for Swamp.
"""
internal_name = "combat_logic"
display_name = "More Combat Logic"
option_off = 0
option_bosses_only = 1
option_on = 2
default = 0
class LaurelsZips(Toggle):
"""
Choose whether to include using the Hero's Laurels to zip through gates, doors, and tricky spots.
@@ -259,6 +275,7 @@ class TunicOptions(PerGameCommonOptions):
hexagon_goal: HexagonGoal
extra_hexagon_percentage: ExtraHexagonPercentage
laurels_location: LaurelsLocation
combat_logic: CombatLogic
lanternless: Lanternless
maskless: Maskless
laurels_zips: LaurelsZips
@@ -272,6 +289,7 @@ class TunicOptions(PerGameCommonOptions):
tunic_option_groups = [
OptionGroup("Logic Options", [
CombatLogic,
Lanternless,
Maskless,
LaurelsZips,

View File

@@ -56,9 +56,8 @@ def has_ability(ability: str, state: CollectionState, world: "TunicWorld") -> bo
# a check to see if you can whack things in melee at all
def has_stick(state: CollectionState, player: int) -> bool:
return (state.has("Stick", player) or state.has("Sword Upgrade", player, 1)
or state.has("Sword", player))
def has_melee(state: CollectionState, player: int) -> bool:
return state.has_any({"Stick", "Sword", "Sword Upgrade"}, player)
def has_sword(state: CollectionState, player: int) -> bool:
@@ -83,7 +82,7 @@ def can_ladder_storage(state: CollectionState, world: "TunicWorld") -> bool:
return False
if world.options.ladder_storage_without_items:
return True
return has_stick(state, world.player) or state.has_any((grapple, shield), world.player)
return has_melee(state, world.player) or state.has_any((grapple, shield), world.player)
def has_mask(state: CollectionState, world: "TunicWorld") -> bool:
@@ -101,7 +100,7 @@ def set_region_rules(world: "TunicWorld") -> None:
world.get_entrance("Overworld -> Overworld Holy Cross").access_rule = \
lambda state: has_ability(holy_cross, state, world)
world.get_entrance("Overworld -> Beneath the Well").access_rule = \
lambda state: has_stick(state, player) or state.has(fire_wand, player)
lambda state: has_melee(state, player) or state.has(fire_wand, player)
world.get_entrance("Overworld -> Dark Tomb").access_rule = \
lambda state: has_lantern(state, world)
# laurels in, ladder storage in through the furnace, or ice grapple down the belltower
@@ -117,7 +116,7 @@ def set_region_rules(world: "TunicWorld") -> None:
world.get_entrance("Overworld -> Beneath the Vault").access_rule = \
lambda state: (has_lantern(state, world) and has_ability(prayer, state, world)
# there's some boxes in the way
and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand), player)))
and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand), player)))
world.get_entrance("Ruined Atoll -> Library").access_rule = \
lambda state: state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world)
world.get_entrance("Overworld -> Quarry").access_rule = \
@@ -237,7 +236,7 @@ def set_location_rules(world: "TunicWorld") -> None:
or (has_lantern(state, world) and (has_sword(state, player) or state.has(fire_wand, player)))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
set_rule(world.get_location("West Furnace - Lantern Pickup"),
lambda state: has_stick(state, player) or state.has_any({fire_wand, laurels}, player))
lambda state: has_melee(state, player) or state.has_any({fire_wand, laurels}, player))
set_rule(world.get_location("Secret Gathering Place - 10 Fairy Reward"),
lambda state: state.has(fairies, player, 10))
@@ -301,18 +300,18 @@ def set_location_rules(world: "TunicWorld") -> None:
# Library Lab
set_rule(world.get_location("Library Lab - Page 1"),
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 2"),
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 3"),
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
# Eastern Vault Fortress
# yes, you can clear the leaves with dagger
# gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have
# but really, I expect the player to just throw a bomb at them if they don't have melee
set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"),
lambda state: state.has(laurels, player) and (has_stick(state, player) or state.has(ice_dagger, player)))
lambda state: state.has(laurels, player) and (has_melee(state, player) or state.has(ice_dagger, player)))
set_rule(world.get_location("Fortress Arena - Siege Engine/Vault Key Pickup"),
lambda state: has_sword(state, player)
and (has_ability(prayer, state, world)
@@ -324,9 +323,9 @@ def set_location_rules(world: "TunicWorld") -> None:
# Beneath the Vault
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_stick(state, player) or state.has_any({laurels, fire_wand}, player))
lambda state: has_melee(state, player) or state.has_any({laurels, fire_wand}, player))
set_rule(world.get_location("Beneath the Fortress - Obscured Behind Waterfall"),
lambda state: has_stick(state, player) and has_lantern(state, world))
lambda state: has_melee(state, player) and has_lantern(state, world))
# Quarry
set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"),

View File

@@ -3,6 +3,8 @@ from .. import options
class TestAccess(TunicTestBase):
options = {options.CombatLogic.internal_name: options.CombatLogic.option_off}
# test whether you can get into the temple without laurels
def test_temple_access(self) -> None:
self.collect_all_but(["Hero's Laurels", "Lantern"])
@@ -61,7 +63,9 @@ class TestNormalGoal(TunicTestBase):
class TestER(TunicTestBase):
options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes,
options.AbilityShuffling.internal_name: options.AbilityShuffling.option_true,
options.HexagonQuest.internal_name: options.HexagonQuest.option_false}
options.HexagonQuest.internal_name: options.HexagonQuest.option_false,
options.CombatLogic.internal_name: options.CombatLogic.option_off,
options.FixedShop.internal_name: options.FixedShop.option_true}
def test_overworld_hc_chest(self) -> None:
# test to see that static connections are working properly -- this chest requires holy cross and is in Overworld

View File

@@ -56,6 +56,7 @@ Doors:
1119 - Quarry Stoneworks Entry (Panel) - 0x01E5A,0x01E59
1120 - Quarry Stoneworks Ramp Controls (Panel) - 0x03678,0x03676
1122 - Quarry Stoneworks Lift Controls (Panel) - 0x03679,0x03675
1123 - Quarry Stoneworks Stairs (Panel) - 0x03677
1125 - Quarry Boathouse Ramp Height Control (Panel) - 0x03852
1127 - Quarry Boathouse Ramp Horizontal Control (Panel) - 0x03858
1129 - Quarry Boathouse Hook Control (Panel) - 0x275FA
@@ -84,6 +85,7 @@ Doors:
1205 - Treehouse Laser House Door Timer (Panel) - 0x2700B,0x17CBC
1208 - Treehouse Drawbridge (Panel) - 0x037FF
1175 - Jungle Popup Wall (Panel) - 0x17CAB
1178 - Jungle Monastery Garden Shortcut (Panel) - 0x17CAA
1180 - Bunker Entry (Panel) - 0x17C2E
1183 - Bunker Tinted Glass Door (Panel) - 0x0A099
1186 - Bunker Elevator Control (Panel) - 0x0A079
@@ -94,12 +96,15 @@ Doors:
1195 - Swamp Rotating Bridge (Panel) - 0x181F5
1196 - Swamp Long Bridge (Panel) - 0x17E2B
1197 - Swamp Maze Controls (Panel) - 0x17C0A,0x17E07
1199 - Swamp Laser Shortcut (Panel) - 0x17C05
1220 - Mountain Floor 1 Light Bridge (Panel) - 0x09E39
1225 - Mountain Floor 2 Light Bridge Near (Panel) - 0x09E86
1230 - Mountain Floor 2 Light Bridge Far (Panel) - 0x09ED8
1235 - Mountain Floor 2 Elevator Control (Panel) - 0x09EEB
1240 - Caves Entry (Panel) - 0x00FF8
1242 - Caves Elevator Controls (Panel) - 0x335AB,0x335AC,0x3369D
1243 - Caves Mountain Shortcut (Panel) - 0x021D7
1244 - Caves Swamp Shortcut (Panel) - 0x17CF2
1245 - Challenge Entry (Panel) - 0x0A16E
1250 - Tunnels Entry (Panel) - 0x039B4
1255 - Tunnels Town Shortcut (Panel) - 0x09E85
@@ -250,19 +255,20 @@ Doors:
2101 - Outside Tutorial Outpost Panels - 0x0A171,0x04CA4
2105 - Desert Panels - 0x09FAA,0x1C2DF,0x1831E,0x1C260,0x1831C,0x1C2F3,0x1831D,0x1C2B1,0x1831B,0x0C339,0x0A249,0x0A015,0x09FA0,0x09F86
2110 - Quarry Outside Panels - 0x17C09,0x09E57,0x17CC4
2115 - Quarry Stoneworks Panels - 0x01E5A,0x01E59,0x03678,0x03676,0x03679,0x03675
2115 - Quarry Stoneworks Panels - 0x01E5A,0x01E59,0x03678,0x03676,0x03679,0x03675,0x03677
2120 - Quarry Boathouse Panels - 0x03852,0x03858,0x275FA
2122 - Keep Hedge Maze Panels - 0x00139,0x019DC,0x019E7,0x01A0F
2125 - Monastery Panels - 0x09D9B,0x00C92,0x00B10
2127 - Jungle Panels - 0x17CAB,0x17CAA
2130 - Town Church & RGB House Panels - 0x28998,0x28A0D,0x334D8
2135 - Town Maze Panels - 0x2896A,0x28A79
2137 - Town Dockside House Panels - 0x0A0C8,0x09F98
2140 - Windmill & Theater Panels - 0x17D02,0x00815,0x17F5F,0x17F89,0x0A168,0x33AB2
2145 - Treehouse Panels - 0x0A182,0x0288C,0x02886,0x2700B,0x17CBC,0x037FF
2150 - Bunker Panels - 0x34BC5,0x34BC6,0x0A079,0x0A099,0x17C2E
2155 - Swamp Panels - 0x00609,0x18488,0x181F5,0x17E2B,0x17C0A,0x17E07,0x17C0D,0x0056E
2155 - Swamp Panels - 0x00609,0x18488,0x181F5,0x17E2B,0x17C0A,0x17E07,0x17C0D,0x0056E,0x17C05
2160 - Mountain Panels - 0x09ED8,0x09E86,0x09E39,0x09EEB
2165 - Caves Panels - 0x3369D,0x00FF8,0x0A16E,0x335AB,0x335AC
2165 - Caves Panels - 0x3369D,0x00FF8,0x0A16E,0x335AB,0x335AC,0x021D7,0x17CF2
2170 - Tunnels Panels - 0x09E85,0x039B4
2200 - Desert Obelisk Key - 0x0332B,0x03367,0x28B8A,0x037B6,0x037B2,0x000F7,0x3351D,0x0053C,0x00771,0x335C8,0x335C9,0x337F8,0x037BB,0x220E4,0x220E5,0x334B9,0x334BC,0x22106,0x0A14C,0x0A14D,0x00359

View File

@@ -9,6 +9,7 @@ Desert Flood Room Entry (Panel)
Quarry Entry 1 (Panel)
Quarry Entry 2 (Panel)
Quarry Stoneworks Entry (Panel)
Quarry Stoneworks Stairs (Panel)
Shadows Door Timer (Panel)
Keep Hedge Maze 1 (Panel)
Keep Hedge Maze 2 (Panel)
@@ -28,11 +29,15 @@ Treehouse Third Door (Panel)
Treehouse Laser House Door Timer (Panel)
Treehouse Drawbridge (Panel)
Jungle Popup Wall (Panel)
Jungle Monastery Garden Shortcut (Panel)
Bunker Entry (Panel)
Bunker Tinted Glass Door (Panel)
Swamp Entry (Panel)
Swamp Platform Shortcut (Panel)
Swamp Laser Shortcut (Panel)
Caves Entry (Panel)
Caves Mountain Shortcut (Panel)
Caves Swamp Shortcut (Panel)
Challenge Entry (Panel)
Tunnels Entry (Panel)
Tunnels Town Shortcut (Panel)

View File

@@ -7,6 +7,7 @@ Quarry Stoneworks Panels
Quarry Boathouse Panels
Keep Hedge Maze Panels
Monastery Panels
Jungle Panels
Town Church & RGB House Panels
Town Maze Panels
Windmill & Theater Panels
@@ -18,5 +19,4 @@ Mountain Panels
Caves Panels
Tunnels Panels
Glass Factory Entry (Panel)
Shadows Door Timer (Panel)
Jungle Popup Wall (Panel)
Shadows Door Timer (Panel)

Some files were not shown because too many files have changed in this diff Show More