Merge remote-tracking branch 'upstream/main' into instruction_patch_clean

This commit is contained in:
Silvris
2024-03-14 16:31:50 -05:00
545 changed files with 26744 additions and 5216 deletions

View File

@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
@@ -50,7 +50,7 @@ jobs:
run: |
echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
if: env.diff != ''
with:
python-version: 3.8

View File

@@ -8,11 +8,13 @@ on:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
workflow_dispatch:
env:
@@ -25,9 +27,9 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.8'
- name: Download run-time dependencies
@@ -46,25 +48,42 @@ jobs:
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Store 7z
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
- name: Store Setup
uses: actions/upload-artifact@v4
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
runs-on: ubuntu-20.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build-time dependencies
@@ -100,13 +119,13 @@ jobs:
source venv/bin/activate
python setup.py build_exe --yes
- name: Store AppImage
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}

View File

@@ -43,7 +43,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -15,9 +15,10 @@ jobs:
steps:
- uses: actions/labeler@v5
with:
sync-labels: true
sync-labels: false
peer_review:
name: 'Apply peer review label'
needs: labeler
if: >-
(github.event.action == 'opened' || github.event.action == 'reopened' ||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
@@ -30,6 +31,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
unblock_draft_prs:
name: 'Remove waiting-on labels'
needs: labeler
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
runs-on: ubuntu-latest
steps:

View File

@@ -18,7 +18,7 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
@@ -35,14 +35,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build-time dependencies
@@ -74,7 +74,7 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Add to Release
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false

View File

@@ -46,9 +46,9 @@ jobs:
os: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies

View File

@@ -85,7 +85,7 @@ class MultiWorld():
game: Dict[int, str]
random: random.Random
per_slot_randoms: Dict[int, random.Random]
per_slot_randoms: Utils.DeprecateDict[int, random.Random]
"""Deprecated. Please use `self.random` instead."""
class AttributeProxy():
@@ -217,7 +217,8 @@ class MultiWorld():
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
@@ -251,14 +252,13 @@ class MultiWorld():
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
assert not self.worlds, "seed needs to be initialized before Worlds"
self.seed = get_seed(seed)
if secure:
self.secure()
else:
self.random.seed(self.seed)
self.seed_name = name if name else str(self.seed)
self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
@@ -275,7 +275,6 @@ class MultiWorld():
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
@@ -1033,7 +1032,7 @@ class Location:
locked: bool = False
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda item, state: False)
always_allow = staticmethod(lambda state, item: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None

View File

@@ -20,8 +20,8 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -72,9 +72,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
self.output(f'{len(self.ctx.items_received)} received items:')
item: NetworkItem
self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
parts = []
add_json_item(parts, item.item, self.ctx.slot, item.flags)
add_json_text(parts, " from ")
add_json_location(parts, item.location, item.player)
add_json_text(parts, " by ")
add_json_text(parts, item.player, type=JSONTypes.player_id)
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True
def _cmd_missing(self, filter_text = "") -> bool:
@@ -115,6 +122,15 @@ class ClientCommandProcessor(CommandProcessor):
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
if not self.ctx.game:
@@ -124,6 +140,15 @@ class ClientCommandProcessor(CommandProcessor):
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready

View File

@@ -208,7 +208,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item]) -> None:
itempool: typing.List[Item],
name: str = "Remaining") -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
@@ -265,10 +266,10 @@ def remaining_fill(multiworld: MultiWorld,
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
_log_fill_progress("Remaining", placed, total)
_log_fill_progress(name, placed, total)
if total > 1000:
_log_fill_progress("Remaining", placed, total)
_log_fill_progress(name, placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
@@ -466,7 +467,7 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")

View File

@@ -323,13 +323,29 @@ def roll_percentage(percentage: Union[int, float]) -> bool:
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
new_options = set(new_weights) - set(weights)
weights.update(new_weights)
cleaned_weights = {}
for option in new_weights:
option_name = option.lstrip("+")
if option.startswith("+") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, (set, dict)):
cleaned_value.update(new_value)
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options:
for new_option in new_options:
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
@@ -452,6 +468,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game]
if any(weight.startswith("+") for weight in game_weights) or \
any(weight.startswith("+") for weight in weights):
raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game]

View File

@@ -707,15 +707,18 @@ class Context:
self.save() # save goal completion flag
def on_new_hint(self, team: int, slot: int):
key: str = f"_read_hints_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
self.on_changed_hints(team, slot)
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
def on_changed_hints(self, team: int, slot: int):
key: str = f"_read_hints_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
@@ -975,7 +978,10 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only
}])
old_hints = ctx.hints[team, slot].copy()
ctx.recheck_hints(team, slot)
if old_hints != ctx.hints[team, slot]:
ctx.on_changed_hints(team, slot)
ctx.save()
@@ -1052,17 +1058,19 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches, " \
f"did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches, did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
class CommandMeta(type):
@@ -1964,7 +1972,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forbid_release(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command."""
"""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player

View File

@@ -290,8 +290,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
class Hint(typing.NamedTuple):

View File

@@ -12,7 +12,7 @@ from dataclasses import dataclass
from schema import And, Optional, Or, Schema
from Utils import get_fuzzy_results, is_iterable_of_str
from Utils import get_fuzzy_results, is_iterable_except_str
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
@@ -41,6 +41,11 @@ class AssembleOptions(abc.ABCMeta):
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
"default" in attrs or
any(hasattr(base, "default") for base in bases)
), f"Option class {name} needs default value"
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
@@ -96,7 +101,7 @@ T = typing.TypeVar('T')
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
default = 0
default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
@@ -106,8 +111,9 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.Dict[T, str]
options: typing.Dict[str, int]
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
@@ -160,6 +166,8 @@ class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
default = ""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@@ -180,6 +188,14 @@ class FreeText(Option[str]):
def get_option_name(cls, value: str) -> str:
return value
def __eq__(self, other):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
return other == self.value
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
@@ -803,7 +819,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default: typing.Dict[str, typing.Any] = {}
default = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
@@ -844,10 +860,10 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
# Not a docstring so it doesn't get grabbed by the options system.
default: typing.Union[typing.List[typing.Any], typing.Tuple[typing.Any, ...]] = ()
default = ()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
def __init__(self, value: typing.Iterable[typing.Any]):
self.value = list(deepcopy(value))
super(OptionList, self).__init__()
@@ -857,7 +873,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_of_str(data):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -870,7 +886,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
default = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
@@ -883,7 +899,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_of_str(data):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))

View File

@@ -225,6 +225,9 @@ class UniqueKeyLoader(SafeLoader):
if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key)
return super().construct_mapping(node, deep)
@@ -713,7 +716,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
import ctypes
style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk
try:
import tkinter
@@ -969,11 +972,8 @@ class RepeatableChain:
return sum(len(iterable) for iterable in self.iterable)
def is_iterable_of_str(obj: object) -> TypeGuard[typing.Iterable[str]]:
""" but not a `str` (because technically, `str` is `Iterable[str]`) """
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
""" `str` is `Iterable`, but that's not what we want """
if isinstance(obj, str):
return False
if not isinstance(obj, typing.Iterable):
return False
obj_it: typing.Iterable[object] = obj
return all(isinstance(v, str) for v in obj_it)
return isinstance(obj, typing.Iterable)

View File

@@ -61,36 +61,42 @@
found_text: "Found?"
TooltipLabel:
id: receiving
sort_key: 'receiving'
text: root.receiving_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: item
sort_key: 'item'
text: root.item_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: finding
sort_key: 'finding'
text: root.finding_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: location
sort_key: 'location'
text: root.location_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: entrance
sort_key: 'entrance'
text: root.entrance_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: found
sort_key: 'found'
text: root.found_text
halign: 'center'
valign: 'center'

View File

@@ -322,7 +322,7 @@ function processBlock(block)
end
end
end
if #itemsBlock ~= itemIndex then
if #itemsBlock > itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end

View File

@@ -31,8 +31,11 @@ ArchitecturesAllowed=x64 arm64
AllowNoIcons=yes
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
; you will likely have to remove the following signtool line when testing/debugging locally. Don't include that change in PRs.
#ifndef NO_SIGNTOOL
; You will likely have to remove the SignTool= line when testing/debugging locally or run with iscc.exe /DNO_SIGNTOOL.
; Don't include that change in PRs.
SignTool= signtool
#endif
LicenseFile= LICENSE
WizardStyle= modern
SetupLogging=yes
@@ -131,10 +134,10 @@ Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apkdl3"; ValueData: "{#MyAppName}kdl3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Archipelago Kirby's Dream Land 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apkdl3"; ValueData: "{#MyAppName}kdl3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Archipelago Kirby's Dream Land 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";

63
kvui.py
View File

@@ -2,6 +2,7 @@ import os
import logging
import sys
import typing
import re
if sys.platform == "win32":
import ctypes
@@ -72,6 +73,8 @@ if typing.TYPE_CHECKING:
else:
context_type = object
remove_between_brackets = re.compile(r"\[.*?]")
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
@@ -303,7 +306,6 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
no_select = []
def __init__(self):
super(HintLabel, self).__init__()
@@ -321,9 +323,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
def refresh_view_attrs(self, rv, index, data):
self.index = index
if "select" in data and not data["select"] and index not in self.no_select:
self.no_select.append(index)
self.striped = data["striped"]
self.striped = data.get("striped", False)
self.receiving_text = data["receiving"]["text"]
self.item_text = data["item"]["text"]
self.finding_text = data["finding"]["text"]
@@ -337,24 +337,44 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
""" Add selection on touch down """
if super(HintLabel, self).on_touch_down(touch):
return True
if self.index not in self.no_select:
if self.index: # skip header
if self.collide_point(*touch.pos):
if self.selected:
self.parent.clear_selection()
else:
text = "".join([self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
text = "".join((self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
self.finding_text, "\'s World", (" at " + self.entrance_text)
if self.entrance_text != "Vanilla"
else "", ". (", self.found_text.lower(), ")"])
else "", ". (", self.found_text.lower(), ")"))
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
else:
parent = self.parent
parent.clear_selection()
parent: HintLog = parent.parent
# find correct column
for child in self.children:
if child.collide_point(*touch.pos):
key = child.sort_key
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
else:
parent.sort_key = key
parent.reversed = False
break
else:
logging.warning("Did not find clicked header for sorting.")
App.get_running_app().update_hints()
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
if self.index not in self.no_select:
if self.index:
self.selected = is_selected
@@ -646,20 +666,20 @@ class HintLog(RecycleView):
"entrance": {"text": "[u]Entrance[/u]"},
"found": {"text": "[u]Status[/u]"},
"striped": True,
"select": False,
}
sort_key: str = ""
reversed: bool = False
def __init__(self, parser):
super(HintLog, self).__init__()
self.data = [self.header]
self.parser = parser
def refresh_hints(self, hints):
self.data = [self.header]
striped = False
data = []
for hint in hints:
self.data.append({
"striped": striped,
data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node(
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
@@ -672,7 +692,16 @@ class HintLog(RecycleView):
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red",
"text": "Found" if hint["found"] else "Not Found"})},
})
striped = not striped
data.sort(key=self.hint_sorter, reverse=self.reversed)
for i in range(0, len(data), 2):
data[i]["striped"] = True
data.insert(0, self.header)
self.data = data
@staticmethod
def hint_sorter(element: dict) -> str:
return ""
class E(ExceptionHandler):
@@ -721,8 +750,10 @@ class KivyJSONtoTextParser(JSONtoTextParser):
text = f"Game: {slot_info.game}<br>" \
f"Type: {SlotType(slot_info.type).name}"
if slot_info.group_members:
text += f"<br>Members:<br> " + \
"<br> ".join(self.ctx.player_names[player] for player in slot_info.group_members)
text += f"<br>Members:<br> " + "<br> ".join(
escape_markup(self.ctx.player_names[player])
for player in slot_info.group_members
)
node.setdefault("refs", []).append(text)
return super(KivyJSONtoTextParser, self)._handle_player_id(node)

View File

@@ -68,7 +68,6 @@ non_apworlds: set = {
"Archipelago",
"ChecksFinder",
"Clique",
"DLCQuest",
"Final Fantasy",
"Lufia II Ancient Cave",
"Meritous",

View File

@@ -10,7 +10,7 @@ from worlds import AutoWorld
from worlds.AutoWorld import World, call_all
from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
class TestBase(unittest.TestCase):
@@ -91,15 +91,15 @@ class TestBase(unittest.TestCase):
items = self.multiworld.itempool[:]
items = [item for item in items if
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(ItemFactory(item_pool[0], 1))
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
else:
items = ItemFactory(item_pool[0], 1)
items = item_factory(item_pool[0], self.multiworld.worlds[1])
return self.get_state(items)
def _get_items_partial(self, item_pool, missing_item):
new_items = item_pool[0].copy()
new_items.remove(missing_item)
items = ItemFactory(new_items, 1)
items = item_factory(new_items, self.multiworld.worlds[1])
return self.get_state(items)

View File

@@ -1,28 +1,50 @@
from argparse import Namespace
from typing import Type, Tuple
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import MultiWorld, CollectionState
from worlds.AutoWorld import call_all, World
from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_steps) -> MultiWorld:
def setup_solo_multiworld(
world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None
) -> MultiWorld:
"""
Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps.
:param world_type: Type of the world to generate a multiworld for
:param steps: The gen steps that should be called on the generated multiworld before returning. Default calls
steps through pre_fill
:param seed: The seed to be used when creating this multiworld
"""
multiworld = MultiWorld(1)
multiworld.game[1] = world_type.game
multiworld.player_name = {1: "Tester"}
multiworld.set_seed()
return setup_multiworld(world_type, steps, seed)
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
seed: Optional[int] = None) -> MultiWorld:
"""
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
calling the provided gen steps.
:param worlds: type/s of worlds to generate a multiworld for
:param steps: gen steps that should be called before returning. Default calls through pre_fill
:param seed: The seed to be used when creating this multiworld
"""
if not isinstance(worlds, list):
worlds = [worlds]
players = len(worlds)
multiworld = MultiWorld(players)
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed)
multiworld.state = CollectionState(multiworld)
args = Namespace()
for name, option in world_type.options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(option.default)})
for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items():
updated_options = getattr(args, key, {})
updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options)
multiworld.set_options(args)
for step in steps:
call_all(multiworld, step)

View File

@@ -13,6 +13,7 @@ from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules,
def generate_multiworld(players: int = 1) -> MultiWorld:
multiworld = MultiWorld(players)
multiworld.set_seed(0)
multiworld.player_name = {}
multiworld.state = CollectionState(multiworld)
for i in range(players):
@@ -32,8 +33,6 @@ def generate_multiworld(players: int = 1) -> MultiWorld:
world.options = world.options_dataclass(**{option_key: getattr(multiworld, option_key)[player_id]
for option_key in world.options_dataclass.type_hints})
multiworld.set_seed(0)
return multiworld

View File

@@ -8,7 +8,7 @@ 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 = world_type(None, 0) # this is identical to MultiServer.py creating worlds
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
for item_name in world_type.item_name_to_id:
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name)

View File

@@ -0,0 +1,39 @@
import unittest
import Generate
class TestPlayerOptions(unittest.TestCase):
def test_update_weights(self):
original_weights = {
"scalar_1": 50,
"scalar_2": 25,
"list_1": ["string"],
"dict_1": {"option_a": 50, "option_b": 50},
"dict_2": {"option_f": 50},
"set_1": {"option_c"}
}
# test that we don't allow +merge syntax on scalar variables
with self.assertRaises(BaseException):
Generate.update_weights(original_weights, {"+scalar_1": 0}, "Tested", "")
new_weights = Generate.update_weights(original_weights, {"scalar_2": 0,
"+list_1": ["string_2"],
"+dict_1": {"option_b": 0, "option_c": 50},
"+set_1": {"option_c", "option_d"},
"dict_2": {"option_g": 50},
"+list_2": ["string_3"]},
"Tested", "")
self.assertEqual(new_weights["scalar_1"], 50)
self.assertEqual(new_weights["scalar_2"], 0)
self.assertEqual(new_weights["list_2"], ["string_3"])
self.assertEqual(new_weights["list_1"], ["string", "string_2"])
self.assertEqual(new_weights["dict_1"]["option_a"], 50)
self.assertEqual(new_weights["dict_1"]["option_b"], 0)
self.assertEqual(new_weights["dict_1"]["option_c"], 50)
self.assertNotIn("option_f", new_weights["dict_2"])
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
self.assertEqual(len(new_weights["set_1"]), 2)
self.assertIn("option_d", new_weights["set_1"])

View File

View File

@@ -0,0 +1,77 @@
import unittest
from typing import List, Tuple
from unittest import TestCase
from BaseClasses import CollectionState, Location, MultiWorld
from Fill import distribute_items_restrictive
from Options import Accessibility
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
from ..general import gen_steps, setup_multiworld
class MultiworldTestBase(TestCase):
multiworld: MultiWorld
# similar to the implementation in WorldTestBase.test_fill
# but for multiple players and doesn't allow minimal accessibility
def fulfills_accessibility(self) -> bool:
"""
Checks that the multiworld satisfies locations accessibility requirements, failing if all locations are cleared
but not beatable, or some locations are unreachable.
"""
locations = [loc for loc in self.multiworld.get_locations()]
state = CollectionState(self.multiworld)
while locations:
sphere: List[Location] = []
for n in range(len(locations) - 1, -1, -1):
if locations[n].can_reach(state):
sphere.append(locations.pop(n))
self.assertTrue(sphere, f"Unreachable locations: {locations}")
if not sphere:
return False
for location in sphere:
if location.item:
state.collect(location.item, True, location)
return self.multiworld.has_beaten_game(state, 1)
def assertSteps(self, steps: Tuple[str, ...]) -> None:
"""Calls each step individually, continuing if a step for a specific world step fails."""
world_types = {world.__class__ for world in self.multiworld.worlds.values()}
for step in steps:
for player, world in self.multiworld.worlds.items():
with self.subTest(game=world.game, step=step):
call_single(self.multiworld, step, player)
for world_type in sorted(world_types, key=lambda world: world.__name__):
with self.subTest(game=world_type.game, step=f"stage_{step}"):
stage_callable = getattr(world_type, f"stage_{step}", None)
if stage_callable:
stage_callable(self.multiworld)
@unittest.skip("too slow for main")
class TestAllGamesMultiworld(MultiworldTestBase):
def test_fills(self) -> None:
"""Tests that a multiworld with one of every registered game world can generate."""
all_worlds = list(AutoWorldRegister.world_types.values())
self.multiworld = setup_multiworld(all_worlds, ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_locations
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
class TestTwoPlayerMulti(MultiworldTestBase):
def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate."""
for world in AutoWorldRegister.world_types.values():
self.multiworld = setup_multiworld([world, world], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_locations
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

View File

@@ -1,11 +1,35 @@
from __future__ import annotations
import abc
from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, Any, Optional
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union
from typing_extensions import TypeGuard
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
if TYPE_CHECKING:
from SNIClient import SNIContext
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"))
components.append(component)
def valid_patch_suffix(obj: object) -> TypeGuard[Union[str, Iterable[str]]]:
""" make sure this is a valid value for the class variable `patch_suffix` """
def valid_individual(one: object) -> TypeGuard[str]:
""" check an individual suffix """
# TODO: decide: len(one) > 3 and one.startswith(".ap") ?
# or keep it more general?
return isinstance(one, str) and len(one) > 1 and one.startswith(".")
if isinstance(obj, str):
return valid_individual(obj)
if not isinstance(obj, Iterable):
return False
obj_it: Iterable[object] = obj
return all(valid_individual(each) for each in obj_it)
class AutoSNIClientRegister(abc.ABCMeta):
game_handlers: ClassVar[Dict[str, SNIClient]] = {}
@@ -15,6 +39,22 @@ class AutoSNIClientRegister(abc.ABCMeta):
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoSNIClientRegister.game_handlers[dct["game"]] = new_class()
if "patch_suffix" in dct:
patch_suffix = dct["patch_suffix"]
assert valid_patch_suffix(patch_suffix), f"class {name} defining invalid {patch_suffix=}"
existing_identifier = component.file_identifier
assert isinstance(existing_identifier, SuffixIdentifier), f"{existing_identifier=}"
new_suffixes = [*existing_identifier.suffixes]
if isinstance(patch_suffix, str):
new_suffixes.append(patch_suffix)
else:
new_suffixes.extend(patch_suffix)
component.file_identifier = SuffixIdentifier(*new_suffixes)
return new_class
@staticmethod
@@ -27,6 +67,9 @@ class AutoSNIClientRegister(abc.ABCMeta):
class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
patch_suffix: ClassVar[Union[str, Iterable[str]]] = ()
"""The file extension(s) this client is meant to open and patch (e.g. ".aplttp")"""
@abc.abstractmethod
async def validate_rom(self, ctx: SNIContext) -> bool:
""" TODO: interface documentation here """

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import logging
import pathlib
import random
import re
import sys
import time
@@ -296,8 +297,11 @@ class World(metaclass=AutoWorldRegister):
"""path it was loaded from"""
def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None
self.multiworld = multiworld
self.player = player
self.random = random.Random(multiworld.random.getrandbits(64))
multiworld.per_slot_randoms[player] = self.random
def __getattr__(self, item: str) -> Any:
if item == "settings":
@@ -306,13 +310,15 @@ class World(metaclass=AutoWorldRegister):
# overridable methods that get called by Main.py, sorted by execution order
# can also be implemented as a classmethod and called "stage_<original_name>",
# in that case the MultiWorld object is passed as an argument and it gets called once for the entire multiworld.
# in that case the MultiWorld object is passed as an argument, and it gets called once for the entire multiworld.
# An example of this can be found in alttp as stage_pre_fill
@classmethod
def stage_assert_generate(cls, multiworld: "MultiWorld") -> None:
"""Checks that a game is capable of generating, usually checks for some base file like a ROM.
This gets called once per present world type. Not run for unittests since they don't produce output"""
"""
Checks that a game is capable of generating, such as checking for some base file like a ROM.
This gets called once per present world type. Not run for unittests since they don't produce output.
"""
pass
def generate_early(self) -> None:
@@ -357,16 +363,21 @@ class World(metaclass=AutoWorldRegister):
pass
def post_fill(self) -> None:
"""Optional Method that is called after regular fill. Can be used to do adjustments before output generation.
This happens before progression balancing, so the items may not be in their final locations yet."""
"""
Optional Method that is called after regular fill. Can be used to do adjustments before output generation.
This happens before progression balancing, so the items may not be in their final locations yet.
"""
def generate_output(self, output_directory: str) -> None:
"""This method gets called from a threadpool, do not use multiworld.random here.
If you need any last-second randomization, use self.random instead."""
"""
This method gets called from a threadpool, do not use multiworld.random here.
If you need any last-second randomization, use self.random instead.
"""
pass
def fill_slot_data(self) -> Mapping[str, Any]: # json of WebHostLib.models.Slot
"""What is returned from this function will be in the `slot_data` field
"""
What is returned from this function will be in the `slot_data` field
in the `Connected` network package.
It should be a `dict` with `str` keys, and should be serializable with json.
@@ -374,15 +385,18 @@ class World(metaclass=AutoWorldRegister):
The client will receive this as JSON in the `Connected` response.
The generation does not wait for `generate_output` to complete before calling this.
`threading.Event` can be used if you need to wait for something from `generate_output`."""
`threading.Event` can be used if you need to wait for something from `generate_output`.
"""
# The reason for the `Mapping` type annotation, rather than `dict`
# is so that type checkers won't worry about the mutability of `dict`,
# so you can have more specific typing in your world implementation.
return {}
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
"""Fill in additional entrance information text into locations, which is displayed when hinted.
structure is {player_id: {location_id: text}} You will need to insert your own player_id."""
"""
Fill in additional entrance information text into locations, which is displayed when hinted.
structure is {player_id: {location_id: text}} You will need to insert your own player_id.
"""
pass
def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata?
@@ -391,13 +405,17 @@ class World(metaclass=AutoWorldRegister):
# Spoiler writing is optional, these may not get called.
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
"""Write to the spoiler header. If individual it's right at the end of that player's options,
if as stage it's right under the common header before per-player options."""
"""
Write to the spoiler header. If individual it's right at the end of that player's options,
if as stage it's right under the common header before per-player options.
"""
pass
def write_spoiler(self, spoiler_handle: TextIO) -> None:
"""Write to the spoiler "middle", this is after the per-player options and before locations,
meant for useful or interesting info."""
"""
Write to the spoiler "middle", this is after the per-player options and before locations,
meant for useful or interesting info.
"""
pass
def write_spoiler_end(self, spoiler_handle: TextIO) -> None:
@@ -407,8 +425,10 @@ class World(metaclass=AutoWorldRegister):
# end of ordered Main.py calls
def create_item(self, name: str) -> "Item":
"""Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer"""
"""
Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer
"""
raise NotImplementedError
def get_filler_item_name(self) -> str:
@@ -418,34 +438,42 @@ class World(metaclass=AutoWorldRegister):
@classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
"""Creates a group, which is an instance of World that is responsible for multiple others.
An example case is ItemLinks creating these."""
"""
Creates a group, which is an instance of World that is responsible for multiple others.
An example case is ItemLinks creating these.
"""
# TODO remove loop when worlds use options dataclass
for option_key, option in cls.options_dataclass.type_hints.items():
getattr(multiworld, option_key)[new_player_id] = option(option.default)
getattr(multiworld, option_key)[new_player_id] = option.from_any(option.default)
group = cls(multiworld, new_player_id)
group.options = cls.options_dataclass(**{option_key: option(option.default)
group.options = cls.options_dataclass(**{option_key: option.from_any(option.default)
for option_key, option in cls.options_dataclass.type_hints.items()})
return group
# decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]:
"""Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
"""
Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
Collect None to skip item.
:param state: CollectionState to collect into
:param item: Item to decide on if it should be collected into state
:param remove: indicate if this is meant to remove from state instead of adding."""
:param remove: indicate if this is meant to remove from state instead of adding.
"""
if item.advancement:
return item.name
return None
# called to create all_state, return Items that are created during pre_fill
def get_pre_fill_items(self) -> List["Item"]:
"""
Used to return items that need to be collected when creating a fresh all_state, but don't exist in the
multiworld itempool.
"""
return []
# these two methods can be extended for pseudo-items on state
def collect(self, state: "CollectionState", item: "Item") -> bool:
"""Called when an item is collected in to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item)
if name:
state.prog_items[self.player][name] += 1
@@ -453,6 +481,7 @@ class World(metaclass=AutoWorldRegister):
return False
def remove(self, state: "CollectionState", item: "Item") -> bool:
"""Called when an item is removed from to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item, True)
if name:
state.prog_items[self.player][name] -= 1
@@ -461,6 +490,7 @@ class World(metaclass=AutoWorldRegister):
return True
return False
# following methods should not need to be overridden.
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())
@@ -509,7 +539,8 @@ def data_package_checksum(data: "GamesPackage") -> str:
def _normalize_description(description):
"""Normalizes a description in item_descriptions or location_descriptions.
"""
Normalizes a description in item_descriptions or location_descriptions.
This allows authors to write descritions with nice indentation and line lengths in their world
definitions without having it affect the rendered format.

View File

@@ -85,10 +85,6 @@ components: List[Component] = [
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
'.apsmw', '.apl2ac', '.apkdl3')),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),

View File

@@ -7,7 +7,6 @@ checking or launching the client, otherwise it will probably cause circular impo
import asyncio
import enum
import subprocess
import traceback
from typing import Any, Dict, Optional
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
@@ -260,7 +259,7 @@ def launch() -> None:
try:
await watcher_task
except Exception as e:
logger.error("".join(traceback.format_exception(e)))
logger.exception(e)
await ctx.exit_event.wait()
await ctx.shutdown()

View File

@@ -471,6 +471,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
class ALTTPSNIClient(SNIClient):
game = "A Link to the Past"
patch_suffix = [".aplttp", ".apz3"]
async def deathlink_kill_player(self, ctx):
from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes

View File

@@ -7,7 +7,7 @@ from BaseClasses import CollectionState, Region, MultiWorld
from Fill import fill_restrictive
from .Bosses import BossFactory, Boss
from .Items import ItemFactory
from .Items import item_factory
from .Regions import lookup_boss_drops, key_drop_data
from .Options import small_key_shuffle
@@ -81,90 +81,90 @@ def create_dungeons(world: "ALTTPWorld"):
return dungeon
ES = make_dungeon('Hyrule Castle', None, ['Hyrule Castle', 'Sewers', 'Sewer Drop', 'Sewers (Dark)', 'Sanctuary'],
ItemFactory('Big Key (Hyrule Castle)', player),
ItemFactory(['Small Key (Hyrule Castle)'] * 4, player),
[ItemFactory('Map (Hyrule Castle)', player)])
item_factory('Big Key (Hyrule Castle)', world),
item_factory(['Small Key (Hyrule Castle)'] * 4, world),
[item_factory('Map (Hyrule Castle)', world)])
EP = make_dungeon('Eastern Palace', 'Armos Knights', ['Eastern Palace'],
ItemFactory('Big Key (Eastern Palace)', player),
ItemFactory(['Small Key (Eastern Palace)'] * 2, player),
ItemFactory(['Map (Eastern Palace)', 'Compass (Eastern Palace)'], player))
item_factory('Big Key (Eastern Palace)', world),
item_factory(['Small Key (Eastern Palace)'] * 2, world),
item_factory(['Map (Eastern Palace)', 'Compass (Eastern Palace)'], world))
DP = make_dungeon('Desert Palace', 'Lanmolas',
['Desert Palace North', 'Desert Palace Main (Inner)', 'Desert Palace Main (Outer)',
'Desert Palace East'], ItemFactory('Big Key (Desert Palace)', player),
ItemFactory(['Small Key (Desert Palace)'] * 4, player),
ItemFactory(['Map (Desert Palace)', 'Compass (Desert Palace)'], player))
'Desert Palace East'], item_factory('Big Key (Desert Palace)', world),
item_factory(['Small Key (Desert Palace)'] * 4, world),
item_factory(['Map (Desert Palace)', 'Compass (Desert Palace)'], world))
ToH = make_dungeon('Tower of Hera', 'Moldorm',
['Tower of Hera (Bottom)', 'Tower of Hera (Basement)', 'Tower of Hera (Top)'],
ItemFactory('Big Key (Tower of Hera)', player),
[ItemFactory('Small Key (Tower of Hera)', player)],
ItemFactory(['Map (Tower of Hera)', 'Compass (Tower of Hera)'], player))
item_factory('Big Key (Tower of Hera)', world),
[item_factory('Small Key (Tower of Hera)', world)],
item_factory(['Map (Tower of Hera)', 'Compass (Tower of Hera)'], world))
PoD = make_dungeon('Palace of Darkness', 'Helmasaur King',
['Palace of Darkness (Entrance)', 'Palace of Darkness (Center)',
'Palace of Darkness (Big Key Chest)', 'Palace of Darkness (Bonk Section)',
'Palace of Darkness (North)', 'Palace of Darkness (Maze)',
'Palace of Darkness (Harmless Hellway)', 'Palace of Darkness (Final Section)'],
ItemFactory('Big Key (Palace of Darkness)', player),
ItemFactory(['Small Key (Palace of Darkness)'] * 6, player),
ItemFactory(['Map (Palace of Darkness)', 'Compass (Palace of Darkness)'], player))
item_factory('Big Key (Palace of Darkness)', world),
item_factory(['Small Key (Palace of Darkness)'] * 6, world),
item_factory(['Map (Palace of Darkness)', 'Compass (Palace of Darkness)'], world))
TT = make_dungeon('Thieves Town', 'Blind', ['Thieves Town (Entrance)', 'Thieves Town (Deep)', 'Blind Fight'],
ItemFactory('Big Key (Thieves Town)', player),
ItemFactory(['Small Key (Thieves Town)'] * 3, player),
ItemFactory(['Map (Thieves Town)', 'Compass (Thieves Town)'], player))
item_factory('Big Key (Thieves Town)', world),
item_factory(['Small Key (Thieves Town)'] * 3, world),
item_factory(['Map (Thieves Town)', 'Compass (Thieves Town)'], world))
SW = make_dungeon('Skull Woods', 'Mothula', ['Skull Woods Final Section (Entrance)', 'Skull Woods First Section',
'Skull Woods Second Section', 'Skull Woods Second Section (Drop)',
'Skull Woods Final Section (Mothula)',
'Skull Woods First Section (Right)',
'Skull Woods First Section (Left)', 'Skull Woods First Section (Top)'],
ItemFactory('Big Key (Skull Woods)', player),
ItemFactory(['Small Key (Skull Woods)'] * 5, player),
ItemFactory(['Map (Skull Woods)', 'Compass (Skull Woods)'], player))
item_factory('Big Key (Skull Woods)', world),
item_factory(['Small Key (Skull Woods)'] * 5, world),
item_factory(['Map (Skull Woods)', 'Compass (Skull Woods)'], world))
SP = make_dungeon('Swamp Palace', 'Arrghus',
['Swamp Palace (Entrance)', 'Swamp Palace (First Room)', 'Swamp Palace (Starting Area)',
'Swamp Palace (West)', 'Swamp Palace (Center)', 'Swamp Palace (North)'],
ItemFactory('Big Key (Swamp Palace)', player),
ItemFactory(['Small Key (Swamp Palace)'] * 6, player),
ItemFactory(['Map (Swamp Palace)', 'Compass (Swamp Palace)'], player))
item_factory('Big Key (Swamp Palace)', world),
item_factory(['Small Key (Swamp Palace)'] * 6, world),
item_factory(['Map (Swamp Palace)', 'Compass (Swamp Palace)'], world))
IP = make_dungeon('Ice Palace', 'Kholdstare',
['Ice Palace (Entrance)', 'Ice Palace (Second Section)', 'Ice Palace (Main)', 'Ice Palace (East)',
'Ice Palace (East Top)', 'Ice Palace (Kholdstare)'], ItemFactory('Big Key (Ice Palace)', player),
ItemFactory(['Small Key (Ice Palace)'] * 6, player),
ItemFactory(['Map (Ice Palace)', 'Compass (Ice Palace)'], player))
'Ice Palace (East Top)', 'Ice Palace (Kholdstare)'], item_factory('Big Key (Ice Palace)', world),
item_factory(['Small Key (Ice Palace)'] * 6, world),
item_factory(['Map (Ice Palace)', 'Compass (Ice Palace)'], world))
MM = make_dungeon('Misery Mire', 'Vitreous',
['Misery Mire (Entrance)', 'Misery Mire (Main)', 'Misery Mire (West)', 'Misery Mire (Final Area)',
'Misery Mire (Vitreous)'], ItemFactory('Big Key (Misery Mire)', player),
ItemFactory(['Small Key (Misery Mire)'] * 6, player),
ItemFactory(['Map (Misery Mire)', 'Compass (Misery Mire)'], player))
'Misery Mire (Vitreous)'], item_factory('Big Key (Misery Mire)', world),
item_factory(['Small Key (Misery Mire)'] * 6, world),
item_factory(['Map (Misery Mire)', 'Compass (Misery Mire)'], world))
TR = make_dungeon('Turtle Rock', 'Trinexx',
['Turtle Rock (Entrance)', 'Turtle Rock (First Section)', 'Turtle Rock (Chain Chomp Room)',
'Turtle Rock (Pokey Room)',
'Turtle Rock (Second Section)', 'Turtle Rock (Big Chest)', 'Turtle Rock (Crystaroller Room)',
'Turtle Rock (Dark Room)', 'Turtle Rock (Eye Bridge)', 'Turtle Rock (Trinexx)'],
ItemFactory('Big Key (Turtle Rock)', player),
ItemFactory(['Small Key (Turtle Rock)'] * 6, player),
ItemFactory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], player))
item_factory('Big Key (Turtle Rock)', world),
item_factory(['Small Key (Turtle Rock)'] * 6, world),
item_factory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], world))
if multiworld.mode[player] != 'inverted':
AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None,
ItemFactory(['Small Key (Agahnims Tower)'] * 4, player), [])
item_factory(['Small Key (Agahnims Tower)'] * 4, world), [])
GT = make_dungeon('Ganons Tower', 'Agahnim2',
['Ganons Tower (Entrance)', 'Ganons Tower (Tile Room)', 'Ganons Tower (Compass Room)',
'Ganons Tower (Hookshot Room)', 'Ganons Tower (Map Room)', 'Ganons Tower (Firesnake Room)',
'Ganons Tower (Teleport Room)', 'Ganons Tower (Bottom)', 'Ganons Tower (Top)',
'Ganons Tower (Before Moldorm)', 'Ganons Tower (Moldorm)', 'Agahnim 2'],
ItemFactory('Big Key (Ganons Tower)', player),
ItemFactory(['Small Key (Ganons Tower)'] * 8, player),
ItemFactory(['Map (Ganons Tower)', 'Compass (Ganons Tower)'], player))
item_factory('Big Key (Ganons Tower)', world),
item_factory(['Small Key (Ganons Tower)'] * 8, world),
item_factory(['Map (Ganons Tower)', 'Compass (Ganons Tower)'], world))
else:
AT = make_dungeon('Inverted Agahnims Tower', 'Agahnim', ['Inverted Agahnims Tower', 'Agahnim 1'], None,
ItemFactory(['Small Key (Agahnims Tower)'] * 4, player), [])
item_factory(['Small Key (Agahnims Tower)'] * 4, world), [])
GT = make_dungeon('Inverted Ganons Tower', 'Agahnim2',
['Inverted Ganons Tower (Entrance)', 'Ganons Tower (Tile Room)',
'Ganons Tower (Compass Room)', 'Ganons Tower (Hookshot Room)', 'Ganons Tower (Map Room)',
'Ganons Tower (Firesnake Room)', 'Ganons Tower (Teleport Room)', 'Ganons Tower (Bottom)',
'Ganons Tower (Top)', 'Ganons Tower (Before Moldorm)', 'Ganons Tower (Moldorm)',
'Agahnim 2'], ItemFactory('Big Key (Ganons Tower)', player),
ItemFactory(['Small Key (Ganons Tower)'] * 8, player),
ItemFactory(['Map (Ganons Tower)', 'Compass (Ganons Tower)'], player))
'Agahnim 2'], item_factory('Big Key (Ganons Tower)', world),
item_factory(['Small Key (Ganons Tower)'] * 8, world),
item_factory(['Map (Ganons Tower)', 'Compass (Ganons Tower)'], world))
GT.bosses['bottom'] = BossFactory('Armos Knights', player)
GT.bosses['middle'] = BossFactory('Lanmolas', player)
@@ -259,7 +259,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
if not key_drop_shuffle and player not in multiworld.groups:
for key_loc in key_drop_data:
key_data = key_drop_data[key_loc]
all_state_base.remove(ItemFactory(key_data[3], player))
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
loc = multiworld.get_location(key_loc, player)
if loc in all_state_base.events:

View File

@@ -9,8 +9,8 @@ from .Shops import TakeAny, total_shop_slots, set_up_shops, shop_table_by_locati
from .Bosses import place_bosses
from .Dungeons import get_dungeon_item_pool_player
from .EntranceShuffle import connect_entrance
from .Items import ItemFactory, GetBeemizerItem, trap_replaceable, item_name_groups
from .Options import small_key_shuffle, compass_shuffle, big_key_shuffle, map_shuffle, TriforcePiecesMode
from .Items import item_factory, GetBeemizerItem, trap_replaceable, item_name_groups
from .Options import small_key_shuffle, compass_shuffle, big_key_shuffle, map_shuffle, TriforcePiecesMode, LTTPBosses
from .StateHelpers import has_triforce_pieces, has_melee_weapon
from .Regions import key_drop_data
@@ -234,15 +234,15 @@ def generate_itempool(world):
raise NotImplementedError(f"Goal {multiworld.goal[player]} for player {player}")
if multiworld.mode[player] not in ('open', 'standard', 'inverted'):
raise NotImplementedError(f"Mode {multiworld.mode[player]} for player {player}")
if multiworld.timer[player] not in {False, 'display', 'timed', 'timed_ohko', 'ohko', 'timed_countdown'}:
raise NotImplementedError(f"Timer {multiworld.mode[player]} for player {player}")
if multiworld.timer[player] not in (False, 'display', 'timed', 'timed_ohko', 'ohko', 'timed_countdown'):
raise NotImplementedError(f"Timer {multiworld.timer[player]} for player {player}")
if multiworld.timer[player] in ['ohko', 'timed_ohko']:
multiworld.can_take_damage[player] = False
if multiworld.goal[player] in ['pedestal', 'triforce_hunt', 'local_triforce_hunt']:
multiworld.push_item(multiworld.get_location('Ganon', player), ItemFactory('Nothing', player), False)
multiworld.push_item(multiworld.get_location('Ganon', player), item_factory('Nothing', world), False)
else:
multiworld.push_item(multiworld.get_location('Ganon', player), ItemFactory('Triforce', player), False)
multiworld.push_item(multiworld.get_location('Ganon', player), item_factory('Triforce', world), False)
if multiworld.goal[player] in ['triforce_hunt', 'local_triforce_hunt']:
region = multiworld.get_region('Light World', player)
@@ -252,7 +252,7 @@ def generate_itempool(world):
region.locations.append(loc)
multiworld.push_item(loc, ItemFactory('Triforce', player), False)
multiworld.push_item(loc, item_factory('Triforce', world), False)
loc.event = True
loc.locked = True
@@ -271,7 +271,7 @@ def generate_itempool(world):
]
for location_name, event_name in event_pairs:
location = multiworld.get_location(location_name, player)
event = ItemFactory(event_name, player)
event = item_factory(event_name, world)
multiworld.push_item(location, event, False)
location.event = location.locked = True
@@ -287,7 +287,7 @@ def generate_itempool(world):
treasure_hunt_icon, additional_triforce_pieces = get_pool_core(multiworld, player)
for item in precollected_items:
multiworld.push_precollected(ItemFactory(item, player))
multiworld.push_precollected(item_factory(item, world))
if multiworld.mode[player] == 'standard' and not has_melee_weapon(multiworld.state, player):
if "Link's Uncle" not in placed_items:
@@ -326,9 +326,9 @@ def generate_itempool(world):
multiworld.escape_assist[player].append('bombs')
for (location, item) in placed_items.items():
multiworld.get_location(location, player).place_locked_item(ItemFactory(item, player))
multiworld.get_location(location, player).place_locked_item(item_factory(item, world))
items = ItemFactory(pool, player)
items = item_factory(pool, world)
# convert one Progressive Bow into Progressive Bow (Alt), in ID only, for ganon silvers hint text
if multiworld.worlds[player].has_progressive_bows:
for item in items:
@@ -349,7 +349,7 @@ def generate_itempool(world):
for key_loc in key_drop_data:
key_data = key_drop_data[key_loc]
drop_item = ItemFactory(key_data[3], player)
drop_item = item_factory(key_data[3], world)
if not multiworld.key_drop_shuffle[player]:
if drop_item in dungeon_items:
dungeon_items.remove(drop_item)
@@ -370,7 +370,7 @@ def generate_itempool(world):
loc.address = None
elif "Small" in key_data[3] and multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal:
# key drop shuffle and universal keys are on. Add universal keys in place of key drop keys.
multiworld.itempool.append(ItemFactory(GetBeemizerItem(multiworld, player, 'Small Key (Universal)'), player))
multiworld.itempool.append(item_factory(GetBeemizerItem(multiworld, player, 'Small Key (Universal)'), world))
dungeon_item_replacements = sum(difficulties[multiworld.difficulty[player]].extras, []) * 2
multiworld.random.shuffle(dungeon_item_replacements)
@@ -382,7 +382,7 @@ def generate_itempool(world):
or (multiworld.map_shuffle[player] == map_shuffle.option_start_with and item.type == 'Map')):
dungeon_items.pop(x)
multiworld.push_precollected(item)
multiworld.itempool.append(ItemFactory(dungeon_item_replacements.pop(), player))
multiworld.itempool.append(item_factory(dungeon_item_replacements.pop(), world))
multiworld.itempool.extend([item for item in dungeon_items])
set_up_shops(multiworld, player)
@@ -394,7 +394,7 @@ def generate_itempool(world):
location.shop_slot is not None]
for location in shop_locations:
if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow":
location.place_locked_item(ItemFactory("Single Arrow", player))
location.place_locked_item(item_factory("Single Arrow", world))
else:
shop_items += 1
else:
@@ -406,9 +406,9 @@ def generate_itempool(world):
multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal) * 0.5
for _ in range(shop_items):
if multiworld.random.random() < chance_100:
items.append(ItemFactory(GetBeemizerItem(multiworld, player, "Rupees (100)"), player))
items.append(item_factory(GetBeemizerItem(multiworld, player, "Rupees (100)"), world))
else:
items.append(ItemFactory(GetBeemizerItem(multiworld, player, "Rupees (50)"), player))
items.append(item_factory(GetBeemizerItem(multiworld, player, "Rupees (50)"), world))
multiworld.random.shuffle(items)
pool_count = len(items)
@@ -431,7 +431,7 @@ def generate_itempool(world):
new_items += ["Arrow Upgrade (+5)"] * 6
new_items.append("Arrow Upgrade (+5)" if progressive else "Arrow Upgrade (+10)")
items += [ItemFactory(item, player) for item in new_items]
items += [item_factory(item, world) for item in new_items]
removed_filler = []
multiworld.random.shuffle(items) # Decide what gets tossed randomly.
@@ -444,22 +444,22 @@ def generate_itempool(world):
else:
# no more junk to remove, condense progressive items
def condense_items(items, small_item, big_item, rem, add):
small_item = ItemFactory(small_item, player)
small_item = item_factory(small_item, world)
# while (len(items) >= pool_count + rem - 1 # minus 1 to account for the replacement item
# and items.count(small_item) >= rem):
if items.count(small_item) >= rem:
for _ in range(rem):
items.remove(small_item)
removed_filler.append(ItemFactory(small_item.name, player))
items += [ItemFactory(big_item, player) for _ in range(add)]
removed_filler.append(item_factory(small_item.name, world))
items += [item_factory(big_item, world) for _ in range(add)]
return True
return False
def cut_item(items, item_to_cut, minimum_items):
item_to_cut = ItemFactory(item_to_cut, player)
item_to_cut = item_factory(item_to_cut, world)
if items.count(item_to_cut) > minimum_items:
items.remove(item_to_cut)
removed_filler.append(ItemFactory(item_to_cut.name, player))
removed_filler.append(item_factory(item_to_cut.name, world))
return True
return False
@@ -551,7 +551,7 @@ def set_up_take_anys(world, player):
if swords:
sword = world.random.choice(swords)
world.itempool.remove(sword)
world.itempool.append(ItemFactory('Rupees (20)', player))
world.itempool.append(item_factory('Rupees (20)', world))
old_man_take_any.shop.add_inventory(0, sword.name, 0, 0)
loc_name = "Old Man Sword Cave"
location = ALttPLocation(player, loc_name, shop_table_by_location[loc_name], parent=old_man_take_any)
@@ -577,7 +577,7 @@ def set_up_take_anys(world, player):
location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any)
location.shop_slot = 1
take_any.locations.append(location)
location.place_locked_item(ItemFactory("Boss Heart Container", player))
location.place_locked_item(item_factory("Boss Heart Container", world))
def get_pool_core(world, player: int):
@@ -605,7 +605,7 @@ def get_pool_core(world, player: int):
placed_items[loc] = item
# provide boots to major glitch dependent seeds
if logic in {'overworld_glitches', 'hybrid_major_glitches', 'no_logic'} and world.glitch_boots[player]:
if logic.current_key in {'overworld_glitches', 'hybrid_major_glitches', 'no_logic'} and world.glitch_boots[player]:
precollected_items.append('Pegasus Boots')
pool.remove('Pegasus Boots')
pool.append('Rupees (20)')

View File

@@ -1,6 +1,7 @@
import typing
from BaseClasses import ItemClassification as IC
from worlds.AutoWorld import World
def GetBeemizerItem(world, player: int, item):
@@ -17,13 +18,10 @@ def GetBeemizerItem(world, player: int, item):
if not world.beemizer_trap_chance[player] or world.random.random() > (world.beemizer_trap_chance[player] / 100):
return "Bee" if isinstance(item, str) else world.create_item("Bee", player)
else:
return "Bee Trap" if isinstance(item, str) else world.create_item("Bee Trap", player)
return "Bee Trap" if isinstance(item, str) else world.create_item("Bee Trap", player)
# should be replaced with direct world.create_item(item) call in the future
def ItemFactory(items: typing.Union[str, typing.Iterable[str]], player: int):
from worlds.alttp import ALTTPWorld
world = ALTTPWorld(None, player)
def item_factory(items: typing.Union[str, typing.Iterable[str]], world: World):
ret = []
singleton = False
if isinstance(items, str):

View File

@@ -156,7 +156,7 @@ class OpenPyramid(Choice):
return world.goal[player].current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'}
elif self.value == self.option_auto:
return world.goal[player].current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} \
and (world.entrance_shuffle[player] in {'vanilla', 'dungeons_simple', 'dungeons_full', 'dungeons_crossed'} or not
and (world.entrance_shuffle[player].current_key in {'vanilla', 'dungeons_simple', 'dungeons_full', 'dungeons_crossed'} or not
world.shuffle_ganon)
elif self.value == self.option_open:
return True

View File

@@ -34,7 +34,7 @@ from .Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts, Blacksmith
DeathMountain_texts, \
LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, \
SickKid_texts, FluteBoy_texts, Zora_texts, MagicShop_texts, Sahasrahla_names
from .Items import ItemFactory, item_table, item_name_groups, progression_items
from .Items import item_table, item_name_groups, progression_items
from .EntranceShuffle import door_addresses
from .Options import small_key_shuffle
@@ -996,7 +996,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00)
GREEN_TWENTY_RUPEES = 0x47
GREEN_CLOCK = ItemFactory('Green Clock', player).code
GREEN_CLOCK = item_table["Green Clock"].item_code
rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on
@@ -1777,13 +1777,13 @@ def write_custom_shops(rom, world, player):
if item['player'] and world.game[item['player']] != "A Link to the Past": # item not native to ALTTP
item_code = get_nonnative_item_sprite(world.worlds[item['player']].item_name_to_id[item['item']])
else:
item_code = ItemFactory(item['item'], player).code
item_code = item_table[item["item"]].item_code
if item['item'] == 'Single Arrow' and item['player'] == 0 and world.retro_bow[player]:
rom.write_byte(0x186500 + shop.sram_offset + slot, arrow_mask)
item_data = [shop_id, item_code] + price_data + \
[item['max'], ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF] + \
replacement_price_data + [0 if item['player'] == player else min(ROM_PLAYER_LIMIT, item['player'])]
[item["max"], item_table[item["replacement"]].item_code if item["replacement"] else 0xFF] + \
replacement_price_data + [0 if item["player"] == player else min(ROM_PLAYER_LIMIT, item["player"])]
items_data.extend(item_data)
rom.write_bytes(0x184800, shop_data)

View File

@@ -8,7 +8,7 @@ from worlds.generic.Rules import (add_item_rule, add_rule, forbid_item,
from . import OverworldGlitchRules
from .Bosses import GanonDefeatRule
from .Items import ItemFactory, item_name_groups, item_table, progression_items
from .Items import item_factory, item_name_groups, item_table, progression_items
from .Options import small_key_shuffle
from .OverworldGlitchRules import no_logic_rules, overworld_glitches_rules
from .Regions import LTTPRegionType, location_table
@@ -89,7 +89,7 @@ def set_rules(world):
if world.mode[player] != 'inverted':
set_big_bomb_rules(world, player)
if world.glitches_required[player] in {'overworld_glitches', 'hybrid_major_glitches', 'no_logic'} and world.entrance_shuffle[player] not in {'insanity', 'insanity_legacy', 'madness'}:
if world.glitches_required[player].current_key in {'overworld_glitches', 'hybrid_major_glitches', 'no_logic'} and world.entrance_shuffle[player].current_key not in {'insanity', 'insanity_legacy', 'madness'}:
path_to_courtyard = mirrorless_path_to_castle_courtyard(world, player)
add_rule(world.get_entrance('Pyramid Fairy', player), lambda state: state.multiworld.get_entrance('Dark Death Mountain Offset Mirror', player).can_reach(state) and all(rule(state) for rule in path_to_courtyard), 'or')
else:
@@ -1181,7 +1181,7 @@ def set_trock_key_rules(world, player):
forbid_item(world.get_location(location, player), 'Big Key (Turtle Rock)', player)
else:
# A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works
item = ItemFactory('Small Key (Turtle Rock)', player)
item = item_factory('Small Key (Turtle Rock)', world.worlds[player])
location = world.get_location('Turtle Rock - Big Key Chest', player)
location.place_locked_item(item)
location.event = True

View File

@@ -642,17 +642,18 @@ class ALTTPWorld(World):
return ALttPItem(name, self.player, **item_init_table[name])
@classmethod
def stage_fill_hook(cls, world, progitempool, usefulitempool, filleritempool, fill_locations):
def stage_fill_hook(cls, multiworld, progitempool, usefulitempool, filleritempool, fill_locations):
trash_counts = {}
for player in world.get_game_players("A Link to the Past"):
if not world.ganonstower_vanilla[player] or \
world.glitches_required[player] in {'overworld_glitches', 'hybrid_major_glitches', "no_logic"}:
for player in multiworld.get_game_players("A Link to the Past"):
world = multiworld.worlds[player]
if not multiworld.ganonstower_vanilla[player] or \
world.options.glitches_required.current_key in {'overworld_glitches', 'hybrid_major_glitches', "no_logic"}:
pass
elif 'triforce_hunt' in world.goal[player].current_key and ('local' in world.goal[player].current_key or world.players == 1):
trash_counts[player] = world.random.randint(world.crystals_needed_for_gt[player] * 2,
world.crystals_needed_for_gt[player] * 4)
elif 'triforce_hunt' in world.options.goal.current_key and ('local' in world.options.goal.current_key or world.players == 1):
trash_counts[player] = multiworld.random.randint(world.options.crystals_needed_for_gt * 2,
world.options.crystals_needed_for_gt * 4)
else:
trash_counts[player] = world.random.randint(0, world.crystals_needed_for_gt[player] * 2)
trash_counts[player] = multiworld.random.randint(0, world.options.crystals_needed_for_gt * 2)
if trash_counts:
locations_mapping = {player: [] for player in trash_counts}
@@ -662,14 +663,14 @@ class ALTTPWorld(World):
for player, trash_count in trash_counts.items():
gtower_locations = locations_mapping[player]
world.random.shuffle(gtower_locations)
multiworld.random.shuffle(gtower_locations)
while gtower_locations and filleritempool and trash_count > 0:
spot_to_fill = gtower_locations.pop()
for index, item in enumerate(filleritempool):
if spot_to_fill.item_rule(item):
filleritempool.pop(index) # remove from outer fill
world.push_item(spot_to_fill, item, False)
multiworld.push_item(spot_to_fill, item, False)
fill_locations.remove(spot_to_fill) # very slow, unfortunately
trash_count -= 1
break

View File

@@ -5,11 +5,12 @@
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
- [SNI](https://github.com/alttpo/sni/releases). This is automatically included with your Archipelago installation above.
- SNI is not compatible with (Q)Usb2Snes.
- Hardware or software capable of loading and playing SNES ROM files
- Hardware or software capable of loading and playing SNES ROM files, including:
- An emulator capable of connecting to SNI
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
[BizHawk](https://tasvideos.org/BizHawk), or
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 or newer). Or,
([snes9x-nwa](https://github.com/Skarsnik/snes9x-emunwa/releases), [snes9x-rr](https://github.com/gocha/snes9x-rr/releases),
[BSNES-plus](https://github.com/black-sliver/bsnes-plus),
[BizHawk](http://tasvideos.org/BizHawk.html), or
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 or newer)
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware. **note:
modded SNES minis are currently not supported by SNI. Some users have claimed success with QUsb2Snes for this system,
but it is not supported.**
@@ -47,6 +48,11 @@ client, and will also create your ROM in the same place as your patch file.
When the client launched automatically, SNI should have also automatically launched in the background. If this is its
first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
#### snes9x-nwa
1. Click on the Network Menu and check **Enable Emu Network Control**
2. Load your ROM file if it hasn't already been loaded.
##### snes9x-rr
1. Load your ROM file if it hasn't already been loaded.
@@ -58,6 +64,11 @@ first time launching, you may be prompted to allow it to communicate through the
6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of
the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install.
#### BSNES-Plus
1. Load your ROM file if it hasn't already been loaded.
2. The emulator should automatically connect while SNI is running.
##### BizHawk
1. Ensure you have the BSNES core loaded. This is done with the main menubar, under:

View File

@@ -14,3 +14,4 @@ class LTTPTestBase(unittest.TestCase):
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
self.multiworld.set_options(args)
self.world = self.multiworld.worlds[1]

View File

@@ -2,7 +2,7 @@ from BaseClasses import CollectionState, ItemClassification
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import mandatory_connections, connect_simple
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import create_regions
from worlds.alttp.Shops import create_shops
from worlds.alttp.test import LTTPTestBase
@@ -24,10 +24,10 @@ class TestDungeon(LTTPTestBase):
connect_simple(self.multiworld, 'Big Bomb Shop', 'Big Bomb Shop', 1)
self.multiworld.get_region('Menu', 1).exits = []
self.multiworld.swamp_patch_required[1] = True
self.multiworld.worlds[1].set_rules()
self.multiworld.worlds[1].create_items()
self.world.set_rules()
self.world.create_items()
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
self.multiworld.itempool.extend(item_factory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
def run_tests(self, access_pool):
for exit in self.remove_exits:
@@ -40,9 +40,9 @@ class TestDungeon(LTTPTestBase):
if all_except and len(all_except) > 0:
items = self.multiworld.itempool[:]
items = [item for item in items if item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(ItemFactory(item_pool[0], 1))
items.extend(item_factory(item_pool[0], self.world))
else:
items = ItemFactory(items, 1)
items = item_factory(items, self.world)
state = CollectionState(self.multiworld)
state.reachable_regions[1].add(self.multiworld.get_region('Menu', 1))
for region_name in self.starting_regions:

View File

@@ -2,7 +2,7 @@ from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.TestBase import TestBase
@@ -18,14 +18,14 @@ class TestInverted(TestBase, LTTPTestBase):
self.multiworld.bombless_start[1].value = True
self.multiworld.shuffle_capacity_upgrades[1].value = True
create_inverted_regions(self.multiworld, 1)
self.multiworld.worlds[1].create_dungeons()
self.world.create_dungeons()
create_shops(self.multiworld, 1)
link_inverted_entrances(self.multiworld, 1)
self.multiworld.worlds[1].create_items()
self.world.create_items()
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
self.multiworld.itempool.extend(item_factory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
self.multiworld.get_location('Agahnim 1', 1).item = None
self.multiworld.get_location('Agahnim 2', 1).item = None
mark_light_world_regions(self.multiworld, 1)
self.multiworld.worlds[1].set_rules()
self.world.set_rules()

View File

@@ -1,8 +1,9 @@
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.TestBase import TestBase
@@ -14,19 +15,19 @@ class TestInvertedMinor(TestBase, LTTPTestBase):
def setUp(self):
self.world_setup()
self.multiworld.mode[1].value = 2
self.multiworld.glitches_required[1] = "minor_glitches"
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("minor_glitches")
self.multiworld.bombless_start[1].value = True
self.multiworld.shuffle_capacity_upgrades[1].value = True
self.multiworld.difficulty_requirements[1] = difficulties['normal']
create_inverted_regions(self.multiworld, 1)
self.multiworld.worlds[1].create_dungeons()
self.world.create_dungeons()
create_shops(self.multiworld, 1)
link_inverted_entrances(self.multiworld, 1)
self.multiworld.worlds[1].create_items()
self.world.create_items()
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
self.multiworld.itempool.extend(item_factory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
self.multiworld.get_location('Agahnim 1', 1).item = None
self.multiworld.get_location('Agahnim 2', 1).item = None
mark_light_world_regions(self.multiworld, 1)
self.multiworld.worlds[1].set_rules()
self.world.set_rules()

View File

@@ -1,8 +1,9 @@
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.TestBase import TestBase
@@ -13,22 +14,22 @@ from worlds.alttp.test import LTTPTestBase
class TestInvertedOWG(TestBase, LTTPTestBase):
def setUp(self):
self.world_setup()
self.multiworld.glitches_required[1] = "overworld_glitches"
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("overworld_glitches")
self.multiworld.mode[1].value = 2
self.multiworld.bombless_start[1].value = True
self.multiworld.shuffle_capacity_upgrades[1].value = True
self.multiworld.difficulty_requirements[1] = difficulties['normal']
create_inverted_regions(self.multiworld, 1)
self.multiworld.worlds[1].create_dungeons()
self.world.create_dungeons()
create_shops(self.multiworld, 1)
link_inverted_entrances(self.multiworld, 1)
self.multiworld.worlds[1].create_items()
self.world.create_items()
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
self.multiworld.itempool.extend(item_factory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
self.multiworld.get_location('Agahnim 1', 1).item = None
self.multiworld.get_location('Agahnim 2', 1).item = None
self.multiworld.precollected_items[1].clear()
self.multiworld.itempool.append(ItemFactory('Pegasus Boots', 1))
self.multiworld.itempool.append(item_factory('Pegasus Boots', self.world))
mark_light_world_regions(self.multiworld, 1)
self.multiworld.worlds[1].set_rules()
self.world.set_rules()

View File

@@ -1,8 +1,9 @@
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from test.TestBase import TestBase
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.test import LTTPTestBase
@@ -10,19 +11,19 @@ from worlds.alttp.test import LTTPTestBase
class TestMinor(TestBase, LTTPTestBase):
def setUp(self):
self.world_setup()
self.multiworld.glitches_required[1] = "minor_glitches"
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("minor_glitches")
self.multiworld.bombless_start[1].value = True
self.multiworld.shuffle_capacity_upgrades[1].value = True
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.worlds[1].er_seed = 0
self.multiworld.worlds[1].create_regions()
self.multiworld.worlds[1].create_items()
self.world.er_seed = 0
self.world.create_regions()
self.world.create_items()
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(
self.multiworld.itempool.extend(item_factory(
['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1',
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
self.multiworld.get_location('Agahnim 1', 1).item = None
self.multiworld.get_location('Agahnim 2', 1).item = None
mark_dark_world_regions(self.multiworld, 1)
self.multiworld.worlds[1].set_rules()
self.world.set_rules()

View File

@@ -1,5 +1,5 @@
from test.TestBase import WorldTestBase
from ...Items import ItemFactory
from test.bases import WorldTestBase
from ...Items import item_factory
class PyramidTestBase(WorldTestBase):
@@ -32,6 +32,6 @@ class GoalPyramidTest(PyramidTestBase):
self.assertFalse(self.can_reach_entrance("Pyramid Hole"))
self.collect_by_name(["Hammer", "Progressive Glove", "Moon Pearl"])
self.assertFalse(self.can_reach_entrance("Pyramid Hole"))
self.multiworld.state.collect(ItemFactory("Beat Agahnim 2", 1))
self.collect(item_factory("Beat Agahnim 2", self.multiworld.worlds[1]))
self.assertTrue(self.can_reach_entrance("Pyramid Hole"))

View File

@@ -1,8 +1,9 @@
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from test.TestBase import TestBase
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.test import LTTPTestBase
@@ -11,7 +12,7 @@ class TestVanillaOWG(TestBase, LTTPTestBase):
def setUp(self):
self.world_setup()
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.glitches_required[1] = "overworld_glitches"
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("overworld_glitches")
self.multiworld.bombless_start[1].value = True
self.multiworld.shuffle_capacity_upgrades[1].value = True
self.multiworld.worlds[1].er_seed = 0
@@ -19,10 +20,10 @@ class TestVanillaOWG(TestBase, LTTPTestBase):
self.multiworld.worlds[1].create_items()
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
self.multiworld.itempool.extend(item_factory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
self.multiworld.get_location('Agahnim 1', 1).item = None
self.multiworld.get_location('Agahnim 2', 1).item = None
self.multiworld.precollected_items[1].clear()
self.multiworld.itempool.append(ItemFactory('Pegasus Boots', 1))
self.multiworld.itempool.append(item_factory('Pegasus Boots', self.world))
mark_dark_world_regions(self.multiworld, 1)
self.multiworld.worlds[1].set_rules()
self.world.set_rules()

View File

@@ -1,15 +1,16 @@
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
from test.TestBase import TestBase
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.test import LTTPTestBase
class TestVanilla(TestBase, LTTPTestBase):
def setUp(self):
self.world_setup()
self.multiworld.glitches_required[1] = "no_glitches"
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("no_glitches")
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.bombless_start[1].value = True
self.multiworld.shuffle_capacity_upgrades[1].value = True
@@ -18,8 +19,8 @@ class TestVanilla(TestBase, LTTPTestBase):
self.multiworld.worlds[1].create_items()
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
self.multiworld.itempool.extend(item_factory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], self.world))
self.multiworld.get_location('Agahnim 1', 1).item = None
self.multiworld.get_location('Agahnim 2', 1).item = None
mark_dark_world_regions(self.multiworld, 1)
self.multiworld.worlds[1].set_rules()
self.world.set_rules()

View File

@@ -116,12 +116,12 @@ class BumpStikWorld(World):
self.multiworld.itempool += item_pool
def set_rules(self):
for x in range(1, 32):
self.multiworld.get_location(f"Treasure Bumper {x + 1}", self.player).access_rule = \
lambda state, x = x: state.has("Treasure Bumper", self.player, x)
for x in range(1, 5):
self.multiworld.get_location(f"Bonus Booster {x + 1}", self.player).access_rule = \
lambda state, x = x: state.has("Booster Bumper", self.player, x)
for treasure_count in range(1, 33):
self.multiworld.get_location(f"Treasure Bumper {treasure_count}", self.player).access_rule = \
lambda state, treasure_held = treasure_count: state.has("Treasure Bumper", self.player, treasure_held)
for booster_count in range(1, 6):
self.multiworld.get_location(f"Bonus Booster {booster_count}", self.player).access_rule = \
lambda state, booster_held = booster_count: state.has("Booster Bumper", self.player, booster_held)
self.multiworld.get_location("Level 5 - Cleared all Hazards", self.player).access_rule = \
lambda state: state.has("Hazard Bumper", self.player, 25)

View File

@@ -3,36 +3,38 @@ from . import BumpStikTestBase
class TestRuleLogic(BumpStikTestBase):
def testLogic(self):
for x in range(1, 33):
if x == 32:
for treasure_bumpers_held in range(1, 33):
if treasure_bumpers_held == 32:
self.assertFalse(self.can_reach_location("Level 5 - Cleared all Hazards"))
self.collect(self.get_item_by_name("Treasure Bumper"))
if x % 8 == 0:
bb_count = round(x / 8)
if treasure_bumpers_held % 8 == 0:
bb_count = round(treasure_bumpers_held / 8)
if bb_count < 4:
self.assertFalse(self.can_reach_location(f"Treasure Bumper {x + 1}"))
self.assertFalse(self.can_reach_location(f"Treasure Bumper {treasure_bumpers_held + 1}"))
# Can't reach Treasure Bumper 9 check until level 2 is unlocked, etc.
# But we don't have enough Treasure Bumpers to reach this check anyway??
elif bb_count == 4:
bb_count += 1
# Level 4 has two new Bonus Booster checks; need to check both
for y in range(self.count("Booster Bumper"), bb_count):
self.assertTrue(self.can_reach_location(f"Bonus Booster {y + 1}"),
f"BB {y + 1} check not reachable with {self.count('Booster Bumper')} BBs")
if y < 4:
self.assertFalse(self.can_reach_location(f"Bonus Booster {y + 2}"),
f"BB {y + 2} check reachable with {self.count('Treasure Bumper')} TBs")
self.collect(self.get_item_by_name("Booster Bumper"))
for booster_bumpers_held in range(self.count("Booster Bumper"), bb_count + 1):
if booster_bumpers_held > 0:
self.assertTrue(self.can_reach_location(f"Bonus Booster {booster_bumpers_held}"),
f"Bonus Booster {booster_bumpers_held} check not reachable with {self.count('Booster Bumper')} Booster Bumpers")
if booster_bumpers_held < 5:
self.assertFalse(self.can_reach_location(f"Bonus Booster {booster_bumpers_held + 1}"),
f"Bonus Booster {booster_bumpers_held + 1} check reachable with {self.count('Treasure Bumper')} Treasure Bumpers and {self.count('Booster Bumper')} Booster Bumpers")
if booster_bumpers_held < bb_count:
self.collect(self.get_item_by_name("Booster Bumper"))
if x < 31:
self.assertFalse(self.can_reach_location(f"Treasure Bumper {x + 2}"))
elif x == 31:
self.assertFalse(self.can_reach_location("Level 5 - 50,000+ Total Points"))
self.assertTrue(self.can_reach_location(f"Treasure Bumper {treasure_bumpers_held}"),
f"Treasure Bumper {treasure_bumpers_held} check not reachable with {self.count('Treasure Bumper')} Treasure Bumpers")
if x < 32:
self.assertTrue(self.can_reach_location(f"Treasure Bumper {x + 1}"),
f"TB {x + 1} check not reachable with {self.count('Treasure Bumper')} TBs")
elif x == 32:
if treasure_bumpers_held < 32:
self.assertFalse(self.can_reach_location(f"Treasure Bumper {treasure_bumpers_held + 1}"))
elif treasure_bumpers_held == 32:
self.assertTrue(self.can_reach_location("Level 5 - 50,000+ Total Points"))
self.assertFalse(self.can_reach_location("Level 5 - Cleared all Hazards"))
self.collect(self.get_items_by_name("Hazard Bumper"))

View File

@@ -24,6 +24,7 @@ DEATH_LINK_ACTIVE_ADDR = DKC3_ROMNAME_START + 0x15 # DKC3_TODO: Find a perma
class DKC3SNIClient(SNIClient):
game = "Donkey Kong Country 3"
patch_suffix = ".apdkc3"
async def deathlink_kill_player(self, ctx):
pass

View File

@@ -25,6 +25,10 @@ class Group(enum.Enum):
Item = enum.auto()
Coin = enum.auto()
Trap = enum.auto()
Twice = enum.auto()
Piece = enum.auto()
Deprecated = enum.auto()
@dataclass(frozen=True)
@@ -85,49 +89,75 @@ initialize_item_table()
initialize_groups()
def create_trap_items(world, World_Options: Options.DLCQuestOptions, trap_needed: int, random: Random) -> List[Item]:
def create_trap_items(world, world_options: Options.DLCQuestOptions, trap_needed: int, random: Random) -> List[Item]:
traps = []
for i in range(trap_needed):
trap = random.choice(items_by_group[Group.Trap])
traps.append(world.create_item(trap))
traps.append(world.create_item(trap, ItemClassification.trap))
return traps
def create_items(world, World_Options: Options.DLCQuestOptions, locations_count: int, random: Random):
def create_items(world, world_options: Options.DLCQuestOptions, locations_count: int, random: Random):
created_items = []
if World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign == Options.Campaign.option_both:
for item in items_by_group[Group.DLCQuest]:
if item.has_any_group(Group.DLC):
created_items.append(world.create_item(item))
if item.has_any_group(Group.Item) and World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
created_items.append(world.create_item(item))
if World_Options.coinsanity == Options.CoinSanity.option_coin:
coin_bundle_needed = math.floor(825 / World_Options.coinbundlequantity)
for item in items_by_group[Group.DLCQuest]:
if item.has_any_group(Group.Coin):
for i in range(coin_bundle_needed):
created_items.append(world.create_item(item))
if 825 % World_Options.coinbundlequantity != 0:
created_items.append(world.create_item(item))
if world_options.campaign == Options.Campaign.option_basic or world_options.campaign == Options.Campaign.option_both:
create_items_basic(world_options, created_items, world)
if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or
World_Options.campaign == Options.Campaign.option_both):
for item in items_by_group[Group.Freemium]:
if item.has_any_group(Group.DLC):
created_items.append(world.create_item(item))
if item.has_any_group(Group.Item) and World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
created_items.append(world.create_item(item))
if World_Options.coinsanity == Options.CoinSanity.option_coin:
coin_bundle_needed = math.floor(889 / World_Options.coinbundlequantity)
for item in items_by_group[Group.Freemium]:
if item.has_any_group(Group.Coin):
for i in range(coin_bundle_needed):
created_items.append(world.create_item(item))
if 889 % World_Options.coinbundlequantity != 0:
created_items.append(world.create_item(item))
if (world_options.campaign == Options.Campaign.option_live_freemium_or_die or
world_options.campaign == Options.Campaign.option_both):
create_items_lfod(world_options, created_items, world)
trap_items = create_trap_items(world, World_Options, locations_count - len(created_items), random)
trap_items = create_trap_items(world, world_options, locations_count - len(created_items), random)
created_items += trap_items
return created_items
def create_items_lfod(world_options, created_items, world):
for item in items_by_group[Group.Freemium]:
if item.has_any_group(Group.DLC):
created_items.append(world.create_item(item))
if item.has_any_group(Group.Item) and world_options.item_shuffle == Options.ItemShuffle.option_shuffled:
created_items.append(world.create_item(item))
if item.has_any_group(Group.Twice):
created_items.append(world.create_item(item))
if world_options.coinsanity == Options.CoinSanity.option_coin:
if world_options.coinbundlequantity == -1:
create_coin_piece(created_items, world, 889, 200, Group.Freemium)
return
create_coin(world_options, created_items, world, 889, 200, Group.Freemium)
def create_items_basic(world_options, created_items, world):
for item in items_by_group[Group.DLCQuest]:
if item.has_any_group(Group.DLC):
created_items.append(world.create_item(item))
if item.has_any_group(Group.Item) and world_options.item_shuffle == Options.ItemShuffle.option_shuffled:
created_items.append(world.create_item(item))
if item.has_any_group(Group.Twice):
created_items.append(world.create_item(item))
if world_options.coinsanity == Options.CoinSanity.option_coin:
if world_options.coinbundlequantity == -1:
create_coin_piece(created_items, world, 825, 250, Group.DLCQuest)
return
create_coin(world_options, created_items, world, 825, 250, Group.DLCQuest)
def create_coin(world_options, created_items, world, total_coins, required_coins, group):
coin_bundle_required = math.ceil(required_coins / world_options.coinbundlequantity)
coin_bundle_useful = math.ceil((total_coins - coin_bundle_required * world_options.coinbundlequantity) / world_options.coinbundlequantity)
for item in items_by_group[group]:
if item.has_any_group(Group.Coin):
for i in range(coin_bundle_required):
created_items.append(world.create_item(item))
for i in range(coin_bundle_useful):
created_items.append(world.create_item(item, ItemClassification.useful))
def create_coin_piece(created_items, world, total_coins, required_coins, group):
for item in items_by_group[group]:
if item.has_any_group(Group.Piece):
for i in range(required_coins*10):
created_items.append(world.create_item(item))
for i in range((total_coins - required_coins) * 10):
created_items.append(world.create_item(item, ItemClassification.useful))

View File

@@ -76,3 +76,14 @@ for i in range(1, 826):
for i in range(1, 890):
item_coin_freemium = f"Live Freemium or Die: {i} Coin"
location_table[item_coin_freemium] = offset + 825 + 58 + i
offset_special = 3829200000
for i in range(1, 8251):
item_coin_piece = f"DLC Quest: {i} Coin Piece"
location_table[item_coin_piece] = offset_special + i
for i in range(1, 8891):
item_coin_piece_freemium = f"Live Freemium or Die: {i} Coin Piece"
location_table[item_coin_piece_freemium] = offset_special + 8250 + i

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass
import datetime
from Options import Choice, DeathLink, NamedRange, PerGameCommonOptions
@@ -48,6 +49,20 @@ class CoinSanityRange(NamedRange):
"normal": 20,
"high": 50,
}
if datetime.datetime.today().month == 4:
if datetime.datetime.today().day == 1:
special_range_names["surprise"] = -1
else:
special_range_names["coin piece"] = -1
class PermanentCoins(Choice):
"""If purchasing a pack decreases your current coins amounts."""
internal_name = "permanent_coins"
display_name = "Permanent Coins"
option_false = 0
option_true = 1
default = 0
class EndingChoice(Choice):
@@ -83,6 +98,7 @@ class DLCQuestOptions(PerGameCommonOptions):
double_jump_glitch: DoubleJumpGlitch
coinsanity: CoinSanity
coinbundlequantity: CoinSanityRange
permanent_coins: PermanentCoins
time_is_money: TimeIsMoney
ending_choice: EndingChoice
campaign: Campaign

View File

@@ -182,9 +182,22 @@ def create_coinsanity_locations_lfod(has_coinsanity: bool, coin_bundle_size: int
def create_coinsanity_locations(has_coinsanity: bool, coin_bundle_size: int, player: int, region: Region, last_coin_number: int, campaign_prefix: str):
if not has_coinsanity:
return
if coin_bundle_size == -1:
create_coinsanity_piece_locations(player, region, last_coin_number, campaign_prefix)
return
coin_bundle_needed = math.ceil(last_coin_number / coin_bundle_size)
for i in range(1, coin_bundle_needed + 1):
number_coins = min(last_coin_number, coin_bundle_size * i)
item_coin = f"{campaign_prefix}: {number_coins} Coin"
region.locations += [DLCQuestLocation(player, item_coin, location_table[item_coin], region)]
def create_coinsanity_piece_locations(player: int, region: Region, total_coin: int, campaign_prefix:str):
pieces_needed = total_coin * 10
for i in range(1, pieces_needed + 1):
number_piece = i
item_piece = f"{campaign_prefix}: {number_piece} Coin Piece"
region.locations += [DLCQuestLocation(player, item_piece, location_table[item_piece], region)]

View File

@@ -1,5 +1,4 @@
import math
import re
from BaseClasses import ItemClassification
from worlds.generic.Rules import add_rule, item_name_in_locations, set_rule
@@ -19,23 +18,23 @@ def has_enough_coin_freemium(player: int, coin: int):
return lambda state: state.prog_items[player][" coins freemium"] >= coin
def set_rules(world, player, World_Options: Options.DLCQuestOptions):
set_basic_rules(World_Options, player, world)
set_lfod_rules(World_Options, player, world)
set_completion_condition(World_Options, player, world)
def set_rules(world, player, world_options: Options.DLCQuestOptions):
set_basic_rules(world_options, player, world)
set_lfod_rules(world_options, player, world)
set_completion_condition(world_options, player, world)
def set_basic_rules(World_Options, player, world):
if World_Options.campaign == Options.Campaign.option_live_freemium_or_die:
def set_basic_rules(world_options, player, world):
if world_options.campaign == Options.Campaign.option_live_freemium_or_die:
return
set_basic_entrance_rules(player, world)
set_basic_self_obtained_items_rules(World_Options, player, world)
set_basic_shuffled_items_rules(World_Options, player, world)
set_double_jump_glitchless_rules(World_Options, player, world)
set_easy_double_jump_glitch_rules(World_Options, player, world)
self_basic_coinsanity_funded_purchase_rules(World_Options, player, world)
set_basic_self_funded_purchase_rules(World_Options, player, world)
self_basic_win_condition(World_Options, player, world)
set_basic_self_obtained_items_rules(world_options, player, world)
set_basic_shuffled_items_rules(world_options, player, world)
set_double_jump_glitchless_rules(world_options, player, world)
set_easy_double_jump_glitch_rules(world_options, player, world)
self_basic_coinsanity_funded_purchase_rules(world_options, player, world)
set_basic_self_funded_purchase_rules(world_options, player, world)
self_basic_win_condition(world_options, player, world)
def set_basic_entrance_rules(player, world):
@@ -49,13 +48,13 @@ def set_basic_entrance_rules(player, world):
lambda state: state.has("Double Jump Pack", player))
def set_basic_self_obtained_items_rules(World_Options, player, world):
if World_Options.item_shuffle != Options.ItemShuffle.option_disabled:
def set_basic_self_obtained_items_rules(world_options, player, world):
if world_options.item_shuffle != Options.ItemShuffle.option_disabled:
return
set_rule(world.get_entrance("Behind Ogre", player),
lambda state: state.has("Gun Pack", player))
if World_Options.time_is_money == Options.TimeIsMoney.option_required:
if world_options.time_is_money == Options.TimeIsMoney.option_required:
set_rule(world.get_entrance("Tree", player),
lambda state: state.has("Time is Money Pack", player))
set_rule(world.get_entrance("Cave Tree", player),
@@ -70,35 +69,35 @@ def set_basic_self_obtained_items_rules(World_Options, player, world):
lambda state: state.has("Time is Money Pack", player))
def set_basic_shuffled_items_rules(World_Options, player, world):
if World_Options.item_shuffle != Options.ItemShuffle.option_shuffled:
def set_basic_shuffled_items_rules(world_options, player, world):
if world_options.item_shuffle != Options.ItemShuffle.option_shuffled:
return
set_rule(world.get_entrance("Behind Ogre", player),
lambda state: state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player, 2))
set_rule(world.get_entrance("Tree", player),
lambda state: state.has("Sword", player) or state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player))
set_rule(world.get_entrance("Cave Tree", player),
lambda state: state.has("Sword", player) or state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player))
set_rule(world.get_entrance("True Double Jump", player),
lambda state: state.has("Double Jump Pack", player))
set_rule(world.get_location("Shepherd Sheep", player),
lambda state: state.has("Sword", player) or state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player))
set_rule(world.get_location("North West Ceiling Sheep", player),
lambda state: state.has("Sword", player) or state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player))
set_rule(world.get_location("North West Alcove Sheep", player),
lambda state: state.has("Sword", player) or state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player))
set_rule(world.get_location("West Cave Sheep", player),
lambda state: state.has("Sword", player) or state.has("Gun", player))
lambda state: state.has("DLC Quest: Progressive Weapon", player))
set_rule(world.get_location("Gun", player),
lambda state: state.has("Gun Pack", player))
if World_Options.time_is_money == Options.TimeIsMoney.option_required:
if world_options.time_is_money == Options.TimeIsMoney.option_required:
set_rule(world.get_location("Sword", player),
lambda state: state.has("Time is Money Pack", player))
def set_double_jump_glitchless_rules(World_Options, player, world):
if World_Options.double_jump_glitch != Options.DoubleJumpGlitch.option_none:
def set_double_jump_glitchless_rules(world_options, player, world):
if world_options.double_jump_glitch != Options.DoubleJumpGlitch.option_none:
return
set_rule(world.get_entrance("Cloud Double Jump", player),
lambda state: state.has("Double Jump Pack", player))
@@ -106,8 +105,8 @@ def set_double_jump_glitchless_rules(World_Options, player, world):
lambda state: state.has("Double Jump Pack", player))
def set_easy_double_jump_glitch_rules(World_Options, player, world):
if World_Options.double_jump_glitch == Options.DoubleJumpGlitch.option_all:
def set_easy_double_jump_glitch_rules(world_options, player, world):
if world_options.double_jump_glitch == Options.DoubleJumpGlitch.option_all:
return
set_rule(world.get_entrance("Behind Tree Double Jump", player),
lambda state: state.has("Double Jump Pack", player))
@@ -115,71 +114,74 @@ def set_easy_double_jump_glitch_rules(World_Options, player, world):
lambda state: state.has("Double Jump Pack", player))
def self_basic_coinsanity_funded_purchase_rules(World_Options, player, world):
if World_Options.coinsanity != Options.CoinSanity.option_coin:
def self_basic_coinsanity_funded_purchase_rules(world_options, player, world):
if world_options.coinsanity != Options.CoinSanity.option_coin:
return
number_of_bundle = math.floor(825 / World_Options.coinbundlequantity)
if world_options.coinbundlequantity == -1:
self_basic_coinsanity_piece_rules(player, world)
return
number_of_bundle = math.floor(825 / world_options.coinbundlequantity)
for i in range(number_of_bundle):
item_coin = f"DLC Quest: {World_Options.coinbundlequantity * (i + 1)} Coin"
item_coin = f"DLC Quest: {world_options.coinbundlequantity * (i + 1)} Coin"
set_rule(world.get_location(item_coin, player),
has_enough_coin(player, World_Options.coinbundlequantity * (i + 1)))
if 825 % World_Options.coinbundlequantity != 0:
has_enough_coin(player, world_options.coinbundlequantity * (i + 1)))
if 825 % world_options.coinbundlequantity != 0:
set_rule(world.get_location("DLC Quest: 825 Coin", player),
has_enough_coin(player, 825))
set_rule(world.get_location("Movement Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(4 / World_Options.coinbundlequantity)))
math.ceil(4 / world_options.coinbundlequantity)))
set_rule(world.get_location("Animation Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Audio Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Pause Menu Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Time is Money Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(20 / World_Options.coinbundlequantity)))
math.ceil(20 / world_options.coinbundlequantity)))
set_rule(world.get_location("Double Jump Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(100 / World_Options.coinbundlequantity)))
math.ceil(100 / world_options.coinbundlequantity)))
set_rule(world.get_location("Pet Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Sexy Outfits Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Top Hat Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Map Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(140 / World_Options.coinbundlequantity)))
math.ceil(140 / world_options.coinbundlequantity)))
set_rule(world.get_location("Gun Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(75 / World_Options.coinbundlequantity)))
math.ceil(75 / world_options.coinbundlequantity)))
set_rule(world.get_location("The Zombie Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Night Map Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(75 / World_Options.coinbundlequantity)))
math.ceil(75 / world_options.coinbundlequantity)))
set_rule(world.get_location("Psychological Warfare Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(50 / World_Options.coinbundlequantity)))
math.ceil(50 / world_options.coinbundlequantity)))
set_rule(world.get_location("Armor for your Horse Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(250 / World_Options.coinbundlequantity)))
math.ceil(250 / world_options.coinbundlequantity)))
set_rule(world.get_location("Finish the Fight Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
def set_basic_self_funded_purchase_rules(World_Options, player, world):
if World_Options.coinsanity != Options.CoinSanity.option_none:
def set_basic_self_funded_purchase_rules(world_options, player, world):
if world_options.coinsanity != Options.CoinSanity.option_none:
return
set_rule(world.get_location("Movement Pack", player),
has_enough_coin(player, 4))
@@ -215,25 +217,25 @@ def set_basic_self_funded_purchase_rules(World_Options, player, world):
has_enough_coin(player, 5))
def self_basic_win_condition(World_Options, player, world):
if World_Options.ending_choice == Options.EndingChoice.option_any:
def self_basic_win_condition(world_options, player, world):
if world_options.ending_choice == Options.EndingChoice.option_any:
set_rule(world.get_location("Winning Basic", player),
lambda state: state.has("Finish the Fight Pack", player))
if World_Options.ending_choice == Options.EndingChoice.option_true:
if world_options.ending_choice == Options.EndingChoice.option_true:
set_rule(world.get_location("Winning Basic", player),
lambda state: state.has("Armor for your Horse Pack", player) and state.has("Finish the Fight Pack",
player))
def set_lfod_rules(World_Options, player, world):
if World_Options.campaign == Options.Campaign.option_basic:
def set_lfod_rules(world_options, player, world):
if world_options.campaign == Options.Campaign.option_basic:
return
set_lfod_entrance_rules(player, world)
set_boss_door_requirements_rules(player, world)
set_lfod_self_obtained_items_rules(World_Options, player, world)
set_lfod_shuffled_items_rules(World_Options, player, world)
self_lfod_coinsanity_funded_purchase_rules(World_Options, player, world)
set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world)
set_lfod_self_obtained_items_rules(world_options, player, world)
set_lfod_shuffled_items_rules(world_options, player, world)
self_lfod_coinsanity_funded_purchase_rules(world_options, player, world)
set_lfod_self_funded_purchase_rules(world_options, has_enough_coin_freemium, player, world)
def set_lfod_entrance_rules(player, world):
@@ -251,8 +253,6 @@ def set_lfod_entrance_rules(player, world):
lambda state: state.has("Death of Comedy Pack", player))
set_rule(world.get_location("Story is Important", player),
lambda state: state.has("DLC NPC Pack", player))
set_rule(world.get_entrance("Pickaxe Hard Cave", player),
lambda state: state.has("Pickaxe", player))
def set_boss_door_requirements_rules(player, world):
@@ -280,8 +280,8 @@ def set_boss_door_requirements_rules(player, world):
set_rule(world.get_entrance("Boss Door", player), has_3_swords)
def set_lfod_self_obtained_items_rules(World_Options, player, world):
if World_Options.item_shuffle != Options.ItemShuffle.option_disabled:
def set_lfod_self_obtained_items_rules(world_options, player, world):
if world_options.item_shuffle != Options.ItemShuffle.option_disabled:
return
set_rule(world.get_entrance("Vines", player),
lambda state: state.has("Incredibly Important Pack", player))
@@ -292,13 +292,15 @@ def set_lfod_self_obtained_items_rules(World_Options, player, world):
state.has("Name Change Pack", player))
def set_lfod_shuffled_items_rules(World_Options, player, world):
if World_Options.item_shuffle != Options.ItemShuffle.option_shuffled:
def set_lfod_shuffled_items_rules(world_options, player, world):
if world_options.item_shuffle != Options.ItemShuffle.option_shuffled:
return
set_rule(world.get_entrance("Vines", player),
lambda state: state.has("Wooden Sword", player) or state.has("Pickaxe", player))
lambda state: state.has("Live Freemium or Die: Progressive Weapon", player))
set_rule(world.get_entrance("Behind Rocks", player),
lambda state: state.has("Pickaxe", player))
lambda state: state.has("Live Freemium or Die: Progressive Weapon", player, 2))
set_rule(world.get_entrance("Pickaxe Hard Cave", player),
lambda state: state.has("Live Freemium or Die: Progressive Weapon", player, 2))
set_rule(world.get_location("Wooden Sword", player),
lambda state: state.has("Incredibly Important Pack", player))
@@ -311,83 +313,84 @@ def set_lfod_shuffled_items_rules(World_Options, player, world):
lambda state: state.can_reach("Cut Content", 'region', player))
def self_lfod_coinsanity_funded_purchase_rules(World_Options, player, world):
if World_Options.coinsanity != Options.CoinSanity.option_coin:
def self_lfod_coinsanity_funded_purchase_rules(world_options, player, world):
if world_options.coinsanity != Options.CoinSanity.option_coin:
return
number_of_bundle = math.floor(889 / World_Options.coinbundlequantity)
if world_options.coinbundlequantity == -1:
self_lfod_coinsanity_piece_rules(player, world)
return
number_of_bundle = math.floor(889 / world_options.coinbundlequantity)
for i in range(number_of_bundle):
item_coin_freemium = "Live Freemium or Die: number Coin"
item_coin_loc_freemium = re.sub("number", str(World_Options.coinbundlequantity * (i + 1)),
item_coin_freemium)
set_rule(world.get_location(item_coin_loc_freemium, player),
has_enough_coin_freemium(player, World_Options.coinbundlequantity * (i + 1)))
if 889 % World_Options.coinbundlequantity != 0:
item_coin_freemium = f"Live Freemium or Die: {world_options.coinbundlequantity * (i + 1)} Coin"
set_rule(world.get_location(item_coin_freemium, player),
has_enough_coin_freemium(player, world_options.coinbundlequantity * (i + 1)))
if 889 % world_options.coinbundlequantity != 0:
set_rule(world.get_location("Live Freemium or Die: 889 Coin", player),
has_enough_coin_freemium(player, 889))
add_rule(world.get_entrance("Boss Door", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(889 / World_Options.coinbundlequantity)))
math.ceil(200 / world_options.coinbundlequantity)))
set_rule(world.get_location("Particles Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Day One Patch Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Checkpoint Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Incredibly Important Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(15 / World_Options.coinbundlequantity)))
math.ceil(15 / world_options.coinbundlequantity)))
set_rule(world.get_location("Wall Jump Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(35 / World_Options.coinbundlequantity)))
math.ceil(35 / world_options.coinbundlequantity)))
set_rule(world.get_location("Health Bar Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Parallax Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(5 / World_Options.coinbundlequantity)))
math.ceil(5 / world_options.coinbundlequantity)))
set_rule(world.get_location("Harmless Plants Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(130 / World_Options.coinbundlequantity)))
math.ceil(130 / world_options.coinbundlequantity)))
set_rule(world.get_location("Death of Comedy Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(15 / World_Options.coinbundlequantity)))
math.ceil(15 / world_options.coinbundlequantity)))
set_rule(world.get_location("Canadian Dialog Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(10 / World_Options.coinbundlequantity)))
math.ceil(10 / world_options.coinbundlequantity)))
set_rule(world.get_location("DLC NPC Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(15 / World_Options.coinbundlequantity)))
math.ceil(15 / world_options.coinbundlequantity)))
set_rule(world.get_location("Cut Content Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(40 / World_Options.coinbundlequantity)))
math.ceil(40 / world_options.coinbundlequantity)))
set_rule(world.get_location("Name Change Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(150 / World_Options.coinbundlequantity)))
math.ceil(150 / world_options.coinbundlequantity)))
set_rule(world.get_location("Season Pass", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(199 / World_Options.coinbundlequantity)))
math.ceil(199 / world_options.coinbundlequantity)))
set_rule(world.get_location("High Definition Next Gen Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(20 / World_Options.coinbundlequantity)))
math.ceil(20 / world_options.coinbundlequantity)))
set_rule(world.get_location("Increased HP Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(10 / World_Options.coinbundlequantity)))
math.ceil(10 / world_options.coinbundlequantity)))
set_rule(world.get_location("Remove Ads Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
math.ceil(25 / World_Options.coinbundlequantity)))
math.ceil(25 / world_options.coinbundlequantity)))
def set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world):
if World_Options.coinsanity != Options.CoinSanity.option_none:
def set_lfod_self_funded_purchase_rules(world_options, has_enough_coin_freemium, player, world):
if world_options.coinsanity != Options.CoinSanity.option_none:
return
add_rule(world.get_entrance("Boss Door", player),
has_enough_coin_freemium(player, 889))
has_enough_coin_freemium(player, 200))
set_rule(world.get_location("Particles Pack", player),
has_enough_coin_freemium(player, 5))
@@ -425,11 +428,98 @@ def set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium,
has_enough_coin_freemium(player, 25))
def set_completion_condition(World_Options, player, world):
if World_Options.campaign == Options.Campaign.option_basic:
def set_completion_condition(world_options, player, world):
if world_options.campaign == Options.Campaign.option_basic:
world.completion_condition[player] = lambda state: state.has("Victory Basic", player)
if World_Options.campaign == Options.Campaign.option_live_freemium_or_die:
if world_options.campaign == Options.Campaign.option_live_freemium_or_die:
world.completion_condition[player] = lambda state: state.has("Victory Freemium", player)
if World_Options.campaign == Options.Campaign.option_both:
if world_options.campaign == Options.Campaign.option_both:
world.completion_condition[player] = lambda state: state.has("Victory Basic", player) and state.has(
"Victory Freemium", player)
def self_basic_coinsanity_piece_rules(player, world):
for i in range(1,8251):
item_coin = f"DLC Quest: {i} Coin Piece"
set_rule(world.get_location(item_coin, player),
has_enough_coin(player, math.ceil(i / 10)))
set_rule(world.get_location("Movement Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 40))
set_rule(world.get_location("Animation Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Audio Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Pause Menu Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Time is Money Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 200))
set_rule(world.get_location("Double Jump Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 100))
set_rule(world.get_location("Pet Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Sexy Outfits Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Top Hat Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Map Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 1400))
set_rule(world.get_location("Gun Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 750))
set_rule(world.get_location("The Zombie Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
set_rule(world.get_location("Night Map Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 750))
set_rule(world.get_location("Psychological Warfare Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 500))
set_rule(world.get_location("Armor for your Horse Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 2500))
set_rule(world.get_location("Finish the Fight Pack", player),
lambda state: state.has("DLC Quest: Coin Piece", player, 50))
def self_lfod_coinsanity_piece_rules(player, world):
for i in range(1, 8891):
item_coin_freemium = f"Live Freemium or Die: {i} Coin Piece"
set_rule(world.get_location(item_coin_freemium, player),
has_enough_coin_freemium(player, math.ceil(i / 10)))
add_rule(world.get_entrance("Boss Door", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 2000))
set_rule(world.get_location("Particles Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 50))
set_rule(world.get_location("Day One Patch Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 50))
set_rule(world.get_location("Checkpoint Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 50))
set_rule(world.get_location("Incredibly Important Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 150))
set_rule(world.get_location("Wall Jump Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 350))
set_rule(world.get_location("Health Bar Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 50))
set_rule(world.get_location("Parallax Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 50))
set_rule(world.get_location("Harmless Plants Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 1300))
set_rule(world.get_location("Death of Comedy Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 150))
set_rule(world.get_location("Canadian Dialog Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 100))
set_rule(world.get_location("DLC NPC Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 150))
set_rule(world.get_location("Cut Content Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 400))
set_rule(world.get_location("Name Change Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 1500))
set_rule(world.get_location("Season Pass", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 199))
set_rule(world.get_location("High Definition Next Gen Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 20))
set_rule(world.get_location("Increased HP Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 100))
set_rule(world.get_location("Remove Ads Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Piece", player, 250))

View File

@@ -1,6 +1,6 @@
from typing import Union
from BaseClasses import Tutorial, CollectionState
from BaseClasses import Tutorial, CollectionState, ItemClassification
from worlds.AutoWorld import WebWorld, World
from . import Options
from .Items import DLCQuestItem, ItemData, create_items, item_table, items_by_group, Group
@@ -82,11 +82,13 @@ class DLCqworld(World):
if self.options.coinsanity == Options.CoinSanity.option_coin and self.options.coinbundlequantity >= 5:
self.multiworld.push_precollected(self.create_item("Movement Pack"))
def create_item(self, item: Union[str, ItemData]) -> DLCQuestItem:
def create_item(self, item: Union[str, ItemData], classification: ItemClassification = None) -> DLCQuestItem:
if isinstance(item, str):
item = item_table[item]
if classification is None:
classification = item.classification
return DLCQuestItem(item.name, item.classification, item.code, self.player)
return DLCQuestItem(item.name, classification, item.code, self.player)
def get_filler_item_name(self) -> str:
trap = self.multiworld.random.choice(items_by_group[Group.Trap])
@@ -94,7 +96,7 @@ class DLCqworld(World):
def fill_slot_data(self):
options_dict = self.options.as_dict(
"death_link", "ending_choice", "campaign", "coinsanity", "item_shuffle"
"death_link", "ending_choice", "campaign", "coinsanity", "item_shuffle", "permanent_coins"
)
options_dict.update({
"coinbundlerange": self.options.coinbundlequantity.value,

View File

@@ -27,8 +27,8 @@ id,name,classification,groups
25,Canadian Dialog Pack,filler,"DLC,Freemium"
26,DLC NPC Pack,progression,"DLC,Freemium"
27,Cut Content Pack,progression,"DLC,Freemium"
28,Name Change Pack,progression,"DLC,Freemium"
29,Pickaxe,progression,"Item,Freemium"
28,Name Change Pack,progression,"DLC,Freemium,Trap"
29,Pickaxe,progression,"Deprecated"
30,Season Pass,progression,"DLC,Freemium"
31,High Definition Next Gen Pack,filler,"DLC,Freemium"
32,Increased HP Pack,useful,"DLC,Freemium"
@@ -36,13 +36,17 @@ id,name,classification,groups
34,Big Sword Pack,progression,"DLC,Freemium"
35,Really Big Sword Pack,progression,"DLC,Freemium"
36,Unfathomable Sword Pack,progression,"DLC,Freemium"
37,Gun,progression,"Item,DLCQuest"
38,Sword,progression,"Item,DLCQuest"
39,Wooden Sword,progression,"Item,Freemium"
37,Gun,progression,"Deprecated"
38,Sword,progression,"Deprecated"
39,Wooden Sword,progression,"Deprecated"
40,Box of Various Supplies,progression,"Item,Freemium"
41,Humble Indie Bindle,progression,"Item,Freemium"
42,DLC Quest: Coin Bundle,progression,"Coin,DLCQuest"
43,Live Freemium or Die: Coin Bundle,progression,"Coin,Freemium"
44,Zombie Sheep,trap,Trap
45,Temporary Spike,trap,Trap
46,Loading Screen,trap,Trap
46,Loading Screen,trap,Trap
48,DLC Quest: Progressive Weapon,progression,"Item,Twice,DLCQuest"
49,Live Freemium or Die: Progressive Weapon,progression,"Item,Twice,Freemium"
50,DLC Quest: Coin Piece,progression,"Piece,DLCQuest"
51,Live Freemium or Die: Coin Piece,progression,"Piece,Freemium"
1 id name classification groups
27 25 Canadian Dialog Pack filler DLC,Freemium
28 26 DLC NPC Pack progression DLC,Freemium
29 27 Cut Content Pack progression DLC,Freemium
30 28 Name Change Pack progression DLC,Freemium DLC,Freemium,Trap
31 29 Pickaxe progression Item,Freemium Deprecated
32 30 Season Pass progression DLC,Freemium
33 31 High Definition Next Gen Pack filler DLC,Freemium
34 32 Increased HP Pack useful DLC,Freemium
36 34 Big Sword Pack progression DLC,Freemium
37 35 Really Big Sword Pack progression DLC,Freemium
38 36 Unfathomable Sword Pack progression DLC,Freemium
39 37 Gun progression Item,DLCQuest Deprecated
40 38 Sword progression Item,DLCQuest Deprecated
41 39 Wooden Sword progression Item,Freemium Deprecated
42 40 Box of Various Supplies progression Item,Freemium
43 41 Humble Indie Bindle progression Item,Freemium
44 42 DLC Quest: Coin Bundle progression Coin,DLCQuest
45 43 Live Freemium or Die: Coin Bundle progression Coin,Freemium
46 44 Zombie Sheep trap Trap
47 45 Temporary Spike trap Trap
48 46 Loading Screen trap Trap
49 48 DLC Quest: Progressive Weapon progression Item,Twice,DLCQuest
50 49 Live Freemium or Die: Progressive Weapon progression Item,Twice,Freemium
51 50 DLC Quest: Coin Piece progression Piece,DLCQuest
52 51 Live Freemium or Die: Coin Piece progression Piece,Freemium

View File

@@ -7,7 +7,10 @@ wooden_sword = "Wooden Sword"
pickaxe = "Pickaxe"
humble_bindle = "Humble Indie Bindle"
box_supplies = "Box of Various Supplies"
items = [sword, gun, wooden_sword, pickaxe, humble_bindle, box_supplies]
locations = [sword, gun, wooden_sword, pickaxe, humble_bindle, box_supplies]
prog_weapon_basic = "DLC Quest: Progressive Weapon"
prog_weapon_lfod = "Live Freemium or Die: Progressive Weapon"
items = [prog_weapon_basic, prog_weapon_lfod, humble_bindle, box_supplies]
important_pack = "Incredibly Important Pack"
@@ -22,9 +25,14 @@ class TestItemShuffle(DLCQuestTestBase):
with self.subTest(f"{item}"):
self.assertIn(item, item_names)
def test_progressive_weapon_in_pool(self):
item_names = [item.name for item in self.multiworld.get_items()]
self.assertEqual(item_names.count(prog_weapon_basic), 2)
self.assertEqual(item_names.count(prog_weapon_lfod), 2)
def test_item_locations_in_pool(self):
location_names = {location.name for location in self.multiworld.get_locations()}
for item_location in items:
for item_location in locations:
with self.subTest(f"{item_location}"):
self.assertIn(item_location, location_names)
@@ -42,7 +50,7 @@ class TestItemShuffle(DLCQuestTestBase):
movement_pack = self.multiworld.create_item("Movement Pack", self.player)
self.collect(movement_pack)
self.assertFalse(self.can_reach_location(gun))
sword_item = self.multiworld.create_item(sword, self.player)
sword_item = self.multiworld.create_item(prog_weapon_basic, self.player)
self.collect(sword_item)
self.assertFalse(self.can_reach_location(gun))
gun_pack = self.multiworld.create_item("Gun Pack", self.player)
@@ -57,7 +65,7 @@ class TestItemShuffle(DLCQuestTestBase):
def test_bindle_location_has_correct_rules(self):
self.assertFalse(self.can_reach_location(humble_bindle))
wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player)
wooden_sword_item = self.multiworld.create_item(prog_weapon_lfod, self.player)
self.collect(wooden_sword_item)
self.assertFalse(self.can_reach_location(humble_bindle))
plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player)
@@ -78,7 +86,7 @@ class TestItemShuffle(DLCQuestTestBase):
def test_box_supplies_location_has_correct_rules(self):
self.assertFalse(self.can_reach_location(box_supplies))
wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player)
wooden_sword_item = self.multiworld.create_item(prog_weapon_lfod, self.player)
self.collect(wooden_sword_item)
self.assertFalse(self.can_reach_location(box_supplies))
plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player)
@@ -96,7 +104,7 @@ class TestItemShuffle(DLCQuestTestBase):
def test_pickaxe_location_has_correct_rules(self):
self.assertFalse(self.can_reach_location(pickaxe))
wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player)
wooden_sword_item = self.multiworld.create_item(prog_weapon_lfod, self.player)
self.collect(wooden_sword_item)
self.assertFalse(self.can_reach_location(pickaxe))
plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player)
@@ -125,6 +133,6 @@ class TestNoItemShuffle(DLCQuestTestBase):
def test_item_locations_not_in_pool(self):
location_names = {location.name for location in self.multiworld.get_locations()}
for item_location in items:
for item_location in locations:
with self.subTest(f"{item_location}"):
self.assertNotIn(item_location, location_names)

View File

@@ -37,8 +37,7 @@ def setup_dlc_quest_solo_multiworld(test_options=None, seed=None, _cache: Dict[F
if frozen_options in _cache:
return _cache[frozen_options]
multiworld = setup_base_solo_multiworld(DLCqworld, ())
multiworld.set_seed(seed)
multiworld = setup_base_solo_multiworld(DLCqworld, (), seed=seed)
# print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test
args = Namespace()
for name, option in DLCqworld.options_dataclass.type_hints.items():

View File

@@ -207,10 +207,10 @@ class CrestShuffle(Toggle):
class MapShuffleSeed(FreeText):
"""If this is a number, it will be used as a set seed number for Map, Crest, and Battlefield Reward shuffles.
"""If this is a number, it will be used as a set seed number for Map, Crest, Battlefield Reward, and Companion shuffles.
If this is "random" the seed will be chosen randomly. If it is any other text, it will be used as a seed group name.
All players using the same seed group name will get the same shuffle results, as long as their Map Shuffle,
Crest Shuffle, and Shuffle Battlefield Rewards settings are the same."""
Crest Shuffle, Shuffle Battlefield Rewards, Companion Shuffle, and Kaeli's Mom settings are the same."""
display_name = "Map Shuffle Seed"
default = "random"

View File

@@ -121,4 +121,31 @@ For example:
In this example (thanks to @Black-Sliver), if the `pupdunk` option is rolled, then the difficulty values will be rolled
again using the new options `normal`, `pupdunk_hard`, and `pupdunk_mystery`, and the exp modifier will be rerolled using
new weights for 150 and 200. This allows for two more triggers that will only be used for the new `pupdunk_hard`
and `pupdunk_mystery` options so that they will only be triggered on "pupdunk AND hard/mystery".
and `pupdunk_mystery` options so that they will only be triggered on "pupdunk AND hard/mystery".
Options that define a list, set, or dict can additionally have the character `+` added to the start of their name, which applies the contents of
the activated trigger to the already present equivalents in the game options.
For example:
```yaml
Super Metroid:
start_location:
landing_site: 50
aqueduct: 50
start_hints:
- Morph Ball
triggers:
- option_category: Super Metroid
option_name: start_location
option_result: aqueduct
options:
Super Metroid:
+start_hints:
- Gravity Suit
```
In this example, if the `start_location` option rolls `landing_site`, only a starting hint for Morph Ball will be created.
If `aqueduct` is rolled, a starting hint for Gravity Suit will also be created alongside the hint for Morph Ball.
Note that for lists, items can only be added, not removed or replaced. For dicts, defining a value for a present key will
replace that value within the dict.

View File

@@ -35,6 +35,7 @@ item_name_groups = ({
"GeoChests": lookup_type_to_names["Geo"],
"GeoRocks": lookup_type_to_names["Rock"],
"GrimmkinFlames": lookup_type_to_names["Flame"],
"Grimmchild": {"Grimmchild1", "Grimmchild2"},
"Grubs": lookup_type_to_names["Grub"],
"JournalEntries": lookup_type_to_names["Journal"],
"JunkPitChests": lookup_type_to_names["JunkPitChest"],

View File

@@ -406,6 +406,15 @@ class ExtraPlatforms(DefaultOnToggle):
"""Places additional platforms to make traveling throughout Hallownest more convenient."""
class AddUnshuffledLocations(Toggle):
"""Adds non-randomized locations to the location pool, which allows syncing
of location state with co-op or automatic collection via collect.
Note: This will increase the number of location checks required to purchase
hints to the total maximum.
"""
class DeathLinkShade(Choice):
"""Sets whether to create a shade when you are killed by a DeathLink and how to handle your existing shade, if any.
@@ -488,7 +497,7 @@ hollow_knight_options: typing.Dict[str, type(Option)] = {
**{
option.__name__: option
for option in (
StartLocation, Goal, WhitePalace, ExtraPlatforms, StartingGeo,
StartLocation, Goal, WhitePalace, ExtraPlatforms, AddUnshuffledLocations, StartingGeo,
DeathLink, DeathLinkShade, DeathLinkBreaksFragileCharms,
MinimumGeoPrice, MaximumGeoPrice,
MinimumGrubPrice, MaximumGrubPrice,

View File

@@ -234,7 +234,7 @@ class HKWorld(World):
randomized_starting_items.update(items)
# noinspection PyShadowingNames
def _add(item_name: str, location_name: str):
def _add(item_name: str, location_name: str, randomized: bool):
"""
Adds a pairing of an item and location, doing appropriate checks to see if it should be vanilla or not.
"""
@@ -252,7 +252,7 @@ class HKWorld(World):
if item_name in junk_replace:
item_name = self.get_filler_item_name()
item = self.create_item(item_name)
item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.multiworld.AddUnshuffledLocations[self.player] else self.create_event(item_name)
if location_name == "Start":
if item_name in randomized_starting_items:
@@ -277,30 +277,35 @@ class HKWorld(World):
for option_key, option in hollow_knight_randomize_options.items():
randomized = getattr(self.multiworld, option_key)[self.player]
if all([not randomized, option_key in logicless_options, not self.multiworld.AddUnshuffledLocations[self.player]]):
continue
for item_name, location_name in zip(option.items, option.locations):
if item_name in junk_replace:
item_name = self.get_filler_item_name()
if (item_name == "Crystal_Heart" and self.multiworld.SplitCrystalHeart[self.player]) or \
(item_name == "Mothwing_Cloak" and self.multiworld.SplitMothwingCloak[self.player]):
_add("Left_" + item_name, location_name)
_add("Right_" + item_name, "Split_" + location_name)
_add("Left_" + item_name, location_name, randomized)
_add("Right_" + item_name, "Split_" + location_name, randomized)
continue
if item_name == "Mantis_Claw" and self.multiworld.SplitMantisClaw[self.player]:
_add("Left_" + item_name, "Left_" + location_name)
_add("Right_" + item_name, "Right_" + location_name)
_add("Left_" + item_name, "Left_" + location_name, randomized)
_add("Right_" + item_name, "Right_" + location_name, randomized)
continue
if item_name == "Shade_Cloak" and self.multiworld.SplitMothwingCloak[self.player]:
if self.multiworld.random.randint(0, 1):
item_name = "Left_Mothwing_Cloak"
else:
item_name = "Right_Mothwing_Cloak"
if item_name == "Grimmchild2" and self.multiworld.RandomizeGrimmkinFlames[self.player] and self.multiworld.RandomizeCharms[self.player]:
_add("Grimmchild1", location_name, randomized)
continue
_add(item_name, location_name)
_add(item_name, location_name, randomized)
if self.multiworld.RandomizeElevatorPass[self.player]:
randomized = True
_add("Elevator_Pass", "Elevator_Pass")
_add("Elevator_Pass", "Elevator_Pass", randomized)
for shop, locations in self.created_multi_locations.items():
for _ in range(len(locations), getattr(self.multiworld, shop_to_option[shop])[self.player].value):
@@ -475,6 +480,10 @@ class HKWorld(World):
item_data = item_table[name]
return HKItem(name, item_data.advancement, item_data.id, item_data.type, self.player)
def create_event(self, name: str) -> HKItem:
item_data = item_table[name]
return HKItem(name, item_data.advancement, None, item_data.type, self.player)
def create_location(self, name: str, vanilla=False) -> HKLocation:
costs = None
basename = name
@@ -493,9 +502,15 @@ class HKWorld(World):
name = f"{name}_{i}"
region = self.multiworld.get_region("Menu", self.player)
loc = HKLocation(self.player, name,
self.location_name_to_id[name], region, costs=costs, vanilla=vanilla,
basename=basename)
if vanilla and not self.multiworld.AddUnshuffledLocations[self.player]:
loc = HKLocation(self.player, name,
None, region, costs=costs, vanilla=vanilla,
basename=basename)
else:
loc = HKLocation(self.player, name,
self.location_name_to_id[name], region, costs=costs, vanilla=vanilla,
basename=basename)
if multi is not None:
multi.append(loc)

View File

@@ -97,6 +97,7 @@ def cmd_gift(self: "SNIClientCommandProcessor"):
class KDL3SNIClient(SNIClient):
game = "Kirby's Dream Land 3"
patch_suffix = ".apkdl3"
levels = None
consumables = None
stars = None
@@ -308,10 +309,13 @@ class KDL3SNIClient(SNIClient):
if current_bgm[0] in (0x00, 0x21, 0x22, 0x23, 0x25, 0x2A, 0x2B):
return # null, title screen, opening, save select, true and false endings
game_state = await snes_read(ctx, KDL3_GAME_STATE, 1)
current_hp = await snes_read(ctx, KDL3_KIRBY_HP, 1)
if "DeathLink" in ctx.tags and game_state[0] == 0x00 and ctx.last_death_link + 1 < time.time():
current_hp = await snes_read(ctx, KDL3_KIRBY_HP, 1)
current_world = struct.unpack("H", await snes_read(ctx, KDL3_CURRENT_WORLD, 2))[0]
current_level = struct.unpack("H", await snes_read(ctx, KDL3_CURRENT_LEVEL, 2))[0]
currently_dead = current_hp[0] == 0x00
await ctx.handle_deathlink_state(currently_dead)
message = deathlink_messages[self.levels[current_world][current_level - 1]]
await ctx.handle_deathlink_state(currently_dead, f"{ctx.player_names[ctx.slot]}{message}")
recv_count = await snes_read(ctx, KDL3_RECV_COUNT, 2)
recv_amount = unpack("H", recv_count)[0]
@@ -406,7 +410,8 @@ class KDL3SNIClient(SNIClient):
ctx.locations_checked.add(new_check_id)
location = ctx.location_names[new_check_id]
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
f'New Check: {location} ({len(ctx.locations_checked)}/'
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}])
except Exception as ex:
# we crashed, so print log and clean up

View File

@@ -206,13 +206,19 @@ def set_rules(world: "KDL3World") -> None:
lambda state: can_reach_needle(state, world.player))
set_rule(world.multiworld.get_location(LocationName.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)))
(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(LocationName.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)))
(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(LocationName.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)))
(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(LocationName.cloudy_park_6_u1, world.player),
lambda state: can_reach_cutter(state, world.player))
@@ -242,7 +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)))
(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))
@@ -267,32 +275,32 @@ def set_rules(world: "KDL3World") -> None:
# Kirby cannot eat enemies fully submerged in water. Vast majority of cases, the enemy can be brought to the surface
# and eaten by inhaling while falling on top of them
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_2_E3, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_3_E6, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
# Ripple Field 4 E5, E7, and E8 are doable, but too strict to leave in logic
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_4_E5, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_4_E7, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_4_E8, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_5_E1, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_5_E2, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_5_E3, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Ripple_Field_5_E4, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Sand_Canyon_4_E7, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Sand_Canyon_4_E8, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Sand_Canyon_4_E9, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(state, world.player))
set_rule(world.multiworld.get_location(EnemyAbilities.Sand_Canyon_4_E10, world.player),
lambda state: can_reach_kine(state, world.player))
lambda state: can_reach_kine(state, world.player) or can_reach_chuchu(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",

View File

@@ -129,6 +129,8 @@ class KDL3World(World):
# randomize copy abilities
valid_abilities = list(copy_ability_access_table.keys())
enemies_to_set = list(self.copy_abilities.keys())
unplaced_abilities = set(key for key in copy_ability_access_table.keys()
if key not in ("No Ability", "Cutter Ability", "Burning Ability"))
# now for the edge cases
for abilities, enemies in enemy_restrictive:
available_enemies = list()
@@ -143,6 +145,7 @@ class KDL3World(World):
chosen_ability = self.random.choice(abilities)
self.copy_abilities[chosen_enemy] = chosen_ability
enemies_to_set.remove(chosen_enemy)
unplaced_abilities.discard(chosen_ability)
# two less restrictive ones, we need to ensure Cutter and Burning appear before their required stages
sand_canyon_5 = self.get_region("Sand Canyon 5 - 9")
# this is primarily for typing, but if this ever hits it's fine to crash
@@ -160,6 +163,13 @@ class KDL3World(World):
if burning_enemy:
self.copy_abilities[burning_enemy] = "Burning Ability"
enemies_to_set.remove(burning_enemy)
# ensure we place one of every ability
if unplaced_abilities and self.options.accessibility != self.options.accessibility.option_minimal:
# failsafe, on non-minimal we need to guarantee every copy ability exists
for ability in sorted(unplaced_abilities):
enemy = self.random.choice(enemies_to_set)
self.copy_abilities[enemy] = ability
enemies_to_set.remove(enemy)
# place remaining
for enemy in enemies_to_set:
self.copy_abilities[enemy] = self.random.choice(valid_abilities)
@@ -283,6 +293,8 @@ class KDL3World(World):
self.boss_butch_bosses = [True for _ in range(6)]
else:
self.boss_butch_bosses = [self.random.choice([True, False]) for _ in range(6)]
else:
self.boss_butch_bosses = [False for _ in range(6)]
def generate_output(self, output_directory: str):
rom_path = ""

View File

@@ -54,29 +54,30 @@ def patch_kh2(self, output_directory):
formName = None
levelsetting = list()
if self.multiworld.Keyblade_Minimum[self.player].value > self.multiworld.Keyblade_Maximum[self.player].value:
if self.options.Keyblade_Minimum.value > self.options.Keyblade_Maximum.value:
logging.info(
f"{self.multiworld.get_file_safe_player_name(self.player)} has Keyblade Minimum greater than Keyblade Maximum")
keyblademin = self.multiworld.Keyblade_Maximum[self.player].value
keyblademax = self.multiworld.Keyblade_Minimum[self.player].value
keyblademin = self.options.Keyblade_Maximum.value
keyblademax = self.options.Keyblade_Minimum.value
else:
keyblademin = self.multiworld.Keyblade_Minimum[self.player].value
keyblademax = self.multiworld.Keyblade_Maximum[self.player].value
keyblademin = self.options.Keyblade_Minimum.value
keyblademax = self.options.Keyblade_Maximum.value
if self.multiworld.LevelDepth[self.player] == "level_50":
if self.options.LevelDepth == "level_50":
levelsetting.extend(exclusion_table["Level50"])
elif self.multiworld.LevelDepth[self.player] == "level_99":
elif self.options.LevelDepth == "level_99":
levelsetting.extend(exclusion_table["Level99"])
elif self.multiworld.LevelDepth[self.player] != "level_1":
elif self.options.LevelDepth != "level_1":
levelsetting.extend(exclusion_table["Level50Sanity"])
if self.multiworld.LevelDepth[self.player] == "level_99_sanity":
if self.options.LevelDepth == "level_99_sanity":
levelsetting.extend(exclusion_table["Level99Sanity"])
mod_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.get_file_safe_player_name(self.player)}"
all_valid_locations = {location for location, data in all_locations.items()}
for location in self.multiworld.get_filled_locations(self.player):
if location.name in all_valid_locations:
data = all_locations[location.name]
@@ -142,11 +143,11 @@ def patch_kh2(self, output_directory):
if data.locid == 2:
formDict = {1: "Valor", 2: "Wisdom", 3: "Limit", 4: "Master", 5: "Final"}
formDictExp = {
1: self.multiworld.Valor_Form_EXP[self.player].value,
2: self.multiworld.Wisdom_Form_EXP[self.player].value,
3: self.multiworld.Limit_Form_EXP[self.player].value,
4: self.multiworld.Master_Form_EXP[self.player].value,
5: self.multiworld.Final_Form_EXP[self.player].value
1: self.options.Valor_Form_EXP.value,
2: self.options.Wisdom_Form_EXP.value,
3: self.options.Limit_Form_EXP.value,
4: self.options.Master_Form_EXP.value,
5: self.options.Final_Form_EXP.value
}
formexp = formDictExp[data.charName]
formName = formDict[data.charName]
@@ -172,7 +173,7 @@ def patch_kh2(self, output_directory):
for x in range(1, 7):
self.formattedFmlv["Summon"].append({
"Ability": 123,
"Experience": int(formExp[0][x] / self.multiworld.Summon_EXP[self.player].value),
"Experience": int(formExp[0][x] / self.options.Summon_EXP.value),
"FormId": 0,
"FormLevel": x,
"GrowthAbilityLevel": 0,
@@ -192,7 +193,7 @@ def patch_kh2(self, output_directory):
increaseStat(self.random.randint(0, 3))
itemcode = 0
self.formattedLvup["Sora"][self.i] = {
"Exp": int(soraExp[self.i] / self.multiworld.Sora_Level_EXP[self.player].value),
"Exp": int(soraExp[self.i] / self.options.Sora_Level_EXP.value),
"Strength": self.strength,
"Magic": self.magic,
"Defense": self.defense,
@@ -224,7 +225,7 @@ def patch_kh2(self, output_directory):
"Unknown": 0
})
self.formattedLvup["Sora"][1] = {
"Exp": int(soraExp[1] / self.multiworld.Sora_Level_EXP[self.player].value),
"Exp": int(soraExp[1] / self.options.Sora_Level_EXP.value),
"Strength": 2,
"Magic": 6,
"Defense": 2,
@@ -379,35 +380,35 @@ def patch_kh2(self, output_directory):
}
lucky_emblem_text = {
0: "Your Goal is not Lucky Emblem. It is Hitlist or Three Proofs.",
1: f"Lucky Emblem Required: {self.multiworld.LuckyEmblemsRequired[self.player]} out of {self.multiworld.LuckyEmblemsAmount[self.player]}",
1: f"Lucky Emblem Required: {self.options.LuckyEmblemsRequired} out of {self.options.LuckyEmblemsAmount}",
2: "Your Goal is not Lucky Emblem. It is Hitlist or Three Proofs.",
3: f"Lucky Emblem Required: {self.multiworld.LuckyEmblemsRequired[self.player]} out of {self.multiworld.LuckyEmblemsAmount[self.player]}"
3: f"Lucky Emblem Required: {self.options.LuckyEmblemsRequired} out of {self.options.LuckyEmblemsAmount}"
}
hitlist_text = {
0: "Your Goal is not Hitlist. It is Lucky Emblem or Three Proofs",
1: "Your Goal is not Hitlist. It is Lucky Emblem or Three Proofs",
2: f"Bounties Required: {self.multiworld.BountyRequired[self.player]} out of {self.multiworld.BountyAmount[self.player]}",
3: f"Bounties Required: {self.multiworld.BountyRequired[self.player]} out of {self.multiworld.BountyAmount[self.player]}",
2: f"Bounties Required: {self.options.BountyRequired} out of {self.options.BountyAmount}",
3: f"Bounties Required: {self.options.BountyRequired} out of {self.options.BountyAmount}",
}
self.pooh_text = [
{
'id': 18326,
'en': f"Your goal is {goal_to_text[self.multiworld.Goal[self.player].value]}"
'en': f"Your goal is {goal_to_text[self.options.Goal.value]}"
},
{
'id': 18327,
'en': lucky_emblem_text[self.multiworld.Goal[self.player].value]
'en': lucky_emblem_text[self.options.Goal.value]
},
{
'id': 18328,
'en': hitlist_text[self.multiworld.Goal[self.player].value]
'en': hitlist_text[self.options.Goal.value]
}
]
self.level_depth_text = [
{
'id': 0x3BF1,
'en': f"Your Level Depth is {self.multiworld.LevelDepth[self.player].current_option_name}"
'en': f"Your Level Depth is {self.options.LevelDepth.current_option_name}"
}
]
mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)

View File

@@ -935,7 +935,7 @@ def create_regions(self):
for level_region_name in level_region_list:
KH2REGIONS[level_region_name] = []
if multiworld.LevelDepth[player] == "level_50":
if self.options.LevelDepth == "level_50":
KH2REGIONS[RegionName.LevelsVS1] = [LocationName.Lvl2, LocationName.Lvl4, LocationName.Lvl7, LocationName.Lvl9,
LocationName.Lvl10]
KH2REGIONS[RegionName.LevelsVS3] = [LocationName.Lvl12, LocationName.Lvl14, LocationName.Lvl15,
@@ -949,7 +949,7 @@ def create_regions(self):
KH2REGIONS[RegionName.LevelsVS15] = [LocationName.Lvl50]
# level 99
elif multiworld.LevelDepth[player] == "level_99":
elif self.options.LevelDepth == "level_99":
KH2REGIONS[RegionName.LevelsVS1] = [LocationName.Lvl7, LocationName.Lvl9]
KH2REGIONS[RegionName.LevelsVS3] = [LocationName.Lvl12, LocationName.Lvl15, LocationName.Lvl17,
LocationName.Lvl20]
@@ -965,7 +965,7 @@ def create_regions(self):
KH2REGIONS[RegionName.LevelsVS26] = [LocationName.Lvl99]
# level sanity
# has to be [] instead of {} for in
elif multiworld.LevelDepth[player] in ["level_50_sanity", "level_99_sanity"]:
elif self.options.LevelDepth in ["level_50_sanity", "level_99_sanity"]:
KH2REGIONS[RegionName.LevelsVS1] = [LocationName.Lvl2, LocationName.Lvl3, LocationName.Lvl4, LocationName.Lvl5,
LocationName.Lvl6,
LocationName.Lvl7, LocationName.Lvl8, LocationName.Lvl9, LocationName.Lvl10]
@@ -986,7 +986,7 @@ def create_regions(self):
LocationName.Lvl46, LocationName.Lvl47, LocationName.Lvl48,
LocationName.Lvl49, LocationName.Lvl50]
# level 99 sanity
if multiworld.LevelDepth[player] == "level_99_sanity":
if self.options.LevelDepth == "level_99_sanity":
KH2REGIONS[RegionName.LevelsVS15] = [LocationName.Lvl51, LocationName.Lvl52, LocationName.Lvl53,
LocationName.Lvl54,
LocationName.Lvl55, LocationName.Lvl56, LocationName.Lvl57,
@@ -1012,7 +1012,7 @@ def create_regions(self):
LocationName.Lvl95, LocationName.Lvl96, LocationName.Lvl97,
LocationName.Lvl98, LocationName.Lvl99]
KH2REGIONS[RegionName.Summon] = []
if multiworld.SummonLevelLocationToggle[player]:
if self.options.SummonLevelLocationToggle:
KH2REGIONS[RegionName.Summon] = [LocationName.Summonlvl2,
LocationName.Summonlvl3,
LocationName.Summonlvl4,

View File

@@ -157,7 +157,7 @@ class KH2Rules:
def form_list_unlock(self, state: CollectionState, parent_form_list, level_required, fight_logic=False) -> bool:
form_access = {parent_form_list}
if self.multiworld.AutoFormLogic[self.player] and state.has(ItemName.SecondChance, self.player) and not fight_logic:
if self.world.options.AutoFormLogic and state.has(ItemName.SecondChance, self.player) and not fight_logic:
if parent_form_list == ItemName.MasterForm:
if state.has(ItemName.DriveConverter, self.player):
form_access.add(auto_form_dict[parent_form_list])
@@ -170,8 +170,8 @@ class KH2Rules:
forms_available = 0
form_list = [ItemName.ValorForm, ItemName.WisdomForm, ItemName.LimitForm, ItemName.MasterForm,
ItemName.FinalForm]
if self.world.multiworld.FinalFormLogic[self.player] != "no_light_and_darkness":
if self.world.multiworld.FinalFormLogic[self.player] == "light_and_darkness":
if self.world.options.FinalFormLogic != "no_light_and_darkness":
if self.world.options.FinalFormLogic == "light_and_darkness":
if state.has(ItemName.LightDarkness, self.player) and state.has_any(set(form_list), self.player):
forms_available += 1
form_list.remove(ItemName.FinalForm)
@@ -273,34 +273,35 @@ class KH2WorldRules(KH2Rules):
def set_kh2_goal(self):
final_xemnas_location = self.multiworld.get_location(LocationName.FinalXemnasEventLocation, self.player)
if self.multiworld.Goal[self.player] == "three_proofs":
if self.world.options.Goal == "three_proofs":
final_xemnas_location.access_rule = lambda state: self.kh2_has_all(three_proofs, state)
if self.multiworld.FinalXemnas[self.player]:
if self.world.options.FinalXemnas:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Victory, self.player, 1)
else:
self.multiworld.completion_condition[self.player] = lambda state: self.kh2_has_all(three_proofs, state)
# lucky emblem hunt
elif self.multiworld.Goal[self.player] == "lucky_emblem_hunt":
final_xemnas_location.access_rule = lambda state: state.has(ItemName.LuckyEmblem, self.player, self.multiworld.LuckyEmblemsRequired[self.player].value)
if self.multiworld.FinalXemnas[self.player]:
elif self.world.options.Goal == "lucky_emblem_hunt":
final_xemnas_location.access_rule = lambda state: state.has(ItemName.LuckyEmblem, self.player, self.world.options.LuckyEmblemsRequired.value)
if self.world.options.FinalXemnas:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Victory, self.player, 1)
else:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.LuckyEmblem, self.player, self.multiworld.LuckyEmblemsRequired[self.player].value)
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.LuckyEmblem, self.player, self.world.options.LuckyEmblemsRequired.value)
# hitlist if == 2
elif self.multiworld.Goal[self.player] == "hitlist":
final_xemnas_location.access_rule = lambda state: state.has(ItemName.Bounty, self.player, self.multiworld.BountyRequired[self.player].value)
if self.multiworld.FinalXemnas[self.player]:
elif self.world.options.Goal == "hitlist":
final_xemnas_location.access_rule = lambda state: state.has(ItemName.Bounty, self.player, self.world.options.BountyRequired.value)
if self.world.options.FinalXemnas:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Victory, self.player, 1)
else:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Bounty, self.player, self.multiworld.BountyRequired[self.player].value)
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Bounty, self.player, self.world.options.BountyRequired.value)
else:
final_xemnas_location.access_rule = lambda state: state.has(ItemName.Bounty, self.player, self.multiworld.BountyRequired[self.player].value) and \
state.has(ItemName.LuckyEmblem, self.player, self.multiworld.LuckyEmblemsRequired[self.player].value)
if self.multiworld.FinalXemnas[self.player]:
final_xemnas_location.access_rule = lambda state: state.has(ItemName.Bounty, self.player, self.world.options.BountyRequired.value) and \
state.has(ItemName.LuckyEmblem, self.player, self.world.options.LuckyEmblemsRequired.value)
if self.world.options.FinalXemnas:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Victory, self.player, 1)
else:
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Bounty, self.player, self.multiworld.BountyRequired[self.player].value) and \
state.has(ItemName.LuckyEmblem, self.player, self.multiworld.LuckyEmblemsRequired[self.player].value)
self.multiworld.completion_condition[self.player] = lambda state: state.has(ItemName.Bounty, self.player, self.world.options.BountyRequired.value) and \
state.has(ItemName.LuckyEmblem, self.player, self.world.options.LuckyEmblemsRequired.value)
class KH2FormRules(KH2Rules):
@@ -409,7 +410,7 @@ class KH2FightRules(KH2Rules):
# if skip rules are of return false
def __init__(self, world: KH2World) -> None:
super().__init__(world)
self.fight_logic = self.multiworld.FightLogic[self.player].current_key
self.fight_logic = world.options.FightLogic.current_key
self.fight_region_rules = {
RegionName.ShanYu: lambda state: self.get_shan_yu_rules(state),
@@ -935,7 +936,7 @@ class KH2FightRules(KH2Rules):
def get_cor_skip_first_rules(self, state: CollectionState) -> bool:
# if option is not allow skips return false else run rules
if not self.multiworld.CorSkipToggle[self.player]:
if not self.world.options.CorSkipToggle:
return False
# easy: aerial dodge 3,master form,fire
# normal: aerial dodge 2, master form,fire

View File

@@ -240,7 +240,7 @@ class KH2World(World):
self.hitlist_verify()
prio_hitlist = [location for location in self.multiworld.priority_locations[self.player].value if
prio_hitlist = [location for location in self.options.priority_locations.value if
location in self.random_super_boss_list]
for bounty in range(self.options.BountyAmount.value):
if prio_hitlist:
@@ -261,11 +261,11 @@ class KH2World(World):
if self.options.WeaponSlotStartHint:
for location in all_weapon_slot:
self.multiworld.start_location_hints[self.player].value.add(location)
self.options.start_location_hints.value.add(location)
if self.options.FillerItemsLocal:
for item in filler_items:
self.multiworld.local_items[self.player].value.add(item)
self.options.local_items.value.add(item)
# By imitating remote this doesn't have to be plandoded filler anymore
# for location in {LocationName.JunkMedal, LocationName.JunkMedal}:
# self.plando_locations[location] = random_stt_item
@@ -325,7 +325,7 @@ class KH2World(World):
self.item_quantity_dict[random_ability] -= 1
self.total_locations -= 1
self.slot_data_donald_weapon = [item_name.name for item_name in self.donald_weapon_abilities]
if not self.multiworld.DonaldGoofyStatsanity[self.player]:
if not self.options.DonaldGoofyStatsanity:
# pre plando donald get bonuses
self.donald_get_bonus_abilities += [self.create_item(random_prog_ability)]
self.total_locations -= 1
@@ -385,7 +385,7 @@ class KH2World(World):
location.place_locked_item(random_ability)
self.goofy_weapon_abilities.remove(random_ability)
if not self.multiworld.DonaldGoofyStatsanity[self.player]:
if not self.options.DonaldGoofyStatsanity:
# plando goofy get bonuses
goofy_get_bonus_location_pool = [self.multiworld.get_location(location, self.player) for location in
Goofy_Checks.keys() if Goofy_Checks[location].yml != "Keyblade"]
@@ -406,7 +406,7 @@ class KH2World(World):
location.place_locked_item(random_ability)
self.donald_weapon_abilities.remove(random_ability)
if not self.multiworld.DonaldGoofyStatsanity[self.player]:
if not self.options.DonaldGoofyStatsanity:
# plando goofy get bonuses
donald_get_bonus_location_pool = [self.multiworld.get_location(location, self.player) for location in
Donald_Checks.keys() if Donald_Checks[location].yml != "Keyblade"]
@@ -428,7 +428,7 @@ class KH2World(World):
"""
Making sure the player doesn't put too many abilities in their starting inventory.
"""
for item, value in self.multiworld.start_inventory[self.player].value.items():
for item, value in self.options.start_inventory.value.items():
if item in ActionAbility_Table \
or item in SupportAbility_Table or exclusion_item_table["StatUps"] \
or item in DonaldAbility_Table or item in GoofyAbility_Table:
@@ -461,7 +461,7 @@ class KH2World(World):
"""
Making sure hitlist have amount>=required.
"""
for location in self.multiworld.exclude_locations[self.player].value:
for location in self.options.exclude_locations.value:
if location in self.random_super_boss_list:
self.random_super_boss_list.remove(location)
@@ -491,7 +491,7 @@ class KH2World(World):
self.options.BountyAmount.value = temp
if self.options.BountyStartingHintToggle:
self.multiworld.start_hints[self.player].value.add(ItemName.Bounty)
self.options.start_hints.value.add(ItemName.Bounty)
if ItemName.ProofofNonexistence in self.item_quantity_dict:
del self.item_quantity_dict[ItemName.ProofofNonexistence]
@@ -503,19 +503,19 @@ class KH2World(World):
# Option to turn off all superbosses. Can do this individually but its like 20+ checks
if not self.options.SuperBosses:
for superboss in exclusion_table["SuperBosses"]:
self.multiworld.exclude_locations[self.player].value.add(superboss)
self.options.exclude_locations.value.add(superboss)
# Option to turn off Olympus Colosseum Cups.
if self.options.Cups == "no_cups":
for cup in exclusion_table["Cups"]:
self.multiworld.exclude_locations[self.player].value.add(cup)
self.options.exclude_locations.value.add(cup)
# exclude only hades paradox. If cups and hades paradox then nothing is excluded
elif self.options.Cups == "cups":
self.multiworld.exclude_locations[self.player].value.add(LocationName.HadesCupTrophyParadoxCups)
self.options.exclude_locations.value.add(LocationName.HadesCupTrophyParadoxCups)
if not self.options.AtlanticaToggle:
for loc in exclusion_table["Atlantica"]:
self.multiworld.exclude_locations[self.player].value.add(loc)
self.options.exclude_locations.value.add(loc)
def level_subtraction(self):
"""

View File

@@ -179,6 +179,22 @@ class ShuffleStoneBeaks(DungeonItemShuffle):
display_name = "Shuffle Stone Beaks"
ladxr_item = "STONE_BEAK"
class ShuffleInstruments(DungeonItemShuffle):
"""
Shuffle Instruments
[Original Dungeon] The item will be within its original dungeon
[Own Dungeons] The item will be within a dungeon in your world
[Own World] The item will be somewhere in your world
[Any World] The item could be anywhere
[Different World] The item will be somewhere in another world
[Vanilla] The item will be in its vanilla location in your world
"""
display_name = "Shuffle Instruments"
ladxr_item = "INSTRUMENT"
default = 100
option_vanilla = 100
alias_false = 100
class Goal(Choice, LADXROption):
"""
The Goal of the game
@@ -465,6 +481,7 @@ links_awakening_options: typing.Dict[str, typing.Type[Option]] = {
'shuffle_compasses': ShuffleCompasses,
'shuffle_stone_beaks': ShuffleStoneBeaks,
'music': Music,
'shuffle_instruments': ShuffleInstruments,
'music_change_condition': MusicChangeCondition,
'nag_messages': NagMessages,
'ap_title_screen': APTitleScreen,

View File

@@ -23,7 +23,7 @@ from .LADXR.settings import Settings as LADXRSettings
from .LADXR.worldSetup import WorldSetup as LADXRWorldSetup
from .Locations import (LinksAwakeningLocation, LinksAwakeningRegion,
create_regions_from_ladxr, get_locations_to_id)
from .Options import DungeonItemShuffle, links_awakening_options
from .Options import DungeonItemShuffle, links_awakening_options, ShuffleInstruments
from .Rom import LADXDeltaPatch
DEVELOPER_MODE = False
@@ -184,7 +184,7 @@ class LinksAwakeningWorld(World):
self.pre_fill_items = []
# For any and different world, set item rule instead
for option in ["maps", "compasses", "small_keys", "nightmare_keys", "stone_beaks"]:
for option in ["maps", "compasses", "small_keys", "nightmare_keys", "stone_beaks", "instruments"]:
option = "shuffle_" + option
option = self.player_options[option]
@@ -224,7 +224,10 @@ class LinksAwakeningWorld(World):
continue
if isinstance(item.item_data, DungeonItemData):
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT:
item_type = item.item_data.ladxr_id[:-1]
shuffle_type = dungeon_item_types[item_type]
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
# Find instrument, lock
# TODO: we should be able to pinpoint the region we want, save a lookup table please
found = False
@@ -240,10 +243,8 @@ class LinksAwakeningWorld(World):
found = True
break
if found:
break
break
else:
item_type = item.item_data.ladxr_id[:-1]
shuffle_type = dungeon_item_types[item_type]
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)

View File

@@ -30,6 +30,7 @@ L2AC_RX_ADDR: int = SRAM_START + 0x2800
class L2ACSNIClient(SNIClient):
game: str = "Lufia II Ancient Cave"
patch_suffix = ".apl2ac"
async def validate_rom(self, ctx: SNIContext) -> bool:
from SNIClient import snes_read

View File

@@ -1,14 +1,33 @@
import logging
from typing import Any, Dict, List, Optional
from typing import Any, ClassVar, Dict, List, Optional, TextIO
from BaseClasses import CollectionState, Item, ItemClassification, Tutorial
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial
from Options import Accessibility
from Utils import output_path
from settings import FilePath, Group
from worlds.AutoWorld import WebWorld, World
from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS
from .options import Goal, Logic, MessengerOptions, NotesNeeded, PowerSeals
from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS
from worlds.LauncherComponents import Component, Type, components
from .client_setup import launch_game
from .connections import CONNECTIONS, RANDOMIZED_CONNECTIONS, TRANSITIONS
from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS
from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions
from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals, validate_portals
from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS
from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules
from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices
from .subclasses import MessengerItem, MessengerRegion
from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices
from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation
components.append(
Component("The Messenger", component_type=Type.CLIENT, func=launch_game)#, game_name="The Messenger", supports_uri=True)
)
class MessengerSettings(Group):
class GamePath(FilePath):
description = "The Messenger game executable"
is_exe = True
game_path: GamePath = GamePath("TheMessenger.exe")
class MessengerWeb(WebWorld):
@@ -35,17 +54,10 @@ class MessengerWorld(World):
adventure full of thrills, surprises, and humor.
"""
game = "The Messenger"
item_name_groups = {
"Notes": set(NOTES),
"Keys": set(NOTES),
"Crest": {"Sun Crest", "Moon Crest"},
"Phobe": set(PHOBEKINS),
"Phobekin": set(PHOBEKINS),
}
options_dataclass = MessengerOptions
options: MessengerOptions
settings_key = "messenger_settings"
settings: ClassVar[MessengerSettings]
base_offset = 0xADD_000
item_name_to_id = {item: item_id
@@ -54,58 +66,144 @@ class MessengerWorld(World):
for location_id, location in
enumerate([
*ALWAYS_LOCATIONS,
*[seal for seals in SEALS.values() for seal in seals],
*[shard for shards in MEGA_SHARDS.values() for shard in shards],
*BOSS_LOCATIONS,
*[f"The Shop - {shop_loc}" for shop_loc in SHOP_ITEMS],
*FIGURINES,
"Money Wrench",
], base_offset)}
item_name_groups = {
"Notes": set(NOTES),
"Keys": set(NOTES),
"Crest": {"Sun Crest", "Moon Crest"},
"Phobe": set(PHOBEKINS),
"Phobekin": set(PHOBEKINS),
}
location_name_groups = {
"Notes": {
"Autumn Hills - Key of Hope",
"Searing Crags - Key of Strength",
"Underworld - Key of Chaos",
"Sunken Shrine - Key of Love",
"Elemental Skylands - Key of Symbiosis",
"Corrupted Future - Key of Courage",
},
"Keys": {
"Autumn Hills - Key of Hope",
"Searing Crags - Key of Strength",
"Underworld - Key of Chaos",
"Sunken Shrine - Key of Love",
"Elemental Skylands - Key of Symbiosis",
"Corrupted Future - Key of Courage",
},
"Phobe": {
"Catacombs - Necro",
"Bamboo Creek - Claustro",
"Searing Crags - Pyro",
"Cloud Ruins - Acro",
},
"Phobekin": {
"Catacombs - Necro",
"Bamboo Creek - Claustro",
"Searing Crags - Pyro",
"Cloud Ruins - Acro",
},
}
required_client_version = (0, 4, 2)
required_client_version = (0, 4, 3)
web = MessengerWeb()
total_seals: int = 0
required_seals: int = 0
created_seals: int = 0
total_shards: int = 0
shop_prices: Dict[str, int]
figurine_prices: Dict[str, int]
_filler_items: List[str]
starting_portals: List[str]
plando_portals: List[str]
spoiler_portal_mapping: Dict[str, str]
portal_mapping: List[int]
transitions: List[Entrance]
reachable_locs: int = 0
def generate_early(self) -> None:
if self.options.goal == Goal.option_power_seal_hunt:
self.options.shuffle_seals.value = PowerSeals.option_true
self.total_seals = self.options.total_seals.value
if self.options.limited_movement:
self.options.accessibility.value = Accessibility.option_minimal
if self.options.logic_level < Logic.option_hard:
self.options.logic_level.value = Logic.option_hard
if self.options.early_meditation:
self.multiworld.early_items[self.player]["Meditation"] = 1
self.shop_prices, self.figurine_prices = shuffle_shop_prices(self)
starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", "Searing Crags"]
self.starting_portals = [f"{portal} Portal"
for portal in starting_portals[:3] +
self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)]
# super complicated method for adding searing crags to starting portals if it wasn't chosen
# need to add a check for transition shuffle when that gets added back in
if not self.options.shuffle_portals and "Searing Crags Portal" not in self.starting_portals:
self.starting_portals.append("Searing Crags Portal")
if len(self.starting_portals) > 4:
portals_to_strip = [portal for portal in ["Riviere Turquoise Portal", "Sunken Shrine Portal"]
if portal in self.starting_portals]
self.starting_portals.remove(self.random.choice(portals_to_strip))
self.plando_portals = []
self.portal_mapping = []
self.spoiler_portal_mapping = {}
self.transitions = []
def create_regions(self) -> None:
# MessengerRegion adds itself to the multiworld
for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]:
if region.name in REGION_CONNECTIONS:
region.add_exits(REGION_CONNECTIONS[region.name])
# create simple regions
simple_regions = [MessengerRegion(level, self) for level in LEVELS]
# create complex regions that have sub-regions
complex_regions = [MessengerRegion(f"{parent} - {reg_name}", self, parent)
for parent, sub_region in CONNECTIONS.items()
for reg_name in sub_region]
for region in complex_regions:
region_name = region.name.replace(f"{region.parent} - ", "")
connection_data = CONNECTIONS[region.parent][region_name]
for exit_region in connection_data:
region.connect(self.multiworld.get_region(exit_region, self.player))
# all regions need to be created before i can do these connections so we create and connect the complex first
for region in [level for level in simple_regions if level.name in REGION_CONNECTIONS]:
region.add_exits(REGION_CONNECTIONS[region.name])
def create_items(self) -> None:
# create items that are always in the item pool
main_movement_items = ["Rope Dart", "Wingsuit"]
itempool: List[MessengerItem] = [
self.create_item(item)
for item in self.item_name_to_id
if item not in
{
"Power Seal", *NOTES, *FIGURINES,
if "Time Shard" not in item and item not in {
"Power Seal", *NOTES, *FIGURINES, *main_movement_items,
*{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]},
} and "Time Shard" not in item
}
]
if self.options.limited_movement:
itempool.append(self.create_item(self.random.choice(main_movement_items)))
else:
itempool += [self.create_item(move_item) for move_item in main_movement_items]
if self.options.goal == Goal.option_open_music_box:
# make a list of all notes except those in the player's defined starting inventory, and adjust the
# amount we need to put in the itempool and precollect based on that
notes = [note for note in NOTES if note not in self.multiworld.precollected_items[self.player]]
self.random.shuffle(notes)
precollected_notes_amount = NotesNeeded.range_end - \
self.options.notes_needed - \
(len(NOTES) - len(notes))
self.options.notes_needed - \
(len(NOTES) - len(notes))
if precollected_notes_amount:
for note in notes[:precollected_notes_amount]:
self.multiworld.push_precollected(self.create_item(note))
@@ -116,26 +214,27 @@ class MessengerWorld(World):
total_seals = min(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool),
self.options.total_seals.value)
if total_seals < self.total_seals:
logging.warning(f"Not enough locations for total seals setting "
f"({self.options.total_seals}). Adjusting to {total_seals}")
logging.warning(
f"Not enough locations for total seals setting "
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)
seals = [self.create_item("Power Seal") for _ in range(self.total_seals)]
for i in range(self.required_seals):
seals[i].classification = ItemClassification.progression_skip_balancing
itempool += seals
self.multiworld.itempool += itempool
remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool)
if remaining_fill < 10:
self._filler_items = self.random.choices(
list(FILLER)[2:],
weights=list(FILLER.values())[2:],
k=remaining_fill
list(FILLER)[2:],
weights=list(FILLER.values())[2:],
k=remaining_fill
)
itempool += [self.create_filler() for _ in range(remaining_fill)]
filler = [self.create_filler() for _ in range(remaining_fill)]
self.multiworld.itempool += itempool
self.multiworld.itempool += filler
def set_rules(self) -> None:
logic = self.options.logic_level
@@ -144,16 +243,59 @@ class MessengerWorld(World):
elif logic == Logic.option_hard:
MessengerHardRules(self).set_messenger_rules()
else:
MessengerOOBRules(self).set_messenger_rules()
raise ValueError(f"Somehow you have a logic option that's currently invalid."
f" {logic} for {self.multiworld.get_player_name(self.player)}")
# MessengerOOBRules(self).set_messenger_rules()
add_closed_portal_reqs(self)
# i need portal shuffle to happen after rules exist so i can validate it
attempts = 5
if self.options.shuffle_portals:
self.portal_mapping = []
self.spoiler_portal_mapping = {}
for _ in range(attempts):
disconnect_portals(self)
shuffle_portals(self)
if validate_portals(self):
break
# failsafe mostly for invalid plandoed portals with no transition shuffle
else:
raise RuntimeError("Unable to generate valid portal output.")
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
if self.options.available_portals < 6:
spoiler_handle.write(f"\nStarting Portals:\n\n")
for portal in self.starting_portals:
spoiler_handle.write(f"{portal}\n")
spoiler = self.multiworld.spoiler
if self.options.shuffle_portals:
# sort the portals as they appear left to right in-game
portal_info = sorted(
self.spoiler_portal_mapping.items(),
key=lambda portal:
["Autumn Hills", "Riviere Turquoise",
"Howling Grotto", "Sunken Shrine",
"Searing Crags", "Glacial Peak"].index(portal[0]))
for portal, output in portal_info:
spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player)
def fill_slot_data(self) -> Dict[str, Any]:
return {
slot_data = {
"shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()},
"figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()},
"max_price": self.total_shards,
"required_seals": self.required_seals,
"starting_portals": self.starting_portals,
"portal_exits": self.portal_mapping,
"transitions": [[TRANSITIONS.index("Corrupted Future") if transition.name == "Artificer's Portal"
else TRANSITIONS.index(RANDOMIZED_CONNECTIONS[transition.parent_region.name]),
TRANSITIONS.index(transition.connected_region.name)]
for transition in self.transitions],
**self.options.as_dict("music_box", "death_link", "logic_level"),
}
return slot_data
def get_filler_item_name(self) -> str:
if not getattr(self, "_filler_items", None):
@@ -166,15 +308,35 @@ class MessengerWorld(World):
def create_item(self, name: str) -> MessengerItem:
item_id: Optional[int] = self.item_name_to_id.get(name, None)
override_prog = getattr(self, "multiworld") is not None and \
name in {"Windmill Shuriken"} and \
self.options.logic_level > Logic.option_normal
count = 0
return MessengerItem(
name,
ItemClassification.progression if item_id is None else self.get_item_classification(name),
item_id,
self.player
)
def get_item_classification(self, name: str) -> ItemClassification:
if "Time Shard " in name:
count = int(name.strip("Time Shard ()"))
count = count if count >= 100 else 0
self.total_shards += count
return MessengerItem(name, self.player, item_id, override_prog, count)
return ItemClassification.progression_skip_balancing if count else ItemClassification.filler
if name == "Windmill Shuriken" and getattr(self, "multiworld", None) is not None:
return ItemClassification.progression if self.options.logic_level else ItemClassification.filler
if name == "Power Seal":
self.created_seals += 1
return ItemClassification.progression_skip_balancing \
if self.required_seals >= self.created_seals else ItemClassification.filler
if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS, *PROG_SHOP_ITEMS}:
return ItemClassification.progression
if name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}:
return ItemClassification.useful
return ItemClassification.filler
def collect(self, state: "CollectionState", item: "Item") -> bool:
change = super().collect(state, item)
@@ -187,3 +349,25 @@ class MessengerWorld(World):
if change and "Time Shard" in item.name:
state.prog_items[self.player]["Shards"] -= int(item.name.strip("Time Shard ()"))
return change
@classmethod
def stage_generate_output(cls, multiworld: MultiWorld, output_directory: str) -> None:
# using stage_generate_output because it doesn't increase the logged player count for players without output
# only generate output if there's a single player
if multiworld.players > 1:
return
# the messenger client calls into AP with specific args, so check the out path matches what the client sends
out_path = output_path(multiworld.get_out_file_name_base(1) + ".aptm")
if "The Messenger\\Archipelago\\output" not in out_path:
return
import orjson
data = {
"name": multiworld.get_player_name(1),
"slot_data": multiworld.worlds[1].fill_slot_data(),
"loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]}
for loc in multiworld.get_filled_locations() if loc.address},
}
output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS)
with open(out_path, "wb") as f:
f.write(output)

View File

@@ -0,0 +1,164 @@
import io
import logging
import os.path
import subprocess
import urllib.request
from shutil import which
from tkinter.messagebox import askyesnocancel
from typing import Any, Optional
from zipfile import ZipFile
from Utils import open_file
import requests
from Utils import is_windows, messagebox, tuplize_version
MOD_URL = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest"
def launch_game(url: Optional[str] = None) -> None:
"""Check the game installation, then launch it"""
def courier_installed() -> bool:
"""Check if Courier is installed"""
return os.path.exists(os.path.join(game_folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.Courier.mm.dll"))
def mod_installed() -> bool:
"""Check if the mod is installed"""
return os.path.exists(os.path.join(game_folder, "Mods", "TheMessengerRandomizerAP", "courier.toml"))
def request_data(request_url: str) -> Any:
"""Fetches json response from given url"""
logging.info(f"requesting {request_url}")
response = requests.get(request_url)
if response.status_code == 200: # success
try:
data = response.json()
except requests.exceptions.JSONDecodeError:
raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})")
else:
raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})")
return data
def install_courier() -> None:
"""Installs latest version of Courier"""
# can't use latest since courier uses pre-release tags
courier_url = "https://api.github.com/repos/Brokemia/Courier/releases"
latest_download = request_data(courier_url)[0]["assets"][-1]["browser_download_url"]
with urllib.request.urlopen(latest_download) as download:
with ZipFile(io.BytesIO(download.read()), "r") as zf:
for member in zf.infolist():
zf.extract(member, path=game_folder)
os.chdir(game_folder)
# linux and mac handling
if not is_windows:
mono_exe = which("mono")
if not mono_exe:
# steam deck support but doesn't currently work
messagebox("Failure", "Failed to install Courier", True)
raise RuntimeError("Failed to install Courier")
# # download and use mono kickstart
# # this allows steam deck support
# mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/master.zip"
# target = os.path.join(folder, "monoKickstart")
# os.makedirs(target, exist_ok=True)
# with urllib.request.urlopen(mono_kick_url) as download:
# with ZipFile(io.BytesIO(download.read()), "r") as zf:
# for member in zf.infolist():
# zf.extract(member, path=target)
# installer = subprocess.Popen([os.path.join(target, "precompiled"),
# os.path.join(folder, "MiniInstaller.exe")], shell=False)
# os.remove(target)
else:
installer = subprocess.Popen([mono_exe, os.path.join(game_folder, "MiniInstaller.exe")], shell=False)
else:
installer = subprocess.Popen(os.path.join(game_folder, "MiniInstaller.exe"), shell=False)
failure = installer.wait()
if failure:
messagebox("Failure", "Failed to install Courier", True)
os.chdir(working_directory)
raise RuntimeError("Failed to install Courier")
os.chdir(working_directory)
if courier_installed():
messagebox("Success!", "Courier successfully installed!")
return
messagebox("Failure", "Failed to install Courier", True)
raise RuntimeError("Failed to install Courier")
def install_mod() -> None:
"""Installs latest version of the mod"""
assets = request_data(MOD_URL)["assets"]
if len(assets) == 1:
release_url = assets[0]["browser_download_url"]
else:
for asset in assets:
if "TheMessengerRandomizerAP" in asset["name"]:
release_url = asset["browser_download_url"]
break
else:
messagebox("Failure", "Failed to find latest mod download", True)
raise RuntimeError("Failed to install Mod")
mod_folder = os.path.join(game_folder, "Mods")
os.makedirs(mod_folder, exist_ok=True)
with urllib.request.urlopen(release_url) as download:
with ZipFile(io.BytesIO(download.read()), "r") as zf:
for member in zf.infolist():
zf.extract(member, path=mod_folder)
messagebox("Success!", "Latest mod successfully installed!")
def available_mod_update(latest_version: str) -> bool:
"""Check if there's an available update"""
latest_version = latest_version.lstrip("v")
toml_path = os.path.join(game_folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")
with open(toml_path, "r") as f:
installed_version = f.read().splitlines()[1].strip("version = \"")
logging.info(f"Installed version: {installed_version}. Latest version: {latest_version}")
# one of the alpha builds
return "alpha" in latest_version or tuplize_version(latest_version) > tuplize_version(installed_version)
from . import MessengerWorld
game_folder = os.path.dirname(MessengerWorld.settings.game_path)
working_directory = os.getcwd()
if not courier_installed():
should_install = askyesnocancel("Install Courier",
"No Courier installation detected. Would you like to install now?")
if not should_install:
return
logging.info("Installing Courier")
install_courier()
if not mod_installed():
should_install = askyesnocancel("Install Mod",
"No randomizer mod detected. Would you like to install now?")
if not should_install:
return
logging.info("Installing Mod")
install_mod()
else:
latest = request_data(MOD_URL)["tag_name"]
if available_mod_update(latest):
should_update = askyesnocancel("Update Mod",
f"New mod version detected. Would you like to update to {latest} now?")
if should_update:
logging.info("Updating mod")
install_mod()
elif should_update is None:
return
if not is_windows:
if url:
open_file(f"steam://rungameid/764790//{url}/")
else:
open_file("steam://rungameid/764790")
else:
os.chdir(game_folder)
if url:
subprocess.Popen([MessengerWorld.settings.game_path, str(url)])
else:
subprocess.Popen(MessengerWorld.settings.game_path)
os.chdir(working_directory)

View File

@@ -0,0 +1,725 @@
from typing import Dict, List
CONNECTIONS: Dict[str, Dict[str, List[str]]] = {
"Ninja Village": {
"Right": [
"Autumn Hills - Left",
"Ninja Village - Nest",
],
"Nest": [
"Ninja Village - Right",
],
},
"Autumn Hills": {
"Left": [
"Ninja Village - Right",
"Autumn Hills - Climbing Claws Shop",
],
"Right": [
"Forlorn Temple - Left",
"Autumn Hills - Leaf Golem Shop",
],
"Bottom": [
"Catacombs - Bottom Left",
"Autumn Hills - Double Swing Checkpoint",
],
"Portal": [
"Tower HQ",
"Autumn Hills - Dimension Climb Shop",
],
"Climbing Claws Shop": [
"Autumn Hills - Left",
"Autumn Hills - Hope Path Shop",
"Autumn Hills - Lakeside Checkpoint",
"Autumn Hills - Key of Hope Checkpoint",
],
"Hope Path Shop": [
"Autumn Hills - Climbing Claws Shop",
"Autumn Hills - Hope Latch Checkpoint",
"Autumn Hills - Lakeside Checkpoint",
],
"Dimension Climb Shop": [
"Autumn Hills - Lakeside Checkpoint",
"Autumn Hills - Portal",
"Autumn Hills - Double Swing Checkpoint",
],
"Leaf Golem Shop": [
"Autumn Hills - Spike Ball Swing Checkpoint",
"Autumn Hills - Right",
],
"Hope Latch Checkpoint": [
"Autumn Hills - Hope Path Shop",
"Autumn Hills - Key of Hope Checkpoint",
],
"Key of Hope Checkpoint": [
"Autumn Hills - Hope Latch Checkpoint",
"Autumn Hills - Lakeside Checkpoint",
],
"Lakeside Checkpoint": [
"Autumn Hills - Climbing Claws Shop",
"Autumn Hills - Dimension Climb Shop",
],
"Double Swing Checkpoint": [
"Autumn Hills - Dimension Climb Shop",
"Autumn Hills - Spike Ball Swing Checkpoint",
"Autumn Hills - Bottom",
],
"Spike Ball Swing Checkpoint": [
"Autumn Hills - Double Swing Checkpoint",
"Autumn Hills - Leaf Golem Shop",
],
},
"Forlorn Temple": {
"Left": [
"Autumn Hills - Right",
"Forlorn Temple - Outside Shop",
],
"Right": [
"Bamboo Creek - Top Left",
"Forlorn Temple - Demon King Shop",
],
"Bottom": [
"Catacombs - Top Left",
"Forlorn Temple - Outside Shop",
],
"Outside Shop": [
"Forlorn Temple - Left",
"Forlorn Temple - Bottom",
"Forlorn Temple - Entrance Shop",
],
"Entrance Shop": [
"Forlorn Temple - Outside Shop",
"Forlorn Temple - Sunny Day Checkpoint",
],
"Climb Shop": [
"Forlorn Temple - Rocket Maze Checkpoint",
"Forlorn Temple - Rocket Sunset Shop",
],
"Rocket Sunset Shop": [
"Forlorn Temple - Climb Shop",
"Forlorn Temple - Descent Shop",
],
"Descent Shop": [
"Forlorn Temple - Rocket Sunset Shop",
"Forlorn Temple - Saw Gauntlet Shop",
],
"Saw Gauntlet Shop": [
"Forlorn Temple - Demon King Shop",
],
"Demon King Shop": [
"Forlorn Temple - Saw Gauntlet Shop",
"Forlorn Temple - Right",
],
"Sunny Day Checkpoint": [
"Forlorn Temple - Rocket Maze Checkpoint",
],
"Rocket Maze Checkpoint": [
"Forlorn Temple - Sunny Day Checkpoint",
"Forlorn Temple - Climb Shop",
],
},
"Catacombs": {
"Top Left": [
"Forlorn Temple - Bottom",
"Catacombs - Triple Spike Crushers Shop",
],
"Bottom Left": [
"Autumn Hills - Bottom",
"Catacombs - Triple Spike Crushers Shop",
"Catacombs - Death Trap Checkpoint",
],
"Bottom": [
"Dark Cave - Right",
"Catacombs - Dirty Pond Checkpoint",
],
"Right": [
"Bamboo Creek - Bottom Left",
"Catacombs - Ruxxtin Shop",
],
"Triple Spike Crushers Shop": [
"Catacombs - Bottom Left",
"Catacombs - Death Trap Checkpoint",
],
"Ruxxtin Shop": [
"Catacombs - Right",
"Catacombs - Dirty Pond Checkpoint",
],
"Death Trap Checkpoint": [
"Catacombs - Triple Spike Crushers Shop",
"Catacombs - Bottom Left",
"Catacombs - Dirty Pond Checkpoint",
],
"Crusher Gauntlet Checkpoint": [
"Catacombs - Dirty Pond Checkpoint",
],
"Dirty Pond Checkpoint": [
"Catacombs - Bottom",
"Catacombs - Death Trap Checkpoint",
"Catacombs - Crusher Gauntlet Checkpoint",
"Catacombs - Ruxxtin Shop",
],
},
"Bamboo Creek": {
"Bottom Left": [
"Catacombs - Right",
"Bamboo Creek - Spike Crushers Shop",
],
"Top Left": [
"Bamboo Creek - Abandoned Shop",
"Forlorn Temple - Right",
],
"Right": [
"Howling Grotto - Left",
"Bamboo Creek - Time Loop Shop",
],
"Spike Crushers Shop": [
"Bamboo Creek - Bottom Left",
"Bamboo Creek - Abandoned Shop",
],
"Abandoned Shop": [
"Bamboo Creek - Spike Crushers Shop",
"Bamboo Creek - Spike Doors Checkpoint",
],
"Time Loop Shop": [
"Bamboo Creek - Right",
"Bamboo Creek - Spike Doors Checkpoint",
],
"Spike Ball Pits Checkpoint": [
"Bamboo Creek - Spike Doors Checkpoint",
],
"Spike Doors Checkpoint": [
"Bamboo Creek - Abandoned Shop",
"Bamboo Creek - Spike Ball Pits Checkpoint",
"Bamboo Creek - Time Loop Shop",
],
},
"Howling Grotto": {
"Left": [
"Bamboo Creek - Right",
"Howling Grotto - Wingsuit Shop",
],
"Top": [
"Howling Grotto - Crushing Pits Shop",
"Quillshroom Marsh - Bottom Left",
],
"Right": [
"Howling Grotto - Emerald Golem Shop",
"Quillshroom Marsh - Top Left",
],
"Bottom": [
"Howling Grotto - Lost Woods Checkpoint",
"Sunken Shrine - Left",
],
"Portal": [
"Howling Grotto - Crushing Pits Shop",
"Tower HQ",
],
"Wingsuit Shop": [
"Howling Grotto - Left",
"Howling Grotto - Lost Woods Checkpoint",
],
"Crushing Pits Shop": [
"Howling Grotto - Lost Woods Checkpoint",
"Howling Grotto - Portal",
"Howling Grotto - Breezy Crushers Checkpoint",
"Howling Grotto - Top",
],
"Emerald Golem Shop": [
"Howling Grotto - Breezy Crushers Checkpoint",
"Howling Grotto - Right",
],
"Lost Woods Checkpoint": [
"Howling Grotto - Wingsuit Shop",
"Howling Grotto - Crushing Pits Shop",
"Howling Grotto - Bottom",
],
"Breezy Crushers Checkpoint": [
"Howling Grotto - Crushing Pits Shop",
"Howling Grotto - Emerald Golem Shop",
],
},
"Quillshroom Marsh": {
"Top Left": [
"Howling Grotto - Right",
"Quillshroom Marsh - Seashell Checkpoint",
"Quillshroom Marsh - Spikey Window Shop",
],
"Bottom Left": [
"Howling Grotto - Top",
"Quillshroom Marsh - Sand Trap Shop",
"Quillshroom Marsh - Bottom Right",
],
"Top Right": [
"Quillshroom Marsh - Queen of Quills Shop",
"Searing Crags - Left",
],
"Bottom Right": [
"Quillshroom Marsh - Bottom Left",
"Quillshroom Marsh - Sand Trap Shop",
"Searing Crags - Bottom",
],
"Spikey Window Shop": [
"Quillshroom Marsh - Top Left",
"Quillshroom Marsh - Seashell Checkpoint",
"Quillshroom Marsh - Quicksand Checkpoint",
],
"Sand Trap Shop": [
"Quillshroom Marsh - Quicksand Checkpoint",
"Quillshroom Marsh - Bottom Left",
"Quillshroom Marsh - Bottom Right",
"Quillshroom Marsh - Spike Wave Checkpoint",
],
"Queen of Quills Shop": [
"Quillshroom Marsh - Spike Wave Checkpoint",
"Quillshroom Marsh - Top Right",
],
"Seashell Checkpoint": [
"Quillshroom Marsh - Top Left",
"Quillshroom Marsh - Spikey Window Shop",
],
"Quicksand Checkpoint": [
"Quillshroom Marsh - Spikey Window Shop",
"Quillshroom Marsh - Sand Trap Shop",
],
"Spike Wave Checkpoint": [
"Quillshroom Marsh - Sand Trap Shop",
"Quillshroom Marsh - Queen of Quills Shop",
],
},
"Searing Crags": {
"Left": [
"Quillshroom Marsh - Top Right",
"Searing Crags - Rope Dart Shop",
],
"Top": [
"Searing Crags - Colossuses Shop",
"Glacial Peak - Bottom",
],
"Bottom": [
"Searing Crags - Portal",
"Quillshroom Marsh - Bottom Right",
],
"Right": [
"Searing Crags - Portal",
"Underworld - Left",
],
"Portal": [
"Searing Crags - Bottom",
"Searing Crags - Right",
"Searing Crags - Before Final Climb Shop",
"Searing Crags - Colossuses Shop",
"Tower HQ",
],
"Rope Dart Shop": [
"Searing Crags - Left",
"Searing Crags - Triple Ball Spinner Checkpoint",
],
"Falling Rocks Shop": [
"Searing Crags - Triple Ball Spinner Checkpoint",
"Searing Crags - Searing Mega Shard Shop",
],
"Searing Mega Shard Shop": [
"Searing Crags - Falling Rocks Shop",
"Searing Crags - Before Final Climb Shop",
"Searing Crags - Key of Strength Shop",
],
"Before Final Climb Shop": [
"Searing Crags - Raining Rocks Checkpoint",
"Searing Crags - Portal",
"Searing Crags - Colossuses Shop",
],
"Colossuses Shop": [
"Searing Crags - Before Final Climb Shop",
"Searing Crags - Key of Strength Shop",
"Searing Crags - Portal",
"Searing Crags - Top",
],
"Key of Strength Shop": [
"Searing Crags - Searing Mega Shard Shop",
],
"Triple Ball Spinner Checkpoint": [
"Searing Crags - Rope Dart Shop",
"Searing Crags - Falling Rocks Shop",
],
"Raining Rocks Checkpoint": [
"Searing Crags - Searing Mega Shard Shop",
"Searing Crags - Before Final Climb Shop",
],
},
"Glacial Peak": {
"Bottom": [
"Searing Crags - Top",
"Glacial Peak - Ice Climbers' Shop",
],
"Left": [
"Elemental Skylands - Air Shmup",
"Glacial Peak - Projectile Spike Pit Checkpoint",
"Glacial Peak - Glacial Mega Shard Shop",
],
"Top": [
"Glacial Peak - Tower Entrance Shop",
"Cloud Ruins - Left",
],
"Portal": [
"Glacial Peak - Tower Entrance Shop",
"Tower HQ",
],
"Ice Climbers' Shop": [
"Glacial Peak - Bottom",
"Glacial Peak - Projectile Spike Pit Checkpoint",
],
"Glacial Mega Shard Shop": [
"Glacial Peak - Left",
"Glacial Peak - Air Swag Checkpoint",
],
"Tower Entrance Shop": [
"Glacial Peak - Top",
"Glacial Peak - Free Climbing Checkpoint",
"Glacial Peak - Portal",
],
"Projectile Spike Pit Checkpoint": [
"Glacial Peak - Ice Climbers' Shop",
"Glacial Peak - Left",
],
"Air Swag Checkpoint": [
"Glacial Peak - Glacial Mega Shard Shop",
"Glacial Peak - Free Climbing Checkpoint",
],
"Free Climbing Checkpoint": [
"Glacial Peak - Air Swag Checkpoint",
"Glacial Peak - Tower Entrance Shop",
],
},
"Tower of Time": {
"Left": [
"Tower of Time - Final Chance Shop",
],
"Final Chance Shop": [
"Tower of Time - First Checkpoint",
],
"Arcane Golem Shop": [
"Tower of Time - Sixth Checkpoint",
],
"First Checkpoint": [
"Tower of Time - Second Checkpoint",
],
"Second Checkpoint": [
"Tower of Time - Third Checkpoint",
],
"Third Checkpoint": [
"Tower of Time - Fourth Checkpoint",
],
"Fourth Checkpoint": [
"Tower of Time - Fifth Checkpoint",
],
"Fifth Checkpoint": [
"Tower of Time - Sixth Checkpoint",
],
"Sixth Checkpoint": [
"Tower of Time - Arcane Golem Shop",
],
},
"Cloud Ruins": {
"Left": [
"Glacial Peak - Top",
"Cloud Ruins - Cloud Entrance Shop",
],
"Cloud Entrance Shop": [
"Cloud Ruins - Left",
"Cloud Ruins - Spike Float Checkpoint",
],
"Pillar Glide Shop": [
"Cloud Ruins - Spike Float Checkpoint",
"Cloud Ruins - Ghost Pit Checkpoint",
"Cloud Ruins - Crushers' Descent Shop",
],
"Crushers' Descent Shop": [
"Cloud Ruins - Pillar Glide Shop",
"Cloud Ruins - Toothbrush Alley Checkpoint",
],
"Seeing Spikes Shop": [
"Cloud Ruins - Toothbrush Alley Checkpoint",
"Cloud Ruins - Sliding Spikes Shop",
],
"Sliding Spikes Shop": [
"Cloud Ruins - Seeing Spikes Shop",
"Cloud Ruins - Saw Pit Checkpoint",
],
"Final Flight Shop": [
"Cloud Ruins - Saw Pit Checkpoint",
"Cloud Ruins - Manfred's Shop",
],
"Manfred's Shop": [
"Cloud Ruins - Final Flight Shop",
],
"Spike Float Checkpoint": [
"Cloud Ruins - Cloud Entrance Shop",
"Cloud Ruins - Pillar Glide Shop",
],
"Ghost Pit Checkpoint": [
"Cloud Ruins - Pillar Glide Shop",
],
"Toothbrush Alley Checkpoint": [
"Cloud Ruins - Crushers' Descent Shop",
"Cloud Ruins - Seeing Spikes Shop",
],
"Saw Pit Checkpoint": [
"Cloud Ruins - Sliding Spikes Shop",
"Cloud Ruins - Final Flight Shop",
],
},
"Underworld": {
"Left": [
"Underworld - Left Shop",
"Searing Crags - Right",
],
"Left Shop": [
"Underworld - Left",
"Underworld - Hot Dip Checkpoint",
],
"Fireball Wave Shop": [
"Underworld - Hot Dip Checkpoint",
"Underworld - Long Climb Shop",
],
"Long Climb Shop": [
"Underworld - Fireball Wave Shop",
"Underworld - Hot Tub Checkpoint",
],
"Barm'athaziel Shop": [
"Underworld - Hot Tub Checkpoint",
],
"Key of Chaos Shop": [
],
"Hot Dip Checkpoint": [
"Underworld - Left Shop",
"Underworld - Fireball Wave Shop",
"Underworld - Lava Run Checkpoint",
],
"Hot Tub Checkpoint": [
"Underworld - Long Climb Shop",
"Underworld - Barm'athaziel Shop",
],
"Lava Run Checkpoint": [
"Underworld - Hot Dip Checkpoint",
"Underworld - Key of Chaos Shop",
],
},
"Dark Cave": {
"Right": [
"Catacombs - Bottom",
"Dark Cave - Left",
],
"Left": [
"Riviere Turquoise - Right",
],
},
"Riviere Turquoise": {
"Right": [
"Riviere Turquoise - Portal",
],
"Portal": [
"Riviere Turquoise - Waterfall Shop",
"Tower HQ",
],
"Waterfall Shop": [
"Riviere Turquoise - Portal",
"Riviere Turquoise - Flower Flight Checkpoint",
],
"Launch of Faith Shop": [
"Riviere Turquoise - Flower Flight Checkpoint",
"Riviere Turquoise - Log Flume Shop",
],
"Log Flume Shop": [
"Riviere Turquoise - Log Climb Shop",
],
"Log Climb Shop": [
"Riviere Turquoise - Restock Shop",
],
"Restock Shop": [
"Riviere Turquoise - Butterfly Matriarch Shop",
],
"Butterfly Matriarch Shop": [
],
"Flower Flight Checkpoint": [
"Riviere Turquoise - Waterfall Shop",
"Riviere Turquoise - Launch of Faith Shop",
],
},
"Elemental Skylands": {
"Air Shmup": [
"Elemental Skylands - Air Intro Shop",
],
"Air Intro Shop": [
"Elemental Skylands - Air Seal Checkpoint",
"Elemental Skylands - Air Generator Shop",
],
"Air Seal Checkpoint": [
"Elemental Skylands - Air Intro Shop",
"Elemental Skylands - Air Generator Shop",
],
"Air Generator Shop": [
"Elemental Skylands - Earth Shmup",
],
"Earth Shmup": [
"Elemental Skylands - Earth Intro Shop",
],
"Earth Intro Shop": [
"Elemental Skylands - Earth Generator Shop",
],
"Earth Generator Shop": [
"Elemental Skylands - Fire Shmup",
],
"Fire Shmup": [
"Elemental Skylands - Fire Intro Shop",
],
"Fire Intro Shop": [
"Elemental Skylands - Fire Generator Shop",
],
"Fire Generator Shop": [
"Elemental Skylands - Water Shmup",
],
"Water Shmup": [
"Elemental Skylands - Water Intro Shop",
],
"Water Intro Shop": [
"Elemental Skylands - Water Generator Shop",
],
"Water Generator Shop": [
"Elemental Skylands - Right",
],
"Right": [
"Glacial Peak - Left",
],
},
"Sunken Shrine": {
"Left": [
"Howling Grotto - Bottom",
"Sunken Shrine - Portal",
],
"Portal": [
"Sunken Shrine - Left",
"Sunken Shrine - Above Portal Shop",
"Sunken Shrine - Sun Path Shop",
"Sunken Shrine - Moon Path Shop",
"Tower HQ",
],
"Above Portal Shop": [
"Sunken Shrine - Portal",
"Sunken Shrine - Lifeguard Shop",
],
"Lifeguard Shop": [
"Sunken Shrine - Above Portal Shop",
"Sunken Shrine - Lightfoot Tabi Checkpoint",
],
"Sun Path Shop": [
"Sunken Shrine - Portal",
"Sunken Shrine - Tabi Gauntlet Shop",
],
"Tabi Gauntlet Shop": [
"Sunken Shrine - Sun Path Shop",
"Sunken Shrine - Sun Crest Checkpoint",
],
"Moon Path Shop": [
"Sunken Shrine - Portal",
"Sunken Shrine - Waterfall Paradise Checkpoint",
],
"Lightfoot Tabi Checkpoint": [
"Sunken Shrine - Portal",
],
"Sun Crest Checkpoint": [
"Sunken Shrine - Tabi Gauntlet Shop",
"Sunken Shrine - Portal",
],
"Waterfall Paradise Checkpoint": [
"Sunken Shrine - Moon Path Shop",
"Sunken Shrine - Moon Crest Checkpoint",
],
"Moon Crest Checkpoint": [
"Sunken Shrine - Waterfall Paradise Checkpoint",
"Sunken Shrine - Portal",
],
},
}
RANDOMIZED_CONNECTIONS: Dict[str, str] = {
"Ninja Village - Right": "Autumn Hills - Left",
"Autumn Hills - Left": "Ninja Village - Right",
"Autumn Hills - Right": "Forlorn Temple - Left",
"Autumn Hills - Bottom": "Catacombs - Bottom Left",
"Forlorn Temple - Left": "Autumn Hills - Right",
"Forlorn Temple - Right": "Bamboo Creek - Top Left",
"Forlorn Temple - Bottom": "Catacombs - Top Left",
"Catacombs - Top Left": "Forlorn Temple - Bottom",
"Catacombs - Bottom Left": "Autumn Hills - Bottom",
"Catacombs - Bottom": "Dark Cave - Right",
"Catacombs - Right": "Bamboo Creek - Bottom Left",
"Bamboo Creek - Bottom Left": "Catacombs - Right",
"Bamboo Creek - Right": "Howling Grotto - Left",
"Bamboo Creek - Top Left": "Forlorn Temple - Right",
"Howling Grotto - Left": "Bamboo Creek - Right",
"Howling Grotto - Top": "Quillshroom Marsh - Bottom Left",
"Howling Grotto - Right": "Quillshroom Marsh - Top Left",
"Howling Grotto - Bottom": "Sunken Shrine - Left",
"Quillshroom Marsh - Top Left": "Howling Grotto - Right",
"Quillshroom Marsh - Bottom Left": "Howling Grotto - Top",
"Quillshroom Marsh - Top Right": "Searing Crags - Left",
"Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom",
"Searing Crags - Left": "Quillshroom Marsh - Top Right",
"Searing Crags - Top": "Glacial Peak - Bottom",
"Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right",
"Searing Crags - Right": "Underworld - Left",
"Glacial Peak - Bottom": "Searing Crags - Top",
"Glacial Peak - Top": "Cloud Ruins - Left",
"Glacial Peak - Left": "Elemental Skylands - Air Shmup",
"Cloud Ruins - Left": "Glacial Peak - Top",
"Elemental Skylands - Right": "Glacial Peak - Left",
"Tower HQ": "Tower of Time - Left",
"Artificer": "Corrupted Future",
"Underworld - Left": "Searing Crags - Right",
"Dark Cave - Right": "Catacombs - Bottom",
"Dark Cave - Left": "Riviere Turquoise - Right",
"Sunken Shrine - Left": "Howling Grotto - Bottom",
}
TRANSITIONS: List[str] = [
"Ninja Village - Right",
"Autumn Hills - Left",
"Autumn Hills - Right",
"Autumn Hills - Bottom",
"Forlorn Temple - Left",
"Forlorn Temple - Bottom",
"Forlorn Temple - Right",
"Catacombs - Top Left",
"Catacombs - Right",
"Catacombs - Bottom",
"Catacombs - Bottom Left",
"Dark Cave - Right",
"Dark Cave - Left",
"Riviere Turquoise - Right",
"Howling Grotto - Left",
"Howling Grotto - Right",
"Howling Grotto - Top",
"Howling Grotto - Bottom",
"Sunken Shrine - Left",
"Bamboo Creek - Top Left",
"Bamboo Creek - Bottom Left",
"Bamboo Creek - Right",
"Quillshroom Marsh - Top Left",
"Quillshroom Marsh - Bottom Left",
"Quillshroom Marsh - Top Right",
"Quillshroom Marsh - Bottom Right",
"Searing Crags - Left",
"Searing Crags - Bottom",
"Searing Crags - Right",
"Searing Crags - Top",
"Glacial Peak - Bottom",
"Glacial Peak - Top",
"Glacial Peak - Left",
"Elemental Skylands - Air Shmup",
"Elemental Skylands - Right",
"Tower HQ",
"Tower of Time - Left",
"Corrupted Future",
"Cloud Ruins - Left",
"Underworld - Left",
]

View File

@@ -24,6 +24,8 @@ PROG_ITEMS = [
# "Astral Seed",
# "Astral Tea Leaves",
"Money Wrench",
"Candle",
"Seashell",
]
PHOBEKINS = [
@@ -103,6 +105,52 @@ ALWAYS_LOCATIONS = [
"Searing Crags - Pyro",
"Bamboo Creek - Claustro",
"Cloud Ruins - Acro",
# seals
"Ninja Village Seal - Tree House",
"Autumn Hills Seal - Trip Saws",
"Autumn Hills Seal - Double Swing Saws",
"Autumn Hills Seal - Spike Ball Swing",
"Autumn Hills Seal - Spike Ball Darts",
"Catacombs Seal - Triple Spike Crushers",
"Catacombs Seal - Crusher Gauntlet",
"Catacombs Seal - Dirty Pond",
"Bamboo Creek Seal - Spike Crushers and Doors",
"Bamboo Creek Seal - Spike Ball Pits",
"Bamboo Creek Seal - Spike Crushers and Doors v2",
"Howling Grotto Seal - Windy Saws and Balls",
"Howling Grotto Seal - Crushing Pits",
"Howling Grotto Seal - Breezy Crushers",
"Quillshroom Marsh Seal - Spikey Window",
"Quillshroom Marsh Seal - Sand Trap",
"Quillshroom Marsh Seal - Do the Spike Wave",
"Searing Crags Seal - Triple Ball Spinner",
"Searing Crags Seal - Raining Rocks",
"Searing Crags Seal - Rhythm Rocks",
"Glacial Peak Seal - Ice Climbers",
"Glacial Peak Seal - Projectile Spike Pit",
"Glacial Peak Seal - Glacial Air Swag",
"Tower of Time Seal - Time Waster",
"Tower of Time Seal - Lantern Climb",
"Tower of Time Seal - Arcane Orbs",
"Cloud Ruins Seal - Ghost Pit",
"Cloud Ruins Seal - Toothbrush Alley",
"Cloud Ruins Seal - Saw Pit",
"Cloud Ruins Seal - Money Farm Room",
"Underworld Seal - Sharp and Windy Climb",
"Underworld Seal - Spike Wall",
"Underworld Seal - Fireball Wave",
"Underworld Seal - Rising Fanta",
"Forlorn Temple Seal - Rocket Maze",
"Forlorn Temple Seal - Rocket Sunset",
"Sunken Shrine Seal - Ultra Lifeguard",
"Sunken Shrine Seal - Waterfall Paradise",
"Sunken Shrine Seal - Tabi Gauntlet",
"Riviere Turquoise Seal - Bounces and Balls",
"Riviere Turquoise Seal - Launch of Faith",
"Riviere Turquoise Seal - Flower Power",
"Elemental Skylands Seal - Air",
"Elemental Skylands Seal - Water",
"Elemental Skylands Seal - Fire",
]
BOSS_LOCATIONS = [

View File

@@ -69,8 +69,8 @@ for it. The groups you can use for The Messenger are:
* Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the
player. This may also cause a softlock.
* Text entry menus don't accept controller input
* Opening the shop chest in power seal hunt mode from the tower of time HQ will softlock the game.
* If you are unable to reset file slots, load into a save slot, let the game save, and close it.
* In power seal hunt mode, the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the
chest will not work.
## What do I do if I have a problem?

View File

@@ -9,10 +9,20 @@
## Installation
1. Read the [Game Info Page](/games/The%20Messenger/info/en) for how the game works, caveats and known issues
2. Download and install Courier Mod Loader using the instructions on the release page
Read changes to the base game on the [Game Info Page](/games/The%20Messenger/info/en)
### Automated Installation
1. Download and install the latest [Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
2. Launch the Archipelago Launcher (ArchipelagoLauncher.exe)
3. Click on "The Messenger"
4. Follow the prompts
### Manual Installation
1. Download and install Courier Mod Loader using the instructions on the release page
* [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases)
3. Download and install the randomizer mod
2. Download and install the randomizer mod
1. Download the latest TheMessengerRandomizerAP.zip from
[The Messenger Randomizer Mod AP releases page](https://github.com/alwaysintreble/TheMessengerRandomizerModAP/releases)
2. Extract the zip file to `TheMessenger/Mods/` of your game's install location
@@ -32,19 +42,17 @@
## Joining a MultiWorld Game
1. Launch the game
2. Navigate to `Options > Third Party Mod Options`
3. Select `Reset Randomizer File Slots`
* This will set up all of your save slots with new randomizer save files. You can have up to 3 randomizer files at a
time, but must do this step again to start new runs afterward.
4. Enter connection info using the relevant option buttons
2. Navigate to `Options > Archipelago Options`
3. Enter connection info using the relevant option buttons
* **The game is limited to alphanumerical characters, `.`, and `-`.**
* This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the
website.
* If using a name that cannot be entered in the in game menus, there is a config file (APConfig.toml) in the game
directory. When using this, all connection information must be entered in the file.
5. Select the `Connect to Archipelago` button
6. Navigate to save file selection
7. Select a new valid randomizer save
4. Select the `Connect to Archipelago` button
5. Navigate to save file selection
6. Start a new game
* If you're already connected, deleting a save will not disconnect you and is completely safe.
## Continuing a MultiWorld Game
@@ -52,6 +60,5 @@ At any point while playing, it is completely safe to quit. Returning to the titl
disconnect you from the server. To reconnect to an in progress MultiWorld, simply load the correct save file for that
MultiWorld.
If the reconnection fails, the message on screen will state you are disconnected. If this happens, you can return to the
main menu and connect to the server as in [Joining a Multiworld Game](#joining-a-multiworld-game), then load the correct
save file.
If the reconnection fails, the message on screen will state you are disconnected. If this happens, the game will attempt
to reconnect in the background. An option will also be added to the in game menu to change the port, if necessary.

View File

@@ -17,29 +17,78 @@ class Logic(Choice):
"""
The level of logic to use when determining what locations in your world are accessible.
Normal: can require damage boosts, but otherwise approachable for someone who has beaten the game.
Hard: has leashing, normal clips, time warps and turtle boosting in logic.
OoB: places everything with the minimum amount of rules possible. Expect to do OoB. Not guaranteed completable.
Normal: Can require damage boosts, but otherwise approachable for someone who has beaten the game.
Hard: Expects more knowledge and tighter execution. Has leashing, normal clips and much tighter d-boosting in logic.
"""
display_name = "Logic Level"
option_normal = 0
option_hard = 1
option_oob = 2
alias_oob = 1
alias_challenging = 1
class PowerSeals(DefaultOnToggle):
"""Whether power seal locations should be randomized."""
display_name = "Shuffle Seals"
class MegaShards(Toggle):
"""Whether mega shards should be item locations."""
display_name = "Shuffle Mega Time Shards"
class LimitedMovement(Toggle):
"""
Removes either rope dart or wingsuit from the itempool. Forces logic to at least hard and accessibility to minimal.
"""
display_name = "Limited Movement"
class EarlyMed(Toggle):
"""Guarantees meditation will be found early"""
display_name = "Early Meditation"
class AvailablePortals(Range):
"""Number of portals that are available from the start. Autumn Hills, Howling Grotto, and Glacial Peak are always available. If portal outputs are not randomized, Searing Crags will also be available."""
display_name = "Available Starting Portals"
range_start = 3
range_end = 6
default = 6
class ShufflePortals(Choice):
"""
Whether the portals lead to random places.
Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant.
Supports plando.
None: Portals will take you where they're supposed to.
Shops: Portals can lead to any area except Music Box and Elemental Skylands, with each portal output guaranteed to not overlap with another portal's. Will only put you at a portal or a shop.
Checkpoints: Like Shops except checkpoints without shops are also valid drop points.
Anywhere: Like Checkpoints except it's possible for multiple portals to output to the same map.
"""
display_name = "Shuffle Portal Outputs"
option_none = 0
alias_off = 0
option_shops = 1
option_checkpoints = 2
option_anywhere = 3
class ShuffleTransitions(Choice):
"""
Whether the transitions between the levels should be randomized.
Supports plando.
None: Level transitions lead where they should.
Coupled: Returning through a transition will take you from whence you came.
Decoupled: Any level transition can take you to any other level transition.
"""
display_name = "Shuffle Level Transitions"
option_none = 0
alias_off = 0
option_coupled = 1
option_decoupled = 2
class Goal(Choice):
"""Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled."""
"""Requirement to finish the game."""
display_name = "Goal"
option_open_music_box = 0
option_power_seal_hunt = 1
@@ -137,8 +186,12 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions):
accessibility: MessengerAccessibility
start_inventory: StartInventoryPool
logic_level: Logic
shuffle_seals: PowerSeals
shuffle_shards: MegaShards
limited_movement: LimitedMovement
early_meditation: EarlyMed
available_portals: AvailablePortals
shuffle_portals: ShufflePortals
# shuffle_transitions: ShuffleTransitions
goal: Goal
music_box: MusicBox
notes_needed: NotesNeeded

290
worlds/messenger/portals.py Normal file
View File

@@ -0,0 +1,290 @@
from typing import List, TYPE_CHECKING
from BaseClasses import CollectionState, PlandoOptions
from .options import ShufflePortals
from ..generic import PlandoConnection
if TYPE_CHECKING:
from . import MessengerWorld
PORTALS = [
"Autumn Hills",
"Riviere Turquoise",
"Howling Grotto",
"Sunken Shrine",
"Searing Crags",
"Glacial Peak",
]
REGION_ORDER = [
"Autumn Hills",
"Forlorn Temple",
"Catacombs",
"Bamboo Creek",
"Howling Grotto",
"Quillshroom Marsh",
"Searing Crags",
"Glacial Peak",
"Tower of Time",
"Cloud Ruins",
"Underworld",
"Riviere Turquoise",
"Elemental Skylands",
"Sunken Shrine",
]
SHOP_POINTS = {
"Autumn Hills": [
"Climbing Claws",
"Hope Path",
"Dimension Climb",
"Leaf Golem",
],
"Forlorn Temple": [
"Outside",
"Entrance",
"Climb",
"Rocket Sunset",
"Descent",
"Saw Gauntlet",
"Demon King",
],
"Catacombs": [
"Triple Spike Crushers",
"Ruxxtin",
],
"Bamboo Creek": [
"Spike Crushers",
"Abandoned",
"Time Loop",
],
"Howling Grotto": [
"Wingsuit",
"Crushing Pits",
"Emerald Golem",
],
"Quillshroom Marsh": [
"Spikey Window",
"Sand Trap",
"Queen of Quills",
],
"Searing Crags": [
"Rope Dart",
"Falling Rocks",
"Searing Mega Shard",
"Before Final Climb",
"Colossuses",
"Key of Strength",
],
"Glacial Peak": [
"Ice Climbers'",
"Glacial Mega Shard",
"Tower Entrance",
],
"Tower of Time": [
"Final Chance",
"Arcane Golem",
],
"Cloud Ruins": [
"Cloud Entrance",
"Pillar Glide",
"Crushers' Descent",
"Seeing Spikes",
"Final Flight",
"Manfred's",
],
"Underworld": [
"Left",
"Fireball Wave",
"Long Climb",
# "Barm'athaziel", # not currently valid
"Key of Chaos",
],
"Riviere Turquoise": [
"Waterfall",
"Launch of Faith",
"Log Flume",
"Log Climb",
"Restock",
"Butterfly Matriarch",
],
"Elemental Skylands": [
"Air Intro",
"Air Generator",
"Earth Intro",
"Earth Generator",
"Fire Intro",
"Fire Generator",
"Water Intro",
"Water Generator",
],
"Sunken Shrine": [
"Above Portal",
"Lifeguard",
"Sun Path",
"Tabi Gauntlet",
"Moon Path",
]
}
CHECKPOINTS = {
"Autumn Hills": [
"Hope Latch",
"Key of Hope",
"Lakeside",
"Double Swing",
"Spike Ball Swing",
],
"Forlorn Temple": [
"Sunny Day",
"Rocket Maze",
],
"Catacombs": [
"Death Trap",
"Crusher Gauntlet",
"Dirty Pond",
],
"Bamboo Creek": [
"Spike Ball Pits",
"Spike Doors",
],
"Howling Grotto": [
"Lost Woods",
"Breezy Crushers",
],
"Quillshroom Marsh": [
"Seashell",
"Quicksand",
"Spike Wave",
],
"Searing Crags": [
"Triple Ball Spinner",
"Raining Rocks",
],
"Glacial Peak": [
"Projectile Spike Pit",
"Air Swag",
"Free Climbing",
],
"Tower of Time": [
"First",
"Second",
"Third",
"Fourth",
"Fifth",
"Sixth",
],
"Cloud Ruins": [
"Spike Float",
"Ghost Pit",
"Toothbrush Alley",
"Saw Pit",
],
"Underworld": [
"Hot Dip",
"Hot Tub",
"Lava Run",
],
"Riviere Turquoise": [
"Flower Flight",
],
"Elemental Skylands": [
"Air Seal",
],
"Sunken Shrine": [
"Lightfoot Tabi",
"Sun Crest",
"Waterfall Paradise",
"Moon Crest",
]
}
def shuffle_portals(world: "MessengerWorld") -> None:
def create_mapping(in_portal: str, warp: str) -> None:
nonlocal available_portals
parent = out_to_parent[warp]
exit_string = f"{parent.strip(' ')} - "
if "Portal" in warp:
exit_string += "Portal"
world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}00"))
elif warp_point in SHOP_POINTS[parent]:
exit_string += f"{warp_point} Shop"
world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}1{SHOP_POINTS[parent].index(warp_point)}"))
else:
exit_string += f"{warp_point} Checkpoint"
world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}2{CHECKPOINTS[parent].index(warp_point)}"))
world.spoiler_portal_mapping[in_portal] = exit_string
connect_portal(world, in_portal, exit_string)
available_portals.remove(warp)
if shuffle_type < ShufflePortals.option_anywhere:
available_portals = [port for port in available_portals if port not in shop_points[parent]]
def handle_planned_portals(plando_connections: List[PlandoConnection]) -> None:
for connection in plando_connections:
if connection.entrance not in PORTALS:
continue
# let it crash here if input is invalid
create_mapping(connection.entrance, connection.exit)
world.plando_portals.append(connection.entrance)
shuffle_type = world.options.shuffle_portals
shop_points = SHOP_POINTS.copy()
for portal in PORTALS:
shop_points[portal].append(f"{portal} Portal")
if shuffle_type > ShufflePortals.option_shops:
shop_points.update(CHECKPOINTS)
out_to_parent = {checkpoint: parent for parent, checkpoints in shop_points.items() for checkpoint in checkpoints}
available_portals = [val for zone in shop_points.values() for val in zone]
plando = world.multiworld.plando_connections[world.player]
if plando and world.multiworld.plando_options & PlandoOptions.connections:
handle_planned_portals(plando)
world.multiworld.plando_connections[world.player] = [connection for connection in plando
if connection.entrance not in PORTALS]
for portal in PORTALS:
warp_point = world.random.choice(available_portals)
create_mapping(portal, warp_point)
def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> None:
entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player)
entrance.connect(world.multiworld.get_region(out_region, world.player))
def disconnect_portals(world: "MessengerWorld") -> None:
for portal in [port for port in PORTALS if port not in world.plando_portals]:
entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player)
entrance.connected_region.entrances.remove(entrance)
entrance.connected_region = None
if portal in world.spoiler_portal_mapping:
del world.spoiler_portal_mapping[portal]
if len(world.portal_mapping) > len(world.spoiler_portal_mapping):
world.portal_mapping = world.portal_mapping[:len(world.spoiler_portal_mapping)]
def validate_portals(world: "MessengerWorld") -> bool:
# if world.options.shuffle_transitions:
# return True
new_state = CollectionState(world.multiworld)
new_state.update_reachable_regions(world.player)
reachable_locs = 0
for loc in world.multiworld.get_locations(world.player):
reachable_locs += loc.can_reach(new_state)
if reachable_locs > 5:
return True
return False
def add_closed_portal_reqs(world: "MessengerWorld") -> None:
closed_portals = [entrance for entrance in PORTALS if f"{entrance} Portal" not in world.starting_portals]
for portal in closed_portals:
tower_exit = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player)
tower_exit.access_rule = lambda state: state.has(portal, world.player)

View File

@@ -1,103 +1,446 @@
from typing import Dict, List, Set
from typing import Dict, List
REGIONS: Dict[str, List[str]] = {
"Menu": [],
"Tower HQ": [],
"The Shop": [],
"The Craftsman's Corner": [],
"Tower of Time": [],
"Ninja Village": ["Ninja Village - Candle", "Ninja Village - Astral Seed"],
"Autumn Hills": ["Autumn Hills - Climbing Claws", "Autumn Hills - Key of Hope", "Autumn Hills - Leaf Golem"],
"Forlorn Temple": ["Forlorn Temple - Demon King"],
"Catacombs": ["Catacombs - Necro", "Catacombs - Ruxxtin's Amulet", "Catacombs - Ruxxtin"],
"Bamboo Creek": ["Bamboo Creek - Claustro"],
"Howling Grotto": ["Howling Grotto - Wingsuit", "Howling Grotto - Emerald Golem"],
"Quillshroom Marsh": ["Quillshroom Marsh - Seashell", "Quillshroom Marsh - Queen of Quills"],
"Searing Crags": ["Searing Crags - Rope Dart"],
"Searing Crags Upper": ["Searing Crags - Power Thistle", "Searing Crags - Key of Strength",
"Searing Crags - Astral Tea Leaves"],
"Glacial Peak": [],
"Cloud Ruins": [],
"Cloud Ruins Right": ["Cloud Ruins - Acro"],
"Underworld": ["Searing Crags - Pyro", "Underworld - Key of Chaos"],
"Dark Cave": [],
"Riviere Turquoise Entrance": [],
"Riviere Turquoise": ["Riviere Turquoise - Butterfly Matriarch"],
"Sunken Shrine": ["Sunken Shrine - Lightfoot Tabi", "Sunken Shrine - Sun Crest", "Sunken Shrine - Moon Crest",
"Sunken Shrine - Key of Love"],
"Elemental Skylands": ["Elemental Skylands - Key of Symbiosis"],
LOCATIONS: Dict[str, List[str]] = {
"Ninja Village - Nest": [
"Ninja Village - Candle",
"Ninja Village - Astral Seed",
"Ninja Village Seal - Tree House",
],
"Autumn Hills - Climbing Claws Shop": [
"Autumn Hills - Climbing Claws",
"Autumn Hills Seal - Trip Saws",
],
"Autumn Hills - Key of Hope Checkpoint": [
"Autumn Hills - Key of Hope",
],
"Autumn Hills - Double Swing Checkpoint": [
"Autumn Hills Seal - Double Swing Saws",
],
"Autumn Hills - Spike Ball Swing Checkpoint": [
"Autumn Hills Seal - Spike Ball Swing",
"Autumn Hills Seal - Spike Ball Darts",
],
"Autumn Hills - Leaf Golem Shop": [
"Autumn Hills - Leaf Golem",
],
"Forlorn Temple - Rocket Maze Checkpoint": [
"Forlorn Temple Seal - Rocket Maze",
],
"Forlorn Temple - Rocket Sunset Shop": [
"Forlorn Temple Seal - Rocket Sunset",
],
"Forlorn Temple - Demon King Shop": [
"Forlorn Temple - Demon King",
],
"Catacombs - Top Left": [
"Catacombs - Necro",
],
"Catacombs - Triple Spike Crushers Shop": [
"Catacombs Seal - Triple Spike Crushers",
],
"Catacombs - Dirty Pond Checkpoint": [
"Catacombs Seal - Crusher Gauntlet",
"Catacombs Seal - Dirty Pond",
],
"Catacombs - Ruxxtin Shop": [
"Catacombs - Ruxxtin's Amulet",
"Catacombs - Ruxxtin",
],
"Bamboo Creek - Spike Crushers Shop": [
"Bamboo Creek Seal - Spike Crushers and Doors",
],
"Bamboo Creek - Spike Ball Pits Checkpoint": [
"Bamboo Creek Seal - Spike Ball Pits",
],
"Bamboo Creek - Time Loop Shop": [
"Bamboo Creek Seal - Spike Crushers and Doors v2",
"Bamboo Creek - Claustro",
],
"Howling Grotto - Wingsuit Shop": [
"Howling Grotto - Wingsuit",
"Howling Grotto Seal - Windy Saws and Balls",
],
"Howling Grotto - Crushing Pits Shop": [
"Howling Grotto Seal - Crushing Pits",
],
"Howling Grotto - Breezy Crushers Checkpoint": [
"Howling Grotto Seal - Breezy Crushers",
],
"Howling Grotto - Emerald Golem Shop": [
"Howling Grotto - Emerald Golem",
],
"Quillshroom Marsh - Seashell Checkpoint": [
"Quillshroom Marsh - Seashell",
],
"Quillshroom Marsh - Spikey Window Shop": [
"Quillshroom Marsh Seal - Spikey Window",
],
"Quillshroom Marsh - Sand Trap Shop": [
"Quillshroom Marsh Seal - Sand Trap",
],
"Quillshroom Marsh - Spike Wave Checkpoint": [
"Quillshroom Marsh Seal - Do the Spike Wave",
],
"Quillshroom Marsh - Queen of Quills Shop": [
"Quillshroom Marsh - Queen of Quills",
],
"Searing Crags - Rope Dart Shop": [
"Searing Crags - Rope Dart",
],
"Searing Crags - Triple Ball Spinner Checkpoint": [
"Searing Crags Seal - Triple Ball Spinner",
],
"Searing Crags - Raining Rocks Checkpoint": [
"Searing Crags Seal - Raining Rocks",
],
"Searing Crags - Colossuses Shop": [
"Searing Crags Seal - Rhythm Rocks",
"Searing Crags - Power Thistle",
"Searing Crags - Astral Tea Leaves",
],
"Searing Crags - Key of Strength Shop": [
"Searing Crags - Key of Strength",
],
"Searing Crags - Portal": [
"Searing Crags - Pyro",
],
"Glacial Peak - Ice Climbers' Shop": [
"Glacial Peak Seal - Ice Climbers",
],
"Glacial Peak - Projectile Spike Pit Checkpoint": [
"Glacial Peak Seal - Projectile Spike Pit",
],
"Glacial Peak - Air Swag Checkpoint": [
"Glacial Peak Seal - Glacial Air Swag",
],
"Tower of Time - First Checkpoint": [
"Tower of Time Seal - Time Waster",
],
"Tower of Time - Fourth Checkpoint": [
"Tower of Time Seal - Lantern Climb",
],
"Tower of Time - Fifth Checkpoint": [
"Tower of Time Seal - Arcane Orbs",
],
"Cloud Ruins - Ghost Pit Checkpoint": [
"Cloud Ruins Seal - Ghost Pit",
],
"Cloud Ruins - Toothbrush Alley Checkpoint": [
"Cloud Ruins Seal - Toothbrush Alley",
],
"Cloud Ruins - Saw Pit Checkpoint": [
"Cloud Ruins Seal - Saw Pit",
],
"Cloud Ruins - Final Flight Shop": [
"Cloud Ruins - Acro",
],
"Cloud Ruins - Manfred's Shop": [
"Cloud Ruins Seal - Money Farm Room",
],
"Underworld - Left Shop": [
"Underworld Seal - Sharp and Windy Climb",
],
"Underworld - Fireball Wave Shop": [
"Underworld Seal - Spike Wall",
"Underworld Seal - Fireball Wave",
],
"Underworld - Hot Tub Checkpoint": [
"Underworld Seal - Rising Fanta",
],
"Underworld - Key of Chaos Shop": [
"Underworld - Key of Chaos",
],
"Riviere Turquoise - Waterfall Shop": [
"Riviere Turquoise Seal - Bounces and Balls",
],
"Riviere Turquoise - Launch of Faith Shop": [
"Riviere Turquoise Seal - Launch of Faith",
],
"Riviere Turquoise - Restock Shop": [
"Riviere Turquoise Seal - Flower Power",
],
"Riviere Turquoise - Butterfly Matriarch Shop": [
"Riviere Turquoise - Butterfly Matriarch",
],
"Sunken Shrine - Lifeguard Shop": [
"Sunken Shrine Seal - Ultra Lifeguard",
],
"Sunken Shrine - Lightfoot Tabi Checkpoint": [
"Sunken Shrine - Lightfoot Tabi",
],
"Sunken Shrine - Portal": [
"Sunken Shrine - Key of Love",
],
"Sunken Shrine - Tabi Gauntlet Shop": [
"Sunken Shrine Seal - Tabi Gauntlet",
],
"Sunken Shrine - Sun Crest Checkpoint": [
"Sunken Shrine - Sun Crest",
],
"Sunken Shrine - Waterfall Paradise Checkpoint": [
"Sunken Shrine Seal - Waterfall Paradise",
],
"Sunken Shrine - Moon Crest Checkpoint": [
"Sunken Shrine - Moon Crest",
],
"Elemental Skylands - Air Seal Checkpoint": [
"Elemental Skylands Seal - Air",
],
"Elemental Skylands - Water Intro Shop": [
"Elemental Skylands Seal - Water",
],
"Elemental Skylands - Fire Intro Shop": [
"Elemental Skylands Seal - Fire",
],
"Elemental Skylands - Right": [
"Elemental Skylands - Key of Symbiosis",
],
"Corrupted Future": ["Corrupted Future - Key of Courage"],
"Music Box": ["Rescue Phantom"],
}
SEALS: Dict[str, List[str]] = {
"Ninja Village": ["Ninja Village Seal - Tree House"],
"Autumn Hills": ["Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws",
"Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts"],
"Catacombs": ["Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet",
"Catacombs Seal - Dirty Pond"],
"Bamboo Creek": ["Bamboo Creek Seal - Spike Crushers and Doors", "Bamboo Creek Seal - Spike Ball Pits",
"Bamboo Creek Seal - Spike Crushers and Doors v2"],
"Howling Grotto": ["Howling Grotto Seal - Windy Saws and Balls", "Howling Grotto Seal - Crushing Pits",
"Howling Grotto Seal - Breezy Crushers"],
"Quillshroom Marsh": ["Quillshroom Marsh Seal - Spikey Window", "Quillshroom Marsh Seal - Sand Trap",
"Quillshroom Marsh Seal - Do the Spike Wave"],
"Searing Crags": ["Searing Crags Seal - Triple Ball Spinner"],
"Searing Crags Upper": ["Searing Crags Seal - Raining Rocks", "Searing Crags Seal - Rhythm Rocks"],
"Glacial Peak": ["Glacial Peak Seal - Ice Climbers", "Glacial Peak Seal - Projectile Spike Pit",
"Glacial Peak Seal - Glacial Air Swag"],
"Tower of Time": ["Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb",
"Tower of Time Seal - Arcane Orbs"],
"Cloud Ruins Right": ["Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley",
"Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"],
"Underworld": ["Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall",
"Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta"],
"Forlorn Temple": ["Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset"],
"Sunken Shrine": ["Sunken Shrine Seal - Ultra Lifeguard", "Sunken Shrine Seal - Waterfall Paradise",
"Sunken Shrine Seal - Tabi Gauntlet"],
"Riviere Turquoise Entrance": ["Riviere Turquoise Seal - Bounces and Balls"],
"Riviere Turquoise": ["Riviere Turquoise Seal - Launch of Faith", "Riviere Turquoise Seal - Flower Power"],
"Elemental Skylands": ["Elemental Skylands Seal - Air", "Elemental Skylands Seal - Water",
"Elemental Skylands Seal - Fire"]
SUB_REGIONS: Dict[str, List[str]] = {
"Ninja Village": [
"Right",
],
"Autumn Hills": [
"Left",
"Right",
"Bottom",
"Portal",
"Climbing Claws Shop",
"Hope Path Shop",
"Dimension Climb Shop",
"Leaf Golem Shop",
"Hope Path Checkpoint",
"Key of Hope Checkpoint",
"Lakeside Checkpoint",
"Double Swing Checkpoint",
"Spike Ball Swing Checkpoint",
],
"Forlorn Temple": [
"Left",
"Right",
"Bottom",
"Outside Shop",
"Entrance Shop",
"Climb Shop",
"Rocket Sunset Shop",
"Descent Shop",
"Final Fall Shop",
"Demon King Shop",
"Sunny Day Checkpoint",
"Rocket Maze Checkpoint",
],
"Catacombs": [
"Top Left",
"Bottom Left",
"Bottom",
"Right",
"Triple Spike Crushers Shop",
"Ruxxtin Shop",
"Death Trap Checkpoint",
"Crusher Gauntlet Checkpoint",
"Dirty Pond Checkpoint",
],
"Bamboo Creek": [
"Bottom Left",
"Top Left",
"Right",
"Spike Crushers Shop",
"Abandoned Shop",
"Time Loop Shop",
"Spike Ball Pits Checkpoint",
"Spike Doors Checkpoint",
],
"Howling Grotto": [
"Left",
"Top",
"Right",
"Bottom",
"Portal",
"Wingsuit Shop",
"Crushing Pits Shop",
"Emerald Golem Shop",
"Lost Woods Checkpoint",
"Breezy Crushers Checkpoint",
],
"Quillshroom Marsh": [
"Top Left",
"Bottom Left",
"Top Right",
"Bottom Right",
"Spikey Window Shop",
"Sand Trap Shop",
"Queen of Quills Shop",
"Seashell Checkpoint",
"Quicksand Checkpoint",
"Spike Wave Checkpoint",
],
"Searing Crags": [
"Left",
"Top",
"Bottom",
"Right",
"Portal",
"Rope Dart Shop",
"Falling Rocks Shop",
"Searing Mega Shard Shop",
"Before Final Climb Shop",
"Colossuses Shop",
"Key of Strength Shop",
"Triple Ball Spinner Checkpoint",
"Raining Rocks Checkpoint",
],
"Glacial Peak": [
"Bottom",
"Top",
"Portal",
"Ice Climbers' Shop",
"Glacial Mega Shard Shop",
"Tower Entrance Shop",
"Projectile Spike Pit Checkpoint",
"Air Swag Checkpoint",
"Free Climbing Checkpoint",
],
"Tower of Time": [
"Left",
"Entrance Shop",
"Arcane Golem Shop",
"First Checkpoint",
"Second Checkpoint",
"Third Checkpoint",
"Fourth Checkpoint",
"Fifth Checkpoint",
"Sixth Checkpoint",
],
"Cloud Ruins": [
"Left",
"Entrance Shop",
"Pillar Glide Shop",
"Crushers' Descent Shop",
"Seeing Spikes Shop",
"Sliding Spikes Shop",
"Final Flight Shop",
"Manfred's Shop",
"Spike Float Checkpoint",
"Ghost Pit Checkpoint",
"Toothbrush Alley Checkpoint",
"Saw Pit Checkpoint",
],
"Underworld": [
"Left",
"Entrance Shop",
"Fireball Wave Shop",
"Long Climb Shop",
"Barm'athaziel Shop",
"Key of Chaos Shop",
"Hot Dip Checkpoint",
"Hot Tub Checkpoint",
"Lava Run Checkpoint",
],
"Riviere Turquoise": [
"Right",
"Portal",
"Waterfall Shop",
"Launch of Faith Shop",
"Log Flume Shop",
"Log Climb Shop",
"Restock Shop",
"Butterfly Matriarch Shop",
"Flower Flight Checkpoint",
],
"Elemental Skylands": [
"Air Shmup",
"Air Intro Shop",
"Air Seal Checkpoint",
"Air Generator Shop",
"Earth Shmup",
"Earth Intro Shop",
"Earth Generator Shop",
"Fire Shmup",
"Fire Intro Shop",
"Fire Generator Shop",
"Water Shmup",
"Water Intro Shop",
"Water Generator Shop",
"Right",
],
"Sunken Shrine": [
"Left",
"Portal",
"Entrance Shop",
"Lifeguard Shop",
"Sun Path Shop",
"Tabi Gauntlet Shop",
"Moon Path Shop",
"Ninja Tabi Checkpoint",
"Sun Crest Checkpoint",
"Waterfall Paradise Checkpoint",
"Moon Crest Checkpoint",
],
}
# order is slightly funky here for back compat
MEGA_SHARDS: Dict[str, List[str]] = {
"Autumn Hills": ["Autumn Hills Mega Shard", "Hidden Entrance Mega Shard"],
"Catacombs": ["Catacombs Mega Shard"],
"Bamboo Creek": ["Above Entrance Mega Shard", "Abandoned Mega Shard", "Time Loop Mega Shard"],
"Howling Grotto": ["Bottom Left Mega Shard", "Near Portal Mega Shard", "Pie in the Sky Mega Shard"],
"Quillshroom Marsh": ["Quillshroom Marsh Mega Shard"],
"Searing Crags Upper": ["Searing Crags Mega Shard"],
"Glacial Peak": ["Glacial Peak Mega Shard"],
"Cloud Ruins": ["Cloud Entrance Mega Shard", "Time Warp Mega Shard"],
"Cloud Ruins Right": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"],
"Underworld": ["Under Entrance Mega Shard", "Hot Tub Mega Shard", "Projectile Pit Mega Shard"],
"Forlorn Temple": ["Sunny Day Mega Shard", "Down Under Mega Shard"],
"Sunken Shrine": ["Mega Shard of the Moon", "Beginner's Mega Shard", "Mega Shard of the Stars", "Mega Shard of the Sun"],
"Riviere Turquoise Entrance": ["Waterfall Mega Shard"],
"Riviere Turquoise": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"],
"Elemental Skylands": ["Earth Mega Shard", "Water Mega Shard"],
"Autumn Hills - Lakeside Checkpoint": ["Autumn Hills Mega Shard"],
"Forlorn Temple - Outside Shop": ["Hidden Entrance Mega Shard"],
"Catacombs - Top Left": ["Catacombs Mega Shard"],
"Bamboo Creek - Spike Crushers Shop": ["Above Entrance Mega Shard"],
"Bamboo Creek - Abandoned Shop": ["Abandoned Mega Shard"],
"Bamboo Creek - Time Loop Shop": ["Time Loop Mega Shard"],
"Howling Grotto - Lost Woods Checkpoint": ["Bottom Left Mega Shard"],
"Howling Grotto - Breezy Crushers Checkpoint": ["Near Portal Mega Shard", "Pie in the Sky Mega Shard"],
"Quillshroom Marsh - Spikey Window Shop": ["Quillshroom Marsh Mega Shard"],
"Searing Crags - Searing Mega Shard Shop": ["Searing Crags Mega Shard"],
"Glacial Peak - Glacial Mega Shard Shop": ["Glacial Peak Mega Shard"],
"Cloud Ruins - Cloud Entrance Shop": ["Cloud Entrance Mega Shard", "Time Warp Mega Shard"],
"Cloud Ruins - Manfred's Shop": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"],
"Underworld - Left Shop": ["Under Entrance Mega Shard"],
"Underworld - Hot Tub Checkpoint": ["Hot Tub Mega Shard", "Projectile Pit Mega Shard"],
"Forlorn Temple - Sunny Day Checkpoint": ["Sunny Day Mega Shard"],
"Forlorn Temple - Demon King Shop": ["Down Under Mega Shard"],
"Sunken Shrine - Waterfall Paradise Checkpoint": ["Mega Shard of the Moon"],
"Sunken Shrine - Portal": ["Beginner's Mega Shard"],
"Sunken Shrine - Above Portal Shop": ["Mega Shard of the Stars"],
"Sunken Shrine - Sun Crest Checkpoint": ["Mega Shard of the Sun"],
"Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"],
"Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"],
"Elemental Skylands - Earth Intro Shop": ["Earth Mega Shard"],
"Elemental Skylands - Water Generator Shop": ["Water Mega Shard"],
}
REGION_CONNECTIONS: Dict[str, Set[str]] = {
"Menu": {"Tower HQ"},
"Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time",
"Riviere Turquoise Entrance", "Sunken Shrine", "Corrupted Future", "The Shop",
"The Craftsman's Corner", "Music Box"},
"Autumn Hills": {"Ninja Village", "Forlorn Temple", "Catacombs"},
"Forlorn Temple": {"Catacombs", "Bamboo Creek"},
"Catacombs": {"Autumn Hills", "Bamboo Creek", "Dark Cave"},
"Bamboo Creek": {"Catacombs", "Howling Grotto"},
"Howling Grotto": {"Bamboo Creek", "Quillshroom Marsh", "Sunken Shrine"},
"Quillshroom Marsh": {"Howling Grotto", "Searing Crags"},
"Searing Crags": {"Searing Crags Upper", "Quillshroom Marsh", "Underworld"},
"Searing Crags Upper": {"Searing Crags", "Glacial Peak"},
"Glacial Peak": {"Searing Crags Upper", "Tower HQ", "Cloud Ruins", "Elemental Skylands"},
"Cloud Ruins": {"Cloud Ruins Right"},
"Cloud Ruins Right": {"Underworld"},
"Dark Cave": {"Catacombs", "Riviere Turquoise Entrance"},
"Riviere Turquoise Entrance": {"Riviere Turquoise"},
"Sunken Shrine": {"Howling Grotto"},
REGION_CONNECTIONS: Dict[str, Dict[str, str]] = {
"Menu": {"Tower HQ": "Start Game"},
"Tower HQ": {
"Autumn Hills - Portal": "ToTHQ Autumn Hills Portal",
"Howling Grotto - Portal": "ToTHQ Howling Grotto Portal",
"Searing Crags - Portal": "ToTHQ Searing Crags Portal",
"Glacial Peak - Portal": "ToTHQ Glacial Peak Portal",
"Tower of Time - Left": "Artificer's Challenge",
"Riviere Turquoise - Portal": "ToTHQ Riviere Turquoise Portal",
"Sunken Shrine - Portal": "ToTHQ Sunken Shrine Portal",
"Corrupted Future": "Artificer's Portal",
"The Shop": "Home",
"Music Box": "Shrink Down",
},
"The Shop": {
"The Craftsman's Corner": "Money Sink",
},
}
"""Vanilla layout mapping with all Tower HQ portals open. from -> to"""
"""Vanilla layout mapping with all Tower HQ portals open. format is source[exit_region][entrance_name]"""
# regions that don't have sub-regions
LEVELS: List[str] = [
"Menu",
"Tower HQ",
"The Shop",
"The Craftsman's Corner",
"Corrupted Future",
"Music Box",
]

View File

@@ -1,7 +1,7 @@
from typing import Dict, TYPE_CHECKING
from BaseClasses import CollectionState
from worlds.generic.Rules import add_rule, allow_self_locking_items, CollectionRule
from worlds.generic.Rules import CollectionRule, add_rule, allow_self_locking_items
from .constants import NOTES, PHOBEKINS
from .options import MessengerAccessibility
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
class MessengerRules:
player: int
world: "MessengerWorld"
connection_rules: Dict[str, CollectionRule]
region_rules: Dict[str, CollectionRule]
location_rules: Dict[str, CollectionRule]
maximum_price: int
@@ -27,83 +28,286 @@ class MessengerRules:
self.maximum_price = min(maximum_price, world.total_shards)
self.required_seals = max(1, world.required_seals)
self.region_rules = {
"Ninja Village": self.has_wingsuit,
"Autumn Hills": self.has_wingsuit,
"Catacombs": self.has_wingsuit,
"Bamboo Creek": self.has_wingsuit,
"Searing Crags Upper": self.has_vertical,
"Cloud Ruins": lambda state: self.has_vertical(state) and state.has("Ruxxtin's Amulet", self.player),
"Cloud Ruins Right": lambda state: self.has_wingsuit(state) and
(self.has_dart(state) or self.can_dboost(state)),
"Underworld": self.has_tabi,
"Riviere Turquoise": lambda state: self.has_dart(state) or
(self.has_wingsuit(state) and self.can_destroy_projectiles(state)),
"Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state),
"Glacial Peak": self.has_vertical,
"Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state),
"Music Box": lambda state: (state.has_all(NOTES, self.player)
or self.has_enough_seals(state)) and self.has_dart(state),
"The Craftsman's Corner": lambda state: state.has("Money Wrench", self.player) and self.can_shop(state),
# dict of connection names and requirements to traverse the exit
self.connection_rules = {
# from ToTHQ
"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),
# the shop
"Money Sink":
lambda state: state.has("Money Wrench", self.player) and self.can_shop(state),
# Autumn Hills
"Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop":
lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Autumn Hills - Dimension Climb Shop -> Autumn Hills - Portal":
self.has_vertical,
"Autumn Hills - Climbing Claws Shop -> Autumn Hills - Hope Path Shop":
self.has_dart,
"Autumn Hills - Climbing Claws Shop -> Autumn Hills - Key of Hope Checkpoint":
self.false, # hard logic only
"Autumn Hills - Hope Path Shop -> Autumn Hills - Hope Latch Checkpoint":
self.has_dart,
"Autumn Hills - Hope Path Shop -> Autumn Hills - Climbing Claws Shop":
lambda state: self.has_dart(state) and self.can_dboost(state),
"Autumn Hills - Hope Path Shop -> Autumn Hills - Lakeside Checkpoint":
lambda state: self.has_dart(state) and self.can_dboost(state),
"Autumn Hills - Hope Latch Checkpoint -> Autumn Hills - Hope Path Shop":
self.can_dboost,
"Autumn Hills - Hope Latch Checkpoint -> Autumn Hills - Key of Hope Checkpoint":
lambda state: self.has_dart(state) and self.has_wingsuit(state),
# Forlorn Temple
"Forlorn Temple - Outside Shop -> Forlorn Temple - Entrance Shop":
lambda state: state.has_all(PHOBEKINS, self.player),
"Forlorn Temple - Entrance Shop -> Forlorn Temple - Outside Shop":
lambda state: state.has_all(PHOBEKINS, self.player),
"Forlorn Temple - Entrance Shop -> Forlorn Temple - Sunny Day Checkpoint":
lambda state: self.has_vertical(state) and self.can_dboost(state),
"Forlorn Temple - Sunny Day Checkpoint -> Forlorn Temple - Rocket Maze Checkpoint":
self.has_vertical,
"Forlorn Temple - Rocket Sunset Shop -> Forlorn Temple - Descent Shop":
lambda state: self.has_dart(state) and (self.can_dboost(state) or self.has_wingsuit(state)),
"Forlorn Temple - Saw Gauntlet Shop -> Forlorn Temple - Demon King Shop":
self.has_vertical,
"Forlorn Temple - Demon King Shop -> Forlorn Temple - Saw Gauntlet Shop":
self.has_vertical,
# Howling Grotto
"Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop":
self.has_wingsuit,
"Howling Grotto - Wingsuit Shop -> Howling Grotto - Left":
self.has_wingsuit,
"Howling Grotto - Wingsuit Shop -> Howling Grotto - Lost Woods Checkpoint":
self.has_wingsuit,
"Howling Grotto - Lost Woods Checkpoint -> Howling Grotto - Bottom":
lambda state: state.has("Seashell", self.player),
"Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal":
lambda state: self.has_wingsuit(state) or self.can_dboost(state),
"Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop":
self.has_wingsuit,
"Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Crushing Pits Shop":
lambda state: (self.has_wingsuit(state) or self.can_dboost(
state
) or self.can_destroy_projectiles(state))
and state.multiworld.get_region(
"Howling Grotto - Emerald Golem Shop", self.player
).can_reach(state),
"Howling Grotto - Emerald Golem Shop -> Howling Grotto - Right":
self.has_wingsuit,
# Searing Crags
"Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint":
self.has_vertical,
"Searing Crags - Portal -> Searing Crags - Right":
self.has_tabi,
"Searing Crags - Portal -> Searing Crags - Before Final Climb Shop":
self.has_wingsuit,
"Searing Crags - Portal -> Searing Crags - Colossuses Shop":
self.has_wingsuit,
"Searing Crags - Bottom -> Searing Crags - Portal":
self.has_wingsuit,
"Searing Crags - Right -> Searing Crags - Portal":
lambda state: self.has_tabi(state) and self.has_wingsuit(state),
"Searing Crags - Colossuses Shop -> Searing Crags - Key of Strength Shop":
lambda state: state.has("Power Thistle", self.player)
and (self.has_dart(state)
or (self.has_wingsuit(state)
and self.can_destroy_projectiles(state))),
"Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop":
self.has_dart,
"Searing Crags - Searing Mega Shard Shop -> Searing Crags - Before Final Climb Shop":
lambda state: self.has_dart(state) or self.can_destroy_projectiles(state),
"Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop":
self.has_dart,
"Searing Crags - Searing Mega Shard Shop -> Searing Crags - Key of Strength Shop":
self.false,
"Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop":
self.has_dart,
# Glacial Peak
"Glacial Peak - Portal -> Glacial Peak - Tower Entrance Shop":
self.has_vertical,
"Glacial Peak - Left -> Elemental Skylands - Air Shmup":
lambda state: state.has("Magic Firefly", self.player)
and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player)
.can_reach(state),
"Glacial Peak - Tower Entrance Shop -> Glacial Peak - Top":
lambda state: state.has("Ruxxtin's Amulet", self.player),
"Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left":
lambda state: self.has_dart(state) or (self.can_dboost(state) and self.has_wingsuit(state)),
# Tower of Time
"Tower of Time - Left -> Tower of Time - Final Chance Shop":
self.has_dart,
"Tower of Time - Second Checkpoint -> Tower of Time - Third Checkpoint":
lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)),
"Tower of Time - Third Checkpoint -> Tower of Time - Fourth Checkpoint":
lambda state: self.has_wingsuit(state) or self.can_dboost(state),
"Tower of Time - Fourth Checkpoint -> Tower of Time - Fifth Checkpoint":
lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Tower of Time - Fifth Checkpoint -> Tower of Time - Sixth Checkpoint":
self.has_wingsuit,
# Cloud Ruins
"Cloud Ruins - Cloud Entrance Shop -> Cloud Ruins - Spike Float Checkpoint":
self.has_wingsuit,
"Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Cloud Entrance Shop":
lambda state: self.has_vertical(state) or self.can_dboost(state),
"Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Pillar Glide Shop":
lambda state: self.has_vertical(state) or self.can_dboost(state),
"Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Spike Float Checkpoint":
lambda state: self.has_vertical(state) and self.can_double_dboost(state),
"Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Ghost Pit Checkpoint":
lambda state: self.has_dart(state) and self.has_wingsuit(state),
"Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Crushers' Descent Shop":
lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)),
"Cloud Ruins - Toothbrush Alley Checkpoint -> Cloud Ruins - Seeing Spikes Shop":
self.has_vertical,
"Cloud Ruins - Seeing Spikes Shop -> Cloud Ruins - Sliding Spikes Shop":
self.has_wingsuit,
"Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Seeing Spikes Shop":
self.has_wingsuit,
"Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint":
self.has_vertical,
"Cloud Ruins - Final Flight Shop -> Cloud Ruins - Manfred's Shop":
lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Cloud Ruins - Manfred's Shop -> Cloud Ruins - Final Flight Shop":
lambda state: self.has_wingsuit(state) and self.can_dboost(state),
# Underworld
"Underworld - Left -> Underworld - Left Shop":
self.has_tabi,
"Underworld - Left Shop -> Underworld - Left":
self.has_tabi,
"Underworld - Hot Dip Checkpoint -> Underworld - Lava Run Checkpoint":
self.has_tabi,
"Underworld - Fireball Wave Shop -> Underworld - Long Climb Shop":
lambda state: self.can_destroy_projectiles(state) or self.has_tabi(state) or self.has_vertical(state),
"Underworld - Long Climb Shop -> Underworld - Hot Tub Checkpoint":
lambda state: self.has_tabi(state)
and (self.can_destroy_projectiles(state)
or self.has_wingsuit(state))
or (self.has_wingsuit(state)
and (self.has_dart(state)
or self.can_dboost(state)
or self.can_destroy_projectiles(state))),
"Underworld - Hot Tub Checkpoint -> Underworld - Long Climb Shop":
lambda state: self.has_tabi(state)
or self.can_destroy_projectiles(state)
or (self.has_dart(state) and self.has_wingsuit(state)),
# Dark Cave
"Dark Cave - Right -> Dark Cave - Left":
lambda state: state.has("Candle", self.player) and self.has_dart(state),
# Riviere Turquoise
"Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint":
lambda state: self.has_dart(state) or (
self.has_wingsuit(state) and self.can_destroy_projectiles(state)),
"Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint":
lambda state: self.has_dart(state) and self.can_dboost(state),
"Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop":
lambda state: False,
# Elemental Skylands
"Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Seal Checkpoint":
self.has_wingsuit,
"Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop":
self.has_wingsuit,
# Sunken Shrine
"Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop":
self.has_tabi,
"Sunken Shrine - Portal -> Sunken Shrine - Moon Path Shop":
self.has_tabi,
"Sunken Shrine - Moon Path Shop -> Sunken Shrine - Waterfall Paradise Checkpoint":
self.has_tabi,
"Sunken Shrine - Waterfall Paradise Checkpoint -> Sunken Shrine - Moon Path Shop":
self.has_tabi,
"Sunken Shrine - Tabi Gauntlet Shop -> Sunken Shrine - Sun Path Shop":
lambda state: self.can_dboost(state) or self.has_dart(state),
}
self.location_rules = {
# ninja village
"Ninja Village Seal - Tree House": self.has_dart,
"Ninja Village Seal - Tree House":
self.has_dart,
"Ninja Village - Candle":
lambda state: state.multiworld.get_location("Searing Crags - Astral Tea Leaves", self.player).can_reach(
state),
# autumn hills
"Autumn Hills - Key of Hope": self.has_dart,
"Autumn Hills Seal - Spike Ball Darts": self.is_aerobatic,
"Autumn Hills Seal - Spike Ball Darts":
self.is_aerobatic,
"Autumn Hills Seal - Trip Saws":
self.has_wingsuit,
# forlorn temple
"Forlorn Temple Seal - Rocket Maze":
self.has_vertical,
# bamboo creek
"Bamboo Creek - Claustro": lambda state: self.has_dart(state) or self.can_dboost(state),
"Bamboo Creek - Claustro":
lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)),
"Above Entrance Mega Shard":
lambda state: self.has_dart(state) or self.can_dboost(state),
"Bamboo Creek Seal - Spike Ball Pits":
self.has_wingsuit,
# howling grotto
"Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit,
"Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Howling Grotto - Emerald Golem": self.has_wingsuit,
"Howling Grotto Seal - Windy Saws and Balls":
self.has_wingsuit,
"Howling Grotto Seal - Crushing Pits":
lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Howling Grotto - Emerald Golem":
self.has_wingsuit,
# searing crags
"Searing Crags Seal - Triple Ball Spinner": self.has_vertical,
"Searing Crags - Astral Tea Leaves":
lambda state: state.can_reach("Ninja Village - Astral Seed", "Location", self.player),
"Searing Crags - Key of Strength": lambda state: state.has("Power Thistle", self.player)
and (self.has_dart(state)
or (self.has_wingsuit(state)
and self.can_destroy_projectiles(state))),
lambda state: state.multiworld.get_location("Ninja Village - Astral Seed", self.player).can_reach(state),
"Searing Crags Seal - Triple Ball Spinner":
self.can_dboost,
"Searing Crags - Pyro":
self.has_tabi,
# glacial peak
"Glacial Peak Seal - Ice Climbers": self.has_dart,
"Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles,
# cloud ruins
"Cloud Ruins Seal - Ghost Pit": self.has_dart,
"Glacial Peak Seal - Ice Climbers":
self.has_dart,
"Glacial Peak Seal - Projectile Spike Pit":
self.can_destroy_projectiles,
# tower of time
"Tower of Time Seal - Time Waster": self.has_dart,
"Tower of Time Seal - Lantern Climb": lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state),
"Tower of Time Seal - Time Waster":
self.has_dart,
# cloud ruins
"Time Warp Mega Shard":
lambda state: self.has_vertical(state) or self.can_dboost(state),
"Cloud Ruins Seal - Ghost Pit":
self.has_vertical,
"Cloud Ruins Seal - Toothbrush Alley":
self.has_dart,
"Cloud Ruins Seal - Saw Pit":
self.has_vertical,
# underworld
"Underworld Seal - Sharp and Windy Climb": self.has_wingsuit,
"Underworld Seal - Fireball Wave": self.is_aerobatic,
"Underworld Seal - Rising Fanta": self.has_dart,
"Underworld Seal - Sharp and Windy Climb":
self.has_wingsuit,
"Underworld Seal - Fireball Wave":
self.is_aerobatic,
"Underworld Seal - Rising Fanta":
self.has_dart,
"Hot Tub Mega Shard":
lambda state: self.has_tabi(state) or self.has_dart(state),
# sunken shrine
"Sunken Shrine - Sun Crest": self.has_tabi,
"Sunken Shrine - Moon Crest": self.has_tabi,
"Sunken Shrine - Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player),
"Sunken Shrine Seal - Waterfall Paradise": self.has_tabi,
"Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi,
"Mega Shard of the Moon": self.has_tabi,
"Mega Shard of the Sun": self.has_tabi,
"Sunken Shrine - Key of Love":
lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player),
"Sunken Shrine Seal - Waterfall Paradise":
self.has_tabi,
"Sunken Shrine Seal - Tabi Gauntlet":
self.has_tabi,
"Mega Shard of the Sun":
self.has_tabi,
# riviere turquoise
"Riviere Turquoise Seal - Bounces and Balls": self.can_dboost,
"Riviere Turquoise Seal - Launch of Faith": lambda state: self.can_dboost(state) or self.has_dart(state),
"Riviere Turquoise Seal - Bounces and Balls":
self.can_dboost,
"Riviere Turquoise Seal - Launch of Faith":
lambda state: self.has_vertical(state),
# elemental skylands
"Elemental Skylands - Key of Symbiosis": self.has_dart,
"Elemental Skylands Seal - Air": self.has_wingsuit,
"Elemental Skylands Seal - Water": lambda state: self.has_dart(state) and
state.has("Currents Master", self.player),
"Elemental Skylands Seal - Fire": lambda state: self.has_dart(state) and self.can_destroy_projectiles(state),
"Earth Mega Shard": self.has_dart,
"Water Mega Shard": self.has_dart,
# corrupted future
"Corrupted Future - Key of Courage": lambda state: state.has_all({"Demon King Crown", "Magic Firefly"},
self.player),
# tower hq
"Money Wrench": self.can_shop,
"Elemental Skylands - Key of Symbiosis":
self.has_dart,
"Elemental Skylands Seal - Air":
self.has_wingsuit,
"Elemental Skylands Seal - Water":
lambda state: self.has_dart(state) and state.has("Currents Master", self.player),
"Elemental Skylands Seal - Fire":
lambda state: self.has_dart(state) and self.can_destroy_projectiles(state) and self.is_aerobatic(state),
"Earth Mega Shard":
self.has_dart,
"Water Mega Shard":
self.has_dart,
}
def has_wingsuit(self, state: CollectionState) -> bool:
@@ -128,6 +332,9 @@ class MessengerRules:
return state.has_any({"Path of Resilience", "Meditation"}, self.player) and \
state.has("Second Wind", self.player)
def can_double_dboost(self, state: CollectionState) -> bool:
return state.has_all({"Path of Resilience", "Meditation", "Second Wind"}, self.player)
def is_aerobatic(self, state: CollectionState) -> bool:
return self.has_wingsuit(state) and state.has("Aerobatics Warrior", self.player)
@@ -135,87 +342,147 @@ class MessengerRules:
"""I know this is stupid, but it's easier to read in the dicts."""
return True
def false(self, state: CollectionState) -> bool:
"""It's a bit easier to just always create the connections that are only possible in hard or higher logic."""
return False
def can_shop(self, state: CollectionState) -> bool:
return state.has("Shards", self.player, self.maximum_price)
def set_messenger_rules(self) -> None:
multiworld = self.world.multiworld
for region in multiworld.get_regions(self.player):
if region.name in self.region_rules:
for entrance in region.entrances:
entrance.access_rule = self.region_rules[region.name]
for loc in region.locations:
if loc.name in self.location_rules:
loc.access_rule = self.location_rules[loc.name]
for entrance_name, rule in self.connection_rules.items():
entrance = multiworld.get_entrance(entrance_name, self.player)
entrance.access_rule = rule
for loc in multiworld.get_locations(self.player):
if loc.name in self.location_rules:
loc.access_rule = self.location_rules[loc.name]
multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player)
if multiworld.accessibility[self.player]: # not locations accessibility
if self.world.options.music_box and not self.world.options.limited_movement:
add_rule(multiworld.get_entrance("Shrink Down", self.player), self.has_dart)
multiworld.completion_condition[self.player] = lambda state: state.has("Do the Thing!", self.player)
if self.world.options.accessibility: # not locations accessibility
set_self_locking_items(self.world, self.player)
class MessengerHardRules(MessengerRules):
extra_rules: Dict[str, CollectionRule]
def __init__(self, world: "MessengerWorld") -> None:
super().__init__(world)
self.region_rules.update({
"Ninja Village": self.has_vertical,
"Autumn Hills": self.has_vertical,
"Catacombs": self.has_vertical,
"Bamboo Creek": self.has_vertical,
"Riviere Turquoise": self.true,
"Forlorn Temple": lambda state: self.has_vertical(state) and state.has_all(PHOBEKINS, self.player),
"Searing Crags Upper": lambda state: self.can_destroy_projectiles(state) or self.has_windmill(state)
or self.has_vertical(state),
"Glacial Peak": lambda state: self.can_destroy_projectiles(state) or self.has_windmill(state)
or self.has_vertical(state),
"Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) or
self.has_windmill(state) or
self.has_dart(state),
})
self.connection_rules.update(
{
# Autumn Hills
"Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop":
self.has_dart,
"Autumn Hills - Climbing Claws Shop -> Autumn Hills - Key of Hope Checkpoint":
self.true, # super easy normal clip - also possible with moderately difficult cloud stepping
# Howling Grotto
"Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop":
self.true,
"Howling Grotto - Lost Woods Checkpoint -> Howling Grotto - Bottom":
self.true, # just memorize the pattern :)
"Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal":
self.true,
"Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop":
lambda state: self.has_wingsuit(state) or # there's a very easy normal clip here but it's 16-bit only
"Howling Grotto - Breezy Crushers Checkpoint" in self.world.spoiler_portal_mapping.values(),
# Searing Crags
"Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint":
lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state),
# it's doable without anything but one jump is pretty hard and time warping is no longer reliable
"Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop":
lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state),
"Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop":
lambda state: self.has_dart(state) or
(self.can_destroy_projectiles(state) and
(self.has_wingsuit(state) or self.can_dboost(state))),
"Searing Crags - Searing Mega Shard Shop -> Searing Crags - Key of Strength Shop":
lambda state: self.can_leash(state) or self.has_windmill(state),
"Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop":
self.true,
# Glacial Peak
"Glacial Peak - Left -> Elemental Skylands - Air Shmup":
lambda state: self.has_windmill(state) or
(state.has("Magic Firefly", self.player) and
state.multiworld.get_location(
"Quillshroom Marsh - Queen of Quills", self.player).can_reach(state)) or
(self.has_dart(state) and self.can_dboost(state)),
"Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left":
lambda state: self.has_vertical(state) or self.has_windmill(state),
# Cloud Ruins
"Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint":
self.true,
# Elemental Skylands
"Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop":
self.true,
# Riviere Turquoise
"Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint":
self.true,
"Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint":
self.can_dboost,
"Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop":
self.can_double_dboost,
}
)
self.location_rules.update({
"Howling Grotto Seal - Windy Saws and Balls": self.true,
"Searing Crags Seal - Triple Ball Spinner": self.true,
"Searing Crags Seal - Raining Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state),
"Searing Crags Seal - Rhythm Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state),
"Searing Crags - Power Thistle": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state),
"Glacial Peak Seal - Ice Climbers": lambda state: self.has_vertical(state) or self.can_dboost(state),
"Glacial Peak Seal - Projectile Spike Pit": self.true,
"Glacial Peak Seal - Glacial Air Swag": lambda state: self.has_windmill(state) or self.has_vertical(state),
"Glacial Peak Mega Shard": lambda state: self.has_windmill(state) or self.has_vertical(state),
"Cloud Ruins Seal - Ghost Pit": self.true,
"Bamboo Creek - Claustro": self.has_wingsuit,
"Tower of Time Seal - Lantern Climb": self.has_wingsuit,
"Elemental Skylands Seal - Water": lambda state: self.has_dart(state) or self.can_dboost(state)
or self.has_windmill(state),
"Elemental Skylands Seal - Fire": lambda state: (self.has_dart(state) or self.can_dboost(state)
or self.has_windmill(state)) and
self.can_destroy_projectiles(state),
"Earth Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
"Water Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
})
self.extra_rules = {
"Searing Crags - Key of Strength": lambda state: self.has_dart(state) or self.has_windmill(state),
"Elemental Skylands - Key of Symbiosis": lambda state: self.has_windmill(state) or self.can_dboost(state),
"Autumn Hills Seal - Spike Ball Darts": lambda state: self.has_dart(state) or self.has_windmill(state),
"Underworld Seal - Fireball Wave": self.has_windmill,
}
self.location_rules.update(
{
"Autumn Hills Seal - Spike Ball Darts":
lambda state: self.has_vertical(state) and self.has_windmill(state) or self.is_aerobatic(state),
"Bamboo Creek - Claustro":
self.has_wingsuit,
"Bamboo Creek Seal - Spike Ball Pits":
self.true,
"Howling Grotto Seal - Windy Saws and Balls":
self.true,
"Searing Crags Seal - Triple Ball Spinner":
self.true,
"Glacial Peak Seal - Ice Climbers":
lambda state: self.has_vertical(state) or self.can_dboost(state),
"Glacial Peak Seal - Projectile Spike Pit":
lambda state: self.can_dboost(state) or self.can_destroy_projectiles(state),
"Glacial Peak Seal - Glacial Air Swag":
lambda state: self.has_windmill(state) or self.has_vertical(state),
"Glacial Peak Mega Shard":
lambda state: self.has_windmill(state) or self.has_vertical(state),
"Cloud Ruins Seal - Ghost Pit":
self.true,
"Cloud Ruins Seal - Toothbrush Alley":
self.true,
"Cloud Ruins Seal - Saw Pit":
self.true,
"Underworld Seal - Fireball Wave":
lambda state: self.is_aerobatic(state) or self.has_windmill(state),
"Riviere Turquoise Seal - Bounces and Balls":
self.true,
"Riviere Turquoise Seal - Launch of Faith":
lambda state: self.can_dboost(state) or self.has_vertical(state),
"Elemental Skylands - Key of Symbiosis":
lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
"Elemental Skylands Seal - Water":
lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
"Elemental Skylands Seal - Fire":
lambda state: (self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state))
and self.can_destroy_projectiles(state),
"Earth Mega Shard":
lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
"Water Mega Shard":
lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
}
)
def has_windmill(self, state: CollectionState) -> bool:
return state.has("Windmill Shuriken", self.player)
def set_messenger_rules(self) -> None:
super().set_messenger_rules()
for loc, rule in self.extra_rules.items():
if not self.world.options.shuffle_seals and "Seal" in loc:
continue
if not self.world.options.shuffle_shards and "Shard" in loc:
continue
add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or")
def can_dboost(self, state: CollectionState) -> bool:
return state.has("Second Wind", self.player) # who really needs meditation
def can_destroy_projectiles(self, state: CollectionState) -> bool:
return super().can_destroy_projectiles(state) or self.has_windmill(state)
def can_leash(self, state: CollectionState) -> bool:
return self.has_dart(state) and self.can_dboost(state)
class MessengerOOBRules(MessengerRules):
@@ -226,7 +493,9 @@ class MessengerOOBRules(MessengerRules):
self.required_seals = max(1, world.required_seals)
self.region_rules = {
"Elemental Skylands":
lambda state: state.has_any({"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player),
lambda state: state.has_any(
{"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player
),
"Music Box": lambda state: state.has_all(set(NOTES), self.player) or self.has_enough_seals(state),
}
@@ -240,8 +509,10 @@ class MessengerOOBRules(MessengerRules):
lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player),
"Autumn Hills Seal - Spike Ball Darts": self.has_dart,
"Ninja Village Seal - Tree House": self.has_dart,
"Underworld Seal - Fireball Wave": lambda state: state.has_any({"Wingsuit", "Windmill Shuriken"},
self.player),
"Underworld Seal - Fireball Wave": lambda state: state.has_any(
{"Wingsuit", "Windmill Shuriken"},
self.player
),
"Tower of Time Seal - Time Waster": self.has_dart,
}
@@ -251,18 +522,8 @@ class MessengerOOBRules(MessengerRules):
def set_self_locking_items(world: "MessengerWorld", player: int) -> None:
multiworld = world.multiworld
# do the ones for seal shuffle on and off first
allow_self_locking_items(multiworld.get_location("Searing Crags - Key of Strength", player), "Power Thistle")
allow_self_locking_items(multiworld.get_location("Sunken Shrine - Key of Love", player), "Sun Crest", "Moon Crest")
allow_self_locking_items(multiworld.get_location("Corrupted Future - Key of Courage", player), "Demon King Crown")
# add these locations when seals are shuffled
if world.options.shuffle_seals:
allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), "Currents Master")
# add these locations when seals and shards aren't shuffled
elif not world.options.shuffle_shards:
for entrance in multiworld.get_region("Cloud Ruins", player).entrances:
entrance.access_rule = lambda state: state.has("Wingsuit", player) or state.has("Rope Dart", player)
allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS)
# locations where these placements are always valid
allow_self_locking_items(world.get_location("Searing Crags - Key of Strength").parent_region, "Power Thistle")
allow_self_locking_items(world.get_location("Sunken Shrine - Key of Love"), "Sun Crest", "Moon Crest")
allow_self_locking_items(world.get_location("Corrupted Future - Key of Courage").parent_region, "Demon King Crown")
allow_self_locking_items(world.get_location("Elemental Skylands Seal - Water"), "Currents Master")

View File

@@ -1,36 +1,48 @@
from functools import cached_property
from typing import Optional, TYPE_CHECKING, cast
from typing import Optional, TYPE_CHECKING
from BaseClasses import CollectionState, Item, ItemClassification, Location, Region
from .constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS
from .regions import MEGA_SHARDS, REGIONS, SEALS
from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region
from .regions import LOCATIONS, MEGA_SHARDS
from .shop import FIGURINES, SHOP_ITEMS
if TYPE_CHECKING:
from . import MessengerWorld
class MessengerEntrance(Entrance):
world: Optional["MessengerWorld"] = None
class MessengerRegion(Region):
def __init__(self, name: str, world: "MessengerWorld") -> None:
parent: str
entrance_type = MessengerEntrance
def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = None) -> None:
super().__init__(name, world.player, world.multiworld)
locations = [loc for loc in REGIONS[self.name]]
if self.name == "The Shop":
self.parent = parent
locations = []
if name in LOCATIONS:
locations = [loc for loc in LOCATIONS[name]]
# portal event locations since portals can be opened from their exit regions
if name.endswith("Portal"):
locations.append(name.replace(" -", ""))
if name == "The Shop":
shop_locations = {f"The Shop - {shop_loc}": world.location_name_to_id[f"The Shop - {shop_loc}"]
for shop_loc in SHOP_ITEMS}
self.add_locations(shop_locations, MessengerShopLocation)
elif self.name == "The Craftsman's Corner":
elif name == "The Craftsman's Corner":
self.add_locations({figurine: world.location_name_to_id[figurine] for figurine in FIGURINES},
MessengerLocation)
elif self.name == "Tower HQ":
elif name == "Tower HQ":
locations.append("Money Wrench")
if world.options.shuffle_seals and self.name in SEALS:
locations += [seal_loc for seal_loc in SEALS[self.name]]
if world.options.shuffle_shards and self.name in MEGA_SHARDS:
locations += [shard for shard in MEGA_SHARDS[self.name]]
if world.options.shuffle_shards and name in MEGA_SHARDS:
locations += MEGA_SHARDS[name]
loc_dict = {loc: world.location_name_to_id.get(loc, None) for loc in locations}
self.add_locations(loc_dict, MessengerLocation)
world.multiworld.regions.append(self)
self.multiworld.regions.append(self)
class MessengerLocation(Location):
@@ -39,46 +51,36 @@ class MessengerLocation(Location):
def __init__(self, player: int, name: str, loc_id: Optional[int], parent: MessengerRegion) -> None:
super().__init__(player, name, loc_id, parent)
if loc_id is None:
self.place_locked_item(MessengerItem(name, parent.player, None))
if name == "Rescue Phantom":
name = "Do the Thing!"
self.place_locked_item(MessengerItem(name, ItemClassification.progression, None, parent.player))
class MessengerShopLocation(MessengerLocation):
@cached_property
def cost(self) -> int:
name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped
world = cast("MessengerWorld", self.parent_region.multiworld.worlds[self.player])
world = self.parent_region.multiworld.worlds[self.player]
shop_data = SHOP_ITEMS[name]
if shop_data.prerequisite:
prereq_cost = 0
if isinstance(shop_data.prerequisite, set):
for prereq in shop_data.prerequisite:
prereq_cost +=\
cast(MessengerShopLocation,
world.multiworld.get_location(prereq, self.player)).cost
loc = world.multiworld.get_location(prereq, self.player)
assert isinstance(loc, MessengerShopLocation)
prereq_cost += loc.cost
else:
prereq_cost +=\
cast(MessengerShopLocation,
world.multiworld.get_location(shop_data.prerequisite, self.player)).cost
loc = world.multiworld.get_location(shop_data.prerequisite, self.player)
assert isinstance(loc, MessengerShopLocation)
prereq_cost += loc.cost
return world.shop_prices[name] + prereq_cost
return world.shop_prices[name]
def access_rule(self, state: CollectionState) -> bool:
world = cast("MessengerWorld", state.multiworld.worlds[self.player])
world = state.multiworld.worlds[self.player]
can_afford = state.has("Shards", self.player, min(self.cost, world.total_shards))
return can_afford
class MessengerItem(Item):
game = "The Messenger"
def __init__(self, name: str, player: int, item_id: Optional[int] = None, override_progression: bool = False,
count: int = 0) -> None:
if count:
item_class = ItemClassification.progression_skip_balancing
elif item_id is None or override_progression or name in {*NOTES, *PROG_ITEMS, *PHOBEKINS, *PROG_SHOP_ITEMS}:
item_class = ItemClassification.progression
elif name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}:
item_class = ItemClassification.useful
else:
item_class = ItemClassification.filler
super().__init__(name, item_class, item_id, player)

View File

@@ -1,4 +1,4 @@
from test.TestBase import WorldTestBase
from test.bases import WorldTestBase
from .. import MessengerWorld

View File

@@ -1,3 +1,5 @@
import typing
from . import MessengerTestBase
from ..constants import NOTES, PHOBEKINS
@@ -22,11 +24,27 @@ class AccessTest(MessengerTestBase):
def test_dart(self) -> None:
"""locations that hard require the Rope Dart"""
locations = [
"Ninja Village Seal - Tree House", "Autumn Hills - Key of Hope", "Howling Grotto Seal - Crushing Pits",
"Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb",
"Tower of Time Seal - Arcane Orbs", "Cloud Ruins Seal - Ghost Pit", "Underworld Seal - Rising Fanta",
"Elemental Skylands - Key of Symbiosis", "Elemental Skylands Seal - Water",
"Elemental Skylands Seal - Fire", "Earth Mega Shard", "Water Mega Shard", "Rescue Phantom",
"Ninja Village Seal - Tree House",
"Autumn Hills - Key of Hope",
"Forlorn Temple - Demon King",
"Down Under Mega Shard",
"Howling Grotto Seal - Crushing Pits",
"Glacial Peak Seal - Ice Climbers",
"Tower of Time Seal - Time Waster",
"Tower of Time Seal - Lantern Climb",
"Tower of Time Seal - Arcane Orbs",
"Cloud Ruins Seal - Ghost Pit",
"Cloud Ruins Seal - Money Farm Room",
"Cloud Ruins Seal - Toothbrush Alley",
"Money Farm Room Mega Shard 1",
"Money Farm Room Mega Shard 2",
"Underworld Seal - Rising Fanta",
"Elemental Skylands - Key of Symbiosis",
"Elemental Skylands Seal - Water",
"Elemental Skylands Seal - Fire",
"Earth Mega Shard",
"Water Mega Shard",
"Rescue Phantom",
]
items = [["Rope Dart"]]
self.assertAccessDependency(locations, items)
@@ -136,11 +154,37 @@ class AccessTest(MessengerTestBase):
items = [["Demon King Crown"]]
self.assertAccessDependency(locations, items)
def test_dboost(self) -> None:
"""
short for damage boosting, d-boosting is a technique in video games where the player intentionally or
unintentionally takes damage and uses the several following frames of invincibility to defeat or get past an
enemy or obstacle, most commonly used in platformers such as the Super Mario games
"""
locations = [
"Riviere Turquoise Seal - Bounces and Balls", "Searing Crags Seal - Triple Ball Spinner",
"Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset",
"Sunny Day Mega Shard", "Down Under Mega Shard",
]
items = [["Path of Resilience", "Meditation", "Second Wind"]]
self.assertAccessDependency(locations, items)
def test_currents(self) -> None:
"""there's one of these but oh man look at it go"""
self.assertAccessDependency(["Elemental Skylands Seal - Water"], [["Currents Master"]])
def test_strike(self) -> None:
"""strike is pretty cool but it doesn't block much"""
locations = [
"Glacial Peak Seal - Projectile Spike Pit", "Elemental Skylands Seal - Fire",
]
items = [["Strike of the Ninja"]]
self.assertAccessDependency(locations, items)
def test_goal(self) -> None:
"""Test some different states to verify goal requires the correct items"""
self.collect_all_but([*NOTES, "Rescue Phantom"])
self.collect_all_but([*NOTES, "Do the Thing!"])
self.assertEqual(self.can_reach_location("Rescue Phantom"), False)
self.collect_all_but(["Key of Love", "Rescue Phantom"])
self.collect_all_but(["Key of Love", "Do the Thing!"])
self.assertBeatable(False)
self.collect_by_name(["Key of Love"])
self.assertEqual(self.can_reach_location("Rescue Phantom"), True)
@@ -159,14 +203,15 @@ class ItemsAccessTest(MessengerTestBase):
"Searing Crags - Key of Strength": ["Power Thistle"],
"Sunken Shrine - Key of Love": ["Sun Crest", "Moon Crest"],
"Corrupted Future - Key of Courage": ["Demon King Crown"],
"Cloud Ruins - Acro": ["Ruxxtin's Amulet"],
"Forlorn Temple - Demon King": PHOBEKINS
}
self.multiworld.state = self.multiworld.get_all_state(True)
self.remove_by_name(location_lock_pairs.values())
self.collect_all_but([item for items in location_lock_pairs.values() for item in items])
for loc in location_lock_pairs:
for item_name in location_lock_pairs[loc]:
item = self.get_item_by_name(item_name)
with self.subTest("Fulfills Accessibility", location=loc, item=item_name):
self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, True))
location = self.multiworld.get_location(loc, self.player)
self.assertTrue(location.can_fill(self.multiworld.state, item, True))
location.item = item
self.multiworld.state.update_reachable_regions(self.player)
self.assertTrue(self.can_reach_location(loc))

View File

@@ -41,7 +41,7 @@ class HardLogicTest(MessengerTestBase):
# cloud ruins
"Cloud Ruins - Acro", "Cloud Ruins Seal - Ghost Pit",
"Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room",
"Cloud Entrance Mega Shard", "Time Warp Mega Shard", "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2",
"Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2",
# underworld
"Underworld Seal - Rising Fanta", "Underworld Seal - Sharp and Windy Climb",
# elemental skylands
@@ -80,18 +80,6 @@ class HardLogicTest(MessengerTestBase):
self.collect(item)
self.assertTrue(self.can_reach_location(special_loc))
def test_glacial(self) -> None:
"""Test Glacial Peak locations."""
self.assertAccessDependency(["Glacial Peak Seal - Ice Climbers"],
[["Second Wind", "Meditation"], ["Rope Dart"], ["Wingsuit"]],
True)
self.assertAccessDependency(["Glacial Peak Seal - Projectile Spike Pit"],
[["Strike of the Ninja"], ["Windmill Shuriken"], ["Rope Dart"], ["Wingsuit"]],
True)
self.assertAccessDependency(["Glacial Peak Seal - Glacial Air Swag", "Glacial Peak Mega Shard"],
[["Windmill Shuriken"], ["Wingsuit"], ["Rope Dart"]],
True)
class NoLogicTest(MessengerTestBase):
options = {

View File

@@ -2,29 +2,19 @@ from . import MessengerTestBase
from ..constants import NOTES
class TwoNoteGoalTest(MessengerTestBase):
options = {
"notes_needed": 2,
}
class PrecollectedNotesTestBase(MessengerTestBase):
starting_notes: int = 0
@property
def run_default_tests(self) -> bool:
return False
def test_precollected_notes(self) -> None:
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4)
class FourNoteGoalTest(MessengerTestBase):
options = {
"notes_needed": 4,
}
def test_precollected_notes(self) -> None:
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2)
class DefaultGoalTest(MessengerTestBase):
def test_precollected_notes(self) -> None:
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0)
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), self.starting_notes)
def test_goal(self) -> None:
if self.__class__ is not PrecollectedNotesTestBase:
return
self.assertBeatable(False)
self.collect_by_name(NOTES)
rope_dart = self.get_item_by_name("Rope Dart")
@@ -33,3 +23,17 @@ class DefaultGoalTest(MessengerTestBase):
self.remove(rope_dart)
self.collect_by_name("Wingsuit")
self.assertBeatable(True)
class TwoNoteGoalTest(PrecollectedNotesTestBase):
options = {
"notes_needed": 2,
}
starting_notes = 4
class FourNoteGoalTest(PrecollectedNotesTestBase):
options = {
"notes_needed": 4,
}
starting_notes = 2

View File

@@ -0,0 +1,35 @@
from BaseClasses import CollectionState
from Fill import distribute_items_restrictive
from . import MessengerTestBase
from .. import MessengerWorld
from ..options import Logic
class LimitedMovementTest(MessengerTestBase):
options = {
"limited_movement": "true",
"shuffle_shards": "true",
}
@property
def run_default_tests(self) -> bool:
# This test base fails reachability tests. Not sure if the core tests should change to support that
return False
def test_options(self) -> None:
"""Tests that options were correctly changed."""
assert isinstance(self.multiworld.worlds[self.player], MessengerWorld)
self.assertEqual(Logic.option_hard, self.world.options.logic_level)
class EarlyMeditationTest(MessengerTestBase):
options = {
"early_meditation": "true",
}
def test_option(self) -> None:
"""Checks that Meditation gets placed early"""
distribute_items_restrictive(self.multiworld)
sphere1 = self.multiworld.get_reachable_locations(CollectionState(self.multiworld))
items = [loc.item.name for loc in sphere1]
self.assertIn("Meditation", items)

View File

@@ -0,0 +1,33 @@
from BaseClasses import CollectionState
from . import MessengerTestBase
from ..portals import PORTALS
class PortalTestBase(MessengerTestBase):
def test_portal_reqs(self) -> None:
"""tests the paths to open a portal if only that portal is closed with vanilla connections."""
# portal and requirements to reach it if it's the only closed portal
portal_requirements = {
"Autumn Hills Portal": [["Wingsuit"]], # grotto -> bamboo -> catacombs -> hills
"Riviere Turquoise Portal": [["Candle", "Wingsuit", "Rope Dart"]], # hills -> catacombs -> dark cave -> riviere
"Howling Grotto Portal": [["Wingsuit"], ["Meditation", "Second Wind"]], # crags -> quillshroom -> grotto
"Sunken Shrine Portal": [["Seashell"]], # crags -> quillshroom -> grotto -> shrine
"Searing Crags Portal": [["Wingsuit"], ["Rope Dart"]], # grotto -> quillshroom -> crags there's two separate paths
"Glacial Peak Portal": [["Wingsuit", "Second Wind", "Meditation"], ["Rope Dart"]], # grotto -> quillshroom -> crags -> peak or crags -> peak
}
for portal in PORTALS:
name = f"{portal} Portal"
entrance_name = f"ToTHQ {name}"
with self.subTest(portal=name, entrance_name=entrance_name):
entrance = self.multiworld.get_entrance(entrance_name, self.player)
# this emulates the portal being initially closed
entrance.access_rule = lambda state: state.has(name, self.player)
for grouping in portal_requirements[name]:
test_state = CollectionState(self.multiworld)
self.assertFalse(entrance.can_reach(test_state), "reachable with nothing")
items = self.get_items_by_name(grouping)
for item in items:
test_state.collect(item)
self.assertTrue(entrance.can_reach(test_state), grouping)
entrance.access_rule = lambda state: True

View File

@@ -24,25 +24,6 @@ class ShopCostTest(MessengerTestBase):
self.assertTrue(loc in SHOP_ITEMS)
self.assertEqual(len(prices), len(SHOP_ITEMS))
def test_dboost(self) -> None:
locations = [
"Riviere Turquoise Seal - Bounces and Balls",
"Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset",
"Sunny Day Mega Shard", "Down Under Mega Shard",
]
items = [["Path of Resilience", "Meditation", "Second Wind"]]
self.assertAccessDependency(locations, items)
def test_currents(self) -> None:
self.assertAccessDependency(["Elemental Skylands Seal - Water"], [["Currents Master"]])
def test_strike(self) -> None:
locations = [
"Glacial Peak Seal - Projectile Spike Pit", "Elemental Skylands Seal - Fire",
]
items = [["Strike of the Ninja"]]
self.assertAccessDependency(locations, items)
class ShopCostMinTest(ShopCostTest):
options = {

View File

@@ -4,19 +4,14 @@ from . import MessengerTestBase
class AllSealsRequired(MessengerTestBase):
options = {
"shuffle_seals": "false",
"goal": "power_seal_hunt",
}
def test_seals_shuffled(self) -> None:
"""Shuffle seals should be forced on when shop chest is the goal so test it."""
self.assertTrue(self.multiworld.shuffle_seals[self.player])
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.multiworld.total_seals[self.player])
self.world.options.total_seals)
locations = ["Rescue Phantom"]
items = [["Power Seal"]]
self.assertAccessDependency(locations, items)
@@ -24,7 +19,7 @@ class AllSealsRequired(MessengerTestBase):
self.assertEqual(self.can_reach_location("Rescue Phantom"), False)
self.assertBeatable(False)
self.collect_all_but(["Power Seal", "Rescue Phantom"])
self.collect_all_but(["Power Seal", "Do the Thing!"])
self.assertEqual(self.can_reach_location("Rescue Phantom"), False)
self.assertBeatable(False)
self.collect_by_name("Power Seal")
@@ -40,7 +35,7 @@ class HalfSealsRequired(MessengerTestBase):
def test_seals_amount(self) -> None:
"""Should have 45 power seals in the item pool and half that required"""
self.assertEqual(self.multiworld.total_seals[self.player], 45)
self.assertEqual(self.world.options.total_seals, 45)
self.assertEqual(self.world.total_seals, 45)
self.assertEqual(self.world.required_seals, 22)
total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]
@@ -59,7 +54,7 @@ class ThirtyThirtySeals(MessengerTestBase):
def test_seals_amount(self) -> None:
"""Should have 30 power seals in the pool and 33 percent of that required."""
self.assertEqual(self.multiworld.total_seals[self.player], 30)
self.assertEqual(self.world.options.total_seals, 30)
self.assertEqual(self.world.total_seals, 30)
self.assertEqual(self.world.required_seals, 10)
total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]
@@ -77,7 +72,7 @@ class MaxSealsNoShards(MessengerTestBase):
def test_seals_amount(self) -> None:
"""Should set total seals to 70 since shards aren't shuffled."""
self.assertEqual(self.multiworld.total_seals[self.player], 85)
self.assertEqual(self.world.options.total_seals, 85)
self.assertEqual(self.world.total_seals, 70)
@@ -90,7 +85,7 @@ class MaxSealsWithShards(MessengerTestBase):
def test_seals_amount(self) -> None:
"""Should have 85 seals in the pool with all required and be a valid seed."""
self.assertEqual(self.multiworld.total_seals[self.player], 85)
self.assertEqual(self.world.options.total_seals, 85)
self.assertEqual(self.world.total_seals, 85)
self.assertEqual(self.world.required_seals, 85)
total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]

View File

@@ -11,9 +11,9 @@ Some recipes are locked from being able to be crafted and shuffled into the item
structures appear in each dimension. Crafting recipes are re-learned when they are received from other players as item
checks, and occasionally when completing your own achievements. See below for which recipes are shuffled.
## What is considered a location check in minecraft?
## What is considered a location check in Minecraft?
Location checks in are completed when the player completes various Minecraft achievements. Opening the advancements menu
Location checks are completed when the player completes various Minecraft achievements. Opening the advancements menu
in-game by pressing "L" will display outstanding achievements.
## When the player receives an item, what happens?
@@ -24,7 +24,7 @@ inventory directly.
## What is the victory condition?
Victory is achieved when the player kills the Ender Dragon, enters the portal in The End, and completes the credits
sequence either by skipping it or watching hit play out.
sequence either by skipping it or watching it play out.
## Which recipes are locked?
@@ -64,12 +64,15 @@ sequence either by skipping it or watching hit play out.
* Diamond Axe
* Progessive Tools
* Tier I
* Stone Pickaxe
* Stone Shovel
* Stone Hoe
* Tier II
* Iron Pickaxe
* Iron Shovel
* Iron Hoe
* Tier III
* Diamond Pickaxe
* Diamond Shovel
* Diamond Hoe
* Netherite Ingot

View File

@@ -0,0 +1,186 @@
# 2.0.0
### Features
- Picking up items for other players will display the actual item name and receiving player in-game instead of
"ARCHIPELAGO ITEM". (This does have a limit, but you're unlikely to reach it in all but the largest multiworlds.)
- New goal `legendary_hunt`. Your goal is to catch/defeat some number of legendary encounters. That is, the static
encounters themselves, whatever species they may be. Legendary species found in the wild don't count.
- You can force the goal to require captures with `legendary_hunt_catch`. If you accidentally faint a legendary, you
can respawn it by beating the champion.
- The number of legendaries needed is controlled by the `legendary_hunt_count` option.
- The caves containing Kyogre and Groudon are fixed to one location per seed. You need to go to the weather
institute to trigger a permanent weather event at the corresponding locations. Only one weather event can be active
at a time.
- The move tutor for the move Sleep Talk has been changed to Dig and is unlimited use (for Sealed Chamber).
- Relicanth and Wailord are guaranteed to be reachable in the wild (for Sealed Chamber). Interacting with the Sealed
Chamber wall will give you dex info for Wailord and Relicanth.
- Event legendaries are included for this goal (see below for new ferry behavior and event tickets).
- The roamer is included in this count. It will _always_ be Latios no matter what your options are. Otherwise you
might not have any way of knowing which species is roaming to be able to track it. In legendary hunt, Latios will
never appear as a wild pokemon to make tracking it easier. The television broadcast that creates the roamer will
give you dex info for Latios.
- You can set which encounters are considered for this goal with the `allowed_legendary_hunt_encounters` option.
- New option `dexsanity`. Adds pokedex entries as locations.
- Added locations contribute either a Poke Ball, Great Ball, or Ultra Ball to the item pool, based on the evolution
stage.
- Logic uses only wild encounters for now.
- Defeating a gym leader awards "seen" info on 1/8th of the pokedex.
- New option `trainersanity`. Defeating a trainer awards a random item.
- Trainers no longer award money upon defeat. Instead they add a sellable item to the item pool.
- Missable trainers are prevented from disappearing when this is enabled.
- Gym trainers remain active after their leader is defeated.
- Does not include trainers in the Trick House.
- New option `berry_trees`. Adds berry trees as locations.
- All soil patches start with a fully grown berry tree that gives one item.
- There are 88 berry trees.
- Berries cannot be planted in soil with this option enabled.
- Soil that doesn't start with a tree on a fresh save contributes a Sitrus Berry to the item pool.
- New option `death_link`. Forgive me, Figment.
- Added Artisan Cave locations
- Requires Wailmer Pail and the ability to Surf to access.
- Added Trick House locations. The Trick Master is finally here!
- He will make new layouts only if you have the corresponding badge (or beat the game) and have completed the
previous layout (all vanilla behavior).
- If you neglect to pick up an item in a puzzle before completing it, the Trick Master will give the item to you
alongside the prize.
- Locations are enabled or disabled with their broader categories (npc gifts, overworld items, etc...)
- Added daily berry gift locations. There are a half dozen or so NPCs that give you one or two berries per day.
- All these locations are considered NPC gifts.
- The NPCs have been reworked to give this gift once permanently so they can be added as locations.
- New option `remote_items`. All randomized items are sent from the server instead of being patched into your game
(except for start inventory, which remains in the PC)
- As a side effect, when you pick up your own item, there will be a gap between the item disappearing from the
overworld and your game actually receiving it. It also causes gifts from NPCs which contain your own items to not
show up until after their text box closes. It can feel odd, but there should be no danger to it.
- If the seed is in race mode, this is forcibly enabled.
- Benefits include:
- Two players can play the same slot and both receive items that slot picks up for itself (as long as it was
randomized)
- You receive items you picked up for yourself if you lose progress on your save
- Competitive integrity; the patch file no longer has any knowledge of item placement
- New option `match_trainer_levels`. This is a sort of pseudo level cap for a randomizer context.
- When you start a trainer fight, all your pokemon have their levels temporarily set to the highest level in the
opponent's party.
- During the battle, all earned exp is set to 0 (EVs are still gained during battle as normal). When the outcome of
the battle is decided, your pokemon have their levels reset to what they were before the fight and exp is awarded as
it would have been without this option. Think of it as holding earned exp in reserve and awarding it at the end
instead, even giving it to fainted pokemon if they earned any before fainting.
- Exp gain is based on _your_ party's average level to moderate exp over the course of a seed. Wild battles are
entirely unchanged by this option.
- New option `match_trainer_levels_bonus`. A flat bonus to apply to your party's levels when using
`match_trainer_levels`. In case you want to give yourself a nerf or buff while still approximately matching your
opponent.
- New option `force_fully_evolved`. Define a level at which trainers will stop using pokemon that have further evolution
stages.
- New option `move_blacklist`. Define a list of moves that should not be given randomly to learnsets or TMs. Move names
are accurate to Gen 3 except for capitalization.
- New option `extra_bumpy_slope`. Adds a "bumpy slope" to Route 115 that lets you hop up the ledge with the Acro Bike.
- New option `modify_118`. Changes Route 118 so that it must be crossed with the Acro Bike, and cannot be crossed by
surfing.
- Changed `require_flash` option to a choice between none, only granite cave, only victory road, or both caves.
- Removed `static_encounters` option.
- New option `legendary_encounters`. Replaces `static_encounters`, but only concerns legendaries.
- New option `misc_pokemon`. Replaces `static_encounters`, but only concerns non-legendaries.
- Removed `fly_without_badge` option. (Don't worry)
- New option `hm_requirements`. Will eventually be able to give you more control over the badge requirements for all
HMs. For now, only includes the presets `vanilla` and `fly_without_badge`.
- Removed `allow_wild_legendaries`, `allow_starter_legendaries`, and `allow_trainer_legendaries` options.
- New options `wild_encounter_blacklist`, `starter_blacklist`, and `trainer_party_blacklist`.
- These take lists of species and prevent them from randomizing into the corresponding categories
- If adhering to your blacklist would make it impossible to choose a random species, your blacklist is ignored in
that case
- All three include a shorthand for excluding legendaries
- Removed `enable_ferry` option.
- The ferry is now always present.
- The S.S. Ticket item/location is now part of `key_items`.
- Added event tickets and islands.
- All event tickets are given to the player by Norman after defeating the Champion alongside the S.S. Ticket.
- As in vanilla, these tickets are only usable from Lilycove. Not Slateport or the Battle Frontier.
- New option `event_tickets`. Randomizes the above-mentioned tickets into the item pool.
- New option `enable_wonder_trading`. You can participate in Wonder Trading by interacting with the center receptionist
on the second floor of Pokemon Centers.
- Why is this an option instead of just being enabled? You might want to disable wonder trading in a meta yaml to
make sure certain rules can't be broken. Or you may want to turn it off for yourself to definitively prevent being
asked for help if you prefer to keep certain walls up between your game and others. Trades _do_ include items and
known moves, which means there is potential for an extra level of cooperation and even ways to go out of logic. But
that's not a boundary everyone wants broken down all the time. Please be respectful of someone's choice to not
participate if that's their preference.
- A lot of time was spent trying to make this all work without having to touch your client. Hopefully it goes
smoothly, but there's room for jank. Anything you decide to give to her you should consider gone forever, whether
because it was traded away or because something "went wrong in transit" and the pokemon's data got lost after being
removed from the server.
- Wonder Trading is _not_ resistant to save scumming in either direction. You _could_ abuse it to dupe pokemon,
because there's not realistically a way for me to prevent it, but I'd urge you to stick to the spirit of the design
unless everyone involved doesn't mind.
- The wonder trades you receive are stored in your save data even before you pick them up, so if you save after the
client tells you that you received a wonder trade, it's safe. You don't need to retrieve it from a poke center for
it to persist. However, if you reset your game to a point in time before your client popped the "Wonder trade
received" message, that pokemon is lost forever.
- New `easter_egg` passphrase system.
- All valid easter egg passphrases will be a phrase that it's possible to submit as a trendy phrase in Dewford Town.
Changing the trendy phrase does ***not*** trigger easter eggs. Only the phrase you put in your YAML can trigger an
easter egg.
- There may be other ways to learn more information.
- Phrases are case insensitive. Here are a couple examples of possible phrases: `"GET FOE"`,
`"HERE GOES GRANDMOTHER"`, `"late eh?"` (None of those do anything, but I'd love to hear what you think they would.)
- Added three new easter egg effects.
- Changed the original easter egg phrase to use the new system.
- Renamed `tm_moves` to `tm_tutor_moves`. Move tutors are also affected by this option (except the new Dig tutor).
- Renamed `tm_compatibility` to `tm_tutor_compatibility`. Move tutors are also affected by this option.
- Changed `tm_tutor_compatibility` to be a percent chance instead of a choice. Use `-1` for vanilla.
- Changed `hm_compatibility` to be a percent chance instead of a choice. Use `-1` for vanilla.
- New option `music`. Shuffles all looping music. Includes FRLG tracks and possibly some unused stuff.
- New option `fanfares`. Shuffles all fanfares. Includes FRLG tracks. When this is enabled, pressing B will interrupt
most fanfares.
- New option `purge_spinners`. Trainers that change which direction they face will do so predictably, and will no longer
turn to face you when you run.
- New option `normalize_encounter_rates`. Sets every encounter slot to (almost) equal probability. Does NOT make every
species equally likely to appear, but makes rare encounters less rare.
- Added `Trick House` location group.
- Removed `Postgame Locations` location group.
### QoL
- Can teach moves over HM moves.
- Fishing is much less random; pokemon will always bite if there's an encounter there.
- Mirage Island is now always present.
- Waking Rayquaza is no longer required. After releasing Kyogre, going to Sootopolis will immediately trigger the
Rayquaza cutscene.
- Renamed some locations to be more accurate.
- Most trainers will no longer ask to be registered in your Pokegear after battle. Also removed most step-based match
calls.
- Removed a ledge on Route 123. With careful routing, it's now possible to check every location without having to save
scum or go back around.
- Added "GO HOME" button on the start menu where "EXIT" used to be. Will teleport you to Littleroot.
- Some locations which are directly blocked by completing your goal are automatically excluded.
- For example, the S.S. Ticket and a Champion goal, or the Sludge Bomb TM and the Norman goal.
- Your particular options might still result in locations that can't be reached until after your goal. For example,
setting a Norman goal and setting your E4 requirement to 8 gyms means that post-Champion locations will not be
reachable before defeating Norman, but they are NOT excluded by this modification. That's one of the simpler
examples. It is extremely tedious to try to detect these sorts of situations, so I'll instead leave it to you to be
aware of your own options.
- Species in the pokedex are searchable by type even if you haven't caught that species yet
### Fixes
- Mt. Pyre summit state no longer changes when you finish the Sootopolis events, which would lock you out of one or two
locations.
- Whiting out under certain conditions no longer softlocks you by moving Mr. Briney to an inaccessible area.
- It's no longer possible to join a room using the wrong patch file, even if the slot names match.
- NPCs now stop moving while you're receiving an item.
- Creating a secret base no longer triggers sending the Secret Power TM location.
- Hopefully fix bug where receiving an item while walking over a trigger can skip that trigger (the Moving
Truck/Petalburg wrong warp)
## Easter Eggs
There are plenty among you who are capable of ~~cheating~~ finding information about the easter egg phrases by reading
source code, writing brute force scripts, and inspecting memory for clues and answers. By all means, go ahead, that can
be your version of this puzzle and I don't intend to stand in your way. **However**, I would ask that any information
you come up with by doing this, you keep entirely to yourself until the community as a whole has figured out what you
know. There was not previously a way to reasonably learn about or make guesses at the easter egg, but that has changed.
There are mechanisms by which solutions can be found or guessed over the course of multiple games by multiple people,
and I'd rather the fun not get spoiled immediately.
Once a solution has been found I'd _still_ prefer discussion about hints and effects remain behind spoiler tags just in
case there are people who want to do the hunt on their own. Thank you all, and good luck.

View File

@@ -1,58 +1,3 @@
# Pokemon Emerald
Version 1.2.1
This README contains general info useful for understanding the world. Pretty much all the long lists of locations,
regions, and items are stored in `data/` and (mostly) loaded in by `data.py`. Access rules are in `rules.py`. Check
[data/README.md](data/README.md) for more detailed information on the JSON files holding most of the data.
## Warps
Quick note to start, you should not be defining or modifying encoded warps from this repository. They're encoded in the
source code repository for the mod, and then assigned to regions in `data/regions/`. All warps in the game already exist
within `extracted_data.json`, and all relevant warps are already placed in `data/regions/` (unless they were deleted
accidentally).
Many warps are actually two or three events acting as one logical warp. Doorways, for example, are often 2 tiles wide
indoors but only 1 tile wide outdoors. Both indoor warps point to the outdoor warp, and the outdoor warp points to only
one of the indoor warps. We want to describe warps logically in a way that retains information about individual warp
events. That way a 2-tile-wide doorway doesnt look like a one-way warp next to an unrelated two-way warp, but if we want
to randomize the destinations of those warps, we can still get back each individual id of the multi-tile warp.
This is how warps are encoded:
`{source_map}:{source_warp_ids}/{dest_map}:{dest_warp_ids}[!]`
- `source_map`: The map the warp events are located in
- `source_warp_ids`: The ids of all adjacent warp events in source_map which lead to the same destination (these must be
in ascending order)
- `dest_map`: The map of the warp event to which this one is connected
- `dest_warp_ids`: The ids of the warp events in dest_map
- `[!]`: If the warp expects to lead to a destination which doesnot lead back to it, add a ! to the end
Example: `MAP_LAVARIDGE_TOWN_HOUSE:0,1/MAP_LAVARIDGE_TOWN:4`
Example 2: `MAP_AQUA_HIDEOUT_B1F:14/MAP_AQUA_HIDEOUT_B1F:12!`
Note: A warp must have its destination set to another warp event. However, that does not guarantee that the destination
warp event will warp back to the source.
Note 2: Some warps _only_ act as destinations and cannot actually be interacted with by the player as sources. These are
usually places you fall from a hole above. At the time of writing, these are actually not accounted for, but there are
no instances where it changes logical access.
Note 3: Some warp destinations go to the map `MAP_DYNAMIC` and have a special warp id. These edge cases are:
- The Moving Truck
- Terra Cave
- Marine Cave
- The Department Store Elevator
- Secret Bases
- The Trade Center
- The Union Room
- The Record Corner
- 2P/4P Battle Colosseum
Note 4: The trick house on Route 110 changes the warp destinations of its entrance and ending room as you progress
through the puzzles, but the source code only sets the trick house up for the first puzzle, and I assume the destination
gets overwritten at run time when certain flags are set.
Version 2.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,28 @@
from typing import TYPE_CHECKING, Dict, Set
import asyncio
import copy
import orjson
import random
import time
from typing import TYPE_CHECKING, Optional, Dict, Set, Tuple
import uuid
from NetUtils import ClientStatus
from Options import Toggle
import Utils
import worlds._bizhawk as bizhawk
from worlds._bizhawk.client import BizHawkClient
from .data import BASE_OFFSET, data
from .options import Goal
from .data import BASE_OFFSET, POKEDEX_OFFSET, data
from .options import Goal, RemoteItems
from .util import pokemon_data_to_json, json_to_pokemon_data
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext
EXPECTED_ROM_NAME = "pokemon emerald version / AP 2"
EXPECTED_ROM_NAME = "pokemon emerald version / AP 5"
IS_CHAMPION_FLAG = data.constants["FLAG_IS_CHAMPION"]
DEFEATED_WALLACE_FLAG = data.constants["TRAINER_FLAGS_START"] + data.constants["TRAINER_WALLACE"]
DEFEATED_STEVEN_FLAG = data.constants["TRAINER_FLAGS_START"] + data.constants["TRAINER_STEVEN"]
DEFEATED_NORMAN_FLAG = data.constants["TRAINER_FLAGS_START"] + data.constants["TRAINER_NORMAN_1"]
@@ -31,7 +40,7 @@ TRACKER_EVENT_FLAGS = [
"FLAG_RECEIVED_POKENAV", # Talk to Mr. Stone
"FLAG_DELIVERED_STEVEN_LETTER",
"FLAG_DELIVERED_DEVON_GOODS",
"FLAG_HIDE_ROUTE_119_TEAM_AQUA", # Clear Weather Institute
"FLAG_HIDE_ROUTE_119_TEAM_AQUA_SHELLY", # Clear Weather Institute
"FLAG_MET_ARCHIE_METEOR_FALLS", # Magma steals meteorite
"FLAG_GROUDON_AWAKENED_MAGMA_HIDEOUT", # Clear Magma Hideout
"FLAG_MET_TEAM_AQUA_HARBOR", # Aqua steals submarine
@@ -41,19 +50,19 @@ TRACKER_EVENT_FLAGS = [
"FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA", # Rayquaza departs for Sootopolis
"FLAG_OMIT_DIVE_FROM_STEVEN_LETTER", # Steven gives Dive HM (clears seafloor cavern grunt)
"FLAG_IS_CHAMPION",
"FLAG_PURCHASED_HARBOR_MAIL"
"FLAG_PURCHASED_HARBOR_MAIL",
]
EVENT_FLAG_MAP = {data.constants[flag_name]: flag_name for flag_name in TRACKER_EVENT_FLAGS}
KEY_LOCATION_FLAGS = [
"NPC_GIFT_RECEIVED_HM01",
"NPC_GIFT_RECEIVED_HM02",
"NPC_GIFT_RECEIVED_HM03",
"NPC_GIFT_RECEIVED_HM04",
"NPC_GIFT_RECEIVED_HM05",
"NPC_GIFT_RECEIVED_HM06",
"NPC_GIFT_RECEIVED_HM07",
"NPC_GIFT_RECEIVED_HM08",
"NPC_GIFT_RECEIVED_HM_CUT",
"NPC_GIFT_RECEIVED_HM_FLY",
"NPC_GIFT_RECEIVED_HM_SURF",
"NPC_GIFT_RECEIVED_HM_STRENGTH",
"NPC_GIFT_RECEIVED_HM_FLASH",
"NPC_GIFT_RECEIVED_HM_ROCK_SMASH",
"NPC_GIFT_RECEIVED_HM_WATERFALL",
"NPC_GIFT_RECEIVED_HM_DIVE",
"NPC_GIFT_RECEIVED_ACRO_BIKE",
"NPC_GIFT_RECEIVED_WAILMER_PAIL",
"NPC_GIFT_RECEIVED_DEVON_GOODS_RUSTURF_TUNNEL",
@@ -70,7 +79,7 @@ KEY_LOCATION_FLAGS = [
"HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY",
"HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY",
"HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY",
"ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_4_SCANNER",
"ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER",
"ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY",
"NPC_GIFT_RECEIVED_OLD_ROD",
"NPC_GIFT_RECEIVED_GOOD_ROD",
@@ -78,6 +87,24 @@ KEY_LOCATION_FLAGS = [
]
KEY_LOCATION_FLAG_MAP = {data.locations[location_name].flag: location_name for location_name in KEY_LOCATION_FLAGS}
LEGENDARY_NAMES = {
"Groudon": "GROUDON",
"Kyogre": "KYOGRE",
"Rayquaza": "RAYQUAZA",
"Latias": "LATIAS",
"Latios": "LATIOS",
"Regirock": "REGIROCK",
"Regice": "REGICE",
"Registeel": "REGISTEEL",
"Mew": "MEW",
"Deoxys": "DEOXYS",
"Ho-oh": "HO_OH",
"Lugia": "LUGIA",
}
DEFEATED_LEGENDARY_FLAG_MAP = {data.constants[f"FLAG_DEFEATED_{name}"]: name for name in LEGENDARY_NAMES.values()}
CAUGHT_LEGENDARY_FLAG_MAP = {data.constants[f"FLAG_CAUGHT_{name}"]: name for name in LEGENDARY_NAMES.values()}
class PokemonEmeraldClient(BizHawkClient):
game = "Pokemon Emerald"
@@ -86,14 +113,31 @@ class PokemonEmeraldClient(BizHawkClient):
local_checked_locations: Set[int]
local_set_events: Dict[str, bool]
local_found_key_items: Dict[str, bool]
goal_flag: int
local_defeated_legendaries: Dict[str, bool]
goal_flag: Optional[int]
wonder_trade_update_event: asyncio.Event
latest_wonder_trade_reply: dict
wonder_trade_cooldown: int
wonder_trade_cooldown_timer: int
death_counter: Optional[int]
previous_death_link: float
ignore_next_death_link: bool
def __init__(self) -> None:
super().__init__()
self.local_checked_locations = set()
self.local_set_events = {}
self.local_found_key_items = {}
self.goal_flag = IS_CHAMPION_FLAG
self.local_defeated_legendaries = {}
self.goal_flag = None
self.wonder_trade_update_event = asyncio.Event()
self.wonder_trade_cooldown = 5000
self.wonder_trade_cooldown_timer = 0
self.death_counter = None
self.previous_death_link = 0
self.ignore_next_death_link = False
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
from CommonClient import logger
@@ -123,88 +167,103 @@ class PokemonEmeraldClient(BizHawkClient):
ctx.want_slot_data = True
ctx.watcher_timeout = 0.125
self.death_counter = None
self.previous_death_link = 0
self.ignore_next_death_link = False
return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
slot_name_bytes = (await bizhawk.read(ctx.bizhawk_ctx, [(data.rom_addresses["gArchipelagoInfo"], 64, "ROM")]))[0]
ctx.auth = bytes([byte for byte in slot_name_bytes if byte != 0]).decode("utf-8")
import base64
auth_raw = (await bizhawk.read(ctx.bizhawk_ctx, [(data.rom_addresses["gArchipelagoInfo"], 16, "ROM")]))[0]
ctx.auth = base64.b64encode(auth_raw).decode("utf-8")
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.slot_data is not None:
if ctx.slot_data["goal"] == Goal.option_champion:
self.goal_flag = IS_CHAMPION_FLAG
elif ctx.slot_data["goal"] == Goal.option_steven:
self.goal_flag = DEFEATED_STEVEN_FLAG
elif ctx.slot_data["goal"] == Goal.option_norman:
self.goal_flag = DEFEATED_NORMAN_FLAG
if ctx.server is None or ctx.server.socket.closed or ctx.slot_data is None:
return
if ctx.slot_data["goal"] == Goal.option_champion:
self.goal_flag = DEFEATED_WALLACE_FLAG
elif ctx.slot_data["goal"] == Goal.option_steven:
self.goal_flag = DEFEATED_STEVEN_FLAG
elif ctx.slot_data["goal"] == Goal.option_norman:
self.goal_flag = DEFEATED_NORMAN_FLAG
elif ctx.slot_data["goal"] == Goal.option_legendary_hunt:
self.goal_flag = None
if ctx.slot_data["remote_items"] == RemoteItems.option_true and not ctx.items_handling & 0b010:
ctx.items_handling = 0b011
Utils.async_start(ctx.send_msgs([{
"cmd": "ConnectUpdate",
"items_handling": ctx.items_handling
}]))
try:
guards: Dict[str, Tuple[int, bytes, str]] = {}
# Checks that the player is in the overworld
overworld_guard = (data.ram_addresses["gMain"] + 4, (data.ram_addresses["CB2_Overworld"] + 1).to_bytes(4, "little"), "System Bus")
# Read save block address
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx,
[(data.ram_addresses["gSaveBlock1Ptr"], 4, "System Bus")],
[overworld_guard]
guards["IN OVERWORLD"] = (
data.ram_addresses["gMain"] + 4,
(data.ram_addresses["CB2_Overworld"] + 1).to_bytes(4, "little"),
"System Bus"
)
if read_result is None: # Not in overworld
return
# Checks that the save block hasn't moved
save_block_address_guard = (data.ram_addresses["gSaveBlock1Ptr"], read_result[0], "System Bus")
save_block_address = int.from_bytes(read_result[0], "little")
# Handle giving the player items
read_result = await bizhawk.guarded_read(
# Read save block addresses
read_result = await bizhawk.read(
ctx.bizhawk_ctx,
[
(save_block_address + 0x3778, 2, "System Bus"), # Number of received items
(data.ram_addresses["gArchipelagoReceivedItem"] + 4, 1, "System Bus") # Received item struct full?
],
[overworld_guard, save_block_address_guard]
(data.ram_addresses["gSaveBlock1Ptr"], 4, "System Bus"),
(data.ram_addresses["gSaveBlock2Ptr"], 4, "System Bus"),
]
)
if read_result is None: # Not in overworld, or save block moved
return
num_received_items = int.from_bytes(read_result[0], "little")
received_item_is_empty = read_result[1][0] == 0
# Checks that the save data hasn't moved
guards["SAVE BLOCK 1"] = (data.ram_addresses["gSaveBlock1Ptr"], read_result[0], "System Bus")
guards["SAVE BLOCK 2"] = (data.ram_addresses["gSaveBlock2Ptr"], read_result[1], "System Bus")
# If the game hasn't received all items yet and the received item struct doesn't contain an item, then
# fill it with the next item
if num_received_items < len(ctx.items_received) and received_item_is_empty:
next_item = ctx.items_received[num_received_items]
await bizhawk.write(ctx.bizhawk_ctx, [
(data.ram_addresses["gArchipelagoReceivedItem"] + 0, (next_item.item - BASE_OFFSET).to_bytes(2, "little"), "System Bus"),
(data.ram_addresses["gArchipelagoReceivedItem"] + 2, (num_received_items + 1).to_bytes(2, "little"), "System Bus"),
(data.ram_addresses["gArchipelagoReceivedItem"] + 4, [1], "System Bus"), # Mark struct full
(data.ram_addresses["gArchipelagoReceivedItem"] + 5, [next_item.flags & 1], "System Bus"),
])
sb1_address = int.from_bytes(guards["SAVE BLOCK 1"][1], "little")
sb2_address = int.from_bytes(guards["SAVE BLOCK 2"][1], "little")
await self.handle_death_link(ctx, guards)
await self.handle_received_items(ctx, guards)
await self.handle_wonder_trade(ctx, guards)
# Read flags in 2 chunks
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx,
[(save_block_address + 0x1450, 0x96, "System Bus")], # Flags
[overworld_guard, save_block_address_guard]
[(sb1_address + 0x1450, 0x96, "System Bus")], # Flags
[guards["IN OVERWORLD"], guards["SAVE BLOCK 1"]]
)
if read_result is None: # Not in overworld, or save block moved
return
flag_bytes = read_result[0]
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx,
[(save_block_address + 0x14E6, 0x96, "System Bus")], # Flags
[overworld_guard, save_block_address_guard]
[(sb1_address + 0x14E6, 0x96, "System Bus")], # Flags continued
[guards["IN OVERWORLD"], guards["SAVE BLOCK 1"]]
)
if read_result is not None:
flag_bytes += read_result[0]
# Read pokedex flags
pokedex_caught_bytes = bytes(0)
if ctx.slot_data["dexsanity"] == Toggle.option_true:
# Read pokedex flags
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx,
[(sb2_address + 0x28, 0x34, "System Bus")],
[guards["IN OVERWORLD"], guards["SAVE BLOCK 2"]]
)
if read_result is not None:
pokedex_caught_bytes = read_result[0]
game_clear = False
local_checked_locations = set()
local_set_events = {flag_name: False for flag_name in TRACKER_EVENT_FLAGS}
local_found_key_items = {location_name: False for location_name in KEY_LOCATION_FLAGS}
defeated_legendaries = {legendary_name: False for legendary_name in LEGENDARY_NAMES.values()}
caught_legendaries = {legendary_name: False for legendary_name in LEGENDARY_NAMES.values()}
# Check set flags
for byte_i, byte in enumerate(flag_bytes):
@@ -219,12 +278,45 @@ class PokemonEmeraldClient(BizHawkClient):
if flag_id == self.goal_flag:
game_clear = True
if flag_id in DEFEATED_LEGENDARY_FLAG_MAP:
defeated_legendaries[DEFEATED_LEGENDARY_FLAG_MAP[flag_id]] = True
if flag_id in CAUGHT_LEGENDARY_FLAG_MAP:
caught_legendaries[CAUGHT_LEGENDARY_FLAG_MAP[flag_id]] = True
if flag_id in EVENT_FLAG_MAP:
local_set_events[EVENT_FLAG_MAP[flag_id]] = True
if flag_id in KEY_LOCATION_FLAG_MAP:
local_found_key_items[KEY_LOCATION_FLAG_MAP[flag_id]] = True
# Check pokedex
if ctx.slot_data["dexsanity"] == Toggle.option_true:
for byte_i, byte in enumerate(pokedex_caught_bytes):
for i in range(8):
if byte & (1 << i) != 0:
dex_number = (byte_i * 8 + i) + 1
location_id = dex_number + BASE_OFFSET + POKEDEX_OFFSET
if location_id in ctx.server_locations:
local_checked_locations.add(location_id)
# Count legendary hunt flags
if ctx.slot_data["goal"] == Goal.option_legendary_hunt:
# If legendary hunt doesn't require catching, add defeated legendaries to caught_legendaries
if ctx.slot_data["legendary_hunt_catch"] == Toggle.option_false:
for legendary, is_defeated in defeated_legendaries.items():
if is_defeated:
caught_legendaries[legendary] = True
num_caught = 0
for legendary, is_caught in caught_legendaries.items():
if is_caught and legendary in [LEGENDARY_NAMES[name] for name in ctx.slot_data["allowed_legendary_hunt_encounters"]]:
num_caught += 1
if num_caught >= ctx.slot_data["legendary_hunt_count"]:
game_clear = True
# Send locations
if local_checked_locations != self.local_checked_locations:
self.local_checked_locations = local_checked_locations
@@ -232,14 +324,14 @@ class PokemonEmeraldClient(BizHawkClient):
if local_checked_locations is not None:
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": list(local_checked_locations)
"locations": list(local_checked_locations),
}])
# Send game clear
if not ctx.finished_game and game_clear:
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL
"status": ClientStatus.CLIENT_GOAL,
}])
# Send tracker event flags
@@ -254,7 +346,7 @@ class PokemonEmeraldClient(BizHawkClient):
"key": f"pokemon_emerald_events_{ctx.team}_{ctx.slot}",
"default": 0,
"want_reply": False,
"operations": [{"operation": "or", "value": event_bitfield}]
"operations": [{"operation": "or", "value": event_bitfield}],
}])
self.local_set_events = local_set_events
@@ -269,9 +361,313 @@ class PokemonEmeraldClient(BizHawkClient):
"key": f"pokemon_emerald_keys_{ctx.team}_{ctx.slot}",
"default": 0,
"want_reply": False,
"operations": [{"operation": "or", "value": key_bitfield}]
"operations": [{"operation": "or", "value": key_bitfield}],
}])
self.local_found_key_items = local_found_key_items
if ctx.slot_data["goal"] == Goal.option_legendary_hunt:
if caught_legendaries != self.local_defeated_legendaries and ctx.slot is not None:
legendary_bitfield = 0
for i, legendary_name in enumerate(LEGENDARY_NAMES.values()):
if caught_legendaries[legendary_name]:
legendary_bitfield |= 1 << i
await ctx.send_msgs([{
"cmd": "Set",
"key": f"pokemon_emerald_legendaries_{ctx.team}_{ctx.slot}",
"default": 0,
"want_reply": False,
"operations": [{"operation": "or", "value": legendary_bitfield}],
}])
self.local_defeated_legendaries = caught_legendaries
except bizhawk.RequestFailedError:
# Exit handler and return to main loop to reconnect
pass
async def handle_death_link(self, ctx: "BizHawkClientContext", guards: Dict[str, Tuple[int, bytes, str]]) -> None:
"""
Checks whether the player has died while connected and sends a death link if so. Queues a death link in the game
if a new one has been received.
"""
if ctx.slot_data.get("death_link", Toggle.option_false) == Toggle.option_true:
if "DeathLink" not in ctx.tags:
await ctx.update_death_link(True)
self.previous_death_link = ctx.last_death_link
sb1_address = int.from_bytes(guards["SAVE BLOCK 1"][1], "little")
sb2_address = int.from_bytes(guards["SAVE BLOCK 2"][1], "little")
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx, [
(sb1_address + 0x177C + (52 * 4), 4, "System Bus"), # White out stat
(sb1_address + 0x177C + (22 * 4), 4, "System Bus"), # Canary stat
(sb2_address + 0xAC, 4, "System Bus"), # Encryption key
],
[guards["SAVE BLOCK 1"], guards["SAVE BLOCK 2"]]
)
if read_result is None: # Save block moved
return
encryption_key = int.from_bytes(read_result[2], "little")
times_whited_out = int.from_bytes(read_result[0], "little") ^ encryption_key
# Canary is an unused stat that will always be 0. There is a low chance that we've done this read on
# a frame where the user has just entered a battle and the encryption key has been changed, but the data
# has not yet been encrypted with the new key. If `canary` is 0, `times_whited_out` is correct.
canary = int.from_bytes(read_result[1], "little") ^ encryption_key
# Skip all deathlink code if save is not yet loaded (encryption key is zero) or white out stat not yet
# initialized (starts at 100 as a safety for subtracting values from an unsigned int).
if canary == 0 and encryption_key != 0 and times_whited_out >= 100:
if self.previous_death_link != ctx.last_death_link:
self.previous_death_link = ctx.last_death_link
if self.ignore_next_death_link:
self.ignore_next_death_link = False
else:
await bizhawk.write(
ctx.bizhawk_ctx,
[(data.ram_addresses["gArchipelagoDeathLinkQueued"], [1], "System Bus")]
)
if self.death_counter is None:
self.death_counter = times_whited_out
elif times_whited_out > self.death_counter:
await ctx.send_death(f"{ctx.player_names[ctx.slot]} is out of usable POKéMON! "
f"{ctx.player_names[ctx.slot]} whited out!")
self.ignore_next_death_link = True
self.death_counter = times_whited_out
async def handle_received_items(self, ctx: "BizHawkClientContext", guards: Dict[str, Tuple[int, bytes, str]]) -> None:
"""
Checks the index of the most recently received item and whether the item queue is full. Writes the next item
into the game if necessary.
"""
received_item_address = data.ram_addresses["gArchipelagoReceivedItem"]
sb1_address = int.from_bytes(guards["SAVE BLOCK 1"][1], "little")
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx,
[
(sb1_address + 0x3778, 2, "System Bus"), # Number of received items
(received_item_address + 4, 1, "System Bus") # Received item struct full?
],
[guards["IN OVERWORLD"], guards["SAVE BLOCK 1"]]
)
if read_result is None: # Not in overworld, or save block moved
return
num_received_items = int.from_bytes(read_result[0], "little")
received_item_is_empty = read_result[1][0] == 0
# If the game hasn't received all items yet and the received item struct doesn't contain an item, then
# fill it with the next item
if num_received_items < len(ctx.items_received) and received_item_is_empty:
next_item = ctx.items_received[num_received_items]
should_display = 1 if next_item.flags & 1 or next_item.player == ctx.slot else 0
await bizhawk.write(ctx.bizhawk_ctx, [
(received_item_address + 0, (next_item.item - BASE_OFFSET).to_bytes(2, "little"), "System Bus"),
(received_item_address + 2, (num_received_items + 1).to_bytes(2, "little"), "System Bus"),
(received_item_address + 4, [1], "System Bus"),
(received_item_address + 5, [should_display], "System Bus"),
])
async def handle_wonder_trade(self, ctx: "BizHawkClientContext", guards: Dict[str, Tuple[int, bytes, str]]) -> None:
"""
Read wonder trade status from save data and either send a queued pokemon to data storage or attempt to retrieve
one from data storage and write it into the save.
"""
from CommonClient import logger
sb1_address = int.from_bytes(guards["SAVE BLOCK 1"][1], "little")
read_result = await bizhawk.guarded_read(
ctx.bizhawk_ctx,
[
(sb1_address + 0x377C, 0x50, "System Bus"), # Wonder trade data
(sb1_address + 0x37CC, 1, "System Bus"), # Is wonder trade sent
],
[guards["IN OVERWORLD"], guards["SAVE BLOCK 1"]]
)
if read_result is not None:
wonder_trade_pokemon_data = read_result[0]
trade_is_sent = read_result[1][0]
if trade_is_sent == 0 and wonder_trade_pokemon_data[19] == 2:
# Game has wonder trade data to send. Send it to data storage, remove it from the game's memory,
# and mark that the game is waiting on receiving a trade
Utils.async_start(self.wonder_trade_send(ctx, pokemon_data_to_json(wonder_trade_pokemon_data)))
await bizhawk.write(ctx.bizhawk_ctx, [
(sb1_address + 0x377C, bytes(0x50), "System Bus"),
(sb1_address + 0x37CC, [1], "System Bus"),
])
elif trade_is_sent != 0 and wonder_trade_pokemon_data[19] != 2:
# Game is waiting on receiving a trade. See if there are any available trades that were not
# sent by this player, and if so, try to receive one.
if self.wonder_trade_cooldown_timer <= 0 and f"pokemon_wonder_trades_{ctx.team}" in ctx.stored_data:
if any(item[0] != ctx.slot
for key, item in ctx.stored_data.get(f"pokemon_wonder_trades_{ctx.team}", {}).items()
if key != "_lock" and orjson.loads(item[1])["species"] <= 386):
received_trade = await self.wonder_trade_receive(ctx)
if received_trade is None:
self.wonder_trade_cooldown_timer = self.wonder_trade_cooldown
self.wonder_trade_cooldown *= 2
self.wonder_trade_cooldown += random.randrange(0, 500)
else:
await bizhawk.write(ctx.bizhawk_ctx, [
(sb1_address + 0x377C, json_to_pokemon_data(received_trade), "System Bus"),
])
logger.info("Wonder trade received!")
self.wonder_trade_cooldown = 5000
else:
# Very approximate "time since last loop", but extra delay is fine for this
self.wonder_trade_cooldown_timer -= int(ctx.watcher_timeout * 1000)
async def wonder_trade_acquire(self, ctx: "BizHawkClientContext", keep_trying: bool = False) -> Optional[dict]:
"""
Acquires a lock on the `pokemon_wonder_trades_{ctx.team}` key in
datastorage. Locking the key means you have exclusive access
to modifying the value until you unlock it or the key expires (5
seconds).
If `keep_trying` is `True`, it will keep trying to acquire the lock
until successful. Otherwise it will return `None` if it fails to
acquire the lock.
"""
while not ctx.exit_event.is_set():
lock = int(time.time_ns() / 1000000)
message_uuid = str(uuid.uuid4())
await ctx.send_msgs([{
"cmd": "Set",
"key": f"pokemon_wonder_trades_{ctx.team}",
"default": {"_lock": 0},
"want_reply": True,
"operations": [{"operation": "update", "value": {"_lock": lock}}],
"uuid": message_uuid,
}])
self.wonder_trade_update_event.clear()
try:
await asyncio.wait_for(self.wonder_trade_update_event.wait(), 5)
except asyncio.TimeoutError:
if not keep_trying:
return None
continue
reply = copy.deepcopy(self.latest_wonder_trade_reply)
# Make sure the most recently received update was triggered by our lock attempt
if reply.get("uuid", None) != message_uuid:
if not keep_trying:
return None
await asyncio.sleep(self.wonder_trade_cooldown)
continue
# Make sure the current value of the lock is what we set it to
# (I think this should theoretically never run)
if reply["value"]["_lock"] != lock:
if not keep_trying:
return None
await asyncio.sleep(self.wonder_trade_cooldown)
continue
# Make sure that the lock value we replaced is at least 5 seconds old
# If it was unlocked before our change, its value was 0 and it will look decades old
if lock - reply["original_value"]["_lock"] < 5000:
# Multiple clients trying to lock the key may get stuck in a loop of checking the lock
# by trying to set it, which will extend its expiration. So if we see that the lock was
# too new when we replaced it, we should wait for increasingly longer periods so that
# eventually the lock will expire and a client will acquire it.
self.wonder_trade_cooldown *= 2
self.wonder_trade_cooldown += random.randrange(0, 500)
if not keep_trying:
self.wonder_trade_cooldown_timer = self.wonder_trade_cooldown
return None
await asyncio.sleep(self.wonder_trade_cooldown)
continue
# We have the lock, reset the cooldown and return
self.wonder_trade_cooldown = 5000
return reply
async def wonder_trade_send(self, ctx: "BizHawkClientContext", data: str) -> None:
"""
Sends a wonder trade pokemon to data storage
"""
from CommonClient import logger
reply = await self.wonder_trade_acquire(ctx, True)
wonder_trade_slot = 0
while str(wonder_trade_slot) in reply["value"]:
wonder_trade_slot += 1
await ctx.send_msgs([{
"cmd": "Set",
"key": f"pokemon_wonder_trades_{ctx.team}",
"default": {"_lock": 0},
"operations": [{"operation": "update", "value": {
"_lock": 0,
str(wonder_trade_slot): (ctx.slot, data),
}}],
}])
logger.info("Wonder trade sent! We'll notify you here when a trade has been found.")
async def wonder_trade_receive(self, ctx: "BizHawkClientContext") -> Optional[str]:
"""
Tries to pop a pokemon out of the wonder trades. Returns `None` if
for some reason it can't immediately remove a compatible pokemon.
"""
reply = await self.wonder_trade_acquire(ctx)
if reply is None:
return None
candidate_slots = [
int(slot)
for slot in reply["value"]
if slot != "_lock" \
and reply["value"][slot][0] != ctx.slot \
and orjson.loads(reply["value"][slot][1])["species"] <= 386
]
if len(candidate_slots) == 0:
await ctx.send_msgs([{
"cmd": "Set",
"key": f"pokemon_wonder_trades_{ctx.team}",
"default": {"_lock": 0},
"operations": [{"operation": "update", "value": {"_lock": 0}}],
}])
return None
wonder_trade_slot = max(candidate_slots)
await ctx.send_msgs([{
"cmd": "Set",
"key": f"pokemon_wonder_trades_{ctx.team}",
"default": {"_lock": 0},
"operations": [
{"operation": "update", "value": {"_lock": 0}},
{"operation": "pop", "value": str(wonder_trade_slot)},
]
}])
return reply["value"][str(wonder_trade_slot)][1]
def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict) -> None:
if cmd == "Connected":
Utils.async_start(ctx.send_msgs([{
"cmd": "SetNotify",
"keys": [f"pokemon_wonder_trades_{ctx.team}"],
}, {
"cmd": "Get",
"keys": [f"pokemon_wonder_trades_{ctx.team}"],
}]))
elif cmd == "SetReply":
if args.get("key", "") == f"pokemon_wonder_trades_{ctx.team}":
self.latest_wonder_trade_reply = args
self.wonder_trade_update_event.set()

File diff suppressed because it is too large Load Diff

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