mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-25 02:13:24 -07:00
Merge branch 'main' into tunc-portal-direction-pairing
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
||||
|
||||
2
.github/pyright-config.json
vendored
2
.github/pyright-config.json
vendored
@@ -16,7 +16,7 @@
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": true,
|
||||
|
||||
"pythonVersion": "3.8",
|
||||
"pythonVersion": "3.10",
|
||||
"pythonPlatform": "Windows",
|
||||
|
||||
"executionEnvironments": [
|
||||
|
||||
2
.github/workflows/analyze-modified-files.yml
vendored
2
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: '3.10'
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -24,14 +24,15 @@ env:
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
build-win: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
@@ -111,10 +112,11 @@ jobs:
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -44,10 +44,11 @@ jobs:
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
|
||||
6
.github/workflows/unittests.yml
vendored
6
.github/workflows/unittests.yml
vendored
@@ -33,13 +33,11 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
- python: {version: '3.10'} # old compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
os: windows-latest
|
||||
@@ -55,7 +53,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pytest-subtests pytest-xdist
|
||||
pip install pytest "pytest-subtests<0.14.0" pytest-xdist
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||
- name: Unittests
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import functools
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, Type)
|
||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
@@ -20,7 +18,7 @@ import NetUtils
|
||||
import Options
|
||||
import Utils
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
@@ -231,7 +229,7 @@ class MultiWorld():
|
||||
for player in self.player_ids:
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||
self.worlds[player] = world_type(self, player)
|
||||
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
||||
options_dataclass: 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})
|
||||
|
||||
@@ -606,6 +604,49 @@ class MultiWorld():
|
||||
state.collect(location.item, True, location)
|
||||
locations -= sphere
|
||||
|
||||
def get_sendable_spheres(self) -> Iterator[Set[Location]]:
|
||||
"""
|
||||
yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
|
||||
|
||||
If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
|
||||
and then a set of all of the unreachable locations.
|
||||
"""
|
||||
state = CollectionState(self)
|
||||
locations: Set[Location] = set()
|
||||
events: Set[Location] = set()
|
||||
for location in self.get_filled_locations():
|
||||
if type(location.item.code) is int:
|
||||
locations.add(location)
|
||||
else:
|
||||
events.add(location)
|
||||
|
||||
while locations:
|
||||
sphere: Set[Location] = set()
|
||||
|
||||
# cull events out
|
||||
done_events: Set[Union[Location, None]] = {None}
|
||||
while done_events:
|
||||
done_events = set()
|
||||
for event in events:
|
||||
if event.can_reach(state):
|
||||
state.collect(event.item, True, event)
|
||||
done_events.add(event)
|
||||
events -= done_events
|
||||
|
||||
for location in locations:
|
||||
if location.can_reach(state):
|
||||
sphere.add(location)
|
||||
|
||||
yield sphere
|
||||
if not sphere:
|
||||
if locations:
|
||||
yield locations # unreachable locations
|
||||
break
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
locations -= sphere
|
||||
|
||||
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||
if not state:
|
||||
@@ -975,7 +1016,7 @@ class Region:
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
||||
entrance_type: ClassVar[type[Entrance]] = Entrance
|
||||
|
||||
class Register(MutableSequence):
|
||||
region_manager: MultiWorld.RegionManager
|
||||
@@ -1075,7 +1116,7 @@ class Region:
|
||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
|
||||
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||
location_type: Optional[Type[Location]] = None) -> None:
|
||||
location_type: Optional[type[Location]] = None) -> None:
|
||||
"""
|
||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||
location names to address.
|
||||
@@ -1112,7 +1153,7 @@ class Region:
|
||||
return exit_
|
||||
|
||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||
"""
|
||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||
|
||||
@@ -1122,10 +1163,14 @@ class Region:
|
||||
"""
|
||||
if not isinstance(exits, Dict):
|
||||
exits = dict.fromkeys(exits)
|
||||
for connecting_region, name in exits.items():
|
||||
self.connect(self.multiworld.get_region(connecting_region, self.player),
|
||||
name,
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
return [
|
||||
self.connect(
|
||||
self.multiworld.get_region(connecting_region, self.player),
|
||||
name,
|
||||
rules[connecting_region] if rules and connecting_region in rules else None,
|
||||
)
|
||||
for connecting_region, name in exits.items()
|
||||
]
|
||||
|
||||
def __repr__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
@@ -1264,6 +1309,10 @@ class Item:
|
||||
def trap(self) -> bool:
|
||||
return ItemClassification.trap in self.classification
|
||||
|
||||
@property
|
||||
def filler(self) -> bool:
|
||||
return not (self.advancement or self.useful or self.trap)
|
||||
|
||||
@property
|
||||
def excludable(self) -> bool:
|
||||
return not (self.advancement or self.useful)
|
||||
@@ -1386,14 +1435,21 @@ class Spoiler:
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected: List[Item] = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
multiworld.state.remove(item)
|
||||
if not multiworld.can_beat_game():
|
||||
multiworld.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
for precollected_items in multiworld.precollected_items.values():
|
||||
# The list of items is mutated by removing one item at a time to determine if each item is required to beat
|
||||
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
|
||||
for item in precollected_items.copy():
|
||||
if not item.advancement:
|
||||
continue
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
precollected_items.remove(item)
|
||||
multiworld.state.remove(item)
|
||||
if not multiworld.can_beat_game():
|
||||
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
||||
multiworld.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
@@ -1532,7 +1588,7 @@ class Spoiler:
|
||||
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
||||
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
||||
if self.unreachables:
|
||||
outfile.write('\n\nUnreachable Items:\n\n')
|
||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||
outfile.write(
|
||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ if __name__ == "__main__":
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
@@ -412,6 +412,7 @@ class CommonContext:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
self.ui.update_hints()
|
||||
|
||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||
""" `msgs` JSON serializable """
|
||||
@@ -551,7 +552,14 @@ class CommonContext:
|
||||
await self.ui_task
|
||||
if self.input_task:
|
||||
self.input_task.cancel()
|
||||
|
||||
|
||||
# Hints
|
||||
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
|
||||
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
|
||||
if status is not None:
|
||||
msg["status"] = status
|
||||
async_start(self.send_msgs([msg]), name="update_hint")
|
||||
|
||||
# DataPackage
|
||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||
remote_date_package_versions: typing.Dict[str, int],
|
||||
|
||||
59
Fill.py
59
Fill.py
@@ -36,7 +36,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
|
||||
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
||||
name: str = "Unknown") -> None:
|
||||
"""
|
||||
:param multiworld: Multiworld to be filled.
|
||||
:param base_state: State assumed before fill.
|
||||
@@ -63,14 +64,22 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
placed = 0
|
||||
|
||||
while any(reachable_items.values()) and locations:
|
||||
# grab one item per player
|
||||
items_to_place = [items.pop()
|
||||
for items in reachable_items.values() if items]
|
||||
if one_item_per_player:
|
||||
# grab one item per player
|
||||
items_to_place = [items.pop()
|
||||
for items in reachable_items.values() if items]
|
||||
else:
|
||||
next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
|
||||
items_to_place = []
|
||||
if item_pool:
|
||||
items_to_place.append(reachable_items[next_player].pop())
|
||||
|
||||
for item in items_to_place:
|
||||
for p, pool_item in enumerate(item_pool):
|
||||
if pool_item is item:
|
||||
item_pool.pop(p)
|
||||
break
|
||||
|
||||
maximum_exploration_state = sweep_from_pool(
|
||||
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
@@ -480,7 +489,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||
name="Priority", one_item_per_player=False)
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
@@ -978,15 +988,32 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
multiworld.random.shuffle(items)
|
||||
count = 0
|
||||
err: typing.List[str] = []
|
||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
||||
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
||||
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
||||
for item_name in items:
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
index_to_delete: typing.Optional[int] = None
|
||||
if from_pool:
|
||||
try:
|
||||
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
||||
index_to_delete, item = next(
|
||||
(i, item) for i, item in enumerate(multiworld.itempool)
|
||||
if item.player == player and item.name == item_name and i not in claimed_indices
|
||||
)
|
||||
except StopIteration:
|
||||
warn(
|
||||
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||
placement['force'])
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
else:
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
|
||||
for location in reversed(candidates):
|
||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
||||
if not location.item:
|
||||
if location.item_rule(item):
|
||||
if location.can_fill(multiworld.state, item, False):
|
||||
successful_pairs.append((item, location))
|
||||
successful_pairs.append((index_to_delete, item, location))
|
||||
claimed_indices.add(index_to_delete)
|
||||
candidates.remove(location)
|
||||
count = count + 1
|
||||
break
|
||||
@@ -998,6 +1025,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||
else:
|
||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
||||
|
||||
if count == maxcount:
|
||||
break
|
||||
if count < placement['count']['min']:
|
||||
@@ -1005,17 +1033,16 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
failed(
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
||||
placement['force'])
|
||||
for (item, location) in successful_pairs:
|
||||
|
||||
# Sort indices in reverse so we can remove them one by one
|
||||
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
||||
|
||||
for (index, item, location) in successful_pairs:
|
||||
multiworld.push_item(location, item, collect=False)
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
if from_pool:
|
||||
try:
|
||||
multiworld.itempool.remove(item)
|
||||
except ValueError:
|
||||
warn(
|
||||
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||
placement['force'])
|
||||
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
||||
multiworld.itempool.pop(index)
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
|
||||
@@ -114,7 +114,14 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
weights_for_file = []
|
||||
for doc_idx, yaml in enumerate(read_weights_yamls(path)):
|
||||
if yaml is None:
|
||||
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
|
||||
else:
|
||||
weights_for_file.append(yaml)
|
||||
weights_cache[fname] = tuple(weights_for_file)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
|
||||
|
||||
65
Launcher.py
65
Launcher.py
@@ -126,12 +126,13 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
||||
if client_component is None:
|
||||
run_component(text_client_component, *launch_args)
|
||||
return
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Window
|
||||
|
||||
class Popup(App):
|
||||
timer_label: Label
|
||||
remaining_time: Optional[int]
|
||||
|
||||
def __init__(self):
|
||||
self.title = "Connect to Multiworld"
|
||||
self.icon = r"data/icon.png"
|
||||
@@ -139,48 +140,25 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
|
||||
def build(self):
|
||||
layout = BoxLayout(orientation="vertical")
|
||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||
|
||||
if client_component is None:
|
||||
self.remaining_time = 7
|
||||
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
||||
f"Launching Text Client in 7 seconds...")
|
||||
self.timer_label = Label(text=label_text)
|
||||
layout.add_widget(self.timer_label)
|
||||
Clock.schedule_interval(self.update_label, 1)
|
||||
else:
|
||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||
text_client_button = Button(
|
||||
text=text_client_component.display_name,
|
||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(text_client_button)
|
||||
|
||||
text_client_button = Button(
|
||||
text=text_client_component.display_name,
|
||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(text_client_button)
|
||||
game_client_button = Button(
|
||||
text=client_component.display_name,
|
||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(game_client_button)
|
||||
|
||||
game_client_button = Button(
|
||||
text=client_component.display_name,
|
||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(game_client_button)
|
||||
|
||||
layout.add_widget(button_row)
|
||||
layout.add_widget(button_row)
|
||||
|
||||
return layout
|
||||
|
||||
def update_label(self, dt):
|
||||
if self.remaining_time > 1:
|
||||
# countdown the timer and string replace the number
|
||||
self.remaining_time -= 1
|
||||
self.timer_label.text = self.timer_label.text.replace(
|
||||
str(self.remaining_time + 1), str(self.remaining_time)
|
||||
)
|
||||
else:
|
||||
# our timer is finished so launch text client and close down
|
||||
run_component(text_client_component, *launch_args)
|
||||
Clock.unschedule(self.update_label)
|
||||
App.get_running_app().stop()
|
||||
Window.close()
|
||||
|
||||
def _stop(self, *largs):
|
||||
# see run_gui Launcher _stop comment for details
|
||||
self.root_window.close()
|
||||
@@ -246,9 +224,8 @@ refresh_components: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
||||
from kivy.core.window import Window
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
class Launcher(App):
|
||||
@@ -281,8 +258,8 @@ def run_gui():
|
||||
button.component = component
|
||||
button.bind(on_release=self.component_action)
|
||||
if component.icon != "icon":
|
||||
image = AsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
image = ApAsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
||||
box_layout.add_widget(button)
|
||||
box_layout.add_widget(image)
|
||||
|
||||
90
Main.py
90
Main.py
@@ -153,45 +153,38 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
||||
new_items: List[Item] = []
|
||||
old_items: List[Item] = []
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options,
|
||||
"start_inventory_from_pool",
|
||||
StartInventoryPool({})).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
for player, items in depletion_pool.items():
|
||||
player_world: AutoWorld.World = multiworld.worlds[player]
|
||||
for count in items.values():
|
||||
for _ in range(count):
|
||||
new_items.append(player_world.create_filler())
|
||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
||||
for i, item in enumerate(multiworld.itempool):
|
||||
if depletion_pool[item.player].get(item.name, 0):
|
||||
target -= 1
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
# quick abort if we have found all items
|
||||
if not target:
|
||||
old_items.extend(multiworld.itempool[i+1:])
|
||||
break
|
||||
else:
|
||||
old_items.append(item)
|
||||
fallback_inventory = StartInventoryPool({})
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
target_per_player = {
|
||||
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
||||
}
|
||||
|
||||
# leftovers?
|
||||
if target:
|
||||
for player, remaining_items in depletion_pool.items():
|
||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||
if remaining_items:
|
||||
logger.warning(f"{multiworld.get_player_name(player)}"
|
||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||
# find all filler we generated for the current player and remove until it matches
|
||||
removables = [item for item in new_items if item.player == player]
|
||||
for _ in range(sum(remaining_items.values())):
|
||||
new_items.remove(removables.pop())
|
||||
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items + old_items
|
||||
if target_per_player:
|
||||
new_itempool: List[Item] = []
|
||||
|
||||
# Make new itempool with start_inventory_from_pool items removed
|
||||
for item in multiworld.itempool:
|
||||
if depletion_pool[item.player].get(item.name, 0):
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
|
||||
for player, target in target_per_player.items():
|
||||
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
|
||||
|
||||
if unfound_items:
|
||||
player_name = multiworld.get_player_name(player)
|
||||
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
|
||||
|
||||
needed_items = target_per_player[player] - sum(unfound_items.values())
|
||||
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
||||
|
||||
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_itempool
|
||||
|
||||
multiworld.link_items()
|
||||
|
||||
@@ -249,6 +242,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
from NetUtils import HintStatus
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
@@ -273,10 +267,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for slot in multiworld.player_ids:
|
||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||
|
||||
def precollect_hint(location):
|
||||
def precollect_hint(location: Location, auto_status: HintStatus):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False, entrance, location.item.flags)
|
||||
location.item.code, False, entrance, location.item.flags, auto_status)
|
||||
precollected_hints[location.player].add(hint)
|
||||
if location.item.player not in multiworld.groups:
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
@@ -289,19 +283,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if type(location.address) == int:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None. Location: " \
|
||||
f" {location}"
|
||||
f" {location}, Item: {location.item}"
|
||||
assert location.address not in locations_data[location.player], (
|
||||
f"Locations with duplicate address. {location} and "
|
||||
f"{locations_data[location.player][location.address]}")
|
||||
locations_data[location.player][location.address] = \
|
||||
location.item.code, location.item.player, location.item.flags
|
||||
auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
|
||||
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
||||
precollect_hint(location)
|
||||
if not location.item.trap: # Unspecified status for location hints, except traps
|
||||
auto_status = HintStatus.HINT_UNSPECIFIED
|
||||
precollect_hint(location, auto_status)
|
||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
||||
precollect_hint(location)
|
||||
precollect_hint(location, auto_status)
|
||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
precollect_hint(location, auto_status)
|
||||
|
||||
# embedded data package
|
||||
data_package = {
|
||||
@@ -313,11 +310,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# get spheres -> filter address==None -> skip empty
|
||||
spheres: List[Dict[int, Set[int]]] = []
|
||||
for sphere in multiworld.get_spheres():
|
||||
for sphere in multiworld.get_sendable_spheres():
|
||||
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
||||
for sphere_location in sphere:
|
||||
if type(sphere_location.address) is int:
|
||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||
|
||||
if current_sphere:
|
||||
spheres.append(dict(current_sphere))
|
||||
|
||||
@@ -5,8 +5,15 @@ import multiprocessing
|
||||
import warnings
|
||||
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
|
||||
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
|
||||
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
|
||||
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||
elif sys.version_info < (3, 10, 1):
|
||||
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
|
||||
|
||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
||||
|
||||
193
MultiServer.py
193
MultiServer.py
@@ -41,7 +41,8 @@ import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType, LocationStore
|
||||
SlotType, LocationStore, Hint, HintStatus
|
||||
from BaseClasses import ItemClassification
|
||||
|
||||
min_client_version = Version(0, 1, 6)
|
||||
colorama.init()
|
||||
@@ -228,7 +229,7 @@ class Context:
|
||||
self.hint_cost = hint_cost
|
||||
self.location_check_points = location_check_points
|
||||
self.hints_used = collections.defaultdict(int)
|
||||
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
|
||||
self.release_mode: str = release_mode
|
||||
self.remaining_mode: str = remaining_mode
|
||||
self.collect_mode: str = collect_mode
|
||||
@@ -656,13 +657,29 @@ class Context:
|
||||
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
return 0
|
||||
|
||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
|
||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
|
||||
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
|
||||
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
|
||||
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
|
||||
pair that has at least one hint modified will be added to the set.
|
||||
"""
|
||||
for hint_team, hint_slot in self.hints:
|
||||
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
|
||||
self.hints[hint_team, hint_slot] = {
|
||||
hint.re_check(self, hint_team) for hint in
|
||||
self.hints[hint_team, hint_slot]
|
||||
}
|
||||
if team != hint_team and team is not None:
|
||||
continue # Check specified team only, all if team is None
|
||||
if slot != hint_slot and slot is not None:
|
||||
continue # Check specified slot only, all if slot is None
|
||||
new_hints: typing.Set[Hint] = set()
|
||||
for hint in self.hints[hint_team, hint_slot]:
|
||||
new_hint = hint.re_check(self, hint_team)
|
||||
new_hints.add(new_hint)
|
||||
if hint == new_hint:
|
||||
continue
|
||||
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
|
||||
if changed is not None:
|
||||
changed.add((hint_team,player))
|
||||
if slot is not None and slot != player:
|
||||
self.replace_hint(hint_team, player, hint, new_hint)
|
||||
self.hints[hint_team, hint_slot] = new_hints
|
||||
|
||||
def get_rechecked_hints(self, team: int, slot: int):
|
||||
self.recheck_hints(team, slot)
|
||||
@@ -711,7 +728,7 @@ class Context:
|
||||
else:
|
||||
return self.player_names[team, slot]
|
||||
|
||||
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
|
||||
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||
recipients: typing.Sequence[int] = None):
|
||||
"""Send and remember hints."""
|
||||
if only_new:
|
||||
@@ -749,6 +766,17 @@ class Context:
|
||||
for client in clients:
|
||||
async_start(self.send_msgs(client, client_hints))
|
||||
|
||||
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
||||
for hint in self.hints[team, finding_player]:
|
||||
if hint.location == seeked_location:
|
||||
return hint
|
||||
return None
|
||||
|
||||
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
|
||||
if old_hint in self.hints[team, slot]:
|
||||
self.hints[team, slot].remove(old_hint)
|
||||
self.hints[team, slot].add(new_hint)
|
||||
|
||||
# "events"
|
||||
|
||||
def on_goal_achieved(self, client: Client):
|
||||
@@ -947,9 +975,13 @@ def get_status_string(ctx: Context, team: int, tag: str):
|
||||
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
||||
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
||||
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
||||
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
||||
status_text = (
|
||||
" and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
|
||||
" and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
|
||||
"."
|
||||
)
|
||||
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
||||
f"{tag_text}{goal_text} {completion_text}"
|
||||
f"{tag_text}{status_text} {completion_text}"
|
||||
return text
|
||||
|
||||
|
||||
@@ -1050,14 +1082,15 @@ 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)
|
||||
updated_slots: typing.Set[tuple[int, int]] = set()
|
||||
ctx.recheck_hints(team, slot, updated_slots)
|
||||
for hint_team, hint_slot in updated_slots:
|
||||
ctx.on_changed_hints(hint_team, hint_slot)
|
||||
ctx.save()
|
||||
|
||||
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
hints = []
|
||||
slots: typing.Set[int] = {slot}
|
||||
for group_id, group in ctx.groups.items():
|
||||
@@ -1067,31 +1100,58 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||
in ctx.locations.find_item(slots, seeked_item_id):
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
item_flags))
|
||||
prev_hint = ctx.get_hint(team, slot, location_id)
|
||||
if prev_hint:
|
||||
hints.append(prev_hint)
|
||||
else:
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
new_status = auto_status
|
||||
if found:
|
||||
new_status = HintStatus.HINT_FOUND
|
||||
elif item_flags & ItemClassification.trap:
|
||||
new_status = HintStatus.HINT_AVOID
|
||||
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
item_flags, new_status))
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||
|
||||
|
||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||
if prev_hint:
|
||||
return [prev_hint]
|
||||
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
||||
if any(result):
|
||||
item_id, receiving_player, item_flags = result
|
||||
|
||||
found = seeked_location in ctx.location_checks[team, slot]
|
||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
|
||||
new_status = auto_status
|
||||
if found:
|
||||
new_status = HintStatus.HINT_FOUND
|
||||
elif item_flags & ItemClassification.trap:
|
||||
new_status = HintStatus.HINT_AVOID
|
||||
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
||||
new_status)]
|
||||
return []
|
||||
|
||||
|
||||
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||
status_names: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "(found)",
|
||||
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||
HintStatus.HINT_AVOID: "(avoid)",
|
||||
HintStatus.HINT_PRIORITY: "(priority)",
|
||||
}
|
||||
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
|
||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
||||
@@ -1099,7 +1159,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||
|
||||
if hint.entrance:
|
||||
text += f" at {hint.entrance}"
|
||||
return text + (". (found)" if hint.found else ".")
|
||||
|
||||
return text + ". " + status_names.get(hint.status, "(unknown)")
|
||||
|
||||
|
||||
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||
@@ -1503,7 +1564,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||
|
||||
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
||||
if not input_text:
|
||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||
self.ctx.hints[self.client.team, self.client.slot]}
|
||||
@@ -1529,9 +1590,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||
hints = []
|
||||
elif not for_location:
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
else:
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
|
||||
else:
|
||||
game = self.ctx.games[self.client.slot]
|
||||
@@ -1551,16 +1612,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
hints = []
|
||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||
hints = []
|
||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||
if loc_name in self.ctx.location_names_for_game(game):
|
||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
|
||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
||||
else: # location name
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1832,13 +1893,56 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||
if create_as_hint:
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED))
|
||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||
if locs and create_as_hint:
|
||||
ctx.save()
|
||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||
|
||||
|
||||
elif cmd == 'UpdateHint':
|
||||
location = args["location"]
|
||||
player = args["player"]
|
||||
status = args["status"]
|
||||
if not isinstance(player, int) or not isinstance(location, int) \
|
||||
or (status is not None and not isinstance(status, int)):
|
||||
await ctx.send_msgs(client,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
hint = ctx.get_hint(client.team, player, location)
|
||||
if not hint:
|
||||
return # Ignored safely
|
||||
if hint.receiving_player != client.slot:
|
||||
await ctx.send_msgs(client,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
new_hint = hint
|
||||
if status is None:
|
||||
return
|
||||
try:
|
||||
status = HintStatus(status)
|
||||
except ValueError:
|
||||
await ctx.send_msgs(client,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
|
||||
return
|
||||
if status == HintStatus.HINT_FOUND:
|
||||
await ctx.send_msgs(client,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
|
||||
return
|
||||
new_hint = new_hint.re_prioritize(ctx, status)
|
||||
if hint == new_hint:
|
||||
return
|
||||
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
|
||||
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
|
||||
ctx.save()
|
||||
ctx.on_changed_hints(client.team, hint.finding_player)
|
||||
ctx.on_changed_hints(client.team, hint.receiving_player)
|
||||
|
||||
elif cmd == 'StatusUpdate':
|
||||
update_client_status(ctx, client, args["status"])
|
||||
|
||||
@@ -2143,9 +2247,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
hints = []
|
||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
||||
else: # item name or id
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
@@ -2179,14 +2283,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
if usable:
|
||||
if isinstance(location, int):
|
||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED)
|
||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||
hints = []
|
||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
||||
HintStatus.HINT_UNSPECIFIED))
|
||||
else:
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED)
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
else:
|
||||
@@ -2276,6 +2383,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
parser.add_argument('--logtime', help="Add timestamps to STDOUT",
|
||||
default=defaults["logtime"], action='store_true')
|
||||
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
||||
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
||||
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
||||
@@ -2356,7 +2465,9 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace):
|
||||
Utils.init_logging("Server", loglevel=args.loglevel.lower())
|
||||
Utils.init_logging(name="Server",
|
||||
loglevel=args.loglevel.lower(),
|
||||
add_timestamp=args.logtime)
|
||||
|
||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||
|
||||
41
NetUtils.py
41
NetUtils.py
@@ -29,6 +29,14 @@ class ClientStatus(ByValue, enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0
|
||||
HINT_UNSPECIFIED = 1
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
|
||||
|
||||
class SlotType(ByValue, enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
@@ -297,6 +305,20 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
|
||||
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||
|
||||
|
||||
status_names: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "(found)",
|
||||
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||
HintStatus.HINT_AVOID: "(avoid)",
|
||||
HintStatus.HINT_PRIORITY: "(priority)",
|
||||
}
|
||||
status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "green",
|
||||
HintStatus.HINT_UNSPECIFIED: "white",
|
||||
HintStatus.HINT_NO_PRIORITY: "slateblue",
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
HintStatus.HINT_PRIORITY: "plum",
|
||||
}
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
@@ -305,14 +327,21 @@ class Hint(typing.NamedTuple):
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
status: HintStatus = HintStatus.HINT_UNSPECIFIED
|
||||
|
||||
def re_check(self, ctx, team) -> Hint:
|
||||
if self.found:
|
||||
if self.found and self.status == HintStatus.HINT_FOUND:
|
||||
return self
|
||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||
if found:
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
||||
self.item_flags)
|
||||
return self._replace(found=found, status=HintStatus.HINT_FOUND)
|
||||
return self
|
||||
|
||||
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
|
||||
if self.found and status != HintStatus.HINT_FOUND:
|
||||
status = HintStatus.HINT_FOUND
|
||||
if status != self.status:
|
||||
return self._replace(status=status)
|
||||
return self
|
||||
|
||||
def __hash__(self):
|
||||
@@ -334,10 +363,8 @@ class Hint(typing.NamedTuple):
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
add_json_text(parts, ". ")
|
||||
if self.found:
|
||||
add_json_text(parts, "(found)", type="color", color="green")
|
||||
else:
|
||||
add_json_text(parts, "(not found)", type="color", color="red")
|
||||
add_json_text(parts, status_names.get(self.status, "(unknown)"), type="color",
|
||||
color=status_colors.get(self.status, "red"))
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
|
||||
37
Options.py
37
Options.py
@@ -828,7 +828,10 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
f"is not a valid location name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
|
||||
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||
return self.value.__iter__()
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
@@ -860,6 +863,8 @@ class ItemDict(OptionDict):
|
||||
verify_item_name = True
|
||||
|
||||
def __init__(self, value: typing.Dict[str, int]):
|
||||
if any(item_count is None for item_count in value.values()):
|
||||
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
|
||||
if any(item_count < 1 for item_count in value.values()):
|
||||
raise Exception("Cannot have non-positive item counts.")
|
||||
super(ItemDict, self).__init__(value)
|
||||
@@ -1460,22 +1465,26 @@ it.
|
||||
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
||||
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
||||
"""Generates and returns a dictionary for the option groups of a specified world."""
|
||||
option_groups = {option: option_group.name
|
||||
for option_group in world.web.option_groups
|
||||
for option in option_group.options}
|
||||
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
|
||||
|
||||
ordered_groups = {group.name: group.options for group in world.web.option_groups}
|
||||
|
||||
# add a default option group for uncategorized options to get thrown into
|
||||
ordered_groups = ["Game Options"]
|
||||
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
||||
grouped_options = {group: {} for group in ordered_groups}
|
||||
for option_name, option in world.options_dataclass.type_hints.items():
|
||||
if visibility_level & option.visibility:
|
||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
||||
if "Game Options" not in ordered_groups:
|
||||
grouped_options = set(option for group in ordered_groups.values() for option in group)
|
||||
ungrouped_options = [option for option in option_to_name if option not in grouped_options]
|
||||
# only add the game options group if we have ungrouped options
|
||||
if ungrouped_options:
|
||||
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
|
||||
|
||||
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
|
||||
if not grouped_options["Game Options"]:
|
||||
del grouped_options["Game Options"]
|
||||
|
||||
return grouped_options
|
||||
return {
|
||||
group: {
|
||||
option_to_name[option]: option
|
||||
for option in group_options
|
||||
if (visibility_level in option.visibility and option in option_to_name)
|
||||
}
|
||||
for group, group_options in ordered_groups.items()
|
||||
}
|
||||
|
||||
|
||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||
|
||||
@@ -76,6 +76,8 @@ Currently, the following games are supported:
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
* Faxanadu
|
||||
* Saving Princess
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
30
Utils.py
30
Utils.py
@@ -19,8 +19,7 @@ import warnings
|
||||
from argparse import Namespace
|
||||
from settings import Settings, get_settings
|
||||
from time import sleep
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||
from typing_extensions import TypeGuard
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||
from yaml import load, load_all, dump
|
||||
|
||||
try:
|
||||
@@ -48,7 +47,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.5.1"
|
||||
__version__ = "0.6.0"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -422,7 +421,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name == "PlandoItem":
|
||||
@@ -485,9 +485,9 @@ def get_text_after(text: str, start: str) -> str:
|
||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
||||
|
||||
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
||||
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None):
|
||||
import datetime
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = user_path("logs")
|
||||
@@ -515,10 +515,14 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
return self.condition(record)
|
||||
|
||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.msg))
|
||||
root_logger.addHandler(file_handler)
|
||||
if sys.stdout:
|
||||
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
||||
if add_timestamp:
|
||||
stream_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(stream_handler)
|
||||
|
||||
# Relay unhandled exceptions to logger.
|
||||
@@ -553,7 +557,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
import platform
|
||||
logging.info(
|
||||
f"Archipelago ({__version__}) logging initialized"
|
||||
f" on {platform.platform()}"
|
||||
f" on {platform.platform()} process {os.getpid()}"
|
||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
f"{' (frozen)' if is_frozen() else ''}"
|
||||
)
|
||||
@@ -855,11 +859,10 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
||||
task.add_done_callback(_faf_tasks.discard)
|
||||
|
||||
|
||||
def deprecate(message: str):
|
||||
def deprecate(message: str, add_stacklevels: int = 0):
|
||||
if __debug__:
|
||||
raise Exception(message)
|
||||
import warnings
|
||||
warnings.warn(message)
|
||||
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
||||
|
||||
|
||||
class DeprecateDict(dict):
|
||||
@@ -873,10 +876,9 @@ class DeprecateDict(dict):
|
||||
|
||||
def __getitem__(self, item: Any) -> Any:
|
||||
if self.should_error:
|
||||
deprecate(self.log_message)
|
||||
deprecate(self.log_message, add_stacklevels=1)
|
||||
elif __debug__:
|
||||
import warnings
|
||||
warnings.warn(self.log_message)
|
||||
warnings.warn(self.log_message, stacklevel=2)
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from Utils import get_file_safe_name
|
||||
if typing.TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
|
||||
@@ -85,6 +85,6 @@ def register():
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -105,8 +105,9 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
if yaml_data is not None:
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
if e.__cause__:
|
||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
||||
|
||||
@@ -18,13 +18,6 @@ def get_world_theme(game_name: str):
|
||||
return 'grass'
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
|
||||
@@ -5,9 +5,7 @@ waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.8.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.4.3; python_version == '3.9'
|
||||
bokeh>=3.5.2; python_version >= '3.10'
|
||||
bokeh>=3.5.2
|
||||
markupsafe>=2.1.5
|
||||
Markdown>=3.7
|
||||
mdx-breakless-lists>=1.0.1
|
||||
|
||||
31
WebHostLib/session.py
Normal file
31
WebHostLib/session.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
from flask import session, render_template
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.route('/session')
|
||||
def show_session():
|
||||
return render_template(
|
||||
"session.html",
|
||||
)
|
||||
|
||||
|
||||
@app.route('/session/<string:_id>')
|
||||
def set_session(_id: str):
|
||||
new_id: UUID = UUID(_id, version=4)
|
||||
old_id: UUID = session["_id"]
|
||||
if old_id != new_id:
|
||||
session["_id"] = new_id
|
||||
return render_template(
|
||||
"session.html",
|
||||
old_id=old_id,
|
||||
)
|
||||
@@ -178,8 +178,15 @@
|
||||
})
|
||||
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
||||
.then(newDocument => {
|
||||
let el = newDocument.getElementById("host-room-info");
|
||||
document.getElementById("host-room-info").innerHTML = el.innerHTML;
|
||||
["host-room-info", "slots-table"].forEach(function(id) {
|
||||
const newEl = newDocument.getElementById(id);
|
||||
const oldEl = document.getElementById(id);
|
||||
if (oldEl && newEl) {
|
||||
oldEl.innerHTML = newEl.innerHTML;
|
||||
} else if (newEl) {
|
||||
console.warn(`Did not find element to replace for ${id}`)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{%- endmacro %}
|
||||
{% macro list_patches_room(room) %}
|
||||
{% if room.seed.slots %}
|
||||
<table>
|
||||
<table id="slots-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
|
||||
30
WebHostLib/templates/session.html
Normal file
30
WebHostLib/templates/session.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/stoneHeader.html' %}
|
||||
<title>Session</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="markdown">
|
||||
{% if old_id is defined %}
|
||||
<p>Your old code was:</p>
|
||||
<code>{{ old_id }}</code>
|
||||
<br>
|
||||
{% endif %}
|
||||
<p>The following code is your unique identifier, it binds your uploaded content, such as rooms and seeds to you.
|
||||
Treat it like a combined login name and password.
|
||||
You should save this securely if you ever need to restore access.
|
||||
You can also paste it into another device to access your content from multiple devices / browsers.
|
||||
Some browsers, such as Brave, will delete your identifier cookie on a timer.</p>
|
||||
<code>{{ session["_id"] }}</code>
|
||||
<br>
|
||||
<p>
|
||||
The following link can be used to set the identifier. Do not share the code or link with others. <br>
|
||||
<a href="{{ url_for('set_session', _id=session['_id']) }}">
|
||||
{{ url_for('set_session', _id=session['_id'], _external=True) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -26,6 +26,7 @@
|
||||
<li><a href="/user-content">User Content</a></li>
|
||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
||||
<li><a href="/glossary/en">Glossary</a></li>
|
||||
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Tutorials</h2>
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<title>Option Templates (YAML)</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||
crossorigin="anonymous"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<table class="range-rows" data-option="{{ option_name }}">
|
||||
<tbody>
|
||||
{{ RangeRow(option_name, option, option.range_start, option.range_start, True) }}
|
||||
{% if option.range_start < option.default < option.range_end %}
|
||||
{% if option.default is number and option.range_start < option.default < option.range_end %}
|
||||
{{ RangeRow(option_name, option, option.default, option.default, True) }}
|
||||
{% endif %}
|
||||
{{ RangeRow(option_name, option, option.range_end, option.range_end, True) }}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
finding_text: "Finding Player"
|
||||
location_text: "Location"
|
||||
entrance_text: "Entrance"
|
||||
found_text: "Found?"
|
||||
status_text: "Status"
|
||||
TooltipLabel:
|
||||
id: receiving
|
||||
sort_key: 'receiving'
|
||||
@@ -96,9 +96,9 @@
|
||||
valign: 'center'
|
||||
pos_hint: {"center_y": 0.5}
|
||||
TooltipLabel:
|
||||
id: found
|
||||
sort_key: 'found'
|
||||
text: root.found_text
|
||||
id: status
|
||||
sort_key: 'status'
|
||||
text: root.status_text
|
||||
halign: 'center'
|
||||
valign: 'center'
|
||||
pos_hint: {"center_y": 0.5}
|
||||
|
||||
@@ -55,19 +55,22 @@
|
||||
/worlds/dlcquest/ @axe-y @agilbert1412
|
||||
|
||||
# DOOM 1993
|
||||
/worlds/doom_1993/ @Daivuk
|
||||
/worlds/doom_1993/ @Daivuk @KScl
|
||||
|
||||
# DOOM II
|
||||
/worlds/doom_ii/ @Daivuk
|
||||
/worlds/doom_ii/ @Daivuk @KScl
|
||||
|
||||
# Factorio
|
||||
/worlds/factorio/ @Berserker66
|
||||
|
||||
# Faxanadu
|
||||
/worlds/faxanadu/ @Daivuk
|
||||
|
||||
# Final Fantasy Mystic Quest
|
||||
/worlds/ffmq/ @Alchav @wildham0
|
||||
|
||||
# Heretic
|
||||
/worlds/heretic/ @Daivuk
|
||||
/worlds/heretic/ @Daivuk @KScl
|
||||
|
||||
# Hollow Knight
|
||||
/worlds/hk/ @BadMagic100 @qwint
|
||||
@@ -139,6 +142,9 @@
|
||||
# Risk of Rain 2
|
||||
/worlds/ror2/ @kindasneaki
|
||||
|
||||
# Saving Princess
|
||||
/worlds/saving_princess/ @LeonarthCG
|
||||
|
||||
# Shivers
|
||||
/worlds/shivers/ @GodlFire
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ game contributions:
|
||||
* **Do not introduce unit test failures/regressions.**
|
||||
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
||||
your changes. Currently, the oldest supported version
|
||||
is [Python 3.8](https://www.python.org/downloads/release/python-380/).
|
||||
is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
|
||||
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
||||
pushing.
|
||||
You can turn them on here:
|
||||
|
||||
@@ -272,6 +272,7 @@ These packets are sent purely from client to server. They are not accepted by cl
|
||||
* [Sync](#Sync)
|
||||
* [LocationChecks](#LocationChecks)
|
||||
* [LocationScouts](#LocationScouts)
|
||||
* [UpdateHint](#UpdateHint)
|
||||
* [StatusUpdate](#StatusUpdate)
|
||||
* [Say](#Say)
|
||||
* [GetDataPackage](#GetDataPackage)
|
||||
@@ -342,6 +343,33 @@ This is useful in cases where an item appears in the game world, such as 'ledge
|
||||
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
||||
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
||||
|
||||
### UpdateHint
|
||||
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
|
||||
|
||||
### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| player | int | The ID of the player whose location is being hinted for. |
|
||||
| location | int | The ID of the location to update the hint for. If no hint exists for this location, the packet is ignored. |
|
||||
| status | [HintStatus](#HintStatus) | Optional. If included, sets the status of the hint to this status. Cannot set `HINT_FOUND`, or change the status from `HINT_FOUND`. |
|
||||
|
||||
#### HintStatus
|
||||
An enumeration containing the possible hint states.
|
||||
|
||||
```python
|
||||
import enum
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0 # The location has been collected. Status cannot be changed once found.
|
||||
HINT_UNSPECIFIED = 1 # The receiving player has not specified any status
|
||||
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
|
||||
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
|
||||
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
|
||||
```
|
||||
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
|
||||
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
|
||||
- Hints created with `!hint` or similar (hinting an item for yourself) default to `HINT_PRIORITY`.
|
||||
- Once a hint is collected, its' status is updated to `HINT_FOUND` automatically, and can no longer be changed.
|
||||
|
||||
### StatusUpdate
|
||||
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
|
||||
|
||||
@@ -644,6 +672,7 @@ class Hint(typing.NamedTuple):
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
status: HintStatus = HintStatus.HINT_UNSPECIFIED
|
||||
```
|
||||
|
||||
### Data Package Contents
|
||||
|
||||
@@ -7,7 +7,9 @@ use that version. These steps are for developers or platforms without compiled r
|
||||
## General
|
||||
|
||||
What you'll need:
|
||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* [Python 3.10.11 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* On Windows, please consider only using the latest supported version in production environments since security
|
||||
updates for older versions are not easily available.
|
||||
* Python 3.12.x is currently the newest supported version
|
||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||
* Matching C compiler
|
||||
|
||||
@@ -288,8 +288,8 @@ like entrance randomization in logic.
|
||||
|
||||
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
||||
|
||||
There must be one special region, "Menu", from which the logic unfolds. AP assumes that a player will always be able to
|
||||
return to the "Menu" region by resetting the game ("Save and quit").
|
||||
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295-L296)),
|
||||
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
||||
|
||||
### Entrances
|
||||
|
||||
@@ -328,6 +328,9 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
|
||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||
|
||||
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L301),
|
||||
avoiding the need for indirect conditions at the expense of performance.
|
||||
|
||||
### Item Rules
|
||||
|
||||
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
||||
@@ -463,7 +466,7 @@ The world has to provide the following things for generation:
|
||||
|
||||
* the properties mentioned above
|
||||
* additions to the item pool
|
||||
* additions to the regions list: at least one called "Menu"
|
||||
* additions to the regions list: at least one named after the world class's origin_region_name ("Menu" by default)
|
||||
* locations placed inside those regions
|
||||
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
|
||||
* applying `self.multiworld.push_precollected` for world-defined start inventory
|
||||
@@ -516,7 +519,7 @@ def generate_early(self) -> None:
|
||||
|
||||
```python
|
||||
def create_regions(self) -> None:
|
||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
||||
# Add regions to the multiworld. One of them must use the origin_region_name as its name ("Menu" by default).
|
||||
# Arguments to Region() are name, player, multiworld, and optionally hint_text
|
||||
menu_region = Region("Menu", self.player, self.multiworld)
|
||||
self.multiworld.regions.append(menu_region) # or use += [menu_region...]
|
||||
|
||||
139
kvui.py
139
kvui.py
@@ -3,6 +3,8 @@ import logging
|
||||
import sys
|
||||
import typing
|
||||
import re
|
||||
import io
|
||||
import pkgutil
|
||||
from collections import deque
|
||||
|
||||
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
|
||||
@@ -12,10 +14,7 @@ if sys.platform == "win32":
|
||||
|
||||
# kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout
|
||||
# by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's
|
||||
try:
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(0)
|
||||
except FileNotFoundError: # shcore may not be found on <= Windows 7
|
||||
pass # TODO: remove silent except when Python 3.8 is phased out.
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(0)
|
||||
|
||||
os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
||||
os.environ["KIVY_NO_FILELOG"] = "1"
|
||||
@@ -37,6 +36,7 @@ from kivy.app import App
|
||||
from kivy.core.window import Window
|
||||
from kivy.core.clipboard import Clipboard
|
||||
from kivy.core.text.markup import MarkupLabel
|
||||
from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
|
||||
from kivy.base import ExceptionHandler, ExceptionManager
|
||||
from kivy.clock import Clock
|
||||
from kivy.factory import Factory
|
||||
@@ -55,6 +55,7 @@ from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.floatlayout import FloatLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
from kivy.uix.dropdown import DropDown
|
||||
from kivy.utils import escape_markup
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.recycleview.views import RecycleDataViewBehavior
|
||||
@@ -63,10 +64,11 @@ from kivy.uix.recycleboxlayout import RecycleBoxLayout
|
||||
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
|
||||
from kivy.animation import Animation
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.image import AsyncImage
|
||||
|
||||
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
||||
|
||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType, HintStatus
|
||||
from Utils import async_start, get_input_text_from_response
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -303,11 +305,11 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
||||
""" Respond to the selection of items in the view. """
|
||||
self.selected = is_selected
|
||||
|
||||
|
||||
class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
selected = BooleanProperty(False)
|
||||
striped = BooleanProperty(False)
|
||||
index = None
|
||||
dropdown: DropDown
|
||||
|
||||
def __init__(self):
|
||||
super(HintLabel, self).__init__()
|
||||
@@ -316,10 +318,32 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
self.finding_text = ""
|
||||
self.location_text = ""
|
||||
self.entrance_text = ""
|
||||
self.found_text = ""
|
||||
self.status_text = ""
|
||||
self.hint = {}
|
||||
for child in self.children:
|
||||
child.bind(texture_size=self.set_height)
|
||||
|
||||
|
||||
ctx = App.get_running_app().ctx
|
||||
self.dropdown = DropDown()
|
||||
|
||||
def set_value(button):
|
||||
self.dropdown.select(button.status)
|
||||
|
||||
def select(instance, data):
|
||||
ctx.update_hint(self.hint["location"],
|
||||
self.hint["finding_player"],
|
||||
data)
|
||||
|
||||
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
|
||||
name = status_names[status]
|
||||
status_button = Button(text=name, size_hint_y=None, height=dp(50))
|
||||
status_button.status = status
|
||||
status_button.bind(on_release=set_value)
|
||||
self.dropdown.add_widget(status_button)
|
||||
|
||||
self.dropdown.bind(on_select=select)
|
||||
|
||||
def set_height(self, instance, value):
|
||||
self.height = max([child.texture_size[1] for child in self.children])
|
||||
|
||||
@@ -331,7 +355,8 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
self.finding_text = data["finding"]["text"]
|
||||
self.location_text = data["location"]["text"]
|
||||
self.entrance_text = data["entrance"]["text"]
|
||||
self.found_text = data["found"]["text"]
|
||||
self.status_text = data["status"]["text"]
|
||||
self.hint = data["status"]["hint"]
|
||||
self.height = self.minimum_height
|
||||
return super(HintLabel, self).refresh_view_attrs(rv, index, data)
|
||||
|
||||
@@ -341,13 +366,21 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
return True
|
||||
if self.index: # skip header
|
||||
if self.collide_point(*touch.pos):
|
||||
if self.selected:
|
||||
status_label = self.ids["status"]
|
||||
if status_label.collide_point(*touch.pos):
|
||||
if self.hint["status"] == HintStatus.HINT_FOUND:
|
||||
return
|
||||
ctx = App.get_running_app().ctx
|
||||
if ctx.slot == self.hint["receiving_player"]: # If this player owns this hint
|
||||
# open a dropdown
|
||||
self.dropdown.open(self.ids["status"])
|
||||
elif self.selected:
|
||||
self.parent.clear_selection()
|
||||
else:
|
||||
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.status_text.lower(), ")"))
|
||||
temp = MarkupLabel(text).markup
|
||||
text = "".join(
|
||||
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
||||
@@ -361,18 +394,16 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
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 == "status":
|
||||
parent.hint_sorter = lambda element: element["status"]["hint"]["status"]
|
||||
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||
if key == parent.sort_key:
|
||||
# second click reverses order
|
||||
parent.reversed = not parent.reversed
|
||||
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()
|
||||
App.get_running_app().update_hints()
|
||||
|
||||
def apply_selection(self, rv, index, is_selected):
|
||||
""" Respond to the selection of items in the view. """
|
||||
@@ -666,7 +697,7 @@ class GameManager(App):
|
||||
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
|
||||
|
||||
def update_hints(self):
|
||||
hints = self.ctx.stored_data[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"]
|
||||
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
|
||||
self.log_panels["Hints"].refresh_hints(hints)
|
||||
|
||||
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
|
||||
@@ -722,6 +753,22 @@ class UILog(RecycleView):
|
||||
element.height = element.texture_size[1]
|
||||
|
||||
|
||||
status_names: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "Found",
|
||||
HintStatus.HINT_UNSPECIFIED: "Unspecified",
|
||||
HintStatus.HINT_NO_PRIORITY: "No Priority",
|
||||
HintStatus.HINT_AVOID: "Avoid",
|
||||
HintStatus.HINT_PRIORITY: "Priority",
|
||||
}
|
||||
status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "green",
|
||||
HintStatus.HINT_UNSPECIFIED: "white",
|
||||
HintStatus.HINT_NO_PRIORITY: "cyan",
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
HintStatus.HINT_PRIORITY: "plum",
|
||||
}
|
||||
|
||||
|
||||
class HintLog(RecycleView):
|
||||
header = {
|
||||
"receiving": {"text": "[u]Receiving Player[/u]"},
|
||||
@@ -729,12 +776,13 @@ class HintLog(RecycleView):
|
||||
"finding": {"text": "[u]Finding Player[/u]"},
|
||||
"location": {"text": "[u]Location[/u]"},
|
||||
"entrance": {"text": "[u]Entrance[/u]"},
|
||||
"found": {"text": "[u]Status[/u]"},
|
||||
"status": {"text": "[u]Status[/u]",
|
||||
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
|
||||
"striped": True,
|
||||
}
|
||||
|
||||
sort_key: str = ""
|
||||
reversed: bool = False
|
||||
reversed: bool = True
|
||||
|
||||
def __init__(self, parser):
|
||||
super(HintLog, self).__init__()
|
||||
@@ -742,8 +790,18 @@ class HintLog(RecycleView):
|
||||
self.parser = parser
|
||||
|
||||
def refresh_hints(self, hints):
|
||||
if not hints: # Fix the scrolling looking visually wrong in some edge cases
|
||||
self.scroll_y = 1.0
|
||||
data = []
|
||||
ctx = App.get_running_app().ctx
|
||||
for hint in hints:
|
||||
if not hint.get("status"): # Allows connecting to old servers
|
||||
hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED
|
||||
hint_status_node = self.parser.handle_node({"type": "color",
|
||||
"color": status_colors.get(hint["status"], "red"),
|
||||
"text": status_names.get(hint["status"], "Unknown")})
|
||||
if hint["status"] != HintStatus.HINT_FOUND and hint["receiving_player"] == ctx.slot:
|
||||
hint_status_node = f"[u]{hint_status_node}[/u]"
|
||||
data.append({
|
||||
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
|
||||
"item": {"text": self.parser.handle_node({
|
||||
@@ -761,9 +819,10 @@ class HintLog(RecycleView):
|
||||
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
|
||||
"color": "blue", "text": hint["entrance"]
|
||||
if hint["entrance"] else "Vanilla"})},
|
||||
"found": {
|
||||
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red",
|
||||
"text": "Found" if hint["found"] else "Not Found"})},
|
||||
"status": {
|
||||
"text": hint_status_node,
|
||||
"hint": hint,
|
||||
},
|
||||
})
|
||||
|
||||
data.sort(key=self.hint_sorter, reverse=self.reversed)
|
||||
@@ -774,7 +833,7 @@ class HintLog(RecycleView):
|
||||
|
||||
@staticmethod
|
||||
def hint_sorter(element: dict) -> str:
|
||||
return ""
|
||||
return element["status"]["hint"]["status"] # By status by default
|
||||
|
||||
def fix_heights(self):
|
||||
"""Workaround fix for divergent texture and layout heights"""
|
||||
@@ -783,6 +842,40 @@ class HintLog(RecycleView):
|
||||
element.height = max_height
|
||||
|
||||
|
||||
class ApAsyncImage(AsyncImage):
|
||||
def is_uri(self, filename: str) -> bool:
|
||||
if filename.startswith("ap:"):
|
||||
return True
|
||||
else:
|
||||
return super().is_uri(filename)
|
||||
|
||||
|
||||
class ImageLoaderPkgutil(ImageLoaderBase):
|
||||
def load(self, filename: str) -> typing.List[ImageData]:
|
||||
# take off the "ap:" prefix
|
||||
module, path = filename[3:].split("/", 1)
|
||||
data = pkgutil.get_data(module, path)
|
||||
return self._bytes_to_data(data)
|
||||
|
||||
def _bytes_to_data(self, data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
|
||||
loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory())
|
||||
return loader.load(loader, io.BytesIO(data))
|
||||
|
||||
|
||||
# grab the default loader method so we can override it but use it as a fallback
|
||||
_original_image_loader_load = ImageLoader.load
|
||||
|
||||
|
||||
def load_override(filename: str, default_load=_original_image_loader_load, **kwargs):
|
||||
if filename.startswith("ap:"):
|
||||
return ImageLoaderPkgutil(filename)
|
||||
else:
|
||||
return default_load(filename, **kwargs)
|
||||
|
||||
|
||||
ImageLoader.load = load_override
|
||||
|
||||
|
||||
class E(ExceptionHandler):
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
|
||||
11
settings.py
11
settings.py
@@ -7,6 +7,7 @@ import os
|
||||
import os.path
|
||||
import shutil
|
||||
import sys
|
||||
import types
|
||||
import typing
|
||||
import warnings
|
||||
from enum import IntEnum
|
||||
@@ -162,8 +163,13 @@ class Group:
|
||||
else:
|
||||
# assign value, try to upcast to type hint
|
||||
annotation = self.get_type_hints().get(k, None)
|
||||
candidates = [] if annotation is None else \
|
||||
typing.get_args(annotation) if typing.get_origin(annotation) is Union else [annotation]
|
||||
candidates = (
|
||||
[] if annotation is None else (
|
||||
typing.get_args(annotation)
|
||||
if typing.get_origin(annotation) in (Union, types.UnionType)
|
||||
else [annotation]
|
||||
)
|
||||
)
|
||||
none_type = type(None)
|
||||
for cls in candidates:
|
||||
assert isinstance(cls, type), f"{self.__class__.__name__}.{k}: type {cls} not supported in settings"
|
||||
@@ -593,6 +599,7 @@ class ServerOptions(Group):
|
||||
savefile: Optional[str] = None
|
||||
disable_save: bool = False
|
||||
loglevel: str = "info"
|
||||
logtime: bool = False
|
||||
server_password: Optional[ServerPassword] = None
|
||||
disable_item_cheat: Union[DisableItemCheat, bool] = False
|
||||
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
|
||||
|
||||
4
setup.py
4
setup.py
@@ -321,7 +321,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
f"{ex}\nPlease close all AP instances and delete manually.")
|
||||
|
||||
# regular cx build
|
||||
self.buildtime = datetime.datetime.utcnow()
|
||||
self.buildtime = datetime.datetime.now(datetime.timezone.utc)
|
||||
super().run()
|
||||
|
||||
# manually copy built modules to lib folder. cx_Freeze does not know they exist.
|
||||
@@ -634,7 +634,7 @@ cx_Freeze.setup(
|
||||
"excludes": ["numpy", "Cython", "PySide2", "PIL",
|
||||
"pandas", "zstandard"],
|
||||
"zip_include_packages": ["*"],
|
||||
"zip_exclude_packages": ["worlds", "sc2", "orjson"], # TODO: remove orjson here once we drop py3.8 support
|
||||
"zip_exclude_packages": ["worlds", "sc2"],
|
||||
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
|
||||
"include_msvcr": False,
|
||||
"replace_paths": ["*."],
|
||||
|
||||
@@ -80,3 +80,21 @@ class TestBase(unittest.TestCase):
|
||||
call_all(multiworld, step)
|
||||
self.assertEqual(created_items, multiworld.itempool,
|
||||
f"{game_name} modified the itempool during {step}")
|
||||
|
||||
def test_locality_not_modified(self):
|
||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
||||
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
||||
for game_name, world_type in worlds_to_test.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
local_items = multiworld.worlds[1].options.local_items.value.copy()
|
||||
non_local_items = multiworld.worlds[1].options.non_local_items.value.copy()
|
||||
for step in additional_steps:
|
||||
with self.subTest("step", step=step):
|
||||
call_all(multiworld, step)
|
||||
self.assertEqual(local_items, multiworld.worlds[1].options.local_items.value,
|
||||
f"{game_name} modified local_items during {step}")
|
||||
self.assertEqual(non_local_items, multiworld.worlds[1].options.non_local_items.value,
|
||||
f"{game_name} modified non_local_items during {step}")
|
||||
|
||||
16
test/general/test_settings.py
Normal file
16
test/general/test_settings.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from settings import Group
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestSettings(TestCase):
|
||||
def test_settings_can_update(self) -> None:
|
||||
"""
|
||||
Test that world settings can update.
|
||||
"""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=game_name):
|
||||
if world_type.settings is not None:
|
||||
assert isinstance(world_type.settings, Group)
|
||||
world_type.settings.update({}) # a previous bug had a crash in this call to update
|
||||
@@ -2,9 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union
|
||||
|
||||
from typing_extensions import TypeGuard
|
||||
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard
|
||||
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ class AutoWorldRegister(type):
|
||||
# lazy loading + caching to minimize runtime cost
|
||||
if cls.__settings is None:
|
||||
from settings import get_settings
|
||||
cls.__settings = get_settings()[cls.settings_key]
|
||||
try:
|
||||
cls.__settings = get_settings()[cls.settings_key]
|
||||
except AttributeError:
|
||||
return None
|
||||
return cls.__settings
|
||||
|
||||
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
|
||||
|
||||
@@ -103,7 +103,7 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
|
||||
try:
|
||||
import zipfile
|
||||
zip = zipfile.ZipFile(apworld_path)
|
||||
directories = [f.filename.strip('/') for f in zip.filelist if f.CRC == 0 and f.file_size == 0 and f.filename.count('/') == 1]
|
||||
directories = [f.name for f in zipfile.Path(zip).iterdir() if f.is_dir()]
|
||||
if len(directories) == 1 and directories[0] in apworld_path.stem:
|
||||
module_name = directories[0]
|
||||
apworld_name = module_name + ".apworld"
|
||||
@@ -207,6 +207,7 @@ components: List[Component] = [
|
||||
]
|
||||
|
||||
|
||||
# if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.png'),
|
||||
|
||||
@@ -66,19 +66,12 @@ class WorldSource:
|
||||
start = time.perf_counter()
|
||||
if self.is_zip:
|
||||
importer = zipimport.zipimporter(self.resolved_path)
|
||||
if hasattr(importer, "find_spec"): # new in Python 3.10
|
||||
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
assert spec, f"{self.path} is not a loadable module"
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
else: # TODO: remove with 3.8 support
|
||||
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
assert spec, f"{self.path} is not a loadable module"
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
|
||||
if mod.__package__ is not None:
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
else:
|
||||
# load_module does not populate package, we'll have to assume mod.__name__ is correct here
|
||||
# probably safe to remove with 3.8 support
|
||||
mod.__package__ = f"worlds.{mod.__name__}"
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
|
||||
@@ -47,8 +47,6 @@ class LocationData:
|
||||
self.local_item: int = None
|
||||
|
||||
def get_random_position(self, random):
|
||||
x: int = None
|
||||
y: int = None
|
||||
if self.world_positions is None or len(self.world_positions) == 0:
|
||||
if self.room_id is None:
|
||||
return None
|
||||
|
||||
@@ -76,10 +76,9 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
|
||||
multiworld.regions.append(credits_room_far_side)
|
||||
|
||||
dragon_slay_check = options.dragon_slay_check.value
|
||||
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
|
||||
priority_locations = determine_priority_locations()
|
||||
|
||||
for name, location_data in location_table.items():
|
||||
require_sword = False
|
||||
if location_data.region == "Varies":
|
||||
if location_data.name == "Slay Yorgle":
|
||||
if not dragon_slay_check:
|
||||
@@ -154,6 +153,7 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
|
||||
|
||||
|
||||
# Placeholder for adding sets of priority locations at generation, possibly as an option in the future
|
||||
def determine_priority_locations(world: MultiWorld, dragon_slay_check: bool) -> {}:
|
||||
# def determine_priority_locations(multiworld: MultiWorld, dragon_slay_check: bool) -> {}:
|
||||
def determine_priority_locations() -> {}:
|
||||
priority_locations = {}
|
||||
return priority_locations
|
||||
|
||||
@@ -86,9 +86,7 @@ class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
|
||||
|
||||
# locations: [], autocollect: [], seed_name: bytes,
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
patch_only = True
|
||||
if "autocollect" in kwargs:
|
||||
patch_only = False
|
||||
self.foreign_items: [AdventureForeignItemInfo] = [AdventureForeignItemInfo(loc.short_location_id, loc.room_id, loc.room_x, loc.room_y)
|
||||
for loc in kwargs["locations"]]
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ class AdventureWorld(World):
|
||||
# end of ordered Main.py calls
|
||||
|
||||
def create_item(self, name: str) -> Item:
|
||||
item_data: ItemData = item_table.get(name)
|
||||
item_data: ItemData = item_table[name]
|
||||
return AdventureItem(name, item_data.classification, item_data.id, self.player)
|
||||
|
||||
def create_event(self, name: str, classification: ItemClassification) -> Item:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from Options import Choice, Range, DeathLink, DefaultOnToggle, FreeText, ItemsAccessibility, Option, \
|
||||
from Options import Choice, Range, DeathLink, DefaultOnToggle, FreeText, ItemsAccessibility, PerGameCommonOptions, \
|
||||
PlandoBosses, PlandoConnections, PlandoTexts, Removed, StartInventoryPool, Toggle
|
||||
from .EntranceShuffle import default_connections, default_dungeon_connections, \
|
||||
inverted_default_connections, inverted_default_dungeon_connections
|
||||
@@ -742,86 +742,86 @@ class ALttPPlandoTexts(PlandoTexts):
|
||||
valid_keys = TextTable.valid_keys
|
||||
|
||||
|
||||
alttp_options: typing.Dict[str, type(Option)] = {
|
||||
"accessibility": ItemsAccessibility,
|
||||
"plando_connections": ALttPPlandoConnections,
|
||||
"plando_texts": ALttPPlandoTexts,
|
||||
"start_inventory_from_pool": StartInventoryPool,
|
||||
"goal": Goal,
|
||||
"mode": Mode,
|
||||
"glitches_required": GlitchesRequired,
|
||||
"dark_room_logic": DarkRoomLogic,
|
||||
"open_pyramid": OpenPyramid,
|
||||
"crystals_needed_for_gt": CrystalsTower,
|
||||
"crystals_needed_for_ganon": CrystalsGanon,
|
||||
"triforce_pieces_mode": TriforcePiecesMode,
|
||||
"triforce_pieces_percentage": TriforcePiecesPercentage,
|
||||
"triforce_pieces_required": TriforcePiecesRequired,
|
||||
"triforce_pieces_available": TriforcePiecesAvailable,
|
||||
"triforce_pieces_extra": TriforcePiecesExtra,
|
||||
"entrance_shuffle": EntranceShuffle,
|
||||
"entrance_shuffle_seed": EntranceShuffleSeed,
|
||||
"big_key_shuffle": big_key_shuffle,
|
||||
"small_key_shuffle": small_key_shuffle,
|
||||
"key_drop_shuffle": key_drop_shuffle,
|
||||
"compass_shuffle": compass_shuffle,
|
||||
"map_shuffle": map_shuffle,
|
||||
"restrict_dungeon_item_on_boss": RestrictBossItem,
|
||||
"item_pool": ItemPool,
|
||||
"item_functionality": ItemFunctionality,
|
||||
"enemy_health": EnemyHealth,
|
||||
"enemy_damage": EnemyDamage,
|
||||
"progressive": Progressive,
|
||||
"swordless": Swordless,
|
||||
"dungeon_counters": DungeonCounters,
|
||||
"retro_bow": RetroBow,
|
||||
"retro_caves": RetroCaves,
|
||||
"hints": Hints,
|
||||
"scams": Scams,
|
||||
"boss_shuffle": LTTPBosses,
|
||||
"pot_shuffle": PotShuffle,
|
||||
"enemy_shuffle": EnemyShuffle,
|
||||
"killable_thieves": KillableThieves,
|
||||
"bush_shuffle": BushShuffle,
|
||||
"shop_item_slots": ShopItemSlots,
|
||||
"randomize_shop_inventories": RandomizeShopInventories,
|
||||
"shuffle_shop_inventories": ShuffleShopInventories,
|
||||
"include_witch_hut": IncludeWitchHut,
|
||||
"randomize_shop_prices": RandomizeShopPrices,
|
||||
"randomize_cost_types": RandomizeCostTypes,
|
||||
"shop_price_modifier": ShopPriceModifier,
|
||||
"shuffle_capacity_upgrades": ShuffleCapacityUpgrades,
|
||||
"bombless_start": BomblessStart,
|
||||
"shuffle_prizes": ShufflePrizes,
|
||||
"tile_shuffle": TileShuffle,
|
||||
"misery_mire_medallion": MiseryMireMedallion,
|
||||
"turtle_rock_medallion": TurtleRockMedallion,
|
||||
"glitch_boots": GlitchBoots,
|
||||
"beemizer_total_chance": BeemizerTotalChance,
|
||||
"beemizer_trap_chance": BeemizerTrapChance,
|
||||
"timer": Timer,
|
||||
"countdown_start_time": CountdownStartTime,
|
||||
"red_clock_time": RedClockTime,
|
||||
"blue_clock_time": BlueClockTime,
|
||||
"green_clock_time": GreenClockTime,
|
||||
"death_link": DeathLink,
|
||||
"allow_collect": AllowCollect,
|
||||
"ow_palettes": OWPalette,
|
||||
"uw_palettes": UWPalette,
|
||||
"hud_palettes": HUDPalette,
|
||||
"sword_palettes": SwordPalette,
|
||||
"shield_palettes": ShieldPalette,
|
||||
# "link_palettes": LinkPalette,
|
||||
"heartbeep": HeartBeep,
|
||||
"heartcolor": HeartColor,
|
||||
"quickswap": QuickSwap,
|
||||
"menuspeed": MenuSpeed,
|
||||
"music": Music,
|
||||
"reduceflashing": ReduceFlashing,
|
||||
"triforcehud": TriforceHud,
|
||||
@dataclass
|
||||
class ALTTPOptions(PerGameCommonOptions):
|
||||
accessibility: ItemsAccessibility
|
||||
plando_connections: ALttPPlandoConnections
|
||||
plando_texts: ALttPPlandoTexts
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
goal: Goal
|
||||
mode: Mode
|
||||
glitches_required: GlitchesRequired
|
||||
dark_room_logic: DarkRoomLogic
|
||||
open_pyramid: OpenPyramid
|
||||
crystals_needed_for_gt: CrystalsTower
|
||||
crystals_needed_for_ganon: CrystalsGanon
|
||||
triforce_pieces_mode: TriforcePiecesMode
|
||||
triforce_pieces_percentage: TriforcePiecesPercentage
|
||||
triforce_pieces_required: TriforcePiecesRequired
|
||||
triforce_pieces_available: TriforcePiecesAvailable
|
||||
triforce_pieces_extra: TriforcePiecesExtra
|
||||
entrance_shuffle: EntranceShuffle
|
||||
entrance_shuffle_seed: EntranceShuffleSeed
|
||||
big_key_shuffle: big_key_shuffle
|
||||
small_key_shuffle: small_key_shuffle
|
||||
key_drop_shuffle: key_drop_shuffle
|
||||
compass_shuffle: compass_shuffle
|
||||
map_shuffle: map_shuffle
|
||||
restrict_dungeon_item_on_boss: RestrictBossItem
|
||||
item_pool: ItemPool
|
||||
item_functionality: ItemFunctionality
|
||||
enemy_health: EnemyHealth
|
||||
enemy_damage: EnemyDamage
|
||||
progressive: Progressive
|
||||
swordless: Swordless
|
||||
dungeon_counters: DungeonCounters
|
||||
retro_bow: RetroBow
|
||||
retro_caves: RetroCaves
|
||||
hints: Hints
|
||||
scams: Scams
|
||||
boss_shuffle: LTTPBosses
|
||||
pot_shuffle: PotShuffle
|
||||
enemy_shuffle: EnemyShuffle
|
||||
killable_thieves: KillableThieves
|
||||
bush_shuffle: BushShuffle
|
||||
shop_item_slots: ShopItemSlots
|
||||
randomize_shop_inventories: RandomizeShopInventories
|
||||
shuffle_shop_inventories: ShuffleShopInventories
|
||||
include_witch_hut: IncludeWitchHut
|
||||
randomize_shop_prices: RandomizeShopPrices
|
||||
randomize_cost_types: RandomizeCostTypes
|
||||
shop_price_modifier: ShopPriceModifier
|
||||
shuffle_capacity_upgrades: ShuffleCapacityUpgrades
|
||||
bombless_start: BomblessStart
|
||||
shuffle_prizes: ShufflePrizes
|
||||
tile_shuffle: TileShuffle
|
||||
misery_mire_medallion: MiseryMireMedallion
|
||||
turtle_rock_medallion: TurtleRockMedallion
|
||||
glitch_boots: GlitchBoots
|
||||
beemizer_total_chance: BeemizerTotalChance
|
||||
beemizer_trap_chance: BeemizerTrapChance
|
||||
timer: Timer
|
||||
countdown_start_time: CountdownStartTime
|
||||
red_clock_time: RedClockTime
|
||||
blue_clock_time: BlueClockTime
|
||||
green_clock_time: GreenClockTime
|
||||
death_link: DeathLink
|
||||
allow_collect: AllowCollect
|
||||
ow_palettes: OWPalette
|
||||
uw_palettes: UWPalette
|
||||
hud_palettes: HUDPalette
|
||||
sword_palettes: SwordPalette
|
||||
shield_palettes: ShieldPalette
|
||||
# link_palettes: LinkPalette
|
||||
heartbeep: HeartBeep
|
||||
heartcolor: HeartColor
|
||||
quickswap: QuickSwap
|
||||
menuspeed: MenuSpeed
|
||||
music: Music
|
||||
reduceflashing: ReduceFlashing
|
||||
triforcehud: TriforceHud
|
||||
|
||||
# removed:
|
||||
"goals": Removed,
|
||||
"smallkey_shuffle": Removed,
|
||||
"bigkey_shuffle": Removed,
|
||||
}
|
||||
goals: Removed
|
||||
smallkey_shuffle: Removed
|
||||
bigkey_shuffle: Removed
|
||||
|
||||
@@ -782,8 +782,8 @@ def get_nonnative_item_sprite(code: int) -> int:
|
||||
|
||||
|
||||
def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
local_random = world.per_slot_randoms[player]
|
||||
local_world = world.worlds[player]
|
||||
local_random = local_world.random
|
||||
|
||||
# patch items
|
||||
|
||||
@@ -1867,7 +1867,7 @@ def apply_oof_sfx(rom, oof: str):
|
||||
def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, sprite: str, oof: str, palettes_options,
|
||||
world=None, player=1, allow_random_on_event=False, reduceflashing=False,
|
||||
triforcehud: str = None, deathlink: bool = False, allowcollect: bool = False):
|
||||
local_random = random if not world else world.per_slot_randoms[player]
|
||||
local_random = random if not world else world.worlds[player].random
|
||||
disable_music: bool = not music
|
||||
# enable instant item menu
|
||||
if menuspeed == 'instant':
|
||||
@@ -2197,8 +2197,9 @@ def write_string_to_rom(rom, target, string):
|
||||
|
||||
def write_strings(rom, world, player):
|
||||
from . import ALTTPWorld
|
||||
local_random = world.per_slot_randoms[player]
|
||||
|
||||
w: ALTTPWorld = world.worlds[player]
|
||||
local_random = w.random
|
||||
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
@@ -2425,7 +2426,7 @@ def write_strings(rom, world, player):
|
||||
if world.worlds[player].has_progressive_bows and (w.difficulty_requirements.progressive_bow_limit >= 2 or (
|
||||
world.swordless[player] or world.glitches_required[player] == 'no_glitches')):
|
||||
prog_bow_locs = world.find_item_locations('Progressive Bow', player, True)
|
||||
world.per_slot_randoms[player].shuffle(prog_bow_locs)
|
||||
local_random.shuffle(prog_bow_locs)
|
||||
found_bow = False
|
||||
found_bow_alt = False
|
||||
while prog_bow_locs and not (found_bow and found_bow_alt):
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import settings
|
||||
import threading
|
||||
import typing
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
from BaseClasses import Item, CollectionState, Tutorial, MultiWorld
|
||||
from worlds.AutoWorld import World, WebWorld, LogicMixin
|
||||
from .Client import ALTTPSNIClient
|
||||
from .Dungeons import create_dungeons, Dungeon
|
||||
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
|
||||
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
|
||||
from .ItemPool import generate_itempool, difficulties
|
||||
from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem
|
||||
from .Options import alttp_options, small_key_shuffle
|
||||
from .Options import ALTTPOptions, small_key_shuffle
|
||||
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance, \
|
||||
is_main_entrance, key_drop_data
|
||||
from .Client import ALTTPSNIClient
|
||||
from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \
|
||||
get_hash_string, get_base_rom_path, LttPDeltaPatch
|
||||
from .Rules import set_rules
|
||||
from .Shops import create_shops, Shop, push_shop_inventories, ShopType, price_rate_display, price_type_display_name
|
||||
from .SubClasses import ALttPItem, LTTPRegionType
|
||||
from worlds.AutoWorld import World, WebWorld, LogicMixin
|
||||
from .StateHelpers import can_buy_unlimited
|
||||
from .SubClasses import ALttPItem, LTTPRegionType
|
||||
|
||||
lttp_logger = logging.getLogger("A Link to the Past")
|
||||
|
||||
@@ -132,7 +131,8 @@ class ALTTPWorld(World):
|
||||
Ganon!
|
||||
"""
|
||||
game = "A Link to the Past"
|
||||
option_definitions = alttp_options
|
||||
options_dataclass = ALTTPOptions
|
||||
options: ALTTPOptions
|
||||
settings_key = "lttp_options"
|
||||
settings: typing.ClassVar[ALTTPSettings]
|
||||
topology_present = True
|
||||
@@ -286,13 +286,22 @@ class ALTTPWorld(World):
|
||||
if not os.path.exists(rom_file):
|
||||
raise FileNotFoundError(rom_file)
|
||||
if multiworld.is_race:
|
||||
import xxtea
|
||||
import xxtea # noqa
|
||||
for player in multiworld.get_game_players(cls.game):
|
||||
if multiworld.worlds[player].use_enemizer:
|
||||
check_enemizer(multiworld.worlds[player].enemizer_path)
|
||||
break
|
||||
|
||||
def generate_early(self):
|
||||
# write old options
|
||||
import dataclasses
|
||||
is_first = self.player == min(self.multiworld.get_game_players(self.game))
|
||||
|
||||
for field in dataclasses.fields(self.options_dataclass):
|
||||
if is_first:
|
||||
setattr(self.multiworld, field.name, {})
|
||||
getattr(self.multiworld, field.name)[self.player] = getattr(self.options, field.name)
|
||||
# end of old options re-establisher
|
||||
|
||||
player = self.player
|
||||
multiworld = self.multiworld
|
||||
@@ -536,12 +545,10 @@ class ALTTPWorld(World):
|
||||
|
||||
@property
|
||||
def use_enemizer(self) -> bool:
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
return bool(world.boss_shuffle[player] or world.enemy_shuffle[player]
|
||||
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
||||
or world.pot_shuffle[player] or world.bush_shuffle[player]
|
||||
or world.killable_thieves[player])
|
||||
return bool(self.options.boss_shuffle or self.options.enemy_shuffle
|
||||
or self.options.enemy_health != 'default' or self.options.enemy_damage != 'default'
|
||||
or self.options.pot_shuffle or self.options.bush_shuffle
|
||||
or self.options.killable_thieves)
|
||||
|
||||
def generate_output(self, output_directory: str):
|
||||
multiworld = self.multiworld
|
||||
|
||||
32
worlds/alttp/docs/fr_A Link to the Past.md
Normal file
32
worlds/alttp/docs/fr_A Link to the Past.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# A Link to the Past
|
||||
|
||||
## Où se trouve la page des paramètres ?
|
||||
|
||||
La [page des paramètres du joueur pour ce jeu](../player-options) contient tous les paramètres dont vous avez besoin
|
||||
pour configurer et exporter le fichier.
|
||||
|
||||
## Quel est l'effet de la randomisation sur ce jeu ?
|
||||
|
||||
Les objets que le joueur devrait normalement obtenir au cours du jeu ont été déplacés. Il y a tout de même une logique
|
||||
pour que le jeu puisse être terminé, mais dû au mélange des objets, le joueur peut avoir besoin d'accéder à certaines
|
||||
zones plus tôt que dans le jeu original.
|
||||
|
||||
## Quels sont les objets et endroits mélangés ?
|
||||
|
||||
Tous les objets principaux, les collectibles et munitions peuvent être mélangés, et tous les endroits qui
|
||||
pourraient contenir un de ces objets peuvent avoir leur contenu modifié.
|
||||
|
||||
## Quels objets peuvent être dans le monde d'un autre joueur ?
|
||||
|
||||
Un objet pouvant être mélangé peut être aussi placé dans le monde d'un autre joueur. Il est possible de limiter certains
|
||||
objets à votre propre monde.
|
||||
|
||||
## À quoi ressemble un objet d'un autre monde dans LttP ?
|
||||
|
||||
Les objets appartenant à d'autres mondes sont représentés par une Étoile de Super Mario World.
|
||||
|
||||
## Quand le joueur reçoit un objet, que ce passe-t-il ?
|
||||
|
||||
Quand le joueur reçoit un objet, Link montrera l'objet au monde en le mettant au-dessus de sa tête. C'est bon pour
|
||||
les affaires !
|
||||
|
||||
@@ -1,41 +1,28 @@
|
||||
# Guide d'installation du MultiWorld de A Link to the Past Randomizer
|
||||
|
||||
<div id="tutorial-video-container">
|
||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/mJKEHaiyR_Y" frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
## Logiciels requis
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
- [SNI](https://github.com/alttpo/sni/releases). Inclus avec l'installation d'Archipelago ci-dessus.
|
||||
- SNI n'est pas compatible avec (Q)Usb2Snes.
|
||||
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
|
||||
- Un émulateur capable d'éxécuter des scripts Lua
|
||||
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
|
||||
[BizHawk](https://tasvideos.org/BizHawk))
|
||||
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), ou une autre solution matérielle
|
||||
compatible
|
||||
- Le fichier ROM de la v1.0 japonaise, sûrement nommé `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
- Un émulateur capable de se connecter à SNI
|
||||
[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](https://tasvideos.org/BizHawk), ou
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 ou plus récent). Ou,
|
||||
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), ou une autre solution matérielle compatible. **À noter:
|
||||
les SNES minis ne sont pas encore supportés par SNI. Certains utilisateurs rapportent avoir du succès avec QUsb2Snes pour ce système,
|
||||
mais ce n'est pas supporté.**
|
||||
- Le fichier ROM de la v1.0 japonaise, habituellement nommé `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Procédure d'installation
|
||||
|
||||
### Installation sur Windows
|
||||
1. Téléchargez et installez [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). **L'installateur se situe dans la section "assets" en bas des informations de version**.
|
||||
|
||||
2. Si c'est la première fois que vous faites une génération locale ou un patch, il vous sera demandé votre fichier ROM de base. Il s'agit de votre fichier ROM Link to the Past japonais. Cet étape n'a besoin d'être faite qu'une seule fois.
|
||||
|
||||
1. Téléchargez et installez les utilitaires du MultiWorld à l'aide du lien au-dessus, faites attention à bien installer
|
||||
la version la plus récente.
|
||||
**Le fichier se situe dans la section "assets" en bas des informations de version**. Si vous voulez jouer des parties
|
||||
classiques de multiworld, téléchargez `Setup.BerserkerMultiWorld.exe`
|
||||
- Si vous voulez jouer à la version alternative avec le mélangeur de portes dans les donjons, vous téléchargez le
|
||||
fichier
|
||||
`Setup.BerserkerMultiWorld.Doors.exe`.
|
||||
- Durant le processus d'installation, il vous sera demandé de localiser votre ROM v1.0 japonaise. Si vous avez déjà
|
||||
installé le logiciel auparavant et qu'il s'agit simplement d'une mise à jour, la localisation de la ROM originale
|
||||
ne sera pas requise.
|
||||
- Il vous sera peut-être également demandé d'installer Microsoft Visual C++. Si vous le possédez déjà (possiblement
|
||||
parce qu'un jeu Steam l'a déjà installé), l'installateur ne reproposera pas de l'installer.
|
||||
|
||||
2. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme
|
||||
3. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme
|
||||
programme par défaut pour ouvrir vos ROMs.
|
||||
1. Extrayez votre dossier d'émulateur sur votre Bureau, ou à un endroit dont vous vous souviendrez.
|
||||
2. Faites un clic droit sur un fichier ROM et sélectionnez **Ouvrir avec...**
|
||||
@@ -44,58 +31,6 @@
|
||||
5. Naviguez dans les dossiers jusqu'au fichier `.exe` de votre émulateur et choisissez **Ouvrir**. Ce fichier
|
||||
devrait se trouver dans le dossier que vous avez extrait à la première étape.
|
||||
|
||||
### Installation sur Mac
|
||||
|
||||
- Des volontaires sont recherchés pour remplir cette section ! Contactez **Farrak Kilhn** sur Discord si vous voulez
|
||||
aider.
|
||||
|
||||
## Configurer son fichier YAML
|
||||
|
||||
### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ?
|
||||
|
||||
Votre fichier YAML contient un ensemble d'options de configuration qui fournissent au générateur des informations sur
|
||||
comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra fournir son propre fichier YAML. Cela permet
|
||||
à chaque joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld
|
||||
peuvent avoir différentes options.
|
||||
|
||||
### Où est-ce que j'obtiens un fichier YAML ?
|
||||
|
||||
La page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-options) vous permet de configurer vos
|
||||
paramètres personnels et de les exporter vers un fichier YAML.
|
||||
|
||||
### Configuration avancée du fichier YAML
|
||||
|
||||
Une version plus avancée du fichier YAML peut être créée en utilisant la page
|
||||
des [paramètres de pondération](/games/A Link to the Past/weighted-options), qui vous permet de configurer jusqu'à
|
||||
trois préréglages. Cette page a de nombreuses options qui sont essentiellement représentées avec des curseurs
|
||||
glissants. Cela vous permet de choisir quelles sont les chances qu'une certaine option apparaisse par rapport aux
|
||||
autres disponibles dans une même catégorie.
|
||||
|
||||
Par exemple, imaginez que le générateur crée un seau étiqueté "Mélange des cartes", et qu'il place un morceau de papier
|
||||
pour chaque sous-option. Imaginez également que la valeur pour "On" est 20 et la valeur pour "Off" est 40.
|
||||
|
||||
Dans cet exemple, il y a soixante morceaux de papier dans le seau : vingt pour "On" et quarante pour "Off". Quand le
|
||||
générateur décide s'il doit oui ou non activer le mélange des cartes pour votre partie, , il tire aléatoirement un
|
||||
papier dans le seau. Dans cet exemple, il y a de plus grandes chances d'avoir le mélange de cartes désactivé.
|
||||
|
||||
S'il y a une option dont vous ne voulez jamais, mettez simplement sa valeur à zéro. N'oubliez pas qu'il faut que pour
|
||||
chaque paramètre il faut au moins une option qui soit paramétrée sur un nombre strictement positif.
|
||||
|
||||
### Vérifier son fichier YAML
|
||||
|
||||
Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous pouvez le vérifier sur la page du
|
||||
[Validateur de YAML](/check).
|
||||
|
||||
## Générer une partie pour un joueur
|
||||
|
||||
1. Aller sur la page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-options), configurez vos options,
|
||||
et cliquez sur le bouton "Generate Game".
|
||||
2. Il vous sera alors présenté une page d'informations sur la seed, où vous pourrez télécharger votre patch.
|
||||
3. Double-cliquez sur le patch et l'émulateur devrait se lancer automatiquement avec la seed. Etant donné que le client
|
||||
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
|
||||
|
||||
## Rejoindre un MultiWorld
|
||||
|
||||
### Obtenir son patch et créer sa ROM
|
||||
|
||||
Quand vous rejoignez un multiworld, il vous sera demandé de fournir votre fichier YAML à celui qui héberge la partie ou
|
||||
@@ -109,35 +44,58 @@ automatiquement le client, et devrait créer la ROM dans le même dossier que vo
|
||||
|
||||
#### Avec un émulateur
|
||||
|
||||
Quand le client se lance automatiquement, QUsb2Snes devrait se lancer automatiquement également en arrière-plan. Si
|
||||
Quand le client se lance automatiquement, SNI devrait se lancer automatiquement également en arrière-plan. Si
|
||||
c'est la première fois qu'il démarre, il vous sera peut-être demandé de l'autoriser à communiquer à travers le pare-feu
|
||||
Windows.
|
||||
|
||||
#### snes9x-nwa
|
||||
|
||||
1. Cliquez sur 'Network Menu' et cochez **Enable Emu Network Control**
|
||||
2. Chargez votre ROM si ce n'est pas déjà fait.
|
||||
|
||||
##### snes9x-rr
|
||||
|
||||
1. Chargez votre ROM si ce n'est pas déjà fait.
|
||||
2. Cliquez sur le menu "File" et survolez l'option **Lua Scripting**
|
||||
3. Cliquez alors sur **New Lua Script Window...**
|
||||
4. Dans la nouvelle fenêtre, sélectionnez **Browse...**
|
||||
5. Dirigez vous vers le dossier où vous avez extrait snes9x-rr, allez dans le dossier `lua`, puis
|
||||
choisissez `multibridge.lua`
|
||||
6. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom
|
||||
dans le coin en haut à gauche.
|
||||
5. Sélectionnez le fichier lua connecteur inclus avec votre client
|
||||
- Recherchez `/SNI/lua/` dans votre fichier Archipelago.
|
||||
6. Si vous avez une erreur en chargeant le script indiquant `socket.dll missing` ou similaire, naviguez vers le fichier du
|
||||
lua que vous utilisez dans votre explorateur de fichiers et copiez le `socket.dll` à la base de votre installation snes9x.
|
||||
|
||||
#### BSNES-Plus
|
||||
|
||||
1. Chargez votre ROM si ce n'est pas déjà fait.
|
||||
2. L'émulateur devrait automatiquement se connecter lorsque SNI se lancera.
|
||||
|
||||
##### BizHawk
|
||||
|
||||
1. Assurez vous d'avoir le coeur BSNES chargé. Cela est possible en cliquant sur le menu "Tools" de BizHawk et suivant
|
||||
1. Assurez vous d'avoir le cœur BSNES chargé. Cela est possible en cliquant sur le menu "Tools" de BizHawk et suivant
|
||||
ces options de menu :
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Une fois le coeur changé, vous devez redémarrer BizHawk.
|
||||
- (≤ 2.8) `Config` 〉 `Cores` 〉 `SNES` 〉 `BSNES`
|
||||
- (≥ 2.9) `Config` 〉 `Preferred Cores` 〉 `SNES` 〉 `BSNESv115+`
|
||||
Une fois le cœur changé, rechargez le avec Ctrl+R (par défaut).
|
||||
2. Chargez votre ROM si ce n'est pas déjà fait.
|
||||
3. Cliquez sur le menu "Tools" et cliquez sur **Lua Console**
|
||||
4. Cliquez sur le bouton pour ouvrir un nouveau script Lua.
|
||||
5. Dirigez vous vers le dossier d'installation des utilitaires du MultiWorld, puis dans les dossiers suivants :
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Sélectionnez `luabridge.lua` et cliquez sur "Open".
|
||||
7. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom
|
||||
dans le coin en haut à gauche.
|
||||
3. Glissez et déposez le fichier `Connector.lua` que vous avez téléchargé ci-dessus sur la fenêtre principale EmuHawk.
|
||||
- Recherchez `/SNI/lua/` dans votre fichier Archipelago.
|
||||
- Vous pouvez aussi ouvrir la console Lua manuellement, cliquez sur `Script` 〉 `Open Script`, et naviguez sur `Connecteur.lua`
|
||||
avec le sélecteur de fichiers.
|
||||
|
||||
##### RetroArch 1.10.1 ou plus récent
|
||||
|
||||
Vous n'avez qu'à faire ces étapes qu'une fois.
|
||||
|
||||
1. Entrez dans le menu principal RetroArch
|
||||
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
|
||||
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
|
||||
Port des commandes réseau à 555355.
|
||||
|
||||

|
||||
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
|
||||
sélectionnez le.
|
||||
|
||||
Quand vous chargez une ROM, veillez a sélectionner un cœur **bsnes-mercury**. Ce sont les seuls cœurs qui autorisent les outils externs à lire les données d'une ROM.
|
||||
|
||||
#### Avec une solution matérielle
|
||||
|
||||
@@ -147,10 +105,7 @@ le maintenant. Les utilisateurs de SD2SNES et de FXPak Pro peuvent télécharger
|
||||
[sur cette page](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Fermez votre émulateur, qui s'est potentiellement lancé automatiquement.
|
||||
2. Fermez QUsb2Snes, qui s'est lancé automatiquement avec le client.
|
||||
3. Lancez la version appropriée de QUsb2Snes (v0.7.16).
|
||||
4. Lancer votre console et chargez la ROM.
|
||||
5. Remarquez que l'interface Web affiche "SNES Device: Connected", avec le nom de votre appareil.
|
||||
2. Lancez votre console et chargez la ROM.
|
||||
|
||||
### Se connecter au MultiServer
|
||||
|
||||
@@ -165,47 +120,6 @@ l'interface Web.
|
||||
|
||||
### Jouer au jeu
|
||||
|
||||
Une fois que l'interface Web affiche que la SNES et le serveur sont connectés, vous êtes prêt à jouer. Félicitations
|
||||
pour avoir rejoint un multiworld !
|
||||
|
||||
## Héberger un MultiWorld
|
||||
|
||||
La méthode recommandée pour héberger une partie est d'utiliser le service d'hébergement fourni par
|
||||
[le site](https://berserkermulti.world/generate). Le processus est relativement simple :
|
||||
|
||||
1. Récupérez les fichiers YAML des joueurs.
|
||||
2. Créez une archive zip contenant ces fichiers YAML.
|
||||
3. Téléversez l'archive zip sur le lien ci-dessus.
|
||||
4. Attendez un moment que les seed soient générées.
|
||||
5. Lorsque les seeds sont générées, vous serez redirigé vers une page d'informations "Seed Info".
|
||||
6. Cliquez sur "Create New Room". Cela vous amènera à la page du serveur. Fournissez le lien de cette page aux autres
|
||||
joueurs afin qu'ils puissent récupérer leurs patchs.
|
||||
**Note:** Les patchs fournis sur cette page permettront aux joueurs de se connecteur automatiquement au serveur,
|
||||
tandis que ceux de la page "Seed Info" non.
|
||||
7. Remarquez qu'un lien vers le traqueur du MultiWorld est en haut de la page de la salle. Vous devriez également
|
||||
fournir ce lien aux joueurs pour qu'ils puissent suivre la progression de la partie. N'importe quel personne voulant
|
||||
observer devrait avoir accès à ce lien.
|
||||
8. Une fois que tous les joueurs ont rejoint, vous pouvez commencer à jouer.
|
||||
|
||||
## Auto-tracking
|
||||
|
||||
Si vous voulez utiliser l'auto-tracking, plusieurs logiciels offrent cette possibilité.
|
||||
Le logiciel recommandé pour l'auto-tracking actuellement est
|
||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
||||
|
||||
### Installation
|
||||
|
||||
1. Téléchargez le fichier d'installation approprié pour votre ordinateur (Les utilisateurs Windows voudront le
|
||||
fichier `.msi`).
|
||||
2. Durant le processus d'installation, il vous sera peut-être demandé d'installer les outils "Microsoft Visual Studio
|
||||
Build Tools". Un lien est fourni durant l'installation d'OpenTracker, et celle des outils doit se faire manuellement.
|
||||
|
||||
### Activer l'auto-tracking
|
||||
|
||||
1. Une fois OpenTracker démarré, cliquez sur le menu "Tracking" en haut de la fenêtre, puis choisissez **
|
||||
AutoTracker...**
|
||||
2. Appuyez sur le bouton **Get Devices**
|
||||
3. Sélectionnez votre appareil SNES dans la liste déroulante.
|
||||
4. Si vous voulez tracquer les petites clés ainsi que les objets des donjons, cochez la case **Race Illegal Tracking**
|
||||
5. Cliquez sur le bouton **Start Autotracking**
|
||||
6. Fermez la fenêtre "AutoTracker" maintenant, elle n'est plus nécessaire
|
||||
Une fois que l'interface Web affiche que la SNES et le serveur sont connectés, vous êtes prêt à jouer. Félicitations,
|
||||
vous venez de rejoindre un multiworld ! Vous pouvez exécuter différentes commandes dans votre client. Pour plus d'informations
|
||||
sur ces commandes, vous pouvez utiliser `/help` pour les commandes locales et `!help` pour les commandes serveur.
|
||||
|
||||
BIN
worlds/alttp/docs/retroarch-network-commands-fr.png
Normal file
BIN
worlds/alttp/docs/retroarch-network-commands-fr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, PerGameCommonOptions, OptionGroup
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, PerGameCommonOptions, StartInventoryPool, OptionGroup
|
||||
import random
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ class BlasphemousDeathLink(DeathLink):
|
||||
|
||||
@dataclass
|
||||
class BlasphemousOptions(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
prie_dieu_warp: PrieDieuWarp
|
||||
skip_cutscenes: SkipCutscenes
|
||||
corpse_hints: CorpseHints
|
||||
|
||||
@@ -137,12 +137,6 @@ class BlasphemousWorld(World):
|
||||
]
|
||||
|
||||
skipped_items = []
|
||||
junk: int = 0
|
||||
|
||||
for item, count in self.options.start_inventory.value.items():
|
||||
for _ in range(count):
|
||||
skipped_items.append(item)
|
||||
junk += 1
|
||||
|
||||
skipped_items.extend(unrandomized_dict.values())
|
||||
|
||||
@@ -194,9 +188,6 @@ class BlasphemousWorld(World):
|
||||
for _ in range(count):
|
||||
pool.append(self.create_item(item["name"]))
|
||||
|
||||
for _ in range(junk):
|
||||
pool.append(self.create_item(self.get_filler_item_name()))
|
||||
|
||||
self.multiworld.itempool += pool
|
||||
|
||||
self.place_items_from_dict(unrandomized_dict)
|
||||
|
||||
@@ -684,38 +684,37 @@ class CV64PatchExtensions(APPatchExtension):
|
||||
|
||||
# Disable the 3HBs checking and setting flags when breaking them and enable their individual items checking and
|
||||
# setting flags instead.
|
||||
if options["multi_hit_breakables"]:
|
||||
rom_data.write_int32(0xE87F8, 0x00000000) # NOP
|
||||
rom_data.write_int16(0xE836C, 0x1000)
|
||||
rom_data.write_int32(0xE8B40, 0x0C0FF3CD) # JAL 0x803FCF34
|
||||
rom_data.write_int32s(0xBFCF34, patches.three_hit_item_flags_setter)
|
||||
# Villa foyer chandelier-specific functions (yeah, IDK why KCEK made different functions for this one)
|
||||
rom_data.write_int32(0xE7D54, 0x00000000) # NOP
|
||||
rom_data.write_int16(0xE7908, 0x1000)
|
||||
rom_data.write_byte(0xE7A5C, 0x10)
|
||||
rom_data.write_int32(0xE7F08, 0x0C0FF3DF) # JAL 0x803FCF7C
|
||||
rom_data.write_int32s(0xBFCF7C, patches.chandelier_item_flags_setter)
|
||||
rom_data.write_int32(0xE87F8, 0x00000000) # NOP
|
||||
rom_data.write_int16(0xE836C, 0x1000)
|
||||
rom_data.write_int32(0xE8B40, 0x0C0FF3CD) # JAL 0x803FCF34
|
||||
rom_data.write_int32s(0xBFCF34, patches.three_hit_item_flags_setter)
|
||||
# Villa foyer chandelier-specific functions (yeah, IDK why KCEK made different functions for this one)
|
||||
rom_data.write_int32(0xE7D54, 0x00000000) # NOP
|
||||
rom_data.write_int16(0xE7908, 0x1000)
|
||||
rom_data.write_byte(0xE7A5C, 0x10)
|
||||
rom_data.write_int32(0xE7F08, 0x0C0FF3DF) # JAL 0x803FCF7C
|
||||
rom_data.write_int32s(0xBFCF7C, patches.chandelier_item_flags_setter)
|
||||
|
||||
# New flag values to put in each 3HB vanilla flag's spot
|
||||
rom_data.write_int32(0x10C7C8, 0x8000FF48) # FoS dirge maiden rock
|
||||
rom_data.write_int32(0x10C7B0, 0x0200FF48) # FoS S1 bridge rock
|
||||
rom_data.write_int32(0x10C86C, 0x0010FF48) # CW upper rampart save nub
|
||||
rom_data.write_int32(0x10C878, 0x4000FF49) # CW Dracula switch slab
|
||||
rom_data.write_int32(0x10CAD8, 0x0100FF49) # Tunnel twin arrows slab
|
||||
rom_data.write_int32(0x10CAE4, 0x0004FF49) # Tunnel lonesome bucket pit rock
|
||||
rom_data.write_int32(0x10CB54, 0x4000FF4A) # UW poison parkour ledge
|
||||
rom_data.write_int32(0x10CB60, 0x0080FF4A) # UW skeleton crusher ledge
|
||||
rom_data.write_int32(0x10CBF0, 0x0008FF4A) # CC Behemoth crate
|
||||
rom_data.write_int32(0x10CC2C, 0x2000FF4B) # CC elevator pedestal
|
||||
rom_data.write_int32(0x10CC70, 0x0200FF4B) # CC lizard locker slab
|
||||
rom_data.write_int32(0x10CD88, 0x0010FF4B) # ToE pre-midsavepoint platforms ledge
|
||||
rom_data.write_int32(0x10CE6C, 0x4000FF4C) # ToSci invisible bridge crate
|
||||
rom_data.write_int32(0x10CF20, 0x0080FF4C) # CT inverted battery slab
|
||||
rom_data.write_int32(0x10CF2C, 0x0008FF4C) # CT inverted door slab
|
||||
rom_data.write_int32(0x10CF38, 0x8000FF4D) # CT final room door slab
|
||||
rom_data.write_int32(0x10CF44, 0x1000FF4D) # CT Renon slab
|
||||
rom_data.write_int32(0x10C908, 0x0008FF4D) # Villa foyer chandelier
|
||||
rom_data.write_byte(0x10CF37, 0x04) # pointer for CT final room door slab item data
|
||||
# New flag values to put in each 3HB vanilla flag's spot
|
||||
rom_data.write_int32(0x10C7C8, 0x8000FF48) # FoS dirge maiden rock
|
||||
rom_data.write_int32(0x10C7B0, 0x0200FF48) # FoS S1 bridge rock
|
||||
rom_data.write_int32(0x10C86C, 0x0010FF48) # CW upper rampart save nub
|
||||
rom_data.write_int32(0x10C878, 0x4000FF49) # CW Dracula switch slab
|
||||
rom_data.write_int32(0x10CAD8, 0x0100FF49) # Tunnel twin arrows slab
|
||||
rom_data.write_int32(0x10CAE4, 0x0004FF49) # Tunnel lonesome bucket pit rock
|
||||
rom_data.write_int32(0x10CB54, 0x4000FF4A) # UW poison parkour ledge
|
||||
rom_data.write_int32(0x10CB60, 0x0080FF4A) # UW skeleton crusher ledge
|
||||
rom_data.write_int32(0x10CBF0, 0x0008FF4A) # CC Behemoth crate
|
||||
rom_data.write_int32(0x10CC2C, 0x2000FF4B) # CC elevator pedestal
|
||||
rom_data.write_int32(0x10CC70, 0x0200FF4B) # CC lizard locker slab
|
||||
rom_data.write_int32(0x10CD88, 0x0010FF4B) # ToE pre-midsavepoint platforms ledge
|
||||
rom_data.write_int32(0x10CE6C, 0x4000FF4C) # ToSci invisible bridge crate
|
||||
rom_data.write_int32(0x10CF20, 0x0080FF4C) # CT inverted battery slab
|
||||
rom_data.write_int32(0x10CF2C, 0x0008FF4C) # CT inverted door slab
|
||||
rom_data.write_int32(0x10CF38, 0x8000FF4D) # CT final room door slab
|
||||
rom_data.write_int32(0x10CF44, 0x1000FF4D) # CT Renon slab
|
||||
rom_data.write_int32(0x10C908, 0x0008FF4D) # Villa foyer chandelier
|
||||
rom_data.write_byte(0x10CF37, 0x04) # pointer for CT final room door slab item data
|
||||
|
||||
# Once-per-frame gameplay checks
|
||||
rom_data.write_int32(0x6C848, 0x080FF40D) # J 0x803FD034
|
||||
|
||||
@@ -253,10 +253,10 @@ all_bosses = [
|
||||
}),
|
||||
DS3BossInfo("Lords of Cinder", 4100800, locations = {
|
||||
"KFF: Soul of the Lords",
|
||||
"FS: Billed Mask - Yuria after killing KFF boss",
|
||||
"FS: Black Dress - Yuria after killing KFF boss",
|
||||
"FS: Black Gauntlets - Yuria after killing KFF boss",
|
||||
"FS: Black Leggings - Yuria after killing KFF boss"
|
||||
"FS: Billed Mask - shop after killing Yuria",
|
||||
"FS: Black Dress - shop after killing Yuria",
|
||||
"FS: Black Gauntlets - shop after killing Yuria",
|
||||
"FS: Black Leggings - shop after killing Yuria"
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
@@ -764,29 +764,29 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
DS3LocationData("US -> RS", None),
|
||||
|
||||
# Yoel/Yuria of Londor
|
||||
DS3LocationData("FS: Soul Arrow - Yoel/Yuria", "Soul Arrow",
|
||||
DS3LocationData("FS: Soul Arrow - Yoel/Yuria shop", "Soul Arrow",
|
||||
static='99,0:-1:50000,110000,70000116:', missable=True, npc=True,
|
||||
shop=True),
|
||||
DS3LocationData("FS: Heavy Soul Arrow - Yoel/Yuria", "Heavy Soul Arrow",
|
||||
DS3LocationData("FS: Heavy Soul Arrow - Yoel/Yuria shop", "Heavy Soul Arrow",
|
||||
static='99,0:-1:50000,110000,70000116:',
|
||||
missable=True, npc=True, shop=True),
|
||||
DS3LocationData("FS: Magic Weapon - Yoel/Yuria", "Magic Weapon",
|
||||
DS3LocationData("FS: Magic Weapon - Yoel/Yuria shop", "Magic Weapon",
|
||||
static='99,0:-1:50000,110000,70000116:', missable=True, npc=True,
|
||||
shop=True),
|
||||
DS3LocationData("FS: Magic Shield - Yoel/Yuria", "Magic Shield",
|
||||
DS3LocationData("FS: Magic Shield - Yoel/Yuria shop", "Magic Shield",
|
||||
static='99,0:-1:50000,110000,70000116:', missable=True, npc=True,
|
||||
shop=True),
|
||||
DS3LocationData("FS: Soul Greatsword - Yoel/Yuria", "Soul Greatsword",
|
||||
DS3LocationData("FS: Soul Greatsword - Yoel/Yuria shop", "Soul Greatsword",
|
||||
static='99,0:-1:50000,110000,70000450,70000475:', missable=True,
|
||||
npc=True, shop=True),
|
||||
DS3LocationData("FS: Dark Hand - Yoel/Yuria", "Dark Hand", missable=True, npc=True),
|
||||
DS3LocationData("FS: Untrue White Ring - Yoel/Yuria", "Untrue White Ring", missable=True,
|
||||
DS3LocationData("FS: Dark Hand - Yuria shop", "Dark Hand", missable=True, npc=True),
|
||||
DS3LocationData("FS: Untrue White Ring - Yuria shop", "Untrue White Ring", missable=True,
|
||||
npc=True),
|
||||
DS3LocationData("FS: Untrue Dark Ring - Yoel/Yuria", "Untrue Dark Ring", missable=True,
|
||||
DS3LocationData("FS: Untrue Dark Ring - Yuria shop", "Untrue Dark Ring", missable=True,
|
||||
npc=True),
|
||||
DS3LocationData("FS: Londor Braille Divine Tome - Yoel/Yuria", "Londor Braille Divine Tome",
|
||||
DS3LocationData("FS: Londor Braille Divine Tome - Yuria shop", "Londor Braille Divine Tome",
|
||||
static='99,0:-1:40000,110000,70000116:', missable=True, npc=True),
|
||||
DS3LocationData("FS: Darkdrift - Yoel/Yuria", "Darkdrift", missable=True, drop=True,
|
||||
DS3LocationData("FS: Darkdrift - kill Yuria", "Darkdrift", missable=True, drop=True,
|
||||
npc=True), # kill her or kill Soul of Cinder
|
||||
|
||||
# Cornyx of the Great Swamp
|
||||
@@ -2476,13 +2476,13 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
"Firelink Leggings", boss=True, shop=True),
|
||||
|
||||
# Yuria (quest, after Soul of Cinder)
|
||||
DS3LocationData("FS: Billed Mask - Yuria after killing KFF boss", "Billed Mask",
|
||||
DS3LocationData("FS: Billed Mask - shop after killing Yuria", "Billed Mask",
|
||||
missable=True, npc=True),
|
||||
DS3LocationData("FS: Black Dress - Yuria after killing KFF boss", "Black Dress",
|
||||
DS3LocationData("FS: Black Dress - shop after killing Yuria", "Black Dress",
|
||||
missable=True, npc=True),
|
||||
DS3LocationData("FS: Black Gauntlets - Yuria after killing KFF boss", "Black Gauntlets",
|
||||
DS3LocationData("FS: Black Gauntlets - shop after killing Yuria", "Black Gauntlets",
|
||||
missable=True, npc=True),
|
||||
DS3LocationData("FS: Black Leggings - Yuria after killing KFF boss", "Black Leggings",
|
||||
DS3LocationData("FS: Black Leggings - shop after killing Yuria", "Black Leggings",
|
||||
missable=True, npc=True),
|
||||
],
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ if __name__ == '__main__':
|
||||
table += f"<tr><td>{html.escape(name)}</td><td>{html.escape(description)}</td></tr>\n"
|
||||
table += "</table>\n"
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), 'docs/locations_en.md'), 'r+') as f:
|
||||
with open(
|
||||
os.path.join(os.path.dirname(__file__), 'docs/locations_en.md'),
|
||||
'r+',
|
||||
encoding='utf-8'
|
||||
) as f:
|
||||
original = f.read()
|
||||
start_flag = "<!-- begin location table -->\n"
|
||||
start = original.index(start_flag) + len(start_flag)
|
||||
|
||||
@@ -1020,7 +1020,7 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>CKG: Drakeblood Helm - tomb, after killing AP mausoleum NPC</td><td>On the Drakeblood Knight after Oceiros fight, after defeating the Drakeblood Knight from the Serpent-Man Summoner</td></tr>
|
||||
<tr><td>CKG: Drakeblood Leggings - tomb, after killing AP mausoleum NPC</td><td>On the Drakeblood Knight after Oceiros fight, after defeating the Drakeblood Knight from the Serpent-Man Summoner</td></tr>
|
||||
<tr><td>CKG: Estus Shard - balcony</td><td>From the middle level of the first Consumed King's Gardens elevator, out the balcony and to the right</td></tr>
|
||||
<tr><td>CKG: Human Pine Resin - by lone stairway bottom</td><td>On the right side of the garden, following the wall past the entrance to the shortcut elevator building, in a toxic pool</td></tr>
|
||||
<tr><td>CKG: Human Pine Resin - pool by lift</td><td>On the right side of the garden, following the wall past the entrance to the shortcut elevator building, in a toxic pool</td></tr>
|
||||
<tr><td>CKG: Human Pine Resin - toxic pool, past rotunda</td><td>In between two platforms near the middle of the garden, by a tree in a toxic pool</td></tr>
|
||||
<tr><td>CKG: Magic Stoneplate Ring - mob drop before boss</td><td>Dropped by the Cathedral Knight closest to the Oceiros fog gate</td></tr>
|
||||
<tr><td>CKG: Ring of Sacrifice - under balcony</td><td>Along the right wall of the garden, next to the first elevator building</td></tr>
|
||||
@@ -1181,16 +1181,18 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Alluring Skull - Mortician's Ashes</td><td>Sold by Handmaid after giving Mortician's Ashes</td></tr>
|
||||
<tr><td>FS: Arstor's Spear - Ludleth for Greatwood</td><td>Boss weapon for Curse-Rotted Greatwood</td></tr>
|
||||
<tr><td>FS: Aural Decoy - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Billed Mask - Yuria after killing KFF boss</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Dress - Yuria after killing KFF boss</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Billed Mask - shop after killing Yuria</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Dress - shop after killing Yuria</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Fire Orb - Karla for Grave Warden Tome</td><td>Sold by Karla after giving her the Grave Warden Pyromancy Tome</td></tr>
|
||||
<tr><td>FS: Black Flame - Karla for Grave Warden Tome</td><td>Sold by Karla after giving her the Grave Warden Pyromancy Tome</td></tr>
|
||||
<tr><td>FS: Black Gauntlets - Yuria after killing KFF boss</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Gauntlets - shop after killing Yuria</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Hand Armor - shop after killing GA NPC</td><td>Sold by Handmaid after killing Black Hand Kumai</td></tr>
|
||||
<tr><td>FS: Black Hand Hat - shop after killing GA NPC</td><td>Sold by Handmaid after killing Black Hand Kumai</td></tr>
|
||||
<tr><td>FS: Black Iron Armor - shop after killing Tsorig</td><td>Sold by Handmaid after killing Knight Slayer Tsorig in Smouldering Lake</td></tr>
|
||||
<tr><td>FS: Black Iron Gauntlets - shop after killing Tsorig</td><td>Sold by Handmaid after killing Knight Slayer Tsorig in Smouldering Lake</td></tr>
|
||||
<tr><td>FS: Black Iron Helm - shop after killing Tsorig</td><td>Sold by Handmaid after killing Knight Slayer Tsorig in Smouldering Lake</td></tr>
|
||||
<tr><td>FS: Black Iron Leggings - shop after killing Tsorig</td><td>Sold by Handmaid after killing Knight Slayer Tsorig in Smouldering Lake</td></tr>
|
||||
<tr><td>FS: Black Leggings - Yuria after killing KFF boss</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Leggings - shop after killing Yuria</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Black Serpent - Ludleth for Wolnir</td><td>Boss weapon for High Lord Wolnir</td></tr>
|
||||
<tr><td>FS: Blessed Weapon - Irina for Tome of Lothric</td><td>Sold by Irina after giving her the Braille Divine Tome of Lothric</td></tr>
|
||||
<tr><td>FS: Blue Tearstone Ring - Greirat</td><td>Given by Greirat upon rescuing him from the High Wall cell</td></tr>
|
||||
@@ -1220,8 +1222,8 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Dancer's Leggings - shop after killing LC entry boss</td><td>Sold by Handmaid after defeating Dancer of the Boreal Valley</td></tr>
|
||||
<tr><td>FS: Dark Blade - Karla for Londor Tome</td><td>Sold by Irina or Karla after giving one the Londor Braille Divine Tome</td></tr>
|
||||
<tr><td>FS: Dark Edge - Karla</td><td>Sold by Karla after recruiting her, or in her ashes</td></tr>
|
||||
<tr><td>FS: Dark Hand - Yoel/Yuria</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Darkdrift - Yoel/Yuria</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Dark Hand - Yuria shop</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Darkdrift - kill Yuria</td><td>Dropped by Yuria upon death or quest completion.</td></tr>
|
||||
<tr><td>FS: Darkmoon Longbow - Ludleth for Aldrich</td><td>Boss weapon for Aldrich</td></tr>
|
||||
<tr><td>FS: Dead Again - Karla for Londor Tome</td><td>Sold by Irina or Karla after giving one the Londor Braille Divine Tome</td></tr>
|
||||
<tr><td>FS: Deep Protection - Karla for Deep Braille Tome</td><td>Sold by Irina or Karla after giving one the Deep Braille Divine Tome</td></tr>
|
||||
@@ -1264,6 +1266,9 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Exile Gauntlets - shop after killing NPCs in RS</td><td>Sold by Handmaid after killing the exiles just before Farron Keep</td></tr>
|
||||
<tr><td>FS: Exile Leggings - shop after killing NPCs in RS</td><td>Sold by Handmaid after killing the exiles just before Farron Keep</td></tr>
|
||||
<tr><td>FS: Exile Mask - shop after killing NPCs in RS</td><td>Sold by Handmaid after killing the exiles just before Farron Keep</td></tr>
|
||||
<tr><td>FS: Faraam Armor - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>FS: Faraam Boots - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>FS: Faraam Gauntlets - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>FS: Faraam Helm - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>FS: Farron Dart - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Farron Dart - shop</td><td>Sold by Handmaid</td></tr>
|
||||
@@ -1308,7 +1313,7 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Heal - Irina</td><td>Sold by Irina after recruiting her, or in her ashes</td></tr>
|
||||
<tr><td>FS: Heal Aid - shop</td><td>Sold by Handmaid</td></tr>
|
||||
<tr><td>FS: Heavy Soul Arrow - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Heavy Soul Arrow - Yoel/Yuria</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Heavy Soul Arrow - Yoel/Yuria shop</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Helm of Favor - shop after killing water reserve minibosses</td><td>Sold by Handmaid after killing Sulyvahn's Beasts in Water Reserve</td></tr>
|
||||
<tr><td>FS: Hidden Blessing - Dreamchaser's Ashes</td><td>Sold by Greirat after pillaging Irithyll</td></tr>
|
||||
<tr><td>FS: Hidden Blessing - Greirat from IBV</td><td>Sold by Greirat after pillaging Irithyll</td></tr>
|
||||
@@ -1338,7 +1343,7 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Lift Chamber Key - Leonhard</td><td>Given by Ringfinger Leonhard after acquiring a Pale Tongue.</td></tr>
|
||||
<tr><td>FS: Lightning Storm - Ludleth for Nameless</td><td>Boss weapon for Nameless King</td></tr>
|
||||
<tr><td>FS: Lloyd's Shield Ring - Paladin's Ashes</td><td>Sold by Handmaid after giving Paladin's Ashes</td></tr>
|
||||
<tr><td>FS: Londor Braille Divine Tome - Yoel/Yuria</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Londor Braille Divine Tome - Yuria shop</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Lorian's Armor - shop after killing GA boss</td><td>Sold by Handmaid after defeating Lothric, Younger Prince</td></tr>
|
||||
<tr><td>FS: Lorian's Gauntlets - shop after killing GA boss</td><td>Sold by Handmaid after defeating Lothric, Younger Prince</td></tr>
|
||||
<tr><td>FS: Lorian's Greatsword - Ludleth for Princes</td><td>Boss weapon for Twin Princes</td></tr>
|
||||
@@ -1347,9 +1352,9 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Lothric's Holy Sword - Ludleth for Princes</td><td>Boss weapon for Twin Princes</td></tr>
|
||||
<tr><td>FS: Magic Barrier - Irina for Tome of Lothric</td><td>Sold by Irina after giving her the Braille Divine Tome of Lothric</td></tr>
|
||||
<tr><td>FS: Magic Shield - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Magic Shield - Yoel/Yuria</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Magic Shield - Yoel/Yuria shop</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Magic Weapon - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Magic Weapon - Yoel/Yuria</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Magic Weapon - Yoel/Yuria shop</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Mail Breaker - Sirris for killing Creighton</td><td>Given by Sirris talking to her in Firelink Shrine after invading and vanquishing Creighton.</td></tr>
|
||||
<tr><td>FS: Master's Attire - NPC drop</td><td>Dropped by Sword Master</td></tr>
|
||||
<tr><td>FS: Master's Gloves - NPC drop</td><td>Dropped by Sword Master</td></tr>
|
||||
@@ -1401,10 +1406,10 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Sneering Mask - Yoel's room, kill Londor Pale Shade twice</td><td>In Yoel/Yuria's area after defeating both Londor Pale Shade invasions</td></tr>
|
||||
<tr><td>FS: Soothing Sunlight - Ludleth for Dancer</td><td>Boss weapon for Dancer of the Boreal Valley</td></tr>
|
||||
<tr><td>FS: Soul Arrow - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Soul Arrow - Yoel/Yuria</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Soul Arrow - Yoel/Yuria shop</td><td>Sold by Yoel/Yuria</td></tr>
|
||||
<tr><td>FS: Soul Arrow - shop</td><td>Sold by Handmaid</td></tr>
|
||||
<tr><td>FS: Soul Greatsword - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
<tr><td>FS: Soul Greatsword - Yoel/Yuria</td><td>Sold by Yoel/Yuria after using Draw Out True Strength</td></tr>
|
||||
<tr><td>FS: Soul Greatsword - Yoel/Yuria shop</td><td>Sold by Yoel/Yuria after using Draw Out True Strength</td></tr>
|
||||
<tr><td>FS: Soul Spear - Orbeck for Logan's Scroll</td><td>Sold by Orbeck after giving him Logan's Scroll</td></tr>
|
||||
<tr><td>FS: Soul of a Deserted Corpse - bell tower door</td><td>Next to the door requiring the Tower Key</td></tr>
|
||||
<tr><td>FS: Spook - Orbeck</td><td>Sold by Orbeck</td></tr>
|
||||
@@ -1427,8 +1432,8 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FS: Undead Legion Gauntlet - shop after killing FK boss</td><td>Sold by Handmaid after defeating Abyss Watchers</td></tr>
|
||||
<tr><td>FS: Undead Legion Helm - shop after killing FK boss</td><td>Sold by Handmaid after defeating Abyss Watchers</td></tr>
|
||||
<tr><td>FS: Undead Legion Leggings - shop after killing FK boss</td><td>Sold by Handmaid after defeating Abyss Watchers</td></tr>
|
||||
<tr><td>FS: Untrue Dark Ring - Yoel/Yuria</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Untrue White Ring - Yoel/Yuria</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Untrue Dark Ring - Yuria shop</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Untrue White Ring - Yuria shop</td><td>Sold by Yuria</td></tr>
|
||||
<tr><td>FS: Vordt's Great Hammer - Ludleth for Vordt</td><td>Boss weapon for Vordt of the Boreal Valley</td></tr>
|
||||
<tr><td>FS: Vow of Silence - Karla for Londor Tome</td><td>Sold by Irina or Karla after giving one the Londor Braille Divine Tome</td></tr>
|
||||
<tr><td>FS: Washing Pole - Easterner's Ashes</td><td>Sold by Handmaid after giving Easterner's Ashes</td></tr>
|
||||
@@ -1477,8 +1482,6 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>FSBT: Twinkling Titanite - lizard behind Firelink</td><td>Dropped by the Crystal Lizard behind Firelink Shrine. Can be accessed with tree jump by going all the way around the roof, left of the entrance to the rafters, or alternatively dropping down from the Bell Tower.</td></tr>
|
||||
<tr><td>FSBT: Very good! Carving - crow for Divine Blessing</td><td>Trade Divine Blessing with crow</td></tr>
|
||||
<tr><td>GA: Avelyn - 1F, drop from 3F onto bookshelves</td><td>On top of a bookshelf on the Archive first floor, accessible by going halfway up the stairs to the third floor, dropping down past the Grand Archives Scholar, and then dropping down again</td></tr>
|
||||
<tr><td>GA: Black Hand Armor - shop after killing GA NPC</td><td>Sold by Handmaid after killing Black Hand Kumai</td></tr>
|
||||
<tr><td>GA: Black Hand Hat - shop after killing GA NPC</td><td>Sold by Handmaid after killing Black Hand Kumai</td></tr>
|
||||
<tr><td>GA: Blessed Gem - rafters</td><td>On the rafters high above the Archives, can be accessed by dropping down from the Winged Knight roof area</td></tr>
|
||||
<tr><td>GA: Chaos Gem - dark room, lizard</td><td>Dropped by a Crystal Lizard on the Archives first floor in the dark room past the large wax pool</td></tr>
|
||||
<tr><td>GA: Cinders of a Lord - Lothric Prince</td><td>Dropped by Twin Princes</td></tr>
|
||||
@@ -1489,9 +1492,6 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>GA: Divine Pillars of Light - cage above rafters</td><td>In a cage above the rafters high above the Archives, can be accessed by dropping down from the Winged Knight roof area</td></tr>
|
||||
<tr><td>GA: Ember - 5F, by entrance</td><td>On a balcony high in the Archives overlooking the area with the Grand Archives Scholars with a shortcut ladder, on the opposite side from the wax pool</td></tr>
|
||||
<tr><td>GA: Estus Shard - dome, far balcony</td><td>On the Archives roof near the three Winged Knights, in a side area overlooking the ocean.</td></tr>
|
||||
<tr><td>GA: Faraam Armor - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>GA: Faraam Boots - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>GA: Faraam Gauntlets - shop after killing GA NPC</td><td>Sold by Handmaid after killing Lion Knight Albert</td></tr>
|
||||
<tr><td>GA: Fleshbite Ring - up stairs from 4F</td><td>From the first shortcut elevator with the movable bookshelf, past the Scholars right before going outside onto the roof, in an alcove to the right with many Clawed Curse bookshelves</td></tr>
|
||||
<tr><td>GA: Golden Wing Crest Shield - outside 5F, NPC drop</td><td>Dropped by Lion Knight Albert before the stairs leading up to Twin Princes</td></tr>
|
||||
<tr><td>GA: Heavy Gem - rooftops, lizard</td><td>Dropped by one of the pair of Crystal Lizards, on the right side, found going up a slope past the gargoyle on the Archives roof</td></tr>
|
||||
@@ -1525,15 +1525,15 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>GA: Titanite Chunk - 2F, by wax pool</td><td>Up the stairs from the Archives second floor on the right side from the entrance, in a corner near the small wax pool</td></tr>
|
||||
<tr><td>GA: Titanite Chunk - 2F, right after dark room</td><td>Exiting from the dark room with the Crystal Lizards on the first floor onto the second floor main room, then taking an immediate right</td></tr>
|
||||
<tr><td>GA: Titanite Chunk - 5F, far balcony</td><td>On a balcony outside where Lothric Knight stands on the top floor of the Archives, accessing by going right from the final wax pool or by dropping down from the gargoyle area</td></tr>
|
||||
<tr><td>GA: Titanite Chunk - rooftops, balcony</td><td>Going onto the roof and down the first ladder, all the way down the ledge facing the ocean to the right</td></tr>
|
||||
<tr><td>GA: Titanite Chunk - rooftops lower, ledge by buttress</td><td>Going onto the roof and down the first ladder, dropping down on either side from the ledge facing the ocean, on a roof ledge to the right</td></tr>
|
||||
<tr><td>GA: Titanite Chunk - rooftops, balcony</td><td>Going onto the roof and down the first ladder, all the way down the ledge facing the ocean to the right</td></tr>
|
||||
<tr><td>GA: Titanite Chunk - rooftops, just before 5F</td><td>On the Archives roof, after a short dropdown, in the small area where the two Gargoyles attack you</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 1F, drop from 2F late onto bookshelves, lizard</td><td>Dropped by a Crystal Lizard on first floor bookshelves. Can be accessed by dropping down to the left at the end of the bridge which is the Crystal Sage's final location</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 1F, up stairs on bookshelf</td><td>On the Archives first floor, up a movable set of stairs near the large wax pool, on top of a bookshelf</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 2F, titanite scale atop bookshelf</td><td>On top of a bookshelf on the Archive second floor, accessible by going halfway up the stairs to the third floor and dropping down near a Grand Archives Scholar</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 3F, by ladder to 2F late</td><td>Going from the Crystal Sage's location on the third floor to its location on the bridge, on the left side of the ladder you descend, behind a table</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 3F, corner up stairs</td><td>From the Grand Archives third floor up past the thralls, in a corner with bookshelves to the left</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 5F, chest by exit</td><td>In a chest after the first elevator shortcut with the movable bookshelf, in the area with the Grand Archives Scholars, to the left of the stairwell leading up to the roof</td></tr>
|
||||
<tr><td>GA: Titanite Scale - 4F, chest by exit</td><td>In a chest after the first elevator shortcut with the movable bookshelf, in the area with the Grand Archives Scholars, to the left of the stairwell leading up to the roof</td></tr>
|
||||
<tr><td>GA: Titanite Scale - dark room, upstairs</td><td>Right after going up the stairs to the Archives second floor, on the left guarded by a Grand Archives Scholar and a sequence of Clawed Curse bookshelves</td></tr>
|
||||
<tr><td>GA: Titanite Scale - rooftops lower, path to 2F</td><td>Going onto the roof and down the first ladder, dropping down on either side from the ledge facing the ocean, then going past the corvians all the way to the left and making a jump</td></tr>
|
||||
<tr><td>GA: Titanite Slab - 1F, after pulling 2F switch</td><td>In a chest on the Archives first floor, behind a bookshelf moved by pulling a lever in the middle of the second floor between two cursed bookshelves</td></tr>
|
||||
@@ -1633,7 +1633,7 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>IBV: Large Soul of a Nameless Soldier - central, by bonfire</td><td>By the Central Irithyll bonfire</td></tr>
|
||||
<tr><td>IBV: Large Soul of a Nameless Soldier - central, by second fountain</td><td>Next to the fountain up the stairs from the Central Irithyll bonfire</td></tr>
|
||||
<tr><td>IBV: Large Soul of a Nameless Soldier - lake island</td><td>On an island in the lake leading to the Distant Manor bonfire</td></tr>
|
||||
<tr><td>IBV: Large Soul of a Nameless Soldier - stairs to plaza</td><td>On the path from Central Irithyll bonfire, before making the left toward Church of Yorshka</td></tr>
|
||||
<tr><td>IBV: Large Soul of a Nameless Soldier - path to plaza</td><td>On the path from Central Irithyll bonfire, before making the left toward Church of Yorshka</td></tr>
|
||||
<tr><td>IBV: Large Titanite Shard - Distant Manor, under overhang</td><td>Under overhang next to second set of stairs leading from Distant Manor bonfire</td></tr>
|
||||
<tr><td>IBV: Large Titanite Shard - ascent, by elevator door</td><td>On the path from the sewer leading up to Pontiff's cathedral, to the right of the statue surrounded by dogs</td></tr>
|
||||
<tr><td>IBV: Large Titanite Shard - ascent, down ladder in last building</td><td>Outside the final building before Pontiff's cathedral, coming from the sewer, dropping down to the left before the entrance</td></tr>
|
||||
@@ -1701,7 +1701,7 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>ID: Large Titanite Shard - B1 far, rightmost cell</td><td>In a cell on the far end of the top corridor opposite to the bonfire in Irithyll Dungeon, nearby the Jailer</td></tr>
|
||||
<tr><td>ID: Large Titanite Shard - B1 near, by door</td><td>At the end of the top corridor on the bonfire side in Irithyll Dungeon, before the Jailbreaker's Key door</td></tr>
|
||||
<tr><td>ID: Large Titanite Shard - B3 near, right corner</td><td>In the main Jailer cell block, to the left of the hallway leading to the Path of the Dragon area</td></tr>
|
||||
<tr><td>ID: Large Titanite Shard - after bonfire, second cell on right</td><td>In the second cell on the right after Irithyll Dungeon bonfire</td></tr>
|
||||
<tr><td>ID: Large Titanite Shard - after bonfire, second cell on left</td><td>In the second cell on the right after Irithyll Dungeon bonfire</td></tr>
|
||||
<tr><td>ID: Large Titanite Shard - pit #1</td><td>On the floor where the Giant Slave is standing</td></tr>
|
||||
<tr><td>ID: Large Titanite Shard - pit #2</td><td>On the floor where the Giant Slave is standing</td></tr>
|
||||
<tr><td>ID: Lightning Blade - B3 lift, middle platform</td><td>On the middle platform riding the elevator up from the Path of the Dragon area</td></tr>
|
||||
|
||||
@@ -72,8 +72,16 @@ class DLCqworld(World):
|
||||
|
||||
self.multiworld.itempool += created_items
|
||||
|
||||
if self.options.campaign == Options.Campaign.option_basic or self.options.campaign == Options.Campaign.option_both:
|
||||
self.multiworld.early_items[self.player]["Movement Pack"] = 1
|
||||
campaign = self.options.campaign
|
||||
has_both = campaign == Options.Campaign.option_both
|
||||
has_base = campaign == Options.Campaign.option_basic or has_both
|
||||
has_big_bundles = self.options.coinsanity and self.options.coinbundlequantity > 50
|
||||
early_items = self.multiworld.early_items
|
||||
if has_base:
|
||||
if has_both and has_big_bundles:
|
||||
early_items[self.player]["Incredibly Important Pack"] = 1
|
||||
else:
|
||||
early_items[self.player]["Movement Pack"] = 1
|
||||
|
||||
for item in items_to_exclude:
|
||||
if item in self.multiworld.itempool:
|
||||
@@ -82,7 +90,7 @@ class DLCqworld(World):
|
||||
def precollect_coinsanity(self):
|
||||
if self.options.campaign == Options.Campaign.option_basic:
|
||||
if self.options.coinsanity == Options.CoinSanity.option_coin and self.options.coinbundlequantity >= 5:
|
||||
self.multiworld.push_precollected(self.create_item("Movement Pack"))
|
||||
self.multiworld.push_precollected(self.create_item("DLC Quest: Coin Bundle"))
|
||||
|
||||
def create_item(self, item: Union[str, ItemData], classification: ItemClassification = None) -> DLCQuestItem:
|
||||
if isinstance(item, str):
|
||||
|
||||
@@ -16,9 +16,9 @@ class Goal(Choice):
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""
|
||||
Choose the difficulty option. Those match DOOM's difficulty options.
|
||||
baby (I'm too young to die.) double ammos, half damage, less monsters or strength.
|
||||
easy (Hey, not too rough.) less monsters or strength.
|
||||
Choose the game difficulty. These options match DOOM's skill levels.
|
||||
baby (I'm too young to die.) Same as easy, with double ammo pickups and half damage taken.
|
||||
easy (Hey, not too rough.) Less monsters or strength.
|
||||
medium (Hurt me plenty.) Default.
|
||||
hard (Ultra-Violence.) More monsters or strength.
|
||||
nightmare (Nightmare!) Monsters attack more rapidly and respawn.
|
||||
@@ -29,6 +29,11 @@ class Difficulty(Choice):
|
||||
option_medium = 2
|
||||
option_hard = 3
|
||||
option_nightmare = 4
|
||||
alias_itytd = 0
|
||||
alias_hntr = 1
|
||||
alias_hmp = 2
|
||||
alias_uv = 3
|
||||
alias_nm = 4
|
||||
default = 2
|
||||
|
||||
|
||||
@@ -112,7 +117,7 @@ class StartWithComputerAreaMaps(Toggle):
|
||||
class ResetLevelOnDeath(DefaultOnToggle):
|
||||
"""When dying, levels are reset and monsters respawned. But inventory and checks are kept.
|
||||
Turning this setting off is considered easy mode. Good for new players that don't know the levels well."""
|
||||
display_name="Reset Level on Death"
|
||||
display_name = "Reset Level on Death"
|
||||
|
||||
|
||||
class Episode1(DefaultOnToggle):
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
You can find the folder in steam by finding the game in your library,
|
||||
right-clicking it and choosing **Manage -> Browse Local Files**. The WAD file is in the `/base/` folder.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
## Joining a MultiWorld Game (via Launcher)
|
||||
|
||||
1. Launch apdoom-launcher.exe
|
||||
2. Select `Ultimate DOOM` from the drop-down
|
||||
@@ -28,6 +28,24 @@
|
||||
To continue a game, follow the same connection steps.
|
||||
Connecting with a different seed won't erase your progress in other seeds.
|
||||
|
||||
## Joining a MultiWorld Game (via command line)
|
||||
|
||||
1. In your command line, navigate to the directory where APDOOM is installed.
|
||||
2. Run `crispy-apdoom -game doom -apserver <server> -applayer <slot name>`, where:
|
||||
- `<server>` is the Archipelago server address, e.g. "`archipelago.gg:38281`"
|
||||
- `<slot name>` is your slot name; if it contains spaces, surround it with double quotes
|
||||
- If the server has a password, add `-password`, followed by the server password
|
||||
3. Enjoy!
|
||||
|
||||
Optionally, you can override some randomization settings from the command line:
|
||||
- `-apmonsterrando 0` will disable monster rando.
|
||||
- `-apitemrando 0` will disable item rando.
|
||||
- `-apmusicrando 0` will disable music rando.
|
||||
- `-apfliplevels 0` will disable flipping levels.
|
||||
- `-apresetlevelondeath 0` will disable resetting the level on death.
|
||||
- `-apdeathlinkoff` will force DeathLink off if it's enabled.
|
||||
- `-skill <1-5>` changes the game difficulty, from 1 (I'm too young to die) to 5 (Nightmare!)
|
||||
|
||||
## Archipelago Text Client
|
||||
|
||||
We recommend having Archipelago's Text Client open on the side to keep track of what items you receive and send.
|
||||
|
||||
@@ -6,9 +6,9 @@ from dataclasses import dataclass
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""
|
||||
Choose the difficulty option. Those match DOOM's difficulty options.
|
||||
baby (I'm too young to die.) double ammos, half damage, less monsters or strength.
|
||||
easy (Hey, not too rough.) less monsters or strength.
|
||||
Choose the game difficulty. These options match DOOM's skill levels.
|
||||
baby (I'm too young to die.) Same as easy, with double ammo pickups and half damage taken.
|
||||
easy (Hey, not too rough.) Less monsters or strength.
|
||||
medium (Hurt me plenty.) Default.
|
||||
hard (Ultra-Violence.) More monsters or strength.
|
||||
nightmare (Nightmare!) Monsters attack more rapidly and respawn.
|
||||
@@ -19,6 +19,11 @@ class Difficulty(Choice):
|
||||
option_medium = 2
|
||||
option_hard = 3
|
||||
option_nightmare = 4
|
||||
alias_itytd = 0
|
||||
alias_hntr = 1
|
||||
alias_hmp = 2
|
||||
alias_uv = 3
|
||||
alias_nm = 4
|
||||
default = 2
|
||||
|
||||
|
||||
@@ -102,7 +107,7 @@ class StartWithComputerAreaMaps(Toggle):
|
||||
class ResetLevelOnDeath(DefaultOnToggle):
|
||||
"""When dying, levels are reset and monsters respawned. But inventory and checks are kept.
|
||||
Turning this setting off is considered easy mode. Good for new players that don't know the levels well."""
|
||||
display_message="Reset level on death"
|
||||
display_name = "Reset Level on Death"
|
||||
|
||||
|
||||
class Episode1(DefaultOnToggle):
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
You can find the folder in steam by finding the game in your library,
|
||||
right clicking it and choosing *Manage→Browse Local Files*. The WAD file is in the `/base/` folder.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
## Joining a MultiWorld Game (via Launcher)
|
||||
|
||||
1. Launch apdoom-launcher.exe
|
||||
2. Select `DOOM II` from the drop-down
|
||||
@@ -26,6 +26,24 @@
|
||||
To continue a game, follow the same connection steps.
|
||||
Connecting with a different seed won't erase your progress in other seeds.
|
||||
|
||||
## Joining a MultiWorld Game (via command line)
|
||||
|
||||
1. In your command line, navigate to the directory where APDOOM is installed.
|
||||
2. Run `crispy-apdoom -game doom2 -apserver <server> -applayer <slot name>`, where:
|
||||
- `<server>` is the Archipelago server address, e.g. "`archipelago.gg:38281`"
|
||||
- `<slot name>` is your slot name; if it contains spaces, surround it with double quotes
|
||||
- If the server has a password, add `-password`, followed by the server password
|
||||
3. Enjoy!
|
||||
|
||||
Optionally, you can override some randomization settings from the command line:
|
||||
- `-apmonsterrando 0` will disable monster rando.
|
||||
- `-apitemrando 0` will disable item rando.
|
||||
- `-apmusicrando 0` will disable music rando.
|
||||
- `-apfliplevels 0` will disable flipping levels.
|
||||
- `-apresetlevelondeath 0` will disable resetting the level on death.
|
||||
- `-apdeathlinkoff` will force DeathLink off if it's enabled.
|
||||
- `-skill <1-5>` changes the game difficulty, from 1 (I'm too young to die) to 5 (Nightmare!)
|
||||
|
||||
## Archipelago Text Client
|
||||
|
||||
We recommend having Archipelago's Text Client open on the side to keep track of what items you receive and send.
|
||||
|
||||
@@ -6,7 +6,7 @@ import typing
|
||||
from schema import Schema, Optional, And, Or
|
||||
|
||||
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
|
||||
StartInventoryPool, PerGameCommonOptions
|
||||
StartInventoryPool, PerGameCommonOptions, OptionGroup
|
||||
|
||||
# schema helpers
|
||||
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
|
||||
@@ -272,6 +272,12 @@ class AtomicRocketTrapCount(TrapCount):
|
||||
display_name = "Atomic Rocket Traps"
|
||||
|
||||
|
||||
class AtomicCliffRemoverTrapCount(TrapCount):
|
||||
"""Trap items that when received trigger an atomic rocket explosion on a random cliff.
|
||||
Warning: there is no warning. The launch is instantaneous."""
|
||||
display_name = "Atomic Cliff Remover Traps"
|
||||
|
||||
|
||||
class EvolutionTrapCount(TrapCount):
|
||||
"""Trap items that when received increase the enemy evolution."""
|
||||
display_name = "Evolution Traps"
|
||||
@@ -293,7 +299,7 @@ class FactorioWorldGen(OptionDict):
|
||||
with in-depth documentation at https://lua-api.factorio.com/latest/Concepts.html#MapGenSettings"""
|
||||
display_name = "World Generation"
|
||||
# FIXME: do we want default be a rando-optimized default or in-game DS?
|
||||
value: typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||
value: dict[str, dict[str, typing.Any]]
|
||||
default = {
|
||||
"autoplace_controls": {
|
||||
# terrain
|
||||
@@ -402,7 +408,7 @@ class FactorioWorldGen(OptionDict):
|
||||
}
|
||||
})
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
def __init__(self, value: dict[str, typing.Any]):
|
||||
advanced = {"pollution", "enemy_evolution", "enemy_expansion"}
|
||||
self.value = {
|
||||
"basic": {k: v for k, v in value.items() if k not in advanced},
|
||||
@@ -421,7 +427,7 @@ class FactorioWorldGen(OptionDict):
|
||||
optional_min_lte_max(enemy_expansion, "min_expansion_cooldown", "max_expansion_cooldown")
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> FactorioWorldGen:
|
||||
def from_any(cls, data: dict[str, typing.Any]) -> FactorioWorldGen:
|
||||
if type(data) == dict:
|
||||
return cls(data)
|
||||
else:
|
||||
@@ -435,7 +441,7 @@ class ImportedBlueprint(DefaultOnToggle):
|
||||
|
||||
class EnergyLink(Toggle):
|
||||
"""Allow sending energy to other worlds. 25% of the energy is lost in the transfer."""
|
||||
display_name = "EnergyLink"
|
||||
display_name = "Energy Link"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -467,9 +473,42 @@ class FactorioOptions(PerGameCommonOptions):
|
||||
cluster_grenade_traps: ClusterGrenadeTrapCount
|
||||
artillery_traps: ArtilleryTrapCount
|
||||
atomic_rocket_traps: AtomicRocketTrapCount
|
||||
atomic_cliff_remover_traps: AtomicCliffRemoverTrapCount
|
||||
attack_traps: AttackTrapCount
|
||||
evolution_traps: EvolutionTrapCount
|
||||
evolution_trap_increase: EvolutionTrapIncrease
|
||||
death_link: DeathLink
|
||||
energy_link: EnergyLink
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
|
||||
|
||||
option_groups: list[OptionGroup] = [
|
||||
OptionGroup(
|
||||
"Technologies",
|
||||
[
|
||||
TechTreeLayout,
|
||||
Progressive,
|
||||
MinTechCost,
|
||||
MaxTechCost,
|
||||
TechCostDistribution,
|
||||
TechCostMix,
|
||||
RampingTechCosts,
|
||||
TechTreeInformation,
|
||||
]
|
||||
),
|
||||
OptionGroup(
|
||||
"Traps",
|
||||
[
|
||||
AttackTrapCount,
|
||||
EvolutionTrapCount,
|
||||
EvolutionTrapIncrease,
|
||||
TeleportTrapCount,
|
||||
GrenadeTrapCount,
|
||||
ClusterGrenadeTrapCount,
|
||||
ArtilleryTrapCount,
|
||||
AtomicRocketTrapCount,
|
||||
AtomicCliffRemoverTrapCount,
|
||||
],
|
||||
start_collapsed=True
|
||||
),
|
||||
]
|
||||
|
||||
@@ -12,7 +12,8 @@ from worlds.LauncherComponents import Component, components, Type, launch_subpro
|
||||
from worlds.generic import Rules
|
||||
from .Locations import location_pools, location_table
|
||||
from .Mod import generate_mod
|
||||
from .Options import FactorioOptions, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution
|
||||
from .Options import (FactorioOptions, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal,
|
||||
TechCostDistribution, option_groups)
|
||||
from .Shapes import get_shapes
|
||||
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
|
||||
all_product_sources, required_technologies, get_rocket_requirements, \
|
||||
@@ -61,6 +62,7 @@ class FactorioWeb(WebWorld):
|
||||
"setup/en",
|
||||
["Berserker, Farrak Kilhn"]
|
||||
)]
|
||||
option_groups = option_groups
|
||||
|
||||
|
||||
class FactorioItem(Item):
|
||||
@@ -75,6 +77,7 @@ all_items["Grenade Trap"] = factorio_base_id - 4
|
||||
all_items["Cluster Grenade Trap"] = factorio_base_id - 5
|
||||
all_items["Artillery Trap"] = factorio_base_id - 6
|
||||
all_items["Atomic Rocket Trap"] = factorio_base_id - 7
|
||||
all_items["Atomic Cliff Remover Trap"] = factorio_base_id - 8
|
||||
|
||||
|
||||
class Factorio(World):
|
||||
@@ -140,6 +143,7 @@ class Factorio(World):
|
||||
self.options.grenade_traps + \
|
||||
self.options.cluster_grenade_traps + \
|
||||
self.options.atomic_rocket_traps + \
|
||||
self.options.atomic_cliff_remover_traps + \
|
||||
self.options.artillery_traps
|
||||
|
||||
location_pool = []
|
||||
@@ -192,7 +196,8 @@ class Factorio(World):
|
||||
def create_items(self) -> None:
|
||||
self.custom_technologies = self.set_custom_technologies()
|
||||
self.set_custom_recipes()
|
||||
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket")
|
||||
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket",
|
||||
"Atomic Cliff Remover")
|
||||
for trap_name in traps:
|
||||
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
|
||||
range(getattr(self.options,
|
||||
|
||||
@@ -28,12 +28,23 @@ function random_offset_position(position, offset)
|
||||
end
|
||||
|
||||
function fire_entity_at_players(entity_name, speed)
|
||||
local entities = {}
|
||||
for _, player in ipairs(game.forces["player"].players) do
|
||||
current_character = player.character
|
||||
if current_character ~= nil then
|
||||
current_character.surface.create_entity{name=entity_name,
|
||||
position=random_offset_position(current_character.position, 128),
|
||||
target=current_character, speed=speed}
|
||||
if player.character ~= nil then
|
||||
table.insert(entities, player.character)
|
||||
end
|
||||
end
|
||||
return fire_entity_at_entities(entity_name, entities, speed)
|
||||
end
|
||||
|
||||
function fire_entity_at_entities(entity_name, entities, speed)
|
||||
for _, current_entity in ipairs(entities) do
|
||||
local target = current_entity
|
||||
if target.health == nil then
|
||||
target = target.position
|
||||
end
|
||||
current_entity.surface.create_entity{name=entity_name,
|
||||
position=random_offset_position(current_entity.position, 128),
|
||||
target=target, speed=speed}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -737,6 +737,13 @@ end,
|
||||
["Atomic Rocket Trap"] = function ()
|
||||
fire_entity_at_players("atomic-rocket", 0.1)
|
||||
end,
|
||||
["Atomic Cliff Remover Trap"] = function ()
|
||||
local cliffs = game.surfaces["nauvis"].find_entities_filtered{type = "cliff"}
|
||||
|
||||
if #cliffs > 0 then
|
||||
fire_entity_at_entities("atomic-rocket", {cliffs[math.random(#cliffs)]}, 0.1)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
commands.add_command("ap-get-technology", "Grant a technology, used by the Archipelago Client.", function(call)
|
||||
|
||||
58
worlds/faxanadu/Items.py
Normal file
58
worlds/faxanadu/Items.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from BaseClasses import ItemClassification
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class ItemDef:
|
||||
def __init__(self,
|
||||
id: Optional[int],
|
||||
name: str,
|
||||
classification: ItemClassification,
|
||||
count: int,
|
||||
progression_count: int,
|
||||
prefill_location: Optional[str]):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.classification = classification
|
||||
self.count = count
|
||||
self.progression_count = progression_count
|
||||
self.prefill_location = prefill_location
|
||||
|
||||
|
||||
items: List[ItemDef] = [
|
||||
ItemDef(400000, 'Progressive Sword', ItemClassification.progression, 4, 0, None),
|
||||
ItemDef(400001, 'Progressive Armor', ItemClassification.progression, 3, 0, None),
|
||||
ItemDef(400002, 'Progressive Shield', ItemClassification.useful, 4, 0, None),
|
||||
ItemDef(400003, 'Spring Elixir', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400004, 'Mattock', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400005, 'Unlock Wingboots', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400006, 'Key Jack', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400007, 'Key Queen', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400008, 'Key King', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400009, 'Key Joker', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400010, 'Key Ace', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400011, 'Ring of Ruby', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400012, 'Ring of Dworf', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400013, 'Demons Ring', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400014, 'Black Onyx', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(None, 'Sky Spring Flow', ItemClassification.progression, 1, 0, 'Sky Spring'),
|
||||
ItemDef(None, 'Tower of Fortress Spring Flow', ItemClassification.progression, 1, 0, 'Tower of Fortress Spring'),
|
||||
ItemDef(None, 'Joker Spring Flow', ItemClassification.progression, 1, 0, 'Joker Spring'),
|
||||
ItemDef(400015, 'Deluge', ItemClassification.progression, 1, 0, None),
|
||||
ItemDef(400016, 'Thunder', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400017, 'Fire', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400018, 'Death', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400019, 'Tilte', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400020, 'Ring of Elf', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400021, 'Magical Rod', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400022, 'Pendant', ItemClassification.useful, 1, 0, None),
|
||||
ItemDef(400023, 'Hourglass', ItemClassification.filler, 6, 0, None),
|
||||
# We need at least 4 red potions for the Tower of Red Potion. Up to the player to save them up!
|
||||
ItemDef(400024, 'Red Potion', ItemClassification.filler, 15, 4, None),
|
||||
ItemDef(400025, 'Elixir', ItemClassification.filler, 4, 0, None),
|
||||
ItemDef(400026, 'Glove', ItemClassification.filler, 5, 0, None),
|
||||
ItemDef(400027, 'Ointment', ItemClassification.filler, 8, 0, None),
|
||||
ItemDef(400028, 'Poison', ItemClassification.trap, 13, 0, None),
|
||||
ItemDef(None, 'Killed Evil One', ItemClassification.progression, 1, 0, 'Evil One'),
|
||||
# Placeholder item so the game knows which shop slot to prefill wingboots
|
||||
ItemDef(400029, 'Wingboots', ItemClassification.useful, 0, 0, None),
|
||||
]
|
||||
199
worlds/faxanadu/Locations.py
Normal file
199
worlds/faxanadu/Locations.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class LocationType():
|
||||
world = 1 # Just standing there in the world
|
||||
hidden = 2 # Kill all monsters in the room to reveal, each "item room" counter tick.
|
||||
boss_reward = 3 # Kill a boss to reveal the item
|
||||
shop = 4 # Buy at a shop
|
||||
give = 5 # Given by an NPC
|
||||
spring = 6 # Activatable spring
|
||||
boss = 7 # Entity to kill to trigger the check
|
||||
|
||||
|
||||
class ItemType():
|
||||
unknown = 0 # Or don't care
|
||||
red_potion = 1
|
||||
|
||||
|
||||
class LocationDef:
|
||||
def __init__(self, id: Optional[int], name: str, region: str, type: int, original_item: int):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.region = region
|
||||
self.type = type
|
||||
self.original_item = original_item
|
||||
|
||||
|
||||
locations: List[LocationDef] = [
|
||||
# Eolis
|
||||
LocationDef(400100, 'Eolis Guru', 'Eolis', LocationType.give, ItemType.unknown),
|
||||
LocationDef(400101, 'Eolis Key Jack', 'Eolis', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400102, 'Eolis Hand Dagger', 'Eolis', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400103, 'Eolis Red Potion', 'Eolis', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400104, 'Eolis Elixir', 'Eolis', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400105, 'Eolis Deluge', 'Eolis', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Path to Apolune
|
||||
LocationDef(400106, 'Path to Apolune Magic Shield', 'Path to Apolune', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400107, 'Path to Apolune Death', 'Path to Apolune', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Apolune
|
||||
LocationDef(400108, 'Apolune Small Shield', 'Apolune', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400109, 'Apolune Hand Dagger', 'Apolune', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400110, 'Apolune Deluge', 'Apolune', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400111, 'Apolune Red Potion', 'Apolune', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400112, 'Apolune Key Jack', 'Apolune', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Tower of Trunk
|
||||
LocationDef(400113, 'Tower of Trunk Hidden Mattock', 'Tower of Trunk', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400114, 'Tower of Trunk Hidden Hourglass', 'Tower of Trunk', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400115, 'Tower of Trunk Boss Mattock', 'Tower of Trunk', LocationType.boss_reward, ItemType.unknown),
|
||||
|
||||
# Path to Forepaw
|
||||
LocationDef(400116, 'Path to Forepaw Hidden Red Potion', 'Path to Forepaw', LocationType.hidden, ItemType.red_potion),
|
||||
LocationDef(400117, 'Path to Forepaw Glove', 'Path to Forepaw', LocationType.world, ItemType.unknown),
|
||||
|
||||
# Forepaw
|
||||
LocationDef(400118, 'Forepaw Long Sword', 'Forepaw', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400119, 'Forepaw Studded Mail', 'Forepaw', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400120, 'Forepaw Small Shield', 'Forepaw', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400121, 'Forepaw Red Potion', 'Forepaw', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400122, 'Forepaw Wingboots', 'Forepaw', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400123, 'Forepaw Key Jack', 'Forepaw', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400124, 'Forepaw Key Queen', 'Forepaw', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Trunk
|
||||
LocationDef(400125, 'Trunk Hidden Ointment', 'Trunk', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400126, 'Trunk Hidden Red Potion', 'Trunk', LocationType.hidden, ItemType.red_potion),
|
||||
LocationDef(400127, 'Trunk Red Potion', 'Trunk', LocationType.world, ItemType.red_potion),
|
||||
LocationDef(None, 'Sky Spring', 'Trunk', LocationType.spring, ItemType.unknown),
|
||||
|
||||
# Joker Spring
|
||||
LocationDef(400128, 'Joker Spring Ruby Ring', 'Joker Spring', LocationType.give, ItemType.unknown),
|
||||
LocationDef(None, 'Joker Spring', 'Joker Spring', LocationType.spring, ItemType.unknown),
|
||||
|
||||
# Tower of Fortress
|
||||
LocationDef(400129, 'Tower of Fortress Poison 1', 'Tower of Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400130, 'Tower of Fortress Poison 2', 'Tower of Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400131, 'Tower of Fortress Hidden Wingboots', 'Tower of Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400132, 'Tower of Fortress Ointment', 'Tower of Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400133, 'Tower of Fortress Boss Wingboots', 'Tower of Fortress', LocationType.boss_reward, ItemType.unknown),
|
||||
LocationDef(400134, 'Tower of Fortress Elixir', 'Tower of Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400135, 'Tower of Fortress Guru', 'Tower of Fortress', LocationType.give, ItemType.unknown),
|
||||
LocationDef(None, 'Tower of Fortress Spring', 'Tower of Fortress', LocationType.spring, ItemType.unknown),
|
||||
|
||||
# Path to Mascon
|
||||
LocationDef(400136, 'Path to Mascon Hidden Wingboots', 'Path to Mascon', LocationType.hidden, ItemType.unknown),
|
||||
|
||||
# Tower of Red Potion
|
||||
LocationDef(400137, 'Tower of Red Potion', 'Tower of Red Potion', LocationType.world, ItemType.red_potion),
|
||||
|
||||
# Mascon
|
||||
LocationDef(400138, 'Mascon Large Shield', 'Mascon', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400139, 'Mascon Thunder', 'Mascon', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400140, 'Mascon Mattock', 'Mascon', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400141, 'Mascon Red Potion', 'Mascon', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400142, 'Mascon Key Jack', 'Mascon', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400143, 'Mascon Key Queen', 'Mascon', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Path to Victim
|
||||
LocationDef(400144, 'Misty Shop Death', 'Path to Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400145, 'Misty Shop Hourglass', 'Path to Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400146, 'Misty Shop Elixir', 'Path to Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400147, 'Misty Shop Red Potion', 'Path to Victim', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400148, 'Misty Doctor Office', 'Path to Victim', LocationType.hidden, ItemType.unknown),
|
||||
|
||||
# Tower of Suffer
|
||||
LocationDef(400149, 'Tower of Suffer Hidden Wingboots', 'Tower of Suffer', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400150, 'Tower of Suffer Hidden Hourglass', 'Tower of Suffer', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400151, 'Tower of Suffer Pendant', 'Tower of Suffer', LocationType.boss_reward, ItemType.unknown),
|
||||
|
||||
# Victim
|
||||
LocationDef(400152, 'Victim Full Plate', 'Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400153, 'Victim Mattock', 'Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400154, 'Victim Red Potion', 'Victim', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400155, 'Victim Key King', 'Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400156, 'Victim Key Queen', 'Victim', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400157, 'Victim Tavern', 'Mist', LocationType.give, ItemType.unknown),
|
||||
|
||||
# Mist
|
||||
LocationDef(400158, 'Mist Hidden Poison 1', 'Mist', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400159, 'Mist Hidden Poison 2', 'Mist', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400160, 'Mist Hidden Wingboots', 'Mist', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400161, 'Misty Magic Hall', 'Mist', LocationType.give, ItemType.unknown),
|
||||
LocationDef(400162, 'Misty House', 'Mist', LocationType.give, ItemType.unknown),
|
||||
|
||||
# Useless Tower
|
||||
LocationDef(400163, 'Useless Tower', 'Useless Tower', LocationType.hidden, ItemType.unknown),
|
||||
|
||||
# Tower of Mist
|
||||
LocationDef(400164, 'Tower of Mist Hidden Ointment', 'Tower of Mist', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400165, 'Tower of Mist Elixir', 'Tower of Mist', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400166, 'Tower of Mist Black Onyx', 'Tower of Mist', LocationType.boss_reward, ItemType.unknown),
|
||||
|
||||
# Path to Conflate
|
||||
LocationDef(400167, 'Path to Conflate Hidden Ointment', 'Path to Conflate', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400168, 'Path to Conflate Poison', 'Path to Conflate', LocationType.hidden, ItemType.unknown),
|
||||
|
||||
# Helm Branch
|
||||
LocationDef(400169, 'Helm Branch Hidden Glove', 'Helm Branch', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400170, 'Helm Branch Battle Helmet', 'Helm Branch', LocationType.boss_reward, ItemType.unknown),
|
||||
|
||||
# Conflate
|
||||
LocationDef(400171, 'Conflate Giant Blade', 'Conflate', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400172, 'Conflate Magic Shield', 'Conflate', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400173, 'Conflate Wingboots', 'Conflate', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400174, 'Conflate Red Potion', 'Conflate', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400175, 'Conflate Guru', 'Conflate', LocationType.give, ItemType.unknown),
|
||||
|
||||
# Branches
|
||||
LocationDef(400176, 'Branches Hidden Ointment', 'Branches', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400177, 'Branches Poison', 'Branches', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400178, 'Branches Hidden Mattock', 'Branches', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400179, 'Branches Hidden Hourglass', 'Branches', LocationType.hidden, ItemType.unknown),
|
||||
|
||||
# Path to Daybreak
|
||||
LocationDef(400180, 'Path to Daybreak Hidden Wingboots 1', 'Path to Daybreak', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400181, 'Path to Daybreak Magical Rod', 'Path to Daybreak', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400182, 'Path to Daybreak Hidden Wingboots 2', 'Path to Daybreak', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400183, 'Path to Daybreak Poison', 'Path to Daybreak', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400184, 'Path to Daybreak Glove', 'Path to Daybreak', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400185, 'Path to Daybreak Battle Suit', 'Path to Daybreak', LocationType.boss_reward, ItemType.unknown),
|
||||
|
||||
# Daybreak
|
||||
LocationDef(400186, 'Daybreak Tilte', 'Daybreak', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400187, 'Daybreak Giant Blade', 'Daybreak', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400188, 'Daybreak Red Potion', 'Daybreak', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400189, 'Daybreak Key King', 'Daybreak', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400190, 'Daybreak Key Queen', 'Daybreak', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Dartmoor Castle
|
||||
LocationDef(400191, 'Dartmoor Castle Hidden Hourglass', 'Dartmoor Castle', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400192, 'Dartmoor Castle Hidden Red Potion', 'Dartmoor Castle', LocationType.hidden, ItemType.red_potion),
|
||||
|
||||
# Dartmoor
|
||||
LocationDef(400193, 'Dartmoor Giant Blade', 'Dartmoor', LocationType.shop, ItemType.unknown),
|
||||
LocationDef(400194, 'Dartmoor Red Potion', 'Dartmoor', LocationType.shop, ItemType.red_potion),
|
||||
LocationDef(400195, 'Dartmoor Key King', 'Dartmoor', LocationType.shop, ItemType.unknown),
|
||||
|
||||
# Fraternal Castle
|
||||
LocationDef(400196, 'Fraternal Castle Hidden Ointment', 'Fraternal Castle', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400197, 'Fraternal Castle Shop Hidden Ointment', 'Fraternal Castle', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400198, 'Fraternal Castle Poison 1', 'Fraternal Castle', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400199, 'Fraternal Castle Poison 2', 'Fraternal Castle', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400200, 'Fraternal Castle Poison 3', 'Fraternal Castle', LocationType.world, ItemType.unknown),
|
||||
# LocationDef(400201, 'Fraternal Castle Red Potion', 'Fraternal Castle', LocationType.world, ItemType.red_potion), # This location is inaccessible. Keeping commented for context.
|
||||
LocationDef(400202, 'Fraternal Castle Hidden Hourglass', 'Fraternal Castle', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(400203, 'Fraternal Castle Dragon Slayer', 'Fraternal Castle', LocationType.boss_reward, ItemType.unknown),
|
||||
LocationDef(400204, 'Fraternal Castle Guru', 'Fraternal Castle', LocationType.give, ItemType.unknown),
|
||||
|
||||
# Evil Fortress
|
||||
LocationDef(400205, 'Evil Fortress Ointment', 'Evil Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400206, 'Evil Fortress Poison 1', 'Evil Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400207, 'Evil Fortress Glove', 'Evil Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400208, 'Evil Fortress Poison 2', 'Evil Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400209, 'Evil Fortress Poison 3', 'Evil Fortress', LocationType.world, ItemType.unknown),
|
||||
LocationDef(400210, 'Evil Fortress Hidden Glove', 'Evil Fortress', LocationType.hidden, ItemType.unknown),
|
||||
LocationDef(None, 'Evil One', 'Evil Fortress', LocationType.boss, ItemType.unknown),
|
||||
]
|
||||
107
worlds/faxanadu/Options.py
Normal file
107
worlds/faxanadu/Options.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from Options import PerGameCommonOptions, Toggle, DefaultOnToggle, StartInventoryPool, Choice
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class KeepShopRedPotions(Toggle):
|
||||
"""
|
||||
Prevents the Shop's Red Potions from being shuffled. Those locations
|
||||
will have purchasable Red Potion as usual for their usual price.
|
||||
"""
|
||||
display_name = "Keep Shop Red Potions"
|
||||
|
||||
|
||||
class IncludePendant(Toggle):
|
||||
"""
|
||||
Pendant is an item that boosts your attack power permanently when picked up.
|
||||
However, due to a programming error in the original game, it has the reverse
|
||||
effect. You start with the Pendant power, and lose it when picking
|
||||
it up. So this item is essentially a trap.
|
||||
There is a setting in the client to reverse the effect back to its original intend.
|
||||
This could be used in conjunction with this option to increase or lower difficulty.
|
||||
"""
|
||||
display_name = "Include Pendant"
|
||||
|
||||
|
||||
class IncludePoisons(DefaultOnToggle):
|
||||
"""
|
||||
Whether or not to include Poison Potions in the pool of items. Including them
|
||||
effectively turn them into traps in multiplayer.
|
||||
"""
|
||||
display_name = "Include Poisons"
|
||||
|
||||
|
||||
class RequireDragonSlayer(Toggle):
|
||||
"""
|
||||
Requires the Dragon Slayer to be available before fighting the final boss is required.
|
||||
Turning this on will turn Progressive Shields into progression items.
|
||||
|
||||
This setting does not force you to use Dragon Slayer to kill the final boss.
|
||||
Instead, it ensures that you will have the Dragon Slayer and be able to equip
|
||||
it before you are expected to beat the final boss.
|
||||
"""
|
||||
display_name = "Require Dragon Slayer"
|
||||
|
||||
|
||||
class RandomMusic(Toggle):
|
||||
"""
|
||||
All levels' music is shuffled. Except the title screen because it's finite.
|
||||
This is an aesthetic option and doesn't affect gameplay.
|
||||
"""
|
||||
display_name = "Random Musics"
|
||||
|
||||
|
||||
class RandomSound(Toggle):
|
||||
"""
|
||||
All sounds are shuffled.
|
||||
This is an aesthetic option and doesn't affect gameplay.
|
||||
"""
|
||||
display_name = "Random Sounds"
|
||||
|
||||
|
||||
class RandomNPC(Toggle):
|
||||
"""
|
||||
NPCs and their portraits are shuffled.
|
||||
This is an aesthetic option and doesn't affect gameplay.
|
||||
"""
|
||||
display_name = "Random NPCs"
|
||||
|
||||
|
||||
class RandomMonsters(Choice):
|
||||
"""
|
||||
Choose how monsters are randomized.
|
||||
"Vanilla": No randomization
|
||||
"Level Shuffle": Monsters are shuffled within a level
|
||||
"Level Random": Monsters are picked randomly, balanced based on the ratio of the current level
|
||||
"World Shuffle": Monsters are shuffled across the entire world
|
||||
"World Random": Monsters are picked randomly, balanced based on the ratio of the entire world
|
||||
"Chaotic": Completely random, except big vs small ratio is kept. Big are mini-bosses.
|
||||
"""
|
||||
display_name = "Random Monsters"
|
||||
option_vanilla = 0
|
||||
option_level_shuffle = 1
|
||||
option_level_random = 2
|
||||
option_world_shuffle = 3
|
||||
option_world_random = 4
|
||||
option_chaotic = 5
|
||||
default = 0
|
||||
|
||||
|
||||
class RandomRewards(Toggle):
|
||||
"""
|
||||
Monsters drops are shuffled.
|
||||
"""
|
||||
display_name = "Random Rewards"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FaxanaduOptions(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
keep_shop_red_potions: KeepShopRedPotions
|
||||
include_pendant: IncludePendant
|
||||
include_poisons: IncludePoisons
|
||||
require_dragon_slayer: RequireDragonSlayer
|
||||
random_musics: RandomMusic
|
||||
random_sounds: RandomSound
|
||||
random_npcs: RandomNPC
|
||||
random_monsters: RandomMonsters
|
||||
random_rewards: RandomRewards
|
||||
66
worlds/faxanadu/Regions.py
Normal file
66
worlds/faxanadu/Regions.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from BaseClasses import Region
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import FaxanaduWorld
|
||||
|
||||
|
||||
def create_region(name, player, multiworld):
|
||||
region = Region(name, player, multiworld)
|
||||
multiworld.regions.append(region)
|
||||
return region
|
||||
|
||||
|
||||
def create_regions(faxanadu_world: "FaxanaduWorld"):
|
||||
player = faxanadu_world.player
|
||||
multiworld = faxanadu_world.multiworld
|
||||
|
||||
# Create regions
|
||||
menu = create_region("Menu", player, multiworld)
|
||||
eolis = create_region("Eolis", player, multiworld)
|
||||
path_to_apolune = create_region("Path to Apolune", player, multiworld)
|
||||
apolune = create_region("Apolune", player, multiworld)
|
||||
create_region("Tower of Trunk", player, multiworld)
|
||||
path_to_forepaw = create_region("Path to Forepaw", player, multiworld)
|
||||
forepaw = create_region("Forepaw", player, multiworld)
|
||||
trunk = create_region("Trunk", player, multiworld)
|
||||
create_region("Joker Spring", player, multiworld)
|
||||
create_region("Tower of Fortress", player, multiworld)
|
||||
path_to_mascon = create_region("Path to Mascon", player, multiworld)
|
||||
create_region("Tower of Red Potion", player, multiworld)
|
||||
mascon = create_region("Mascon", player, multiworld)
|
||||
path_to_victim = create_region("Path to Victim", player, multiworld)
|
||||
create_region("Tower of Suffer", player, multiworld)
|
||||
victim = create_region("Victim", player, multiworld)
|
||||
mist = create_region("Mist", player, multiworld)
|
||||
create_region("Useless Tower", player, multiworld)
|
||||
create_region("Tower of Mist", player, multiworld)
|
||||
path_to_conflate = create_region("Path to Conflate", player, multiworld)
|
||||
create_region("Helm Branch", player, multiworld)
|
||||
create_region("Conflate", player, multiworld)
|
||||
branches = create_region("Branches", player, multiworld)
|
||||
path_to_daybreak = create_region("Path to Daybreak", player, multiworld)
|
||||
daybreak = create_region("Daybreak", player, multiworld)
|
||||
dartmoor_castle = create_region("Dartmoor Castle", player, multiworld)
|
||||
create_region("Dartmoor", player, multiworld)
|
||||
create_region("Fraternal Castle", player, multiworld)
|
||||
create_region("Evil Fortress", player, multiworld)
|
||||
|
||||
# Create connections
|
||||
menu.add_exits(["Eolis"])
|
||||
eolis.add_exits(["Path to Apolune"])
|
||||
path_to_apolune.add_exits(["Apolune"])
|
||||
apolune.add_exits(["Tower of Trunk", "Path to Forepaw"])
|
||||
path_to_forepaw.add_exits(["Forepaw"])
|
||||
forepaw.add_exits(["Trunk"])
|
||||
trunk.add_exits(["Joker Spring", "Tower of Fortress", "Path to Mascon"])
|
||||
path_to_mascon.add_exits(["Tower of Red Potion", "Mascon"])
|
||||
mascon.add_exits(["Path to Victim"])
|
||||
path_to_victim.add_exits(["Tower of Suffer", "Victim"])
|
||||
victim.add_exits(["Mist"])
|
||||
mist.add_exits(["Useless Tower", "Tower of Mist", "Path to Conflate"])
|
||||
path_to_conflate.add_exits(["Helm Branch", "Conflate", "Branches"])
|
||||
branches.add_exits(["Path to Daybreak"])
|
||||
path_to_daybreak.add_exits(["Daybreak"])
|
||||
daybreak.add_exits(["Dartmoor Castle"])
|
||||
dartmoor_castle.add_exits(["Dartmoor", "Fraternal Castle", "Evil Fortress"])
|
||||
79
worlds/faxanadu/Rules.py
Normal file
79
worlds/faxanadu/Rules.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from worlds.generic.Rules import set_rule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import FaxanaduWorld
|
||||
|
||||
|
||||
def can_buy_in_eolis(state, player):
|
||||
# Sword or Deluge so we can farm for gold.
|
||||
# Ring of Elf so we can get 1500 from the King.
|
||||
return state.has_any(["Progressive Sword", "Deluge", "Ring of Elf"], player)
|
||||
|
||||
|
||||
def has_any_magic(state, player):
|
||||
return state.has_any(["Deluge", "Thunder", "Fire", "Death", "Tilte"], player)
|
||||
|
||||
|
||||
def set_rules(faxanadu_world: "FaxanaduWorld"):
|
||||
player = faxanadu_world.player
|
||||
multiworld = faxanadu_world.multiworld
|
||||
|
||||
# Region rules
|
||||
set_rule(multiworld.get_entrance("Eolis -> Path to Apolune", player), lambda state:
|
||||
state.has_all(["Key Jack", "Progressive Sword"], player)) # You can't go far with magic only
|
||||
set_rule(multiworld.get_entrance("Apolune -> Tower of Trunk", player), lambda state: state.has("Key Jack", player))
|
||||
set_rule(multiworld.get_entrance("Apolune -> Path to Forepaw", player), lambda state: state.has("Mattock", player))
|
||||
set_rule(multiworld.get_entrance("Trunk -> Joker Spring", player), lambda state: state.has("Key Joker", player))
|
||||
set_rule(multiworld.get_entrance("Trunk -> Tower of Fortress", player), lambda state: state.has("Key Jack", player))
|
||||
set_rule(multiworld.get_entrance("Trunk -> Path to Mascon", player), lambda state:
|
||||
state.has_all(["Key Queen", "Ring of Ruby", "Sky Spring Flow", "Tower of Fortress Spring Flow", "Joker Spring Flow"], player) and
|
||||
state.has("Progressive Sword", player, 2))
|
||||
set_rule(multiworld.get_entrance("Path to Mascon -> Tower of Red Potion", player), lambda state:
|
||||
state.has("Key Queen", player) and
|
||||
state.has("Red Potion", player, 4)) # It's impossible to go through the tower of Red Potion without at least 1-2 potions. Give them 4 for good measure.
|
||||
set_rule(multiworld.get_entrance("Path to Victim -> Tower of Suffer", player), lambda state: state.has("Key Queen", player))
|
||||
set_rule(multiworld.get_entrance("Path to Victim -> Victim", player), lambda state: state.has("Unlock Wingboots", player))
|
||||
set_rule(multiworld.get_entrance("Mist -> Useless Tower", player), lambda state:
|
||||
state.has_all(["Key King", "Unlock Wingboots"], player))
|
||||
set_rule(multiworld.get_entrance("Mist -> Tower of Mist", player), lambda state: state.has("Key King", player))
|
||||
set_rule(multiworld.get_entrance("Mist -> Path to Conflate", player), lambda state: state.has("Key Ace", player))
|
||||
set_rule(multiworld.get_entrance("Path to Conflate -> Helm Branch", player), lambda state: state.has("Key King", player))
|
||||
set_rule(multiworld.get_entrance("Path to Conflate -> Branches", player), lambda state: state.has("Key King", player))
|
||||
set_rule(multiworld.get_entrance("Daybreak -> Dartmoor Castle", player), lambda state: state.has("Ring of Dworf", player))
|
||||
set_rule(multiworld.get_entrance("Dartmoor Castle -> Evil Fortress", player), lambda state: state.has("Demons Ring", player))
|
||||
|
||||
# Location rules
|
||||
set_rule(multiworld.get_location("Eolis Key Jack", player), lambda state: can_buy_in_eolis(state, player))
|
||||
set_rule(multiworld.get_location("Eolis Hand Dagger", player), lambda state: can_buy_in_eolis(state, player))
|
||||
set_rule(multiworld.get_location("Eolis Elixir", player), lambda state: can_buy_in_eolis(state, player))
|
||||
set_rule(multiworld.get_location("Eolis Deluge", player), lambda state: can_buy_in_eolis(state, player))
|
||||
set_rule(multiworld.get_location("Eolis Red Potion", player), lambda state: can_buy_in_eolis(state, player))
|
||||
set_rule(multiworld.get_location("Path to Apolune Magic Shield", player), lambda state: state.has("Key King", player)) # Mid-late cost, make sure we've progressed
|
||||
set_rule(multiworld.get_location("Path to Apolune Death", player), lambda state: state.has("Key Ace", player)) # Mid-late cost, make sure we've progressed
|
||||
set_rule(multiworld.get_location("Tower of Trunk Hidden Mattock", player), lambda state:
|
||||
# This is actually possible if the monster drop into the stairs and kill it with dagger. But it's a "pro move"
|
||||
state.has("Deluge", player, 1) or
|
||||
state.has("Progressive Sword", player, 2))
|
||||
set_rule(multiworld.get_location("Path to Forepaw Glove", player), lambda state:
|
||||
state.has_all(["Deluge", "Unlock Wingboots"], player))
|
||||
set_rule(multiworld.get_location("Trunk Red Potion", player), lambda state: state.has("Unlock Wingboots", player))
|
||||
set_rule(multiworld.get_location("Sky Spring", player), lambda state: state.has("Unlock Wingboots", player))
|
||||
set_rule(multiworld.get_location("Tower of Fortress Spring", player), lambda state: state.has("Spring Elixir", player))
|
||||
set_rule(multiworld.get_location("Tower of Fortress Guru", player), lambda state: state.has("Sky Spring Flow", player))
|
||||
set_rule(multiworld.get_location("Tower of Suffer Hidden Wingboots", player), lambda state:
|
||||
state.has("Deluge", player) or
|
||||
state.has("Progressive Sword", player, 2))
|
||||
set_rule(multiworld.get_location("Misty House", player), lambda state: state.has("Black Onyx", player))
|
||||
set_rule(multiworld.get_location("Misty Doctor Office", player), lambda state: has_any_magic(state, player))
|
||||
set_rule(multiworld.get_location("Conflate Guru", player), lambda state: state.has("Progressive Armor", player, 3))
|
||||
set_rule(multiworld.get_location("Branches Hidden Mattock", player), lambda state: state.has("Unlock Wingboots", player))
|
||||
set_rule(multiworld.get_location("Path to Daybreak Glove", player), lambda state: state.has("Unlock Wingboots", player))
|
||||
set_rule(multiworld.get_location("Dartmoor Castle Hidden Hourglass", player), lambda state: state.has("Unlock Wingboots", player))
|
||||
set_rule(multiworld.get_location("Dartmoor Castle Hidden Red Potion", player), lambda state: has_any_magic(state, player))
|
||||
set_rule(multiworld.get_location("Fraternal Castle Guru", player), lambda state: state.has("Progressive Sword", player, 4))
|
||||
set_rule(multiworld.get_location("Fraternal Castle Shop Hidden Ointment", player), lambda state: has_any_magic(state, player))
|
||||
|
||||
if faxanadu_world.options.require_dragon_slayer.value:
|
||||
set_rule(multiworld.get_location("Evil One", player), lambda state:
|
||||
state.has_all_counts({"Progressive Sword": 4, "Progressive Armor": 3, "Progressive Shield": 4}, player))
|
||||
190
worlds/faxanadu/__init__.py
Normal file
190
worlds/faxanadu/__init__.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from BaseClasses import Item, Location, Tutorial, ItemClassification, MultiWorld
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from . import Items, Locations, Regions, Rules
|
||||
from .Options import FaxanaduOptions
|
||||
from worlds.generic.Rules import set_rule
|
||||
|
||||
|
||||
DAXANADU_VERSION = "0.3.0"
|
||||
|
||||
|
||||
class FaxanaduLocation(Location):
|
||||
game: str = "Faxanadu"
|
||||
|
||||
|
||||
class FaxanaduItem(Item):
|
||||
game: str = "Faxanadu"
|
||||
|
||||
|
||||
class FaxanaduWeb(WebWorld):
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to setting up the Faxanadu randomizer connected to an Archipelago Multiworld",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["Daivuk"]
|
||||
)]
|
||||
theme = "dirt"
|
||||
|
||||
|
||||
class FaxanaduWorld(World):
|
||||
"""
|
||||
Faxanadu is an action role-playing platform video game developed by Hudson Soft for the Nintendo Entertainment System
|
||||
"""
|
||||
options_dataclass = FaxanaduOptions
|
||||
options: FaxanaduOptions
|
||||
game = "Faxanadu"
|
||||
web = FaxanaduWeb()
|
||||
|
||||
item_name_to_id = {item.name: item.id for item in Items.items if item.id is not None}
|
||||
item_name_to_item = {item.name: item for item in Items.items}
|
||||
location_name_to_id = {loc.name: loc.id for loc in Locations.locations if loc.id is not None}
|
||||
|
||||
def __init__(self, world: MultiWorld, player: int):
|
||||
self.filler_ratios: Dict[str, int] = {}
|
||||
|
||||
super().__init__(world, player)
|
||||
|
||||
def create_regions(self):
|
||||
Regions.create_regions(self)
|
||||
|
||||
# Add locations into regions
|
||||
for region in self.multiworld.get_regions(self.player):
|
||||
for loc in [location for location in Locations.locations if location.region == region.name]:
|
||||
location = FaxanaduLocation(self.player, loc.name, loc.id, region)
|
||||
|
||||
# In Faxanadu, Poison hurts you when picked up. It makes no sense to sell them in shops
|
||||
if loc.type == Locations.LocationType.shop:
|
||||
location.item_rule = lambda item, player=self.player: not (player == item.player and item.name == "Poison")
|
||||
|
||||
region.locations.append(location)
|
||||
|
||||
def set_rules(self):
|
||||
Rules.set_rules(self)
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("Killed Evil One", self.player)
|
||||
|
||||
def create_item(self, name: str) -> FaxanaduItem:
|
||||
item: Items.ItemDef = self.item_name_to_item[name]
|
||||
return FaxanaduItem(name, item.classification, item.id, self.player)
|
||||
|
||||
# Returns how many red potions were prefilled into shops
|
||||
def prefill_shop_red_potions(self) -> int:
|
||||
red_potion_in_shop_count = 0
|
||||
if self.options.keep_shop_red_potions:
|
||||
red_potion_item = self.item_name_to_item["Red Potion"]
|
||||
red_potion_shop_locations = [
|
||||
loc
|
||||
for loc in Locations.locations
|
||||
if loc.type == Locations.LocationType.shop and loc.original_item == Locations.ItemType.red_potion
|
||||
]
|
||||
for loc in red_potion_shop_locations:
|
||||
location = self.get_location(loc.name)
|
||||
location.place_locked_item(FaxanaduItem(red_potion_item.name, red_potion_item.classification, red_potion_item.id, self.player))
|
||||
red_potion_in_shop_count += 1
|
||||
return red_potion_in_shop_count
|
||||
|
||||
def put_wingboot_in_shop(self, shops, region_name):
|
||||
item = self.item_name_to_item["Wingboots"]
|
||||
shop = shops.pop(region_name)
|
||||
slot = self.random.randint(0, len(shop) - 1)
|
||||
loc = shop[slot]
|
||||
location = self.get_location(loc.name)
|
||||
location.place_locked_item(FaxanaduItem(item.name, item.classification, item.id, self.player))
|
||||
|
||||
# Put a rule right away that we need to have to unlocked.
|
||||
set_rule(location, lambda state: state.has("Unlock Wingboots", self.player))
|
||||
|
||||
# Returns how many wingboots were prefilled into shops
|
||||
def prefill_shop_wingboots(self) -> int:
|
||||
# Collect shops
|
||||
shops: Dict[str, List[Locations.LocationDef]] = {}
|
||||
for loc in Locations.locations:
|
||||
if loc.type == Locations.LocationType.shop:
|
||||
if self.options.keep_shop_red_potions and loc.original_item == Locations.ItemType.red_potion:
|
||||
continue # Don't override our red potions
|
||||
shops.setdefault(loc.region, []).append(loc)
|
||||
|
||||
shop_count = len(shops)
|
||||
wingboots_count = round(shop_count / 2.5) # On 10 shops, we should have about 4 shops with wingboots
|
||||
|
||||
# At least one should be in the first 4 shops. Because we require wingboots to progress past that point.
|
||||
must_have_regions = [region for i, region in enumerate(shops) if i < 4]
|
||||
self.put_wingboot_in_shop(shops, self.random.choice(must_have_regions))
|
||||
|
||||
# Fill in the rest randomly in remaining shops
|
||||
for i in range(wingboots_count - 1): # -1 because we added one already
|
||||
region = self.random.choice(list(shops.keys()))
|
||||
self.put_wingboot_in_shop(shops, region)
|
||||
|
||||
return wingboots_count
|
||||
|
||||
def create_items(self) -> None:
|
||||
itempool: List[FaxanaduItem] = []
|
||||
|
||||
# Prefill red potions in shops if option is set
|
||||
red_potion_in_shop_count = self.prefill_shop_red_potions()
|
||||
|
||||
# Prefill wingboots in shops
|
||||
wingboots_in_shop_count = self.prefill_shop_wingboots()
|
||||
|
||||
# Create the item pool, excluding fillers.
|
||||
prefilled_count = red_potion_in_shop_count + wingboots_in_shop_count
|
||||
for item in Items.items:
|
||||
# Ignore pendant if turned off
|
||||
if item.name == "Pendant" and not self.options.include_pendant:
|
||||
continue
|
||||
|
||||
# ignore fillers for now, we will fill them later
|
||||
if item.classification in [ItemClassification.filler, ItemClassification.trap] and \
|
||||
item.progression_count == 0:
|
||||
continue
|
||||
|
||||
prefill_loc = None
|
||||
if item.prefill_location:
|
||||
prefill_loc = self.get_location(item.prefill_location)
|
||||
|
||||
# if require dragon slayer is turned on, we need progressive shields to be progression
|
||||
item_classification = item.classification
|
||||
if self.options.require_dragon_slayer and item.name == "Progressive Shield":
|
||||
item_classification = ItemClassification.progression
|
||||
|
||||
if prefill_loc:
|
||||
prefill_loc.place_locked_item(FaxanaduItem(item.name, item_classification, item.id, self.player))
|
||||
prefilled_count += 1
|
||||
else:
|
||||
for i in range(item.count - item.progression_count):
|
||||
itempool.append(FaxanaduItem(item.name, item_classification, item.id, self.player))
|
||||
for i in range(item.progression_count):
|
||||
itempool.append(FaxanaduItem(item.name, ItemClassification.progression, item.id, self.player))
|
||||
|
||||
# Set up filler ratios
|
||||
self.filler_ratios = {
|
||||
item.name: item.count
|
||||
for item in Items.items
|
||||
if item.classification in [ItemClassification.filler, ItemClassification.trap]
|
||||
}
|
||||
|
||||
# If red potions are locked in shops, remove the count from the ratio.
|
||||
self.filler_ratios["Red Potion"] -= red_potion_in_shop_count
|
||||
|
||||
# Remove poisons if not desired
|
||||
if not self.options.include_poisons:
|
||||
self.filler_ratios["Poison"] = 0
|
||||
|
||||
# Randomly add fillers to the pool with ratios based on og game occurrence counts.
|
||||
filler_count = len(Locations.locations) - len(itempool) - prefilled_count
|
||||
for i in range(filler_count):
|
||||
itempool.append(self.create_item(self.get_filler_item_name()))
|
||||
|
||||
self.multiworld.itempool += itempool
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.random.choices(list(self.filler_ratios.keys()), weights=list(self.filler_ratios.values()))[0]
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
slot_data = self.options.as_dict("keep_shop_red_potions", "random_musics", "random_sounds", "random_npcs", "random_monsters", "random_rewards")
|
||||
slot_data["daxanadu_version"] = DAXANADU_VERSION
|
||||
return slot_data
|
||||
27
worlds/faxanadu/docs/en_Faxanadu.md
Normal file
27
worlds/faxanadu/docs/en_Faxanadu.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Faxanadu
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player options page](../player-options) contains the options needed to configure your game session.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
All game items collected in the map, shops, and boss drops are randomized.
|
||||
|
||||
Keys are unique. Once you get the Jack Key, you can open all Jack doors; the key stays in your inventory.
|
||||
|
||||
Wingboots are randomized across shops only. They are LOCKED and cannot be bought until you get the item that unlocks them.
|
||||
|
||||
Normal Elixirs don't revive the tower spring. A new item, Spring Elixir, is necessary. This new item is unique.
|
||||
|
||||
## What is the goal?
|
||||
|
||||
The goal is to kill the Evil One.
|
||||
|
||||
## What is a "check" in The Faxanadu?
|
||||
|
||||
Shop items, item locations in the world, boss drops, and secret items.
|
||||
|
||||
## What "items" can you unlock in Faxanadu?
|
||||
|
||||
Keys, Armors, Weapons, Potions, Shields, Magics, Poisons, Gloves, etc.
|
||||
32
worlds/faxanadu/docs/setup_en.md
Normal file
32
worlds/faxanadu/docs/setup_en.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Faxanadu Randomizer Setup
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Daxanadu](https://github.com/Daivuk/Daxanadu/releases/)
|
||||
- Faxanadu ROM, English version
|
||||
|
||||
## Optional Software
|
||||
|
||||
- [ArchipelagoTextClient](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
|
||||
## Installing Daxanadu
|
||||
1. Download [Daxanadu.zip](https://github.com/Daivuk/Daxanadu/releases/) and extract it.
|
||||
2. Copy your rom `Faxanadu (U).nes` into the newly extracted folder.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Launch Daxanadu.exe
|
||||
2. From the Main menu, go to the `ARCHIPELAGO` menu. Enter the server's address, slot name, and password. Then select `PLAY`.
|
||||
3. Enjoy!
|
||||
|
||||
To continue a game, follow the same connection steps.
|
||||
Connecting with a different seed won't erase your progress in other seeds.
|
||||
|
||||
## Archipelago Text Client
|
||||
|
||||
We recommend having Archipelago's Text Client open on the side to keep track of what items you receive and send.
|
||||
Daxanadu doesn't display messages. You'll only get popups when picking them up.
|
||||
|
||||
## Auto-Tracking
|
||||
|
||||
Daxanadu has an integrated tracker that can be toggled in the options.
|
||||
@@ -47,6 +47,17 @@ def get_flag(data, flag):
|
||||
bit = int(0x80 / (2 ** (flag % 8)))
|
||||
return (data[byte] & bit) > 0
|
||||
|
||||
def validate_read_state(data1, data2):
|
||||
validation_array = bytes([0x01, 0x46, 0x46, 0x4D, 0x51, 0x52])
|
||||
|
||||
if data1 is None or data2 is None:
|
||||
return False
|
||||
for i in range(6):
|
||||
if data1[i] != validation_array[i] or data2[i] != validation_array[i]:
|
||||
return False;
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class FFMQClient(SNIClient):
|
||||
game = "Final Fantasy Mystic Quest"
|
||||
@@ -67,11 +78,11 @@ class FFMQClient(SNIClient):
|
||||
async def game_watcher(self, ctx):
|
||||
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
|
||||
|
||||
check_1 = await snes_read(ctx, 0xF53749, 1)
|
||||
check_1 = await snes_read(ctx, 0xF53749, 6)
|
||||
received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1])
|
||||
data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START)
|
||||
check_2 = await snes_read(ctx, 0xF53749, 1)
|
||||
if check_1 != b'\x01' or check_2 != b'\x01':
|
||||
check_2 = await snes_read(ctx, 0xF53749, 6)
|
||||
if not validate_read_state(check_1, check_2):
|
||||
return
|
||||
|
||||
def get_range(data_range):
|
||||
|
||||
@@ -211,9 +211,12 @@ def stage_set_rules(multiworld):
|
||||
# If there's no enemies, there's no repeatable income sources
|
||||
no_enemies_players = [player for player in multiworld.get_game_players("Final Fantasy Mystic Quest")
|
||||
if multiworld.worlds[player].options.enemies_density == "none"]
|
||||
if (len([item for item in multiworld.itempool if item.classification in (ItemClassification.filler,
|
||||
ItemClassification.trap)]) > len([player for player in no_enemies_players if
|
||||
multiworld.worlds[player].options.accessibility == "minimal"]) * 3):
|
||||
if (
|
||||
len([item for item in multiworld.itempool if item.excludable]) >
|
||||
len([player
|
||||
for player in no_enemies_players
|
||||
if multiworld.worlds[player].options.accessibility != "minimal"]) * 3
|
||||
):
|
||||
for player in no_enemies_players:
|
||||
for location in vendor_locations:
|
||||
if multiworld.worlds[player].options.accessibility == "full":
|
||||
@@ -221,11 +224,8 @@ def stage_set_rules(multiworld):
|
||||
else:
|
||||
multiworld.get_location(location, player).access_rule = lambda state: False
|
||||
else:
|
||||
# There are not enough junk items to fill non-minimal players' vendors. Just set an item rule not allowing
|
||||
# advancement items so that useful items can be placed.
|
||||
for player in no_enemies_players:
|
||||
for location in vendor_locations:
|
||||
multiworld.get_location(location, player).item_rule = lambda item: not item.advancement
|
||||
raise Exception(f"Not enough filler/trap items for FFMQ players with full and items accessibility. "
|
||||
f"Add more items or change the 'Enemies Density' option to something besides 'none'")
|
||||
|
||||
|
||||
class FFMQLocation(Location):
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
Archipelago does not have a compiled release on macOS. However, it is possible to run from source code on macOS. This guide expects you to have some experience with running software from the terminal.
|
||||
## Prerequisite Software
|
||||
Here is a list of software to install and source code to download.
|
||||
1. Python 3.9 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/).
|
||||
**Python 3.11 is not supported yet.**
|
||||
1. Python 3.10 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/).
|
||||
**Python 3.13 is not supported yet.**
|
||||
2. Xcode from the [macOS App Store](https://apps.apple.com/us/app/xcode/id497799835).
|
||||
3. The source code from the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
4. The asset with darwin in the name from the [SNI Github releases page](https://github.com/alttpo/sni/releases).
|
||||
|
||||
@@ -16,14 +16,8 @@ class Goal(Choice):
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""
|
||||
Choose the difficulty option. Those match DOOM's difficulty options.
|
||||
baby (I'm too young to die.) double ammos, half damage, less monsters or strength.
|
||||
easy (Hey, not too rough.) less monsters or strength.
|
||||
medium (Hurt me plenty.) Default.
|
||||
hard (Ultra-Violence.) More monsters or strength.
|
||||
nightmare (Nightmare!) Monsters attack more rapidly and respawn.
|
||||
|
||||
wet nurse (hou needeth a wet-nurse) - Fewer monsters and more items than medium. Damage taken is halved, and ammo pickups carry twice as much ammo. Any Quartz Flasks and Mystic Urns are automatically used when the player nears death.
|
||||
Choose the game difficulty. These options match Heretic's skill levels.
|
||||
wet nurse (Thou needeth a wet-nurse) - Fewer monsters and more items than medium. Damage taken is halved, and ammo pickups carry twice as much ammo. Any Quartz Flasks and Mystic Urns are automatically used when the player nears death.
|
||||
easy (Yellowbellies-r-us) - Fewer monsters and more items than medium.
|
||||
medium (Bringest them oneth) - Completely balanced, this is the standard difficulty level.
|
||||
hard (Thou art a smite-meister) - More monsters and fewer items than medium.
|
||||
@@ -35,6 +29,11 @@ class Difficulty(Choice):
|
||||
option_medium = 2
|
||||
option_hard = 3
|
||||
option_black_plague = 4
|
||||
alias_wn = 0
|
||||
alias_yru = 1
|
||||
alias_bto = 2
|
||||
alias_sm = 3
|
||||
alias_bp = 4
|
||||
default = 2
|
||||
|
||||
|
||||
@@ -104,7 +103,7 @@ class StartWithMapScrolls(Toggle):
|
||||
class ResetLevelOnDeath(DefaultOnToggle):
|
||||
"""When dying, levels are reset and monsters respawned. But inventory and checks are kept.
|
||||
Turning this setting off is considered easy mode. Good for new players that don't know the levels well."""
|
||||
display_message="Reset level on death"
|
||||
display_name = "Reset Level on Death"
|
||||
|
||||
|
||||
class CheckSanity(Toggle):
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
You can find the folder in steam by finding the game in your library,
|
||||
right clicking it and choosing *Manage→Browse Local Files*. The WAD file is in the `/base/` folder.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
## Joining a MultiWorld Game (via Launcher)
|
||||
|
||||
1. Launch apdoom-launcher.exe
|
||||
2. Choose Heretic in the dropdown
|
||||
@@ -26,6 +26,23 @@
|
||||
To continue a game, follow the same connection steps.
|
||||
Connecting with a different seed won't erase your progress in other seeds.
|
||||
|
||||
## Joining a MultiWorld Game (via command line)
|
||||
|
||||
1. In your command line, navigate to the directory where APDOOM is installed.
|
||||
2. Run `crispy-apheretic -apserver <server> -applayer <slot name>`, where:
|
||||
- `<server>` is the Archipelago server address, e.g. "`archipelago.gg:38281`"
|
||||
- `<slot name>` is your slot name; if it contains spaces, surround it with double quotes
|
||||
- If the server has a password, add `-password`, followed by the server password
|
||||
3. Enjoy!
|
||||
|
||||
Optionally, you can override some randomization settings from the command line:
|
||||
- `-apmonsterrando 0` will disable monster rando.
|
||||
- `-apitemrando 0` will disable item rando.
|
||||
- `-apmusicrando 0` will disable music rando.
|
||||
- `-apresetlevelondeath 0` will disable resetting the level on death.
|
||||
- `-apdeathlinkoff` will force DeathLink off if it's enabled.
|
||||
- `-skill <1-5>` changes the game difficulty, from 1 (thou needeth a wet-nurse) to 5 (black plague possesses thee)
|
||||
|
||||
## Archipelago Text Client
|
||||
|
||||
We recommend having Archipelago's Text Client open on the side to keep track of what items you receive and send.
|
||||
|
||||
@@ -9,11 +9,7 @@ import ast
|
||||
|
||||
import jinja2
|
||||
|
||||
try:
|
||||
from ast import unparse
|
||||
except ImportError:
|
||||
# Py 3.8 and earlier compatibility module
|
||||
from astunparse import unparse
|
||||
from ast import unparse
|
||||
|
||||
from Utils import get_text_between
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ item_name_groups = ({
|
||||
"VesselFragments": lookup_type_to_names["Vessel"],
|
||||
"WhisperingRoots": lookup_type_to_names["Root"],
|
||||
"WhiteFragments": {"Queen_Fragment", "King_Fragment", "Void_Heart"},
|
||||
"DreamNails": {"Dream_Nail", "Dream_Gate", "Awoken_Dream_Nail"},
|
||||
})
|
||||
item_name_groups['Horizontal'] = item_name_groups['Cloak'] | item_name_groups['CDash']
|
||||
item_name_groups['Vertical'] = item_name_groups['Claw'] | {'Monarch_Wings'}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import typing
|
||||
import re
|
||||
from dataclasses import dataclass, make_dataclass
|
||||
from dataclasses import make_dataclass
|
||||
|
||||
from .ExtractedData import logic_options, starts, pool_options
|
||||
from .Rules import cost_terms
|
||||
@@ -300,7 +300,7 @@ class PlandoCharmCosts(OptionDict):
|
||||
display_name = "Charm Notch Cost Plando"
|
||||
valid_keys = frozenset(charm_names)
|
||||
schema = Schema({
|
||||
Optional(name): And(int, lambda n: 6 >= n >= 0) for name in charm_names
|
||||
Optional(name): And(int, lambda n: 6 >= n >= 0, error="Charm costs must be integers in the range 0-6.") for name in charm_names
|
||||
})
|
||||
|
||||
def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]:
|
||||
|
||||
@@ -231,7 +231,7 @@ class HKWorld(World):
|
||||
all_event_names.update(set(godhome_event_names))
|
||||
|
||||
# Link regions
|
||||
for event_name in all_event_names:
|
||||
for event_name in sorted(all_event_names):
|
||||
#if event_name in wp_exclusions:
|
||||
# continue
|
||||
loc = HKLocation(self.player, event_name, None, menu_region)
|
||||
@@ -340,7 +340,7 @@ class HKWorld(World):
|
||||
|
||||
for shop, locations in self.created_multi_locations.items():
|
||||
for _ in range(len(locations), getattr(self.options, shop_to_option[shop]).value):
|
||||
loc = self.create_location(shop)
|
||||
self.create_location(shop)
|
||||
unfilled_locations += 1
|
||||
|
||||
# Balance the pool
|
||||
@@ -356,7 +356,7 @@ class HKWorld(World):
|
||||
if shops:
|
||||
for _ in range(additional_shop_items):
|
||||
shop = self.random.choice(shops)
|
||||
loc = self.create_location(shop)
|
||||
self.create_location(shop)
|
||||
unfilled_locations += 1
|
||||
if len(self.created_multi_locations[shop]) >= 16:
|
||||
shops.remove(shop)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
astunparse>=1.6.3; python_version <= '3.8'
|
||||
@@ -57,7 +57,7 @@ def generate_valid_level(world: "KDL3World", level: int, stage: int,
|
||||
|
||||
def generate_rooms(world: "KDL3World", level_regions: Dict[int, Region]) -> None:
|
||||
level_names = {location_name.level_names[level]: level for level in location_name.level_names}
|
||||
room_data = orjson.loads(get_data(__name__, os.path.join("data", "Rooms.json")))
|
||||
room_data = orjson.loads(get_data(__name__, "data/Rooms.json"))
|
||||
rooms: Dict[str, KDL3Room] = dict()
|
||||
for room_entry in room_data:
|
||||
room = KDL3Room(room_entry["name"], world.player, world.multiworld, None, room_entry["level"],
|
||||
|
||||
@@ -313,7 +313,7 @@ def handle_level_sprites(stages: List[Tuple[int, ...]], sprites: List[bytearray]
|
||||
def write_heart_star_sprites(rom: RomData) -> None:
|
||||
compressed = rom.read_bytes(heart_star_address, heart_star_size)
|
||||
decompressed = hal_decompress(compressed)
|
||||
patch = get_data(__name__, os.path.join("data", "APHeartStar.bsdiff4"))
|
||||
patch = get_data(__name__, "data/APHeartStar.bsdiff4")
|
||||
patched = bytearray(bsdiff4.patch(decompressed, patch))
|
||||
rom.write_bytes(0x1AF7DF, patched)
|
||||
patched[0:0] = [0xE3, 0xFF]
|
||||
@@ -327,10 +327,10 @@ def write_consumable_sprites(rom: RomData, consumables: bool, stars: bool) -> No
|
||||
decompressed = hal_decompress(compressed)
|
||||
patched = bytearray(decompressed)
|
||||
if consumables:
|
||||
patch = get_data(__name__, os.path.join("data", "APConsumable.bsdiff4"))
|
||||
patch = get_data(__name__, "data/APConsumable.bsdiff4")
|
||||
patched = bytearray(bsdiff4.patch(bytes(patched), patch))
|
||||
if stars:
|
||||
patch = get_data(__name__, os.path.join("data", "APStars.bsdiff4"))
|
||||
patch = get_data(__name__, "data/APStars.bsdiff4")
|
||||
patched = bytearray(bsdiff4.patch(bytes(patched), patch))
|
||||
patched[0:0] = [0xE3, 0xFF]
|
||||
patched.append(0xFF)
|
||||
@@ -380,7 +380,7 @@ class KDL3ProcedurePatch(APProcedurePatch, APTokenMixin):
|
||||
|
||||
def patch_rom(world: "KDL3World", patch: KDL3ProcedurePatch) -> None:
|
||||
patch.write_file("kdl3_basepatch.bsdiff4",
|
||||
get_data(__name__, os.path.join("data", "kdl3_basepatch.bsdiff4")))
|
||||
get_data(__name__, "data/kdl3_basepatch.bsdiff4"))
|
||||
|
||||
# Write open world patch
|
||||
if world.options.open_world:
|
||||
|
||||
@@ -355,6 +355,16 @@ class KH2FormRules(KH2Rules):
|
||||
RegionName.Master: lambda state: self.multi_form_region_access(),
|
||||
RegionName.Final: lambda state: self.final_form_region_access(state)
|
||||
}
|
||||
# Accessing Final requires being able to reach one of the locations in final_leveling_access, but reaching a
|
||||
# location requires being able to reach the region the location is in, so an indirect condition is required.
|
||||
# The access rules of each of the locations in final_leveling_access do not check for being able to reach other
|
||||
# locations or other regions, so it is only the parent region of each location that needs to be added as an
|
||||
# indirect condition.
|
||||
self.form_region_indirect_condition_regions = {
|
||||
RegionName.Final: {
|
||||
self.world.get_location(location).parent_region for location in final_leveling_access
|
||||
}
|
||||
}
|
||||
|
||||
def final_form_region_access(self, state: CollectionState) -> bool:
|
||||
"""
|
||||
@@ -388,12 +398,15 @@ class KH2FormRules(KH2Rules):
|
||||
for region_name in drive_form_list:
|
||||
if region_name == RegionName.Summon and not self.world.options.SummonLevelLocationToggle:
|
||||
continue
|
||||
indirect_condition_regions = self.form_region_indirect_condition_regions.get(region_name, ())
|
||||
# could get the location of each of these, but I feel like that would be less optimal
|
||||
region = self.multiworld.get_region(region_name, self.player)
|
||||
# if region_name in form_region_rules
|
||||
if region_name != RegionName.Summon:
|
||||
for entrance in region.entrances:
|
||||
entrance.access_rule = self.form_region_rules[region_name]
|
||||
for indirect_condition_region in indirect_condition_regions:
|
||||
self.multiworld.register_indirect_condition(indirect_condition_region, entrance)
|
||||
for loc in region.locations:
|
||||
loc.access_rule = self.form_rules[loc.name]
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class DungeonItemData(ItemData):
|
||||
@property
|
||||
def dungeon_index(self):
|
||||
return int(self.ladxr_id[-1])
|
||||
|
||||
|
||||
@property
|
||||
def dungeon_item_type(self):
|
||||
s = self.ladxr_id[:-1]
|
||||
@@ -69,7 +69,6 @@ class ItemName:
|
||||
BOMB = "Bomb"
|
||||
SWORD = "Progressive Sword"
|
||||
FLIPPERS = "Flippers"
|
||||
MAGNIFYING_LENS = "Magnifying Lens"
|
||||
MEDICINE = "Medicine"
|
||||
TAIL_KEY = "Tail Key"
|
||||
ANGLER_KEY = "Angler Key"
|
||||
@@ -175,7 +174,7 @@ class ItemName:
|
||||
TRADING_ITEM_SCALE = "Scale"
|
||||
TRADING_ITEM_MAGNIFYING_GLASS = "Magnifying Glass"
|
||||
|
||||
trade_item_prog = ItemClassification.progression
|
||||
trade_item_prog = ItemClassification.progression
|
||||
|
||||
links_awakening_items = [
|
||||
ItemData(ItemName.POWER_BRACELET, "POWER_BRACELET", ItemClassification.progression),
|
||||
@@ -191,7 +190,6 @@ links_awakening_items = [
|
||||
ItemData(ItemName.BOMB, "BOMB", ItemClassification.progression),
|
||||
ItemData(ItemName.SWORD, "SWORD", ItemClassification.progression),
|
||||
ItemData(ItemName.FLIPPERS, "FLIPPERS", ItemClassification.progression),
|
||||
ItemData(ItemName.MAGNIFYING_LENS, "MAGNIFYING_LENS", ItemClassification.progression),
|
||||
ItemData(ItemName.MEDICINE, "MEDICINE", ItemClassification.useful),
|
||||
ItemData(ItemName.TAIL_KEY, "TAIL_KEY", ItemClassification.progression),
|
||||
ItemData(ItemName.ANGLER_KEY, "ANGLER_KEY", ItemClassification.progression),
|
||||
@@ -305,3 +303,135 @@ ladxr_item_to_la_item_name = {
|
||||
links_awakening_items_by_name = {
|
||||
item.item_name : item for item in links_awakening_items
|
||||
}
|
||||
|
||||
links_awakening_item_name_groups: typing.Dict[str, typing.Set[str]] = {
|
||||
"Instruments": {
|
||||
"Full Moon Cello",
|
||||
"Conch Horn",
|
||||
"Sea Lily's Bell",
|
||||
"Surf Harp",
|
||||
"Wind Marimba",
|
||||
"Coral Triangle",
|
||||
"Organ of Evening Calm",
|
||||
"Thunder Drum",
|
||||
},
|
||||
"Entrance Keys": {
|
||||
"Tail Key",
|
||||
"Angler Key",
|
||||
"Face Key",
|
||||
"Bird Key",
|
||||
"Slime Key",
|
||||
},
|
||||
"Nightmare Keys": {
|
||||
"Nightmare Key (Angler's Tunnel)",
|
||||
"Nightmare Key (Bottle Grotto)",
|
||||
"Nightmare Key (Catfish's Maw)",
|
||||
"Nightmare Key (Color Dungeon)",
|
||||
"Nightmare Key (Eagle's Tower)",
|
||||
"Nightmare Key (Face Shrine)",
|
||||
"Nightmare Key (Key Cavern)",
|
||||
"Nightmare Key (Tail Cave)",
|
||||
"Nightmare Key (Turtle Rock)",
|
||||
},
|
||||
"Small Keys": {
|
||||
"Small Key (Angler's Tunnel)",
|
||||
"Small Key (Bottle Grotto)",
|
||||
"Small Key (Catfish's Maw)",
|
||||
"Small Key (Color Dungeon)",
|
||||
"Small Key (Eagle's Tower)",
|
||||
"Small Key (Face Shrine)",
|
||||
"Small Key (Key Cavern)",
|
||||
"Small Key (Tail Cave)",
|
||||
"Small Key (Turtle Rock)",
|
||||
},
|
||||
"Compasses": {
|
||||
"Compass (Angler's Tunnel)",
|
||||
"Compass (Bottle Grotto)",
|
||||
"Compass (Catfish's Maw)",
|
||||
"Compass (Color Dungeon)",
|
||||
"Compass (Eagle's Tower)",
|
||||
"Compass (Face Shrine)",
|
||||
"Compass (Key Cavern)",
|
||||
"Compass (Tail Cave)",
|
||||
"Compass (Turtle Rock)",
|
||||
},
|
||||
"Maps": {
|
||||
"Dungeon Map (Angler's Tunnel)",
|
||||
"Dungeon Map (Bottle Grotto)",
|
||||
"Dungeon Map (Catfish's Maw)",
|
||||
"Dungeon Map (Color Dungeon)",
|
||||
"Dungeon Map (Eagle's Tower)",
|
||||
"Dungeon Map (Face Shrine)",
|
||||
"Dungeon Map (Key Cavern)",
|
||||
"Dungeon Map (Tail Cave)",
|
||||
"Dungeon Map (Turtle Rock)",
|
||||
},
|
||||
"Stone Beaks": {
|
||||
"Stone Beak (Angler's Tunnel)",
|
||||
"Stone Beak (Bottle Grotto)",
|
||||
"Stone Beak (Catfish's Maw)",
|
||||
"Stone Beak (Color Dungeon)",
|
||||
"Stone Beak (Eagle's Tower)",
|
||||
"Stone Beak (Face Shrine)",
|
||||
"Stone Beak (Key Cavern)",
|
||||
"Stone Beak (Tail Cave)",
|
||||
"Stone Beak (Turtle Rock)",
|
||||
},
|
||||
"Trading Items": {
|
||||
"Yoshi Doll",
|
||||
"Ribbon",
|
||||
"Dog Food",
|
||||
"Bananas",
|
||||
"Stick",
|
||||
"Honeycomb",
|
||||
"Pineapple",
|
||||
"Hibiscus",
|
||||
"Letter",
|
||||
"Broom",
|
||||
"Fishing Hook",
|
||||
"Necklace",
|
||||
"Scale",
|
||||
"Magnifying Glass",
|
||||
},
|
||||
"Rupees": {
|
||||
"20 Rupees",
|
||||
"50 Rupees",
|
||||
"100 Rupees",
|
||||
"200 Rupees",
|
||||
"500 Rupees",
|
||||
},
|
||||
"Upgrades": {
|
||||
"Max Powder Upgrade",
|
||||
"Max Bombs Upgrade",
|
||||
"Max Arrows Upgrade",
|
||||
},
|
||||
"Songs": {
|
||||
"Ballad of the Wind Fish",
|
||||
"Manbo's Mambo",
|
||||
"Frog's Song of Soul",
|
||||
},
|
||||
"Tunics": {
|
||||
"Red Tunic",
|
||||
"Blue Tunic",
|
||||
},
|
||||
"Bush Breakers": {
|
||||
"Progressive Power Bracelet",
|
||||
"Magic Rod",
|
||||
"Magic Powder",
|
||||
"Bomb",
|
||||
"Progressive Sword",
|
||||
"Boomerang",
|
||||
},
|
||||
"Sword": {
|
||||
"Progressive Sword",
|
||||
},
|
||||
"Shield": {
|
||||
"Progressive Shield",
|
||||
},
|
||||
"Power Bracelet": {
|
||||
"Progressive Power Bracelet",
|
||||
},
|
||||
"Bracelet": {
|
||||
"Progressive Power Bracelet",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ from . import hints
|
||||
|
||||
from .patches import bank34
|
||||
from .utils import formatText
|
||||
from ..Options import TrendyGame, Palette
|
||||
from ..Options import TrendyGame, Palette, Warps
|
||||
from .roomEditor import RoomEditor, Object
|
||||
from .patches.aesthetics import rgb_to_bin, bin_to_rgb
|
||||
|
||||
@@ -153,7 +153,9 @@ def generateRom(args, world: "LinksAwakeningWorld"):
|
||||
if world.ladxr_settings.witch:
|
||||
patches.witch.updateWitch(rom)
|
||||
patches.softlock.fixAll(rom)
|
||||
patches.maptweaks.tweakMap(rom)
|
||||
if not world.ladxr_settings.rooster:
|
||||
patches.maptweaks.tweakMap(rom)
|
||||
patches.maptweaks.tweakBirdKeyRoom(rom)
|
||||
patches.chest.fixChests(rom)
|
||||
patches.shop.fixShop(rom)
|
||||
patches.rooster.patchRooster(rom)
|
||||
@@ -176,11 +178,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
|
||||
patches.songs.upgradeMarin(rom)
|
||||
patches.songs.upgradeManbo(rom)
|
||||
patches.songs.upgradeMamu(rom)
|
||||
if world.ladxr_settings.tradequest:
|
||||
patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings.boomerang)
|
||||
else:
|
||||
# Monkey bridge patch, always have the bridge there.
|
||||
rom.patch(0x00, 0x333D, assembler.ASM("bit 4, e\njr Z, $05"), b"", fill_nop=True)
|
||||
patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings)
|
||||
patches.bowwow.fixBowwow(rom, everywhere=world.ladxr_settings.bowwow != 'normal')
|
||||
if world.ladxr_settings.bowwow != 'normal':
|
||||
patches.bowwow.bowwowMapPatches(rom)
|
||||
@@ -268,6 +266,8 @@ def generateRom(args, world: "LinksAwakeningWorld"):
|
||||
our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification]
|
||||
|
||||
def gen_hint():
|
||||
if not world.options.in_game_hints:
|
||||
return 'Hints are disabled!'
|
||||
chance = world.random.uniform(0, 1)
|
||||
if chance < JUNK_HINT:
|
||||
return None
|
||||
@@ -288,7 +288,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
|
||||
else:
|
||||
location_name = location.name
|
||||
|
||||
hint = f"{name} {location.item} is at {location_name}"
|
||||
hint = f"{name} {location.item.name} is at {location_name}"
|
||||
if location.player != world.player:
|
||||
# filter out { and } since they cause issues with string.format later on
|
||||
player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "")
|
||||
@@ -342,11 +342,53 @@ def generateRom(args, world: "LinksAwakeningWorld"):
|
||||
patches.enemies.doubleTrouble(rom)
|
||||
|
||||
if world.options.text_shuffle:
|
||||
excluded_ids = [
|
||||
# Overworld owl statues
|
||||
0x1B6, 0x1B7, 0x1B8, 0x1B9, 0x1BA, 0x1BB, 0x1BC, 0x1BD, 0x1BE, 0x22D,
|
||||
|
||||
# Dungeon owls
|
||||
0x288, 0x280, # D1
|
||||
0x28A, 0x289, 0x281, # D2
|
||||
0x282, 0x28C, 0x28B, # D3
|
||||
0x283, # D4
|
||||
0x28D, 0x284, # D5
|
||||
0x285, 0x28F, 0x28E, # D6
|
||||
0x291, 0x290, 0x286, # D7
|
||||
0x293, 0x287, 0x292, # D8
|
||||
0x263, # D0
|
||||
|
||||
# Hint books
|
||||
0x267, # color dungeon
|
||||
0x200, 0x201,
|
||||
0x202, 0x203,
|
||||
0x204, 0x205,
|
||||
0x206, 0x207,
|
||||
0x208, 0x209,
|
||||
0x20A, 0x20B,
|
||||
0x20C,
|
||||
0x20D, 0x20E,
|
||||
0x217, 0x218, 0x219, 0x21A,
|
||||
|
||||
# Goal sign
|
||||
0x1A3,
|
||||
|
||||
# Signpost maze
|
||||
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
|
||||
|
||||
# Prices
|
||||
0x02C, 0x02D, 0x030, 0x031, 0x032, 0x033, # Shop items
|
||||
0x03B, # Trendy Game
|
||||
0x045, # Fisherman
|
||||
0x018, 0x019, # Crazy Tracy
|
||||
0x0DC, # Mamu
|
||||
0x0F0, # Raft ride
|
||||
]
|
||||
excluded_texts = [ rom.texts[excluded_id] for excluded_id in excluded_ids]
|
||||
buckets = defaultdict(list)
|
||||
# For each ROM bank, shuffle text within the bank
|
||||
for n, data in enumerate(rom.texts._PointerTable__data):
|
||||
# Don't muck up which text boxes are questions and which are statements
|
||||
if type(data) != int and data and data != b'\xFF':
|
||||
if type(data) != int and data and data != b'\xFF' and data not in excluded_texts:
|
||||
buckets[(rom.texts._PointerTable__banks[n], data[len(data) - 1] == 0xfe)].append((n, data))
|
||||
for bucket in buckets.values():
|
||||
# For each bucket, make a copy and shuffle
|
||||
@@ -418,8 +460,8 @@ def generateRom(args, world: "LinksAwakeningWorld"):
|
||||
for channel in range(3):
|
||||
color[channel] = color[channel] * 31 // 0xbc
|
||||
|
||||
if world.options.warp_improvements:
|
||||
patches.core.addWarpImprovements(rom, world.options.additional_warp_points)
|
||||
if world.options.warps != Warps.option_vanilla:
|
||||
patches.core.addWarpImprovements(rom, world.options.warps == Warps.option_improved_additional)
|
||||
|
||||
palette = world.options.palette
|
||||
if palette != Palette.option_normal:
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
from .droppedKey import DroppedKey
|
||||
from ..roomEditor import RoomEditor
|
||||
from ..assembler import ASM
|
||||
|
||||
|
||||
class BirdKey(DroppedKey):
|
||||
def __init__(self):
|
||||
super().__init__(0x27A)
|
||||
|
||||
def patch(self, rom, option, *, multiworld=None):
|
||||
super().patch(rom, option, multiworld=multiworld)
|
||||
|
||||
re = RoomEditor(rom, self.room)
|
||||
|
||||
# Make the bird key accessible without the rooster
|
||||
re.removeObject(1, 6)
|
||||
re.removeObject(2, 6)
|
||||
re.removeObject(3, 5)
|
||||
re.removeObject(3, 6)
|
||||
re.moveObject(1, 5, 2, 6)
|
||||
re.moveObject(2, 5, 3, 6)
|
||||
re.addEntity(3, 5, 0x9D)
|
||||
re.store(rom)
|
||||
|
||||
@@ -24,11 +24,6 @@ class BoomerangGuy(ItemInfo):
|
||||
# But SHIELD, BOMB and MAGIC_POWDER would most likely break things.
|
||||
# SWORD and POWER_BRACELET would most likely introduce the lv0 shield/bracelet issue
|
||||
def patch(self, rom, option, *, multiworld=None):
|
||||
# Always have the boomerang trade guy enabled (normally you need the magnifier)
|
||||
rom.patch(0x19, 0x05EC, ASM("ld a, [wTradeSequenceItem]\ncp $0E"), ASM("ld a, $0E\ncp $0E"), fill_nop=True) # show the guy
|
||||
rom.patch(0x00, 0x3199, ASM("ld a, [wTradeSequenceItem]\ncp $0E"), ASM("ld a, $0E\ncp $0E"), fill_nop=True) # load the proper room layout
|
||||
rom.patch(0x19, 0x05F4, ASM("ld a, [wTradeSequenceItem2]\nand a"), ASM("xor a"), fill_nop=True)
|
||||
|
||||
if self.setting == 'trade':
|
||||
inv = INVENTORY_MAP[option]
|
||||
# Patch the check if you traded back the boomerang (so traded twice)
|
||||
|
||||
@@ -25,7 +25,7 @@ CHEST_ITEMS = {
|
||||
PEGASUS_BOOTS: 0x05,
|
||||
OCARINA: 0x06,
|
||||
FEATHER: 0x07, SHOVEL: 0x08, MAGIC_POWDER: 0x09, BOMB: 0x0A, SWORD: 0x0B, FLIPPERS: 0x0C,
|
||||
MAGNIFYING_LENS: 0x0D, MEDICINE: 0x10,
|
||||
MEDICINE: 0x10,
|
||||
TAIL_KEY: 0x11, ANGLER_KEY: 0x12, FACE_KEY: 0x13, BIRD_KEY: 0x14, GOLD_LEAF: 0x15,
|
||||
RUPEES_50: 0x1B, RUPEES_20: 0x1C, RUPEES_100: 0x1D, RUPEES_200: 0x1E, RUPEES_500: 0x1F,
|
||||
SEASHELL: 0x20, MESSAGE: 0x21, GEL: 0x22,
|
||||
|
||||
@@ -11,7 +11,6 @@ MAGIC_POWDER = "MAGIC_POWDER"
|
||||
BOMB = "BOMB"
|
||||
SWORD = "SWORD"
|
||||
FLIPPERS = "FLIPPERS"
|
||||
MAGNIFYING_LENS = "MAGNIFYING_LENS"
|
||||
MEDICINE = "MEDICINE"
|
||||
TAIL_KEY = "TAIL_KEY"
|
||||
ANGLER_KEY = "ANGLER_KEY"
|
||||
|
||||
@@ -9,7 +9,7 @@ class Dungeon1:
|
||||
entrance.add(DungeonChest(0x113), DungeonChest(0x115), DungeonChest(0x10E))
|
||||
Location(dungeon=1).add(DroppedKey(0x116)).connect(entrance, OR(BOMB, r.push_hardhat)) # hardhat beetles (can kill with bomb)
|
||||
Location(dungeon=1).add(DungeonChest(0x10D)).connect(entrance, OR(r.attack_hookshot_powder, SHIELD)) # moldorm spawn chest
|
||||
stalfos_keese_room = Location(dungeon=1).add(DungeonChest(0x114)).connect(entrance, r.attack_hookshot) # 2 stalfos 2 keese room
|
||||
stalfos_keese_room = Location(dungeon=1).add(DungeonChest(0x114)).connect(entrance, AND(OR(r.attack_skeleton, SHIELD),r.attack_hookshot_powder)) # 2 stalfos 2 keese room
|
||||
Location(dungeon=1).add(DungeonChest(0x10C)).connect(entrance, BOMB) # hidden seashell room
|
||||
dungeon1_upper_left = Location(dungeon=1).connect(entrance, AND(KEY1, FOUND(KEY1, 3)))
|
||||
if options.owlstatues == "both" or options.owlstatues == "dungeon":
|
||||
@@ -19,21 +19,22 @@ class Dungeon1:
|
||||
dungeon1_right_side = Location(dungeon=1).connect(entrance, AND(KEY1, FOUND(KEY1, 3)))
|
||||
if options.owlstatues == "both" or options.owlstatues == "dungeon":
|
||||
Location(dungeon=1).add(OwlStatue(0x10A)).connect(dungeon1_right_side, STONE_BEAK1)
|
||||
Location(dungeon=1).add(DungeonChest(0x10A)).connect(dungeon1_right_side, OR(r.attack_hookshot, SHIELD)) # three of a kind, shield stops the suit from changing
|
||||
dungeon1_3_of_a_kind = Location(dungeon=1).add(DungeonChest(0x10A)).connect(dungeon1_right_side, OR(r.attack_hookshot_no_bomb, SHIELD)) # three of a kind, shield stops the suit from changing
|
||||
dungeon1_miniboss = Location(dungeon=1).connect(dungeon1_right_side, AND(r.miniboss_requirements[world_setup.miniboss_mapping[0]], FEATHER))
|
||||
dungeon1_boss = Location(dungeon=1).connect(dungeon1_miniboss, NIGHTMARE_KEY1)
|
||||
Location(dungeon=1).add(HeartContainer(0x106), Instrument(0x102)).connect(dungeon1_boss, r.boss_requirements[world_setup.boss_mapping[0]])
|
||||
boss = Location(dungeon=1).add(HeartContainer(0x106), Instrument(0x102)).connect(dungeon1_boss, r.boss_requirements[world_setup.boss_mapping[0]])
|
||||
|
||||
if options.logic not in ('normal', 'casual'):
|
||||
if options.logic == 'hard' or options.logic == 'glitched' or options.logic == 'hell':
|
||||
stalfos_keese_room.connect(entrance, r.attack_hookshot_powder) # stalfos jump away when you press a button.
|
||||
|
||||
dungeon1_3_of_a_kind.connect(dungeon1_right_side, BOMB) # use timed bombs to match the 3 of a kinds
|
||||
|
||||
if options.logic == 'glitched' or options.logic == 'hell':
|
||||
boss_key.connect(entrance, FEATHER) # super jump
|
||||
boss_key.connect(entrance, r.super_jump_feather) # super jump
|
||||
dungeon1_miniboss.connect(dungeon1_right_side, r.miniboss_requirements[world_setup.miniboss_mapping[0]]) # damage boost or buffer pause over the pit to cross or mushroom
|
||||
|
||||
if options.logic == 'hell':
|
||||
feather_chest.connect(dungeon1_upper_left, SWORD) # keep slashing the spiked beetles until they keep moving 1 pixel close towards you and the pit, to get them to fall
|
||||
boss_key.connect(entrance, FOUND(KEY1,3)) # damage boost off the hardhat to cross the pit
|
||||
boss_key.connect(entrance, AND(r.damage_boost, FOUND(KEY1,3))) # damage boost off the hardhat to cross the pit
|
||||
|
||||
self.entrance = entrance
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class Dungeon2:
|
||||
Location(dungeon=2).add(DungeonChest(0x137)).connect(dungeon2_r2, AND(KEY2, FOUND(KEY2, 5), OR(r.rear_attack, r.rear_attack_range))) # compass chest
|
||||
if options.owlstatues == "both" or options.owlstatues == "dungeon":
|
||||
Location(dungeon=2).add(OwlStatue(0x133)).connect(dungeon2_r2, STONE_BEAK2)
|
||||
dungeon2_r3 = Location(dungeon=2).add(DungeonChest(0x138)).connect(dungeon2_r2, r.attack_hookshot) # first chest with key, can hookshot the switch in previous room
|
||||
dungeon2_r3 = Location(dungeon=2).add(DungeonChest(0x138)).connect(dungeon2_r2, r.hit_switch) # first chest with key, can hookshot the switch in previous room
|
||||
dungeon2_r4 = Location(dungeon=2).add(DungeonChest(0x139)).connect(dungeon2_r3, FEATHER) # button spawn chest
|
||||
if options.logic == "casual":
|
||||
shyguy_key_drop = Location(dungeon=2).add(DroppedKey(0x134)).connect(dungeon2_r3, AND(FEATHER, OR(r.rear_attack, r.rear_attack_range))) # shyguy drop key
|
||||
@@ -39,16 +39,16 @@ class Dungeon2:
|
||||
|
||||
if options.logic == 'glitched' or options.logic == 'hell':
|
||||
dungeon2_ghosts_chest.connect(dungeon2_ghosts_room, SWORD) # use sword to spawn ghosts on other side of the room so they run away (logically irrelevant because of torches at start)
|
||||
dungeon2_r6.connect(miniboss, FEATHER) # superjump to staircase next to hinox.
|
||||
dungeon2_r6.connect(miniboss, r.super_jump_feather) # superjump to staircase next to hinox.
|
||||
|
||||
if options.logic == 'hell':
|
||||
dungeon2_map_chest.connect(dungeon2_l2, AND(r.attack_hookshot_powder, PEGASUS_BOOTS)) # use boots to jump over the pits
|
||||
dungeon2_r4.connect(dungeon2_r3, OR(PEGASUS_BOOTS, HOOKSHOT)) # can use both pegasus boots bonks or hookshot spam to cross the pit room
|
||||
dungeon2_map_chest.connect(dungeon2_l2, AND(r.attack_hookshot_powder, r.boots_bonk_pit)) # use boots to jump over the pits
|
||||
dungeon2_r4.connect(dungeon2_r3, OR(r.boots_bonk_pit, r.hookshot_spam_pit)) # can use both pegasus boots bonks or hookshot spam to cross the pit room
|
||||
dungeon2_r4.connect(shyguy_key_drop, r.rear_attack_range, one_way=True) # adjust for alternate requirements for dungeon2_r4
|
||||
miniboss.connect(dungeon2_r5, AND(PEGASUS_BOOTS, r.miniboss_requirements[world_setup.miniboss_mapping[1]])) # use boots to dash over the spikes in the 2d section
|
||||
miniboss.connect(dungeon2_r5, AND(r.boots_dash_2d, r.miniboss_requirements[world_setup.miniboss_mapping[1]])) # use boots to dash over the spikes in the 2d section
|
||||
dungeon2_pre_stairs_boss.connect(dungeon2_r6, AND(HOOKSHOT, OR(BOW, BOMB, MAGIC_ROD, AND(OCARINA, SONG1)), FOUND(KEY2, 5))) # hookshot clip through the pot using both pol's voice
|
||||
dungeon2_post_stairs_boss.connect(dungeon2_pre_stairs_boss, OR(BOMB, AND(PEGASUS_BOOTS, FEATHER))) # use a bomb to lower the last platform, or boots + feather to cross over top (only relevant in hell logic)
|
||||
dungeon2_pre_boss.connect(dungeon2_post_stairs_boss, AND(PEGASUS_BOOTS, HOOKSHOT)) # boots bonk off bottom wall + hookshot spam across the two 1 tile pits vertically
|
||||
dungeon2_post_stairs_boss.connect(dungeon2_pre_stairs_boss, OR(BOMB, r.boots_jump)) # use a bomb to lower the last platform, or boots + feather to cross over top (only relevant in hell logic)
|
||||
dungeon2_pre_boss.connect(dungeon2_post_stairs_boss, AND(r.boots_bonk_pit, r.hookshot_spam_pit)) # boots bonk off bottom wall + hookshot spam across the two 1 tile pits vertically
|
||||
|
||||
self.entrance = entrance
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user