mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-24 08:23:25 -07:00
Compare commits
117 Commits
0-6-0-rc1
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930f627794 | ||
|
|
d4fc90410c | ||
|
|
484c5f2671 | ||
|
|
04771fa4f0 | ||
|
|
2639796255 | ||
|
|
4ebabc1208 | ||
|
|
ce34b60712 | ||
|
|
54094c6331 | ||
|
|
3986f6f11a | ||
|
|
5662da6f7d | ||
|
|
33a75fb2cb | ||
|
|
ee9bcb84b7 | ||
|
|
b5269e9aa4 | ||
|
|
00a6ac3a52 | ||
|
|
ea8a14b003 | ||
|
|
414ab86422 | ||
|
|
d4e2698ae0 | ||
|
|
3f8e3082c0 | ||
|
|
0f738935ee | ||
|
|
9c57976252 | ||
|
|
3e08acf381 | ||
|
|
113259bc15 | ||
|
|
61afe76eae | ||
|
|
08b3b3ecf5 | ||
|
|
bc61221ec6 | ||
|
|
2f0b81e12c | ||
|
|
bb9a6bcd2e | ||
|
|
c8b7ef1016 | ||
|
|
e00467c2a2 | ||
|
|
0eb6150e95 | ||
|
|
91d977479d | ||
|
|
cd761db170 | ||
|
|
026011323e | ||
|
|
adc5f3a07d | ||
|
|
69940374e1 | ||
|
|
6dc461609b | ||
|
|
58d460678e | ||
|
|
0f7fd48cdd | ||
|
|
18de035b4d | ||
|
|
11fa43f0a4 | ||
|
|
91a8fc91d6 | ||
|
|
15bde56551 | ||
|
|
d744e086ef | ||
|
|
378fa5d5c4 | ||
|
|
8349774c5c | ||
|
|
34795b598a | ||
|
|
efd5004330 | ||
|
|
c799531105 | ||
|
|
5c1ded1fe9 | ||
|
|
b2162bb8e6 | ||
|
|
f1769a8d00 | ||
|
|
f520c1d9f2 | ||
|
|
910369a7f8 | ||
|
|
dbf6b6f935 | ||
|
|
e9c463c897 | ||
|
|
f4e43ca9e0 | ||
|
|
a298be9c41 | ||
|
|
18bcaa85a2 | ||
|
|
359f45d50f | ||
|
|
f5c574c37a | ||
|
|
f75a1ae117 | ||
|
|
768ccffe72 | ||
|
|
f6668997e6 | ||
|
|
db11c620a7 | ||
|
|
da48af60dc | ||
|
|
19faaa4104 | ||
|
|
628252896e | ||
|
|
f28aff6f9a | ||
|
|
894732be47 | ||
|
|
051518e72a | ||
|
|
b7b78dead3 | ||
|
|
d1167027f4 | ||
|
|
445c9b22d6 | ||
|
|
67e8877143 | ||
|
|
1fe8024b43 | ||
|
|
8e14e463e4 | ||
|
|
b8666b2562 | ||
|
|
57afdfda6f | ||
|
|
738c21c625 | ||
|
|
41898ed640 | ||
|
|
1ebc9e2ec0 | ||
|
|
9466d5274e | ||
|
|
a53bcb4697 | ||
|
|
8c5592e406 | ||
|
|
41055cd963 | ||
|
|
43874b1d28 | ||
|
|
b570aa2ec6 | ||
|
|
c43233120a | ||
|
|
57a571cc11 | ||
|
|
8622cb6204 | ||
|
|
90417e0022 | ||
|
|
96b941ed35 | ||
|
|
1832bac1a3 | ||
|
|
86641223c1 | ||
|
|
cc770418f2 | ||
|
|
513e361764 | ||
|
|
ddf7fdccc7 | ||
|
|
3df2dbe051 | ||
|
|
3d1d6908c8 | ||
|
|
7474c27372 | ||
|
|
bb0948154d | ||
|
|
fa2816822b | ||
|
|
5a42c70675 | ||
|
|
949527f9cb | ||
|
|
1a1b7e9cf4 | ||
|
|
edacb17171 | ||
|
|
33fd9de281 | ||
|
|
a126dee068 | ||
|
|
e2b942139a | ||
|
|
823b17c386 | ||
|
|
05d1b2129a | ||
|
|
436c0a4104 | ||
|
|
96f469c737 | ||
|
|
4f77abac4f | ||
|
|
d5cd95c7fb | ||
|
|
a2fbf856ff | ||
|
|
4fa8c43266 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
|
||||
4
.github/workflows/ctest.yml
vendored
4
.github/workflows/ctest.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -21,7 +21,7 @@ on:
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
|
||||
@@ -869,21 +869,40 @@ class CollectionState():
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
return self.prog_items[player][item] >= count
|
||||
|
||||
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
|
||||
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
|
||||
# argument to all() would be a new generator instance, for example.
|
||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
||||
"""Returns True if each item name of items is in state at least once."""
|
||||
return all(self.prog_items[player][item] for item in items)
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item in items:
|
||||
if not player_prog_items[item]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
||||
"""Returns True if at least one item name of items is in state at least once."""
|
||||
return any(self.prog_items[player][item] for item in items)
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item in items:
|
||||
if player_prog_items[item]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
||||
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item, count in item_counts.items():
|
||||
if player_prog_items[item] < count:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
||||
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item, count in item_counts.items():
|
||||
if player_prog_items[item] >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[player][item]
|
||||
@@ -911,11 +930,20 @@ class CollectionState():
|
||||
|
||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
player_prog_items = self.prog_items[player]
|
||||
total = 0
|
||||
for item_name in items:
|
||||
total += player_prog_items[item_name]
|
||||
return total
|
||||
|
||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||
player_prog_items = self.prog_items[player]
|
||||
total = 0
|
||||
for item_name in items:
|
||||
if player_prog_items[item_name] > 0:
|
||||
total += 1
|
||||
return total
|
||||
|
||||
# item name group related
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
|
||||
@@ -709,8 +709,16 @@ class CommonContext:
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
||||
def make_gui(self) -> "type[kvui.GameManager]":
|
||||
"""
|
||||
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
|
||||
|
||||
Common changes are changing `base_title` to update the window title of the client and
|
||||
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
|
||||
|
||||
ex. `logging_pairs.append(("Foo", "Bar"))`
|
||||
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
|
||||
"""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
@@ -899,6 +907,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.disconnected_intentionally = True
|
||||
ctx.event_invalid_game()
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
ctx.disconnected_intentionally = True
|
||||
raise Exception('Server reported your client version as incompatible. '
|
||||
'This probably means you have to update.')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
@@ -1087,7 +1096,7 @@ def run_as_textclient(*args):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
await self.send_connect(game="")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
|
||||
8
Fill.py
8
Fill.py
@@ -502,7 +502,13 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||
name="Priority", one_item_per_player=False)
|
||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||
|
||||
if prioritylocations:
|
||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||
name="Priority Retry", one_item_per_player=False)
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||
from worlds.ladx.GpsTracker import GpsTracker
|
||||
from worlds.ladx.TrackerConsts import storage_key
|
||||
from worlds.ladx.ItemTracker import ItemTracker
|
||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||
@@ -100,19 +101,23 @@ class LAClientConstants:
|
||||
WRamCheckSize = 0x4
|
||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||
|
||||
wRamStart = 0xC000
|
||||
hRamStart = 0xFF80
|
||||
hRamSize = 0x80
|
||||
|
||||
MinGameplayValue = 0x06
|
||||
MaxGameplayValue = 0x1A
|
||||
VictoryGameplayAndSub = 0x0102
|
||||
|
||||
|
||||
class RAGameboy():
|
||||
cache = []
|
||||
cache_start = 0
|
||||
cache_size = 0
|
||||
last_cache_read = None
|
||||
socket = None
|
||||
|
||||
def __init__(self, address, port) -> None:
|
||||
self.cache_start = LAClientConstants.wRamStart
|
||||
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
|
||||
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -131,9 +136,14 @@ class RAGameboy():
|
||||
async def get_retroarch_status(self):
|
||||
return await self.send_command("GET_STATUS")
|
||||
|
||||
def set_cache_limits(self, cache_start, cache_size):
|
||||
self.cache_start = cache_start
|
||||
self.cache_size = cache_size
|
||||
def set_checks_range(self, checks_start, checks_size):
|
||||
self.checks_start = checks_start
|
||||
self.checks_size = checks_size
|
||||
|
||||
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||
self.location_start = location_start
|
||||
self.location_size = location_size
|
||||
self.critical_location_addresses = critical_addresses
|
||||
|
||||
def send(self, b):
|
||||
if type(b) is str:
|
||||
@@ -188,21 +198,57 @@ class RAGameboy():
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
cache = []
|
||||
remaining_size = self.cache_size
|
||||
while remaining_size:
|
||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
||||
remaining_size -= len(block)
|
||||
cache += block
|
||||
attempts = 0
|
||||
while True:
|
||||
# RA doesn't let us do an atomic read of a large enough block of RAM
|
||||
# Some bytes can't change in between reading location_block and hram_block
|
||||
location_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
|
||||
verification_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||
|
||||
valid = True
|
||||
for address in self.critical_location_addresses:
|
||||
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
|
||||
valid = False
|
||||
|
||||
if valid:
|
||||
break
|
||||
|
||||
attempts += 1
|
||||
|
||||
# Shouldn't really happen, but keep it from choking
|
||||
if attempts > 5:
|
||||
return
|
||||
|
||||
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
self.cache = cache
|
||||
self.cache = bytearray(self.cache_size)
|
||||
|
||||
start = self.checks_start - self.cache_start
|
||||
self.cache[start:start + len(checks_block)] = checks_block
|
||||
|
||||
start = self.location_start - self.cache_start
|
||||
self.cache[start:start + len(location_block)] = location_block
|
||||
|
||||
start = LAClientConstants.hRamStart - self.cache_start
|
||||
self.cache[start:start + len(hram_block)] = hram_block
|
||||
|
||||
self.last_cache_read = time.time()
|
||||
|
||||
async def read_memory_block(self, address: int, size: int):
|
||||
block = bytearray()
|
||||
remaining_size = size
|
||||
while remaining_size:
|
||||
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||
remaining_size -= len(chunk)
|
||||
block += chunk
|
||||
|
||||
return block
|
||||
|
||||
async def read_memory_cache(self, addresses):
|
||||
# TODO: can we just update once per frame?
|
||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||
await self.update_cache()
|
||||
if not self.cache:
|
||||
@@ -359,11 +405,12 @@ class LinksAwakeningClient():
|
||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||
self.auth = auth
|
||||
|
||||
async def wait_and_init_tracker(self):
|
||||
async def wait_and_init_tracker(self, magpie: MagpieBridge):
|
||||
await self.wait_for_game_ready()
|
||||
self.tracker = LocationTracker(self.gameboy)
|
||||
self.item_tracker = ItemTracker(self.gameboy)
|
||||
self.gps_tracker = GpsTracker(self.gameboy)
|
||||
magpie.gps_tracker = self.gps_tracker
|
||||
|
||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||
# Don't allow getting an item until you've got your first check
|
||||
@@ -405,9 +452,11 @@ class LinksAwakeningClient():
|
||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||
|
||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||
await self.gameboy.update_cache()
|
||||
await self.tracker.readChecks(item_get_cb)
|
||||
await self.item_tracker.readItems()
|
||||
await self.gps_tracker.read_location()
|
||||
await self.gps_tracker.read_entrances()
|
||||
|
||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||
if self.deathlink_debounce and current_health != 0:
|
||||
@@ -465,6 +514,10 @@ class LinksAwakeningContext(CommonContext):
|
||||
magpie_task = None
|
||||
won = False
|
||||
|
||||
@property
|
||||
def slot_storage_key(self):
|
||||
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
@@ -507,7 +560,19 @@ class LinksAwakeningContext(CommonContext):
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def send_checks(self):
|
||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
||||
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||
# Store the entrances we find on the server for future sessions
|
||||
message = [{
|
||||
"cmd": "Set",
|
||||
"key": self.slot_storage_key,
|
||||
"default": {},
|
||||
"want_reply": False,
|
||||
"operations": [{"operation": "update", "value": entrances}],
|
||||
}]
|
||||
|
||||
await self.send_msgs(message)
|
||||
|
||||
had_invalid_slot_data = None
|
||||
@@ -536,6 +601,12 @@ class LinksAwakeningContext(CommonContext):
|
||||
logger.info("victory!")
|
||||
await self.send_msgs(message)
|
||||
self.won = True
|
||||
|
||||
async def request_found_entrances(self):
|
||||
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||
|
||||
# Ask for updates so that players can co-op entrances in a seed
|
||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||
|
||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
if self.ENABLE_DEATHLINK:
|
||||
@@ -576,6 +647,12 @@ class LinksAwakeningContext(CommonContext):
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
self.client.recvd_checks[index] = item
|
||||
|
||||
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
||||
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||
|
||||
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
|
||||
self.client.gps_tracker.receive_found_entrances(args["value"])
|
||||
|
||||
async def sync(self):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
@@ -589,6 +666,12 @@ class LinksAwakeningContext(CommonContext):
|
||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||
|
||||
for check in ladxr_checks:
|
||||
if check.value and check.linkedItem:
|
||||
linkedItem = check.linkedItem
|
||||
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
|
||||
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||
|
||||
async def victory():
|
||||
await self.send_victory()
|
||||
|
||||
@@ -622,12 +705,20 @@ class LinksAwakeningContext(CommonContext):
|
||||
if not self.client.recvd_checks:
|
||||
await self.sync()
|
||||
|
||||
await self.client.wait_and_init_tracker()
|
||||
await self.client.wait_and_init_tracker(self.magpie)
|
||||
|
||||
min_tick_duration = 0.1
|
||||
last_tick = time.time()
|
||||
while True:
|
||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
now = time.time()
|
||||
tick_duration = now - last_tick
|
||||
sleep_duration = max(min_tick_duration - tick_duration, 0)
|
||||
await asyncio.sleep(sleep_duration)
|
||||
|
||||
last_tick = now
|
||||
|
||||
if self.last_resend + 5.0 < now:
|
||||
self.last_resend = now
|
||||
await self.send_checks()
|
||||
@@ -635,8 +726,15 @@ class LinksAwakeningContext(CommonContext):
|
||||
try:
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
self.magpie.slot_data = self.slot_data
|
||||
|
||||
if self.client.gps_tracker.needs_found_entrances:
|
||||
await self.request_found_entrances()
|
||||
self.client.gps_tracker.needs_found_entrances = False
|
||||
|
||||
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
|
||||
if new_entrances:
|
||||
await self.send_new_entrances(new_entrances)
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
|
||||
@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
class AdjusterWorld(object):
|
||||
class AdjusterSubWorld(object):
|
||||
def __init__(self, random):
|
||||
self.random = random
|
||||
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.per_slot_randoms = {1: random}
|
||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
|
||||
3
Main.py
3
Main.py
@@ -148,7 +148,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
else:
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
|
||||
|
||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||
AutoWorld.call_all(multiworld, "generate_basic")
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
|
||||
@@ -28,9 +28,11 @@ ModuleUpdate.update()
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl
|
||||
from NetUtils import ServerConnection
|
||||
|
||||
import websockets
|
||||
import colorama
|
||||
import websockets
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||
try:
|
||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||
from pony.orm.dbapiprovider import OperationalError
|
||||
@@ -119,13 +121,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
tags: typing.List[str] = []
|
||||
tags: typing.List[str]
|
||||
remote_items: bool
|
||||
remote_start_inventory: bool
|
||||
no_items: bool
|
||||
no_locations: bool
|
||||
no_text: bool
|
||||
|
||||
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
||||
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||
super().__init__(socket)
|
||||
self.auth = False
|
||||
self.team = None
|
||||
@@ -175,6 +178,7 @@ class Context:
|
||||
"compatibility": int}
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||
endpoints: list[Client]
|
||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||
@@ -364,18 +368,28 @@ class Context:
|
||||
return True
|
||||
|
||||
def broadcast_all(self, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||
data = self.dumper(msgs)
|
||||
endpoints = (
|
||||
endpoint
|
||||
for endpoint in self.endpoints
|
||||
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
||||
)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||
|
||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||
self.logger.info("Notice (all): %s" % text)
|
||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||
|
||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||
data = self.dumper(msgs)
|
||||
endpoints = (
|
||||
endpoint
|
||||
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
||||
if not (msg_is_text and endpoint.no_text)
|
||||
)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||
|
||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
@@ -389,13 +403,13 @@ class Context:
|
||||
await on_client_disconnected(self, endpoint)
|
||||
|
||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
if not client.auth or client.no_text:
|
||||
return
|
||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
if not client.auth or client.no_text:
|
||||
return
|
||||
async_start(self.send_msgs(client,
|
||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||
@@ -760,7 +774,7 @@ class Context:
|
||||
self.on_new_hint(team, slot)
|
||||
for slot, hint_data in concerns.items():
|
||||
if recipients is None or slot in recipients:
|
||||
clients = self.clients[team].get(slot)
|
||||
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
||||
if not clients:
|
||||
continue
|
||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||
@@ -769,7 +783,7 @@ class Context:
|
||||
|
||||
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
||||
for hint in self.hints[team, finding_player]:
|
||||
if hint.location == seeked_location:
|
||||
if hint.location == seeked_location and hint.finding_player == finding_player:
|
||||
return hint
|
||||
return None
|
||||
|
||||
@@ -819,7 +833,7 @@ def update_aliases(ctx: Context, team: int):
|
||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||
|
||||
|
||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
||||
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
||||
client = Client(websocket, ctx)
|
||||
ctx.endpoints.append(client)
|
||||
|
||||
@@ -910,6 +924,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||
"If your client supports it, "
|
||||
"you may have additional local commands you can list with /help.",
|
||||
{"type": "Tutorial"})
|
||||
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
||||
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
||||
"It may stop working in the future. If you are a player, please report this to the "
|
||||
"client's developer.")
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
@@ -1060,21 +1078,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
|
||||
|
||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||
count_activity: bool = True):
|
||||
slot_locations = ctx.locations[slot]
|
||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
|
||||
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
|
||||
if new_locations:
|
||||
if count_activity:
|
||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
sortable: list[tuple[int, int, int, int]] = []
|
||||
for location in new_locations:
|
||||
item_id, target_player, flags = ctx.locations[slot][location]
|
||||
# extract all fields to avoid runtime overhead in LocationStore
|
||||
item_id, target_player, flags = slot_locations[location]
|
||||
# sort/group by receiver and item
|
||||
sortable.append((target_player, item_id, location, flags))
|
||||
|
||||
info_texts: list[dict[str, typing.Any]] = []
|
||||
for target_player, item_id, location, flags in sorted(sortable):
|
||||
new_item = NetworkItem(item_id, location, slot, flags)
|
||||
send_items_to(ctx, team, target_player, new_item)
|
||||
|
||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
||||
info_text = json_format_send_event(new_item, target_player)
|
||||
ctx.broadcast_team(team, [info_text])
|
||||
if len(info_texts) >= 140:
|
||||
# split into chunks that are close to compression window of 64K but not too big on the wire
|
||||
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
|
||||
ctx.broadcast_team(team, info_texts)
|
||||
info_texts.clear()
|
||||
info_texts.append(json_format_send_event(new_item, target_player))
|
||||
ctx.broadcast_team(team, info_texts)
|
||||
del info_texts
|
||||
del sortable
|
||||
|
||||
ctx.location_checks[team, slot] |= new_locations
|
||||
send_new_items(ctx)
|
||||
@@ -1101,7 +1135,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||
in ctx.locations.find_item(slots, seeked_item_id):
|
||||
prev_hint = ctx.get_hint(team, slot, location_id)
|
||||
prev_hint = ctx.get_hint(team, finding_player, location_id)
|
||||
if prev_hint:
|
||||
hints.append(prev_hint)
|
||||
else:
|
||||
@@ -1787,7 +1821,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
ctx.clients[team][slot].append(client)
|
||||
client.version = args['version']
|
||||
client.tags = args['tags']
|
||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||
connected_packet = {
|
||||
"cmd": "Connected",
|
||||
"team": client.team, "slot": client.slot,
|
||||
@@ -1860,6 +1896,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
client.tags = args["tags"]
|
||||
if set(old_tags) != set(client.tags):
|
||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||
client.no_text = "NoText" in client.tags or (
|
||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||
)
|
||||
ctx.broadcast_text_all(
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||
f"from {old_tags} to {client.tags}.",
|
||||
|
||||
@@ -5,17 +5,18 @@ import enum
|
||||
import warnings
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
import websockets
|
||||
if typing.TYPE_CHECKING:
|
||||
from websockets import WebSocketServerProtocol as ServerConnection
|
||||
|
||||
from Utils import ByValue, Version
|
||||
|
||||
|
||||
class HintStatus(ByValue, enum.IntEnum):
|
||||
HINT_FOUND = 0
|
||||
HINT_UNSPECIFIED = 1
|
||||
HINT_UNSPECIFIED = 0
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
HINT_FOUND = 40
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
@@ -151,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
socket: "ServerConnection"
|
||||
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
|
||||
@@ -1582,7 +1582,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
}
|
||||
output.append(player_output)
|
||||
for option_key, option in world.options_dataclass.type_hints.items():
|
||||
if issubclass(Removed, option):
|
||||
if option.visibility == Visibility.none:
|
||||
continue
|
||||
display_name = getattr(option, "display_name", option_key)
|
||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||
|
||||
3
Utils.py
3
Utils.py
@@ -443,7 +443,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
else:
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
|
||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||
self.options_module.PlandoText)):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
|
||||
@@ -117,6 +117,7 @@ class WebHostContext(Context):
|
||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||
missing_checksum = False
|
||||
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
@@ -132,11 +133,13 @@ class WebHostContext(Context):
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||
else:
|
||||
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||
|
||||
if not game_data_packages:
|
||||
if not game_data_packages and not missing_checksum:
|
||||
# all static -> use the static dicts directly
|
||||
self.gamespackage = static_gamespackage
|
||||
self.item_name_groups = static_item_name_groups
|
||||
|
||||
@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
|
||||
|
||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
@@ -75,6 +75,27 @@
|
||||
#inventory-table img.acquired.green{ /*32CD32*/
|
||||
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||
}
|
||||
#inventory-table img.acquired.hotpink{ /*FF69B4*/
|
||||
filter: sepia(100%) hue-rotate(300deg) saturate(10);
|
||||
}
|
||||
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
|
||||
filter: sepia(100%) hue-rotate(347deg) saturate(10);
|
||||
}
|
||||
#inventory-table img.acquired.crimson{ /*DB143B*/
|
||||
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
|
||||
}
|
||||
|
||||
#inventory-table span{
|
||||
color: #B4B4A0;
|
||||
font-size: 40px;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||
}
|
||||
|
||||
#inventory-table span.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack{
|
||||
display: grid;
|
||||
|
||||
@@ -99,6 +99,52 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %}
|
||||
<div class="table-row">
|
||||
{% if 'PrismBreak' in options %}
|
||||
<div class="C1">
|
||||
<div class="image-stack">
|
||||
<div class="stack-front">
|
||||
<div class="stack-top-left">
|
||||
<img src="{{ icons['Laser Access'] }}" class="hotpink {{ 'acquired' if 'Laser Access A' in acquired_items }}" title="Laser Access A" />
|
||||
</div>
|
||||
<div class="stack-top-right">
|
||||
<img src="{{ icons['Laser Access'] }}" class="lightsalmon {{ 'acquired' if 'Laser Access I' in acquired_items }}" title="Laser Access I" />
|
||||
</div>
|
||||
<div class="stack-bottum-left">
|
||||
<img src="{{ icons['Laser Access'] }}" class="crimson {{ 'acquired' if 'Laser Access M' in acquired_items }}" title="Laser Access M" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'LockKeyAmadeus' in options %}
|
||||
<div class="C2">
|
||||
<div class="image-stack">
|
||||
<div class="stack-front">
|
||||
<div class="stack-top-left">
|
||||
<img src="{{ icons['Lab Glasses'] }}" class="{{ 'acquired' if 'Lab Access Genza' in acquired_items }}" title="Lab Access Genza" />
|
||||
</div>
|
||||
<div class="stack-top-right">
|
||||
<img src="{{ icons['Eye Orb'] }}" class="{{ 'acquired' if 'Lab Access Dynamo' in acquired_items }}" title="Lab Access Dynamo" />
|
||||
</div>
|
||||
<div class="stack-bottum-left">
|
||||
<img src="{{ icons['Lab Coat'] }}" class="{{ 'acquired' if 'Lab Access Research' in acquired_items }}" title="Lab Access Research" />
|
||||
</div>
|
||||
<div class="stack-bottum-right">
|
||||
<img src="{{ icons['Demon'] }}" class="{{ 'acquired' if 'Lab Access Experiment' in acquired_items }}" title="Lab Access Experiment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'GateKeep' in options %}
|
||||
<div class="C3">
|
||||
<span class="{{ 'acquired' if 'Drawbridge Key' in acquired_items }}" title="Drawbridge Key">❖</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table id="location-table">
|
||||
|
||||
@@ -1071,6 +1071,11 @@ if "Timespinner" in network_data_package["games"]:
|
||||
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
||||
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
||||
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
||||
"Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png",
|
||||
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
|
||||
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
|
||||
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
|
||||
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
||||
}
|
||||
|
||||
timespinner_location_ids = {
|
||||
@@ -1118,6 +1123,9 @@ if "Timespinner" in network_data_package["games"]:
|
||||
timespinner_location_ids["Ancient Pyramid"] += [
|
||||
1337237, 1337238, 1337239,
|
||||
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||
if (slot_data["PyramidStart"]):
|
||||
timespinner_location_ids["Ancient Pyramid"] += [
|
||||
1337233, 1337234, 1337235]
|
||||
|
||||
display_data = {}
|
||||
|
||||
|
||||
@@ -370,19 +370,13 @@ target_group_lookup = bake_target_group_lookup(world, get_target_groups)
|
||||
|
||||
#### When to call `randomize_entrances`
|
||||
|
||||
The short answer is that you will almost always want to do ER in `pre_fill`. For more information why, continue reading.
|
||||
The correct step for this is `World.connect_entrances`.
|
||||
|
||||
ER begins by collecting the entire item pool and then uses your access rules to try and prevent some kinds of failures.
|
||||
This means 2 things about when you can call ER:
|
||||
1. You must supply your item pool before calling ER, or call ER before setting any rules which require items.
|
||||
2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules
|
||||
and create your events before you call ER if you want to guarantee a correct output.
|
||||
|
||||
If the conditions above are met, you could theoretically do ER as early as `create_regions`. However, plando is also
|
||||
a consideration. Since item plando happens between `set_rules` and `pre_fill` and modifies the item pool, doing ER
|
||||
in `pre_fill` is the only way to account for placements made by item plando, otherwise you risk impossible seeds or
|
||||
generation failures. Obviously, if your world implements entrance plando, you will likely want to do that before ER as
|
||||
well.
|
||||
Currently, you could theoretically do it as early as `World.create_regions` or as late as `pre_fill`.
|
||||
However, there are upcoming changes to Item Plando and Generic Entrance Randomizer to make the two features work better
|
||||
together.
|
||||
These changes necessitate that entrance randomization is done exactly in `World.connect_entrances`.
|
||||
It is fine for your Entrances to be connected differently or not at all before this step.
|
||||
|
||||
#### Informing your client about randomized entrances
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
|
||||
|
||||
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
||||
|
||||
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
|
||||
working in the future.
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
||||
@@ -360,11 +363,11 @@ An enumeration containing the possible hint states.
|
||||
```python
|
||||
import enum
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0 # The location has been collected. Status cannot be changed once found.
|
||||
HINT_UNSPECIFIED = 1 # The receiving player has not specified any status
|
||||
HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
|
||||
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
|
||||
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
|
||||
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
|
||||
HINT_FOUND = 40 # The location has been collected. Status cannot be changed once found.
|
||||
```
|
||||
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
|
||||
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
|
||||
@@ -530,9 +533,9 @@ In JSON this may look like:
|
||||
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
||||
]
|
||||
```
|
||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
|
||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
|
||||
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
||||
|
||||
@@ -745,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
|
||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
|
||||
|
||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||
|
||||
@@ -73,11 +73,11 @@ When tests are run, this class will create a multiworld with a single player hav
|
||||
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
||||
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
||||
overridden. For more information on what methods are available to your class, check the
|
||||
[WorldTestBase definition](/test/bases.py#L104).
|
||||
[WorldTestBase definition](/test/bases.py#L106).
|
||||
|
||||
#### Alternatives to WorldTestBase
|
||||
|
||||
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
|
||||
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
||||
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
||||
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
||||
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
||||
|
||||
@@ -222,8 +222,8 @@ could also be progress in a research tree, or even something more abstract like
|
||||
|
||||
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
|
||||
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
|
||||
letter or symbol). The ID needs to be unique across all games, and is best kept in the same range as the item IDs.
|
||||
Locations and items can share IDs, so typically a game's locations and items start at the same ID.
|
||||
letter or symbol). The ID needs to be unique across all locations within the game.
|
||||
Locations and items can share IDs, and locations can share IDs with other games' locations.
|
||||
|
||||
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
||||
|
||||
@@ -243,7 +243,9 @@ progression. Progression items will be assigned to locations with higher priorit
|
||||
and satisfy progression balancing.
|
||||
|
||||
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
|
||||
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
||||
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
||||
The ID thus also needs to be unique across all items with different names within the game.
|
||||
Items and locations can share IDs, and items can share IDs with other games' items.
|
||||
|
||||
Other classifications include:
|
||||
|
||||
@@ -289,7 +291,7 @@ like entrance randomization in logic.
|
||||
|
||||
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
||||
|
||||
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295-L296)),
|
||||
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
|
||||
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
||||
|
||||
### Entrances
|
||||
@@ -329,7 +331,7 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
|
||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||
|
||||
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L301),
|
||||
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
|
||||
avoiding the need for indirect conditions at the expense of performance.
|
||||
|
||||
### Item Rules
|
||||
@@ -490,6 +492,9 @@ In addition, the following methods can be implemented and are called in this ord
|
||||
after this step. Locations cannot be moved to different regions after this step.
|
||||
* `set_rules(self)`
|
||||
called to set access and item rules on locations and entrances.
|
||||
* `connect_entrances(self)`
|
||||
by the end of this step, all entrances must exist and be connected to their source and target regions.
|
||||
Entrance randomization should be done here.
|
||||
* `generate_basic(self)`
|
||||
player-specific randomization that does not affect logic can be done here.
|
||||
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
|
||||
@@ -557,17 +562,13 @@ from .items import is_progression # this is just a dummy
|
||||
|
||||
def create_item(self, item: str) -> MyGameItem:
|
||||
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
|
||||
classification = ItemClassification.progression if is_progression(item) else
|
||||
ItemClassification.filler
|
||||
|
||||
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item],
|
||||
self.player)
|
||||
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
||||
|
||||
|
||||
def create_event(self, event: str) -> MyGameItem:
|
||||
# while we are at it, we can also add a helper to create events
|
||||
return MyGameItem(event, True, None, self.player)
|
||||
return MyGameItem(event, ItemClassification.progression, None, self.player)
|
||||
```
|
||||
|
||||
#### create_items
|
||||
@@ -835,14 +836,16 @@ def generate_output(self, output_directory: str) -> None:
|
||||
|
||||
### Slot Data
|
||||
|
||||
If the game client needs to know information about the generated seed, a preferred method of transferring the data
|
||||
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning
|
||||
a `dict` with `str` keys that can be serialized with json.
|
||||
But, to not waste resources, it should be limited to data that is absolutely necessary. Slot data is sent to your client
|
||||
once it has successfully [connected](network%20protocol.md#connected).
|
||||
If a client or tracker needs to know information about the generated seed, a preferred method of transferring the data
|
||||
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning a `dict` with
|
||||
`str` keys that can be serialized with json. However, to not waste resources, it should be limited to data that is
|
||||
absolutely necessary. Slot data is sent to your client once it has successfully
|
||||
[connected](network%20protocol.md#connected).
|
||||
|
||||
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
|
||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most
|
||||
common usage of slot data is sending option results that the client needs to be aware of.
|
||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. Adding
|
||||
item/location pairs is unnecessary since the AP server already retains and freely gives that information to clients
|
||||
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
||||
|
||||
```python
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -157,17 +157,16 @@ class ERPlacementState:
|
||||
def placed_regions(self) -> set[Region]:
|
||||
return self.collection_state.reachable_regions[self.world.player]
|
||||
|
||||
def find_placeable_exits(self, check_validity: bool) -> list[Entrance]:
|
||||
def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
|
||||
if check_validity:
|
||||
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
||||
blocked_connections = sorted(blocked_connections, key=lambda x: x.name)
|
||||
placeable_randomized_exits = [connection for connection in blocked_connections
|
||||
if not connection.connected_region
|
||||
and connection.is_valid_source_transition(self)]
|
||||
placeable_randomized_exits = [ex for ex in usable_exits
|
||||
if not ex.connected_region
|
||||
and ex in blocked_connections
|
||||
and ex.is_valid_source_transition(self)]
|
||||
else:
|
||||
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
||||
placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player)
|
||||
for ex in region.exits if not ex.connected_region]
|
||||
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
|
||||
self.world.random.shuffle(placeable_randomized_exits)
|
||||
return placeable_randomized_exits
|
||||
|
||||
@@ -181,7 +180,8 @@ class ERPlacementState:
|
||||
self.placements.append(source_exit)
|
||||
self.pairings.append((source_exit.name, target_entrance.name))
|
||||
|
||||
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool:
|
||||
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
|
||||
usable_exits: set[Entrance]) -> bool:
|
||||
copied_state = self.collection_state.copy()
|
||||
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
||||
# propagate back to the real multiworld.
|
||||
@@ -198,6 +198,9 @@ class ERPlacementState:
|
||||
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
||||
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
||||
continue
|
||||
# make sure we are only paying attention to usable exits
|
||||
if _exit not in usable_exits:
|
||||
continue
|
||||
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
||||
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
||||
# not want them to persist). can_reach is a close enough approximation most of the time.
|
||||
@@ -326,6 +329,24 @@ def randomize_entrances(
|
||||
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||
perform_validity_check = True
|
||||
|
||||
if not er_targets:
|
||||
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
||||
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
||||
if not exits:
|
||||
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
||||
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
||||
if len(er_targets) != len(exits):
|
||||
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
||||
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
||||
|
||||
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
||||
exits_set = set(exits)
|
||||
for entrance in er_targets:
|
||||
entrance_lookup.add(entrance)
|
||||
|
||||
# place the menu region and connected start region(s)
|
||||
er_state.collection_state.update_reachable_regions(world.player)
|
||||
|
||||
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
||||
# remove the placed targets from consideration
|
||||
@@ -339,7 +360,7 @@ def randomize_entrances(
|
||||
|
||||
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
||||
nonlocal perform_validity_check
|
||||
placeable_exits = er_state.find_placeable_exits(perform_validity_check)
|
||||
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
||||
for source_exit in placeable_exits:
|
||||
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
||||
@@ -355,7 +376,7 @@ def randomize_entrances(
|
||||
and len(placeable_exits) == 1)
|
||||
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
||||
if (needs_speculative_sweep
|
||||
and not er_state.test_speculative_connection(source_exit, target_entrance)):
|
||||
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
|
||||
continue
|
||||
do_placement(source_exit, target_entrance)
|
||||
return True
|
||||
@@ -378,13 +399,14 @@ def randomize_entrances(
|
||||
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
|
||||
# ensure that we have enough locations to place our progression
|
||||
accessible_location_count = 0
|
||||
prog_item_count = sum(er_state.collection_state.prog_items[world.player].values())
|
||||
prog_item_count = len([item for item in world.multiworld.itempool if item.advancement and item.player == world.player])
|
||||
# short-circuit location checking in this case
|
||||
if prog_item_count == 0:
|
||||
return True
|
||||
for region in er_state.placed_regions:
|
||||
for loc in region.locations:
|
||||
if loc.can_reach(er_state.collection_state):
|
||||
if not loc.item and loc.can_reach(er_state.collection_state):
|
||||
# don't count locations with preplaced items
|
||||
accessible_location_count += 1
|
||||
if accessible_location_count >= prog_item_count:
|
||||
perform_validity_check = False
|
||||
@@ -406,21 +428,6 @@ def randomize_entrances(
|
||||
f"All unplaced entrances: {unplaced_entrances}\n"
|
||||
f"All unplaced exits: {unplaced_exits}")
|
||||
|
||||
if not er_targets:
|
||||
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
||||
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
||||
if not exits:
|
||||
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
||||
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
||||
if len(er_targets) != len(exits):
|
||||
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
||||
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
||||
for entrance in er_targets:
|
||||
entrance_lookup.add(entrance)
|
||||
|
||||
# place the menu region and connected start region(s)
|
||||
er_state.collection_state.update_reachable_regions(world.player)
|
||||
|
||||
# stage 1 - try to place all the non-dead-end entrances
|
||||
while entrance_lookup.others:
|
||||
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||
|
||||
19
kvui.py
19
kvui.py
@@ -26,6 +26,10 @@ import Utils
|
||||
if Utils.is_frozen():
|
||||
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
|
||||
|
||||
import platformdirs
|
||||
os.environ["KIVY_HOME"] = os.path.join(platformdirs.user_config_dir("Archipelago", False), "kivy")
|
||||
os.makedirs(os.environ["KIVY_HOME"], exist_ok=True)
|
||||
|
||||
from kivy.config import Config
|
||||
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
@@ -440,8 +444,11 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
if child.collide_point(*touch.pos):
|
||||
key = child.sort_key
|
||||
if key == "status":
|
||||
parent.hint_sorter = lambda element: element["status"]["hint"]["status"]
|
||||
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||
parent.hint_sorter = lambda element: status_sort_weights[element["status"]["hint"]["status"]]
|
||||
else:
|
||||
parent.hint_sorter = lambda element: (
|
||||
remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||
)
|
||||
if key == parent.sort_key:
|
||||
# second click reverses order
|
||||
parent.reversed = not parent.reversed
|
||||
@@ -825,7 +832,13 @@ status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
HintStatus.HINT_PRIORITY: "plum",
|
||||
}
|
||||
|
||||
status_sort_weights: dict[HintStatus, int] = {
|
||||
HintStatus.HINT_FOUND: 0,
|
||||
HintStatus.HINT_UNSPECIFIED: 1,
|
||||
HintStatus.HINT_NO_PRIORITY: 2,
|
||||
HintStatus.HINT_AVOID: 3,
|
||||
HintStatus.HINT_PRIORITY: 4,
|
||||
}
|
||||
|
||||
|
||||
class HintLog(RecycleView):
|
||||
|
||||
23
settings.py
23
settings.py
@@ -109,7 +109,7 @@ class Group:
|
||||
def get_type_hints(cls) -> Dict[str, Any]:
|
||||
"""Returns resolved type hints for the class"""
|
||||
if cls._type_cache is None:
|
||||
if not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||
# non-str: assume already resolved
|
||||
cls._type_cache = cls.__annotations__
|
||||
else:
|
||||
@@ -270,15 +270,20 @@ class Group:
|
||||
# fetch class to avoid going through getattr
|
||||
cls = self.__class__
|
||||
type_hints = cls.get_type_hints()
|
||||
entries = [e for e in self]
|
||||
if not entries:
|
||||
# write empty dict for empty Group with no instance values
|
||||
cls._dump_value({}, f, indent=" " * level)
|
||||
# validate group
|
||||
for name in cls.__annotations__.keys():
|
||||
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
|
||||
# dump ordered members
|
||||
for name in self:
|
||||
for name in entries:
|
||||
attr = cast(object, getattr(self, name))
|
||||
attr_cls = type_hints[name] if name in type_hints else attr.__class__
|
||||
attr_cls_origin = typing.get_origin(attr_cls)
|
||||
while attr_cls_origin is Union: # resolve to first type for doc string
|
||||
# resolve to first type for doc string
|
||||
while attr_cls_origin is Union or attr_cls_origin is types.UnionType:
|
||||
attr_cls = typing.get_args(attr_cls)[0]
|
||||
attr_cls_origin = typing.get_origin(attr_cls)
|
||||
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
|
||||
@@ -787,7 +792,17 @@ class Settings(Group):
|
||||
if location:
|
||||
from Utils import parse_yaml
|
||||
with open(location, encoding="utf-8-sig") as f:
|
||||
options = parse_yaml(f.read())
|
||||
from yaml.error import MarkedYAMLError
|
||||
try:
|
||||
options = parse_yaml(f.read())
|
||||
except MarkedYAMLError as ex:
|
||||
if ex.problem_mark:
|
||||
f.seek(0)
|
||||
lines = f.readlines()
|
||||
problem_line = lines[ex.problem_mark.line]
|
||||
error_line = " " * ex.problem_mark.column + "^"
|
||||
raise Exception(f"{ex.context} {ex.problem}\n{problem_line}{error_line}")
|
||||
raise ex
|
||||
# TODO: detect if upgrade is required
|
||||
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
|
||||
self.update(options or {})
|
||||
|
||||
@@ -18,7 +18,15 @@ def run_locations_benchmark():
|
||||
|
||||
class BenchmarkRunner:
|
||||
gen_steps: typing.Tuple[str, ...] = (
|
||||
"generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
"create_items",
|
||||
"set_rules",
|
||||
"connect_entrances",
|
||||
"generate_basic",
|
||||
"pre_fill",
|
||||
)
|
||||
|
||||
rule_iterations: int = 100_000
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(ap-cpp-tests)
|
||||
|
||||
enable_testing()
|
||||
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
|
||||
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
add_definitions("/source-charset:utf-8")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
||||
# set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
|
||||
# set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
|
||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
# enable static analysis for gcc
|
||||
add_compile_options(-fanalyzer -Werror)
|
||||
|
||||
@@ -5,7 +5,15 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, Mul
|
||||
from worlds import network_data_package
|
||||
from worlds.AutoWorld import World, call_all
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
gen_steps = (
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
"create_items",
|
||||
"set_rules",
|
||||
"connect_entrances",
|
||||
"generate_basic",
|
||||
"pre_fill",
|
||||
)
|
||||
|
||||
|
||||
def setup_solo_multiworld(
|
||||
|
||||
@@ -311,6 +311,37 @@ class TestRandomizeEntrances(unittest.TestCase):
|
||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||
for exit_ in region.exits if not exit_.connected_region])
|
||||
|
||||
def test_minimal_entrance_rando_with_collect_override(self):
|
||||
"""
|
||||
tests that entrance randomization can complete with minimal accessibility and unreachable exits
|
||||
when the world defines a collect override that add extra values to prog_items
|
||||
"""
|
||||
multiworld = generate_test_multiworld()
|
||||
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
|
||||
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
|
||||
generate_disconnected_region_grid(multiworld, 5, 1)
|
||||
prog_items = generate_items(10, 1, True)
|
||||
multiworld.itempool += prog_items
|
||||
filler_items = generate_items(15, 1, False)
|
||||
multiworld.itempool += filler_items
|
||||
e = multiworld.get_entrance("region1_right", 1)
|
||||
set_rule(e, lambda state: False)
|
||||
|
||||
old_collect = multiworld.worlds[1].collect
|
||||
|
||||
def new_collect(state, item):
|
||||
old_collect(state, item)
|
||||
state.prog_items[item.player]["counter"] += 300
|
||||
|
||||
multiworld.worlds[1].collect = new_collect
|
||||
|
||||
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||
|
||||
self.assertEqual([], [entrance for region in multiworld.get_regions()
|
||||
for entrance in region.entrances if not entrance.parent_region])
|
||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||
for exit_ in region.exits if not exit_.connected_region])
|
||||
|
||||
def test_restrictive_region_requirement_does_not_fail(self):
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 2, 1)
|
||||
|
||||
63
test/general/test_entrances.py
Normal file
63
test/general/test_entrances.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import unittest
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all, World
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
def test_entrance_connection_steps(self):
|
||||
"""Tests that Entrances are connected and not changed after connect_entrances."""
|
||||
def get_entrance_name_to_source_and_target_dict(world: World):
|
||||
return [
|
||||
(entrance.name, entrance.parent_region, entrance.connected_region)
|
||||
for entrance in world.get_entrances()
|
||||
]
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||
additional_steps = ("generate_basic", "pre_fill")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
|
||||
original_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
|
||||
|
||||
self.assertTrue(
|
||||
all(entrance[1] is not None and entrance[2] is not None for entrance in original_entrances),
|
||||
f"{game_name} had unconnected entrances after connect_entrances"
|
||||
)
|
||||
|
||||
for step in additional_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
step_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
|
||||
|
||||
self.assertEqual(
|
||||
original_entrances, step_entrances, f"{game_name} modified entrances during {step}"
|
||||
)
|
||||
|
||||
def test_all_state_before_connect_entrances(self):
|
||||
"""Before connect_entrances, Entrance objects may be unconnected.
|
||||
Thus, we test that get_all_state is performed with allow_partial_entrances if used before or during
|
||||
connect_entrances."""
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, ())
|
||||
|
||||
original_get_all_state = multiworld.get_all_state
|
||||
|
||||
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
|
||||
self.assertTrue(allow_partial_entrances, (
|
||||
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
||||
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
||||
))
|
||||
|
||||
return original_get_all_state(use_cache, allow_partial_entrances)
|
||||
|
||||
multiworld.get_all_state = patched_get_all_state
|
||||
|
||||
for step in gen_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
@@ -1,5 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
@@ -8,12 +9,31 @@ class TestBase(unittest.TestCase):
|
||||
def test_create_item(self):
|
||||
"""Test that a world can successfully create all items in its datapackage"""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
|
||||
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
|
||||
proxy_world = multiworld.worlds[1]
|
||||
for item_name in world_type.item_name_to_id:
|
||||
test_state = CollectionState(multiworld)
|
||||
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
|
||||
item = proxy_world.create_item(item_name)
|
||||
|
||||
with self.subTest("Item Name", item_name=item_name, game_name=game_name):
|
||||
self.assertEqual(item.name, item_name)
|
||||
|
||||
if item.advancement:
|
||||
with self.subTest("Item State Collect", item_name=item_name, game_name=game_name):
|
||||
test_state.collect(item, True)
|
||||
|
||||
with self.subTest("Item State Remove", item_name=item_name, game_name=game_name):
|
||||
test_state.remove(item)
|
||||
|
||||
self.assertEqual(test_state.prog_items, multiworld.state.prog_items,
|
||||
"Item Collect -> Remove should restore empty state.")
|
||||
else:
|
||||
with self.subTest("Item State Collect No Change", item_name=item_name, game_name=game_name):
|
||||
# Non-Advancement should not modify state.
|
||||
test_state.collect(item)
|
||||
self.assertEqual(test_state.prog_items, multiworld.state.prog_items)
|
||||
|
||||
def test_item_name_group_has_valid_item(self):
|
||||
"""Test that all item name groups contain valid items. """
|
||||
# This cannot test for Event names that you may have declared for logic, only sendable Items.
|
||||
@@ -67,7 +87,7 @@ class TestBase(unittest.TestCase):
|
||||
def test_itempool_not_modified(self):
|
||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
||||
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
|
||||
worlds_to_test = {game: world
|
||||
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
||||
@@ -84,7 +104,7 @@ class TestBase(unittest.TestCase):
|
||||
def test_locality_not_modified(self):
|
||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
||||
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
||||
for game_name, world_type in worlds_to_test.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
|
||||
@@ -45,6 +45,12 @@ class TestBase(unittest.TestCase):
|
||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||
f"{game_name} modified locations count during rule creation")
|
||||
|
||||
call_all(multiworld, "connect_entrances")
|
||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||
f"{game_name} modified region count during rule creation")
|
||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||
f"{game_name} modified locations count during rule creation")
|
||||
|
||||
call_all(multiworld, "generate_basic")
|
||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||
f"{game_name} modified region count during generate_basic")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
@@ -9,8 +10,12 @@ class TestWorldMemory(unittest.TestCase):
|
||||
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
|
||||
import gc
|
||||
import weakref
|
||||
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
with self.subTest("Game creation", game_name=game_name):
|
||||
weak = weakref.ref(setup_solo_multiworld(world_type))
|
||||
gc.collect()
|
||||
refs[game_name] = weak
|
||||
gc.collect()
|
||||
for game_name, weak in refs.items():
|
||||
with self.subTest("Game cleanup", game_name=game_name):
|
||||
self.assertFalse(weak(), "World leaked a reference")
|
||||
|
||||
@@ -2,11 +2,11 @@ import unittest
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_solo_multiworld
|
||||
from . import setup_solo_multiworld, gen_steps
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
|
||||
gen_steps = gen_steps
|
||||
|
||||
default_settings_unreachable_regions = {
|
||||
"A Link to the Past": {
|
||||
|
||||
29
test/general/test_state.py
Normal file
29
test/general/test_state.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import unittest
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
gen_steps = (
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
)
|
||||
|
||||
test_steps = (
|
||||
"create_items",
|
||||
"set_rules",
|
||||
"connect_entrances",
|
||||
"generate_basic",
|
||||
"pre_fill",
|
||||
)
|
||||
|
||||
def test_all_state_is_available(self):
|
||||
"""Ensure all_state can be created at certain steps."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
|
||||
for step in self.test_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
self.assertTrue(multiworld.get_all_state(False, True))
|
||||
@@ -378,6 +378,10 @@ class World(metaclass=AutoWorldRegister):
|
||||
"""Method for setting the rules on the World's regions and locations."""
|
||||
pass
|
||||
|
||||
def connect_entrances(self) -> None:
|
||||
"""Method to finalize the source and target regions of the World's entrances"""
|
||||
pass
|
||||
|
||||
def generate_basic(self) -> None:
|
||||
"""
|
||||
Useful for randomizing things that don't affect logic but are better to be determined before the output stage.
|
||||
|
||||
@@ -87,7 +87,7 @@ class Component:
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
|
||||
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||
global processes
|
||||
import multiprocessing
|
||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||
@@ -95,6 +95,14 @@ def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] =
|
||||
processes.add(process)
|
||||
|
||||
|
||||
def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||
from Utils import is_kivy_running
|
||||
if is_kivy_running():
|
||||
launch_subprocess(func, name, args)
|
||||
else:
|
||||
func(*args)
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
@@ -111,7 +119,7 @@ class SuffixIdentifier:
|
||||
|
||||
def launch_textclient(*args):
|
||||
import CommonClient
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
launch(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
|
||||
@@ -55,6 +55,7 @@ async def lock(ctx) -> None
|
||||
async def unlock(ctx) -> None
|
||||
|
||||
async def get_hash(ctx) -> str
|
||||
async def get_memory_size(ctx, domain: str) -> int
|
||||
async def get_system(ctx) -> str
|
||||
async def get_cores(ctx) -> dict[str, str]
|
||||
async def ping(ctx) -> None
|
||||
@@ -168,9 +169,10 @@ select dialog and they will be associated with BizHawkClient. This does not affe
|
||||
associate the file extension with Archipelago.
|
||||
|
||||
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is
|
||||
running on a system you specified in your `system` class variable. In most cases, that will be a single system and you
|
||||
can be sure that you're not about to try to read from nonexistent domains or out of bounds. If you decide to claim this
|
||||
ROM as yours, this is where you should do setup for things like `items_handling`.
|
||||
running on a system you specified in your `system` class variable. Take extra care here, because your code will run
|
||||
against ROMs that you have no control over. If you're reading an address deep in ROM, you might want to check the size
|
||||
of ROM before you attempt to read it using `get_memory_size`. If you decide to claim this ROM as yours, this is where
|
||||
you should do setup for things like `items_handling`.
|
||||
|
||||
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM.
|
||||
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do
|
||||
@@ -268,6 +270,8 @@ server connection before trying to interact with it.
|
||||
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and
|
||||
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to
|
||||
set it automatically based on data in the ROM or on your client instance.
|
||||
- Use `get_memory_size` inside `validate_rom` if you need to read at large addresses, in case some other game has a
|
||||
smaller ROM size.
|
||||
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a
|
||||
subclass of `CommonContext` and its API.
|
||||
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import abc
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch as launch_component
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import BizHawkClientContext
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def launch_client(*args) -> None:
|
||||
from .context import launch
|
||||
launch_subprocess(launch, name="BizHawkClient", args=args)
|
||||
launch_component(launch, name="BizHawkClient", args=args)
|
||||
|
||||
|
||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||
|
||||
@@ -41,6 +41,7 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
|
||||
|
||||
class BizHawkClientContext(CommonContext):
|
||||
command_processor = BizHawkClientCommandProcessor
|
||||
server_seed_name: str | None = None
|
||||
auth_status: AuthStatus
|
||||
password_requested: bool
|
||||
client_handler: BizHawkClient | None
|
||||
@@ -68,6 +69,8 @@ class BizHawkClientContext(CommonContext):
|
||||
if cmd == "Connected":
|
||||
self.slot_data = args.get("slot_data", None)
|
||||
self.auth_status = AuthStatus.AUTHENTICATED
|
||||
elif cmd == "RoomInfo":
|
||||
self.server_seed_name = args.get("seed_name", None)
|
||||
|
||||
if self.client_handler is not None:
|
||||
self.client_handler.on_package(self, cmd, args)
|
||||
@@ -100,6 +103,7 @@ class BizHawkClientContext(CommonContext):
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool=False):
|
||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||
self.server_seed_name = None
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
|
||||
@@ -238,6 +242,7 @@ def _patch_and_run_game(patch_file: str):
|
||||
return metadata
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
Utils.messagebox("Error Patching Game", str(exc), True)
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ class MinExtraYarn(Range):
|
||||
There must be at least this much more yarn over the total number of yarn needed to craft all hats.
|
||||
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
|
||||
there must be at least 50 yarn in the pool."""
|
||||
display_name = "Max Extra Yarn"
|
||||
display_name = "Min Extra Yarn"
|
||||
range_start = 5
|
||||
range_end = 15
|
||||
default = 10
|
||||
|
||||
@@ -12,13 +12,13 @@ from .DeathWishRules import set_dw_rules, create_enemy_events, hit_list, bosses
|
||||
from worlds.AutoWorld import World, WebWorld, CollectionState
|
||||
from worlds.generic.Rules import add_rule
|
||||
from typing import List, Dict, TextIO
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, launch as launch_component, Type
|
||||
from Utils import local_path
|
||||
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="AHITClient")
|
||||
launch_component(launch, name="AHITClient")
|
||||
|
||||
|
||||
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
3. Click the **Betas** tab. In the **Beta Participation** dropdown, select `tcplink`.
|
||||
While it downloads, you can subscribe to the [Archipelago workshop mod.]((https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601))
|
||||
While it downloads, you can subscribe to the [Archipelago workshop mod](https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601).
|
||||
|
||||
|
||||
4. Once the game finishes downloading, start it up.
|
||||
@@ -62,4 +62,4 @@ The level that the relic set unlocked will stay unlocked.
|
||||
|
||||
### When I start a new save file, the intro cinematic doesn't get skipped, Hat Kid's body is missing and the mod doesn't work!
|
||||
There is a bug on older versions of A Hat in Time that causes save file creation to fail to work properly
|
||||
if you have too many save files. Delete them and it should fix the problem.
|
||||
if you have too many save files. Delete them and it should fix the problem.
|
||||
|
||||
@@ -1547,9 +1547,9 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
rom.write_byte(0x18003B, 0x01 if world.map_shuffle[player] else 0x00) # maps showing crystals on overworld
|
||||
|
||||
# compasses showing dungeon count
|
||||
if local_world.clock_mode or not world.dungeon_counters[player]:
|
||||
if local_world.clock_mode or world.dungeon_counters[player] == 'off':
|
||||
rom.write_byte(0x18003C, 0x00) # Currently must be off if timer is on, because they use same HUD location
|
||||
elif world.dungeon_counters[player] is True:
|
||||
elif world.dungeon_counters[player] == 'on':
|
||||
rom.write_byte(0x18003C, 0x02) # always on
|
||||
elif world.compass_shuffle[player] or world.dungeon_counters[player] == 'pickup':
|
||||
rom.write_byte(0x18003C, 0x01) # show on pickup
|
||||
|
||||
@@ -1125,7 +1125,7 @@ def set_trock_key_rules(world, player):
|
||||
for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']:
|
||||
set_rule(world.get_entrance(entrance, player), lambda state: False)
|
||||
|
||||
all_state = world.get_all_state(use_cache=False)
|
||||
all_state = world.get_all_state(use_cache=False, allow_partial_entrances=True)
|
||||
all_state.reachable_regions[player] = set() # wipe reachable regions so that the locked doors actually work
|
||||
all_state.stale[player] = True
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ location_names: Dict[str, str] = {
|
||||
"RESCUED_CHERUB_15": "DC: Top of elevator Child of Moonlight",
|
||||
"Lady[D01Z05S22]": "DC: Lady of the Six Sorrows, from MD",
|
||||
"QI75": "DC: Chalice room",
|
||||
"Sword[D01Z05S24]": "DC: Mea culpa altar",
|
||||
"Sword[D01Z05S24]": "DC: Mea Culpa altar",
|
||||
"CO44": "DC: Elevator shaft ledge",
|
||||
"RESCUED_CHERUB_22": "DC: Elevator shaft Child of Moonlight",
|
||||
"Lady[D01Z05S26]": "DC: Lady of the Six Sorrows, elevator shaft",
|
||||
|
||||
@@ -67,7 +67,8 @@ class BlasphemousWorld(World):
|
||||
|
||||
def generate_early(self):
|
||||
if not self.options.starting_location.randomized:
|
||||
if self.options.starting_location == "mourning_havoc" and self.options.difficulty < 2:
|
||||
if (self.options.starting_location == "knot_of_words" or self.options.starting_location == "rooftops" \
|
||||
or self.options.starting_location == "mourning_havoc") and self.options.difficulty < 2:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Difficulty is lower than Hard.")
|
||||
|
||||
@@ -83,6 +84,8 @@ class BlasphemousWorld(World):
|
||||
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ]
|
||||
|
||||
if self.options.difficulty < 2:
|
||||
locations.remove(4)
|
||||
locations.remove(5)
|
||||
locations.remove(6)
|
||||
|
||||
if self.options.dash_shuffle:
|
||||
|
||||
@@ -85,20 +85,7 @@ class TestGrievanceHard(BlasphemousTestBase):
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
# knot of the three words, rooftops, and mourning and havoc can't be selected on easy or normal. hard only
|
||||
class TestKnotOfWordsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
@@ -106,20 +93,6 @@ class TestKnotOfWordsHard(BlasphemousTestBase):
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
@@ -127,7 +100,6 @@ class TestRooftopsHard(BlasphemousTestBase):
|
||||
}
|
||||
|
||||
|
||||
# mourning and havoc can't be selected on easy or normal. hard only
|
||||
class TestMourningHavocHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "mourning_havoc",
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
1. Download the above release and extract it.
|
||||
|
||||
## Installation Procedures (Linux and Steam Deck)
|
||||
|
||||
1. Download the above release and extract it.
|
||||
|
||||
2. Add Celeste64.exe to Steam as a Non-Steam Game. In the properties for it on Steam, set it to use Proton as the compatibility tool. Launch the game through Steam in order to run it.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Before launching the game, edit the `AP.json` file in the root of the Celeste 64 install.
|
||||
@@ -33,5 +39,3 @@ An Example `AP.json` file:
|
||||
"Password": ""
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -25,19 +25,10 @@ class DarkSouls3Web(WebWorld):
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["Marech"]
|
||||
["Natalie", "Marech"]
|
||||
)
|
||||
|
||||
setup_fr = Tutorial(
|
||||
setup_en.tutorial_name,
|
||||
setup_en.description,
|
||||
"Français",
|
||||
"setup_fr.md",
|
||||
"setup/fr",
|
||||
["Marech"]
|
||||
)
|
||||
|
||||
tutorials = [setup_en, setup_fr]
|
||||
tutorials = [setup_en]
|
||||
option_groups = option_groups
|
||||
item_descriptions = item_descriptions
|
||||
rich_text_options_doc = True
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
## Required Software
|
||||
|
||||
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
|
||||
- [Dark Souls III AP Client](https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest)
|
||||
- [Dark Souls III AP Client]
|
||||
|
||||
[Dark Souls III AP Client]: https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest
|
||||
|
||||
## Optional Software
|
||||
|
||||
- Map tracker not yet updated for 3.0.0
|
||||
- [Map tracker](https://github.com/TVV1GK/DS3_AP_Maptracker)
|
||||
|
||||
## Setting Up
|
||||
|
||||
@@ -73,3 +75,65 @@ things to keep in mind:
|
||||
|
||||
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
|
||||
[WINE]: https://www.winehq.org/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Enemy randomizer issues
|
||||
|
||||
The DS3 Archipelago randomizer uses [thefifthmatt's DS3 enemy randomizer],
|
||||
essentially unchanged. Unfortunately, this randomizer has a few known issues,
|
||||
including enemy AI not working, enemies spawning in places they can't be killed,
|
||||
and, in a few rare cases, enemies spawning in ways that crash the game when they
|
||||
load. These bugs should be [reported upstream], but unfortunately the
|
||||
Archipelago devs can't help much with them.
|
||||
|
||||
[thefifthmatt's DS3 enemy randomizer]: https://www.nexusmods.com/darksouls3/mods/484
|
||||
[reported upstream]: https://github.com/thefifthmatt/SoulsRandomizers/issues
|
||||
|
||||
Because in rare cases the enemy randomizer can cause seeds to be impossible to
|
||||
complete, we recommend disabling it for large async multiworlds for safety
|
||||
purposes.
|
||||
|
||||
### `launchmod_darksouls3.bat` isn't working
|
||||
|
||||
Sometimes `launchmod_darksouls3.bat` will briefly flash a terminal on your
|
||||
screen and then terminate without actually starting the game. This is usually
|
||||
caused by some issue communicating with Steam either to find `DarkSoulsIII.exe`
|
||||
or to launch it properly. If this is happening to you, make sure:
|
||||
|
||||
* You have DS3 1.15.2 installed. This is the latest patch as of January 2025.
|
||||
(Note that older versions of Archipelago required an older patch, but that
|
||||
_will not work_ with the current version.)
|
||||
|
||||
* You own the DS3 DLC if your randomizer config has DLC enabled. (It's possible,
|
||||
but unconfirmed, that you need the DLC even when it's disabled in your config).
|
||||
|
||||
* Steam is not running in administrator mode. To fix this, right-click
|
||||
`steam.exe` (by default this is in `C:\Program Files\Steam`), select
|
||||
"Properties", open the "Compatiblity" tab, and uncheck "Run this program as an
|
||||
administrator".
|
||||
|
||||
* There is no `dinput8.dll` file in your DS3 game directory. This is the old way
|
||||
of installing mods, and it can interfere with the new ModEngine2 workflow.
|
||||
|
||||
If you've checked all of these, you can also try:
|
||||
|
||||
* Running `launchmod_darksouls3.bat` as an administrator.
|
||||
|
||||
* Reinstalling DS3 or even reinstalling Steam itself.
|
||||
|
||||
* Making sure DS3 is installed on the same drive as Steam and as the randomizer.
|
||||
(A number of users are able to run these on different drives, but this has
|
||||
helped some users.)
|
||||
|
||||
If none of this works, unfortunately there's not much we can do. We use
|
||||
ModEngine2 to launch DS3 with the Archipelago mod enabled, but unfortunately
|
||||
it's no longer maintained and its successor, ModEngine3, isn't usable yet.
|
||||
|
||||
### `DS3Randomizer.exe` isn't working
|
||||
|
||||
This is almost always caused by using a version of the randomizer client that's
|
||||
not compatible with the version used to generate the multiworld. If you're
|
||||
generating your multiworld on archipelago.gg, you *must* use the latest [Dark
|
||||
Souls III AP Client]. If you want to use a different client version, you *must*
|
||||
generate the multiworld locally using the apworld bundled with the client.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Guide d'installation de Dark Souls III Randomizer
|
||||
|
||||
## Logiciels requis
|
||||
|
||||
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
|
||||
- [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases)
|
||||
|
||||
## Concept général
|
||||
|
||||
Le client Archipelago de Dark Souls III est un fichier dinput8.dll. Cette .dll va lancer une invite de commande Windows
|
||||
permettant de lire des informations de la partie et écrire des commandes pour intéragir avec le serveur Archipelago.
|
||||
|
||||
## Procédures d'installation
|
||||
|
||||
<span style="color:#ff7800">
|
||||
**Il y a des risques de bannissement permanent des serveurs FromSoftware si ce mod est utilisé en ligne.**
|
||||
</span>
|
||||
Ce client a été testé sur la version Steam officielle du jeu (v1.15/1.35), peu importe les DLCs actuellement installés.
|
||||
|
||||
Télécharger le fichier dinput8.dll disponible dans le [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) et
|
||||
placez-le à la racine du jeu (ex: "SteamLibrary\steamapps\common\DARK SOULS III\Game")
|
||||
|
||||
## Rejoindre une partie Multiworld
|
||||
|
||||
1. Lancer DarkSoulsIII.exe ou lancer le jeu depuis Steam
|
||||
2. Ecrire "/connect {SERVER_IP}:{SERVER_PORT} {SLOT_NAME}" dans l'invite de commande Windows ouverte au lancement du jeu
|
||||
3. Une fois connecté, créez une nouvelle partie, choisissez une classe et attendez que les autres soient prêts avant de lancer
|
||||
4. Vous pouvez quitter et lancer le jeu n'importe quand pendant une partie
|
||||
|
||||
## Où trouver le fichier de configuration ?
|
||||
|
||||
La [Page de configuration](/games/Dark%20Souls%20III/player-options) sur le site vous permez de configurer vos
|
||||
paramètres et de les exporter sous la forme d'un fichier.
|
||||
@@ -650,8 +650,8 @@ item_table: Dict[int, ItemDict] = {
|
||||
'doom_type': 2006,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
350106: {'classification': ItemClassification.progression,
|
||||
'count': 1,
|
||||
350106: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Backpack',
|
||||
'doom_type': 8,
|
||||
'episode': -1,
|
||||
@@ -1160,6 +1160,30 @@ item_table: Dict[int, ItemDict] = {
|
||||
'doom_type': 2026,
|
||||
'episode': 4,
|
||||
'map': 9},
|
||||
350191: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Bullet capacity',
|
||||
'doom_type': 65001,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
350192: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Shell capacity',
|
||||
'doom_type': 65002,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
350193: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Energy cell capacity',
|
||||
'doom_type': 65003,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
350194: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Rocket capacity',
|
||||
'doom_type': 65004,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from Options import PerGameCommonOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||
from Options import PerGameCommonOptions, Range, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -144,6 +144,84 @@ class Episode4(Toggle):
|
||||
display_name = "Episode 4"
|
||||
|
||||
|
||||
class SplitBackpack(Toggle):
|
||||
"""Split the Backpack into four individual items, each one increasing ammo capacity for one type of weapon only."""
|
||||
display_name = "Split Backpack"
|
||||
|
||||
|
||||
class BackpackCount(Range):
|
||||
"""How many Backpacks will be available.
|
||||
If Split Backpack is set, this will be the number of each capacity upgrade available."""
|
||||
display_name = "Backpack Count"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 1
|
||||
|
||||
|
||||
class MaxAmmoBullets(Range):
|
||||
"""Set the starting ammo capacity for bullets."""
|
||||
display_name = "Max Ammo - Bullets"
|
||||
range_start = 200
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class MaxAmmoShells(Range):
|
||||
"""Set the starting ammo capacity for shotgun shells."""
|
||||
display_name = "Max Ammo - Shells"
|
||||
range_start = 50
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class MaxAmmoRockets(Range):
|
||||
"""Set the starting ammo capacity for rockets."""
|
||||
display_name = "Max Ammo - Rockets"
|
||||
range_start = 50
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class MaxAmmoEnergyCells(Range):
|
||||
"""Set the starting ammo capacity for energy cells."""
|
||||
display_name = "Max Ammo - Energy Cells"
|
||||
range_start = 300
|
||||
range_end = 999
|
||||
default = 300
|
||||
|
||||
|
||||
class AddedAmmoBullets(Range):
|
||||
"""Set the amount of bullet capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Bullets"
|
||||
range_start = 20
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class AddedAmmoShells(Range):
|
||||
"""Set the amount of shotgun shell capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Shells"
|
||||
range_start = 5
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class AddedAmmoRockets(Range):
|
||||
"""Set the amount of rocket capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Rockets"
|
||||
range_start = 5
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class AddedAmmoEnergyCells(Range):
|
||||
"""Set the amount of energy cell capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Energy Cells"
|
||||
range_start = 30
|
||||
range_end = 999
|
||||
default = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class DOOM1993Options(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
@@ -163,3 +241,14 @@ class DOOM1993Options(PerGameCommonOptions):
|
||||
episode3: Episode3
|
||||
episode4: Episode4
|
||||
|
||||
split_backpack: SplitBackpack
|
||||
backpack_count: BackpackCount
|
||||
max_ammo_bullets: MaxAmmoBullets
|
||||
max_ammo_shells: MaxAmmoShells
|
||||
max_ammo_rockets: MaxAmmoRockets
|
||||
max_ammo_energy_cells: MaxAmmoEnergyCells
|
||||
added_ammo_bullets: AddedAmmoBullets
|
||||
added_ammo_shells: AddedAmmoShells
|
||||
added_ammo_rockets: AddedAmmoRockets
|
||||
added_ammo_energy_cells: AddedAmmoEnergyCells
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class DOOM1993World(World):
|
||||
options: DOOM1993Options
|
||||
game = "DOOM 1993"
|
||||
web = DOOM1993Web()
|
||||
required_client_version = (0, 3, 9)
|
||||
required_client_version = (0, 5, 0) # 1.2.0-prerelease or higher
|
||||
|
||||
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
||||
item_name_groups = Items.item_name_groups
|
||||
@@ -204,6 +204,15 @@ class DOOM1993World(World):
|
||||
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
||||
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
||||
|
||||
# Backpack(s) based on options
|
||||
if self.options.split_backpack.value:
|
||||
itempool += [self.create_item("Bullet capacity") for _ in range(self.options.backpack_count.value)]
|
||||
itempool += [self.create_item("Shell capacity") for _ in range(self.options.backpack_count.value)]
|
||||
itempool += [self.create_item("Energy cell capacity") for _ in range(self.options.backpack_count.value)]
|
||||
itempool += [self.create_item("Rocket capacity") for _ in range(self.options.backpack_count.value)]
|
||||
else:
|
||||
itempool += [self.create_item("Backpack") for _ in range(self.options.backpack_count.value)]
|
||||
|
||||
# Place end level items in locked locations
|
||||
for map_name in Maps.map_names:
|
||||
loc_name = map_name + " - Exit"
|
||||
@@ -265,7 +274,7 @@ class DOOM1993World(World):
|
||||
# Was balanced for 3 episodes (We added 4th episode, but keep same ratio)
|
||||
count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3))))
|
||||
if count == 0:
|
||||
logger.warning("Warning, no ", item_name, " will be placed.")
|
||||
logger.warning(f"Warning, no {item_name} will be placed.")
|
||||
return
|
||||
|
||||
for i in range(count):
|
||||
@@ -281,4 +290,14 @@ class DOOM1993World(World):
|
||||
# an older version, the player would end up stuck.
|
||||
slot_data["two_ways_keydoors"] = True
|
||||
|
||||
# Send slot data for ammo capacity values; this must be generic because Heretic uses it too
|
||||
slot_data["ammo1start"] = self.options.max_ammo_bullets.value
|
||||
slot_data["ammo2start"] = self.options.max_ammo_shells.value
|
||||
slot_data["ammo3start"] = self.options.max_ammo_energy_cells.value
|
||||
slot_data["ammo4start"] = self.options.max_ammo_rockets.value
|
||||
slot_data["ammo1add"] = self.options.added_ammo_bullets.value
|
||||
slot_data["ammo2add"] = self.options.added_ammo_shells.value
|
||||
slot_data["ammo3add"] = self.options.added_ammo_energy_cells.value
|
||||
slot_data["ammo4add"] = self.options.added_ammo_rockets.value
|
||||
|
||||
return slot_data
|
||||
|
||||
@@ -56,8 +56,8 @@ item_table: Dict[int, ItemDict] = {
|
||||
'doom_type': 82,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
360007: {'classification': ItemClassification.progression,
|
||||
'count': 1,
|
||||
360007: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Backpack',
|
||||
'doom_type': 8,
|
||||
'episode': -1,
|
||||
@@ -1058,6 +1058,30 @@ item_table: Dict[int, ItemDict] = {
|
||||
'doom_type': 2026,
|
||||
'episode': 4,
|
||||
'map': 2},
|
||||
360600: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Bullet capacity',
|
||||
'doom_type': 65001,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
360601: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Shell capacity',
|
||||
'doom_type': 65002,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
360602: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Energy cell capacity',
|
||||
'doom_type': 65003,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
360603: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Rocket capacity',
|
||||
'doom_type': 65004,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import typing
|
||||
|
||||
from Options import PerGameCommonOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||
from Options import PerGameCommonOptions, Range, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -136,6 +136,84 @@ class SecretLevels(Toggle):
|
||||
display_name = "Secret Levels"
|
||||
|
||||
|
||||
class SplitBackpack(Toggle):
|
||||
"""Split the Backpack into four individual items, each one increasing ammo capacity for one type of weapon only."""
|
||||
display_name = "Split Backpack"
|
||||
|
||||
|
||||
class BackpackCount(Range):
|
||||
"""How many Backpacks will be available.
|
||||
If Split Backpack is set, this will be the number of each capacity upgrade available."""
|
||||
display_name = "Backpack Count"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 1
|
||||
|
||||
|
||||
class MaxAmmoBullets(Range):
|
||||
"""Set the starting ammo capacity for bullets."""
|
||||
display_name = "Max Ammo - Bullets"
|
||||
range_start = 200
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class MaxAmmoShells(Range):
|
||||
"""Set the starting ammo capacity for shotgun shells."""
|
||||
display_name = "Max Ammo - Shells"
|
||||
range_start = 50
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class MaxAmmoRockets(Range):
|
||||
"""Set the starting ammo capacity for rockets."""
|
||||
display_name = "Max Ammo - Rockets"
|
||||
range_start = 50
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class MaxAmmoEnergyCells(Range):
|
||||
"""Set the starting ammo capacity for energy cells."""
|
||||
display_name = "Max Ammo - Energy Cells"
|
||||
range_start = 300
|
||||
range_end = 999
|
||||
default = 300
|
||||
|
||||
|
||||
class AddedAmmoBullets(Range):
|
||||
"""Set the amount of bullet capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Bullets"
|
||||
range_start = 20
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class AddedAmmoShells(Range):
|
||||
"""Set the amount of shotgun shell capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Shells"
|
||||
range_start = 5
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class AddedAmmoRockets(Range):
|
||||
"""Set the amount of rocket capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Rockets"
|
||||
range_start = 5
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class AddedAmmoEnergyCells(Range):
|
||||
"""Set the amount of energy cell capacity added when collecting a backpack or capacity upgrade."""
|
||||
display_name = "Added Ammo - Energy Cells"
|
||||
range_start = 30
|
||||
range_end = 999
|
||||
default = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class DOOM2Options(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
@@ -153,3 +231,14 @@ class DOOM2Options(PerGameCommonOptions):
|
||||
episode2: Episode2
|
||||
episode3: Episode3
|
||||
episode4: SecretLevels
|
||||
|
||||
split_backpack: SplitBackpack
|
||||
backpack_count: BackpackCount
|
||||
max_ammo_bullets: MaxAmmoBullets
|
||||
max_ammo_shells: MaxAmmoShells
|
||||
max_ammo_rockets: MaxAmmoRockets
|
||||
max_ammo_energy_cells: MaxAmmoEnergyCells
|
||||
added_ammo_bullets: AddedAmmoBullets
|
||||
added_ammo_shells: AddedAmmoShells
|
||||
added_ammo_rockets: AddedAmmoRockets
|
||||
added_ammo_energy_cells: AddedAmmoEnergyCells
|
||||
|
||||
@@ -43,7 +43,7 @@ class DOOM2World(World):
|
||||
options: DOOM2Options
|
||||
game = "DOOM II"
|
||||
web = DOOM2Web()
|
||||
required_client_version = (0, 3, 9)
|
||||
required_client_version = (0, 5, 0) # 1.2.0-prerelease or higher
|
||||
|
||||
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
||||
item_name_groups = Items.item_name_groups
|
||||
@@ -196,6 +196,15 @@ class DOOM2World(World):
|
||||
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
||||
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
||||
|
||||
# Backpack(s) based on options
|
||||
if self.options.split_backpack.value:
|
||||
itempool += [self.create_item("Bullet capacity") for _ in range(self.options.backpack_count.value)]
|
||||
itempool += [self.create_item("Shell capacity") for _ in range(self.options.backpack_count.value)]
|
||||
itempool += [self.create_item("Energy cell capacity") for _ in range(self.options.backpack_count.value)]
|
||||
itempool += [self.create_item("Rocket capacity") for _ in range(self.options.backpack_count.value)]
|
||||
else:
|
||||
itempool += [self.create_item("Backpack") for _ in range(self.options.backpack_count.value)]
|
||||
|
||||
# Place end level items in locked locations
|
||||
for map_name in Maps.map_names:
|
||||
loc_name = map_name + " - Exit"
|
||||
@@ -258,11 +267,23 @@ class DOOM2World(World):
|
||||
# Was balanced based on DOOM 1993's first 3 episodes
|
||||
count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3))))
|
||||
if count == 0:
|
||||
logger.warning("Warning, no ", item_name, " will be placed.")
|
||||
logger.warning(f"Warning, no {item_name} will be placed.")
|
||||
return
|
||||
|
||||
for i in range(count):
|
||||
itempool.append(self.create_item(item_name))
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
return self.options.as_dict("difficulty", "random_monsters", "random_pickups", "random_music", "flip_levels", "allow_death_logic", "pro", "death_link", "reset_level_on_death", "episode1", "episode2", "episode3", "episode4")
|
||||
slot_data = self.options.as_dict("difficulty", "random_monsters", "random_pickups", "random_music", "flip_levels", "allow_death_logic", "pro", "death_link", "reset_level_on_death", "episode1", "episode2", "episode3", "episode4")
|
||||
|
||||
# Send slot data for ammo capacity values; this must be generic because Heretic uses it too
|
||||
slot_data["ammo1start"] = self.options.max_ammo_bullets.value
|
||||
slot_data["ammo2start"] = self.options.max_ammo_shells.value
|
||||
slot_data["ammo3start"] = self.options.max_ammo_energy_cells.value
|
||||
slot_data["ammo4start"] = self.options.max_ammo_rockets.value
|
||||
slot_data["ammo1add"] = self.options.added_ammo_bullets.value
|
||||
slot_data["ammo2add"] = self.options.added_ammo_shells.value
|
||||
slot_data["ammo3add"] = self.options.added_ammo_energy_cells.value
|
||||
slot_data["ammo4add"] = self.options.added_ammo_rockets.value
|
||||
|
||||
return slot_data
|
||||
|
||||
@@ -235,6 +235,12 @@ class FactorioStartItems(OptionDict):
|
||||
"""Mapping of Factorio internal item-name to amount granted on start."""
|
||||
display_name = "Starting Items"
|
||||
default = {"burner-mining-drill": 4, "stone-furnace": 4, "raw-fish": 50}
|
||||
schema = Schema(
|
||||
{
|
||||
str: And(int, lambda n: n > 0,
|
||||
error="amount of starting items has to be a positive integer"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FactorioFreeSampleBlacklist(OptionSet):
|
||||
@@ -257,7 +263,8 @@ class AttackTrapCount(TrapCount):
|
||||
|
||||
|
||||
class TeleportTrapCount(TrapCount):
|
||||
"""Trap items that when received trigger a random teleport."""
|
||||
"""Trap items that when received trigger a random teleport.
|
||||
It is ensured the player can walk back to where they got teleported from."""
|
||||
display_name = "Teleport Traps"
|
||||
|
||||
|
||||
@@ -304,6 +311,11 @@ class EvolutionTrapIncrease(Range):
|
||||
range_end = 100
|
||||
|
||||
|
||||
class InventorySpillTrapCount(TrapCount):
|
||||
"""Trap items that when received trigger dropping your main inventory and trash inventory onto the ground."""
|
||||
display_name = "Inventory Spill Traps"
|
||||
|
||||
|
||||
class FactorioWorldGen(OptionDict):
|
||||
"""World Generation settings. Overview of options at https://wiki.factorio.com/Map_generator,
|
||||
with in-depth documentation at https://lua-api.factorio.com/latest/Concepts.html#MapGenSettings"""
|
||||
@@ -484,6 +496,7 @@ class FactorioOptions(PerGameCommonOptions):
|
||||
artillery_traps: ArtilleryTrapCount
|
||||
atomic_rocket_traps: AtomicRocketTrapCount
|
||||
atomic_cliff_remover_traps: AtomicCliffRemoverTrapCount
|
||||
inventory_spill_traps: InventorySpillTrapCount
|
||||
attack_traps: AttackTrapCount
|
||||
evolution_traps: EvolutionTrapCount
|
||||
evolution_trap_increase: EvolutionTrapIncrease
|
||||
@@ -518,6 +531,7 @@ option_groups: list[OptionGroup] = [
|
||||
ArtilleryTrapCount,
|
||||
AtomicRocketTrapCount,
|
||||
AtomicCliffRemoverTrapCount,
|
||||
InventorySpillTrapCount,
|
||||
],
|
||||
start_collapsed=True
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ import Utils
|
||||
import settings
|
||||
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
|
||||
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
|
||||
from worlds.generic import Rules
|
||||
from .Locations import location_pools, location_table
|
||||
from .Mod import generate_mod
|
||||
@@ -24,7 +24,7 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="FactorioClient")
|
||||
launch_component(launch, name="FactorioClient")
|
||||
|
||||
|
||||
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
|
||||
@@ -78,6 +78,7 @@ all_items["Cluster Grenade Trap"] = factorio_base_id - 5
|
||||
all_items["Artillery Trap"] = factorio_base_id - 6
|
||||
all_items["Atomic Rocket Trap"] = factorio_base_id - 7
|
||||
all_items["Atomic Cliff Remover Trap"] = factorio_base_id - 8
|
||||
all_items["Inventory Spill Trap"] = factorio_base_id - 9
|
||||
|
||||
|
||||
class Factorio(World):
|
||||
@@ -112,6 +113,8 @@ class Factorio(World):
|
||||
science_locations: typing.List[FactorioScienceLocation]
|
||||
removed_technologies: typing.Set[str]
|
||||
settings: typing.ClassVar[FactorioSettings]
|
||||
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
|
||||
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
|
||||
|
||||
def __init__(self, world, player: int):
|
||||
super(Factorio, self).__init__(world, player)
|
||||
@@ -136,15 +139,11 @@ class Factorio(World):
|
||||
random = self.random
|
||||
nauvis = Region("Nauvis", player, self.multiworld)
|
||||
|
||||
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
|
||||
self.options.evolution_traps + \
|
||||
self.options.attack_traps + \
|
||||
self.options.teleport_traps + \
|
||||
self.options.grenade_traps + \
|
||||
self.options.cluster_grenade_traps + \
|
||||
self.options.atomic_rocket_traps + \
|
||||
self.options.atomic_cliff_remover_traps + \
|
||||
self.options.artillery_traps
|
||||
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo
|
||||
|
||||
for name in self.trap_names:
|
||||
name = name.replace(" ", "_").lower()+"_traps"
|
||||
location_count += getattr(self.options, name)
|
||||
|
||||
location_pool = []
|
||||
|
||||
@@ -196,9 +195,8 @@ class Factorio(World):
|
||||
def create_items(self) -> None:
|
||||
self.custom_technologies = self.set_custom_technologies()
|
||||
self.set_custom_recipes()
|
||||
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket",
|
||||
"Atomic Cliff Remover")
|
||||
for trap_name in traps:
|
||||
|
||||
for trap_name in self.trap_names:
|
||||
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
|
||||
range(getattr(self.options,
|
||||
f"{trap_name.lower().replace(' ', '_')}_traps")))
|
||||
@@ -280,9 +278,6 @@ class Factorio(World):
|
||||
self.get_location("Rocket Launch").access_rule = lambda state: all(state.has(technology, player)
|
||||
for technology in
|
||||
victory_tech_names)
|
||||
for tech_name in victory_tech_names:
|
||||
if not self.multiworld.get_all_state(True).has(tech_name, player):
|
||||
print(tech_name)
|
||||
self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player)
|
||||
|
||||
def get_recipe(self, name: str) -> Recipe:
|
||||
|
||||
@@ -48,3 +48,107 @@ function fire_entity_at_entities(entity_name, entities, speed)
|
||||
target=target, speed=speed}
|
||||
end
|
||||
end
|
||||
|
||||
local teleport_requests = {}
|
||||
local teleport_attempts = {}
|
||||
local max_attempts = 100
|
||||
|
||||
function attempt_teleport_player(player, attempt)
|
||||
-- global attempt storage as metadata can't be stored
|
||||
if attempt == nil then
|
||||
attempt = teleport_attempts[player.index]
|
||||
else
|
||||
teleport_attempts[player.index] = attempt
|
||||
end
|
||||
|
||||
if attempt > max_attempts then
|
||||
player.print("Teleport failed: No valid position found after " .. max_attempts .. " attempts!")
|
||||
teleport_attempts[player.index] = 0
|
||||
return
|
||||
end
|
||||
|
||||
local surface = player.character.surface
|
||||
local prototype_name = player.character.prototype.name
|
||||
local original_position = player.character.position
|
||||
local candidate_position = random_offset_position(original_position, 1024)
|
||||
|
||||
local non_colliding_position = surface.find_non_colliding_position(
|
||||
prototype_name, candidate_position, 0, 1
|
||||
)
|
||||
|
||||
if non_colliding_position then
|
||||
-- Request pathfinding asynchronously
|
||||
local path_id = surface.request_path{
|
||||
bounding_box = player.character.prototype.collision_box,
|
||||
collision_mask = { layers = { ["player"] = true } },
|
||||
start = original_position,
|
||||
goal = non_colliding_position,
|
||||
force = player.force.name,
|
||||
radius = 1,
|
||||
pathfind_flags = {cache = true, low_priority = true, allow_paths_through_own_entities = true},
|
||||
}
|
||||
|
||||
-- Store the request with the player index as the key
|
||||
teleport_requests[player.index] = path_id
|
||||
else
|
||||
attempt_teleport_player(player, attempt + 1)
|
||||
end
|
||||
end
|
||||
|
||||
function handle_teleport_attempt(event)
|
||||
for player_index, path_id in pairs(teleport_requests) do
|
||||
-- Check if the event matches the stored path_id
|
||||
if path_id == event.id then
|
||||
local player = game.players[player_index]
|
||||
|
||||
if event.path then
|
||||
if player.character then
|
||||
player.character.teleport(event.path[#event.path].position) -- Teleport to the last point in the path
|
||||
-- Clear the attempts for this player
|
||||
teleport_attempts[player_index] = 0
|
||||
return
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
attempt_teleport_player(player, nil)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
function spill_character_inventory(character)
|
||||
if not (character and character.valid) then
|
||||
return false
|
||||
end
|
||||
|
||||
-- grab attrs once pre-loop
|
||||
local position = character.position
|
||||
local surface = character.surface
|
||||
|
||||
local inventories_to_spill = {
|
||||
defines.inventory.character_main, -- Main inventory
|
||||
defines.inventory.character_trash, -- Logistic trash slots
|
||||
}
|
||||
|
||||
for _, inventory_type in pairs(inventories_to_spill) do
|
||||
local inventory = character.get_inventory(inventory_type)
|
||||
if inventory and inventory.valid then
|
||||
-- Spill each item stack onto the ground
|
||||
for i = 1, #inventory do
|
||||
local stack = inventory[i]
|
||||
if stack and stack.valid_for_read then
|
||||
local spilled_items = surface.spill_item_stack{
|
||||
position = position,
|
||||
stack = stack,
|
||||
enable_looted = false, -- do not mark for auto-pickup
|
||||
force = nil, -- do not mark for auto-deconstruction
|
||||
allow_belts = true, -- do mark for putting it onto belts
|
||||
}
|
||||
if #spilled_items > 0 then
|
||||
stack.clear() -- only delete if spilled successfully
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -134,6 +134,9 @@ end
|
||||
|
||||
script.on_event(defines.events.on_player_changed_position, on_player_changed_position)
|
||||
{% endif %}
|
||||
-- Handle the pathfinding result of teleport traps
|
||||
script.on_event(defines.events.on_script_path_request_finished, handle_teleport_attempt)
|
||||
|
||||
function count_energy_bridges()
|
||||
local count = 0
|
||||
for i, bridge in pairs(storage.energy_link_bridges) do
|
||||
@@ -143,9 +146,11 @@ function count_energy_bridges()
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
function get_energy_increment(bridge)
|
||||
return ENERGY_INCREMENT + (ENERGY_INCREMENT * 0.3 * bridge.quality.level)
|
||||
end
|
||||
|
||||
function on_check_energy_link(event)
|
||||
--- assuming 1 MJ increment and 5MJ battery:
|
||||
--- first 2 MJ request fill, last 2 MJ push energy, middle 1 MJ does nothing
|
||||
@@ -722,12 +727,10 @@ end,
|
||||
game.forces["enemy"].set_evolution_factor(new_factor, "nauvis")
|
||||
game.print({"", "New evolution factor:", new_factor})
|
||||
end,
|
||||
["Teleport Trap"] = function ()
|
||||
["Teleport Trap"] = function()
|
||||
for _, player in ipairs(game.forces["player"].players) do
|
||||
current_character = player.character
|
||||
if current_character ~= nil then
|
||||
current_character.teleport(current_character.surface.find_non_colliding_position(
|
||||
current_character.prototype.name, random_offset_position(current_character.position, 1024), 0, 1))
|
||||
if player.character then
|
||||
attempt_teleport_player(player, 1)
|
||||
end
|
||||
end
|
||||
end,
|
||||
@@ -750,6 +753,11 @@ end,
|
||||
fire_entity_at_entities("atomic-rocket", {cliffs[math.random(#cliffs)]}, 0.1)
|
||||
end
|
||||
end,
|
||||
["Inventory Spill Trap"] = function ()
|
||||
for _, player in ipairs(game.forces["player"].players) do
|
||||
spill_character_inventory(player.character)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
commands.add_command("ap-get-technology", "Grant a technology, used by the Archipelago Client.", function(call)
|
||||
|
||||
@@ -152,14 +152,23 @@ class FFMQWorld(World):
|
||||
return FFMQItem(name, self.player)
|
||||
|
||||
def collect_item(self, state, item, remove=False):
|
||||
if not item.advancement:
|
||||
return None
|
||||
if "Progressive" in item.name:
|
||||
i = item.code - 256
|
||||
if remove:
|
||||
if state.has(self.item_id_to_name[i+1], self.player):
|
||||
if state.has(self.item_id_to_name[i+2], self.player):
|
||||
return self.item_id_to_name[i+2]
|
||||
return self.item_id_to_name[i+1]
|
||||
return self.item_id_to_name[i]
|
||||
|
||||
if state.has(self.item_id_to_name[i], self.player):
|
||||
if state.has(self.item_id_to_name[i+1], self.player):
|
||||
return self.item_id_to_name[i+2]
|
||||
return self.item_id_to_name[i+1]
|
||||
return self.item_id_to_name[i]
|
||||
return item.name if item.advancement else None
|
||||
return item.name
|
||||
|
||||
def modify_multidata(self, multidata):
|
||||
# wait for self.rom_name to be available.
|
||||
|
||||
@@ -50,8 +50,8 @@ item_table: Dict[int, ItemDict] = {
|
||||
'doom_type': 2004,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
370006: {'classification': ItemClassification.progression,
|
||||
'count': 1,
|
||||
370006: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Bag of Holding',
|
||||
'doom_type': 8,
|
||||
'episode': -1,
|
||||
@@ -1592,6 +1592,42 @@ item_table: Dict[int, ItemDict] = {
|
||||
'doom_type': 35,
|
||||
'episode': 5,
|
||||
'map': 9},
|
||||
370600: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Crystal Capacity',
|
||||
'doom_type': 65001,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
370601: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Ethereal Arrow Capacity',
|
||||
'doom_type': 65002,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
370602: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Claw Orb Capacity',
|
||||
'doom_type': 65003,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
370603: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Rune Capacity',
|
||||
'doom_type': 65004,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
370604: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Flame Orb Capacity',
|
||||
'doom_type': 65005,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
370605: {'classification': ItemClassification.useful,
|
||||
'count': 0,
|
||||
'name': 'Mace Sphere Capacity',
|
||||
'doom_type': 65006,
|
||||
'episode': -1,
|
||||
'map': -1},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from Options import PerGameCommonOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||
from Options import PerGameCommonOptions, Range, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -144,6 +144,116 @@ class Episode5(Toggle):
|
||||
display_name = "Episode 5"
|
||||
|
||||
|
||||
class SplitBagOfHolding(Toggle):
|
||||
"""Split the Bag of Holding into six individual items, each one increasing ammo capacity for one type of weapon only."""
|
||||
display_name = "Split Bag of Holding"
|
||||
|
||||
|
||||
class BagOfHoldingCount(Range):
|
||||
"""How many Bags of Holding will be available.
|
||||
If Split Bag of Holding is set, this will be the number of each capacity upgrade available."""
|
||||
display_name = "Bag of Holding Count"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 1
|
||||
|
||||
|
||||
class MaxAmmoCrystals(Range):
|
||||
"""Set the starting ammo capacity for crystals (Elven Wand ammo)."""
|
||||
display_name = "Max Ammo - Crystals"
|
||||
range_start = 100
|
||||
range_end = 999
|
||||
default = 100
|
||||
|
||||
|
||||
class MaxAmmoArrows(Range):
|
||||
"""Set the starting ammo capacity for arrows (Ethereal Crossbow ammo)."""
|
||||
display_name = "Max Ammo - Arrows"
|
||||
range_start = 50
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class MaxAmmoClawOrbs(Range):
|
||||
"""Set the starting ammo capacity for claw orbs (Dragon Claw ammo)."""
|
||||
display_name = "Max Ammo - Claw Orbs"
|
||||
range_start = 200
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class MaxAmmoRunes(Range):
|
||||
"""Set the starting ammo capacity for runes (Hellstaff ammo)."""
|
||||
display_name = "Max Ammo - Runes"
|
||||
range_start = 200
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class MaxAmmoFlameOrbs(Range):
|
||||
"""Set the starting ammo capacity for flame orbs (Phoenix Rod ammo)."""
|
||||
display_name = "Max Ammo - Flame Orbs"
|
||||
range_start = 20
|
||||
range_end = 999
|
||||
default = 20
|
||||
|
||||
|
||||
class MaxAmmoSpheres(Range):
|
||||
"""Set the starting ammo capacity for spheres (Firemace ammo)."""
|
||||
display_name = "Max Ammo - Spheres"
|
||||
range_start = 150
|
||||
range_end = 999
|
||||
default = 150
|
||||
|
||||
|
||||
class AddedAmmoCrystals(Range):
|
||||
"""Set the amount of crystal capacity gained when collecting a bag of holding or a capacity upgrade."""
|
||||
display_name = "Added Ammo - Crystals"
|
||||
range_start = 10
|
||||
range_end = 999
|
||||
default = 100
|
||||
|
||||
|
||||
class AddedAmmoArrows(Range):
|
||||
"""Set the amount of arrow capacity gained when collecting a bag of holding or a capacity upgrade."""
|
||||
display_name = "Added Ammo - Arrows"
|
||||
range_start = 5
|
||||
range_end = 999
|
||||
default = 50
|
||||
|
||||
|
||||
class AddedAmmoClawOrbs(Range):
|
||||
"""Set the amount of claw orb capacity gained when collecting a bag of holding or a capacity upgrade."""
|
||||
display_name = "Added Ammo - Claw Orbs"
|
||||
range_start = 20
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class AddedAmmoRunes(Range):
|
||||
"""Set the amount of rune capacity gained when collecting a bag of holding or a capacity upgrade."""
|
||||
display_name = "Added Ammo - Runes"
|
||||
range_start = 20
|
||||
range_end = 999
|
||||
default = 200
|
||||
|
||||
|
||||
class AddedAmmoFlameOrbs(Range):
|
||||
"""Set the amount of flame orb capacity gained when collecting a bag of holding or a capacity upgrade."""
|
||||
display_name = "Added Ammo - Flame Orbs"
|
||||
range_start = 2
|
||||
range_end = 999
|
||||
default = 20
|
||||
|
||||
|
||||
class AddedAmmoSpheres(Range):
|
||||
"""Set the amount of sphere capacity gained when collecting a bag of holding or a capacity upgrade."""
|
||||
display_name = "Added Ammo - Spheres"
|
||||
range_start = 15
|
||||
range_end = 999
|
||||
default = 150
|
||||
|
||||
|
||||
@dataclass
|
||||
class HereticOptions(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
@@ -163,3 +273,18 @@ class HereticOptions(PerGameCommonOptions):
|
||||
episode3: Episode3
|
||||
episode4: Episode4
|
||||
episode5: Episode5
|
||||
|
||||
split_bag_of_holding: SplitBagOfHolding
|
||||
bag_of_holding_count: BagOfHoldingCount
|
||||
max_ammo_crystals: MaxAmmoCrystals
|
||||
max_ammo_arrows: MaxAmmoArrows
|
||||
max_ammo_claw_orbs: MaxAmmoClawOrbs
|
||||
max_ammo_runes: MaxAmmoRunes
|
||||
max_ammo_flame_orbs: MaxAmmoFlameOrbs
|
||||
max_ammo_spheres: MaxAmmoSpheres
|
||||
added_ammo_crystals: AddedAmmoCrystals
|
||||
added_ammo_arrows: AddedAmmoArrows
|
||||
added_ammo_claw_orbs: AddedAmmoClawOrbs
|
||||
added_ammo_runes: AddedAmmoRunes
|
||||
added_ammo_flame_orbs: AddedAmmoFlameOrbs
|
||||
added_ammo_spheres: AddedAmmoSpheres
|
||||
|
||||
@@ -695,13 +695,11 @@ def set_episode5_rules(player, multiworld, pro):
|
||||
state.has("Phoenix Rod", player, 1) and
|
||||
state.has("Firemace", player, 1) and
|
||||
state.has("Hellstaff", player, 1) and
|
||||
state.has("Gauntlets of the Necromancer", player, 1) and
|
||||
state.has("Bag of Holding", player, 1))
|
||||
state.has("Gauntlets of the Necromancer", player, 1))
|
||||
|
||||
# Skein of D'Sparil (E5M9)
|
||||
set_rule(multiworld.get_entrance("Hub -> Skein of D'Sparil (E5M9) Main", player), lambda state:
|
||||
state.has("Skein of D'Sparil (E5M9)", player, 1) and
|
||||
state.has("Bag of Holding", player, 1) and
|
||||
state.has("Hellstaff", player, 1) and
|
||||
state.has("Phoenix Rod", player, 1) and
|
||||
state.has("Dragon Claw", player, 1) and
|
||||
|
||||
@@ -41,7 +41,7 @@ class HereticWorld(World):
|
||||
options: HereticOptions
|
||||
game = "Heretic"
|
||||
web = HereticWeb()
|
||||
required_client_version = (0, 3, 9)
|
||||
required_client_version = (0, 5, 0) # 1.2.0-prerelease or higher
|
||||
|
||||
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
||||
item_name_groups = Items.item_name_groups
|
||||
@@ -206,6 +206,17 @@ class HereticWorld(World):
|
||||
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
||||
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
||||
|
||||
# Bag(s) of Holding based on options
|
||||
if self.options.split_bag_of_holding.value:
|
||||
itempool += [self.create_item("Crystal Capacity") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
itempool += [self.create_item("Ethereal Arrow Capacity") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
itempool += [self.create_item("Claw Orb Capacity") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
itempool += [self.create_item("Rune Capacity") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
itempool += [self.create_item("Flame Orb Capacity") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
itempool += [self.create_item("Mace Sphere Capacity") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
else:
|
||||
itempool += [self.create_item("Bag of Holding") for _ in range(self.options.bag_of_holding_count.value)]
|
||||
|
||||
# Place end level items in locked locations
|
||||
for map_name in Maps.map_names:
|
||||
loc_name = map_name + " - Exit"
|
||||
@@ -274,7 +285,7 @@ class HereticWorld(World):
|
||||
episode_count = self.get_episode_count()
|
||||
count = min(remaining_loc, max(1, self.items_ratio[item_name] * episode_count))
|
||||
if count == 0:
|
||||
logger.warning("Warning, no " + item_name + " will be placed.")
|
||||
logger.warning(f"Warning, no {item_name} will be placed.")
|
||||
return
|
||||
|
||||
for i in range(count):
|
||||
@@ -290,4 +301,18 @@ class HereticWorld(World):
|
||||
slot_data["episode4"] = self.included_episodes[3]
|
||||
slot_data["episode5"] = self.included_episodes[4]
|
||||
|
||||
# Send slot data for ammo capacity values; this must be generic because Doom uses it too
|
||||
slot_data["ammo1start"] = self.options.max_ammo_crystals.value
|
||||
slot_data["ammo2start"] = self.options.max_ammo_arrows.value
|
||||
slot_data["ammo3start"] = self.options.max_ammo_claw_orbs.value
|
||||
slot_data["ammo4start"] = self.options.max_ammo_runes.value
|
||||
slot_data["ammo5start"] = self.options.max_ammo_flame_orbs.value
|
||||
slot_data["ammo6start"] = self.options.max_ammo_spheres.value
|
||||
slot_data["ammo1add"] = self.options.added_ammo_crystals.value
|
||||
slot_data["ammo2add"] = self.options.added_ammo_arrows.value
|
||||
slot_data["ammo3add"] = self.options.added_ammo_claw_orbs.value
|
||||
slot_data["ammo4add"] = self.options.added_ammo_runes.value
|
||||
slot_data["ammo5add"] = self.options.added_ammo_flame_orbs.value
|
||||
slot_data["ammo6add"] = self.options.added_ammo_spheres.value
|
||||
|
||||
return slot_data
|
||||
|
||||
@@ -333,7 +333,7 @@ class PlandoCharmCosts(OptionDict):
|
||||
continue
|
||||
try:
|
||||
self.value[key] = CharmCost.from_any(data).value
|
||||
except ValueError as ex:
|
||||
except ValueError:
|
||||
# will fail schema afterwords
|
||||
self.value[key] = data
|
||||
|
||||
|
||||
@@ -7,22 +7,22 @@ import itertools
|
||||
import operator
|
||||
from collections import defaultdict, Counter
|
||||
|
||||
logger = logging.getLogger("Hollow Knight")
|
||||
|
||||
from .Items import item_table, lookup_type_to_names, item_name_groups
|
||||
from .Regions import create_regions
|
||||
from .Items import item_table, item_name_groups
|
||||
from .Rules import set_rules, cost_terms, _hk_can_beat_thk, _hk_siblings_ending, _hk_can_beat_radiance
|
||||
from .Options import hollow_knight_options, hollow_knight_randomize_options, Goal, WhitePalace, CostSanity, \
|
||||
shop_to_option, HKOptions, GrubHuntGoal
|
||||
from .ExtractedData import locations, starts, multi_locations, location_to_region_lookup, \
|
||||
event_names, item_effects, connectors, one_ways, vanilla_shop_costs, vanilla_location_costs
|
||||
from .ExtractedData import locations, starts, multi_locations, event_names, item_effects, connectors, \
|
||||
vanilla_shop_costs, vanilla_location_costs
|
||||
from .Charms import names as charm_names
|
||||
|
||||
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, CollectionState
|
||||
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, \
|
||||
CollectionState
|
||||
from worlds.AutoWorld import World, LogicMixin, WebWorld
|
||||
|
||||
from settings import Group, Bool
|
||||
|
||||
logger = logging.getLogger("Hollow Knight")
|
||||
|
||||
|
||||
class HollowKnightSettings(Group):
|
||||
class DisableMapModSpoilers(Bool):
|
||||
@@ -160,7 +160,7 @@ class HKWeb(WebWorld):
|
||||
|
||||
|
||||
class HKWorld(World):
|
||||
"""Beneath the fading town of Dirtmouth sleeps a vast, ancient kingdom. Many are drawn beneath the surface,
|
||||
"""Beneath the fading town of Dirtmouth sleeps a vast, ancient kingdom. Many are drawn beneath the surface,
|
||||
searching for riches, or glory, or answers to old secrets.
|
||||
|
||||
As the enigmatic Knight, you’ll traverse the depths, unravel its mysteries and conquer its evils.
|
||||
@@ -209,7 +209,7 @@ class HKWorld(World):
|
||||
# defaulting so completion condition isn't incorrect before pre_fill
|
||||
self.grub_count = (
|
||||
46 if options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
|
||||
else options.GrubHuntGoal
|
||||
else options.GrubHuntGoal.value
|
||||
)
|
||||
self.grub_player_count = {self.player: self.grub_count}
|
||||
|
||||
@@ -231,7 +231,6 @@ class HKWorld(World):
|
||||
def create_regions(self):
|
||||
menu_region: Region = create_region(self.multiworld, self.player, 'Menu')
|
||||
self.multiworld.regions.append(menu_region)
|
||||
# wp_exclusions = self.white_palace_exclusions()
|
||||
|
||||
# check for any goal that godhome events are relevant to
|
||||
all_event_names = event_names.copy()
|
||||
@@ -241,21 +240,17 @@ class HKWorld(World):
|
||||
|
||||
# Link regions
|
||||
for event_name in sorted(all_event_names):
|
||||
#if event_name in wp_exclusions:
|
||||
# continue
|
||||
loc = HKLocation(self.player, event_name, None, menu_region)
|
||||
loc.place_locked_item(HKItem(event_name,
|
||||
True, #event_name not in wp_exclusions,
|
||||
True,
|
||||
None, "Event", self.player))
|
||||
menu_region.locations.append(loc)
|
||||
for entry_transition, exit_transition in connectors.items():
|
||||
#if entry_transition in wp_exclusions:
|
||||
# continue
|
||||
if exit_transition:
|
||||
# if door logic fulfilled -> award vanilla target as event
|
||||
loc = HKLocation(self.player, entry_transition, None, menu_region)
|
||||
loc.place_locked_item(HKItem(exit_transition,
|
||||
True, #exit_transition not in wp_exclusions,
|
||||
True,
|
||||
None, "Event", self.player))
|
||||
menu_region.locations.append(loc)
|
||||
|
||||
@@ -292,7 +287,10 @@ class HKWorld(World):
|
||||
if item_name in junk_replace:
|
||||
item_name = self.get_filler_item_name()
|
||||
|
||||
item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.options.AddUnshuffledLocations else self.create_event(item_name)
|
||||
item = (self.create_item(item_name)
|
||||
if not vanilla or location_name == "Start" or self.options.AddUnshuffledLocations
|
||||
else self.create_event(item_name)
|
||||
)
|
||||
|
||||
if location_name == "Start":
|
||||
if item_name in randomized_starting_items:
|
||||
@@ -347,8 +345,8 @@ class HKWorld(World):
|
||||
randomized = True
|
||||
_add("Elevator_Pass", "Elevator_Pass", randomized)
|
||||
|
||||
for shop, locations in self.created_multi_locations.items():
|
||||
for _ in range(len(locations), getattr(self.options, shop_to_option[shop]).value):
|
||||
for shop, shop_locations in self.created_multi_locations.items():
|
||||
for _ in range(len(shop_locations), getattr(self.options, shop_to_option[shop]).value):
|
||||
self.create_location(shop)
|
||||
unfilled_locations += 1
|
||||
|
||||
@@ -358,7 +356,7 @@ class HKWorld(World):
|
||||
|
||||
# Add additional shop items, as needed.
|
||||
if additional_shop_items > 0:
|
||||
shops = list(shop for shop, locations in self.created_multi_locations.items() if len(locations) < 16)
|
||||
shops = [shop for shop, shop_locations in self.created_multi_locations.items() if len(shop_locations) < 16]
|
||||
if not self.options.EggShopSlots: # No eggshop, so don't place items there
|
||||
shops.remove('Egg_Shop')
|
||||
|
||||
@@ -380,8 +378,8 @@ class HKWorld(World):
|
||||
self.sort_shops_by_cost()
|
||||
|
||||
def sort_shops_by_cost(self):
|
||||
for shop, locations in self.created_multi_locations.items():
|
||||
randomized_locations = list(loc for loc in locations if not loc.vanilla)
|
||||
for shop, shop_locations in self.created_multi_locations.items():
|
||||
randomized_locations = [loc for loc in shop_locations if not loc.vanilla]
|
||||
prices = sorted(
|
||||
(loc.costs for loc in randomized_locations),
|
||||
key=lambda costs: (len(costs),) + tuple(costs.values())
|
||||
@@ -405,7 +403,7 @@ class HKWorld(World):
|
||||
return {k: v for k, v in weights.items() if v}
|
||||
|
||||
random = self.random
|
||||
hybrid_chance = getattr(self.options, f"CostSanityHybridChance").value
|
||||
hybrid_chance = getattr(self.options, "CostSanityHybridChance").value
|
||||
weights = {
|
||||
data.term: getattr(self.options, f"CostSanity{data.option}Weight").value
|
||||
for data in cost_terms.values()
|
||||
@@ -493,7 +491,11 @@ class HKWorld(World):
|
||||
worlds = [world for world in multiworld.get_game_worlds(cls.game) if world.options.Goal in ["any", "grub_hunt"]]
|
||||
if worlds:
|
||||
grubs = [item for item in multiworld.get_items() if item.name == "Grub"]
|
||||
all_grub_players = [world.player for world in worlds if world.options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]]
|
||||
all_grub_players = [
|
||||
world.player
|
||||
for world in worlds
|
||||
if world.options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
|
||||
]
|
||||
|
||||
if all_grub_players:
|
||||
group_lookup = defaultdict(set)
|
||||
@@ -668,8 +670,8 @@ class HKWorld(World):
|
||||
):
|
||||
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
|
||||
else:
|
||||
for shop_name, locations in hk_world.created_multi_locations.items():
|
||||
for loc in locations:
|
||||
for shop_name, shop_locations in hk_world.created_multi_locations.items():
|
||||
for loc in shop_locations:
|
||||
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
|
||||
|
||||
def get_multi_location_name(self, base: str, i: typing.Optional[int]) -> str:
|
||||
|
||||
@@ -2,7 +2,6 @@ import typing
|
||||
from argparse import Namespace
|
||||
from BaseClasses import CollectionState, MultiWorld
|
||||
from Options import ItemLinks
|
||||
from test.bases import WorldTestBase
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from .. import HKWorld
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from . import linkedTestHK, WorldTestBase
|
||||
from test.bases import WorldTestBase
|
||||
from Options import ItemLinks
|
||||
from . import linkedTestHK
|
||||
|
||||
|
||||
class test_grubcount_limited(linkedTestHK, WorldTestBase):
|
||||
|
||||
@@ -206,19 +206,19 @@ def set_rules(world: "KDL3World") -> None:
|
||||
lambda state: can_reach_needle(state, world.player))
|
||||
set_rule(world.multiworld.get_location(location_name.sand_canyon_5_u2, world.player),
|
||||
lambda state: can_reach_ice(state, world.player) and
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
set_rule(world.multiworld.get_location(location_name.sand_canyon_5_u3, world.player),
|
||||
lambda state: can_reach_ice(state, world.player) and
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
set_rule(world.multiworld.get_location(location_name.sand_canyon_5_u4, world.player),
|
||||
lambda state: can_reach_ice(state, world.player) and
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
set_rule(world.multiworld.get_location(location_name.cloudy_park_6_u1, world.player),
|
||||
lambda state: can_reach_cutter(state, world.player))
|
||||
|
||||
@@ -248,9 +248,9 @@ def set_rules(world: "KDL3World") -> None:
|
||||
for i in range(12, 18):
|
||||
set_rule(world.multiworld.get_location(f"Sand Canyon 5 - Star {i}", world.player),
|
||||
lambda state: can_reach_ice(state, world.player) and
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
(can_reach_rick(state, world.player) or can_reach_coo(state, world.player)
|
||||
or can_reach_chuchu(state, world.player) or can_reach_pitch(state, world.player)
|
||||
or can_reach_nago(state, world.player)))
|
||||
for i in range(21, 23):
|
||||
set_rule(world.multiworld.get_location(f"Sand Canyon 5 - Star {i}", world.player),
|
||||
lambda state: can_reach_chuchu(state, world.player))
|
||||
@@ -307,7 +307,7 @@ def set_rules(world: "KDL3World") -> None:
|
||||
lambda state: can_reach_coo(state, world.player) and can_reach_burning(state, world.player))
|
||||
set_rule(world.multiworld.get_location(animal_friend_spawns.iceberg_4_a3, world.player),
|
||||
lambda state: can_reach_chuchu(state, world.player) and can_reach_coo(state, world.player)
|
||||
and can_reach_burning(state, world.player))
|
||||
and can_reach_burning(state, world.player))
|
||||
|
||||
for boss_flag, purification, i in zip(["Level 1 Boss - Purified", "Level 2 Boss - Purified",
|
||||
"Level 3 Boss - Purified", "Level 4 Boss - Purified",
|
||||
@@ -329,6 +329,14 @@ def set_rules(world: "KDL3World") -> None:
|
||||
world.options.ow_boss_requirement.value,
|
||||
world.player_levels)))
|
||||
|
||||
if world.options.open_world:
|
||||
for boss_flag, level in zip(["Level 1 Boss - Defeated", "Level 2 Boss - Defeated", "Level 3 Boss - Defeated",
|
||||
"Level 4 Boss - Defeated", "Level 5 Boss - Defeated"],
|
||||
location_name.level_names.keys()):
|
||||
set_rule(world.get_location(boss_flag),
|
||||
lambda state, lvl=level: state.has(f"{lvl} - Stage Completion", world.player,
|
||||
world.options.ow_boss_requirement.value))
|
||||
|
||||
set_rule(world.multiworld.get_entrance("To Level 6", world.player),
|
||||
lambda state: state.has("Heart Star", world.player, world.required_heart_stars))
|
||||
|
||||
|
||||
@@ -483,6 +483,8 @@ def create_regions(multiworld: MultiWorld, player: int, options):
|
||||
for name, data in regions.items():
|
||||
multiworld.regions.append(create_region(multiworld, player, name, data))
|
||||
|
||||
|
||||
def connect_entrances(multiworld: MultiWorld, player: int):
|
||||
multiworld.get_entrance("Awakening", player).connect(multiworld.get_region("Awakening", player))
|
||||
multiworld.get_entrance("Destiny Islands", player).connect(multiworld.get_region("Destiny Islands", player))
|
||||
multiworld.get_entrance("Traverse Town", player).connect(multiworld.get_region("Traverse Town", player))
|
||||
@@ -500,6 +502,7 @@ def create_regions(multiworld: MultiWorld, player: int, options):
|
||||
multiworld.get_entrance("World Map", player).connect(multiworld.get_region("World Map", player))
|
||||
multiworld.get_entrance("Levels", player).connect(multiworld.get_region("Levels", player))
|
||||
|
||||
|
||||
def create_region(multiworld: MultiWorld, player: int, name: str, data: KH1RegionData):
|
||||
region = Region(name, player, multiworld)
|
||||
if data.locations:
|
||||
|
||||
@@ -6,15 +6,15 @@ from worlds.AutoWorld import WebWorld, World
|
||||
from .Items import KH1Item, KH1ItemData, event_item_table, get_items_by_category, item_table, item_name_groups
|
||||
from .Locations import KH1Location, location_table, get_locations_by_category, location_name_groups
|
||||
from .Options import KH1Options, kh1_option_groups
|
||||
from .Regions import create_regions
|
||||
from .Regions import connect_entrances, create_regions
|
||||
from .Rules import set_rules
|
||||
from .Presets import kh1_option_presets
|
||||
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
|
||||
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
|
||||
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="KH1 Client")
|
||||
launch_component(launch, name="KH1 Client")
|
||||
|
||||
|
||||
components.append(Component("KH1 Client", "KH1Client", func=launch_client, component_type=Type.CLIENT))
|
||||
@@ -242,6 +242,9 @@ class KH1World(World):
|
||||
|
||||
def create_regions(self):
|
||||
create_regions(self.multiworld, self.player, self.options)
|
||||
|
||||
def connect_entrances(self):
|
||||
connect_entrances(self.multiworld, self.player)
|
||||
|
||||
def generate_early(self):
|
||||
value_names = ["Reports to Open End of the World", "Reports to Open Final Rest Door", "Reports in Pool"]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import ModuleUpdate
|
||||
import Utils
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import requests
|
||||
from pymem import pymem
|
||||
from . import item_dictionary_table, exclusion_item_table, CheckDupingItems, all_locations, exclusion_table, SupportAbility_Table, ActionAbility_Table, all_weapon_slot
|
||||
from . import item_dictionary_table, exclusion_item_table, CheckDupingItems, all_locations, exclusion_table, \
|
||||
SupportAbility_Table, ActionAbility_Table, all_weapon_slot
|
||||
from .Names import ItemName
|
||||
from .WorldLocations import *
|
||||
|
||||
@@ -21,6 +24,7 @@ class KH2Context(CommonContext):
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(KH2Context, self).__init__(server_address, password)
|
||||
|
||||
self.goofy_ability_to_slot = dict()
|
||||
self.donald_ability_to_slot = dict()
|
||||
self.all_weapon_location_id = None
|
||||
@@ -33,6 +37,7 @@ class KH2Context(CommonContext):
|
||||
self.serverconneced = False
|
||||
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
|
||||
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
|
||||
self.kh2_data_package = {}
|
||||
self.kh2_loc_name_to_id = None
|
||||
self.kh2_item_name_to_id = None
|
||||
self.lookup_id_to_item = None
|
||||
@@ -81,7 +86,10 @@ class KH2Context(CommonContext):
|
||||
},
|
||||
}
|
||||
self.kh2seedname = None
|
||||
self.kh2_seed_save_path_join = None
|
||||
|
||||
self.kh2slotdata = None
|
||||
self.mem_json = None
|
||||
self.itemamount = {}
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
|
||||
@@ -111,26 +119,18 @@ class KH2Context(CommonContext):
|
||||
# 255: {}, # starting screen
|
||||
}
|
||||
self.last_world_int = -1
|
||||
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
|
||||
# self.sveroom = 0x2A09C00 + 0x41
|
||||
# 0 not in battle 1 in yellow battle 2 red battle #short
|
||||
# self.inBattle = 0x2A0EAC4 + 0x40
|
||||
# self.onDeath = 0xAB9078
|
||||
# PC Address anchors
|
||||
# self.Now = 0x0714DB8 old address
|
||||
# epic addresses
|
||||
# epic .10 addresses
|
||||
self.Now = 0x0716DF8
|
||||
self.Save = 0x09A92F0
|
||||
self.Save = 0x9A9330
|
||||
self.Journal = 0x743260
|
||||
self.Shop = 0x743350
|
||||
self.Slot1 = 0x2A22FD8
|
||||
# self.Sys3 = 0x2A59DF0
|
||||
# self.Bt10 = 0x2A74880
|
||||
# self.BtlEnd = 0x2A0D3E0
|
||||
# self.Slot1 = 0x2A20C98 old address
|
||||
self.Slot1 = 0x2A23018
|
||||
|
||||
self.kh2_game_version = None # can be egs or steam
|
||||
|
||||
self.kh2_seed_save_path = None
|
||||
|
||||
self.chest_set = set(exclusion_table["Chests"])
|
||||
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
|
||||
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
|
||||
@@ -178,7 +178,8 @@ class KH2Context(CommonContext):
|
||||
self.base_accessory_slots = 1
|
||||
self.base_armor_slots = 1
|
||||
self.base_item_slots = 3
|
||||
self.front_ability_slots = [0x2546, 0x2658, 0x276C, 0x2548, 0x254A, 0x254C, 0x265A, 0x265C, 0x265E, 0x276E, 0x2770, 0x2772]
|
||||
self.front_ability_slots = [0x2546, 0x2658, 0x276C, 0x2548, 0x254A, 0x254C, 0x265A, 0x265C, 0x265E, 0x276E,
|
||||
0x2770, 0x2772]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -190,8 +191,7 @@ class KH2Context(CommonContext):
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname is not None and self.auth is not None:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save2{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
with open(self.kh2_seed_save_path_join, 'w') as f:
|
||||
f.write(json.dumps(self.kh2_seed_save, indent=4))
|
||||
await super(KH2Context, self).connection_closed()
|
||||
|
||||
@@ -199,8 +199,7 @@ class KH2Context(CommonContext):
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save2{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
with open(self.kh2_seed_save_path_join, 'w') as f:
|
||||
f.write(json.dumps(self.kh2_seed_save, indent=4))
|
||||
await super(KH2Context, self).disconnect()
|
||||
|
||||
@@ -213,8 +212,7 @@ class KH2Context(CommonContext):
|
||||
|
||||
async def shutdown(self):
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save2{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
with open(self.kh2_seed_save_path_join, 'w') as f:
|
||||
f.write(json.dumps(self.kh2_seed_save, indent=4))
|
||||
await super(KH2Context, self).shutdown()
|
||||
|
||||
@@ -228,7 +226,7 @@ class KH2Context(CommonContext):
|
||||
return self.kh2.write_bytes(self.kh2.base_address + address, value.to_bytes(1, 'big'), 1)
|
||||
|
||||
def kh2_read_byte(self, address):
|
||||
return int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + address, 1), "big")
|
||||
return int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + address, 1))
|
||||
|
||||
def kh2_read_int(self, address):
|
||||
return self.kh2.read_int(self.kh2.base_address + address)
|
||||
@@ -240,11 +238,14 @@ class KH2Context(CommonContext):
|
||||
return self.kh2.read_string(self.kh2.base_address + address, length)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"RoomInfo"}:
|
||||
if cmd == "RoomInfo":
|
||||
self.kh2seedname = args['seed_name']
|
||||
self.kh2_seed_save_path = f"kh2save2{self.kh2seedname}{self.auth}.json"
|
||||
self.kh2_seed_save_path_join = os.path.join(self.game_communication_path, self.kh2_seed_save_path)
|
||||
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
if not os.path.exists(self.game_communication_path + f"\kh2save2{self.kh2seedname}{self.auth}.json"):
|
||||
if not os.path.exists(self.kh2_seed_save_path_join):
|
||||
self.kh2_seed_save = {
|
||||
"Levels": {
|
||||
"SoraLevel": 0,
|
||||
@@ -257,12 +258,11 @@ class KH2Context(CommonContext):
|
||||
},
|
||||
"SoldEquipment": [],
|
||||
}
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save2{self.kh2seedname}{self.auth}.json"),
|
||||
'wt') as f:
|
||||
with open(self.kh2_seed_save_path_join, 'wt') as f:
|
||||
pass
|
||||
# self.locations_checked = set()
|
||||
elif os.path.exists(self.game_communication_path + f"\kh2save2{self.kh2seedname}{self.auth}.json"):
|
||||
with open(self.game_communication_path + f"\kh2save2{self.kh2seedname}{self.auth}.json", 'r') as f:
|
||||
elif os.path.exists(self.kh2_seed_save_path_join):
|
||||
with open(self.kh2_seed_save_path_join) as f:
|
||||
self.kh2_seed_save = json.load(f)
|
||||
if self.kh2_seed_save is None:
|
||||
self.kh2_seed_save = {
|
||||
@@ -280,13 +280,22 @@ class KH2Context(CommonContext):
|
||||
# self.locations_checked = set(self.kh2_seed_save_cache["LocationsChecked"])
|
||||
# self.serverconneced = True
|
||||
|
||||
if cmd in {"Connected"}:
|
||||
asyncio.create_task(self.send_msgs([{"cmd": "GetDataPackage", "games": ["Kingdom Hearts 2"]}]))
|
||||
if cmd == "Connected":
|
||||
self.kh2slotdata = args['slot_data']
|
||||
# self.kh2_local_items = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
|
||||
|
||||
self.kh2_data_package = Utils.load_data_package_for_checksum(
|
||||
"Kingdom Hearts 2", self.checksums["Kingdom Hearts 2"])
|
||||
|
||||
if "location_name_to_id" in self.kh2_data_package:
|
||||
self.data_package_kh2_cache(
|
||||
self.kh2_data_package["location_name_to_id"], self.kh2_data_package["item_name_to_id"])
|
||||
self.connect_to_game()
|
||||
else:
|
||||
asyncio.create_task(self.send_msgs([{"cmd": "GetDataPackage", "games": ["Kingdom Hearts 2"]}]))
|
||||
|
||||
self.locations_checked = set(args["checked_locations"])
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
if cmd == "ReceivedItems":
|
||||
# 0x2546
|
||||
# 0x2658
|
||||
# 0x276A
|
||||
@@ -334,56 +343,46 @@ class KH2Context(CommonContext):
|
||||
for item in args['items']:
|
||||
asyncio.create_task(self.give_item(item.item, item.location))
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if cmd == "RoomUpdate":
|
||||
if "checked_locations" in args:
|
||||
new_locations = set(args["checked_locations"])
|
||||
self.locations_checked |= new_locations
|
||||
|
||||
if cmd in {"DataPackage"}:
|
||||
self.kh2_loc_name_to_id = args["data"]["games"]["Kingdom Hearts 2"]["location_name_to_id"]
|
||||
self.lookup_id_to_location = {v: k for k, v in self.kh2_loc_name_to_id.items()}
|
||||
self.kh2_item_name_to_id = args["data"]["games"]["Kingdom Hearts 2"]["item_name_to_id"]
|
||||
self.lookup_id_to_item = {v: k for k, v in self.kh2_item_name_to_id.items()}
|
||||
self.ability_code_list = [self.kh2_item_name_to_id[item] for item in exclusion_item_table["Ability"]]
|
||||
if cmd == "DataPackage":
|
||||
if "Kingdom Hearts 2" in args["data"]["games"]:
|
||||
self.data_package_kh2_cache(
|
||||
args["data"]["games"]["Kingdom Hearts 2"]["location_name_to_id"],
|
||||
args["data"]["games"]["Kingdom Hearts 2"]["item_name_to_id"])
|
||||
self.connect_to_game()
|
||||
asyncio.create_task(self.send_msgs([{'cmd': 'Sync'}]))
|
||||
|
||||
if "KeybladeAbilities" in self.kh2slotdata.keys():
|
||||
# sora ability to slot
|
||||
self.AbilityQuantityDict.update(self.kh2slotdata["KeybladeAbilities"])
|
||||
# itemid:[slots that are available for that item]
|
||||
self.AbilityQuantityDict.update(self.kh2slotdata["StaffAbilities"])
|
||||
self.AbilityQuantityDict.update(self.kh2slotdata["ShieldAbilities"])
|
||||
def connect_to_game(self):
|
||||
if "KeybladeAbilities" in self.kh2slotdata.keys():
|
||||
# sora ability to slot
|
||||
self.AbilityQuantityDict.update(self.kh2slotdata["KeybladeAbilities"])
|
||||
# itemid:[slots that are available for that item]
|
||||
self.AbilityQuantityDict.update(self.kh2slotdata["StaffAbilities"])
|
||||
self.AbilityQuantityDict.update(self.kh2slotdata["ShieldAbilities"])
|
||||
|
||||
all_weapon_location_id = []
|
||||
for weapon_location in all_weapon_slot:
|
||||
all_weapon_location_id.append(self.kh2_loc_name_to_id[weapon_location])
|
||||
self.all_weapon_location_id = set(all_weapon_location_id)
|
||||
self.all_weapon_location_id = {self.kh2_loc_name_to_id[loc] for loc in all_weapon_slot}
|
||||
|
||||
try:
|
||||
try:
|
||||
if not self.kh2:
|
||||
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
if self.kh2_game_version is None:
|
||||
if self.kh2_read_string(0x09A9830, 4) == "KH2J":
|
||||
self.kh2_game_version = "STEAM"
|
||||
self.Now = 0x0717008
|
||||
self.Save = 0x09A9830
|
||||
self.Slot1 = 0x2A23518
|
||||
self.Journal = 0x7434E0
|
||||
self.Shop = 0x7435D0
|
||||
self.get_addresses()
|
||||
|
||||
elif self.kh2_read_string(0x09A92F0, 4) == "KH2J":
|
||||
self.kh2_game_version = "EGS"
|
||||
else:
|
||||
self.kh2_game_version = None
|
||||
logger.info("Your game version is out of date. Please update your game via The Epic Games Store or Steam.")
|
||||
if self.kh2_game_version is not None:
|
||||
logger.info(f"You are now auto-tracking. {self.kh2_game_version}")
|
||||
self.kh2connected = True
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
self.kh2connected = False
|
||||
logger.info("Game is not open.")
|
||||
self.serverconneced = True
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
self.kh2connected = False
|
||||
logger.info("Game is not open.")
|
||||
self.serverconneced = True
|
||||
asyncio.create_task(self.send_msgs([{'cmd': 'Sync'}]))
|
||||
def data_package_kh2_cache(self, loc_to_id, item_to_id):
|
||||
self.kh2_loc_name_to_id = loc_to_id
|
||||
self.lookup_id_to_location = {v: k for k, v in self.kh2_loc_name_to_id.items()}
|
||||
self.kh2_item_name_to_id = item_to_id
|
||||
self.lookup_id_to_item = {v: k for k, v in self.kh2_item_name_to_id.items()}
|
||||
self.ability_code_list = [self.kh2_item_name_to_id[item] for item in exclusion_item_table["Ability"]]
|
||||
|
||||
async def checkWorldLocations(self):
|
||||
try:
|
||||
@@ -425,7 +424,6 @@ class KH2Context(CommonContext):
|
||||
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
|
||||
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels], 5: ["SummonLevel", SummonLevels]
|
||||
}
|
||||
# TODO: remove formDict[i][0] in self.kh2_seed_save_cache["Levels"].keys() after 4.3
|
||||
for i in range(6):
|
||||
for location, data in formDict[i][1].items():
|
||||
formlevel = self.kh2_read_byte(self.Save + data.addrObtained)
|
||||
@@ -469,9 +467,11 @@ class KH2Context(CommonContext):
|
||||
if locationName in self.chest_set:
|
||||
if locationName in self.location_name_to_worlddata.keys():
|
||||
locationData = self.location_name_to_worlddata[locationName]
|
||||
if self.kh2_read_byte(self.Save + locationData.addrObtained) & 0x1 << locationData.bitIndex == 0:
|
||||
if self.kh2_read_byte(
|
||||
self.Save + locationData.addrObtained) & 0x1 << locationData.bitIndex == 0:
|
||||
roomData = self.kh2_read_byte(self.Save + locationData.addrObtained)
|
||||
self.kh2_write_byte(self.Save + locationData.addrObtained, roomData | 0x01 << locationData.bitIndex)
|
||||
self.kh2_write_byte(self.Save + locationData.addrObtained,
|
||||
roomData | 0x01 << locationData.bitIndex)
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
@@ -494,6 +494,9 @@ class KH2Context(CommonContext):
|
||||
async def give_item(self, item, location):
|
||||
try:
|
||||
# todo: ripout all the itemtype stuff and just have one dictionary. the only thing that needs to be tracked from the server/local is abilites
|
||||
#sleep so we can get the datapackage and not miss any items that were sent to us while we didnt have our item id dicts
|
||||
while not self.lookup_id_to_item:
|
||||
await asyncio.sleep(0.5)
|
||||
itemname = self.lookup_id_to_item[item]
|
||||
itemdata = self.item_name_to_data[itemname]
|
||||
# itemcode = self.kh2_item_name_to_id[itemname]
|
||||
@@ -637,7 +640,8 @@ class KH2Context(CommonContext):
|
||||
item_data = self.item_name_to_data[item_name]
|
||||
# if the inventory slot for that keyblade is less than the amount they should have,
|
||||
# and they are not in stt
|
||||
if self.kh2_read_byte(self.Save + item_data.memaddr) != 1 and self.kh2_read_byte(self.Save + 0x1CFF) != 13:
|
||||
if self.kh2_read_byte(self.Save + item_data.memaddr) != 1 and self.kh2_read_byte(
|
||||
self.Save + 0x1CFF) != 13:
|
||||
# Checking form anchors for the keyblade to remove extra keyblades
|
||||
if self.kh2_read_short(self.Save + 0x24F0) == item_data.kh2id \
|
||||
or self.kh2_read_short(self.Save + 0x32F4) == item_data.kh2id \
|
||||
@@ -738,13 +742,15 @@ class KH2Context(CommonContext):
|
||||
item_data = self.item_name_to_data[item_name]
|
||||
amount_of_items = 0
|
||||
amount_of_items += self.kh2_seed_save_cache["AmountInvo"]["Magic"][item_name]
|
||||
if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items and self.kh2_read_byte(self.Shop) in {10, 8}:
|
||||
if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items and self.kh2_read_byte(
|
||||
self.Shop) in {10, 8}:
|
||||
self.kh2_write_byte(self.Save + item_data.memaddr, amount_of_items)
|
||||
|
||||
for item_name in master_stat:
|
||||
amount_of_items = 0
|
||||
amount_of_items += self.kh2_seed_save_cache["AmountInvo"]["StatIncrease"][item_name]
|
||||
if self.kh2_read_byte(self.Slot1 + 0x1B2) >= 5:
|
||||
# checking if they talked to the computer to give them these
|
||||
if self.kh2_read_byte(self.Slot1 + 0x1B2) >= 5 and (self.kh2_read_byte(self.Save + 0x1D27) & 0x1 << 3) > 0:
|
||||
if item_name == ItemName.MaxHPUp:
|
||||
if self.kh2_read_byte(self.Save + 0x2498) < 3: # Non-Critical
|
||||
Bonus = 5
|
||||
@@ -797,7 +803,8 @@ class KH2Context(CommonContext):
|
||||
# self.kh2_write_byte(self.Save + item_data.memaddr, amount_of_items)
|
||||
|
||||
if "PoptrackerVersionCheck" in self.kh2slotdata:
|
||||
if self.kh2slotdata["PoptrackerVersionCheck"] > 4.2 and self.kh2_read_byte(self.Save + 0x3607) != 1: # telling the goa they are on version 4.3
|
||||
if self.kh2slotdata["PoptrackerVersionCheck"] > 4.2 and self.kh2_read_byte(
|
||||
self.Save + 0x3607) != 1: # telling the goa they are on version 4.3
|
||||
self.kh2_write_byte(self.Save + 0x3607, 1)
|
||||
|
||||
except Exception as e:
|
||||
@@ -806,10 +813,58 @@ class KH2Context(CommonContext):
|
||||
logger.info(e)
|
||||
logger.info("line 840")
|
||||
|
||||
def get_addresses(self):
|
||||
if not self.kh2connected and self.kh2 is not None:
|
||||
if self.kh2_game_version is None:
|
||||
# current verions is .10 then runs the get from github stuff
|
||||
if self.kh2_read_string(0x9A98B0, 4) == "KH2J":
|
||||
self.kh2_game_version = "STEAM"
|
||||
self.Now = 0x0717008
|
||||
self.Save = 0x09A98B0
|
||||
self.Slot1 = 0x2A23598
|
||||
self.Journal = 0x7434E0
|
||||
self.Shop = 0x7435D0
|
||||
elif self.kh2_read_string(0x9A9330, 4) == "KH2J":
|
||||
self.kh2_game_version = "EGS"
|
||||
else:
|
||||
if self.game_communication_path:
|
||||
logger.info("Checking with most up to date addresses from the addresses json.")
|
||||
#if mem addresses file is found then check version and if old get new one
|
||||
kh2memaddresses_path = os.path.join(self.game_communication_path, "kh2memaddresses.json")
|
||||
if not os.path.exists(kh2memaddresses_path):
|
||||
logger.info("File is not found. Downloading json with memory addresses. This might take a moment")
|
||||
mem_resp = requests.get("https://raw.githubusercontent.com/JaredWeakStrike/KH2APMemoryValues/master/kh2memaddresses.json")
|
||||
if mem_resp.status_code == 200:
|
||||
self.mem_json = json.loads(mem_resp.content)
|
||||
with open(kh2memaddresses_path, 'w') as f:
|
||||
f.write(json.dumps(self.mem_json, indent=4))
|
||||
else:
|
||||
with open(kh2memaddresses_path) as f:
|
||||
self.mem_json = json.load(f)
|
||||
if self.mem_json:
|
||||
for key in self.mem_json.keys():
|
||||
if self.kh2_read_string(int(self.mem_json[key]["GameVersionCheck"], 0), 4) == "KH2J":
|
||||
self.Now = int(self.mem_json[key]["Now"], 0)
|
||||
self.Save = int(self.mem_json[key]["Save"], 0)
|
||||
self.Slot1 = int(self.mem_json[key]["Slot1"], 0)
|
||||
self.Journal = int(self.mem_json[key]["Journal"], 0)
|
||||
self.Shop = int(self.mem_json[key]["Shop"], 0)
|
||||
self.kh2_game_version = key
|
||||
|
||||
if self.kh2_game_version is not None:
|
||||
logger.info(f"You are now auto-tracking {self.kh2_game_version}")
|
||||
self.kh2connected = True
|
||||
else:
|
||||
logger.info("Your game version does not match what the client requires. Check in the "
|
||||
"kingdom-hearts-2-final-mix channel for more information on correcting the game "
|
||||
"version.")
|
||||
self.kh2connected = False
|
||||
|
||||
|
||||
def finishedGame(ctx: KH2Context):
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if not ctx.final_xemnas and ctx.kh2_read_byte(ctx.Save + all_world_locations[LocationName.FinalXemnas].addrObtained) \
|
||||
if not ctx.final_xemnas and ctx.kh2_read_byte(
|
||||
ctx.Save + all_world_locations[LocationName.FinalXemnas].addrObtained) \
|
||||
& 0x1 << all_world_locations[LocationName.FinalXemnas].bitIndex > 0:
|
||||
ctx.final_xemnas = True
|
||||
# three proofs
|
||||
@@ -843,7 +898,8 @@ def finishedGame(ctx: KH2Context):
|
||||
for boss in ctx.kh2slotdata["hitlist"]:
|
||||
if boss in locations:
|
||||
ctx.hitlist_bounties += 1
|
||||
if ctx.hitlist_bounties >= ctx.kh2slotdata["BountyRequired"] or ctx.kh2_seed_save_cache["AmountInvo"]["Amount"]["Bounty"] >= ctx.kh2slotdata["BountyRequired"]:
|
||||
if ctx.hitlist_bounties >= ctx.kh2slotdata["BountyRequired"] or ctx.kh2_seed_save_cache["AmountInvo"]["Amount"][
|
||||
"Bounty"] >= ctx.kh2slotdata["BountyRequired"]:
|
||||
if ctx.kh2_read_byte(ctx.Save + 0x36B3) < 1:
|
||||
ctx.kh2_write_byte(ctx.Save + 0x36B2, 1)
|
||||
ctx.kh2_write_byte(ctx.Save + 0x36B3, 1)
|
||||
@@ -894,24 +950,7 @@ async def kh2_watcher(ctx: KH2Context):
|
||||
while not ctx.kh2connected and ctx.serverconneced:
|
||||
await asyncio.sleep(15)
|
||||
ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
if ctx.kh2 is not None:
|
||||
if ctx.kh2_game_version is None:
|
||||
if ctx.kh2_read_string(0x09A9830, 4) == "KH2J":
|
||||
ctx.kh2_game_version = "STEAM"
|
||||
ctx.Now = 0x0717008
|
||||
ctx.Save = 0x09A9830
|
||||
ctx.Slot1 = 0x2A23518
|
||||
ctx.Journal = 0x7434E0
|
||||
ctx.Shop = 0x7435D0
|
||||
|
||||
elif ctx.kh2_read_string(0x09A92F0, 4) == "KH2J":
|
||||
ctx.kh2_game_version = "EGS"
|
||||
else:
|
||||
ctx.kh2_game_version = None
|
||||
logger.info("Your game version is out of date. Please update your game via The Epic Games Store or Steam.")
|
||||
if ctx.kh2_game_version is not None:
|
||||
logger.info(f"You are now auto-tracking {ctx.kh2_game_version}")
|
||||
ctx.kh2connected = True
|
||||
ctx.get_addresses()
|
||||
except Exception as e:
|
||||
if ctx.kh2connected:
|
||||
ctx.kh2connected = False
|
||||
|
||||
@@ -368,6 +368,37 @@ def patch_kh2(self, output_directory):
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'msg/us/he.bar',
|
||||
'multi': [
|
||||
{
|
||||
'name': 'msg/fr/he.bar'
|
||||
},
|
||||
{
|
||||
'name': 'msg/gr/he.bar'
|
||||
},
|
||||
{
|
||||
'name': 'msg/it/he.bar'
|
||||
},
|
||||
{
|
||||
'name': 'msg/sp/he.bar'
|
||||
}
|
||||
],
|
||||
'method': 'binarc',
|
||||
'source': [
|
||||
{
|
||||
'name': 'he',
|
||||
'type': 'list',
|
||||
'method': 'kh2msg',
|
||||
'source': [
|
||||
{
|
||||
'name': 'he.yml',
|
||||
'language': 'en'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
'title': 'Randomizer Seed'
|
||||
}
|
||||
@@ -411,6 +442,34 @@ def patch_kh2(self, output_directory):
|
||||
'en': f"Your Level Depth is {self.options.LevelDepth.current_option_name}"
|
||||
}
|
||||
]
|
||||
self.fight_and_form_text = [
|
||||
{
|
||||
'id': 15121, # poster name
|
||||
'en': f"Game Options"
|
||||
},
|
||||
{
|
||||
'id': 15122,
|
||||
'en': f"Fight Logic is {self.options.FightLogic.current_option_name}\n"
|
||||
f"Auto Form Logic is {self.options.AutoFormLogic.current_option_name}\n"
|
||||
f"Final Form Logic is {self.options.FinalFormLogic.current_option_name}"
|
||||
}
|
||||
|
||||
]
|
||||
self.cups_text = [
|
||||
{
|
||||
'id': 4043,
|
||||
'en': f"CupsToggle: {self.options.Cups.current_option_name}"
|
||||
},
|
||||
{
|
||||
'id': 4044,
|
||||
'en': f"CupsToggle: {self.options.Cups.current_option_name}"
|
||||
},
|
||||
{
|
||||
'id': 4045,
|
||||
'en': f"CupsToggle: {self.options.Cups.current_option_name}"
|
||||
},
|
||||
]
|
||||
|
||||
mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)
|
||||
|
||||
self.mod_yml["title"] = f"Randomizer Seed {mod_name}"
|
||||
@@ -423,7 +482,8 @@ def patch_kh2(self, output_directory):
|
||||
"FmlvList.yml": yaml.dump(self.formattedFmlv, line_break="\n"),
|
||||
"mod.yml": yaml.dump(self.mod_yml, line_break="\n"),
|
||||
"po.yml": yaml.dump(self.pooh_text, line_break="\n"),
|
||||
"sys.yml": yaml.dump(self.level_depth_text, line_break="\n"),
|
||||
"sys.yml": yaml.dump(self.level_depth_text + self.fight_and_form_text, line_break="\n"),
|
||||
"he.yml": yaml.dump(self.cups_text, line_break="\n")
|
||||
}
|
||||
|
||||
mod = KH2Container(openkhmod, mod_dir, output_directory, self.player,
|
||||
|
||||
@@ -540,7 +540,7 @@ KH2REGIONS: typing.Dict[str, typing.List[str]] = {
|
||||
LocationName.SephirothFenrir,
|
||||
LocationName.SephiEventLocation
|
||||
],
|
||||
RegionName.CoR: [
|
||||
RegionName.CoR: [ #todo: make logic for getting these checks.
|
||||
LocationName.CoRDepthsAPBoost,
|
||||
LocationName.CoRDepthsPowerCrystal,
|
||||
LocationName.CoRDepthsFrostCrystal,
|
||||
@@ -1032,99 +1032,99 @@ def connect_regions(self):
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
# connecting every first visit to the GoA
|
||||
KH2RegionConnections: typing.Dict[str, typing.Set[str]] = {
|
||||
"Menu": {RegionName.GoA},
|
||||
RegionName.GoA: {RegionName.Sp, RegionName.Pr, RegionName.Tt, RegionName.Oc, RegionName.Ht,
|
||||
KH2RegionConnections: typing.Dict[str, typing.Tuple[str]] = {
|
||||
"Menu": (RegionName.GoA,),
|
||||
RegionName.GoA: (RegionName.Sp, RegionName.Pr, RegionName.Tt, RegionName.Oc, RegionName.Ht,
|
||||
RegionName.LoD,
|
||||
RegionName.Twtnw, RegionName.Bc, RegionName.Ag, RegionName.Pl, RegionName.Hb,
|
||||
RegionName.Dc, RegionName.Stt,
|
||||
RegionName.Ha1, RegionName.Keyblade, RegionName.LevelsVS1,
|
||||
RegionName.Valor, RegionName.Wisdom, RegionName.Limit, RegionName.Master,
|
||||
RegionName.Final, RegionName.Summon, RegionName.AtlanticaSongOne},
|
||||
RegionName.LoD: {RegionName.ShanYu},
|
||||
RegionName.ShanYu: {RegionName.LoD2},
|
||||
RegionName.LoD2: {RegionName.AnsemRiku},
|
||||
RegionName.AnsemRiku: {RegionName.StormRider},
|
||||
RegionName.StormRider: {RegionName.DataXigbar},
|
||||
RegionName.Ag: {RegionName.TwinLords},
|
||||
RegionName.TwinLords: {RegionName.Ag2},
|
||||
RegionName.Ag2: {RegionName.GenieJafar},
|
||||
RegionName.GenieJafar: {RegionName.DataLexaeus},
|
||||
RegionName.Dc: {RegionName.Tr},
|
||||
RegionName.Tr: {RegionName.OldPete},
|
||||
RegionName.OldPete: {RegionName.FuturePete},
|
||||
RegionName.FuturePete: {RegionName.Terra, RegionName.DataMarluxia},
|
||||
RegionName.Ha1: {RegionName.Ha2},
|
||||
RegionName.Ha2: {RegionName.Ha3},
|
||||
RegionName.Ha3: {RegionName.Ha4},
|
||||
RegionName.Ha4: {RegionName.Ha5},
|
||||
RegionName.Ha5: {RegionName.Ha6},
|
||||
RegionName.Pr: {RegionName.Barbosa},
|
||||
RegionName.Barbosa: {RegionName.Pr2},
|
||||
RegionName.Pr2: {RegionName.GrimReaper1},
|
||||
RegionName.GrimReaper1: {RegionName.GrimReaper2},
|
||||
RegionName.GrimReaper2: {RegionName.DataLuxord},
|
||||
RegionName.Oc: {RegionName.Cerberus},
|
||||
RegionName.Cerberus: {RegionName.OlympusPete},
|
||||
RegionName.OlympusPete: {RegionName.Hydra},
|
||||
RegionName.Hydra: {RegionName.OcPainAndPanicCup, RegionName.OcCerberusCup, RegionName.Oc2},
|
||||
RegionName.Oc2: {RegionName.Hades},
|
||||
RegionName.Hades: {RegionName.Oc2TitanCup, RegionName.Oc2GofCup, RegionName.DataZexion},
|
||||
RegionName.Oc2GofCup: {RegionName.HadesCups},
|
||||
RegionName.Bc: {RegionName.Thresholder},
|
||||
RegionName.Thresholder: {RegionName.Beast},
|
||||
RegionName.Beast: {RegionName.DarkThorn},
|
||||
RegionName.DarkThorn: {RegionName.Bc2},
|
||||
RegionName.Bc2: {RegionName.Xaldin},
|
||||
RegionName.Xaldin: {RegionName.DataXaldin},
|
||||
RegionName.Sp: {RegionName.HostileProgram},
|
||||
RegionName.HostileProgram: {RegionName.Sp2},
|
||||
RegionName.Sp2: {RegionName.Mcp},
|
||||
RegionName.Mcp: {RegionName.DataLarxene},
|
||||
RegionName.Ht: {RegionName.PrisonKeeper},
|
||||
RegionName.PrisonKeeper: {RegionName.OogieBoogie},
|
||||
RegionName.OogieBoogie: {RegionName.Ht2},
|
||||
RegionName.Ht2: {RegionName.Experiment},
|
||||
RegionName.Experiment: {RegionName.DataVexen},
|
||||
RegionName.Hb: {RegionName.Hb2},
|
||||
RegionName.Hb2: {RegionName.CoR, RegionName.HBDemyx},
|
||||
RegionName.HBDemyx: {RegionName.ThousandHeartless},
|
||||
RegionName.ThousandHeartless: {RegionName.Mushroom13, RegionName.DataDemyx, RegionName.Sephi},
|
||||
RegionName.CoR: {RegionName.CorFirstFight},
|
||||
RegionName.CorFirstFight: {RegionName.CorSecondFight},
|
||||
RegionName.CorSecondFight: {RegionName.Transport},
|
||||
RegionName.Pl: {RegionName.Scar},
|
||||
RegionName.Scar: {RegionName.Pl2},
|
||||
RegionName.Pl2: {RegionName.GroundShaker},
|
||||
RegionName.GroundShaker: {RegionName.DataSaix},
|
||||
RegionName.Stt: {RegionName.TwilightThorn},
|
||||
RegionName.TwilightThorn: {RegionName.Axel1},
|
||||
RegionName.Axel1: {RegionName.Axel2},
|
||||
RegionName.Axel2: {RegionName.DataRoxas},
|
||||
RegionName.Tt: {RegionName.Tt2},
|
||||
RegionName.Tt2: {RegionName.Tt3},
|
||||
RegionName.Tt3: {RegionName.DataAxel},
|
||||
RegionName.Twtnw: {RegionName.Roxas},
|
||||
RegionName.Roxas: {RegionName.Xigbar},
|
||||
RegionName.Xigbar: {RegionName.Luxord},
|
||||
RegionName.Luxord: {RegionName.Saix},
|
||||
RegionName.Saix: {RegionName.Twtnw2},
|
||||
RegionName.Twtnw2: {RegionName.Xemnas},
|
||||
RegionName.Xemnas: {RegionName.ArmoredXemnas, RegionName.DataXemnas},
|
||||
RegionName.ArmoredXemnas: {RegionName.ArmoredXemnas2},
|
||||
RegionName.ArmoredXemnas2: {RegionName.FinalXemnas},
|
||||
RegionName.LevelsVS1: {RegionName.LevelsVS3},
|
||||
RegionName.LevelsVS3: {RegionName.LevelsVS6},
|
||||
RegionName.LevelsVS6: {RegionName.LevelsVS9},
|
||||
RegionName.LevelsVS9: {RegionName.LevelsVS12},
|
||||
RegionName.LevelsVS12: {RegionName.LevelsVS15},
|
||||
RegionName.LevelsVS15: {RegionName.LevelsVS18},
|
||||
RegionName.LevelsVS18: {RegionName.LevelsVS21},
|
||||
RegionName.LevelsVS21: {RegionName.LevelsVS24},
|
||||
RegionName.LevelsVS24: {RegionName.LevelsVS26},
|
||||
RegionName.AtlanticaSongOne: {RegionName.AtlanticaSongTwo},
|
||||
RegionName.AtlanticaSongTwo: {RegionName.AtlanticaSongThree},
|
||||
RegionName.AtlanticaSongThree: {RegionName.AtlanticaSongFour},
|
||||
RegionName.Final, RegionName.Summon, RegionName.AtlanticaSongOne),
|
||||
RegionName.LoD: (RegionName.ShanYu,),
|
||||
RegionName.ShanYu: (RegionName.LoD2,),
|
||||
RegionName.LoD2: (RegionName.AnsemRiku,),
|
||||
RegionName.AnsemRiku: (RegionName.StormRider,),
|
||||
RegionName.StormRider: (RegionName.DataXigbar,),
|
||||
RegionName.Ag: (RegionName.TwinLords,),
|
||||
RegionName.TwinLords: (RegionName.Ag2,),
|
||||
RegionName.Ag2: (RegionName.GenieJafar,),
|
||||
RegionName.GenieJafar: (RegionName.DataLexaeus,),
|
||||
RegionName.Dc: (RegionName.Tr,),
|
||||
RegionName.Tr: (RegionName.OldPete,),
|
||||
RegionName.OldPete: (RegionName.FuturePete,),
|
||||
RegionName.FuturePete: (RegionName.Terra, RegionName.DataMarluxia),
|
||||
RegionName.Ha1: (RegionName.Ha2,),
|
||||
RegionName.Ha2: (RegionName.Ha3,),
|
||||
RegionName.Ha3: (RegionName.Ha4,),
|
||||
RegionName.Ha4: (RegionName.Ha5,),
|
||||
RegionName.Ha5: (RegionName.Ha6,),
|
||||
RegionName.Pr: (RegionName.Barbosa,),
|
||||
RegionName.Barbosa: (RegionName.Pr2,),
|
||||
RegionName.Pr2: (RegionName.GrimReaper1,),
|
||||
RegionName.GrimReaper1: (RegionName.GrimReaper2,),
|
||||
RegionName.GrimReaper2: (RegionName.DataLuxord,),
|
||||
RegionName.Oc: (RegionName.Cerberus,),
|
||||
RegionName.Cerberus: (RegionName.OlympusPete,),
|
||||
RegionName.OlympusPete: (RegionName.Hydra,),
|
||||
RegionName.Hydra: (RegionName.OcPainAndPanicCup, RegionName.OcCerberusCup, RegionName.Oc2),
|
||||
RegionName.Oc2: (RegionName.Hades,),
|
||||
RegionName.Hades: (RegionName.Oc2TitanCup, RegionName.Oc2GofCup, RegionName.DataZexion),
|
||||
RegionName.Oc2GofCup: (RegionName.HadesCups,),
|
||||
RegionName.Bc: (RegionName.Thresholder,),
|
||||
RegionName.Thresholder: (RegionName.Beast,),
|
||||
RegionName.Beast: (RegionName.DarkThorn,),
|
||||
RegionName.DarkThorn: (RegionName.Bc2,),
|
||||
RegionName.Bc2: (RegionName.Xaldin,),
|
||||
RegionName.Xaldin: (RegionName.DataXaldin,),
|
||||
RegionName.Sp: (RegionName.HostileProgram,),
|
||||
RegionName.HostileProgram: (RegionName.Sp2,),
|
||||
RegionName.Sp2: (RegionName.Mcp,),
|
||||
RegionName.Mcp: (RegionName.DataLarxene,),
|
||||
RegionName.Ht: (RegionName.PrisonKeeper,),
|
||||
RegionName.PrisonKeeper: (RegionName.OogieBoogie,),
|
||||
RegionName.OogieBoogie: (RegionName.Ht2,),
|
||||
RegionName.Ht2: (RegionName.Experiment,),
|
||||
RegionName.Experiment: (RegionName.DataVexen,),
|
||||
RegionName.Hb: (RegionName.Hb2,),
|
||||
RegionName.Hb2: (RegionName.CoR, RegionName.HBDemyx),
|
||||
RegionName.HBDemyx: (RegionName.ThousandHeartless,),
|
||||
RegionName.ThousandHeartless: (RegionName.Mushroom13, RegionName.DataDemyx, RegionName.Sephi),
|
||||
RegionName.CoR: (RegionName.CorFirstFight,),
|
||||
RegionName.CorFirstFight: (RegionName.CorSecondFight,),
|
||||
RegionName.CorSecondFight: (RegionName.Transport,),
|
||||
RegionName.Pl: (RegionName.Scar,),
|
||||
RegionName.Scar: (RegionName.Pl2,),
|
||||
RegionName.Pl2: (RegionName.GroundShaker,),
|
||||
RegionName.GroundShaker: (RegionName.DataSaix,),
|
||||
RegionName.Stt: (RegionName.TwilightThorn,),
|
||||
RegionName.TwilightThorn: (RegionName.Axel1,),
|
||||
RegionName.Axel1: (RegionName.Axel2,),
|
||||
RegionName.Axel2: (RegionName.DataRoxas,),
|
||||
RegionName.Tt: (RegionName.Tt2,),
|
||||
RegionName.Tt2: (RegionName.Tt3,),
|
||||
RegionName.Tt3: (RegionName.DataAxel,),
|
||||
RegionName.Twtnw: (RegionName.Roxas,),
|
||||
RegionName.Roxas: (RegionName.Xigbar,),
|
||||
RegionName.Xigbar: (RegionName.Luxord,),
|
||||
RegionName.Luxord: (RegionName.Saix,),
|
||||
RegionName.Saix: (RegionName.Twtnw2,),
|
||||
RegionName.Twtnw2: (RegionName.Xemnas,),
|
||||
RegionName.Xemnas: (RegionName.ArmoredXemnas, RegionName.DataXemnas),
|
||||
RegionName.ArmoredXemnas: (RegionName.ArmoredXemnas2,),
|
||||
RegionName.ArmoredXemnas2: (RegionName.FinalXemnas,),
|
||||
RegionName.LevelsVS1: (RegionName.LevelsVS3,),
|
||||
RegionName.LevelsVS3: (RegionName.LevelsVS6,),
|
||||
RegionName.LevelsVS6: (RegionName.LevelsVS9,),
|
||||
RegionName.LevelsVS9: (RegionName.LevelsVS12,),
|
||||
RegionName.LevelsVS12: (RegionName.LevelsVS15,),
|
||||
RegionName.LevelsVS15: (RegionName.LevelsVS18,),
|
||||
RegionName.LevelsVS18: (RegionName.LevelsVS21,),
|
||||
RegionName.LevelsVS21: (RegionName.LevelsVS24,),
|
||||
RegionName.LevelsVS24: (RegionName.LevelsVS26,),
|
||||
RegionName.AtlanticaSongOne: (RegionName.AtlanticaSongTwo,),
|
||||
RegionName.AtlanticaSongTwo: (RegionName.AtlanticaSongThree,),
|
||||
RegionName.AtlanticaSongThree: (RegionName.AtlanticaSongFour,),
|
||||
}
|
||||
|
||||
for source, target in KH2RegionConnections.items():
|
||||
|
||||
@@ -194,8 +194,8 @@ class KH2WorldRules(KH2Rules):
|
||||
RegionName.Oc: lambda state: self.oc_unlocked(state, 1),
|
||||
RegionName.Oc2: lambda state: self.oc_unlocked(state, 2),
|
||||
|
||||
#twtnw1 is actually the roxas fight region thus roxas requires 1 way to the dawn
|
||||
RegionName.Twtnw2: lambda state: self.twtnw_unlocked(state, 2),
|
||||
# These will be swapped and First Visit lock for twtnw is in development.
|
||||
# RegionName.Twtnw1: lambda state: self.lod_unlocked(state, 2),
|
||||
|
||||
RegionName.Ht: lambda state: self.ht_unlocked(state, 1),
|
||||
@@ -263,7 +263,10 @@ class KH2WorldRules(KH2Rules):
|
||||
|
||||
weapon_region = self.multiworld.get_region(RegionName.Keyblade, self.player)
|
||||
for location in weapon_region.locations:
|
||||
add_rule(location, lambda state: state.has(exclusion_table["WeaponSlots"][location.name], self.player))
|
||||
if location.name in exclusion_table["WeaponSlots"]: # shop items and starting items are not in this list
|
||||
exclusion_item = exclusion_table["WeaponSlots"][location.name]
|
||||
add_rule(location, lambda state, e_item=exclusion_item: state.has(e_item, self.player))
|
||||
|
||||
if location.name in Goofy_Checks:
|
||||
add_item_rule(location, lambda item: item.player == self.player and item.name in GoofyAbility_Table.keys())
|
||||
elif location.name in Donald_Checks:
|
||||
@@ -919,8 +922,8 @@ class KH2FightRules(KH2Rules):
|
||||
# normal:both gap closers,limit 5,reflera,guard,both 2 ground finishers,3 dodge roll,finishing plus
|
||||
# hard:1 gap closers,reflect, guard,both 1 ground finisher,2 dodge roll,finishing plus
|
||||
sephiroth_rules = {
|
||||
"easy": self.kh2_dict_count(easy_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state) and self.kh2_list_any_sum([donald_limit], state) >= 1,
|
||||
"normal": self.kh2_dict_count(normal_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state) and self.kh2_list_any_sum([donald_limit, gap_closer], state) >= 2,
|
||||
"easy": self.kh2_dict_count(easy_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state),
|
||||
"normal": self.kh2_dict_count(normal_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state) and self.kh2_list_any_sum([gap_closer], state) >= 1,
|
||||
"hard": self.kh2_dict_count(hard_sephiroth_tools, state) and self.kh2_list_any_sum([gap_closer, ground_finisher], state) >= 2,
|
||||
}
|
||||
return sephiroth_rules[self.fight_logic]
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List
|
||||
|
||||
from BaseClasses import Tutorial, ItemClassification
|
||||
from Fill import fast_fill
|
||||
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
|
||||
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from .Items import *
|
||||
from .Locations import *
|
||||
@@ -17,7 +17,7 @@ from .Subclasses import KH2Item
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="KH2Client")
|
||||
launch_component(launch, name="KH2Client")
|
||||
|
||||
|
||||
components.append(Component("KH2 Client", "KH2Client", func=launch_client, component_type=Type.CLIENT))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
Kingdom Hearts II Final Mix from the [Epic Games Store](https://store.epicgames.com/en-US/discover/kingdom-hearts) or [Steam](https://store.steampowered.com/app/2552430/KINGDOM_HEARTS_HD_1525_ReMIX/)
|
||||
|
||||
- Follow this Guide to set up these requirements [KH2Rando.com](https://tommadness.github.io/KH2Randomizer/setup/Panacea-ModLoader/)
|
||||
1. Version 3.4.0 or greater OpenKH Mod Manager with Panacea
|
||||
1. Version 25.01.26.0 or greater OpenKH Mod Manager with Panacea
|
||||
2. Lua Backend from the OpenKH Mod Manager
|
||||
3. Install the mod `KH2FM-Mods-Num/GoA-ROM-Edition` using OpenKH Mod Manager
|
||||
- Needed for Archipelago
|
||||
@@ -52,7 +52,7 @@ After Installing the seed click "Mod Loader -> Build/Build and Run". Every slot
|
||||
|
||||
<h2 style="text-transform:none";>What the Mod Manager Should Look Like.</h2>
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
<h2 style="text-transform:none";>Using the KH2 Client</h2>
|
||||
|
||||
@@ -1,92 +1,266 @@
|
||||
import json
|
||||
roomAddress = 0xFFF6
|
||||
mapIdAddress = 0xFFF7
|
||||
indoorFlagAddress = 0xDBA5
|
||||
entranceRoomOffset = 0xD800
|
||||
screenCoordAddress = 0xFFFA
|
||||
import typing
|
||||
from websockets import WebSocketServerProtocol
|
||||
|
||||
mapMap = {
|
||||
0x00: 0x01,
|
||||
0x01: 0x01,
|
||||
0x02: 0x01,
|
||||
0x03: 0x01,
|
||||
0x04: 0x01,
|
||||
0x05: 0x01,
|
||||
0x06: 0x02,
|
||||
0x07: 0x02,
|
||||
0x08: 0x02,
|
||||
0x09: 0x02,
|
||||
0x0A: 0x02,
|
||||
0x0B: 0x02,
|
||||
0x0C: 0x02,
|
||||
0x0D: 0x02,
|
||||
0x0E: 0x02,
|
||||
0x0F: 0x02,
|
||||
0x10: 0x02,
|
||||
0x11: 0x02,
|
||||
0x12: 0x02,
|
||||
0x13: 0x02,
|
||||
0x14: 0x02,
|
||||
0x15: 0x02,
|
||||
0x16: 0x02,
|
||||
0x17: 0x02,
|
||||
0x18: 0x02,
|
||||
0x19: 0x02,
|
||||
0x1D: 0x01,
|
||||
0x1E: 0x01,
|
||||
0x1F: 0x01,
|
||||
0xFF: 0x03,
|
||||
}
|
||||
from . import TrackerConsts as Consts
|
||||
from .TrackerConsts import EntranceCoord
|
||||
from .LADXR.entranceInfo import ENTRANCE_INFO
|
||||
|
||||
class Entrance:
|
||||
outdoor_room: int
|
||||
indoor_map: int
|
||||
indoor_address: int
|
||||
name: str
|
||||
other_side_name: str = None
|
||||
changed: bool = False
|
||||
known_to_server: bool = False
|
||||
|
||||
def __init__(self, outdoor: int, indoor: int, name: str, indoor_address: int=None):
|
||||
self.outdoor_room = outdoor
|
||||
self.indoor_map = indoor
|
||||
self.indoor_address = indoor_address
|
||||
self.name = name
|
||||
|
||||
def map(self, other_side: str, known_to_server: bool = False):
|
||||
if other_side != self.other_side_name:
|
||||
self.changed = True
|
||||
self.known_to_server = known_to_server
|
||||
|
||||
self.other_side_name = other_side
|
||||
|
||||
class GpsTracker:
|
||||
room = None
|
||||
location_changed = False
|
||||
screenX = 0
|
||||
screenY = 0
|
||||
indoors = None
|
||||
room: int = None
|
||||
last_room: int = None
|
||||
last_different_room: int = None
|
||||
room_same_for: int = 0
|
||||
room_changed: bool = False
|
||||
screen_x: int = 0
|
||||
screen_y: int = 0
|
||||
spawn_x: int = 0
|
||||
spawn_y: int = 0
|
||||
indoors: int = None
|
||||
indoors_changed: bool = False
|
||||
spawn_map: int = None
|
||||
spawn_room: int = None
|
||||
spawn_changed: bool = False
|
||||
spawn_same_for: int = 0
|
||||
entrance_mapping: typing.Dict[str, str] = None
|
||||
entrances_by_name: typing.Dict[str, Entrance] = {}
|
||||
needs_found_entrances: bool = False
|
||||
needs_slot_data: bool = True
|
||||
|
||||
def __init__(self, gameboy) -> None:
|
||||
self.gameboy = gameboy
|
||||
|
||||
async def read_byte(self, b):
|
||||
return (await self.gameboy.async_read_memory(b))[0]
|
||||
self.gameboy.set_location_range(
|
||||
Consts.link_motion_state,
|
||||
Consts.transition_sequence - Consts.link_motion_state + 1,
|
||||
[Consts.transition_state]
|
||||
)
|
||||
|
||||
async def read_byte(self, b: int):
|
||||
return (await self.gameboy.read_memory_cache([b]))[b]
|
||||
|
||||
def load_slot_data(self, slot_data: typing.Dict[str, typing.Any]):
|
||||
if 'entrance_mapping' not in slot_data:
|
||||
return
|
||||
|
||||
# We need to know how entrances were mapped at generation before we can autotrack them
|
||||
self.entrance_mapping = {}
|
||||
|
||||
# Convert to upstream's newer format
|
||||
for outside, inside in slot_data['entrance_mapping'].items():
|
||||
new_inside = f"{inside}:inside"
|
||||
self.entrance_mapping[outside] = new_inside
|
||||
self.entrance_mapping[new_inside] = outside
|
||||
|
||||
self.entrances_by_name = {}
|
||||
|
||||
for name, info in ENTRANCE_INFO.items():
|
||||
alternate_address = (
|
||||
Consts.entrance_address_overrides[info.target]
|
||||
if info.target in Consts.entrance_address_overrides
|
||||
else None
|
||||
)
|
||||
|
||||
entrance = Entrance(info.room, info.target, name, alternate_address)
|
||||
self.entrances_by_name[name] = entrance
|
||||
|
||||
inside_entrance = Entrance(info.target, info.room, f"{name}:inside", alternate_address)
|
||||
self.entrances_by_name[f"{name}:inside"] = inside_entrance
|
||||
|
||||
self.needs_slot_data = False
|
||||
self.needs_found_entrances = True
|
||||
|
||||
async def read_location(self):
|
||||
indoors = await self.read_byte(indoorFlagAddress)
|
||||
# We need to wait for screen transitions to finish
|
||||
transition_state = await self.read_byte(Consts.transition_state)
|
||||
transition_target_x = await self.read_byte(Consts.transition_target_x)
|
||||
transition_target_y = await self.read_byte(Consts.transition_target_y)
|
||||
transition_scroll_x = await self.read_byte(Consts.transition_scroll_x)
|
||||
transition_scroll_y = await self.read_byte(Consts.transition_scroll_y)
|
||||
transition_sequence = await self.read_byte(Consts.transition_sequence)
|
||||
motion_state = await self.read_byte(Consts.link_motion_state)
|
||||
if (transition_state != 0
|
||||
or transition_target_x != transition_scroll_x
|
||||
or transition_target_y != transition_scroll_y
|
||||
or transition_sequence != 0x04):
|
||||
return
|
||||
|
||||
indoors = await self.read_byte(Consts.indoor_flag)
|
||||
|
||||
if indoors != self.indoors and self.indoors != None:
|
||||
self.indoorsChanged = True
|
||||
|
||||
self.indoors_changed = True
|
||||
|
||||
self.indoors = indoors
|
||||
|
||||
mapId = await self.read_byte(mapIdAddress)
|
||||
if mapId not in mapMap:
|
||||
print(f'Unknown map ID {hex(mapId)}')
|
||||
# We use the spawn point to know which entrance was most recently entered
|
||||
spawn_map = await self.read_byte(Consts.spawn_map)
|
||||
map_digit = Consts.map_map[spawn_map] << 8 if self.spawn_map else 0
|
||||
spawn_room = await self.read_byte(Consts.spawn_room) + map_digit
|
||||
spawn_x = await self.read_byte(Consts.spawn_x)
|
||||
spawn_y = await self.read_byte(Consts.spawn_y)
|
||||
|
||||
# The spawn point needs to be settled before we can trust location data
|
||||
if ((spawn_room != self.spawn_room and self.spawn_room != None)
|
||||
or (spawn_map != self.spawn_map and self.spawn_map != None)
|
||||
or (spawn_x != self.spawn_x and self.spawn_x != None)
|
||||
or (spawn_y != self.spawn_y and self.spawn_y != None)):
|
||||
self.spawn_changed = True
|
||||
self.spawn_same_for = 0
|
||||
else:
|
||||
self.spawn_same_for += 1
|
||||
|
||||
self.spawn_map = spawn_map
|
||||
self.spawn_room = spawn_room
|
||||
self.spawn_x = spawn_x
|
||||
self.spawn_y = spawn_y
|
||||
|
||||
# Spawn point is preferred, but doesn't work for the sidescroller entrances
|
||||
# Those can be addressed by keeping track of which room we're in
|
||||
# Also used to validate that we came from the right room for what the spawn point is mapped to
|
||||
map_id = await self.read_byte(Consts.map_id)
|
||||
if map_id not in Consts.map_map:
|
||||
print(f'Unknown map ID {hex(map_id)}')
|
||||
return
|
||||
|
||||
mapDigit = mapMap[mapId] << 8 if indoors else 0
|
||||
last_room = self.room
|
||||
self.room = await self.read_byte(roomAddress) + mapDigit
|
||||
map_digit = Consts.map_map[map_id] << 8 if indoors else 0
|
||||
self.last_room = self.room
|
||||
self.room = await self.read_byte(Consts.room) + map_digit
|
||||
|
||||
coords = await self.read_byte(screenCoordAddress)
|
||||
self.screenX = coords & 0x0F
|
||||
self.screenY = (coords & 0xF0) >> 4
|
||||
# Again, the room needs to settle before we can trust location data
|
||||
if self.last_room != self.room:
|
||||
self.room_same_for = 0
|
||||
self.room_changed = True
|
||||
self.last_different_room = self.last_room
|
||||
else:
|
||||
self.room_same_for += 1
|
||||
|
||||
if (self.room != last_room):
|
||||
self.location_changed = True
|
||||
|
||||
last_message = {}
|
||||
async def send_location(self, socket, diff=False):
|
||||
if self.room is None:
|
||||
# Only update Link's location when he's not in the air to avoid weirdness
|
||||
if motion_state in [0, 1]:
|
||||
coords = await self.read_byte(Consts.screen_coord)
|
||||
self.screen_x = coords & 0x0F
|
||||
self.screen_y = (coords & 0xF0) >> 4
|
||||
|
||||
async def read_entrances(self):
|
||||
if not self.last_different_room or not self.entrance_mapping:
|
||||
return
|
||||
|
||||
if self.spawn_changed and self.spawn_same_for > 0 and self.room_same_for > 0:
|
||||
# Use the spawn location, last room, and entrance mapping at generation to map the right entrance
|
||||
# A bit overkill for simple ER, but necessary for upstream's advanced ER
|
||||
spawn_coord = EntranceCoord(None, self.spawn_room, self.spawn_x, self.spawn_y)
|
||||
if str(spawn_coord) in Consts.entrance_lookup:
|
||||
valid_sources = {x.name for x in Consts.entrance_coords if x.room == self.last_different_room}
|
||||
dest_entrance = Consts.entrance_lookup[str(spawn_coord)].name
|
||||
source_entrance = [
|
||||
x for x in self.entrance_mapping
|
||||
if self.entrance_mapping[x] == dest_entrance and x in valid_sources
|
||||
]
|
||||
|
||||
if source_entrance:
|
||||
self.entrances_by_name[source_entrance[0]].map(dest_entrance)
|
||||
|
||||
self.spawn_changed = False
|
||||
elif self.room_changed and self.room_same_for > 0:
|
||||
# Check for the stupid sidescroller rooms that don't set your spawn point
|
||||
if self.last_different_room in Consts.sidescroller_rooms:
|
||||
source_entrance = Consts.sidescroller_rooms[self.last_different_room]
|
||||
if source_entrance in self.entrance_mapping:
|
||||
dest_entrance = self.entrance_mapping[source_entrance]
|
||||
|
||||
expected_room = self.entrances_by_name[dest_entrance].outdoor_room
|
||||
if dest_entrance.endswith(":indoor"):
|
||||
expected_room = self.entrances_by_name[dest_entrance].indoor_map
|
||||
|
||||
if expected_room == self.room:
|
||||
self.entrances_by_name[source_entrance].map(dest_entrance)
|
||||
|
||||
if self.room in Consts.sidescroller_rooms:
|
||||
valid_sources = {x.name for x in Consts.entrance_coords if x.room == self.last_different_room}
|
||||
dest_entrance = Consts.sidescroller_rooms[self.room]
|
||||
source_entrance = [
|
||||
x for x in self.entrance_mapping
|
||||
if self.entrance_mapping[x] == dest_entrance and x in valid_sources
|
||||
]
|
||||
|
||||
if source_entrance:
|
||||
self.entrances_by_name[source_entrance[0]].map(dest_entrance)
|
||||
|
||||
self.room_changed = False
|
||||
|
||||
last_location_message = {}
|
||||
async def send_location(self, socket: WebSocketServerProtocol) -> None:
|
||||
if self.room is None or self.room_same_for < 1:
|
||||
return
|
||||
|
||||
message = {
|
||||
"type":"location",
|
||||
"refresh": True,
|
||||
"version":"1.0",
|
||||
"room": f'0x{self.room:02X}',
|
||||
"x": self.screenX,
|
||||
"y": self.screenY,
|
||||
"x": self.screen_x,
|
||||
"y": self.screen_y,
|
||||
"drawFine": True,
|
||||
}
|
||||
if message != self.last_message:
|
||||
self.last_message = message
|
||||
|
||||
if message != self.last_location_message:
|
||||
self.last_location_message = message
|
||||
await socket.send(json.dumps(message))
|
||||
|
||||
async def send_entrances(self, socket: WebSocketServerProtocol, diff: bool=True) -> typing.Dict[str, str]:
|
||||
if not self.entrance_mapping:
|
||||
return
|
||||
|
||||
new_entrances = [x for x in self.entrances_by_name.values() if x.changed or (not diff and x.other_side_name)]
|
||||
|
||||
if not new_entrances:
|
||||
return
|
||||
|
||||
message = {
|
||||
"type":"entrance",
|
||||
"refresh": True,
|
||||
"diff": True,
|
||||
"entranceMap": {},
|
||||
}
|
||||
|
||||
for entrance in new_entrances:
|
||||
message['entranceMap'][entrance.name] = entrance.other_side_name
|
||||
entrance.changed = False
|
||||
|
||||
await socket.send(json.dumps(message))
|
||||
|
||||
new_to_server = {
|
||||
entrance.name: entrance.other_side_name
|
||||
for entrance in new_entrances
|
||||
if not entrance.known_to_server
|
||||
}
|
||||
|
||||
return new_to_server
|
||||
|
||||
def receive_found_entrances(self, found_entrances: typing.Dict[str, str]):
|
||||
if not found_entrances:
|
||||
return
|
||||
|
||||
for entrance, destination in found_entrances.items():
|
||||
if entrance in self.entrances_by_name:
|
||||
self.entrances_by_name[entrance].map(destination, known_to_server=True)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import json
|
||||
gameStateAddress = 0xDB95
|
||||
validGameStates = {0x0B, 0x0C}
|
||||
gameStateResetThreshold = 0x06
|
||||
|
||||
inventorySlotCount = 16
|
||||
inventoryStartAddress = 0xDB00
|
||||
inventoryEndAddress = inventoryStartAddress + inventorySlotCount
|
||||
|
||||
rupeesHigh = 0xDB5D
|
||||
rupeesLow = 0xDB5E
|
||||
addRupeesHigh = 0xDB8F
|
||||
addRupeesLow = 0xDB90
|
||||
removeRupeesHigh = 0xDB91
|
||||
removeRupeesLow = 0xDB92
|
||||
|
||||
inventoryItemIds = {
|
||||
0x02: 'BOMB',
|
||||
0x05: 'BOW',
|
||||
@@ -98,10 +102,11 @@ dungeonItemOffsets = {
|
||||
'STONE_BEAK{}': 2,
|
||||
'NIGHTMARE_KEY{}': 3,
|
||||
'KEY{}': 4,
|
||||
'UNUSED_KEY{}': 4,
|
||||
}
|
||||
|
||||
class Item:
|
||||
def __init__(self, id, address, threshold=0, mask=None, increaseOnly=False, count=False, max=None):
|
||||
def __init__(self, id, address, threshold=0, mask=None, increaseOnly=False, count=False, max=None, encodedCount=True):
|
||||
self.id = id
|
||||
self.address = address
|
||||
self.threshold = threshold
|
||||
@@ -112,6 +117,7 @@ class Item:
|
||||
self.rawValue = 0
|
||||
self.diff = 0
|
||||
self.max = max
|
||||
self.encodedCount = encodedCount
|
||||
|
||||
def set(self, byte, extra):
|
||||
oldValue = self.value
|
||||
@@ -121,7 +127,7 @@ class Item:
|
||||
|
||||
if not self.count:
|
||||
byte = int(byte > self.threshold)
|
||||
else:
|
||||
elif self.encodedCount:
|
||||
# LADX seems to store one decimal digit per nibble
|
||||
byte = byte - (byte // 16 * 6)
|
||||
|
||||
@@ -165,6 +171,7 @@ class ItemTracker:
|
||||
Item('BOOMERANG', None),
|
||||
Item('TOADSTOOL', None),
|
||||
Item('ROOSTER', None),
|
||||
Item('RUPEE_COUNT', None, count=True, encodedCount=False),
|
||||
Item('SWORD', 0xDB4E, count=True),
|
||||
Item('POWER_BRACELET', 0xDB43, count=True),
|
||||
Item('SHIELD', 0xDB44, count=True),
|
||||
@@ -219,9 +226,9 @@ class ItemTracker:
|
||||
|
||||
self.itemDict = {item.id: item for item in self.items}
|
||||
|
||||
async def readItems(state):
|
||||
extraItems = state.extraItems
|
||||
missingItems = {x for x in state.items if x.address == None}
|
||||
async def readItems(self):
|
||||
extraItems = self.extraItems
|
||||
missingItems = {x for x in self.items if x.address == None and x.id != 'RUPEE_COUNT'}
|
||||
|
||||
# Add keys for opened key doors
|
||||
for i in range(len(dungeonKeyDoors)):
|
||||
@@ -230,16 +237,16 @@ class ItemTracker:
|
||||
|
||||
for address, masks in dungeonKeyDoors[i].items():
|
||||
for mask in masks:
|
||||
value = await state.readRamByte(address) & mask
|
||||
value = await self.readRamByte(address) & mask
|
||||
if value > 0:
|
||||
extraItems[item] += 1
|
||||
|
||||
# Main inventory items
|
||||
for i in range(inventoryStartAddress, inventoryEndAddress):
|
||||
value = await state.readRamByte(i)
|
||||
value = await self.readRamByte(i)
|
||||
|
||||
if value in inventoryItemIds:
|
||||
item = state.itemDict[inventoryItemIds[value]]
|
||||
item = self.itemDict[inventoryItemIds[value]]
|
||||
extra = extraItems[item.id] if item.id in extraItems else 0
|
||||
item.set(1, extra)
|
||||
missingItems.remove(item)
|
||||
@@ -249,9 +256,21 @@ class ItemTracker:
|
||||
item.set(0, extra)
|
||||
|
||||
# All other items
|
||||
for item in [x for x in state.items if x.address]:
|
||||
for item in [x for x in self.items if x.address]:
|
||||
extra = extraItems[item.id] if item.id in extraItems else 0
|
||||
item.set(await state.readRamByte(item.address), extra)
|
||||
item.set(await self.readRamByte(item.address), extra)
|
||||
|
||||
# The current rupee count is BCD, but the add/remove values are not
|
||||
currentRupees = self.calculateRupeeCount(await self.readRamByte(rupeesHigh), await self.readRamByte(rupeesLow))
|
||||
addingRupees = (await self.readRamByte(addRupeesHigh) << 8) + await self.readRamByte(addRupeesLow)
|
||||
removingRupees = (await self.readRamByte(removeRupeesHigh) << 8) + await self.readRamByte(removeRupeesLow)
|
||||
self.itemDict['RUPEE_COUNT'].set(currentRupees + addingRupees - removingRupees, 0)
|
||||
|
||||
def calculateRupeeCount(self, high: int, low: int) -> int:
|
||||
return (high - (high // 16 * 6)) * 100 + (low - (low // 16 * 6))
|
||||
|
||||
def setExtraItem(self, item: str, qty: int) -> None:
|
||||
self.extraItems[item] = qty
|
||||
|
||||
async def sendItems(self, socket, diff=False):
|
||||
if not self.items:
|
||||
@@ -259,7 +278,6 @@ class ItemTracker:
|
||||
message = {
|
||||
"type":"item",
|
||||
"refresh": True,
|
||||
"version":"1.0",
|
||||
"diff": diff,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
@@ -7,23 +7,12 @@ from ..roomEditor import RoomEditor
|
||||
|
||||
|
||||
class StartItem(DroppedKey):
|
||||
# We need to give something here that we can use to progress.
|
||||
# FEATHER
|
||||
OPTIONS = [SWORD, SHIELD, POWER_BRACELET, OCARINA, BOOMERANG, MAGIC_ROD, TAIL_KEY, SHOVEL, HOOKSHOT, PEGASUS_BOOTS, MAGIC_POWDER, BOMB]
|
||||
MULTIWORLD = False
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(0x2A3)
|
||||
self.give_bowwow = False
|
||||
|
||||
def configure(self, options):
|
||||
if options.bowwow != 'normal':
|
||||
# When we have bowwow mode, we pretend to be a sword for logic reasons
|
||||
self.OPTIONS = [SWORD]
|
||||
self.give_bowwow = True
|
||||
if options.randomstartlocation and options.entranceshuffle != 'none':
|
||||
self.OPTIONS.append(FLIPPERS)
|
||||
|
||||
def patch(self, rom, option, *, multiworld=None):
|
||||
assert multiworld is None
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class World:
|
||||
|
||||
mabe_village = Location("Mabe Village")
|
||||
Location().add(HeartPiece(0x2A4)).connect(mabe_village, r.bush) # well
|
||||
Location().add(FishingMinigame()).connect(mabe_village, AND(r.bush, COUNT("RUPEES", 20))) # fishing game, heart piece is directly done by the minigame.
|
||||
Location().add(FishingMinigame()).connect(mabe_village, AND(r.can_farm, COUNT("RUPEES", 20))) # fishing game, heart piece is directly done by the minigame.
|
||||
Location().add(Seashell(0x0A3)).connect(mabe_village, r.bush) # bushes below the shop
|
||||
Location().add(Seashell(0x0D2)).connect(mabe_village, PEGASUS_BOOTS) # smash into tree next to lv1
|
||||
Location().add(Song(0x092)).connect(mabe_village, OCARINA) # Marins song
|
||||
@@ -23,7 +23,7 @@ class World:
|
||||
papahl_house.connect(mamasha_trade, TRADING_ITEM_YOSHI_DOLL)
|
||||
|
||||
trendy_shop = Location("Trendy Shop")
|
||||
trendy_shop.connect(Location().add(TradeSequenceItem(0x2A0, TRADING_ITEM_YOSHI_DOLL)), FOUND("RUPEES", 50))
|
||||
trendy_shop.connect(Location().add(TradeSequenceItem(0x2A0, TRADING_ITEM_YOSHI_DOLL)), AND(r.can_farm, FOUND("RUPEES", 50)))
|
||||
outside_trendy = Location()
|
||||
outside_trendy.connect(mabe_village, r.bush)
|
||||
|
||||
@@ -43,8 +43,8 @@ class World:
|
||||
self._addEntrance("start_house", mabe_village, start_house, None)
|
||||
|
||||
shop = Location("Shop")
|
||||
Location().add(ShopItem(0)).connect(shop, OR(COUNT("RUPEES", 500), SWORD))
|
||||
Location().add(ShopItem(1)).connect(shop, OR(COUNT("RUPEES", 1480), SWORD))
|
||||
Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD))
|
||||
Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD))
|
||||
self._addEntrance("shop", mabe_village, shop, None)
|
||||
|
||||
dream_hut = Location("Dream Hut")
|
||||
@@ -164,7 +164,7 @@ class World:
|
||||
self._addEntrance("prairie_left_cave2", ukuku_prairie, prairie_left_cave2, BOMB)
|
||||
self._addEntranceRequirementExit("prairie_left_cave2", None) # if exiting, you do not need bombs
|
||||
|
||||
mamu = Location().connect(Location().add(Song(0x2FB)), AND(OCARINA, COUNT("RUPEES", 1480)))
|
||||
mamu = Location().connect(Location().add(Song(0x2FB)), AND(OCARINA, r.can_farm, COUNT("RUPEES", 1480)))
|
||||
self._addEntrance("mamu", ukuku_prairie, mamu, AND(OR(AND(FEATHER, PEGASUS_BOOTS), ROOSTER), OR(HOOKSHOT, ROOSTER), POWER_BRACELET))
|
||||
|
||||
dungeon3_entrance = Location().connect(ukuku_prairie, OR(FEATHER, ROOSTER, FLIPPERS))
|
||||
@@ -377,7 +377,7 @@ class World:
|
||||
|
||||
# Raft game.
|
||||
raft_house = Location("Raft House")
|
||||
Location().add(KeyLocation("RAFT")).connect(raft_house, AND(r.bush, COUNT("RUPEES", 100))) # add bush requirement for farming in case player has to try again
|
||||
Location().add(KeyLocation("RAFT")).connect(raft_house, AND(r.can_farm, COUNT("RUPEES", 100)))
|
||||
raft_return_upper = Location()
|
||||
raft_return_lower = Location().connect(raft_return_upper, None, one_way=True)
|
||||
outside_raft_house = Location().connect(below_right_taltal, HOOKSHOT).connect(below_right_taltal, FLIPPERS, one_way=True)
|
||||
|
||||
@@ -253,7 +253,8 @@ def isConsumable(item) -> bool:
|
||||
|
||||
class RequirementsSettings:
|
||||
def __init__(self, options):
|
||||
self.bush = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, POWER_BRACELET, BOOMERANG)
|
||||
self.can_farm = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, BOOMERANG, BOMB, HOOKSHOT, BOW)
|
||||
self.bush = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, POWER_BRACELET, BOOMERANG, BOMB)
|
||||
self.pit_bush = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, BOOMERANG, BOMB) # unique
|
||||
self.attack = OR(SWORD, BOMB, BOW, MAGIC_ROD, BOOMERANG)
|
||||
self.attack_hookshot = OR(SWORD, BOMB, BOW, MAGIC_ROD, BOOMERANG, HOOKSHOT) # hinox, shrouded stalfos
|
||||
|
||||
@@ -4,6 +4,7 @@ from ..roomEditor import RoomEditor
|
||||
from .. import entityData
|
||||
import os
|
||||
import bsdiff4
|
||||
import pkgutil
|
||||
|
||||
def imageTo2bpp(filename):
|
||||
import PIL.Image
|
||||
@@ -179,24 +180,9 @@ def noText(rom):
|
||||
|
||||
def reduceMessageLengths(rom, rnd):
|
||||
# Into text from Marin. Got to go fast, so less text. (This intro text is very long)
|
||||
rom.texts[0x01] = formatText(rnd.choice([
|
||||
"Let's a go!",
|
||||
"Remember, sword goes on A!",
|
||||
"Avoid the heart piece of shame!",
|
||||
"Marin? No, this is Zelda. Welcome to Hyrule",
|
||||
"Why are you in my bed?",
|
||||
"This is not a Mario game!",
|
||||
"MuffinJets was here...",
|
||||
"Remember, there are no bugs in LADX",
|
||||
"#####, #####, you got to wake up!\nDinner is ready.",
|
||||
"Go find the stepladder",
|
||||
"Pizza power!",
|
||||
"Eastmost penninsula is the secret",
|
||||
"There is no cow level",
|
||||
"You cannot lift rocks with your bear hands",
|
||||
"Thank you, daid!",
|
||||
"There, there now. Just relax. You've been asleep for almost nine hours now."
|
||||
]))
|
||||
lines = pkgutil.get_data(__name__, "marin.txt").decode("unicode_escape").splitlines()
|
||||
lines = [l for l in lines if l.strip()]
|
||||
rom.texts[0x01] = formatText(rnd.choice(lines).strip())
|
||||
|
||||
# Reduce length of a bunch of common texts
|
||||
rom.texts[0xEA] = formatText("You've got a Guardian Acorn!")
|
||||
|
||||
465
worlds/ladx/LADXR/patches/marin.txt
Normal file
465
worlds/ladx/LADXR/patches/marin.txt
Normal file
@@ -0,0 +1,465 @@
|
||||
Let's a go!
|
||||
Remember, sword goes on A!
|
||||
Remember, sword goes on B!
|
||||
It's pronounced Hydrocity Zone.
|
||||
Avoid the heart piece of shame!
|
||||
Marin? No, this is Zelda. Welcome to Hyrule
|
||||
Why are you in my bed?
|
||||
This is not a Mario game!
|
||||
Wait, I thought Daid was French!
|
||||
Is it spicefather or spaceotter?
|
||||
kbranch finally took a break!
|
||||
Baby seed ahead.
|
||||
Abandon all hope ye who enter here...
|
||||
Link... Open your eyes...\nWait, you're #####?
|
||||
Remember, there are no bugs in LADX.
|
||||
#####, #####, you got to wake up!\nDinner is ready.
|
||||
Go find the stepladder.
|
||||
Pizza power!
|
||||
Eastmost peninsula is the secret.
|
||||
There is no cow level.
|
||||
You cannot lift rocks with your bear hands.
|
||||
Don't worry, the doghouse was patched.
|
||||
The carpet whale isn't real, it can't hurt you.
|
||||
Isn't this a demake of Phantom Hourglass?
|
||||
Go try the LAS rando!
|
||||
Go try the Oracles rando!
|
||||
Go try Archipelago!
|
||||
Go try touching grass!
|
||||
Please leave my house.
|
||||
Trust me, this will be a 2 hour seed, max.
|
||||
This is still better than doing Dampe dungeons.
|
||||
They say that Marin can be found here.
|
||||
Stalfos are such boneheads.
|
||||
90 percent bug-free!
|
||||
404 Marin.personality not found.
|
||||
Idk man, works on my machine.
|
||||
Hey guys, did you know that Vaporeon
|
||||
Trans rights!
|
||||
Support gay rights!\nAnd their lefts!
|
||||
Snake? Snake?! SNAAAAKE!!!
|
||||
Oh, you chose THESE settings?
|
||||
As seen on TV!
|
||||
May contain nuts.
|
||||
Limited edition!
|
||||
May contain RNG.
|
||||
Reticulating splines!
|
||||
Keyboard compatible!
|
||||
Teetsuuuuoooo!
|
||||
Kaaneeeedaaaa!
|
||||
Learn about allyship!
|
||||
This Marin text left intentionally blank.
|
||||
'Autological' is!
|
||||
Technoblade never dies!
|
||||
Thank you, CrystalSaver!
|
||||
Wait, LADX has a rando?
|
||||
Wait, how many Pokemon are there now?
|
||||
GOOD EMU
|
||||
Good luck finding the feather.
|
||||
Good luck finding the bracelets.
|
||||
Good luck finding the boots.
|
||||
Good luck finding your swords.
|
||||
Good luck finding the flippers.
|
||||
Good luck finding the rooster.
|
||||
Good luck finding the hookshot.
|
||||
Good luck finding the magic rod.
|
||||
It's not a fire rod.\nIt's a magic rod, it shoots magic.
|
||||
You should check the Seashell Mansion.
|
||||
Mt. Tamaranch
|
||||
WIND FISH IN NAME ONLY, FOR IT IS NEITHER.
|
||||
Stuck? Try Magpie!
|
||||
Ribbit! Ribbit! I'm Marin, on vocals!
|
||||
Try this rando at ladxr.daid.eu!
|
||||
He turned himself into a carpet whale!
|
||||
Which came first, the whale or the egg?
|
||||
Glan - Known Death and Taxes appreciator.
|
||||
Pokemon number 591.
|
||||
Would you?
|
||||
Sprinkle the desert skulls.
|
||||
Please don't curse in my Christian LADXR seed.
|
||||
... ... ... \n... ...smash.
|
||||
How was bedwetting practice?
|
||||
The Oracles decomp project is going well!
|
||||
#####, how do I download RAM?
|
||||
Is this a delayed April Fool's Joke?
|
||||
Play as if your footage will go in a\nSummoning Salt video.
|
||||
I hope you prepared for our date later.
|
||||
Isn't this the game where you date a seagull?
|
||||
You look pretty good for a guy who probably drowned.
|
||||
Remember, we race on Sundays.
|
||||
This randomizer was made possible by players like you. \n \n Thank you!
|
||||
Now with real fake doors!
|
||||
Now with real fake floors!
|
||||
You could be doing something productive right now.
|
||||
No eggs were harmed in the making of this game.
|
||||
I'm helping the goat, \ncatfishing Mr. Write is kinda the goal.
|
||||
There are actually two LADX randomizers.
|
||||
You're not gonna cheat... \n ...right?
|
||||
Mamu's singing is so bad it wakes the dead.
|
||||
Don't forget the Richard picture.
|
||||
Are you sure you wanna do this? I kinda like this island.
|
||||
SJ, BT, WW, OoB, HIJKLMNOP.
|
||||
5 dollars in the swear jar. Now.
|
||||
#####, I promise this seed will be better than the last one.
|
||||
Want your name here? Contribute to LADXR!
|
||||
Kappa
|
||||
HEY! \n \n LANGUAGE!
|
||||
I sell seashells on the seashore.
|
||||
Hey! Are you even listening to me?
|
||||
Your stay will total 10,000 rupees. I hope you have good insurance.
|
||||
I have like the biggest crush on you. Will you get the hints now?
|
||||
Daid watches Matty for ideas. \nBlame her if things go wrong.
|
||||
'All of you are to blame.' -Daid
|
||||
Batman Contingency Plan: Link. Step 1: Disguise yourself as a maiden to attract the young hero.
|
||||
I have flooded Koholint with a deadly neurotoxin.
|
||||
Ahh, General #####.
|
||||
Finally, Link's Awakening!
|
||||
Is the Wind Fish dreaming that he's sleeping in an egg? Or is he dreaming that he's you?
|
||||
Save Koholint. By destroying it. Huh? Don't ask me, I'm just a kid!
|
||||
There aren't enough women in this village to sustain a civilization.
|
||||
So does this game take place before or after Oracles?
|
||||
Have you tried the critically acclaimed MMORPG FINAL FANTASY XIV that has a free trial up to level 60 including the Heavensward expansion?
|
||||
The thumbs-up sign had been used by the Galactic Federation for ages. Me, I was known for giving the thumbs-down during briefing. I had my reasons, though... Commander Adam Malkovich was normally cool and not one to joke around, but he would end all of his mission briefings by saying, 'Any objections, Lady?'
|
||||
Hot hippos are near your location!
|
||||
#####, get up! It's my turn in the bed! Tarin's smells too much...
|
||||
Have you ever had a dream\nthat\nyo wa-\nyo had\nyo\nthat\nthat you could do anything?
|
||||
Next time, try a salad.
|
||||
seagull noises
|
||||
I'm telling you, YOU HAVE UNO, it came free with your Xbox!
|
||||
I'm telling you, YOU HAVE TRENDY, it came free with your Mabe!
|
||||
LADXR - Now with even more Marin quotes!
|
||||
You guys are spending more time adding Marin quotes than actually playing the game.
|
||||
NASA faked the moon.
|
||||
Doh, I missed!
|
||||
Beginning the seed in... 100\n99\n98\n97\n96\n...\nJust Kidding.
|
||||
Consider libre software!
|
||||
Consider a GNU/Linux installation!
|
||||
Now you're gonna tell me about how you need to get some instruments or maybe shells to hatch a whale out of an egg, right? All you boys are the same...
|
||||
Oh hey #####! I made pancakes!
|
||||
Oh hey #####! I made breakfast!
|
||||
Alright Tarin, test subject number 142857 was a failure, give him the item and the memory drug and we'll try next time.
|
||||
Betcha 100 rupees that Tarin gives you a sword.
|
||||
Betcha 100 rupees that Tarin gives you the feather.
|
||||
Betcha 100 rupees that Tarin gives you a bracelet.
|
||||
Betcha 100 rupees that Tarin gives you the boots.
|
||||
Betcha 100 rupees that Tarin gives you the hookshot.
|
||||
Betcha 100 rupees that Tarin gives you the rod.
|
||||
You'd think that Madam MeowMeow would be a cat person.
|
||||
Look at you, with them dry lips.
|
||||
You are now manually breathing. Hope that doesn't throw you off for this race.
|
||||
Lemme get a number nine, a number nine large, a number six, with extra dip...
|
||||
Tarin, the red-nosed deadbeat \nHad a mushroom addiction!
|
||||
I'm using tilt controls!
|
||||
SPLASH! \n \n \n ...Wait, you meant something else by 'splash text'?
|
||||
CRACKLE-FWOOSH!
|
||||
'Logic' is a strong word.
|
||||
They say that the go-to way for fixing things is just to add another one of me.
|
||||
gl hf
|
||||
Have you considered multi-classing as a THIEF?
|
||||
Don't call me Shirley
|
||||
WHY are you buying CLOTHES at the SOUP STORE?
|
||||
Believe it or not, this won't be the last time Link gets stranded on an island.
|
||||
Is this the real life? Or is this just fantasy?
|
||||
To the owner of the white sedan, your lights are on.
|
||||
Now remade, in beautiful SD 2D!
|
||||
Animal Village in my seed \nMarin and rabbits, loop de loop.
|
||||
You seem totally entranced in Marin's appearance.
|
||||
House hippoes are very timid creatures and are rarely seen, but they will defend their territory if provoked.
|
||||
New goal! Close this seed, open the LADXR source code, and find the typo.
|
||||
All your base are belong to us
|
||||
Really? Another seed?
|
||||
This seed brought to you by: the corners in the D2 boss room.
|
||||
Hey, THIEF! Oh wait, you haven't done anything wrong... yet.
|
||||
Hello World
|
||||
With these hands, I give you life!
|
||||
I heard we're a subcommunity of FFR now.
|
||||
Try the Final Fantasy Randomizer!
|
||||
How soon should we start calling you THIEF?
|
||||
... Why do you keep doing this to yourself?
|
||||
YOUR AD HERE
|
||||
Did Matty give you this seed? Yeesh, good luck.
|
||||
Yoooo I looked ahead into the spoiler log for this one...\n...\n...\n...good luck.
|
||||
Lemme check the spoiler log...\nOkay, cool, only the normal amount of stupid.
|
||||
Oh, you're alive. Dang. Guess I won't be needing THIS anymore.
|
||||
Now you're gonna go talk to my dad. Gosh, boys are so predictable.
|
||||
Shoot, I WAS going to steal your kidneys while you were asleep. Guess I'll have to find a moment when you don't expect me.
|
||||
You caught me, mid-suavamente!
|
||||
You'll be the bedwetting champion in no time.
|
||||
Link, stop doing that, this is the fifth time this week I've had to change the sheets!
|
||||
You mind napping in Not My Bed next time?
|
||||
Why do they call it oven when you of in the cold food of out hot eat the food?
|
||||
Marin sayings will never be generated by AI. Our community really is just that unfunny.
|
||||
skibidi toilet\n...\nYes, that joke WILL age well
|
||||
WHO DARES AWAKEN ME FROM MY THOUSAND-YEAR SLUMBER
|
||||
The wind... it is... blowing...
|
||||
Have I ever told you how much I hate sand?
|
||||
explosion.gif
|
||||
It is pronounced LADXR, not LADXR.
|
||||
Stop pronouncing it lah-decks.
|
||||
Someone once suggested to add all the nag messages all at once for me.
|
||||
Accidentally playing Song 2? In front of the egg? It's more likely than you think.
|
||||
Ladies and gentlemen? We got him.
|
||||
Ladies and gentlemen? We got her.
|
||||
Ladies and gentlemen? We got 'em.
|
||||
What a wake up! I thought you'd never Marin! You were feeling a bit woozy and Zelda... What? Koholint? No, my name's relief! You must still be tossing. You are on turning Island!
|
||||
...Zelda? Oh Marin is it? My apologies, thank you for saving me. So I'm on Koholint Island? Wait, where's my sword and shield?!
|
||||
Koholint? More like kOWOlint.
|
||||
What? The Wind Fish will grant my wish literally? I forsee nothing wrong happening with this.
|
||||
Hey Marin! You woke me up from a fine nap! ... Thanks a lot! But now, I'll get my revenge! Are you ready?!
|
||||
Why bother coming up with a funny quote? You're just gonna mash through it anyway.
|
||||
something something whale something something dream something something adventure.
|
||||
Some people won't be able to see this message!
|
||||
If you're playing Archipelago and see this message, say hi to zig for me!
|
||||
I think it may be time to stop playing LADXR seeds.
|
||||
Rings do nothing unless worn!
|
||||
Thank you Link, but our Instruments are in another Dungeon.
|
||||
Are you sure you loaded the right seed?
|
||||
Is this even randomized?
|
||||
This seed brought to you by... Corners!
|
||||
To this day I still don't know if we inconvenienced the Mad Batter or not.
|
||||
Oh, hi #####
|
||||
People forgot I was playable in Hyrule Warriors
|
||||
Join our Discord. Or else.
|
||||
Also try Minecraft!
|
||||
I see you're finally awake...
|
||||
OwO
|
||||
This is Todd Howard, and today I'm pleased to announce... The Elder Scrolls V: Skyrim for the Nintendo Game Boy Color!
|
||||
Hey dummy! Need a hint? The power bracelet is... !! Whoops! There I go, talking too much again.
|
||||
Thank you for visiting Toronbo Shores featuring Mabe Village. Don't forget your complimentary gift on the way out.
|
||||
They say that sand can be found in Yarna Desert.
|
||||
I got to see a previously unreleased cut yesterday. It only cost me 200 rupees. What a deal!
|
||||
Just let him sleep
|
||||
LADXR is going to be renamed X now.
|
||||
Did you hear this chart-topping song yet? It's called Manbo's Mambo, it's so catchy! OH!
|
||||
YOU DARE BRING LIGHT INTO MY LAIR?!?! You must DIE!
|
||||
But enough talk! Have at you!
|
||||
Please input your age for optimal meme-text delivery.
|
||||
So the bear is just calling the walrus fat beecause he's projecting, right?
|
||||
Please help, #####! The Nightmare has shuffled all the items around!
|
||||
One does not simply Wake the Wind Fish.
|
||||
Nothing unusual here, just a completely normal LADX game, Mister Nintendo.
|
||||
Remember:\n1) Play Vanilla\n2) Play Solo Rando\n3) Play Multi
|
||||
Is :) a good item?
|
||||
What version do we have anyway? 0.6.9?
|
||||
So, what &newgames are coming in the next AP version?
|
||||
Is !remaining fixed yet?
|
||||
Remember the APocalypse. Never forget the rooms we lost that day.
|
||||
Have you heard of Berserker's Multiworld?
|
||||
MILF. Man I love Fangames.
|
||||
How big can the Big Async be anyway? A hundred worlds?
|
||||
Have you heard of the After Dark server?
|
||||
Try Adventure!
|
||||
Try Aquaria!
|
||||
Try Blasphemous!
|
||||
Try Bomb Rush Cyberfunk!
|
||||
Try Bumper Stickers!
|
||||
Try Castlevania 64!
|
||||
Try Celeste 64!
|
||||
Try ChecksFinder!
|
||||
Try Clique!
|
||||
Try Dark Souls III!
|
||||
Try DLCQuest!
|
||||
Try Donkey Kong Country 3!
|
||||
Try DOOM 1993!
|
||||
Try DOOM II!
|
||||
Try Factorio!
|
||||
Try Final Fantasy!
|
||||
Try Final Fantasy Mystic Quest!
|
||||
Try A Hat in Time!
|
||||
Try Heretic!
|
||||
Try Hollow Knight!
|
||||
Try Hylics 2!
|
||||
Try Kingdom Hearts 2!
|
||||
Try Kirby's Dream Land 3!
|
||||
Try Landstalker - The Treasures of King Nole!
|
||||
Try The Legend of Zelda!
|
||||
Try Lingo!
|
||||
Try A Link to the Past!
|
||||
Try Links Awakening DX!
|
||||
Try Lufia II Ancient Cave!
|
||||
Try Mario & Luigi Superstar Saga!
|
||||
Try MegaMan Battle Network 3!
|
||||
Try Meritous!
|
||||
Try The Messenger!
|
||||
Try Minecraft!
|
||||
Try Muse Dash!
|
||||
Try Noita!
|
||||
Try Ocarina of Time!
|
||||
Try Overcooked! 2!
|
||||
Try Pokemon Emerald!
|
||||
Try Pokemon Red and Blue!
|
||||
Try Raft!
|
||||
Try Risk of Rain 2!
|
||||
Try Rogue Legacy!
|
||||
Try Secret of Evermore!
|
||||
Try Shivers!
|
||||
Try A Short Hike!
|
||||
Try Slay the Spire!
|
||||
Try SMZ3!
|
||||
Try Sonic Adventure 2 Battle!
|
||||
Try Starcraft 2!
|
||||
Try Stardew Valley!
|
||||
Try Subnautica!
|
||||
Try Sudoku!
|
||||
Try Super Mario 64!
|
||||
Try Super Mario World!
|
||||
Try Super Metroid!
|
||||
Try Terraria!
|
||||
Try Timespinner!
|
||||
Try TUNIC!
|
||||
Try Undertale!
|
||||
Try VVVVVV!
|
||||
Try Wargroove!
|
||||
Try The Witness!
|
||||
Try Yoshi's Island!
|
||||
Try Yu-Gi-Oh! 2006!
|
||||
Try Zillion!
|
||||
Try Zork Grand Inquisitor!
|
||||
Try Old School Runescape!
|
||||
Try Kingdom Hearts!
|
||||
Try Mega Man 2!
|
||||
Try Yacht Dice!
|
||||
VVVVVVVVVVVVVV this should be enough V right?
|
||||
If you see this message, please open a #bug-report about it\n\n\nDon't actually though.
|
||||
This YAML is going in the bucket, isn't it?
|
||||
Oh, this is a terrible seed for a Sync
|
||||
Oh, this is a terrible seed for an Async
|
||||
What does BK stand for anyway?
|
||||
Check out the #future-game-design forum
|
||||
This is actually a Free trial of the critically acclaimed MMORPG Final Fantasy XIV, including the entirety of A Realm Reborn and the award winning Heavensward and Stormblood expansions up to level 70 with no restrictions on playtime!
|
||||
Is it April yet? Can I play ArchipIDLE again?
|
||||
https://archipelago.gg/datapackage
|
||||
Hello, Link! (Disregard message if your player sprite is not Link.)
|
||||
Go back to sleep, Outer Wilds isn't supported yet.
|
||||
:)\nWelcome back!
|
||||
Don't forget about Aginah!
|
||||
Remind your Undertale player not to warp before Mad Dummy.
|
||||
You need\n9 instruments\nor maybe not. I wouldn't know.
|
||||
Try !\n\nIt makes the game easier.
|
||||
Have you tried The Witness? If you're a fan of games about waking up on an unfamiliar island, give it a shot!
|
||||
Have you tried turning it off and on again?
|
||||
Its about time. Now go and check
|
||||
This dream is a lie. Or is it a cake?
|
||||
Don't live your dream. Dream your live.
|
||||
Only 5 more minutes. zzzZ
|
||||
Tell me, for whom do you fight?\nHmmph. How very glib. And do you believe in Koholint?
|
||||
I wonder when Undertale will be merged?\nOh wait it already has.
|
||||
Hit me up if you get stuck -\nwe could go to Burger King together.
|
||||
Post this message to delay Silksong.
|
||||
Sorry #####, but your princess is in another castle!
|
||||
You've been met with a terrible fate, haven't you?
|
||||
Hey!\nListen!\nHey! Hey!\nListen!
|
||||
I bet there's a progression item at the 980 Rupee shop check.
|
||||
Lamp oil? Rope? Bombs? You want it? It's yours, my friend. As long as you have enough rubies.
|
||||
One day I happened to be occupied with the subject of generation of waves by wind.
|
||||
(nuzzles you) uwu
|
||||
why do they call it links awakening when links awake and IN links asleep OUT the wind fish
|
||||
For many years I have been looking, searching for, but never finding, the builder of this house...
|
||||
What the heck is a Quatro?
|
||||
Have you tried The Binding of Isaac yet?
|
||||
Have you played Pong? \n I hear it's still popular nowadays.
|
||||
Five Nights at Freddy's... \n That's where I wanna be
|
||||
Setting Coinsanity to -1...
|
||||
Your Feather can be found at Mask-Shard_Grey_Mourner
|
||||
Your Sword can be found in Ganon's Tower
|
||||
Your Rooster can be found in HylemXylem
|
||||
Your Bracelet can be found at Giant Floor Puzzle
|
||||
Your Flippers can be found in Valley of Bowser
|
||||
Your Magic Rod can be found in Victory Road
|
||||
Your Hookshot can be found in Bowser in the Sky
|
||||
Have they added Among Us to AP yet?
|
||||
Every copy of LADX is personalized, David.
|
||||
Looks like you're going on A Short Hike. Bring back feathers please?
|
||||
Functioning Brain is at...\nWait. This isn't Witness. Wrong game, sorry.
|
||||
Don't forget to check your Clique!\nIf, y'know, you have one. No pressure...
|
||||
:3
|
||||
Sorry ######, but your progression item is in another world.
|
||||
&newgames\n&oldgames
|
||||
Do arrows come with turners? I'm stuck in my Bumper Stickers world.
|
||||
This seed has dexsanity enabled. Don't get stuck in Dewford!
|
||||
Please purchase the Dialogue Pack for DLC Quest: Link's Adventure to read the rest of this text.
|
||||
No hints here. Maybe ask BK Sudoku for some?
|
||||
KILNS (Yellow Middle, 5) \n REVELATION (White Low, 9)
|
||||
Push the button! When someone lets you...
|
||||
You won't believe the WEIRD thing Tarin found at the beach! Go on, ask him about it!
|
||||
When's door randomizer getting added to AP?
|
||||
Can you get my Morph Ball?
|
||||
Shoutouts to Simpleflips
|
||||
Remember, Sword goes on C!\n...you have a C button, right?
|
||||
Ask Berserker for your Progressive Power Bracelets!
|
||||
I will be taking your Burger King order now to save you some time when you inevitably need it.
|
||||
Welcome to KOHOLINT ISLAND.\nNo, we do not have a BURGER KING.
|
||||
Welcome to Burger King, may I take your order?
|
||||
Rise and shine, #####. Rise and shine.
|
||||
Well, this is\nLITTLEROOT TOWN.\nHow do you like it?
|
||||
My boy, this peace is what all true warriors strive for!
|
||||
#####, you can do it!\nSave the Princess...\nZelda is your... ... ...
|
||||
Dear Mario:\nPlease come to the castle, I've baked a cake for you. Yours truly--\nPrincess Toadstool\nPeach
|
||||
Grass-sanity mode activated. Have fun!
|
||||
Don't forget to bring rupees to the signpost maze this time.
|
||||
UP UP DOWN DOWN LEFT RIGHT LEFT RIGHT B A START
|
||||
Try LADX!\nWait a minute...
|
||||
ERROR! Unable to verify player. Please drink a verification can.
|
||||
We have been trying to reach you about your raft's extended warranty
|
||||
Are you ready for the easiest BK of your life?
|
||||
Hello, welcome to the world of Pokemon!\nMy name is Marin, and I'm--
|
||||
Alright, this is very important, I need you to listen to what I'm about to tell you--\nHey, wait, where are you going?!
|
||||
Cheques?\nSorry we don't accept cheques here
|
||||
Hi! \nMarin. \nWho...? \nHow...? \nWait... \nWhy??? \nSorry... \n...\nThanks. \nBye!
|
||||
AHHH WHY IS THERE SO MUCH GRASS? \nHOLY SH*T GRASS SNAKE AHHHH
|
||||
Could you buy some strawberries on your way home? \nHuh it's out of logic??? What??
|
||||
I heard you sleeptalking about skeletons and genocide... Your past must have been full of misery (mire)
|
||||
It's time to let go... \nIt wasn't your fault... \nYou couldn't have known your first check was going to be hardmode...
|
||||
They say that your progression is in another castle...
|
||||
A minute of silence for the failed generations due to the Fitness Gram Pacer test.
|
||||
Save an Ice Trap for me, please?
|
||||
maren
|
||||
ERROR DETECTED IN YAML\nOHKO MODE FORCED ON
|
||||
she awaken my link (extremely loud incorrect buzzer)
|
||||
Is deathlink on? If so, be careful!
|
||||
Sorry, but you're about to be BK'd.
|
||||
Did you set up cheesetracker yet?
|
||||
I've got a hint I need you to get...
|
||||
You aren't planning to destroy this island and kill everyone on it are you?
|
||||
Have you ever had a dream, that, that you um you had you'd you would you could you'd do you wi you wants you you could do so you you'd do you could you you want you want him to do you so much you could do anything?
|
||||
R R R U L L U L U R U R D R D R U U
|
||||
I'm not sure how, but I am pretty sure this is Phar's fault.
|
||||
Oh, look at that. Link's Awakened.\nYou did it, you beat the game.
|
||||
Excellent armaments, #####. Please return - \nCOVERED IN BLOOD -\n...safe and sound.
|
||||
Pray return to the Link's Awakening Sands.
|
||||
This Marin dialogue was inspired by The Witness's audiologs.
|
||||
You're awake!\n....\nYou were warned.\nI'm now going to say every word beginning with Z!\nZA\nZABAGLIONE\nZABAGLIONES\nZABAIONE\nZABAIONES\nZABAJONE\nZABAJONES\nZABETA\nZABETAS\nZABRA\nZABRAS\nZABTIEH\nZABTIEHS\nZACATON\nZACATONS\nZACK\nZACKS\nZADDICK\nZADDIK\nZADDIKIM\nZADDIKS\nZAFFAR\nzAFFARS\nZAFFER\nZAFFERS\nZAFFIR\n....\n....\n....\nI'll let you off easy.\nThis time.
|
||||
Leave me alone, I'm Marinating.
|
||||
praise be to the tungsten cube
|
||||
If you play multiple seeds in a row, you can pretend that each run is the dream you awaken from in the next.
|
||||
If this is a competitive race,\n\nyour time has already started.
|
||||
If anything goes wrong, remember.\n Blame Phar.
|
||||
Better hope your Hookshot didn't land on the Sick Kid.
|
||||
One time, I accidentally said Konoliht instead of Koholint...
|
||||
Sometimes, you must become best girl yourself...
|
||||
You just woke up! My name's #####!\nYou must be Marin, right?
|
||||
I just had the strangest dream, I was a seagull!\nI sung many songs for everybody to hear!\nHave you ever had a strange dream before?
|
||||
If you think about it, Koholint sounds suspiciously similar to Coherent...
|
||||
All I kin remember is biting into a juicy toadstool. Then I had the strangest dream... I was a Marin! Yeah, it sounds strange, but it sure was fun!
|
||||
Prepare for a 100% run!
|
||||
Prediction: 1 hour
|
||||
Prediction: 4 hours
|
||||
Prediction: 6 hours
|
||||
Prediction: 12 hours
|
||||
Prediction: Impossible seed
|
||||
Oak's parcel has arrived.
|
||||
Don't forget to like and subscribe!
|
||||
Don't BK, eat healthy!
|
||||
No omega symbols broke this seed gen? Good!
|
||||
#####...\nYou're lucky.\nLooks like my summer vacation is...\nover.
|
||||
Are you ready to send nukes to someone's Factorio game?
|
||||
You're late... Is this a Cmario game?
|
||||
At least you don't have to fight Ganon... What?
|
||||
PRAISE THE SUN!
|
||||
I'd recommend more sleep before heading out there.
|
||||
You Must Construct Additional Pylons
|
||||
#####, you lazy bum. I knew that I'd find you snoozing down here.
|
||||
This is it, #####.\nJust breathe.\nWhy are you so nervous?
|
||||
Hey, you. You're finally awake.\nYou were trying to cross the border, huh?
|
||||
Hey, you. You're finally awake.\nYou were trying to leave the island, huh?\nSwam straight into that whirlpool, same as us, and that thief over there.
|
||||
Is my Triforce locked behind your Wind Fish?
|
||||
@@ -110,15 +110,6 @@ class LinksAwakeningLocation(Location):
|
||||
add_item_rule(self, filter_item)
|
||||
|
||||
|
||||
def has_free_weapon(state: CollectionState, player: int) -> bool:
|
||||
return state.has("Progressive Sword", player) or state.has("Magic Rod", player) or state.has("Boomerang", player) or state.has("Hookshot", player)
|
||||
|
||||
|
||||
# If the player has access to farm enough rupees to afford a game, we assume that they can keep beating the game
|
||||
def can_farm_rupees(state: CollectionState, player: int) -> bool:
|
||||
return has_free_weapon(state, player) and (state.has("Can Play Trendy Game", player=player) or state.has("RAFT", player=player))
|
||||
|
||||
|
||||
class LinksAwakeningRegion(Region):
|
||||
dungeon_index = None
|
||||
ladxr_region = None
|
||||
@@ -154,9 +145,7 @@ class GameStateAdapater:
|
||||
def get(self, item, default):
|
||||
# Don't allow any money usage if you can't get back wasted rupees
|
||||
if item == "RUPEES":
|
||||
if can_farm_rupees(self.state, self.player):
|
||||
return self.state.prog_items[self.player]["RUPEES"]
|
||||
return 0
|
||||
return self.state.prog_items[self.player]["RUPEES"]
|
||||
elif item.endswith("_USED"):
|
||||
return 0
|
||||
else:
|
||||
|
||||
@@ -527,6 +527,20 @@ class InGameHints(DefaultOnToggle):
|
||||
display_name = "In-game Hints"
|
||||
|
||||
|
||||
class TarinsGift(Choice):
|
||||
"""
|
||||
[Local Progression] Forces Tarin's gift to be an item that immediately opens up local checks.
|
||||
Has little effect in single player games, and isn't always necessary with randomized entrances.
|
||||
[Bush Breaker] Forces Tarin's gift to be an item that can destroy bushes.
|
||||
[Any Item] Tarin's gift can be any item for any world
|
||||
"""
|
||||
display_name = "Tarin's Gift"
|
||||
option_local_progression = 0
|
||||
option_bush_breaker = 1
|
||||
option_any_item = 2
|
||||
default = option_local_progression
|
||||
|
||||
|
||||
class StabilizeItemPool(DefaultOffToggle):
|
||||
"""
|
||||
By default, rupees in the item pool may be randomly swapped with bombs, arrows, powders, or capacity upgrades. This option disables that swapping, which is useful for plando.
|
||||
@@ -565,6 +579,7 @@ ladx_option_groups = [
|
||||
OptionGroup("Miscellaneous", [
|
||||
TradeQuest,
|
||||
Rooster,
|
||||
TarinsGift,
|
||||
Overworld,
|
||||
TrendyGame,
|
||||
InGameHints,
|
||||
@@ -638,6 +653,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
|
||||
text_mode: TextMode
|
||||
no_flash: NoFlash
|
||||
in_game_hints: InGameHints
|
||||
tarins_gift: TarinsGift
|
||||
overworld: Overworld
|
||||
stabilize_item_pool: StabilizeItemPool
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import typing
|
||||
|
||||
from worlds.ladx.GpsTracker import GpsTracker
|
||||
from .LADXR.checkMetadata import checkMetadataTable
|
||||
import json
|
||||
import logging
|
||||
@@ -10,13 +13,14 @@ logger = logging.getLogger("Tracker")
|
||||
# kbranch you're a hero
|
||||
# https://github.com/kbranch/Magpie/blob/master/autotracking/checks.py
|
||||
class Check:
|
||||
def __init__(self, id, address, mask, alternateAddress=None):
|
||||
def __init__(self, id, address, mask, alternateAddress=None, linkedItem=None):
|
||||
self.id = id
|
||||
self.address = address
|
||||
self.alternateAddress = alternateAddress
|
||||
self.mask = mask
|
||||
self.value = None
|
||||
self.diff = 0
|
||||
self.linkedItem = linkedItem
|
||||
|
||||
def set(self, bytes):
|
||||
oldValue = self.value
|
||||
@@ -86,6 +90,27 @@ class LocationTracker:
|
||||
|
||||
blacklist = {'None', '0x2A1-2'}
|
||||
|
||||
def seashellCondition(slot_data):
|
||||
return 'goal' not in slot_data or slot_data['goal'] != 'seashells'
|
||||
|
||||
linkedCheckItems = {
|
||||
'0x2E9': {'item': 'SEASHELL', 'qty': 20, 'condition': seashellCondition},
|
||||
'0x2A2': {'item': 'TOADSTOOL', 'qty': 1},
|
||||
'0x2A6-Trade': {'item': 'TRADING_ITEM_YOSHI_DOLL', 'qty': 1},
|
||||
'0x2B2-Trade': {'item': 'TRADING_ITEM_RIBBON', 'qty': 1},
|
||||
'0x2FE-Trade': {'item': 'TRADING_ITEM_DOG_FOOD', 'qty': 1},
|
||||
'0x07B-Trade': {'item': 'TRADING_ITEM_BANANAS', 'qty': 1},
|
||||
'0x087-Trade': {'item': 'TRADING_ITEM_STICK', 'qty': 1},
|
||||
'0x2D7-Trade': {'item': 'TRADING_ITEM_HONEYCOMB', 'qty': 1},
|
||||
'0x019-Trade': {'item': 'TRADING_ITEM_PINEAPPLE', 'qty': 1},
|
||||
'0x2D9-Trade': {'item': 'TRADING_ITEM_HIBISCUS', 'qty': 1},
|
||||
'0x2A8-Trade': {'item': 'TRADING_ITEM_LETTER', 'qty': 1},
|
||||
'0x0CD-Trade': {'item': 'TRADING_ITEM_BROOM', 'qty': 1},
|
||||
'0x2F5-Trade': {'item': 'TRADING_ITEM_FISHING_HOOK', 'qty': 1},
|
||||
'0x0C9-Trade': {'item': 'TRADING_ITEM_NECKLACE', 'qty': 1},
|
||||
'0x297-Trade': {'item': 'TRADING_ITEM_SCALE', 'qty': 1},
|
||||
}
|
||||
|
||||
# in no dungeons boss shuffle, the d3 boss in d7 set 0x20 in fascade's room (0x1BC)
|
||||
# after beating evil eagile in D6, 0x1BC is now 0xAC (other things may have happened in between)
|
||||
# entered d3, slime eye flag had already been set (0x15A 0x20). after killing angler fish, bits 0x0C were set
|
||||
@@ -98,6 +123,8 @@ class LocationTracker:
|
||||
address = addressOverrides[check_id] if check_id in addressOverrides else 0xD800 + int(
|
||||
room, 16)
|
||||
|
||||
linkedItem = linkedCheckItems[check_id] if check_id in linkedCheckItems else None
|
||||
|
||||
if 'Trade' in check_id or 'Owl' in check_id:
|
||||
mask = 0x20
|
||||
|
||||
@@ -111,13 +138,19 @@ class LocationTracker:
|
||||
highest_check = max(
|
||||
highest_check, alternateAddresses[check_id])
|
||||
|
||||
check = Check(check_id, address, mask,
|
||||
alternateAddresses[check_id] if check_id in alternateAddresses else None)
|
||||
check = Check(
|
||||
check_id,
|
||||
address,
|
||||
mask,
|
||||
(alternateAddresses[check_id] if check_id in alternateAddresses else None),
|
||||
linkedItem,
|
||||
)
|
||||
|
||||
if check_id == '0x2A3':
|
||||
self.start_check = check
|
||||
self.all_checks.append(check)
|
||||
self.remaining_checks = [check for check in self.all_checks]
|
||||
self.gameboy.set_cache_limits(
|
||||
self.gameboy.set_checks_range(
|
||||
lowest_check, highest_check - lowest_check + 1)
|
||||
|
||||
def has_start_item(self):
|
||||
@@ -147,10 +180,17 @@ class MagpieBridge:
|
||||
server = None
|
||||
checks = None
|
||||
item_tracker = None
|
||||
gps_tracker: GpsTracker = None
|
||||
ws = None
|
||||
features = []
|
||||
slot_data = {}
|
||||
|
||||
def use_entrance_tracker(self):
|
||||
return "entrances" in self.features \
|
||||
and self.slot_data \
|
||||
and "entrance_mapping" in self.slot_data \
|
||||
and any([k != v for k, v in self.slot_data["entrance_mapping"].items()])
|
||||
|
||||
async def handler(self, websocket):
|
||||
self.ws = websocket
|
||||
while True:
|
||||
@@ -159,14 +199,18 @@ class MagpieBridge:
|
||||
logger.info(
|
||||
f"Connected, supported features: {message['features']}")
|
||||
self.features = message["features"]
|
||||
|
||||
await self.send_handshAck()
|
||||
|
||||
if message["type"] in ("handshake", "sendFull"):
|
||||
if message["type"] == "sendFull":
|
||||
if "items" in self.features:
|
||||
await self.send_all_inventory()
|
||||
if "checks" in self.features:
|
||||
await self.send_all_checks()
|
||||
if "slot_data" in self.features:
|
||||
if "slot_data" in self.features and self.slot_data:
|
||||
await self.send_slot_data(self.slot_data)
|
||||
if self.use_entrance_tracker():
|
||||
await self.send_gps(diff=False)
|
||||
|
||||
# Translate renamed IDs back to LADXR IDs
|
||||
@staticmethod
|
||||
@@ -176,6 +220,18 @@ class MagpieBridge:
|
||||
if the_id == "0x2A7":
|
||||
return "0x2A1-1"
|
||||
return the_id
|
||||
|
||||
async def send_handshAck(self):
|
||||
if not self.ws:
|
||||
return
|
||||
|
||||
message = {
|
||||
"type": "handshAck",
|
||||
"version": "1.32",
|
||||
"name": "archipelago-ladx-client",
|
||||
}
|
||||
|
||||
await self.ws.send(json.dumps(message))
|
||||
|
||||
async def send_all_checks(self):
|
||||
while self.checks == None:
|
||||
@@ -185,7 +241,6 @@ class MagpieBridge:
|
||||
message = {
|
||||
"type": "check",
|
||||
"refresh": True,
|
||||
"version": "1.0",
|
||||
"diff": False,
|
||||
"checks": [{"id": self.fixup_id(check.id), "checked": check.value} for check in self.checks]
|
||||
}
|
||||
@@ -200,7 +255,6 @@ class MagpieBridge:
|
||||
message = {
|
||||
"type": "check",
|
||||
"refresh": True,
|
||||
"version": "1.0",
|
||||
"diff": True,
|
||||
"checks": [{"id": self.fixup_id(check), "checked": True} for check in checks]
|
||||
}
|
||||
@@ -222,10 +276,17 @@ class MagpieBridge:
|
||||
return
|
||||
await self.item_tracker.sendItems(self.ws, diff=True)
|
||||
|
||||
async def send_gps(self, gps):
|
||||
async def send_gps(self, diff: bool=True) -> typing.Dict[str, str]:
|
||||
if not self.ws:
|
||||
return
|
||||
await gps.send_location(self.ws)
|
||||
|
||||
await self.gps_tracker.send_location(self.ws)
|
||||
|
||||
if self.use_entrance_tracker():
|
||||
if self.slot_data and self.gps_tracker.needs_slot_data:
|
||||
self.gps_tracker.load_slot_data(self.slot_data)
|
||||
|
||||
return await self.gps_tracker.send_entrances(self.ws, diff)
|
||||
|
||||
async def send_slot_data(self, slot_data):
|
||||
if not self.ws:
|
||||
|
||||
291
worlds/ladx/TrackerConsts.py
Normal file
291
worlds/ladx/TrackerConsts.py
Normal file
@@ -0,0 +1,291 @@
|
||||
class EntranceCoord:
|
||||
name: str
|
||||
room: int
|
||||
x: int
|
||||
y: int
|
||||
|
||||
def __init__(self, name: str, room: int, x: int, y: int):
|
||||
self.name = name
|
||||
self.room = room
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def __repr__(self):
|
||||
return EntranceCoord.coordString(self.room, self.x, self.y)
|
||||
|
||||
def coordString(room: int, x: int, y: int):
|
||||
return f"{room:#05x}, {x}, {y}"
|
||||
|
||||
storage_key = "found_entrances"
|
||||
|
||||
room = 0xFFF6
|
||||
map_id = 0xFFF7
|
||||
indoor_flag = 0xDBA5
|
||||
spawn_map = 0xDB60
|
||||
spawn_room = 0xDB61
|
||||
spawn_x = 0xDB62
|
||||
spawn_y = 0xDB63
|
||||
entrance_room_offset = 0xD800
|
||||
transition_state = 0xC124
|
||||
transition_target_x = 0xC12C
|
||||
transition_target_y = 0xC12D
|
||||
transition_scroll_x = 0xFF96
|
||||
transition_scroll_y = 0xFF97
|
||||
link_motion_state = 0xC11C
|
||||
transition_sequence = 0xC16B
|
||||
screen_coord = 0xFFFA
|
||||
|
||||
entrance_address_overrides = {
|
||||
0x312: 0xDDF2,
|
||||
}
|
||||
|
||||
map_map = {
|
||||
0x00: 0x01,
|
||||
0x01: 0x01,
|
||||
0x02: 0x01,
|
||||
0x03: 0x01,
|
||||
0x04: 0x01,
|
||||
0x05: 0x01,
|
||||
0x06: 0x02,
|
||||
0x07: 0x02,
|
||||
0x08: 0x02,
|
||||
0x09: 0x02,
|
||||
0x0A: 0x02,
|
||||
0x0B: 0x02,
|
||||
0x0C: 0x02,
|
||||
0x0D: 0x02,
|
||||
0x0E: 0x02,
|
||||
0x0F: 0x02,
|
||||
0x10: 0x02,
|
||||
0x11: 0x02,
|
||||
0x12: 0x02,
|
||||
0x13: 0x02,
|
||||
0x14: 0x02,
|
||||
0x15: 0x02,
|
||||
0x16: 0x02,
|
||||
0x17: 0x02,
|
||||
0x18: 0x02,
|
||||
0x19: 0x02,
|
||||
0x1D: 0x01,
|
||||
0x1E: 0x01,
|
||||
0x1F: 0x01,
|
||||
0xFF: 0x03,
|
||||
}
|
||||
|
||||
sidescroller_rooms = {
|
||||
0x2e9: "seashell_mansion:inside",
|
||||
0x08a: "seashell_mansion",
|
||||
0x2fd: "mambo:inside",
|
||||
0x02a: "mambo",
|
||||
0x1eb: "castle_secret_exit:inside",
|
||||
0x049: "castle_secret_exit",
|
||||
0x1ec: "castle_secret_entrance:inside",
|
||||
0x04a: "castle_secret_entrance",
|
||||
0x117: "d1:inside", # not a sidescroller, but acts weird
|
||||
}
|
||||
|
||||
entrance_coords = [
|
||||
EntranceCoord("writes_house:inside", 0x2a8, 80, 124),
|
||||
EntranceCoord("rooster_grave", 0x92, 88, 82),
|
||||
EntranceCoord("start_house:inside", 0x2a3, 80, 124),
|
||||
EntranceCoord("dream_hut", 0x83, 40, 66),
|
||||
EntranceCoord("papahl_house_right:inside", 0x2a6, 80, 124),
|
||||
EntranceCoord("papahl_house_right", 0x82, 120, 82),
|
||||
EntranceCoord("papahl_house_left:inside", 0x2a5, 80, 124),
|
||||
EntranceCoord("papahl_house_left", 0x82, 88, 82),
|
||||
EntranceCoord("d2:inside", 0x136, 80, 124),
|
||||
EntranceCoord("shop", 0x93, 72, 98),
|
||||
EntranceCoord("armos_maze_cave:inside", 0x2fc, 104, 96),
|
||||
EntranceCoord("start_house", 0xa2, 88, 82),
|
||||
EntranceCoord("animal_house3:inside", 0x2d9, 80, 124),
|
||||
EntranceCoord("trendy_shop", 0xb3, 88, 82),
|
||||
EntranceCoord("mabe_phone:inside", 0x2cb, 80, 124),
|
||||
EntranceCoord("mabe_phone", 0xb2, 88, 82),
|
||||
EntranceCoord("ulrira:inside", 0x2a9, 80, 124),
|
||||
EntranceCoord("ulrira", 0xb1, 72, 98),
|
||||
EntranceCoord("moblin_cave:inside", 0x2f0, 80, 124),
|
||||
EntranceCoord("kennel", 0xa1, 88, 66),
|
||||
EntranceCoord("madambowwow:inside", 0x2a7, 80, 124),
|
||||
EntranceCoord("madambowwow", 0xa1, 56, 66),
|
||||
EntranceCoord("library:inside", 0x1fa, 80, 124),
|
||||
EntranceCoord("library", 0xb0, 56, 50),
|
||||
EntranceCoord("d5:inside", 0x1a1, 80, 124),
|
||||
EntranceCoord("d1", 0xd3, 104, 34),
|
||||
EntranceCoord("d1:inside", 0x117, 80, 124),
|
||||
EntranceCoord("d3:inside", 0x152, 80, 124),
|
||||
EntranceCoord("d3", 0xb5, 104, 32),
|
||||
EntranceCoord("banana_seller", 0xe3, 72, 48),
|
||||
EntranceCoord("armos_temple:inside", 0x28f, 80, 124),
|
||||
EntranceCoord("boomerang_cave", 0xf4, 24, 32),
|
||||
EntranceCoord("forest_madbatter:inside", 0x1e1, 136, 80),
|
||||
EntranceCoord("ghost_house", 0xf6, 88, 66),
|
||||
EntranceCoord("prairie_low_phone:inside", 0x29d, 80, 124),
|
||||
EntranceCoord("prairie_low_phone", 0xe8, 56, 98),
|
||||
EntranceCoord("prairie_madbatter_connector_entrance:inside", 0x1f6, 136, 112),
|
||||
EntranceCoord("prairie_madbatter_connector_entrance", 0xf9, 120, 80),
|
||||
EntranceCoord("prairie_madbatter_connector_exit", 0xe7, 104, 32),
|
||||
EntranceCoord("prairie_madbatter_connector_exit:inside", 0x1e5, 40, 48),
|
||||
EntranceCoord("ghost_house:inside", 0x1e3, 80, 124),
|
||||
EntranceCoord("prairie_madbatter", 0xe6, 72, 64),
|
||||
EntranceCoord("d4:inside", 0x17a, 80, 124),
|
||||
EntranceCoord("d5", 0xd9, 88, 64),
|
||||
EntranceCoord("prairie_right_cave_bottom:inside", 0x293, 48, 124),
|
||||
EntranceCoord("prairie_right_cave_bottom", 0xc8, 40, 80),
|
||||
EntranceCoord("prairie_right_cave_high", 0xb8, 88, 48),
|
||||
EntranceCoord("prairie_right_cave_high:inside", 0x295, 112, 124),
|
||||
EntranceCoord("prairie_right_cave_top", 0xb8, 120, 96),
|
||||
EntranceCoord("prairie_right_cave_top:inside", 0x292, 48, 124),
|
||||
EntranceCoord("prairie_to_animal_connector:inside", 0x2d0, 40, 64),
|
||||
EntranceCoord("prairie_to_animal_connector", 0xaa, 136, 64),
|
||||
EntranceCoord("animal_to_prairie_connector", 0xab, 120, 80),
|
||||
EntranceCoord("animal_to_prairie_connector:inside", 0x2d1, 120, 64),
|
||||
EntranceCoord("animal_phone:inside", 0x2e3, 80, 124),
|
||||
EntranceCoord("animal_phone", 0xdb, 120, 82),
|
||||
EntranceCoord("animal_house1:inside", 0x2db, 80, 124),
|
||||
EntranceCoord("animal_house1", 0xcc, 40, 80),
|
||||
EntranceCoord("animal_house2:inside", 0x2dd, 80, 124),
|
||||
EntranceCoord("animal_house2", 0xcc, 120, 80),
|
||||
EntranceCoord("hookshot_cave:inside", 0x2b3, 80, 124),
|
||||
EntranceCoord("animal_house3", 0xcd, 40, 80),
|
||||
EntranceCoord("animal_house4:inside", 0x2da, 80, 124),
|
||||
EntranceCoord("animal_house4", 0xcd, 88, 80),
|
||||
EntranceCoord("banana_seller:inside", 0x2fe, 80, 124),
|
||||
EntranceCoord("animal_house5", 0xdd, 88, 66),
|
||||
EntranceCoord("animal_cave:inside", 0x2f7, 96, 124),
|
||||
EntranceCoord("animal_cave", 0xcd, 136, 32),
|
||||
EntranceCoord("d6", 0x8c, 56, 64),
|
||||
EntranceCoord("madbatter_taltal:inside", 0x1e2, 136, 80),
|
||||
EntranceCoord("desert_cave", 0xcf, 88, 16),
|
||||
EntranceCoord("dream_hut:inside", 0x2aa, 80, 124),
|
||||
EntranceCoord("armos_maze_cave", 0xae, 72, 112),
|
||||
EntranceCoord("shop:inside", 0x2a1, 80, 124),
|
||||
EntranceCoord("armos_temple", 0xac, 88, 64),
|
||||
EntranceCoord("d6_connector_exit:inside", 0x1f0, 56, 16),
|
||||
EntranceCoord("d6_connector_exit", 0x9c, 88, 16),
|
||||
EntranceCoord("desert_cave:inside", 0x1f9, 120, 96),
|
||||
EntranceCoord("d6_connector_entrance:inside", 0x1f1, 136, 96),
|
||||
EntranceCoord("d6_connector_entrance", 0x9d, 56, 48),
|
||||
EntranceCoord("armos_fairy:inside", 0x1ac, 80, 124),
|
||||
EntranceCoord("armos_fairy", 0x8d, 56, 32),
|
||||
EntranceCoord("raft_return_enter:inside", 0x1f7, 136, 96),
|
||||
EntranceCoord("raft_return_enter", 0x8f, 8, 32),
|
||||
EntranceCoord("raft_return_exit", 0x2f, 24, 112),
|
||||
EntranceCoord("raft_return_exit:inside", 0x1e7, 72, 16),
|
||||
EntranceCoord("raft_house:inside", 0x2b0, 80, 124),
|
||||
EntranceCoord("raft_house", 0x3f, 40, 34),
|
||||
EntranceCoord("heartpiece_swim_cave:inside", 0x1f2, 72, 124),
|
||||
EntranceCoord("heartpiece_swim_cave", 0x2e, 88, 32),
|
||||
EntranceCoord("rooster_grave:inside", 0x1f4, 88, 112),
|
||||
EntranceCoord("d4", 0x2b, 72, 34),
|
||||
EntranceCoord("castle_phone:inside", 0x2cc, 80, 124),
|
||||
EntranceCoord("castle_phone", 0x4b, 72, 34),
|
||||
EntranceCoord("castle_main_entrance:inside", 0x2d3, 80, 124),
|
||||
EntranceCoord("castle_main_entrance", 0x69, 88, 64),
|
||||
EntranceCoord("castle_upper_left", 0x59, 24, 48),
|
||||
EntranceCoord("castle_upper_left:inside", 0x2d5, 80, 124),
|
||||
EntranceCoord("witch:inside", 0x2a2, 80, 124),
|
||||
EntranceCoord("castle_upper_right", 0x59, 88, 64),
|
||||
EntranceCoord("prairie_left_cave2:inside", 0x2f4, 64, 124),
|
||||
EntranceCoord("castle_jump_cave", 0x78, 40, 112),
|
||||
EntranceCoord("prairie_left_cave1:inside", 0x2cd, 80, 124),
|
||||
EntranceCoord("seashell_mansion", 0x8a, 88, 64),
|
||||
EntranceCoord("prairie_right_phone:inside", 0x29c, 80, 124),
|
||||
EntranceCoord("prairie_right_phone", 0x88, 88, 82),
|
||||
EntranceCoord("prairie_left_fairy:inside", 0x1f3, 80, 124),
|
||||
EntranceCoord("prairie_left_fairy", 0x87, 40, 16),
|
||||
EntranceCoord("bird_cave:inside", 0x27e, 96, 124),
|
||||
EntranceCoord("prairie_left_cave2", 0x86, 24, 64),
|
||||
EntranceCoord("prairie_left_cave1", 0x84, 152, 98),
|
||||
EntranceCoord("prairie_left_phone:inside", 0x2b4, 80, 124),
|
||||
EntranceCoord("prairie_left_phone", 0xa4, 56, 66),
|
||||
EntranceCoord("mamu:inside", 0x2fb, 136, 112),
|
||||
EntranceCoord("mamu", 0xd4, 136, 48),
|
||||
EntranceCoord("richard_house:inside", 0x2c7, 80, 124),
|
||||
EntranceCoord("richard_house", 0xd6, 72, 80),
|
||||
EntranceCoord("richard_maze:inside", 0x2c9, 128, 124),
|
||||
EntranceCoord("richard_maze", 0xc6, 56, 80),
|
||||
EntranceCoord("graveyard_cave_left:inside", 0x2de, 56, 64),
|
||||
EntranceCoord("graveyard_cave_left", 0x75, 56, 64),
|
||||
EntranceCoord("graveyard_cave_right:inside", 0x2df, 56, 48),
|
||||
EntranceCoord("graveyard_cave_right", 0x76, 104, 80),
|
||||
EntranceCoord("trendy_shop:inside", 0x2a0, 80, 124),
|
||||
EntranceCoord("d0", 0x77, 120, 46),
|
||||
EntranceCoord("boomerang_cave:inside", 0x1f5, 72, 124),
|
||||
EntranceCoord("witch", 0x65, 72, 50),
|
||||
EntranceCoord("toadstool_entrance:inside", 0x2bd, 80, 124),
|
||||
EntranceCoord("toadstool_entrance", 0x62, 120, 66),
|
||||
EntranceCoord("toadstool_exit", 0x50, 136, 50),
|
||||
EntranceCoord("toadstool_exit:inside", 0x2ab, 80, 124),
|
||||
EntranceCoord("prairie_madbatter:inside", 0x1e0, 136, 112),
|
||||
EntranceCoord("hookshot_cave", 0x42, 56, 66),
|
||||
EntranceCoord("castle_upper_right:inside", 0x2d6, 80, 124),
|
||||
EntranceCoord("forest_madbatter", 0x52, 104, 48),
|
||||
EntranceCoord("writes_phone:inside", 0x29b, 80, 124),
|
||||
EntranceCoord("writes_phone", 0x31, 104, 82),
|
||||
EntranceCoord("d0:inside", 0x312, 80, 92),
|
||||
EntranceCoord("writes_house", 0x30, 120, 50),
|
||||
EntranceCoord("writes_cave_left:inside", 0x2ae, 80, 124),
|
||||
EntranceCoord("writes_cave_left", 0x20, 136, 50),
|
||||
EntranceCoord("writes_cave_right:inside", 0x2af, 80, 124),
|
||||
EntranceCoord("writes_cave_right", 0x21, 24, 50),
|
||||
EntranceCoord("d6:inside", 0x1d4, 80, 124),
|
||||
EntranceCoord("d2", 0x24, 56, 34),
|
||||
EntranceCoord("animal_house5:inside", 0x2d7, 80, 124),
|
||||
EntranceCoord("moblin_cave", 0x35, 104, 80),
|
||||
EntranceCoord("crazy_tracy:inside", 0x2ad, 80, 124),
|
||||
EntranceCoord("crazy_tracy", 0x45, 136, 66),
|
||||
EntranceCoord("photo_house:inside", 0x2b5, 80, 124),
|
||||
EntranceCoord("photo_house", 0x37, 72, 66),
|
||||
EntranceCoord("obstacle_cave_entrance:inside", 0x2b6, 80, 124),
|
||||
EntranceCoord("obstacle_cave_entrance", 0x17, 56, 50),
|
||||
EntranceCoord("left_to_right_taltalentrance:inside", 0x2ee, 120, 48),
|
||||
EntranceCoord("left_to_right_taltalentrance", 0x7, 56, 80),
|
||||
EntranceCoord("obstacle_cave_outside_chest:inside", 0x2bb, 80, 124),
|
||||
EntranceCoord("obstacle_cave_outside_chest", 0x18, 104, 18),
|
||||
EntranceCoord("obstacle_cave_exit:inside", 0x2bc, 48, 124),
|
||||
EntranceCoord("obstacle_cave_exit", 0x18, 136, 18),
|
||||
EntranceCoord("papahl_entrance:inside", 0x289, 64, 124),
|
||||
EntranceCoord("papahl_entrance", 0x19, 136, 64),
|
||||
EntranceCoord("papahl_exit:inside", 0x28b, 80, 124),
|
||||
EntranceCoord("papahl_exit", 0xa, 24, 112),
|
||||
EntranceCoord("rooster_house:inside", 0x29f, 80, 124),
|
||||
EntranceCoord("rooster_house", 0xa, 72, 34),
|
||||
EntranceCoord("d7:inside", 0x20e, 80, 124),
|
||||
EntranceCoord("bird_cave", 0xa, 120, 112),
|
||||
EntranceCoord("multichest_top:inside", 0x2f2, 80, 124),
|
||||
EntranceCoord("multichest_top", 0xd, 24, 112),
|
||||
EntranceCoord("multichest_left:inside", 0x2f9, 32, 124),
|
||||
EntranceCoord("multichest_left", 0x1d, 24, 48),
|
||||
EntranceCoord("multichest_right:inside", 0x2fa, 112, 124),
|
||||
EntranceCoord("multichest_right", 0x1d, 120, 80),
|
||||
EntranceCoord("right_taltal_connector1:inside", 0x280, 32, 124),
|
||||
EntranceCoord("right_taltal_connector1", 0x1e, 56, 16),
|
||||
EntranceCoord("right_taltal_connector3:inside", 0x283, 128, 124),
|
||||
EntranceCoord("right_taltal_connector3", 0x1e, 120, 16),
|
||||
EntranceCoord("right_taltal_connector2:inside", 0x282, 112, 124),
|
||||
EntranceCoord("right_taltal_connector2", 0x1f, 40, 16),
|
||||
EntranceCoord("right_fairy:inside", 0x1fb, 80, 124),
|
||||
EntranceCoord("right_fairy", 0x1f, 56, 80),
|
||||
EntranceCoord("right_taltal_connector4:inside", 0x287, 96, 124),
|
||||
EntranceCoord("right_taltal_connector4", 0x1f, 88, 64),
|
||||
EntranceCoord("right_taltal_connector5:inside", 0x28c, 96, 124),
|
||||
EntranceCoord("right_taltal_connector5", 0x1f, 120, 16),
|
||||
EntranceCoord("right_taltal_connector6:inside", 0x28e, 112, 124),
|
||||
EntranceCoord("right_taltal_connector6", 0xf, 72, 80),
|
||||
EntranceCoord("d7", 0x0e, 88, 48),
|
||||
EntranceCoord("left_taltal_entrance:inside", 0x2ea, 80, 124),
|
||||
EntranceCoord("left_taltal_entrance", 0x15, 136, 64),
|
||||
EntranceCoord("castle_jump_cave:inside", 0x1fd, 88, 80),
|
||||
EntranceCoord("madbatter_taltal", 0x4, 120, 112),
|
||||
EntranceCoord("fire_cave_exit:inside", 0x1ee, 24, 64),
|
||||
EntranceCoord("fire_cave_exit", 0x3, 72, 80),
|
||||
EntranceCoord("fire_cave_entrance:inside", 0x1fe, 112, 124),
|
||||
EntranceCoord("fire_cave_entrance", 0x13, 88, 16),
|
||||
EntranceCoord("phone_d8:inside", 0x299, 80, 124),
|
||||
EntranceCoord("phone_d8", 0x11, 104, 50),
|
||||
EntranceCoord("kennel:inside", 0x2b2, 80, 124),
|
||||
EntranceCoord("d8", 0x10, 88, 16),
|
||||
EntranceCoord("d8:inside", 0x25d, 80, 124),
|
||||
]
|
||||
|
||||
entrance_lookup = {str(coord): coord for coord in entrance_coords}
|
||||
@@ -4,12 +4,13 @@ import os
|
||||
import pkgutil
|
||||
import tempfile
|
||||
import typing
|
||||
import logging
|
||||
import re
|
||||
|
||||
import bsdiff4
|
||||
|
||||
import settings
|
||||
from BaseClasses import Entrance, Item, ItemClassification, Location, Tutorial, MultiWorld
|
||||
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial, MultiWorld
|
||||
from Fill import fill_restrictive
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from .Common import *
|
||||
@@ -178,10 +179,10 @@ class LinksAwakeningWorld(World):
|
||||
|
||||
assert(start)
|
||||
|
||||
menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld)
|
||||
menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld)
|
||||
menu_region.exits = [Entrance(self.player, "Start Game", menu_region)]
|
||||
menu_region.exits[0].connect(start)
|
||||
|
||||
|
||||
self.multiworld.regions.append(menu_region)
|
||||
|
||||
# Place RAFT, other access events
|
||||
@@ -189,14 +190,14 @@ class LinksAwakeningWorld(World):
|
||||
for loc in region.locations:
|
||||
if loc.address is None:
|
||||
loc.place_locked_item(self.create_event(loc.ladxr_item.event))
|
||||
|
||||
|
||||
# Connect Windfish -> Victory
|
||||
windfish = self.multiworld.get_region("Windfish", self.player)
|
||||
l = Location(self.player, "Windfish", parent=windfish)
|
||||
windfish.locations = [l]
|
||||
|
||||
|
||||
l.place_locked_item(self.create_event("An Alarm Clock"))
|
||||
|
||||
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("An Alarm Clock", player=self.player)
|
||||
|
||||
def create_item(self, item_name: str):
|
||||
@@ -206,6 +207,8 @@ class LinksAwakeningWorld(World):
|
||||
return Item(event, ItemClassification.progression, None, self.player)
|
||||
|
||||
def create_items(self) -> None:
|
||||
itempool = []
|
||||
|
||||
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
|
||||
|
||||
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
|
||||
@@ -265,9 +268,9 @@ class LinksAwakeningWorld(World):
|
||||
self.prefill_own_dungeons.append(item)
|
||||
self.pre_fill_items.append(item)
|
||||
else:
|
||||
self.multiworld.itempool.append(item)
|
||||
itempool.append(item)
|
||||
else:
|
||||
self.multiworld.itempool.append(item)
|
||||
itempool.append(item)
|
||||
|
||||
self.multi_key = self.generate_multi_key()
|
||||
|
||||
@@ -276,8 +279,8 @@ class LinksAwakeningWorld(World):
|
||||
event_location = Location(self.player, "Can Play Trendy Game", parent=trendy_region)
|
||||
trendy_region.locations.insert(0, event_location)
|
||||
event_location.place_locked_item(self.create_event("Can Play Trendy Game"))
|
||||
|
||||
self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []]
|
||||
|
||||
self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []]
|
||||
for r in self.multiworld.get_regions(self.player):
|
||||
# Set aside dungeon locations
|
||||
if r.dungeon_index:
|
||||
@@ -290,21 +293,52 @@ class LinksAwakeningWorld(World):
|
||||
# Properly fill locations within dungeon
|
||||
location.dungeon = r.dungeon_index
|
||||
|
||||
# For now, special case first item
|
||||
FORCE_START_ITEM = True
|
||||
if FORCE_START_ITEM:
|
||||
self.force_start_item()
|
||||
if self.options.tarins_gift != "any_item":
|
||||
self.force_start_item(itempool)
|
||||
|
||||
def force_start_item(self):
|
||||
|
||||
self.multiworld.itempool += itempool
|
||||
|
||||
def force_start_item(self, itempool):
|
||||
start_loc = self.multiworld.get_location("Tarin's Gift (Mabe Village)", self.player)
|
||||
if not start_loc.item:
|
||||
possible_start_items = [index for index, item in enumerate(self.multiworld.itempool)
|
||||
if item.player == self.player
|
||||
and item.item_data.ladxr_id in start_loc.ladxr_item.OPTIONS and not item.location]
|
||||
if possible_start_items:
|
||||
index = self.random.choice(possible_start_items)
|
||||
start_item = self.multiworld.itempool.pop(index)
|
||||
"""
|
||||
Find an item that forces progression or a bush breaker for the player, depending on settings.
|
||||
"""
|
||||
def is_possible_start_item(item):
|
||||
return item.advancement and item.name not in self.options.non_local_items
|
||||
|
||||
def opens_new_regions(item):
|
||||
collection_state = base_collection_state.copy()
|
||||
collection_state.collect(item)
|
||||
return len(collection_state.reachable_regions[self.player]) > reachable_count
|
||||
|
||||
start_items = [item for item in itempool if is_possible_start_item(item)]
|
||||
self.random.shuffle(start_items)
|
||||
|
||||
if self.options.tarins_gift == "bush_breaker":
|
||||
start_item = next((item for item in start_items if item.name in links_awakening_item_name_groups["Bush Breakers"]), None)
|
||||
|
||||
else: # local_progression
|
||||
entrance_mapping = self.ladxr_logic.world_setup.entrance_mapping
|
||||
# Tail key opens a region but not a location if d1 entrance is not mapped to d1 or d4
|
||||
# exclude it in these cases to avoid fill errors
|
||||
if entrance_mapping['d1'] not in ['d1', 'd4']:
|
||||
start_items = [item for item in start_items if item.name != 'Tail Key']
|
||||
# Exclude shovel unless starting in Mabe Village
|
||||
if entrance_mapping['start_house'] not in ['start_house', 'shop']:
|
||||
start_items = [item for item in start_items if item.name != 'Shovel']
|
||||
base_collection_state = CollectionState(self.multiworld)
|
||||
base_collection_state.update_reachable_regions(self.player)
|
||||
reachable_count = len(base_collection_state.reachable_regions[self.player])
|
||||
start_item = next((item for item in start_items if opens_new_regions(item)), None)
|
||||
|
||||
if start_item:
|
||||
itempool.remove(start_item)
|
||||
start_loc.place_locked_item(start_item)
|
||||
else:
|
||||
logging.getLogger("Link's Awakening Logger").warning(f"No {self.options.tarins_gift.current_option_name} available for Tarin's Gift.")
|
||||
|
||||
|
||||
def get_pre_fill_items(self):
|
||||
return self.pre_fill_items
|
||||
@@ -315,11 +349,9 @@ class LinksAwakeningWorld(World):
|
||||
|
||||
# Set up filter rules
|
||||
|
||||
# The list of items we will pass to fill_restrictive, contains at first the items that go to all dungeons
|
||||
all_dungeon_items_to_fill = list(self.prefill_own_dungeons)
|
||||
# set containing the list of all possible dungeon locations for the player
|
||||
all_dungeon_locs = set()
|
||||
|
||||
|
||||
# Do dungeon specific things
|
||||
for dungeon_index in range(0, 9):
|
||||
# set up allow-list for dungeon specific items
|
||||
@@ -327,15 +359,12 @@ class LinksAwakeningWorld(World):
|
||||
for item in self.prefill_original_dungeon[dungeon_index]:
|
||||
allowed_locations_by_item[item] = locs
|
||||
|
||||
# put the items for this dungeon in the list to fill
|
||||
all_dungeon_items_to_fill.extend(self.prefill_original_dungeon[dungeon_index])
|
||||
|
||||
# ...and gather the list of all dungeon locations
|
||||
all_dungeon_locs |= locs
|
||||
# ...also set the rules for the dungeon
|
||||
for location in locs:
|
||||
orig_rule = location.item_rule
|
||||
# If an item is about to be placed on a dungeon location, it can go there iff
|
||||
# If an item is about to be placed on a dungeon location, it can go there iff
|
||||
# 1. it fits the general rules for that location (probably 'return True' for most places)
|
||||
# 2. Either
|
||||
# 2a. it's not a restricted dungeon item
|
||||
@@ -369,16 +398,27 @@ class LinksAwakeningWorld(World):
|
||||
if allowed_locations_by_item[item] is all_dungeon_locs:
|
||||
i += 3
|
||||
return i
|
||||
all_dungeon_items_to_fill = self.get_pre_fill_items()
|
||||
all_dungeon_items_to_fill.sort(key=priority)
|
||||
|
||||
# Set up state
|
||||
all_state = self.multiworld.get_all_state(use_cache=False)
|
||||
# Remove dungeon items we are about to put in from the state so that we don't double count
|
||||
for item in all_dungeon_items_to_fill:
|
||||
all_state.remove(item)
|
||||
|
||||
# Finally, fill!
|
||||
fill_restrictive(self.multiworld, all_state, all_dungeon_locs_to_fill, all_dungeon_items_to_fill, lock=True, single_player_placement=True, allow_partial=False)
|
||||
partial_all_state = CollectionState(self.multiworld)
|
||||
# Collect every item from the item pool and every pre-fill item like MultiWorld.get_all_state, except not our own pre-fill items.
|
||||
for item in self.multiworld.itempool:
|
||||
partial_all_state.collect(item, prevent_sweep=True)
|
||||
for player in self.multiworld.player_ids:
|
||||
if player == self.player:
|
||||
# Don't collect the items we're about to place.
|
||||
continue
|
||||
subworld = self.multiworld.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
partial_all_state.collect(item, prevent_sweep=True)
|
||||
|
||||
# Sweep to pick up already placed items that are reachable with everything but the dungeon items.
|
||||
partial_all_state.sweep_for_advancements()
|
||||
|
||||
fill_restrictive(self.multiworld, partial_all_state, all_dungeon_locs_to_fill, all_dungeon_items_to_fill, lock=True, single_player_placement=True, allow_partial=False)
|
||||
|
||||
|
||||
name_cache = {}
|
||||
# Tries to associate an icon from another game with an icon we have
|
||||
@@ -415,7 +455,7 @@ class LinksAwakeningWorld(World):
|
||||
for name in possibles:
|
||||
if name in self.name_cache:
|
||||
return self.name_cache[name]
|
||||
|
||||
|
||||
return "TRADING_ITEM_LETTER"
|
||||
|
||||
@classmethod
|
||||
@@ -430,7 +470,7 @@ class LinksAwakeningWorld(World):
|
||||
for loc in r.locations:
|
||||
if isinstance(loc, LinksAwakeningLocation):
|
||||
assert(loc.item)
|
||||
|
||||
|
||||
# If we're a links awakening item, just use the item
|
||||
if isinstance(loc.item, LinksAwakeningItem):
|
||||
loc.ladxr_item.item = loc.item.item_data.ladxr_id
|
||||
@@ -464,7 +504,7 @@ class LinksAwakeningWorld(World):
|
||||
args = parser.parse_args([rom_name, "-o", out_name, "--dump"])
|
||||
|
||||
rom = generator.generateRom(args, self)
|
||||
|
||||
|
||||
with open(out_path, "wb") as handle:
|
||||
rom.save(handle, name="LADXR")
|
||||
|
||||
@@ -472,7 +512,7 @@ class LinksAwakeningWorld(World):
|
||||
if self.options.ap_title_screen:
|
||||
with tempfile.NamedTemporaryFile(delete=False) as title_patch:
|
||||
title_patch.write(pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4"))
|
||||
|
||||
|
||||
bsdiff4.file_patch_inplace(out_path, title_patch.name)
|
||||
os.unlink(title_patch.name)
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ class MeritousWorld(World):
|
||||
|
||||
def set_rules(self):
|
||||
set_rules(self.multiworld, self.player)
|
||||
if self.goal == 0:
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has_any(
|
||||
["Victory", "Full Victory"], self.player)
|
||||
else:
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has(
|
||||
"Full Victory", self.player)
|
||||
|
||||
def generate_basic(self):
|
||||
self.multiworld.get_location("Place of Power", self.player).place_locked_item(
|
||||
@@ -166,13 +172,6 @@ class MeritousWorld(World):
|
||||
self.multiworld.get_location(boss, self.player).place_locked_item(
|
||||
self.create_item("Evolution Trap"))
|
||||
|
||||
if self.goal == 0:
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has_any(
|
||||
["Victory", "Full Victory"], self.player)
|
||||
else:
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has(
|
||||
"Full Victory", self.player)
|
||||
|
||||
def fill_slot_data(self) -> dict:
|
||||
return {
|
||||
"goal": self.goal,
|
||||
|
||||
@@ -228,7 +228,7 @@ class MessengerWorld(World):
|
||||
f"({self.options.total_seals}). Adjusting to {total_seals}"
|
||||
)
|
||||
self.total_seals = total_seals
|
||||
self.required_seals = int(self.options.percent_seals_required.value / 100 * self.total_seals)
|
||||
self.required_seals = max(1, int(self.options.percent_seals_required.value / 100 * self.total_seals))
|
||||
|
||||
seals = [self.create_item("Power Seal") for _ in range(self.total_seals)]
|
||||
itempool += seals
|
||||
|
||||
@@ -26,7 +26,7 @@ class MessengerRules:
|
||||
maximum_price = (world.multiworld.get_location("The Shop - Demon's Bane", self.player).cost +
|
||||
world.multiworld.get_location("The Shop - Focused Power Sense", self.player).cost)
|
||||
self.maximum_price = min(maximum_price, world.total_shards)
|
||||
self.required_seals = max(1, world.required_seals)
|
||||
self.required_seals = world.required_seals
|
||||
|
||||
# dict of connection names and requirements to traverse the exit
|
||||
self.connection_rules = {
|
||||
@@ -34,7 +34,7 @@ class MessengerRules:
|
||||
"Artificer's Portal":
|
||||
lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player),
|
||||
"Shrink Down":
|
||||
lambda state: state.has_all(NOTES, self.player) or self.has_enough_seals(state),
|
||||
lambda state: state.has_all(NOTES, self.player),
|
||||
# the shop
|
||||
"Money Sink":
|
||||
lambda state: state.has("Money Wrench", self.player) and self.can_shop(state),
|
||||
@@ -314,6 +314,9 @@ class MessengerRules:
|
||||
self.has_dart,
|
||||
}
|
||||
|
||||
if self.required_seals:
|
||||
self.connection_rules["Shrink Down"] = self.has_enough_seals
|
||||
|
||||
def has_wingsuit(self, state: CollectionState) -> bool:
|
||||
return state.has("Wingsuit", self.player)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from BaseClasses import ItemClassification, CollectionState
|
||||
from BaseClasses import CollectionState, ItemClassification
|
||||
from . import MessengerTestBase
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ class AllSealsRequired(MessengerTestBase):
|
||||
def test_chest_access(self) -> None:
|
||||
"""Defaults to a total of 45 power seals in the pool and required."""
|
||||
with self.subTest("Access Dependency"):
|
||||
self.assertEqual(len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]),
|
||||
self.world.options.total_seals)
|
||||
self.assertEqual(
|
||||
len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]),
|
||||
self.world.options.total_seals)
|
||||
locations = ["Rescue Phantom"]
|
||||
items = [["Power Seal"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
@@ -93,3 +94,22 @@ class MaxSealsWithShards(MessengerTestBase):
|
||||
if seal.classification == ItemClassification.progression_skip_balancing]
|
||||
self.assertEqual(len(total_seals), 85)
|
||||
self.assertEqual(len(required_seals), 85)
|
||||
|
||||
|
||||
class NoSealsRequired(MessengerTestBase):
|
||||
options = {
|
||||
"goal": "power_seal_hunt",
|
||||
"total_seals": 1,
|
||||
"percent_seals_required": 10, # percentage
|
||||
}
|
||||
|
||||
def test_seals_amount(self) -> None:
|
||||
"""Should be 1 seal and it should be progression."""
|
||||
self.assertEqual(self.world.options.total_seals, 1)
|
||||
self.assertEqual(self.world.total_seals, 1)
|
||||
self.assertEqual(self.world.required_seals, 1)
|
||||
total_seals = [item for item in self.multiworld.itempool if item.name == "Power Seal"]
|
||||
required_seals = [item for item in self.multiworld.itempool if
|
||||
item.advancement and item.name == "Power Seal"]
|
||||
self.assertEqual(len(total_seals), 1)
|
||||
self.assertEqual(len(required_seals), 1)
|
||||
|
||||
@@ -269,7 +269,7 @@ class MLSSClient(BizHawkClient):
|
||||
self.local_checked_locations = locs_to_send
|
||||
|
||||
if locs_to_send is not None:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": list(locs_to_send)}])
|
||||
await ctx.check_locations(locs_to_send)
|
||||
|
||||
except bizhawk.RequestFailedError:
|
||||
# Exit handler and return to main loop to reconnect.
|
||||
|
||||
@@ -153,7 +153,6 @@ enemies = [
|
||||
0x50458C,
|
||||
0x5045AC,
|
||||
0x50468C,
|
||||
# 0x5046CC, 6 enemy formation
|
||||
0x5046EC,
|
||||
0x50470C
|
||||
]
|
||||
@@ -166,6 +165,7 @@ bosses = [
|
||||
0x50360C,
|
||||
0x5037AC,
|
||||
0x5037CC,
|
||||
0x50396C,
|
||||
0x503A8C,
|
||||
0x503D6C,
|
||||
0x503F0C,
|
||||
|
||||
@@ -160,6 +160,7 @@ itemList: typing.List[ItemData] = [
|
||||
ItemData(77771142, "Game Boy Horror SP", ItemClassification.useful, 0xFE),
|
||||
ItemData(77771143, "Woo Bean", ItemClassification.skip_balancing, 0x1C),
|
||||
ItemData(77771144, "Hee Bean", ItemClassification.skip_balancing, 0x1F),
|
||||
ItemData(77771145, "Beanstar Emblem", ItemClassification.progression, 0x3E),
|
||||
]
|
||||
|
||||
item_frequencies: typing.Dict[str, int] = {
|
||||
@@ -186,5 +187,12 @@ item_frequencies: typing.Dict[str, int] = {
|
||||
"Hammers": 3,
|
||||
}
|
||||
|
||||
mlss_item_name_groups = {
|
||||
"Beanstar Piece": { "Beanstar Piece 1", "Beanstar Piece 2", "Beanstar Piece 3", "Beanstar Piece 4"},
|
||||
"Beanfruit": { "Bean Fruit 1", "Bean Fruit 2", "Bean Fruit 3", "Bean Fruit 4", "Bean Fruit 5", "Bean Fruit 6", "Bean Fruit 7"},
|
||||
"Neon Egg": { "Blue Neon Egg", "Red Neon Egg", "Green Neon Egg", "Yellow Neon Egg", "Purple Neon Egg", "Orange Neon Egg", "Azure Neon Egg"},
|
||||
"Chuckola Fruit": { "Red Chuckola Fruit", "Purple Chuckola Fruit", "White Chuckola Fruit"}
|
||||
}
|
||||
|
||||
item_table: typing.Dict[str, ItemData] = {item.itemName: item for item in itemList}
|
||||
items_by_id: typing.Dict[int, ItemData] = {item.code: item for item in itemList}
|
||||
|
||||
@@ -251,9 +251,9 @@ coins: typing.List[LocationData] = [
|
||||
LocationData("Hoohoo Village North Cave Room 1 Coin Block", 0x39DAA0, 0),
|
||||
LocationData("Hoohoo Village South Cave Coin Block 1", 0x39DAC5, 0),
|
||||
LocationData("Hoohoo Village South Cave Coin Block 2", 0x39DAD5, 0),
|
||||
LocationData("Hoohoo Mountain Base Boo Statue Cave Coin Block 1", 0x39DAE2, 0),
|
||||
LocationData("Hoohoo Mountain Base Boo Statue Cave Coin Block 2", 0x39DAF2, 0),
|
||||
LocationData("Hoohoo Mountain Base Boo Statue Cave Coin Block 3", 0x39DAFA, 0),
|
||||
LocationData("Hoohoo Mountain Base Boostatue Cave Coin Block 1", 0x39DAE2, 0),
|
||||
LocationData("Hoohoo Mountain Base Boostatue Cave Coin Block 2", 0x39DAF2, 0),
|
||||
LocationData("Hoohoo Mountain Base Boostatue Cave Coin Block 3", 0x39DAFA, 0),
|
||||
LocationData("Beanbean Outskirts NW Coin Block", 0x39DB8F, 0),
|
||||
LocationData("Beanbean Outskirts S Room 1 Coin Block", 0x39DC18, 0),
|
||||
LocationData("Beanbean Outskirts S Room 2 Coin Block", 0x39DC3D, 0),
|
||||
@@ -262,6 +262,8 @@ coins: typing.List[LocationData] = [
|
||||
LocationData("Chucklehuck Woods Cave Room 1 Coin Block", 0x39DD7A, 0),
|
||||
LocationData("Chucklehuck Woods Cave Room 2 Coin Block", 0x39DD97, 0),
|
||||
LocationData("Chucklehuck Woods Cave Room 3 Coin Block", 0x39DDB4, 0),
|
||||
LocationData("Chucklehuck Woods Solo Luigi Cave Room 1 Coin Block 1", 0x39DB48, 0),
|
||||
LocationData("Chucklehuck Woods Solo Luigi Cave Room 1 Coin Block 2", 0x39DB50, 0),
|
||||
LocationData("Chucklehuck Woods Pipe 5 Room Coin Block", 0x39DDE6, 0),
|
||||
LocationData("Chucklehuck Woods Room 7 Coin Block", 0x39DE31, 0),
|
||||
LocationData("Chucklehuck Woods Past Chuckleroot Coin Block", 0x39DF14, 0),
|
||||
@@ -289,6 +291,7 @@ baseUltraRocks: typing.List[LocationData] = [
|
||||
LocationData("Teehee Valley Upper Maze Room 1 Block", 0x39E5E0, 0),
|
||||
LocationData("Teehee Valley Upper Maze Room 2 Digspot 1", 0x39E5C8, 0),
|
||||
LocationData("Teehee Valley Upper Maze Room 2 Digspot 2", 0x39E5D0, 0),
|
||||
LocationData("Guffawha Ruins Block", 0x39E6A3, 0),
|
||||
LocationData("Hoohoo Mountain Base Guffawha Ruins Entrance Digspot", 0x39DA0B, 0),
|
||||
LocationData("Hoohoo Mountain Base Teehee Valley Entrance Digspot", 0x39DA20, 0),
|
||||
LocationData("Hoohoo Mountain Base Teehee Valley Entrance Block", 0x39DA18, 0),
|
||||
@@ -298,7 +301,7 @@ booStatue: typing.List[LocationData] = [
|
||||
LocationData("Beanbean Outskirts Before Harhall Digspot 1", 0x39E951, 0),
|
||||
LocationData("Beanbean Outskirts Before Harhall Digspot 2", 0x39E959, 0),
|
||||
LocationData("Beanstar Piece Harhall", 0x1E9441, 2),
|
||||
LocationData("Beanbean Outskirts Boo Statue Mole", 0x1E9434, 2),
|
||||
LocationData("Beanbean Outskirts Boostatue Mole", 0x1E9434, 2),
|
||||
LocationData("Harhall's Pants", 0x1E9444, 2),
|
||||
LocationData("Beanbean Outskirts S Room 2 Digspot 1", 0x39DC65, 0),
|
||||
LocationData("Beanbean Outskirts S Room 2 Digspot 2", 0x39DC5D, 0),
|
||||
@@ -317,6 +320,9 @@ chucklehuck: typing.List[LocationData] = [
|
||||
LocationData("Chucklehuck Woods Cave Room 1 Block 2", 0x39DD8A, 0),
|
||||
LocationData("Chucklehuck Woods Cave Room 2 Block", 0x39DD9F, 0),
|
||||
LocationData("Chucklehuck Woods Cave Room 3 Block", 0x39DDAC, 0),
|
||||
LocationData("Chucklehuck Woods Solo Luigi Cave Room 2 Block", 0x39DB72, 0),
|
||||
LocationData("Chucklehuck Woods Solo Luigi Cave Room 3 Block 1", 0x39DB5D, 0),
|
||||
LocationData("Chucklehuck Woods Solo Luigi Cave Room 3 Block 2", 0x39DB65, 0),
|
||||
LocationData("Chucklehuck Woods Room 2 Block", 0x39DDC1, 0),
|
||||
LocationData("Chucklehuck Woods Room 2 Digspot", 0x39DDC9, 0),
|
||||
LocationData("Chucklehuck Woods Pipe Room Block 1", 0x39DDD6, 0),
|
||||
@@ -786,7 +792,7 @@ nonBlock = [
|
||||
(0x4373, 0x10, 0x277A45), # Teehee Valley Mole
|
||||
(0x434D, 0x8, 0x1E9444), # Harhall's Pants
|
||||
(0x432E, 0x10, 0x1E9441), # Harhall Beanstar Piece
|
||||
(0x434B, 0x8, 0x1E9434), # Outskirts Boo Statue Mole
|
||||
(0x434B, 0x8, 0x1E9434), # Outskirts Boostatue Mole
|
||||
(0x42FE, 0x2, 0x1E943E), # Red Goblet
|
||||
(0x42FE, 0x4, 0x24E628), # Green Goblet
|
||||
(0x4301, 0x10, 0x250621), # Red Chuckola Fruit
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user