mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
2 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e1c66f1d | ||
|
|
03442621f4 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
7
.github/workflows/unittests.yml
vendored
7
.github/workflows/unittests.yml
vendored
@@ -37,13 +37,12 @@ jobs:
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
python:
|
||||
- {version: '3.12'} # current
|
||||
- {version: '3.11'} # current
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
208
BaseClasses.py
208
BaseClasses.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import itertools
|
||||
import functools
|
||||
import logging
|
||||
@@ -11,10 +12,8 @@ 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)
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
||||
TypedDict, Union, Type, ClassVar
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
@@ -24,16 +23,16 @@ if typing.TYPE_CHECKING:
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
class Group(TypedDict):
|
||||
class Group(TypedDict, total=False):
|
||||
name: str
|
||||
game: str
|
||||
world: "AutoWorld.World"
|
||||
players: AbstractSet[int]
|
||||
item_pool: NotRequired[Set[str]]
|
||||
replacement_items: NotRequired[Dict[int, Optional[str]]]
|
||||
local_items: NotRequired[Set[str]]
|
||||
non_local_items: NotRequired[Set[str]]
|
||||
link_replacement: NotRequired[bool]
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
link_replacement: bool
|
||||
|
||||
|
||||
class ThreadBarrierProxy:
|
||||
@@ -50,11 +49,6 @@ class ThreadBarrierProxy:
|
||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||
|
||||
|
||||
class HasNameAndPlayer(Protocol):
|
||||
name: str
|
||||
player: int
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
debug_types = False
|
||||
player_name: Dict[int, str]
|
||||
@@ -163,7 +157,7 @@ class MultiWorld():
|
||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr: str, val) -> None:
|
||||
def set_player_attr(attr, val):
|
||||
self.__dict__.setdefault(attr, {})[player] = val
|
||||
set_player_attr('plando_items', [])
|
||||
set_player_attr('plando_texts', {})
|
||||
@@ -172,13 +166,13 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||
"world's random object instead (usually self.random)")
|
||||
"world's random object instead (usually self.random)")
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
"""Create a group with name and return the assigned player ID and group.
|
||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||
from worlds import AutoWorld
|
||||
@@ -202,7 +196,7 @@ class MultiWorld():
|
||||
|
||||
return new_id, new_group
|
||||
|
||||
def get_player_groups(self, player: int) -> Set[int]:
|
||||
def get_player_groups(self, player) -> Set[int]:
|
||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||
|
||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||
@@ -265,7 +259,7 @@ class MultiWorld():
|
||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||
}
|
||||
|
||||
for _name, item_link in item_links.items():
|
||||
for name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
local_items = set()
|
||||
@@ -342,8 +336,6 @@ class MultiWorld():
|
||||
region = Region("Menu", group_id, self, "ItemLink")
|
||||
self.regions.append(region)
|
||||
locations = region.locations
|
||||
# ensure that progression items are linked first, then non-progression
|
||||
self.itempool.sort(key=lambda item: item.advancement)
|
||||
for item in self.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
@@ -397,7 +389,7 @@ class MultiWorld():
|
||||
return tuple(world for player, world in self.worlds.items() if
|
||||
player not in self.groups and self.game[player] == game_name)
|
||||
|
||||
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
|
||||
def get_name_string_for_object(self, obj) -> str:
|
||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||
|
||||
def get_player_name(self, player: int) -> str:
|
||||
@@ -439,7 +431,7 @@ class MultiWorld():
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_advancements()
|
||||
ret.sweep_for_events()
|
||||
|
||||
if use_cache:
|
||||
self._all_state = ret
|
||||
@@ -448,7 +440,7 @@ class MultiWorld():
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
@@ -457,7 +449,7 @@ class MultiWorld():
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player]
|
||||
|
||||
def find_item(self, item: str, player: int) -> Location:
|
||||
def find_item(self, item, player: int) -> Location:
|
||||
return next(location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player)
|
||||
|
||||
@@ -550,9 +542,9 @@ class MultiWorld():
|
||||
return True
|
||||
state = starting_state.copy()
|
||||
else:
|
||||
state = CollectionState(self)
|
||||
if self.has_beaten_game(state):
|
||||
if self.has_beaten_game(self.state):
|
||||
return True
|
||||
state = CollectionState(self)
|
||||
prog_locations = {location for location in self.get_locations() if location.item
|
||||
and location.item.advancement and location not in state.locations_checked}
|
||||
|
||||
@@ -625,7 +617,8 @@ class MultiWorld():
|
||||
|
||||
def location_relevant(location: Location) -> bool:
|
||||
"""Determine if this location is relevant to sweep."""
|
||||
return location.player in players["full"] or location.advancement
|
||||
return location.progress_type != LocationProgressType.EXCLUDED \
|
||||
and (location.player in players["full"] or location.advancement)
|
||||
|
||||
def all_done() -> bool:
|
||||
"""Check if all access rules are fulfilled"""
|
||||
@@ -670,7 +663,7 @@ class CollectionState():
|
||||
multiworld: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
advancements: Set[Location]
|
||||
events: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
@@ -682,7 +675,7 @@ class CollectionState():
|
||||
self.multiworld = parent
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.advancements = set()
|
||||
self.events = set()
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
@@ -694,25 +687,17 @@ class CollectionState():
|
||||
|
||||
def update_reachable_regions(self, player: int):
|
||||
self.stale[player] = False
|
||||
world: AutoWorld.World = self.multiworld.worlds[player]
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
queue = deque(self.blocked_connections[player])
|
||||
start: Region = world.get_region(world.origin_region_name)
|
||||
start = self.multiworld.get_region("Menu", player)
|
||||
|
||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||
if start not in reachable_regions:
|
||||
reachable_regions.add(start)
|
||||
self.blocked_connections[player].update(start.exits)
|
||||
blocked_connections.update(start.exits)
|
||||
queue.extend(start.exits)
|
||||
|
||||
if world.explicit_indirect_conditions:
|
||||
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
|
||||
else:
|
||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
while queue:
|
||||
connection = queue.popleft()
|
||||
@@ -720,7 +705,7 @@ class CollectionState():
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
@@ -732,39 +717,16 @@ class CollectionState():
|
||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
new_connection: bool = True
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
while new_connection:
|
||||
new_connection = False
|
||||
while queue:
|
||||
connection = queue.popleft()
|
||||
new_region = connection.connected_region
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
new_connection = True
|
||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||
queue.extend(blocked_connections)
|
||||
|
||||
def copy(self) -> CollectionState:
|
||||
ret = CollectionState(self.multiworld)
|
||||
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
|
||||
ret.reachable_regions = {player: region_set.copy() for player, region_set in
|
||||
self.reachable_regions.items()}
|
||||
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
|
||||
self.blocked_connections.items()}
|
||||
ret.advancements = self.advancements.copy()
|
||||
ret.path = self.path.copy()
|
||||
ret.locations_checked = self.locations_checked.copy()
|
||||
ret.prog_items = copy.deepcopy(self.prog_items)
|
||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||
self.reachable_regions}
|
||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||
self.blocked_connections}
|
||||
ret.events = copy.copy(self.events)
|
||||
ret.path = copy.copy(self.path)
|
||||
ret.locations_checked = copy.copy(self.locations_checked)
|
||||
for function in self.additional_copy_functions:
|
||||
ret = function(self, ret)
|
||||
return ret
|
||||
@@ -795,24 +757,19 @@ class CollectionState():
|
||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
||||
|
||||
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
|
||||
"Please switch over to sweep_for_advancements.")
|
||||
return self.sweep_for_advancements(locations)
|
||||
|
||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.multiworld.get_filled_locations()
|
||||
reachable_advancements = True
|
||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||
reachable_events = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.advancement and location not in self.events}
|
||||
|
||||
while reachable_advancements:
|
||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_advancements
|
||||
for advancement in reachable_advancements:
|
||||
self.advancements.add(advancement)
|
||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(advancement.item, True, advancement)
|
||||
while reachable_events:
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_events
|
||||
for event in reachable_events:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
|
||||
# item name related
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
@@ -846,7 +803,7 @@ class CollectionState():
|
||||
if found >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||
Ignores duplicates of the same item."""
|
||||
@@ -861,7 +818,7 @@ class CollectionState():
|
||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
|
||||
|
||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||
@@ -907,16 +864,20 @@ class CollectionState():
|
||||
)
|
||||
|
||||
# Item related
|
||||
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||
|
||||
if not changed and event:
|
||||
self.prog_items[item.player][item.name] += 1
|
||||
changed = True
|
||||
|
||||
self.stale[item.player] = True
|
||||
|
||||
if changed and not prevent_sweep:
|
||||
self.sweep_for_advancements()
|
||||
if changed and not event:
|
||||
self.sweep_for_events()
|
||||
|
||||
return changed
|
||||
|
||||
@@ -940,13 +901,12 @@ class Entrance:
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.player = player
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||
if not self.hide_path and not self in state.path:
|
||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||
@@ -961,6 +921,9 @@ class Entrance:
|
||||
region.entrances.append(self)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1086,7 +1049,7 @@ class Region:
|
||||
self.locations.append(location_type(self.player, location, address, self))
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
@@ -1126,6 +1089,9 @@ class Region:
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
@@ -1144,9 +1110,9 @@ class Location:
|
||||
locked: bool = False
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
always_allow = staticmethod(lambda state, item: False)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||
@@ -1155,20 +1121,16 @@ class Location:
|
||||
self.address = address
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
|
||||
return ((
|
||||
self.always_allow(state, item)
|
||||
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
|
||||
) or (
|
||||
(self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))
|
||||
))
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))))
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
||||
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
|
||||
return self.parent_region.can_reach(state) and self.access_rule(state)
|
||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||
assert self.parent_region, "Can't reach location without region"
|
||||
return self.access_rule(state) and self.parent_region.can_reach(state)
|
||||
|
||||
def place_locked_item(self, item: Item):
|
||||
if self.item:
|
||||
@@ -1178,6 +1140,9 @@ class Location:
|
||||
self.locked = True
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1199,7 +1164,7 @@ class Location:
|
||||
@property
|
||||
def native_item(self) -> bool:
|
||||
"""Returns True if the item in this location matches game."""
|
||||
return self.item is not None and self.item.game == self.game
|
||||
return self.item and self.item.game == self.game
|
||||
|
||||
@property
|
||||
def hint_text(self) -> str:
|
||||
@@ -1210,7 +1175,7 @@ class ItemClassification(IntFlag):
|
||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||
progression = 0b0001 # Item that is logically relevant
|
||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||
trap = 0b0100 # detrimental item
|
||||
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
||||
skip_balancing = 0b1000 # should technically never occur on its own
|
||||
# Item that is logically relevant, but progression balancing should not touch.
|
||||
# Typically currency or other counted items.
|
||||
@@ -1282,6 +1247,9 @@ class Item:
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||
return f"{self.name} (Player {self.player})"
|
||||
@@ -1359,9 +1327,9 @@ class Spoiler:
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later: Dict[Location, Item] = {}
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete: Set[Location] = set()
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
@@ -1379,7 +1347,7 @@ class Spoiler:
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected: List[Item] = []
|
||||
removed_precollected = []
|
||||
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)
|
||||
@@ -1460,7 +1428,7 @@ class Spoiler:
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||
for (_, exit_path) in path):
|
||||
if multiworld.worlds[player].options.mode != 'inverted':
|
||||
if multiworld.mode[player] != 'inverted':
|
||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
@@ -1532,9 +1500,9 @@ class Spoiler:
|
||||
|
||||
if self.paths:
|
||||
outfile.write('\n\nPaths:\n\n')
|
||||
path_listings: List[str] = []
|
||||
path_listings = []
|
||||
for location, path in sorted(self.paths.items()):
|
||||
path_lines: List[str] = []
|
||||
path_lines = []
|
||||
for region, exit in path:
|
||||
if exit is not None:
|
||||
path_lines.append("{} -> {}".format(region, exit))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds._bizhawk.context import launch
|
||||
|
||||
if __name__ == "__main__":
|
||||
launch(*sys.argv[1:])
|
||||
launch()
|
||||
|
||||
@@ -252,7 +252,7 @@ class CommonContext:
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui: typing.Optional["kvui.GameManager"] = None
|
||||
ui = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
@@ -662,19 +662,17 @@ class CommonContext:
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
return TextManager
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
|
||||
ui_class = self.make_gui()
|
||||
self.ui = ui_class(self)
|
||||
self.ui = TextManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
@@ -996,7 +994,7 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
return parser
|
||||
|
||||
|
||||
def run_as_textclient(*args):
|
||||
def run_as_textclient():
|
||||
class TextContext(CommonContext):
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
tags = CommonContext.tags | {"TextOnly"}
|
||||
@@ -1035,18 +1033,15 @@ def run_as_textclient(*args):
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args(args)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
if url.scheme == "archipelago":
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
else:
|
||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
colorama.init()
|
||||
|
||||
@@ -1056,4 +1051,4 @@ def run_as_textclient(*args):
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
||||
run_as_textclient(*sys.argv[1:]) # default value for parse_args
|
||||
run_as_textclient()
|
||||
|
||||
53
Fill.py
53
Fill.py
@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
|
||||
if "multiworld" in kwargs and isinstance(args[0], str):
|
||||
placements = (args[0] + f"\nAll Placements:\n" +
|
||||
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
|
||||
args = (placements, *args[1:])
|
||||
super().__init__(*args)
|
||||
pass
|
||||
|
||||
|
||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||
@@ -29,7 +24,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_advancements(locations=locations)
|
||||
new_state.sweep_for_events(locations=locations)
|
||||
return new_state
|
||||
|
||||
|
||||
@@ -217,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
|
||||
item_pool.extend(unplaced_items)
|
||||
|
||||
@@ -304,7 +299,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
@@ -329,8 +324,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
||||
pool.append(location.item)
|
||||
state.remove(location.item)
|
||||
location.item = None
|
||||
if location in state.advancements:
|
||||
state.advancements.remove(location)
|
||||
if location in state.events:
|
||||
state.events.remove(location)
|
||||
locations.append(location)
|
||||
if pool and locations:
|
||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||
@@ -363,7 +358,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
loc_indexes_to_remove: typing.Set[int] = set()
|
||||
base_state = multiworld.state.copy()
|
||||
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
for i, loc in enumerate(fill_locations):
|
||||
if loc.can_reach(base_state):
|
||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||
@@ -475,26 +470,28 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
nonlocal lock_later
|
||||
lock_later.append(location)
|
||||
|
||||
single_player = multiworld.players == 1 and not multiworld.groups
|
||||
|
||||
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=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
||||
name="Priority")
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
# "advancement/progression fill"
|
||||
if panic_method == "swap":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
||||
name="Progression", single_player_placement=single_player)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "raise":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||
name="Progression", single_player_placement=single_player)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "start_inventory":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False, allow_partial=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
if progitempool:
|
||||
for item in progitempool:
|
||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||
@@ -509,8 +506,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f"Not enough locations for progression items. "
|
||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
||||
multiworld=multiworld,
|
||||
f"There are {len(progitempool)} more progression items than there are available locations."
|
||||
)
|
||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
@@ -527,8 +523,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. "
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
||||
multiworld=multiworld,
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
||||
)
|
||||
|
||||
restitempool = filleritempool + usefulitempool
|
||||
@@ -556,7 +551,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
progress_done = False
|
||||
|
||||
# sweep once to pick up preplaced items
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_events()
|
||||
|
||||
# fill multiworld from top of itempool while we can
|
||||
while not progress_done:
|
||||
@@ -594,7 +589,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
else:
|
||||
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
||||
raise FillError('No more progress items left to place.')
|
||||
|
||||
# find item to replace with progress item
|
||||
location_list = multiworld.get_reachable_locations()
|
||||
@@ -744,7 +739,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_advancements(locations=locations_to_test)
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
|
||||
if multiworld.has_beaten_game(balancing_state):
|
||||
if not multiworld.has_beaten_game(reducing_state):
|
||||
@@ -827,7 +822,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_advancements()
|
||||
swept_state.sweep_for_events()
|
||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
|
||||
36
Generate.py
36
Generate.py
@@ -43,10 +43,10 @@ def mystery_argparse():
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument("--csv_output", action="store_true",
|
||||
help="Output rolled player options to csv (made for async multiworld).")
|
||||
parser.add_argument("--plando", default=defaults.plando_options,
|
||||
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults.plando_options,
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||
help="Skip progression balancing step during generation.")
|
||||
parser.add_argument("--skip_output", action="store_true",
|
||||
@@ -155,8 +155,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
erargs.skip_output = args.skip_output
|
||||
erargs.name = {}
|
||||
erargs.csv_output = args.csv_output
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
@@ -204,7 +202,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif player not in erargs.name: # if name was not specified, generate it from filename
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
@@ -217,6 +215,28 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
return erargs, seed
|
||||
|
||||
|
||||
@@ -491,7 +511,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
if __name__ == '__main__':
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||
|
||||
from worlds.kh1.Client import launch
|
||||
launch()
|
||||
105
Launcher.py
105
Launcher.py
@@ -16,11 +16,10 @@ import multiprocessing
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||
from typing import Callable, Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
@@ -108,81 +107,7 @@ components.extend([
|
||||
])
|
||||
|
||||
|
||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
launch_args = (path, *launch_args)
|
||||
client_component = None
|
||||
text_client_component = None
|
||||
if "game" in queries:
|
||||
game = queries["game"][0]
|
||||
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
||||
game = "Archipelago"
|
||||
for component in components:
|
||||
if component.supports_uri and component.game_name == game:
|
||||
client_component = component
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Clock, 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"
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
layout = BoxLayout(orientation="vertical")
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
Popup().run()
|
||||
|
||||
|
||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
@@ -341,7 +266,7 @@ def run_gui():
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {file}")
|
||||
logging.warning(f"unable to identify component for {filename}")
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
@@ -374,24 +299,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
path = args.get("Patch|Game|Component|url", None)
|
||||
if path is not None:
|
||||
if path.startswith("archipelago://"):
|
||||
handle_uri(path, args.get("args", ()))
|
||||
return
|
||||
file, component = identify(path)
|
||||
if args.get("Patch|Game|Component", None) is not None:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {path}")
|
||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||
|
||||
if args["update_settings"]:
|
||||
update_settings()
|
||||
if "file" in args:
|
||||
if 'file' in args:
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif "component" in args:
|
||||
elif 'component' in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
run_gui()
|
||||
@@ -401,16 +322,12 @@ if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Archipelago Launcher',
|
||||
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
run_group = parser.add_argument_group("Run")
|
||||
run_group.add_argument("--update_settings", action="store_true",
|
||||
help="Update host.yaml and exit.")
|
||||
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game, the component name to run, or a url to "
|
||||
"connect with.")
|
||||
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
run_group.add_argument("args", nargs="*",
|
||||
help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
|
||||
@@ -467,8 +467,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
@@ -566,8 +564,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
@@ -632,7 +628,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
self.magpie.slot_data = self.slot_data
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
|
||||
@@ -14,7 +14,7 @@ import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
|
||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
@@ -29,8 +29,7 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
@@ -243,17 +242,16 @@ def adjustGUI():
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
|
||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||
|
||||
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
|
||||
bottomFrame2 = Frame(adjustWindow)
|
||||
|
||||
romFrame, romVar = get_rom_frame(adjustWindow)
|
||||
|
||||
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
|
||||
romDialogFrame = Frame(adjustWindow)
|
||||
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
|
||||
romVar2 = StringVar()
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
@@ -263,9 +261,9 @@ def adjustGUI():
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
romDialogFrame.pack(side=TOP, expand=False, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT)
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton2.pack(side=LEFT)
|
||||
|
||||
def adjustRom():
|
||||
@@ -333,11 +331,12 @@ def adjustGUI():
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
||||
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
|
||||
rom_options_frame.pack(side=TOP)
|
||||
adjustButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||
saveButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||
|
||||
tkinter_center_window(adjustWindow)
|
||||
@@ -577,7 +576,7 @@ class AttachTooltip(object):
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romFrame = Frame(parent, padx=8, pady=8)
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value=adjuster_settings.baserom)
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
@@ -597,19 +596,20 @@ def get_rom_frame(parent=None):
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
baseRomLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
romFrame.pack(side=TOP, fill=X)
|
||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
romOptionsFrame.columnconfigure(1, weight=1)
|
||||
for i in range(5):
|
||||
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
|
||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||
|
||||
baseSpriteLabel.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
oofDialogFrame = Frame(romOptionsFrame)
|
||||
|
||||
25
Main.py
25
Main.py
@@ -11,8 +11,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||
flood_items
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from settings import get_settings
|
||||
@@ -46,9 +45,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
||||
|
||||
multiworld.set_options(args)
|
||||
if args.csv_output:
|
||||
from Options import dump_player_options
|
||||
dump_player_options(multiworld)
|
||||
multiworld.set_item_links()
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
||||
@@ -104,7 +100,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||
remaining_count = count-early
|
||||
if remaining_count > 0:
|
||||
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
||||
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
||||
if local_early:
|
||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
del local_early
|
||||
@@ -155,7 +151,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
# 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",
|
||||
@@ -174,24 +169,20 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
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:])
|
||||
new_items.extend(multiworld.itempool[i+1:])
|
||||
break
|
||||
else:
|
||||
old_items.append(item)
|
||||
new_items.append(item)
|
||||
|
||||
# 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)}"
|
||||
raise Exception(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
|
||||
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items
|
||||
|
||||
multiworld.link_items()
|
||||
|
||||
@@ -350,7 +341,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
if not multiworld.can_beat_game():
|
||||
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
|
||||
@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
prev = "" # if a line ends in \ we store here and merge later
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
|
||||
@@ -67,21 +67,6 @@ def update_dict(dictionary, entries):
|
||||
return dictionary
|
||||
|
||||
|
||||
def queue_gc():
|
||||
import gc
|
||||
from threading import Thread
|
||||
|
||||
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
|
||||
def async_collect():
|
||||
time.sleep(2)
|
||||
setattr(queue_gc, "_thread", None)
|
||||
gc.collect()
|
||||
if not gc_thread:
|
||||
gc_thread = Thread(target=async_collect)
|
||||
setattr(queue_gc, "_thread", gc_thread)
|
||||
gc_thread.start()
|
||||
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
modify_functions = {
|
||||
# generic:
|
||||
@@ -566,9 +551,6 @@ class Context:
|
||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
else:
|
||||
self.save_dirty = False
|
||||
if not atexit_save: # if atexit is used, that keeps a reference anyway
|
||||
queue_gc()
|
||||
|
||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||
self.auto_saver_thread.start()
|
||||
|
||||
@@ -1009,7 +991,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
collect_player(ctx, team, group, True)
|
||||
|
||||
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
@@ -1221,10 +1203,6 @@ class CommonCommandProcessor(CommandProcessor):
|
||||
timer = int(seconds, 10)
|
||||
except ValueError:
|
||||
timer = 10
|
||||
else:
|
||||
if timer > 60 * 60:
|
||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||
|
||||
async_start(countdown(self.ctx, timer))
|
||||
return True
|
||||
|
||||
@@ -1372,10 +1350,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def _cmd_remaining(self) -> bool:
|
||||
"""List remaining items in your game, but not their location or recipient"""
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -1385,10 +1363,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
else: # is goal
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -1879,6 +1857,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
args["cmd"] = "SetReply"
|
||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||
args["original_value"] = copy.copy(value)
|
||||
args["slot"] = client.slot
|
||||
for operation in args["operations"]:
|
||||
func = modify_functions[operation["operation"]]
|
||||
value = func(value, operation["value"])
|
||||
@@ -2061,8 +2040,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
item_name, usable, response = get_intended_text(item_name, names)
|
||||
if usable:
|
||||
amount: int = int(amount)
|
||||
if amount > 100:
|
||||
raise ValueError(f"{amount} is invalid. Maximum is 100.")
|
||||
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||
send_items_to(self.ctx, team, slot, *new_items)
|
||||
|
||||
|
||||
12
NetUtils.py
12
NetUtils.py
@@ -79,7 +79,6 @@ class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
|
||||
@@ -273,8 +272,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
|
||||
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
|
||||
|
||||
def color_code(*args):
|
||||
@@ -399,12 +397,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
location_id not in checked]
|
||||
|
||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[typing.Tuple[int, int]]:
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
player_locations = self[slot]
|
||||
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
return sorted([player_locations[location_id][0] for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||
|
||||
81
Options.py
81
Options.py
@@ -8,17 +8,16 @@ import numbers
|
||||
import random
|
||||
import typing
|
||||
import enum
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from schema import And, Optional, Or, Schema
|
||||
from typing_extensions import Self
|
||||
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str, output_path
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, PlandoOptions
|
||||
from BaseClasses import PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
import pathlib
|
||||
|
||||
@@ -974,19 +973,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
if random.random() < float(text.get("percentage", 100)/100):
|
||||
at = text.get("at", None)
|
||||
if at is not None:
|
||||
if isinstance(at, dict):
|
||||
if at:
|
||||
at = random.choices(list(at.keys()),
|
||||
weights=list(at.values()), k=1)[0]
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
given_text = text.get("text", [])
|
||||
if isinstance(given_text, dict):
|
||||
if not given_text:
|
||||
given_text = []
|
||||
else:
|
||||
given_text = random.choices(list(given_text.keys()),
|
||||
weights=list(given_text.values()), k=1)
|
||||
if isinstance(given_text, str):
|
||||
given_text = [given_text]
|
||||
texts.append(PlandoText(
|
||||
@@ -994,8 +981,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
given_text,
|
||||
text.get("percentage", 100)
|
||||
))
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
elif isinstance(text, PlandoText):
|
||||
if random.random() < float(text.percentage/100):
|
||||
texts.append(text)
|
||||
@@ -1251,7 +1236,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
:param option_names: names of the options to return
|
||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||
"""
|
||||
assert option_names, "options.as_dict() was used without any option names."
|
||||
option_results = {}
|
||||
for option_name in option_names:
|
||||
if option_name in type(self).type_hints:
|
||||
@@ -1336,7 +1320,7 @@ class PriorityLocations(LocationSet):
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
display_name = "Death Link"
|
||||
rich_text_doc = True
|
||||
|
||||
@@ -1535,40 +1519,29 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
f.write(res)
|
||||
|
||||
|
||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
from csv import DictWriter
|
||||
if __name__ == "__main__":
|
||||
|
||||
game_players = defaultdict(list)
|
||||
for player, game in multiworld.game.items():
|
||||
game_players[game].append(player)
|
||||
game_players = dict(sorted(game_players.items()))
|
||||
from worlds.alttp.Options import Logic
|
||||
import argparse
|
||||
|
||||
output = []
|
||||
per_game_option_names = [
|
||||
getattr(option, "display_name", option_key)
|
||||
for option_key, option in PerGameCommonOptions.type_hints.items()
|
||||
]
|
||||
all_option_names = per_game_option_names.copy()
|
||||
for game, players in game_players.items():
|
||||
game_option_names = per_game_option_names.copy()
|
||||
for player in players:
|
||||
world = multiworld.worlds[player]
|
||||
player_output = {
|
||||
"Game": multiworld.game[player],
|
||||
"Name": multiworld.get_player_name(player),
|
||||
}
|
||||
output.append(player_output)
|
||||
for option_key, option in world.options_dataclass.type_hints.items():
|
||||
if issubclass(Removed, option):
|
||||
continue
|
||||
display_name = getattr(option, "display_name", option_key)
|
||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||
if display_name not in game_option_names:
|
||||
all_option_names.append(display_name)
|
||||
game_option_names.append(display_name)
|
||||
|
||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||
fields = ["Game", "Name", *all_option_names]
|
||||
writer = DictWriter(file, fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(output)
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
key_shuffle = Toggle
|
||||
big_key_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
test.map_shuffle = map_shuffle.from_text("ON")
|
||||
test.hints = hints.from_text('OFF')
|
||||
try:
|
||||
test.logic = Logic.from_text("overworld_glitches_typo")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
try:
|
||||
test.logic_owg = Logic.from_text("owg")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
if test.map_shuffle:
|
||||
print("map_shuffle is on")
|
||||
print(f"Hints are {bool(test.hints)}")
|
||||
print(test)
|
||||
|
||||
@@ -73,9 +73,6 @@ Currently, the following games are supported:
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
* Old School Runescape
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
|
||||
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
|
||||
|
||||
2
Utils.py
2
Utils.py
@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.5.1"
|
||||
__version__ = "0.5.0"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
|
||||
@@ -267,7 +267,9 @@ class WargrooveContext(CommonContext):
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
self.add_client_tab("Wargroove", self.build_tracker())
|
||||
panel = TabbedPanelItem(text="Wargroove")
|
||||
panel.content = self.build_tracker()
|
||||
self.tabs.add_widget(panel)
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
|
||||
10
WebHost.py
10
WebHost.py
@@ -1,4 +1,3 @@
|
||||
import argparse
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
@@ -32,15 +31,6 @@ def get_app() -> "Flask":
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
if args.config_override:
|
||||
import yaml
|
||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||
logging.info(f"Updated config from {args.config_override}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
"""API endpoints package."""
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, abort, url_for
|
||||
|
||||
from ..models import Seed
|
||||
import worlds.Files
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
# unsorted/misc endpoints
|
||||
|
||||
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
|
||||
|
||||
from . import datapackage, generate, room, user # trigger registration
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str):
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
|
||||
|
||||
from . import generate, user, datapackage # trigger registration
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from typing import Any, Dict
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, url_for
|
||||
|
||||
import worlds.Files
|
||||
from . import api_endpoints, get_players
|
||||
from ..models import Room
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room_id>')
|
||||
def room_info(room_id: UUID) -> Dict[str, Any]:
|
||||
room = Room.get(id=room_id)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str) -> bool:
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
@@ -72,14 +72,6 @@ class WebHostContext(Context):
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
import psutil
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
@@ -257,7 +249,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
@@ -288,7 +279,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
if ctx.saving:
|
||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||
assert ctx.shutdown_task is None
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
@@ -335,7 +325,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect()
|
||||
gc.collect(0)
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
task.add_done_callback(self._done)
|
||||
|
||||
@@ -134,7 +134,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
erargs.skip_output = False
|
||||
erargs.csv_output = False
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
|
||||
@@ -132,41 +132,26 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.post("/room/<suuid:room>")
|
||||
def host_room_command(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
@app.get("/room/<suuid:room>")
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||
automated = ("update" in request.args
|
||||
or "Discordbot" in request.user_agent.string
|
||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||
|
||||
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
||||
if max_size == 0:
|
||||
return "…"
|
||||
def get_log(max_size: int = 1024000) -> str:
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.4
|
||||
pony>=0.7.19
|
||||
werkzeug>=3.0.3
|
||||
pony>=0.7.17
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.8.0
|
||||
Flask-Limiter>=3.7.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.4.1; python_version >= '3.9'
|
||||
markupsafe>=2.1.5
|
||||
|
||||
@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
|
||||
You may also find developer documentation in the `docs` folder:
|
||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
|
||||
@@ -58,28 +58,3 @@
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.loader{
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
margin-left: 5px;
|
||||
width: 40px;
|
||||
aspect-ratio: 4;
|
||||
--_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
|
||||
background:
|
||||
var(--_g) 0 50%,
|
||||
var(--_g) 50% 50%,
|
||||
var(--_g) 100% 50%;
|
||||
background-size: calc(100%/3) 100%;
|
||||
animation: l7 1s infinite linear;
|
||||
}
|
||||
|
||||
.loader.loading{
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@keyframes l7{
|
||||
33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
|
||||
50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
|
||||
66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
|
||||
}
|
||||
|
||||
@@ -99,18 +99,14 @@
|
||||
{% if hint.finding_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||
{% else %}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
</a>
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if hint.receiving_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||
{% else %}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
</a>
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
||||
|
||||
@@ -19,30 +19,28 @@
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="host-room">
|
||||
<span id="host-room-info">
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
{{ macros.list_patches_room(room) }}
|
||||
{% if room.owner == session["_id"] %}
|
||||
<div style="display: flex; align-items: center;">
|
||||
@@ -51,7 +49,6 @@
|
||||
<label for="cmd"></label>
|
||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||
placeholder="Server Command. /help to list them, list gets appended to log.">
|
||||
<span class="loader"></span>
|
||||
</div>
|
||||
</form>
|
||||
<a href="{{ url_for("display_log", room=room.id) }}">
|
||||
@@ -65,7 +62,6 @@
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
let bytesReceived = {{ log_len }};
|
||||
let updateLogTimeout;
|
||||
let updateLogImmediately = false;
|
||||
let awaitingCommandResponse = false;
|
||||
let logger = document.getElementById("logger");
|
||||
|
||||
@@ -82,36 +78,29 @@
|
||||
|
||||
async function updateLog() {
|
||||
try {
|
||||
if (!document.hidden) {
|
||||
updateLogImmediately = false;
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
|
||||
loader.classList.remove("loading");
|
||||
}
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
}
|
||||
} else {
|
||||
updateLogImmediately = true;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
@@ -136,62 +125,20 @@
|
||||
});
|
||||
ev.preventDefault(); // has to happen before first await
|
||||
form.reset();
|
||||
let loader = form.getElementsByClassName("loader")[0];
|
||||
loader.classList.add("loading");
|
||||
try {
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
loader.classList.remove("loading");
|
||||
window.alert(res.statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.classList.remove("loading");
|
||||
window.alert(e.message);
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
window.alert(res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("command-form").addEventListener("submit", postForm);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
||||
logger.scrollTop = logger.scrollHeight;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && updateLogImmediately) {
|
||||
updateLog();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
function updateInfo() {
|
||||
let url = new URL(window.location.href);
|
||||
url.search = "?update";
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error ${res.status}`);
|
||||
}
|
||||
return res.text()
|
||||
})
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
if (document.querySelector("meta[http-equiv='refresh']")) {
|
||||
console.log("Refresh!");
|
||||
window.addEventListener('load', function () {
|
||||
for (let i=0; i<3; i++) {
|
||||
window.setTimeout(updateInfo, Math.pow(2, i) * 2000); // 2, 4, 8s
|
||||
}
|
||||
window.stop(); // cancel meta refresh
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||
<tr>
|
||||
<td>{{ patch.player_id }}</td>
|
||||
<td data-tooltip="Connect via Game Client"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}?game={{ patch.game }}&room={{ room.id | suuid }}">{{ patch.player_name }}</a></td>
|
||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
||||
<td>{{ patch.game }}</td>
|
||||
<td>
|
||||
{% if patch.data %}
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
{% extends 'tablepage.html' %}
|
||||
|
||||
{%- macro games(slots) -%}
|
||||
{%- set gameList = [] -%}
|
||||
{%- set maxGamesToShow = 10 -%}
|
||||
|
||||
{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
|
||||
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
|
||||
{% set _ = gameList.append(player) -%}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if slots|length > maxGamesToShow -%}
|
||||
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
|
||||
{%- endif -%}
|
||||
|
||||
{{ gameList|join('\n') }}
|
||||
{%- endmacro -%}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>User Content</title>
|
||||
@@ -49,12 +33,10 @@
|
||||
<tr>
|
||||
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
|
||||
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
|
||||
<td title="{{ games(room.seed.slots) }}">
|
||||
{{ room.seed.slots|length }}
|
||||
</td>
|
||||
<td>{{ room.seed.slots|length }}</td>
|
||||
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td>
|
||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -78,21 +60,16 @@
|
||||
{% for seed in seeds %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
|
||||
<td title="{{ games(seed.slots) }}">
|
||||
{% if seed.multidata %}
|
||||
{{ seed.slots|length }}
|
||||
{% else %}
|
||||
1
|
||||
{% endif %}
|
||||
<td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
|
||||
</td>
|
||||
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td>
|
||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
You have not generated any seeds yet!
|
||||
You have no generated any seeds yet!
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
id="{{ option_name }}-{{ key }}"
|
||||
name="{{ option_name }}||{{ key }}"
|
||||
value="1"
|
||||
{{ "checked" if key in option.default }}
|
||||
checked="{{ "checked" if key in option.default else "" }}"
|
||||
/>
|
||||
<label for="{{ option_name }}-{{ key }}">
|
||||
{{ key }}
|
||||
|
||||
@@ -287,15 +287,15 @@ cdef class LocationStore:
|
||||
entry in self.entries[start:start + count] if
|
||||
entry.location not in checked]
|
||||
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[int]:
|
||||
cdef LocationEntry* entry
|
||||
cdef ap_player_t sender = slot
|
||||
cdef size_t start = self.sender_index[sender].start
|
||||
cdef size_t count = self.sender_index[sender].count
|
||||
cdef set checked = state[team, slot]
|
||||
return sorted([(entry.receiver, entry.item) for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
return sorted([entry.item for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
|
||||
|
||||
@cython.auto_pickle(False)
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
/worlds/clique/ @ThePhar
|
||||
|
||||
# Dark Souls III
|
||||
/worlds/dark_souls_3/ @Marechal-L @nex3
|
||||
/worlds/dark_souls_3/ @Marechal-L
|
||||
|
||||
# Donkey Kong Country 3
|
||||
/worlds/dkc3/ @PoryGone
|
||||
@@ -78,9 +78,6 @@
|
||||
# Kirby's Dream Land 3
|
||||
/worlds/kdl3/ @Silvris
|
||||
|
||||
# Kingdom Hearts
|
||||
/worlds/kh1/ @gaithern
|
||||
|
||||
# Kingdom Hearts 2
|
||||
/worlds/kh2/ @JaredWeakStrike
|
||||
|
||||
@@ -106,9 +103,6 @@
|
||||
# Minecraft
|
||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
||||
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
# MegaMan Battle Network 3
|
||||
/worlds/mmbn3/ @digiholic
|
||||
|
||||
@@ -118,6 +112,9 @@
|
||||
# Noita
|
||||
/worlds/noita/ @ScipioWright @heinermann
|
||||
|
||||
# Ocarina of Time
|
||||
/worlds/oot/ @espeon65536
|
||||
|
||||
# Old School Runescape
|
||||
/worlds/osrs @digiholic
|
||||
|
||||
@@ -199,9 +196,6 @@
|
||||
# The Witness
|
||||
/worlds/witness/ @NewSoupVi @blastron
|
||||
|
||||
# Yacht Dice
|
||||
/worlds/yachtdice/ @spinerak
|
||||
|
||||
# Yoshi's Island
|
||||
/worlds/yoshisisland/ @PinkSwitch
|
||||
|
||||
@@ -227,9 +221,6 @@
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
## Disabled Unmaintained Worlds
|
||||
|
||||
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
||||
|
||||
@@ -261,6 +261,7 @@ Sent to clients in response to a [Set](#Set) package if want_reply was set to tr
|
||||
| key | str | The key that was updated. |
|
||||
| value | any | The new value for the key. |
|
||||
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
||||
| slot | int | The slot that originally sent the Set package causing this change. |
|
||||
|
||||
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
||||
|
||||
@@ -702,18 +703,14 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
||||
| checksum | str | A checksum hash of this game's data. |
|
||||
|
||||
### Tags
|
||||
Tags are represented as a list of strings, the common client tags follow:
|
||||
Tags are represented as a list of strings, the common Client tags follow:
|
||||
|
||||
| Name | Notes |
|
||||
|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets. |
|
||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
|
||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
|
||||
### DeathLink
|
||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||
|
||||
@@ -24,7 +24,7 @@ display as `Value1` on the webhost.
|
||||
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
|
||||
Choice, and defining `alias_true = option_full`.
|
||||
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
|
||||
`(Named)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||
`(Named/Special)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
|
||||
However, you can override `from_text` and handle `text == "random"` to customize its behavior or
|
||||
implement it for additional option types.
|
||||
@@ -129,23 +129,6 @@ class Difficulty(Choice):
|
||||
default = 1
|
||||
```
|
||||
|
||||
### Option Visibility
|
||||
Every option has a Visibility IntFlag, defaulting to `all` (`0b1111`). This lets you choose where the option will be
|
||||
displayed. This only impacts where options are displayed, not how they can be used. Hidden options are still valid
|
||||
options in a yaml. The flags are as follows:
|
||||
* `none` (`0b0000`): This option is not shown anywhere
|
||||
* `template` (`0b0001`): This option shows up in template yamls
|
||||
* `simple_ui` (`0b0010`): This option shows up on the options page
|
||||
* `complex_ui` (`0b0100`): This option shows up on the advanced/weighted options page
|
||||
* `spoiler` (`0b1000`): This option shows up in spoiler logs
|
||||
|
||||
```python
|
||||
from Options import Choice, Visibility
|
||||
|
||||
class HiddenChoiceOption(Choice):
|
||||
visibility = Visibility.none
|
||||
```
|
||||
|
||||
### Option Groups
|
||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
||||
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
||||
|
||||
@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
|
||||
|
||||
What you'll need:
|
||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* Python 3.12.x is currently the newest supported version
|
||||
* **Python 3.12 is currently unsupported**
|
||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||
* Matching C compiler
|
||||
* possibly optional, read operating system specific sections
|
||||
@@ -31,14 +31,14 @@ After this, you should be able to run the programs.
|
||||
|
||||
Recommended steps
|
||||
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
||||
* [read above](#General) which versions are supported
|
||||
* **Python 3.12 is currently unsupported**
|
||||
|
||||
* **Optional**: Download and install Visual Studio Build Tools from
|
||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
||||
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
|
||||
Generally, selecting the box for "Desktop Development with C++" will provide what you need.
|
||||
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
|
||||
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||
|
||||
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
||||
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
||||
|
||||
@@ -303,31 +303,6 @@ generation (entrance randomization).
|
||||
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
|
||||
(items that have been collected).
|
||||
|
||||
The two possible ways to make a [CollectionRule](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10) are:
|
||||
- `def rule(state: CollectionState) -> bool:`
|
||||
- `lambda state: ... boolean expression ...`
|
||||
|
||||
An access rule can be assigned through `set_rule(location, rule)`.
|
||||
|
||||
Access rules usually check for one of two things.
|
||||
- Items that have been collected (e.g. `state.has("Sword", player)`)
|
||||
- Locations, Regions or Entrances that have been reached (e.g. `state.can_reach_region("Boss Room")`)
|
||||
|
||||
Keep in mind that entrances and locations implicitly check for the accessibility of their parent region, so you do not need to check explicitly for it.
|
||||
|
||||
#### An important note on Entrance access rules:
|
||||
When using `state.can_reach` within an entrance access condition, you must also use `multiworld.register_indirect_condition`.
|
||||
|
||||
For efficiency reasons, every time reachable regions are searched, every entrance is only checked once in a somewhat non-deterministic order.
|
||||
This is fine when checking for items using `state.has`, because items do not change during a region sweep.
|
||||
However, `state.can_reach` checks for the very same thing we are updating: Regions.
|
||||
This can lead to non-deterministic behavior and, in the worst case, even generation failures.
|
||||
Even doing `state.can_reach_location` or `state.can_reach_entrance` is problematic, as these functions call `state.can_reach_region` on the respective parent region.
|
||||
|
||||
**Therefore, it is considered unsafe to perform `state.can_reach` from within an access condition for an entrance**, unless you are checking for something that sits in the source region of the entrance.
|
||||
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.
|
||||
|
||||
### 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
|
||||
@@ -655,7 +630,7 @@ def set_rules(self) -> None:
|
||||
|
||||
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
|
||||
Entrance should be
|
||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10).
|
||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9).
|
||||
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
|
||||
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
|
||||
For an example, see [The Messenger](/worlds/messenger/rules.py).
|
||||
|
||||
@@ -26,17 +26,8 @@ Unless these are shared between multiple people, we expect the following from ea
|
||||
### Adding a World
|
||||
|
||||
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
|
||||
nominate someone else (i.e. there are multiple devs).
|
||||
|
||||
### Being added as a maintainer to an existing implementation
|
||||
|
||||
At any point, a world maintainer can approve the addition of another maintainer to their world.
|
||||
In order to do this, either an existing maintainer or the new maintainer must open a PR updating the
|
||||
[CODEOWNERS](/docs/CODEOWNERS) file.
|
||||
This change must be approved by all existing maintainers of the affected world, the new maintainer candidate, and
|
||||
one core maintainer.
|
||||
To help the core team review the change, information about the new maintainer and their contributions should be
|
||||
included in the PR description.
|
||||
nominate someone else (i.e. there are multiple devs). You can define who is allowed to approve changes to your world
|
||||
in the [CODEOWNERS](/docs/CODEOWNERS) document.
|
||||
|
||||
### Getting Voted
|
||||
|
||||
@@ -44,7 +35,7 @@ When a world is unmaintained, the [core maintainers](https://github.com/orgs/Arc
|
||||
can vote for a new maintainer if there is a candidate.
|
||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||
The time limit is 1 week, but can end early if the majority is reached earlier.
|
||||
Voting shall be conducted on Discord in #ap-core-dev.
|
||||
Voting shall be conducted on Discord in #archipelago-dev.
|
||||
|
||||
## Dropping out
|
||||
|
||||
@@ -60,7 +51,7 @@ for example when they become unreachable.
|
||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
|
||||
made their case or was pinged and has been unreachable for more than 2 weeks already.
|
||||
Voting shall be conducted on Discord in #ap-core-dev. Commits that are a direct result of the voting shall include
|
||||
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
|
||||
date, voting members and final result in the commit message.
|
||||
|
||||
## Handling of Unmaintained Worlds
|
||||
|
||||
@@ -186,11 +186,6 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||
@@ -228,8 +223,8 @@ Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{a
|
||||
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0";
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
|
||||
|
||||
[Code]
|
||||
// See: https://stackoverflow.com/a/51614652/2287576
|
||||
|
||||
15
kvui.py
15
kvui.py
@@ -5,8 +5,6 @@ import typing
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
@@ -536,8 +534,9 @@ class GameManager(App):
|
||||
# show Archipelago tab if other logging is present
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
|
||||
self.log_panels["Hints"] = hint_panel.content
|
||||
hint_panel = TabbedPanelItem(text="Hints")
|
||||
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
|
||||
self.tabs.add_widget(hint_panel)
|
||||
|
||||
if len(self.logging_pairs) == 1:
|
||||
self.tabs.default_tab_text = "Archipelago"
|
||||
@@ -571,14 +570,6 @@ class GameManager(App):
|
||||
|
||||
return self.container
|
||||
|
||||
def add_client_tab(self, title: str, content: Widget) -> Widget:
|
||||
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
|
||||
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
||||
new_tab = TabbedPanelItem(text=title)
|
||||
new_tab.content = content
|
||||
self.tabs.add_widget(new_tab)
|
||||
return new_tab
|
||||
|
||||
def update_texts(self, dt):
|
||||
if hasattr(self.tabs.content.children[0], "fix_heights"):
|
||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
colorama>=0.4.6
|
||||
websockets>=13.0.1
|
||||
PyYAML>=6.0.2
|
||||
jellyfish>=1.1.0
|
||||
websockets>=12.0
|
||||
PyYAML>=6.0.1
|
||||
jellyfish>=1.0.3
|
||||
jinja2>=3.1.4
|
||||
schema>=0.7.7
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.8.30
|
||||
cython>=3.0.11
|
||||
certifi>=2024.6.2
|
||||
cython>=3.0.10
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.7
|
||||
typing_extensions>=4.12.2
|
||||
orjson>=3.10.3
|
||||
typing_extensions>=4.12.1
|
||||
|
||||
@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, prevent_sweep=True)
|
||||
state.sweep_for_advancements()
|
||||
state.collect(item, event=True)
|
||||
state.sweep_for_events()
|
||||
state.update_reachable_regions(1)
|
||||
self._state_cache[self.multiworld, tuple(items)] = state
|
||||
return state
|
||||
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.advancements:
|
||||
self.multiworld.state.advancements.remove(item.location)
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
@@ -293,11 +293,13 @@ class WorldTestBase(unittest.TestCase):
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
||||
state = self.multiworld.get_all_state(False)
|
||||
for location in self.multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
with self.subTest("Beatable"):
|
||||
self.multiworld.state = state
|
||||
self.assertBeatable(True)
|
||||
|
||||
@@ -192,7 +192,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
location_pool = player1.locations[1:] + player2.locations
|
||||
item_pool = player1.prog_items[:-1] + player2.prog_items
|
||||
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
|
||||
multiworld.state.sweep_for_advancements() # collect everything
|
||||
multiworld.state.sweep_for_events() # collect everything
|
||||
|
||||
# all of player2's locations and items should be accessible (not all of player1's)
|
||||
for item in player2.prog_items:
|
||||
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
item = player1.prog_items[0]
|
||||
item.code = None
|
||||
location.place_locked_item(item)
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_events()
|
||||
multiworld.state.sweep_for_events()
|
||||
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
|
||||
self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
|
||||
|
||||
|
||||
@@ -14,18 +14,6 @@ class TestBase(unittest.TestCase):
|
||||
"Desert Northern Cliffs", # on top of mountain, only reachable via OWG
|
||||
"Dark Death Mountain Bunny Descent Area" # OWG Mountain descent
|
||||
},
|
||||
# These Blasphemous regions are not reachable with default options
|
||||
"Blasphemous": {
|
||||
"D01Z04S13[SE]", # difficulty must be hard
|
||||
"D01Z05S25[E]", # difficulty must be hard
|
||||
"D02Z02S05[W]", # difficulty must be hard and purified_hand must be true
|
||||
"D04Z01S06[E]", # purified_hand must be true
|
||||
"D04Z02S02[NE]", # difficulty must be hard and purified_hand must be true
|
||||
"D05Z01S11[SW]", # difficulty must be hard
|
||||
"D06Z01S08[N]", # difficulty must be hard and purified_hand must be true
|
||||
"D20Z02S11[NW]", # difficulty must be hard
|
||||
"D20Z02S11[E]", # difficulty must be hard
|
||||
},
|
||||
"Ocarina of Time": {
|
||||
"Prelude of Light Warp", # Prelude is not progression by default
|
||||
"Serenade of Water Warp", # Serenade is not progression by default
|
||||
@@ -49,10 +37,12 @@ class TestBase(unittest.TestCase):
|
||||
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
excluded = multiworld.worlds[1].options.exclude_locations.value
|
||||
state = multiworld.get_all_state(False)
|
||||
for location in multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
|
||||
for region in multiworld.get_regions():
|
||||
if region.name in unreachable_regions:
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
all_worlds = list(AutoWorldRegister.world_types.values())
|
||||
self.multiworld = setup_multiworld(all_worlds, ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
world.options.accessibility.value = Accessibility.option_locations
|
||||
self.assertSteps(gen_steps)
|
||||
with self.subTest("filling multiworld", seed=self.multiworld.seed):
|
||||
distribute_items_restrictive(self.multiworld)
|
||||
@@ -66,8 +66,8 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
class TestTwoPlayerMulti(MultiworldTestBase):
|
||||
def test_two_player_single_game_fills(self) -> None:
|
||||
"""Tests that a multiworld of two players for each registered game world can generate."""
|
||||
for world_type in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world_type, world_type], ())
|
||||
for world in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world, world], ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
self.assertSteps(gen_steps)
|
||||
|
||||
@@ -130,9 +130,9 @@ class Base:
|
||||
|
||||
def test_get_remaining(self) -> None:
|
||||
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [13, 21])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [13, 21, 22])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99])
|
||||
|
||||
def test_location_set_intersection(self) -> None:
|
||||
locations = {10, 11, 12}
|
||||
|
||||
@@ -131,8 +131,7 @@ class TestHostFakeRoom(TestBase):
|
||||
f.write(text)
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("host_room", room=self.room_id),
|
||||
headers={"User-Agent": "Mozilla/5.0"})
|
||||
response = self.client.get(url_for("host_room", room=self.room_id))
|
||||
response_text = response.get_data(True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("href=\"/seed/", response_text)
|
||||
|
||||
@@ -292,14 +292,6 @@ class World(metaclass=AutoWorldRegister):
|
||||
web: ClassVar[WebWorld] = WebWorld()
|
||||
"""see WebWorld for options"""
|
||||
|
||||
origin_region_name: str = "Menu"
|
||||
"""Name of the Region from which accessibility is tested."""
|
||||
|
||||
explicit_indirect_conditions: bool = True
|
||||
"""If True, the world implementation is supposed to use MultiWorld.register_indirect_condition() correctly.
|
||||
If False, everything is rechecked at every step, which is slower computationally,
|
||||
but may be desirable in complex/dynamic worlds."""
|
||||
|
||||
multiworld: "MultiWorld"
|
||||
"""autoset on creation. The MultiWorld object for the currently generating multiworld."""
|
||||
player: int
|
||||
|
||||
@@ -26,13 +26,10 @@ class Component:
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
game_name: Optional[str]
|
||||
supports_uri: Optional[bool]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
|
||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
|
||||
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
|
||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
@@ -48,8 +45,6 @@ class Component:
|
||||
Type.ADJUSTER if "Adjuster" in display_name else Type.MISC)
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
self.game_name = game_name
|
||||
self.supports_uri = supports_uri
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
@@ -61,10 +56,10 @@ class Component:
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
|
||||
def launch_subprocess(func: Callable, name: str = None):
|
||||
global processes
|
||||
import multiprocessing
|
||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||
process = multiprocessing.Process(target=func, name=name)
|
||||
process.start()
|
||||
processes.add(process)
|
||||
|
||||
@@ -83,9 +78,9 @@ class SuffixIdentifier:
|
||||
return False
|
||||
|
||||
|
||||
def launch_textclient(*args):
|
||||
def launch_textclient():
|
||||
import CommonClient
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
@@ -137,8 +132,7 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
|
||||
break
|
||||
if found_already_loaded:
|
||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
||||
"so a Launcher restart is required to use the new installation.\n"
|
||||
"If the Launcher is not open, no action needs to be taken.")
|
||||
"so a Launcher restart is required to use the new installation.")
|
||||
world_source = worlds.WorldSource(str(target), is_zip=True)
|
||||
bisect.insort(worlds.world_sources, world_source)
|
||||
world_source.load()
|
||||
|
||||
@@ -73,12 +73,7 @@ class WorldSource:
|
||||
else: # TODO: remove with 3.8 support
|
||||
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
|
||||
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.__package__ = f"worlds.{mod.__package__}"
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def launch_client(*args) -> None:
|
||||
from .context import launch
|
||||
launch_subprocess(launch, name="BizHawkClient", args=args)
|
||||
launch_subprocess(launch, name="BizHawkClient")
|
||||
|
||||
|
||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||
|
||||
@@ -59,10 +59,14 @@ class BizHawkClientContext(CommonContext):
|
||||
self.bizhawk_ctx = BizHawkContext()
|
||||
self.watcher_timeout = 0.5
|
||||
|
||||
def make_gui(self):
|
||||
ui = super().make_gui()
|
||||
ui.base_title = "Archipelago BizHawk Client"
|
||||
return ui
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class BizHawkManager(GameManager):
|
||||
base_title = "Archipelago BizHawk Client"
|
||||
|
||||
self.ui = BizHawkManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == "Connected":
|
||||
@@ -239,11 +243,11 @@ async def _patch_and_run_game(patch_file: str):
|
||||
logger.exception(exc)
|
||||
|
||||
|
||||
def launch(*launch_args) -> None:
|
||||
def launch() -> None:
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
||||
args = parser.parse_args(launch_args)
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = BizHawkClientContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
@@ -4,7 +4,7 @@ import websockets
|
||||
import functools
|
||||
from copy import deepcopy
|
||||
from typing import List, Any, Iterable
|
||||
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem, NetworkPlayer
|
||||
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem
|
||||
from MultiServer import Endpoint
|
||||
from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||
|
||||
@@ -101,35 +101,12 @@ class AHITContext(CommonContext):
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
json = args
|
||||
# This data is not needed and causes the game to freeze for long periods of time in large asyncs.
|
||||
if "slot_info" in json.keys():
|
||||
json["slot_info"] = {}
|
||||
if "players" in json.keys():
|
||||
me: NetworkPlayer
|
||||
for n in json["players"]:
|
||||
if n.slot == json["slot"] and n.team == json["team"]:
|
||||
me = n
|
||||
break
|
||||
|
||||
# Only put our player info in there as we actually need it
|
||||
json["players"] = [me]
|
||||
if DEBUG:
|
||||
print(json)
|
||||
self.connected_msg = encode([json])
|
||||
self.connected_msg = encode([args])
|
||||
if self.awaiting_info:
|
||||
self.server_msgs.append(self.room_info)
|
||||
self.update_items()
|
||||
self.awaiting_info = False
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
# Same story as above
|
||||
json = args
|
||||
if "players" in json.keys():
|
||||
json["players"] = []
|
||||
|
||||
self.server_msgs.append(encode(json))
|
||||
|
||||
elif cmd == "ReceivedItems":
|
||||
if args["index"] == 0:
|
||||
self.full_inventory.clear()
|
||||
@@ -189,17 +166,6 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
||||
await ctx.disconnect_proxy()
|
||||
break
|
||||
|
||||
if ctx.auth:
|
||||
name = msg.get("name", "")
|
||||
if name != "" and name != ctx.auth:
|
||||
logger.info("Aborting proxy connection: player name mismatch from save file")
|
||||
logger.info(f"Expected: {ctx.auth}, got: {name}")
|
||||
text = encode([{"cmd": "PrintJSON",
|
||||
"data": [{"text": "Connection aborted - player name mismatch"}]}])
|
||||
await ctx.send_msgs_proxy(text)
|
||||
await ctx.disconnect_proxy()
|
||||
break
|
||||
|
||||
if ctx.connected_msg and ctx.is_connected():
|
||||
await ctx.send_msgs_proxy(ctx.connected_msg)
|
||||
ctx.update_items()
|
||||
|
||||
@@ -152,10 +152,10 @@ def create_dw_regions(world: "HatInTimeWorld"):
|
||||
for name in annoying_dws:
|
||||
world.excluded_dws.append(name)
|
||||
|
||||
if not world.options.DWEnableBonus and world.options.DWAutoCompleteBonuses:
|
||||
if not world.options.DWEnableBonus or world.options.DWAutoCompleteBonuses:
|
||||
for name in death_wishes:
|
||||
world.excluded_bonuses.append(name)
|
||||
if world.options.DWExcludeAnnoyingBonuses and not world.options.DWAutoCompleteBonuses:
|
||||
elif world.options.DWExcludeAnnoyingBonuses:
|
||||
for name in annoying_bonuses:
|
||||
world.excluded_bonuses.append(name)
|
||||
|
||||
|
||||
@@ -968,35 +968,40 @@ def get_act_by_number(world: "HatInTimeWorld", chapter_name: str, num: int) -> R
|
||||
def create_thug_shops(world: "HatInTimeWorld"):
|
||||
min_items: int = world.options.NyakuzaThugMinShopItems.value
|
||||
max_items: int = world.options.NyakuzaThugMaxShopItems.value
|
||||
|
||||
thug_location_counts: Dict[str, int] = {}
|
||||
count = -1
|
||||
step = 0
|
||||
old_name = ""
|
||||
|
||||
for key, data in shop_locations.items():
|
||||
thug_name = data.nyakuza_thug
|
||||
if thug_name == "":
|
||||
# Different shop type.
|
||||
if data.nyakuza_thug == "":
|
||||
continue
|
||||
|
||||
if thug_name not in world.nyakuza_thug_items:
|
||||
shop_item_count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items[thug_name] = shop_item_count
|
||||
else:
|
||||
shop_item_count = world.nyakuza_thug_items[thug_name]
|
||||
|
||||
if shop_item_count <= 0:
|
||||
if old_name != "" and old_name == data.nyakuza_thug:
|
||||
continue
|
||||
|
||||
location_count = thug_location_counts.setdefault(thug_name, 0)
|
||||
if location_count >= shop_item_count:
|
||||
# Already created all the locations for this thug.
|
||||
continue
|
||||
try:
|
||||
if world.nyakuza_thug_items[data.nyakuza_thug] <= 0:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Create the shop location.
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
thug_location_counts[thug_name] = location_count + 1
|
||||
if count == -1:
|
||||
count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items.setdefault(data.nyakuza_thug, count)
|
||||
if count <= 0:
|
||||
continue
|
||||
|
||||
if count >= 1:
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
|
||||
step += 1
|
||||
if step >= count:
|
||||
old_name = data.nyakuza_thug
|
||||
step = 0
|
||||
count = -1
|
||||
|
||||
|
||||
def create_events(world: "HatInTimeWorld") -> int:
|
||||
|
||||
@@ -381,8 +381,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
lambda state: can_use_hat(state, world, HatType.ICE), "or")
|
||||
|
||||
# Moderate: Clock Tower Chest + Ruined Tower with nothing
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
|
||||
# Moderate: enter and clear The Subcon Well without Hookshot and without hitting the bell
|
||||
for loc in world.multiworld.get_region("The Subcon Well", world.player).locations:
|
||||
@@ -432,8 +432,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
|
||||
if world.is_dlc1():
|
||||
# Moderate: clear Rock the Boat without Ice Hat
|
||||
set_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
|
||||
# Moderate: clear Deep Sea without Ice Hat
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Deep Sea)", world.player),
|
||||
@@ -855,9 +855,6 @@ def set_rift_rules(world: "HatInTimeWorld", regions: Dict[str, Region]):
|
||||
|
||||
for entrance in regions["Time Rift - Alpine Skyline"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
if entrance.parent_region.name == "Alpine Free Roam":
|
||||
add_rule(entrance,
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world, umbrella_only=True))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in regions["Time Rift - Balcony"].entrances:
|
||||
@@ -936,9 +933,6 @@ def set_default_rift_rules(world: "HatInTimeWorld"):
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Alpine Skyline", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
if entrance.parent_region.name == "Alpine Free Roam":
|
||||
add_rule(entrance,
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world, umbrella_only=True))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances:
|
||||
|
||||
@@ -253,8 +253,7 @@ class HatInTimeWorld(World):
|
||||
else:
|
||||
item_name = loc.item.name
|
||||
|
||||
shop_item_names.setdefault(str(loc.address),
|
||||
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
|
||||
shop_item_names.setdefault(str(loc.address), item_name)
|
||||
|
||||
slot_data["ShopItemNames"] = shop_item_names
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
pass
|
||||
for item in pre_fill_items:
|
||||
multiworld.worlds[item.player].collect(all_state_base, item)
|
||||
all_state_base.sweep_for_advancements()
|
||||
all_state_base.sweep_for_events()
|
||||
|
||||
# Remove completion condition so that minimal-accessibility worlds place keys properly
|
||||
for player in {item.player for item in in_dungeon_items}:
|
||||
@@ -262,8 +262,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
|
||||
loc = multiworld.get_location(key_loc, player)
|
||||
|
||||
if loc in all_state_base.advancements:
|
||||
all_state_base.advancements.remove(loc)
|
||||
if loc in all_state_base.events:
|
||||
all_state_base.events.remove(loc)
|
||||
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, lock=True, allow_excluded=True,
|
||||
name="LttP Dungeon Items")
|
||||
|
||||
|
||||
@@ -682,7 +682,7 @@ def get_pool_core(world, player: int):
|
||||
if 'triforce_hunt' in goal:
|
||||
|
||||
if world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_extra:
|
||||
treasure_hunt_total = (world.triforce_pieces_required[player].value
|
||||
treasure_hunt_total = (world.triforce_pieces_available[player].value
|
||||
+ world.triforce_pieces_extra[player].value)
|
||||
elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage:
|
||||
percentage = float(world.triforce_pieces_percentage[player].value) / 100
|
||||
|
||||
@@ -728,7 +728,7 @@ class ALttPPlandoConnections(PlandoConnections):
|
||||
entrances = set([connection[0] for connection in (
|
||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
||||
*inverted_default_dungeon_connections)])
|
||||
exits = set([connection[0] for connection in (
|
||||
exits = set([connection[1] for connection in (
|
||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
||||
*inverted_default_dungeon_connections)])
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player))
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Blind\'s Cell', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
|
||||
if multiworld.accessibility[player] != 'full' and not multiworld.key_drop_shuffle[player]:
|
||||
if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]:
|
||||
set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player)
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3))
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player),
|
||||
@@ -547,7 +547,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 6)))
|
||||
|
||||
# this seemed to be causing generation failure, disable for now
|
||||
# if world.accessibility[player] != 'full':
|
||||
# if world.accessibility[player] != 'locations':
|
||||
# set_always_allow(world.get_location('Ganons Tower - Map Chest', player), lambda state, item: item.name == 'Small Key (Ganons Tower)' and item.player == player and state._lttp_has_key('Small Key (Ganons Tower)', player, 7) and state.can_reach('Ganons Tower (Hookshot Room)', 'region', player))
|
||||
|
||||
# It is possible to need more than 6 keys to get through this entrance if you spend keys elsewhere. We reflect this in the chest requirements.
|
||||
|
||||
@@ -356,8 +356,6 @@ class ALTTPWorld(World):
|
||||
self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group]
|
||||
if option == "original_dungeon":
|
||||
self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group]
|
||||
else:
|
||||
self.options.local_items.value |= self.dungeon_local_item_names
|
||||
|
||||
self.difficulty_requirements = difficulties[multiworld.item_pool[player].current_key]
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Configuration
|
||||
|
||||
1. All plando options are enabled by default, except for "items plando" which has to be enabled before it can be used (opt-in).
|
||||
2. To enable it, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
1. Plando features have to be enabled first, before they can be used (opt-in).
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
file with a text editor.
|
||||
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the value
|
||||
to `bosses, items, texts, connections`
|
||||
@@ -66,7 +66,6 @@ boss_shuffle:
|
||||
- ignored if only one world is generated
|
||||
- can be a number, to target that slot in the multiworld
|
||||
- can be a name, to target that player's world
|
||||
- can be a list of names, to target those players' worlds
|
||||
- can be true, to target any other player's world
|
||||
- can be false, to target own world and is the default
|
||||
- can be null, to target a random world
|
||||
@@ -133,15 +132,17 @@ plando_items:
|
||||
|
||||
### Texts
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `text`, `at`, and `percentage`
|
||||
- All of these options support subweights
|
||||
- percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
|
||||
- text is the text to be placed.
|
||||
- can be weighted.
|
||||
- `\n` is a newline.
|
||||
- `@` is the entered player's name.
|
||||
- Warning: Text Mapper does not support full unicode.
|
||||
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758)
|
||||
- at is the location within the game to attach the text to.
|
||||
- can be weighted.
|
||||
- [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
|
||||
|
||||
#### Example
|
||||
@@ -161,6 +162,7 @@ and `uncle_dying_sewer`, then places the text "This is a plando. You've been war
|
||||
|
||||
### Connections
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `percentage`, `entrance`, `exit` and `direction`.
|
||||
- All options support subweights
|
||||
- percentage is the percentage chance for this to be connected, can be omitted entirely for 100%
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestDungeon(LTTPTestBase):
|
||||
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, prevent_sweep=True) # prevent_sweep=True prevents running sweep_for_advancements() and picking up
|
||||
state.sweep_for_advancements() # key drop keys repeatedly
|
||||
state.collect(item, event=True) # event=True prevents running sweep_for_events() and picking up
|
||||
state.sweep_for_events() # key drop keys repeatedly
|
||||
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||
@@ -1,60 +0,0 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from test.general import gen_steps, setup_multiworld
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import locality_rules
|
||||
from ... import ALTTPWorld
|
||||
from ...Options import DungeonItem
|
||||
|
||||
|
||||
class DungeonFillTestBase(TestCase):
|
||||
multiworld: MultiWorld
|
||||
world_1: ALTTPWorld
|
||||
world_2: ALTTPWorld
|
||||
options = (
|
||||
"big_key_shuffle",
|
||||
"small_key_shuffle",
|
||||
"key_drop_shuffle",
|
||||
"compass_shuffle",
|
||||
"map_shuffle",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.multiworld = setup_multiworld([ALTTPWorld, ALTTPWorld], ())
|
||||
self.world_1 = self.multiworld.worlds[1]
|
||||
self.world_2 = self.multiworld.worlds[2]
|
||||
|
||||
def generate_with_options(self, option_value: int):
|
||||
for option in self.options:
|
||||
getattr(self.world_1.options, option).value = getattr(self.world_2.options, option).value = option_value
|
||||
|
||||
for step in gen_steps:
|
||||
call_all(self.multiworld, step)
|
||||
# this is where locality rules are set in normal generation which we need to verify this test
|
||||
if step == "set_rules":
|
||||
locality_rules(self.multiworld)
|
||||
|
||||
def test_original_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_original_dungeon)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
with (self.subTest(location=location)):
|
||||
if location.parent_region.dungeon is None:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
self.assertEqual(location.player, location.item.player,
|
||||
f"{location.item} does not belong to {location}'s player")
|
||||
if location.item.dungeon is None:
|
||||
continue
|
||||
self.assertIs(location.item.dungeon, location.parent_region.dungeon,
|
||||
f"{location.item} was not placed in its original dungeon.")
|
||||
|
||||
def test_own_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_own_dungeons)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
with self.subTest(location=location):
|
||||
if location.parent_region.dungeon is None:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
self.assertEqual(location.player, location.item.player,
|
||||
f"{location.item} does not belong to {location}'s player")
|
||||
@@ -4,7 +4,7 @@ from BaseClasses import Tutorial
|
||||
from ..AutoWorld import WebWorld, World
|
||||
|
||||
class AP_SudokuWebWorld(WebWorld):
|
||||
options_page = False
|
||||
options_page = "games/Sudoku/info/en"
|
||||
theme = 'partyTime'
|
||||
|
||||
setup_en = Tutorial(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# APSudoku Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [APSudoku](https://github.com/APSudoku/APSudoku)
|
||||
- [APSudoku](https://github.com/EmilyV99/APSudoku)
|
||||
- Windows (most tested on Win10)
|
||||
- Other platforms might be able to build from source themselves; and may be included in the future.
|
||||
|
||||
## General Concept
|
||||
|
||||
@@ -11,33 +13,25 @@ Does not need to be added at the start of a seed, as it does not create any slot
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Go to the latest release from the [APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
|
||||
Go to the latest release from the [APSudoku Releases page](https://github.com/EmilyV99/APSudoku/releases). Download and extract the `APSudoku.zip` file.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run the APSudoku executable.
|
||||
2. Under `Settings` → `Connection` at the top-right:
|
||||
- Enter the server address and port number
|
||||
1. Run APSudoku.exe
|
||||
2. Under the 'Archipelago' tab at the top-right:
|
||||
- Enter the server url & port number
|
||||
- Enter the name of the slot you wish to connect to
|
||||
- Enter the room password (optional)
|
||||
- Select DeathLink related settings (optional)
|
||||
- Press `Connect`
|
||||
4. Under the `Sudoku` tab
|
||||
- Choose puzzle difficulty
|
||||
- Click `Start` to generate a puzzle
|
||||
5. Try to solve the Sudoku. Click `Check` when done
|
||||
- A correct solution rewards you with 1 hint for a location in the world you are connected to
|
||||
- An incorrect solution has no penalty, unless DeathLink is enabled (see below)
|
||||
- Press connect
|
||||
3. Go back to the 'Sudoku' tab
|
||||
- Click the various '?' buttons for information on how to play / control
|
||||
4. Choose puzzle difficulty
|
||||
5. Try to solve the Sudoku. Click 'Check' when done.
|
||||
|
||||
Info:
|
||||
- You can set various settings under `Settings` → `Sudoku`, and can change the colors used under `Settings` → `Theme`.
|
||||
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features
|
||||
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md)
|
||||
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted)
|
||||
- Click the various `?` buttons for information on controls/how to play
|
||||
## DeathLink Support
|
||||
|
||||
If `DeathLink` is enabled when you click `Connect`:
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
|
||||
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
If 'DeathLink' is enabled when you click 'Connect':
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or quit a puzzle without solving it (including disconnecting).
|
||||
- Life count customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
- On receiving a DeathLink from another player, your puzzle resets.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"type": "WorldDefinition",
|
||||
"configuration": "./output/StringWorldDefinition.json",
|
||||
"emptyRegionsToKeep": [
|
||||
"D17Z01S01",
|
||||
"D01Z02S01",
|
||||
"D02Z03S09",
|
||||
"D03Z03S11",
|
||||
"D04Z03S01",
|
||||
"D06Z01S09",
|
||||
"D20Z02S09",
|
||||
"D09Z01S09[Cell24]",
|
||||
"D09Z01S08[Cell7]",
|
||||
"D09Z01S08[Cell18]",
|
||||
"D09BZ01S01[Cell24]",
|
||||
"D09BZ01S01[Cell17]",
|
||||
"D09BZ01S01[Cell19]"
|
||||
]
|
||||
}
|
||||
@@ -637,35 +637,52 @@ item_table: List[ItemDict] = [
|
||||
'classification': ItemClassification.filler}
|
||||
]
|
||||
|
||||
event_table: Dict[str, str] = {
|
||||
"OpenedDCGateW": "D01Z05S24",
|
||||
"OpenedDCGateE": "D01Z05S12",
|
||||
"OpenedDCLadder": "D01Z05S20",
|
||||
"OpenedWOTWCave": "D02Z01S06",
|
||||
"RodeGOTPElevator": "D02Z02S11",
|
||||
"OpenedConventLadder": "D02Z03S11",
|
||||
"BrokeJondoBellW": "D03Z02S09",
|
||||
"BrokeJondoBellE": "D03Z02S05",
|
||||
"OpenedMOMLadder": "D04Z02S06",
|
||||
"OpenedTSCGate": "D05Z02S11",
|
||||
"OpenedARLadder": "D06Z01S23",
|
||||
"BrokeBOTTCStatue": "D08Z01S02",
|
||||
"OpenedWOTHPGate": "D09Z01S05",
|
||||
"OpenedBOTSSLadder": "D17Z01S04"
|
||||
}
|
||||
|
||||
group_table: Dict[str, Set[str]] = {
|
||||
"wounds" : {"Holy Wound of Attrition",
|
||||
"wounds" : ["Holy Wound of Attrition",
|
||||
"Holy Wound of Contrition",
|
||||
"Holy Wound of Compunction"},
|
||||
"Holy Wound of Compunction"],
|
||||
|
||||
"masks" : {"Deformed Mask of Orestes",
|
||||
"masks" : ["Deformed Mask of Orestes",
|
||||
"Mirrored Mask of Dolphos",
|
||||
"Embossed Mask of Crescente"},
|
||||
"Embossed Mask of Crescente"],
|
||||
|
||||
"marks" : {"Mark of the First Refuge",
|
||||
"marks" : ["Mark of the First Refuge",
|
||||
"Mark of the Second Refuge",
|
||||
"Mark of the Third Refuge"},
|
||||
"Mark of the Third Refuge"],
|
||||
|
||||
"tirso" : {"Bouquet of Rosemary",
|
||||
"tirso" : ["Bouquet of Rosemary",
|
||||
"Incense Garlic",
|
||||
"Olive Seeds",
|
||||
"Dried Clove",
|
||||
"Sooty Garlic",
|
||||
"Bouquet of Thyme"},
|
||||
"Bouquet of Thyme"],
|
||||
|
||||
"tentudia": {"Tentudia's Carnal Remains",
|
||||
"tentudia": ["Tentudia's Carnal Remains",
|
||||
"Remains of Tentudia's Hair",
|
||||
"Tentudia's Skeletal Remains"},
|
||||
"Tentudia's Skeletal Remains"],
|
||||
|
||||
"egg" : {"Melted Golden Coins",
|
||||
"egg" : ["Melted Golden Coins",
|
||||
"Torn Bridal Ribbon",
|
||||
"Black Grieving Veil"},
|
||||
"Black Grieving Veil"],
|
||||
|
||||
"bones" : {"Parietal bone of Lasser, the Inquisitor",
|
||||
"bones" : ["Parietal bone of Lasser, the Inquisitor",
|
||||
"Jaw of Ashgan, the Inquisitor",
|
||||
"Cervical vertebra of Zicher, the Brewmaster",
|
||||
"Clavicle of Dalhuisen, the Schoolchild",
|
||||
@@ -708,14 +725,14 @@ group_table: Dict[str, Set[str]] = {
|
||||
"Scaphoid of Fierce, the Leper",
|
||||
"Anklebone of Weston, the Pilgrim",
|
||||
"Calcaneum of Persian, the Bandit",
|
||||
"Navicular of Kahnnyhoo, the Murderer"},
|
||||
"Navicular of Kahnnyhoo, the Murderer"],
|
||||
|
||||
"power" : {"Life Upgrade",
|
||||
"power" : ["Life Upgrade",
|
||||
"Fervour Upgrade",
|
||||
"Empty Bile Vessel",
|
||||
"Quicksilver"},
|
||||
"Quicksilver"],
|
||||
|
||||
"prayer" : {"Seguiriya to your Eyes like Stars",
|
||||
"prayer" : ["Seguiriya to your Eyes like Stars",
|
||||
"Debla of the Lights",
|
||||
"Saeta Dolorosa",
|
||||
"Campanillero to the Sons of the Aurora",
|
||||
@@ -729,17 +746,10 @@ group_table: Dict[str, Set[str]] = {
|
||||
"Romance to the Crimson Mist",
|
||||
"Zambra to the Resplendent Crown",
|
||||
"Cantina of the Blue Rose",
|
||||
"Mirabras of the Return to Port"},
|
||||
|
||||
"toe" : {"Little Toe made of Limestone",
|
||||
"Big Toe made of Limestone",
|
||||
"Fourth Toe made of Limestone"},
|
||||
|
||||
"eye" : {"Severed Right Eye of the Traitor",
|
||||
"Broken Left Eye of the Traitor"}
|
||||
"Mirabras of the Return to Port"]
|
||||
}
|
||||
|
||||
tears_list: List[str] = [
|
||||
tears_set: Set[str] = [
|
||||
"Tears of Atonement (500)",
|
||||
"Tears of Atonement (625)",
|
||||
"Tears of Atonement (750)",
|
||||
@@ -762,16 +772,16 @@ tears_list: List[str] = [
|
||||
"Tears of Atonement (30000)"
|
||||
]
|
||||
|
||||
reliquary_set: Set[str] = {
|
||||
reliquary_set: Set[str] = [
|
||||
"Reliquary of the Fervent Heart",
|
||||
"Reliquary of the Suffering Heart",
|
||||
"Reliquary of the Sorrowful Heart"
|
||||
}
|
||||
]
|
||||
|
||||
skill_set: Set[str] = {
|
||||
skill_set: Set[str] = [
|
||||
"Combo Skill",
|
||||
"Charged Skill",
|
||||
"Ranged Skill",
|
||||
"Dive Skill",
|
||||
"Lunge Skill"
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, PerGameCommonOptions, OptionGroup
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool
|
||||
import random
|
||||
|
||||
|
||||
@@ -21,30 +20,23 @@ class ChoiceIsRandom(Choice):
|
||||
|
||||
|
||||
class PrieDieuWarp(DefaultOnToggle):
|
||||
"""
|
||||
Automatically unlocks the ability to warp between Prie Dieu shrines.
|
||||
"""
|
||||
"""Automatically unlocks the ability to warp between Prie Dieu shrines."""
|
||||
display_name = "Unlock Fast Travel"
|
||||
|
||||
|
||||
class SkipCutscenes(DefaultOnToggle):
|
||||
"""
|
||||
Automatically skips most cutscenes.
|
||||
"""
|
||||
"""Automatically skips most cutscenes."""
|
||||
display_name = "Auto Skip Cutscenes"
|
||||
|
||||
|
||||
class CorpseHints(DefaultOnToggle):
|
||||
"""
|
||||
Changes the 34 corpses in game to give various hints about item locations.
|
||||
"""
|
||||
"""Changes the 34 corpses in game to give various hints about item locations."""
|
||||
display_name = "Corpse Hints"
|
||||
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""
|
||||
Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses and advanced movement tricks or glitches.
|
||||
"""
|
||||
"""Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses
|
||||
and advanced movement tricks or glitches."""
|
||||
display_name = "Difficulty"
|
||||
option_easy = 0
|
||||
option_normal = 1
|
||||
@@ -53,18 +45,15 @@ class Difficulty(Choice):
|
||||
|
||||
|
||||
class Penitence(Toggle):
|
||||
"""
|
||||
Allows one of the three Penitences to be chosen at the beginning of the game.
|
||||
"""
|
||||
"""Allows one of the three Penitences to be chosen at the beginning of the game."""
|
||||
display_name = "Penitence"
|
||||
|
||||
|
||||
class StartingLocation(ChoiceIsRandom):
|
||||
"""
|
||||
Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain other options.
|
||||
|
||||
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends cannot be chosen if Shuffle Wall Climb is enabled.
|
||||
"""
|
||||
"""Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain
|
||||
other options.
|
||||
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends
|
||||
cannot be chosen if Shuffle Wall Climb is enabled."""
|
||||
display_name = "Starting Location"
|
||||
option_brotherhood = 0
|
||||
option_albero = 1
|
||||
@@ -77,15 +66,10 @@ class StartingLocation(ChoiceIsRandom):
|
||||
|
||||
|
||||
class Ending(Choice):
|
||||
"""
|
||||
Choose which ending is required to complete the game.
|
||||
|
||||
"""Choose which ending is required to complete the game.
|
||||
Talking to Tirso in Albero will tell you the selected ending for the current game.
|
||||
|
||||
Ending A: Collect all thorn upgrades.
|
||||
|
||||
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation.
|
||||
"""
|
||||
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation."""
|
||||
display_name = "Ending"
|
||||
option_any_ending = 0
|
||||
option_ending_a = 1
|
||||
@@ -94,18 +78,14 @@ class Ending(Choice):
|
||||
|
||||
|
||||
class SkipLongQuests(Toggle):
|
||||
"""
|
||||
Ensures that the rewards for long quests will be filler items.
|
||||
|
||||
Affected locations: "Albero: Donate 50000 Tears", "Ossuary: 11th reward", "AtTotS: Miriam's gift", "TSC: Jocinero's final reward"
|
||||
"""
|
||||
"""Ensures that the rewards for long quests will be filler items.
|
||||
Affected locations: \"Albero: Donate 50000 Tears\", \"Ossuary: 11th reward\", \"AtTotS: Miriam's gift\",
|
||||
\"TSC: Jocinero's final reward\""""
|
||||
display_name = "Skip Long Quests"
|
||||
|
||||
|
||||
class ThornShuffle(Choice):
|
||||
"""
|
||||
Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool.
|
||||
"""
|
||||
"""Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool."""
|
||||
display_name = "Shuffle Thorn"
|
||||
option_anywhere = 0
|
||||
option_local_only = 1
|
||||
@@ -114,68 +94,50 @@ class ThornShuffle(Choice):
|
||||
|
||||
|
||||
class DashShuffle(Toggle):
|
||||
"""
|
||||
Turns the ability to dash into an item that must be found in the multiworld.
|
||||
"""
|
||||
"""Turns the ability to dash into an item that must be found in the multiworld."""
|
||||
display_name = "Shuffle Dash"
|
||||
|
||||
|
||||
class WallClimbShuffle(Toggle):
|
||||
"""
|
||||
Turns the ability to climb walls with your sword into an item that must be found in the multiworld.
|
||||
"""
|
||||
"""Turns the ability to climb walls with your sword into an item that must be found in the multiworld."""
|
||||
display_name = "Shuffle Wall Climb"
|
||||
|
||||
|
||||
class ReliquaryShuffle(DefaultOnToggle):
|
||||
"""
|
||||
Adds the True Torment exclusive Reliquary rosary beads into the item pool.
|
||||
"""
|
||||
"""Adds the True Torment exclusive Reliquary rosary beads into the item pool."""
|
||||
display_name = "Shuffle Penitence Rewards"
|
||||
|
||||
|
||||
class CustomItem1(Toggle):
|
||||
"""
|
||||
Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes and survive.
|
||||
|
||||
Must have the "Boots of Pleading" mod installed to connect to a multiworld.
|
||||
"""
|
||||
"""Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes
|
||||
and survive.
|
||||
Must have the \"Blasphemous-Boots-of-Pleading\" mod installed to connect to a multiworld."""
|
||||
display_name = "Boots of Pleading"
|
||||
|
||||
|
||||
class CustomItem2(Toggle):
|
||||
"""
|
||||
Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump a second time in mid-air.
|
||||
|
||||
Must have the "Double Jump" mod installed to connect to a multiworld.
|
||||
"""
|
||||
"""Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump
|
||||
a second time in mid-air.
|
||||
Must have the \"Blasphemous-Double-Jump\" mod installed to connect to a multiworld."""
|
||||
display_name = "Purified Hand of the Nun"
|
||||
|
||||
|
||||
class StartWheel(Toggle):
|
||||
"""
|
||||
Changes the beginning gift to The Young Mason's Wheel.
|
||||
"""
|
||||
"""Changes the beginning gift to The Young Mason's Wheel."""
|
||||
display_name = "Start with Wheel"
|
||||
|
||||
|
||||
class SkillRando(Toggle):
|
||||
"""
|
||||
Randomizes the abilities from the skill tree into the item pool.
|
||||
"""
|
||||
"""Randomizes the abilities from the skill tree into the item pool."""
|
||||
display_name = "Skill Randomizer"
|
||||
|
||||
|
||||
class EnemyRando(Choice):
|
||||
"""
|
||||
Randomizes the enemies that appear in each room.
|
||||
|
||||
Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in a standard game.
|
||||
|
||||
"""Randomizes the enemies that appear in each room.
|
||||
Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in
|
||||
a standard game.
|
||||
Randomized: Every enemy is completely random, and can appear any number of times.
|
||||
|
||||
Some enemies will never be randomized.
|
||||
"""
|
||||
Some enemies will never be randomized."""
|
||||
display_name = "Enemy Randomizer"
|
||||
option_disabled = 0
|
||||
option_shuffled = 1
|
||||
@@ -184,75 +146,43 @@ class EnemyRando(Choice):
|
||||
|
||||
|
||||
class EnemyGroups(DefaultOnToggle):
|
||||
"""
|
||||
Randomized enemies will be chosen from sets of specific groups.
|
||||
|
||||
"""Randomized enemies will chosen from sets of specific groups.
|
||||
(Weak, normal, large, flying)
|
||||
|
||||
Has no effect if Enemy Randomizer is disabled.
|
||||
"""
|
||||
Has no effect if Enemy Randomizer is disabled."""
|
||||
display_name = "Enemy Groups"
|
||||
|
||||
|
||||
class EnemyScaling(DefaultOnToggle):
|
||||
"""
|
||||
Randomized enemies will have their stats increased or decreased depending on the area they appear in.
|
||||
|
||||
Has no effect if Enemy Randomizer is disabled.
|
||||
"""
|
||||
"""Randomized enemies will have their stats increased or decreased depending on the area they appear in.
|
||||
Has no effect if Enemy Randomizer is disabled."""
|
||||
display_name = "Enemy Scaling"
|
||||
|
||||
|
||||
class BlasphemousDeathLink(DeathLink):
|
||||
"""
|
||||
When you die, everyone dies. The reverse is also true.
|
||||
|
||||
Note that Guilt Fragments will not appear when killed by Death Link.
|
||||
"""
|
||||
"""When you die, everyone dies. The reverse is also true.
|
||||
Note that Guilt Fragments will not appear when killed by Death Link."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlasphemousOptions(PerGameCommonOptions):
|
||||
prie_dieu_warp: PrieDieuWarp
|
||||
skip_cutscenes: SkipCutscenes
|
||||
corpse_hints: CorpseHints
|
||||
difficulty: Difficulty
|
||||
penitence: Penitence
|
||||
starting_location: StartingLocation
|
||||
ending: Ending
|
||||
skip_long_quests: SkipLongQuests
|
||||
thorn_shuffle: ThornShuffle
|
||||
dash_shuffle: DashShuffle
|
||||
wall_climb_shuffle: WallClimbShuffle
|
||||
reliquary_shuffle: ReliquaryShuffle
|
||||
boots_of_pleading: CustomItem1
|
||||
purified_hand: CustomItem2
|
||||
start_wheel: StartWheel
|
||||
skill_randomizer: SkillRando
|
||||
enemy_randomizer: EnemyRando
|
||||
enemy_groups: EnemyGroups
|
||||
enemy_scaling: EnemyScaling
|
||||
death_link: BlasphemousDeathLink
|
||||
|
||||
|
||||
blas_option_groups = [
|
||||
OptionGroup("Quality of Life", [
|
||||
PrieDieuWarp,
|
||||
SkipCutscenes,
|
||||
CorpseHints,
|
||||
SkipLongQuests,
|
||||
StartWheel
|
||||
]),
|
||||
OptionGroup("Moveset", [
|
||||
DashShuffle,
|
||||
WallClimbShuffle,
|
||||
SkillRando,
|
||||
CustomItem1,
|
||||
CustomItem2
|
||||
]),
|
||||
OptionGroup("Enemy Randomizer", [
|
||||
EnemyRando,
|
||||
EnemyGroups,
|
||||
EnemyScaling
|
||||
])
|
||||
]
|
||||
blasphemous_options = {
|
||||
"prie_dieu_warp": PrieDieuWarp,
|
||||
"skip_cutscenes": SkipCutscenes,
|
||||
"corpse_hints": CorpseHints,
|
||||
"difficulty": Difficulty,
|
||||
"penitence": Penitence,
|
||||
"starting_location": StartingLocation,
|
||||
"ending": Ending,
|
||||
"skip_long_quests": SkipLongQuests,
|
||||
"thorn_shuffle" : ThornShuffle,
|
||||
"dash_shuffle": DashShuffle,
|
||||
"wall_climb_shuffle": WallClimbShuffle,
|
||||
"reliquary_shuffle": ReliquaryShuffle,
|
||||
"boots_of_pleading": CustomItem1,
|
||||
"purified_hand": CustomItem2,
|
||||
"start_wheel": StartWheel,
|
||||
"skill_randomizer": SkillRando,
|
||||
"enemy_randomizer": EnemyRando,
|
||||
"enemy_groups": EnemyGroups,
|
||||
"enemy_scaling": EnemyScaling,
|
||||
"death_link": BlasphemousDeathLink,
|
||||
"start_inventory": StartInventoryPool
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
# Preprocessor to convert Blasphemous Randomizer logic into a StringWorldDefinition for use with APHKLogicExtractor
|
||||
# https://github.com/BrandenEK/Blasphemous.Randomizer
|
||||
# https://github.com/ArchipelagoMW-HollowKnight/APHKLogicExtractor
|
||||
|
||||
|
||||
import json, requests, argparse
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def load_resource_local(file: str) -> List[Dict[str, Any]]:
|
||||
print(f"Reading from {file}")
|
||||
loaded = []
|
||||
with open(file, encoding="utf-8") as f:
|
||||
loaded = read_json(f.readlines())
|
||||
f.close()
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load_resource_from_web(url: str) -> List[Dict[str, Any]]:
|
||||
req = requests.get(url, timeout=1)
|
||||
print(f"Reading from {url}")
|
||||
req.encoding = "utf-8"
|
||||
lines: List[str] = []
|
||||
for line in req.text.splitlines():
|
||||
while "\t" in line:
|
||||
line = line[1::]
|
||||
if line != "":
|
||||
lines.append(line)
|
||||
return read_json(lines)
|
||||
|
||||
|
||||
def read_json(lines: List[str]) -> List[Dict[str, Any]]:
|
||||
loaded = []
|
||||
creating_object: bool = False
|
||||
obj: str = ""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if "{" in stripped:
|
||||
creating_object = True
|
||||
obj += stripped
|
||||
continue
|
||||
elif "}," in stripped or "}" in stripped and "]" in lines[lines.index(line)+1]:
|
||||
creating_object = False
|
||||
obj += "}"
|
||||
#print(f"obj = {obj}")
|
||||
loaded.append(json.loads(obj))
|
||||
obj = ""
|
||||
continue
|
||||
|
||||
if not creating_object:
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
if "}," in lines[lines.index(line)+1] and stripped[-1] == ",":
|
||||
obj += stripped[:-1]
|
||||
else:
|
||||
obj += stripped
|
||||
except IndexError:
|
||||
obj += stripped
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def get_room_from_door(door: str) -> str:
|
||||
return door[:door.find("[")]
|
||||
|
||||
|
||||
def preprocess_logic(is_door: bool, id: str, logic: str) -> str:
|
||||
if id in logic and not is_door:
|
||||
index: int = logic.find(id)
|
||||
logic = logic[:index] + logic[index+len(id)+4:]
|
||||
|
||||
while ">=" in logic:
|
||||
index: int = logic.find(">=")
|
||||
logic = logic[:index-1] + logic[index+3:]
|
||||
|
||||
while ">" in logic:
|
||||
index: int = logic.find(">")
|
||||
count = int(logic[index+2])
|
||||
count += 1
|
||||
logic = logic[:index-1] + str(count) + logic[index+3:]
|
||||
|
||||
while "<=" in logic:
|
||||
index: int = logic.find("<=")
|
||||
logic = logic[:index-1] + logic[index+3:]
|
||||
|
||||
while "<" in logic:
|
||||
index: int = logic.find("<")
|
||||
count = int(logic[index+2])
|
||||
count += 1
|
||||
logic = logic[:index-1] + str(count) + logic[index+3:]
|
||||
|
||||
#print(logic)
|
||||
return logic
|
||||
|
||||
|
||||
def build_logic_conditions(logic: str) -> List[List[str]]:
|
||||
all_conditions: List[List[str]] = []
|
||||
|
||||
parts = logic.split()
|
||||
sub_part: str = ""
|
||||
current_index: int = 0
|
||||
parens: int = -1
|
||||
current_condition: List[str] = []
|
||||
parens_conditions: List[List[List[str]]] = []
|
||||
|
||||
for index, part in enumerate(parts):
|
||||
#print(current_index, index, parens, part)
|
||||
|
||||
# skip parts that have already been handled
|
||||
if index < current_index:
|
||||
continue
|
||||
|
||||
# break loop if reached final part
|
||||
try:
|
||||
parts[index+1]
|
||||
except IndexError:
|
||||
#print("INDEXERROR", part)
|
||||
if parens < 0:
|
||||
current_condition.append(part)
|
||||
if len(parens_conditions) > 0:
|
||||
for i in parens_conditions:
|
||||
for j in i:
|
||||
all_conditions.append(j + current_condition)
|
||||
else:
|
||||
all_conditions.append(current_condition)
|
||||
break
|
||||
|
||||
#print(current_condition, parens, sub_part)
|
||||
|
||||
# prepare for subcondition
|
||||
if "(" in part:
|
||||
# keep track of nested parentheses
|
||||
if parens == -1:
|
||||
parens = 0
|
||||
for char in part:
|
||||
if char == "(":
|
||||
parens += 1
|
||||
|
||||
# add to sub part
|
||||
if sub_part == "":
|
||||
sub_part = part
|
||||
else:
|
||||
sub_part += f" {part}"
|
||||
#if not ")" in part:
|
||||
continue
|
||||
|
||||
# end of subcondition
|
||||
if ")" in part:
|
||||
# read every character in case of multiple closing parentheses
|
||||
for char in part:
|
||||
if char == ")":
|
||||
parens -= 1
|
||||
|
||||
sub_part += f" {part}"
|
||||
|
||||
# if reached end of parentheses, handle subcondition
|
||||
if parens == 0:
|
||||
#print(current_condition, sub_part)
|
||||
parens = -1
|
||||
|
||||
try:
|
||||
parts[index+1]
|
||||
except IndexError:
|
||||
#print("END OF LOGIC")
|
||||
if len(parens_conditions) > 0:
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
|
||||
temp_conditions: List[List[str]] = []
|
||||
|
||||
for i in parens_conditions[0]:
|
||||
for j in parens_conditions[1]:
|
||||
temp_conditions.append(i + j)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
parens_conditions.pop(0)
|
||||
|
||||
while len(parens_conditions) > 0:
|
||||
temp_conditions2 = temp_conditions
|
||||
temp_conditions = []
|
||||
for k in temp_conditions2:
|
||||
for l in parens_conditions[0]:
|
||||
temp_conditions.append(k + l)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
|
||||
#print("TEMP:", remove_duplicates(temp_conditions))
|
||||
all_conditions += temp_conditions
|
||||
else:
|
||||
all_conditions += build_logic_subconditions(current_condition, sub_part)
|
||||
else:
|
||||
#print("NEXT PARTS:", parts[index+1], parts[index+2])
|
||||
if parts[index+1] == "&&":
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
else:
|
||||
if len(parens_conditions) > 0:
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
|
||||
temp_conditions: List[List[str]] = []
|
||||
|
||||
for i in parens_conditions[0]:
|
||||
for j in parens_conditions[1]:
|
||||
temp_conditions.append(i + j)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
parens_conditions.pop(0)
|
||||
|
||||
while len(parens_conditions) > 0:
|
||||
temp_conditions2 = temp_conditions
|
||||
temp_conditions = []
|
||||
for k in temp_conditions2:
|
||||
for l in parens_conditions[0]:
|
||||
temp_conditions.append(k + l)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
|
||||
#print("TEMP:", remove_duplicates(temp_conditions))
|
||||
all_conditions += temp_conditions
|
||||
else:
|
||||
all_conditions += build_logic_subconditions(current_condition, sub_part)
|
||||
|
||||
current_index = index+2
|
||||
|
||||
current_condition = []
|
||||
sub_part = ""
|
||||
|
||||
continue
|
||||
|
||||
# collect all parts until reaching end of parentheses
|
||||
if parens > 0:
|
||||
sub_part += f" {part}"
|
||||
continue
|
||||
|
||||
current_condition.append(part)
|
||||
|
||||
# continue with current condition
|
||||
if parts[index+1] == "&&":
|
||||
current_index = index+2
|
||||
continue
|
||||
|
||||
# add condition to list and start new one
|
||||
elif parts[index+1] == "||":
|
||||
if len(parens_conditions) > 0:
|
||||
for i in parens_conditions:
|
||||
for j in i:
|
||||
all_conditions.append(j + current_condition)
|
||||
parens_conditions = []
|
||||
else:
|
||||
all_conditions.append(current_condition)
|
||||
current_condition = []
|
||||
current_index = index+2
|
||||
continue
|
||||
|
||||
return remove_duplicates(all_conditions)
|
||||
|
||||
|
||||
def build_logic_subconditions(current_condition: List[str], subcondition: str) -> List[List[str]]:
|
||||
#print("STARTED SUBCONDITION", current_condition, subcondition)
|
||||
subconditions = build_logic_conditions(subcondition[1:-1])
|
||||
final_conditions = []
|
||||
|
||||
for condition in subconditions:
|
||||
final_condition = current_condition + condition
|
||||
final_conditions.append(final_condition)
|
||||
|
||||
#print("ENDED SUBCONDITION")
|
||||
#print(final_conditions)
|
||||
return final_conditions
|
||||
|
||||
|
||||
def remove_duplicates(conditions: List[List[str]]) -> List[List[str]]:
|
||||
final_conditions: List[List[str]] = []
|
||||
for condition in conditions:
|
||||
final_conditions.append(list(dict.fromkeys(condition)))
|
||||
|
||||
return final_conditions
|
||||
|
||||
|
||||
def handle_door_visibility(door: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if door.get("visibilityFlags") == None:
|
||||
return door
|
||||
else:
|
||||
flags: List[str] = str(door.get("visibilityFlags")).split(", ")
|
||||
#print(flags)
|
||||
temp_flags: List[str] = []
|
||||
this_door: bool = False
|
||||
#required_doors: str = ""
|
||||
|
||||
if "ThisDoor" in flags:
|
||||
this_door = True
|
||||
|
||||
#if "requiredDoors" in flags:
|
||||
# required_doors: str = " || ".join(door.get("requiredDoors"))
|
||||
|
||||
if "DoubleJump" in flags:
|
||||
temp_flags.append("DoubleJump")
|
||||
|
||||
if "NormalLogic" in flags:
|
||||
temp_flags.append("NormalLogic")
|
||||
|
||||
if "NormalLogicAndDoubleJump" in flags:
|
||||
temp_flags.append("NormalLogicAndDoubleJump")
|
||||
|
||||
if "HardLogic" in flags:
|
||||
temp_flags.append("HardLogic")
|
||||
|
||||
if "HardLogicAndDoubleJump" in flags:
|
||||
temp_flags.append("HardLogicAndDoubleJump")
|
||||
|
||||
if "EnemySkips" in flags:
|
||||
temp_flags.append("EnemySkips")
|
||||
|
||||
if "EnemySkipsAndDoubleJump" in flags:
|
||||
temp_flags.append("EnemySkipsAndDoubleJump")
|
||||
|
||||
# remove duplicates
|
||||
temp_flags = list(dict.fromkeys(temp_flags))
|
||||
|
||||
original_logic: str = door.get("logic")
|
||||
temp_logic: str = ""
|
||||
|
||||
if this_door:
|
||||
temp_logic = door.get("id")
|
||||
|
||||
if temp_flags != []:
|
||||
if temp_logic != "":
|
||||
temp_logic += " || "
|
||||
temp_logic += ' && '.join(temp_flags)
|
||||
|
||||
if temp_logic != "" and original_logic != None:
|
||||
if len(original_logic.split()) == 1:
|
||||
if len(temp_logic.split()) == 1:
|
||||
door["logic"] = f"{temp_logic} && {original_logic}"
|
||||
else:
|
||||
door["logic"] = f"({temp_logic}) && {original_logic}"
|
||||
else:
|
||||
if len(temp_logic.split()) == 1:
|
||||
door["logic"] = f"{temp_logic} && ({original_logic})"
|
||||
else:
|
||||
door["logic"] = f"({temp_logic}) && ({original_logic})"
|
||||
elif temp_logic != "" and original_logic == None:
|
||||
door["logic"] = temp_logic
|
||||
|
||||
return door
|
||||
|
||||
|
||||
def get_state_provider_for_condition(condition: List[str]) -> str:
|
||||
for item in condition:
|
||||
if (item[0] == "D" and item[3] == "Z" and item[6] == "S")\
|
||||
or (item[0] == "D" and item[3] == "B" and item[4] == "Z" and item[7] == "S"):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('-l', '--local', action="store_true", help="Use local files in the same directory instead of reading resource files from the BrandenEK/Blasphemous-Randomizer repository.")
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def main(args: argparse.Namespace):
|
||||
doors = []
|
||||
locations = []
|
||||
|
||||
if (args.local):
|
||||
doors = load_resource_local("doors.json")
|
||||
locations = load_resource_local("locations_items.json")
|
||||
|
||||
else:
|
||||
doors = load_resource_from_web("https://raw.githubusercontent.com/BrandenEK/Blasphemous-Randomizer/main/resources/data/Randomizer/doors.json")
|
||||
locations = load_resource_from_web("https://raw.githubusercontent.com/BrandenEK/Blasphemous-Randomizer/main/resources/data/Randomizer/locations_items.json")
|
||||
|
||||
original_connections: Dict[str, str] = {}
|
||||
rooms: Dict[str, List[str]] = {}
|
||||
output: Dict[str, Any] = {}
|
||||
logic_objects: List[Dict[str, Any]] = []
|
||||
|
||||
for door in doors:
|
||||
if door.get("originalDoor") != None:
|
||||
if not door.get("id") in original_connections:
|
||||
original_connections[door.get("id")] = door.get("originalDoor")
|
||||
original_connections[door.get("originalDoor")] = door.get("id")
|
||||
|
||||
room: str = get_room_from_door(door.get("originalDoor"))
|
||||
if not room in rooms.keys():
|
||||
rooms[room] = [door.get("id")]
|
||||
else:
|
||||
rooms[room].append(door.get("id"))
|
||||
|
||||
def flip_doors_in_condition(condition: List[str]) -> List[str]:
|
||||
new_condition = []
|
||||
for item in condition:
|
||||
if item in original_connections:
|
||||
new_condition.append(original_connections[item])
|
||||
else:
|
||||
new_condition.append(item)
|
||||
|
||||
return new_condition
|
||||
|
||||
for room in rooms.keys():
|
||||
obj = {
|
||||
"Name": room,
|
||||
"Logic": [],
|
||||
"Handling": "Default"
|
||||
}
|
||||
|
||||
for door in rooms[room]:
|
||||
logic = {
|
||||
"StateProvider": door,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
for door in doors:
|
||||
if door.get("direction") == 5:
|
||||
continue
|
||||
|
||||
handling: str = "Transition"
|
||||
if "Cell" in door.get("id"):
|
||||
handling = "Default"
|
||||
obj = {
|
||||
"Name": door.get("id"),
|
||||
"Logic": [],
|
||||
"Handling": handling
|
||||
}
|
||||
|
||||
visibility_flags: List[str] = []
|
||||
if door.get("visibilityFlags") != None:
|
||||
visibility_flags = str(door.get("visibilityFlags")).split(", ")
|
||||
if "1" in visibility_flags:
|
||||
visibility_flags.remove("1")
|
||||
visibility_flags.append("ThisDoor")
|
||||
|
||||
required_doors: List[str] = []
|
||||
if door.get("requiredDoors"):
|
||||
required_doors = door.get("requiredDoors")
|
||||
|
||||
if len(visibility_flags) > 0:
|
||||
for flag in visibility_flags:
|
||||
if flag == "RequiredDoors":
|
||||
continue
|
||||
|
||||
if flag == "ThisDoor":
|
||||
flag = original_connections[door.get("id")]
|
||||
|
||||
if door.get("logic") != None:
|
||||
logic: str = door.get("logic")
|
||||
logic = f"{flag} && ({logic})"
|
||||
logic = preprocess_logic(True, door.get("id"), logic)
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
state_provider: str = get_room_from_door(door.get("id"))
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
state_provider = get_state_provider_for_condition(condition)
|
||||
condition.remove(state_provider)
|
||||
|
||||
logic = {
|
||||
"StateProvider": state_provider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": get_room_from_door(door.get("id")),
|
||||
"Conditions": [flag],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
if "RequiredDoors" in visibility_flags:
|
||||
for d in required_doors:
|
||||
flipped = original_connections[d]
|
||||
if door.get("logic") != None:
|
||||
logic: str = preprocess_logic(True, door.get("id"), door.get("logic"))
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
state_provider: str = flipped
|
||||
|
||||
if flipped in condition:
|
||||
condition.remove(flipped)
|
||||
|
||||
logic = {
|
||||
"StateProvider": state_provider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": flipped,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
else:
|
||||
if door.get("logic") != None:
|
||||
logic: str = preprocess_logic(True, door.get("id"), door.get("logic"))
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
stateProvider: str = get_room_from_door(door.get("id"))
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
stateProvider = get_state_provider_for_condition(condition)
|
||||
condition.remove(stateProvider)
|
||||
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": get_room_from_door(door.get("id")),
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
for location in locations:
|
||||
obj = {
|
||||
"Name": location.get("id"),
|
||||
"Logic": [],
|
||||
"Handling": "Location"
|
||||
}
|
||||
|
||||
if location.get("logic") != None:
|
||||
for condition in build_logic_conditions(preprocess_logic(False, location.get("id"), location.get("logic"))):
|
||||
condition = flip_doors_in_condition(condition)
|
||||
stateProvider: str = location.get("room")
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
stateProvider = get_state_provider_for_condition(condition)
|
||||
condition.remove(stateProvider)
|
||||
|
||||
if stateProvider == "Initial":
|
||||
stateProvider = None
|
||||
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
stateProvider: str = location.get("room")
|
||||
if stateProvider == "Initial":
|
||||
stateProvider = None
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
output["LogicObjects"] = logic_objects
|
||||
|
||||
with open("StringWorldDefinition.json", "w") as file:
|
||||
print("Writing to StringWorldDefinition.json")
|
||||
file.write(json.dumps(output, indent=4))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(parse_args())
|
||||
5405
worlds/blasphemous/Rooms.py
Normal file
5405
worlds/blasphemous/Rooms.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,12 @@ unrandomized_dict: Dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
junk_locations: Set[str] = {
|
||||
junk_locations: Set[str] = [
|
||||
"Albero: Donate 50000 Tears",
|
||||
"Ossuary: 11th reward",
|
||||
"AtTotS: Miriam's gift",
|
||||
"TSC: Jocinero's final reward"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
thorn_set: Set[str] = {
|
||||
@@ -44,4 +44,4 @@ skill_dict: Dict[str, str] = {
|
||||
"Skill 5, Tier 1": "Lunge Skill",
|
||||
"Skill 5, Tier 2": "Lunge Skill",
|
||||
"Skill 5, Tier 3": "Lunge Skill",
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
from typing import Dict, List, Set, Any
|
||||
from collections import Counter
|
||||
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
|
||||
from Options import OptionError
|
||||
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from .Items import base_id, item_table, group_table, tears_list, reliquary_set
|
||||
from .Locations import location_names
|
||||
from .Rules import BlasRules
|
||||
from worlds.generic.Rules import set_rule
|
||||
from .Options import BlasphemousOptions, blas_option_groups
|
||||
from .Items import base_id, item_table, group_table, tears_set, reliquary_set, event_table
|
||||
from .Locations import location_table
|
||||
from .Rooms import room_table, door_table
|
||||
from .Rules import rules
|
||||
from worlds.generic.Rules import set_rule, add_rule
|
||||
from .Options import blasphemous_options
|
||||
from .Vanilla import unrandomized_dict, junk_locations, thorn_set, skill_dict
|
||||
from .region_data import regions, locations
|
||||
|
||||
|
||||
class BlasphemousWeb(WebWorld):
|
||||
theme = "stone"
|
||||
@@ -21,33 +21,39 @@ class BlasphemousWeb(WebWorld):
|
||||
"setup/en",
|
||||
["TRPG"]
|
||||
)]
|
||||
option_groups = blas_option_groups
|
||||
|
||||
|
||||
class BlasphemousWorld(World):
|
||||
"""
|
||||
Blasphemous is a challenging Metroidvania set in the cursed land of Cvstodia. Play as the Penitent One, trapped
|
||||
in an endless cycle of death and rebirth, and free the world from its terrible fate in your quest to break
|
||||
in an endless cycle of death and rebirth, and free the world from it's terrible fate in your quest to break
|
||||
your eternal damnation!
|
||||
"""
|
||||
|
||||
game = "Blasphemous"
|
||||
game: str = "Blasphemous"
|
||||
web = BlasphemousWeb()
|
||||
|
||||
item_name_to_id = {item["name"]: (base_id + index) for index, item in enumerate(item_table)}
|
||||
location_name_to_id = {loc: (base_id + index) for index, loc in enumerate(location_names.values())}
|
||||
location_name_to_id = {loc["name"]: (base_id + index) for index, loc in enumerate(location_table)}
|
||||
location_name_to_game_id = {loc["name"]: loc["game_id"] for loc in location_table}
|
||||
|
||||
item_name_groups = group_table
|
||||
options_dataclass = BlasphemousOptions
|
||||
options: BlasphemousOptions
|
||||
option_definitions = blasphemous_options
|
||||
|
||||
required_client_version = (0, 4, 7)
|
||||
required_client_version = (0, 4, 2)
|
||||
|
||||
|
||||
def __init__(self, multiworld, player):
|
||||
super(BlasphemousWorld, self).__init__(multiworld, player)
|
||||
self.start_room: str = "D17Z01S01"
|
||||
self.disabled_locations: List[str] = []
|
||||
self.door_connections: Dict[str, str] = {}
|
||||
|
||||
|
||||
def set_rules(self):
|
||||
rules(self)
|
||||
for door in door_table:
|
||||
add_rule(self.multiworld.get_location(door["Id"], self.player),
|
||||
lambda state: state.can_reach(self.get_connected_door(door["Id"])), self.player)
|
||||
|
||||
|
||||
def create_item(self, name: str) -> "BlasphemousItem":
|
||||
@@ -62,56 +68,64 @@ class BlasphemousWorld(World):
|
||||
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.random.choice(tears_list)
|
||||
return self.multiworld.random.choice(tears_set)
|
||||
|
||||
|
||||
def generate_early(self):
|
||||
if not self.options.starting_location.randomized:
|
||||
if self.options.starting_location == "mourning_havoc" and self.options.difficulty < 2:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Difficulty is lower than Hard.")
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
|
||||
if (self.options.starting_location == "brotherhood" or self.options.starting_location == "mourning_havoc") \
|
||||
and self.options.dash_shuffle:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Shuffle Dash is enabled.")
|
||||
if not world.starting_location[player].randomized:
|
||||
if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
|
||||
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
|
||||
" cannot be chosen if Difficulty is lower than Hard.")
|
||||
|
||||
if (world.starting_location[player].value == 0 or world.starting_location[player].value == 6) \
|
||||
and world.dash_shuffle[player]:
|
||||
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
|
||||
" cannot be chosen if Shuffle Dash is enabled.")
|
||||
|
||||
if self.options.starting_location == "grievance" and self.options.wall_climb_shuffle:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Shuffle Wall Climb is enabled.")
|
||||
if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
|
||||
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
|
||||
" cannot be chosen if Shuffle Wall Climb is enabled.")
|
||||
else:
|
||||
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ]
|
||||
invalid: bool = False
|
||||
|
||||
if self.options.difficulty < 2:
|
||||
if world.difficulty[player].value < 2:
|
||||
locations.remove(6)
|
||||
|
||||
if self.options.dash_shuffle:
|
||||
if world.dash_shuffle[player]:
|
||||
locations.remove(0)
|
||||
if 6 in locations:
|
||||
locations.remove(6)
|
||||
|
||||
if self.options.wall_climb_shuffle:
|
||||
if world.wall_climb_shuffle[player]:
|
||||
locations.remove(3)
|
||||
|
||||
if self.options.starting_location.value not in locations:
|
||||
self.options.starting_location.value = self.random.choice(locations)
|
||||
if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
|
||||
invalid = True
|
||||
|
||||
if (world.starting_location[player].value == 0 or world.starting_location[player].value == 6) \
|
||||
and world.dash_shuffle[player]:
|
||||
invalid = True
|
||||
|
||||
if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
|
||||
invalid = True
|
||||
|
||||
if invalid:
|
||||
world.starting_location[player].value = world.random.choice(locations)
|
||||
|
||||
|
||||
if not self.options.dash_shuffle:
|
||||
self.multiworld.push_precollected(self.create_item("Dash Ability"))
|
||||
if not world.dash_shuffle[player]:
|
||||
world.push_precollected(self.create_item("Dash Ability"))
|
||||
|
||||
if not self.options.wall_climb_shuffle:
|
||||
self.multiworld.push_precollected(self.create_item("Wall Climb Ability"))
|
||||
if not world.wall_climb_shuffle[player]:
|
||||
world.push_precollected(self.create_item("Wall Climb Ability"))
|
||||
|
||||
if not self.options.boots_of_pleading:
|
||||
self.disabled_locations.append("RE401")
|
||||
|
||||
if not self.options.purified_hand:
|
||||
self.disabled_locations.append("RE402")
|
||||
|
||||
if self.options.skip_long_quests:
|
||||
if world.skip_long_quests[player]:
|
||||
for loc in junk_locations:
|
||||
self.options.exclude_locations.value.add(loc)
|
||||
world.exclude_locations[player].value.add(loc)
|
||||
|
||||
start_rooms: Dict[int, str] = {
|
||||
0: "D17Z01S01",
|
||||
@@ -123,10 +137,13 @@ class BlasphemousWorld(World):
|
||||
6: "D20Z02S09"
|
||||
}
|
||||
|
||||
self.start_room = start_rooms[self.options.starting_location.value]
|
||||
self.start_room = start_rooms[world.starting_location[player].value]
|
||||
|
||||
|
||||
def create_items(self):
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
|
||||
removed: int = 0
|
||||
to_remove: List[str] = [
|
||||
"Tears of Atonement (250)",
|
||||
@@ -139,46 +156,46 @@ class BlasphemousWorld(World):
|
||||
skipped_items = []
|
||||
junk: int = 0
|
||||
|
||||
for item, count in self.options.start_inventory.value.items():
|
||||
for item, count in world.start_inventory[player].value.items():
|
||||
for _ in range(count):
|
||||
skipped_items.append(item)
|
||||
junk += 1
|
||||
|
||||
skipped_items.extend(unrandomized_dict.values())
|
||||
|
||||
if self.options.thorn_shuffle == "vanilla":
|
||||
for _ in range(8):
|
||||
if world.thorn_shuffle[player] == 2:
|
||||
for i in range(8):
|
||||
skipped_items.append("Thorn Upgrade")
|
||||
|
||||
if self.options.dash_shuffle:
|
||||
if world.dash_shuffle[player]:
|
||||
skipped_items.append(to_remove[removed])
|
||||
removed += 1
|
||||
elif not self.options.dash_shuffle:
|
||||
elif not world.dash_shuffle[player]:
|
||||
skipped_items.append("Dash Ability")
|
||||
|
||||
if self.options.wall_climb_shuffle:
|
||||
if world.wall_climb_shuffle[player]:
|
||||
skipped_items.append(to_remove[removed])
|
||||
removed += 1
|
||||
elif not self.options.wall_climb_shuffle:
|
||||
elif not world.wall_climb_shuffle[player]:
|
||||
skipped_items.append("Wall Climb Ability")
|
||||
|
||||
if not self.options.reliquary_shuffle:
|
||||
if not world.reliquary_shuffle[player]:
|
||||
skipped_items.extend(reliquary_set)
|
||||
elif self.options.reliquary_shuffle:
|
||||
for _ in range(3):
|
||||
elif world.reliquary_shuffle[player]:
|
||||
for i in range(3):
|
||||
skipped_items.append(to_remove[removed])
|
||||
removed += 1
|
||||
|
||||
if not self.options.boots_of_pleading:
|
||||
if not world.boots_of_pleading[player]:
|
||||
skipped_items.append("Boots of Pleading")
|
||||
|
||||
if not self.options.purified_hand:
|
||||
if not world.purified_hand[player]:
|
||||
skipped_items.append("Purified Hand of the Nun")
|
||||
|
||||
if self.options.start_wheel:
|
||||
if world.start_wheel[player]:
|
||||
skipped_items.append("The Young Mason's Wheel")
|
||||
|
||||
if not self.options.skill_randomizer:
|
||||
if not world.skill_randomizer[player]:
|
||||
skipped_items.extend(skill_dict.values())
|
||||
|
||||
counter = Counter(skipped_items)
|
||||
@@ -191,138 +208,184 @@ class BlasphemousWorld(World):
|
||||
if count <= 0:
|
||||
continue
|
||||
else:
|
||||
for _ in range(count):
|
||||
for i 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
|
||||
world.itempool += pool
|
||||
|
||||
|
||||
def pre_fill(self):
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
|
||||
self.place_items_from_dict(unrandomized_dict)
|
||||
|
||||
if self.options.thorn_shuffle == "vanilla":
|
||||
if world.thorn_shuffle[player] == 2:
|
||||
self.place_items_from_set(thorn_set, "Thorn Upgrade")
|
||||
|
||||
if self.options.start_wheel:
|
||||
self.get_location("Beginning gift").place_locked_item(self.create_item("The Young Mason's Wheel"))
|
||||
if world.start_wheel[player]:
|
||||
world.get_location("Beginning gift", player)\
|
||||
.place_locked_item(self.create_item("The Young Mason's Wheel"))
|
||||
|
||||
if not self.options.skill_randomizer:
|
||||
if not world.skill_randomizer[player]:
|
||||
self.place_items_from_dict(skill_dict)
|
||||
|
||||
if self.options.thorn_shuffle == "local_only":
|
||||
self.options.local_items.value.add("Thorn Upgrade")
|
||||
if world.thorn_shuffle[player] == 1:
|
||||
world.local_items[player].value.add("Thorn Upgrade")
|
||||
|
||||
|
||||
def place_items_from_set(self, location_set: Set[str], name: str):
|
||||
for loc in location_set:
|
||||
self.get_location(loc).place_locked_item(self.create_item(name))
|
||||
self.multiworld.get_location(loc, self.player)\
|
||||
.place_locked_item(self.create_item(name))
|
||||
|
||||
|
||||
def place_items_from_dict(self, option_dict: Dict[str, str]):
|
||||
for loc, item in option_dict.items():
|
||||
self.get_location(loc).place_locked_item(self.create_item(item))
|
||||
self.multiworld.get_location(loc, self.player)\
|
||||
.place_locked_item(self.create_item(item))
|
||||
|
||||
|
||||
def create_regions(self) -> None:
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
|
||||
created_regions: List[str] = []
|
||||
|
||||
for r in regions:
|
||||
multiworld.regions.append(Region(r["name"], player, multiworld))
|
||||
created_regions.append(r["name"])
|
||||
|
||||
self.get_region("Menu").add_exits({self.start_room: "New Game"})
|
||||
|
||||
blas_logic = BlasRules(self)
|
||||
|
||||
for r in regions:
|
||||
region = self.get_region(r["name"])
|
||||
|
||||
for e in r["exits"]:
|
||||
region.add_exits({e["target"]}, {e["target"]: blas_logic.load_rule(True, r["name"], e)})
|
||||
|
||||
for l in [l for l in r["locations"] if l not in self.disabled_locations]:
|
||||
region.add_locations({location_names[l]: self.location_name_to_id[location_names[l]]}, BlasphemousLocation)
|
||||
|
||||
for t in r["transitions"]:
|
||||
if t == r["name"]:
|
||||
continue
|
||||
|
||||
if t in created_regions:
|
||||
region.add_exits({t})
|
||||
else:
|
||||
multiworld.regions.append(Region(t, player, multiworld))
|
||||
created_regions.append(t)
|
||||
region.add_exits({t})
|
||||
|
||||
|
||||
for l in [l for l in locations if l["name"] not in self.disabled_locations]:
|
||||
location = self.get_location(location_names[l["name"]])
|
||||
set_rule(location, blas_logic.load_rule(False, l["name"], l))
|
||||
|
||||
for rname, ename in blas_logic.indirect_conditions:
|
||||
self.multiworld.register_indirect_condition(self.get_region(rname), self.get_entrance(ename))
|
||||
#from Utils import visualize_regions
|
||||
#visualize_regions(self.get_region("Menu"), "blasphemous_regions.puml")
|
||||
world = self.multiworld
|
||||
|
||||
victory = Location(player, "His Holiness Escribar", None, self.get_region("D07Z01S03[W]"))
|
||||
victory.place_locked_item(self.create_event("Victory"))
|
||||
self.get_region("D07Z01S03[W]").locations.append(victory)
|
||||
menu_region = Region("Menu", player, world)
|
||||
misc_region = Region("Misc", player, world)
|
||||
world.regions += [menu_region, misc_region]
|
||||
|
||||
if self.options.ending == "ending_a":
|
||||
for room in room_table:
|
||||
region = Region(room, player, world)
|
||||
world.regions.append(region)
|
||||
|
||||
menu_region.add_exits({self.start_room: "New Game"})
|
||||
world.get_region(self.start_room, player).add_exits({"Misc": "Misc"})
|
||||
|
||||
for door in door_table:
|
||||
if door.get("OriginalDoor") is None:
|
||||
continue
|
||||
else:
|
||||
if not door["Id"] in self.door_connections.keys():
|
||||
self.door_connections[door["Id"]] = door["OriginalDoor"]
|
||||
self.door_connections[door["OriginalDoor"]] = door["Id"]
|
||||
|
||||
parent_region: Region = self.get_room_from_door(door["Id"])
|
||||
target_region: Region = self.get_room_from_door(door["OriginalDoor"])
|
||||
parent_region.add_exits({
|
||||
target_region.name: door["Id"]
|
||||
}, {
|
||||
target_region.name: lambda x: door.get("VisibilityFlags") != 1
|
||||
})
|
||||
|
||||
for index, loc in enumerate(location_table):
|
||||
if not world.boots_of_pleading[player] and loc["name"] == "BotSS: 2nd meeting with Redento":
|
||||
continue
|
||||
if not world.purified_hand[player] and loc["name"] == "MoM: Western room ledge":
|
||||
continue
|
||||
|
||||
region: Region = world.get_region(loc["room"], player)
|
||||
region.add_locations({loc["name"]: base_id + index})
|
||||
#id = base_id + location_table.index(loc)
|
||||
#reg.locations.append(BlasphemousLocation(player, loc["name"], id, reg))
|
||||
|
||||
for e, r in event_table.items():
|
||||
region: Region = world.get_region(r, player)
|
||||
event = BlasphemousLocation(player, e, None, region)
|
||||
event.show_in_spoiler = False
|
||||
event.place_locked_item(self.create_event(e))
|
||||
region.locations.append(event)
|
||||
|
||||
for door in door_table:
|
||||
region: Region = self.get_room_from_door(self.door_connections[door["Id"]])
|
||||
event = BlasphemousLocation(player, door["Id"], None, region)
|
||||
event.show_in_spoiler = False
|
||||
event.place_locked_item(self.create_event(door["Id"]))
|
||||
region.locations.append(event)
|
||||
|
||||
victory = Location(player, "His Holiness Escribar", None, world.get_region("D07Z01S03", player))
|
||||
victory.place_locked_item(self.create_event("Victory"))
|
||||
world.get_region("D07Z01S03", player).locations.append(victory)
|
||||
|
||||
if world.ending[self.player].value == 1:
|
||||
set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8))
|
||||
elif self.options.ending == "ending_c":
|
||||
elif world.ending[self.player].value == 2:
|
||||
set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8) and
|
||||
state.has("Holy Wound of Abnegation", player))
|
||||
|
||||
multiworld.completion_condition[self.player] = lambda state: state.has("Victory", player)
|
||||
world.completion_condition[self.player] = lambda state: state.has("Victory", player)
|
||||
|
||||
|
||||
def get_room_from_door(self, door: str) -> Region:
|
||||
return self.multiworld.get_region(door.split("[")[0], self.player)
|
||||
|
||||
|
||||
def get_connected_door(self, door: str) -> Entrance:
|
||||
return self.multiworld.get_entrance(self.door_connections[door], self.player)
|
||||
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
slot_data: Dict[str, Any] = {}
|
||||
locations = []
|
||||
doors: Dict[str, str] = {}
|
||||
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
thorns: bool = True
|
||||
|
||||
if self.options.thorn_shuffle == "vanilla":
|
||||
if world.thorn_shuffle[player].value == 2:
|
||||
thorns = False
|
||||
|
||||
for loc in world.get_filled_locations(player):
|
||||
if loc.item.code == None:
|
||||
continue
|
||||
else:
|
||||
data = {
|
||||
"id": self.location_name_to_game_id[loc.name],
|
||||
"ap_id": loc.address,
|
||||
"name": loc.item.name,
|
||||
"player_name": world.player_name[loc.item.player],
|
||||
"type": int(loc.item.classification)
|
||||
}
|
||||
|
||||
locations.append(data)
|
||||
|
||||
config = {
|
||||
"LogicDifficulty": self.options.difficulty.value,
|
||||
"StartingLocation": self.options.starting_location.value,
|
||||
"LogicDifficulty": world.difficulty[player].value,
|
||||
"StartingLocation": world.starting_location[player].value,
|
||||
"VersionCreated": "AP",
|
||||
|
||||
"UnlockTeleportation": bool(self.options.prie_dieu_warp.value),
|
||||
"AllowHints": bool(self.options.corpse_hints.value),
|
||||
"AllowPenitence": bool(self.options.penitence.value),
|
||||
"UnlockTeleportation": bool(world.prie_dieu_warp[player].value),
|
||||
"AllowHints": bool(world.corpse_hints[player].value),
|
||||
"AllowPenitence": bool(world.penitence[player].value),
|
||||
|
||||
"ShuffleReliquaries": bool(self.options.reliquary_shuffle.value),
|
||||
"ShuffleBootsOfPleading": bool(self.options.boots_of_pleading.value),
|
||||
"ShufflePurifiedHand": bool(self.options.purified_hand.value),
|
||||
"ShuffleDash": bool(self.options.dash_shuffle.value),
|
||||
"ShuffleWallClimb": bool(self.options.wall_climb_shuffle.value),
|
||||
"ShuffleReliquaries": bool(world.reliquary_shuffle[player].value),
|
||||
"ShuffleBootsOfPleading": bool(world.boots_of_pleading[player].value),
|
||||
"ShufflePurifiedHand": bool(world.purified_hand[player].value),
|
||||
"ShuffleDash": bool(world.dash_shuffle[player].value),
|
||||
"ShuffleWallClimb": bool(world.wall_climb_shuffle[player].value),
|
||||
|
||||
"ShuffleSwordSkills": bool(self.options.wall_climb_shuffle.value),
|
||||
"ShuffleSwordSkills": bool(world.skill_randomizer[player].value),
|
||||
"ShuffleThorns": thorns,
|
||||
"JunkLongQuests": bool(self.options.skip_long_quests.value),
|
||||
"StartWithWheel": bool(self.options.start_wheel.value),
|
||||
"JunkLongQuests": bool(world.skip_long_quests[player].value),
|
||||
"StartWithWheel": bool(world.start_wheel[player].value),
|
||||
|
||||
"EnemyShuffleType": self.options.enemy_randomizer.value,
|
||||
"MaintainClass": bool(self.options.enemy_groups.value),
|
||||
"AreaScaling": bool(self.options.enemy_scaling.value),
|
||||
"EnemyShuffleType": world.enemy_randomizer[player].value,
|
||||
"MaintainClass": bool(world.enemy_groups[player].value),
|
||||
"AreaScaling": bool(world.enemy_scaling[player].value),
|
||||
|
||||
"BossShuffleType": 0,
|
||||
"DoorShuffleType": 0
|
||||
}
|
||||
|
||||
slot_data = {
|
||||
"locationinfo": [{"gameId": loc, "apId": (base_id + index)} for index, loc in enumerate(location_names)],
|
||||
"locations": locations,
|
||||
"doors": doors,
|
||||
"cfg": config,
|
||||
"ending": self.options.ending.value,
|
||||
"death_link": bool(self.options.death_link.value)
|
||||
"ending": world.ending[self.player].value,
|
||||
"death_link": bool(world.death_link[self.player].value)
|
||||
}
|
||||
|
||||
return slot_data
|
||||
@@ -333,4 +396,4 @@ class BlasphemousItem(Item):
|
||||
|
||||
|
||||
class BlasphemousLocation(Location):
|
||||
game: str = "Blasphemous"
|
||||
game: str = "Blasphemous"
|
||||
@@ -1,17 +1,48 @@
|
||||
# Blasphemous Multiworld Setup Guide
|
||||
|
||||
It is recommended to use the [Mod Installer](https://github.com/BrandenEK/Blasphemous.Modding.Installer) to handle installing and updating mods. If you would prefer to install mods manually, instructions can also be found at the Mod Installer repository.
|
||||
## Useful Links
|
||||
|
||||
You will need the [Multiworld](https://github.com/BrandenEK/Blasphemous.Randomizer.Multiworld) mod to play an Archipelago randomizer.
|
||||
Required:
|
||||
- Blasphemous: [Steam](https://store.steampowered.com/app/774361/Blasphemous/)
|
||||
- The GOG version of Blasphemous will also work.
|
||||
- Blasphemous Mod Installer: [GitHub](https://github.com/BrandenEK/Blasphemous-Mod-Installer)
|
||||
- Blasphemous Modding API: [GitHub](https://github.com/BrandenEK/Blasphemous-Modding-API)
|
||||
- Blasphemous Randomizer: [GitHub](https://github.com/BrandenEK/Blasphemous-Randomizer)
|
||||
- Blasphemous Multiworld: [GitHub](https://github.com/BrandenEK/Blasphemous-Multiworld)
|
||||
|
||||
Some optional mods are also recommended:
|
||||
- [Rando Map](https://github.com/BrandenEK/Blasphemous.Randomizer.MapTracker)
|
||||
- [Boots of Pleading](https://github.com/BrandenEK/Blasphemous.BootsOfPleading) (Required if the "Boots of Pleading" option is enabled)
|
||||
- [Double Jump](https://github.com/BrandenEK/Blasphemous.DoubleJump) (Required if the "Purified Hand of the Nun" option is enabled)
|
||||
Optional:
|
||||
- In-game map tracker: [GitHub](https://github.com/BrandenEK/Blasphemous-Rando-Map)
|
||||
- Quick Prie Dieu warp mod: [GitHub](https://github.com/BadMagic100/Blasphemous-PrieWarp)
|
||||
- Boots of Pleading mod: [GitHub](https://github.com/BrandenEK/Blasphemous-Boots-of-Pleading)
|
||||
- Double Jump mod: [GitHub](https://github.com/BrandenEK/Blasphemous-Double-Jump)
|
||||
|
||||
To connect to a multiworld: Choose a save file and enter the address, your name, and the password (if the server has one) into the menu.
|
||||
## Mod Installer (Recommended)
|
||||
|
||||
After connecting, there are some commands you can use in the console, which can be opened by pressing backslash `\`:
|
||||
- `ap status` - Display connection status.
|
||||
- `ap say [message]` - Send a message to the server.
|
||||
- `ap hint [item]` - Request a hint for an item from the server.
|
||||
1. Download the [Mod Installer](https://github.com/BrandenEK/Blasphemous-Mod-Installer),
|
||||
and point it to your install directory for Blasphemous.
|
||||
|
||||
2. Install the `Modding API`, `Randomizer`, and `Multiworld` mods. Optionally, you can also install the
|
||||
`Rando Map`, `PrieWarp`, `Boots of Pleading`, and `Double Jump` mods, and set up the PopTracker pack if desired.
|
||||
|
||||
3. Start Blasphemous. To verfy that the mods are working, look for a version number for both
|
||||
the Randomizer and Multiworld on the title screen.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
1. Download the [Modding API](https://github.com/BrandenEK/Blasphemous-Modding-API/releases), and follow
|
||||
the [installation instructions](https://github.com/BrandenEK/Blasphemous-Modding-API#installation) on the GitHub page.
|
||||
|
||||
2. After the Modding API has been installed, download the
|
||||
[Randomizer](https://github.com/BrandenEK/Blasphemous-Randomizer/releases) and
|
||||
[Multiworld](https://github.com/BrandenEK/Blasphemous-Multiworld/releases) archives, and extract the contents of both
|
||||
into the `Modding` folder. Then, add any desired additional mods.
|
||||
|
||||
3. Start Blasphemous. To verfy that the mods are working, look for a version number for both
|
||||
the Randomizer and Multiworld on the title screen.
|
||||
|
||||
## Connecting
|
||||
|
||||
To connect to an Archipelago server, open the in-game console by pressing backslash `\` and use
|
||||
the command `multiworld connect [address:port] [name] [password]`.
|
||||
The port and password are both optional - if no port is provided then the default port of 38281 is used.
|
||||
**Make sure to connect to the server before attempting to start a new save file.**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
from test.bases import WorldTestBase
|
||||
from .. import BlasphemousWorld
|
||||
|
||||
|
||||
class BlasphemousTestBase(WorldTestBase):
|
||||
game = "Blasphemous"
|
||||
world: BlasphemousWorld
|
||||
@@ -1,56 +0,0 @@
|
||||
from . import BlasphemousTestBase
|
||||
from ..Locations import location_names
|
||||
|
||||
|
||||
class BotSSGauntletTest(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"wall_climb_shuffle": True,
|
||||
"dash_shuffle": True
|
||||
}
|
||||
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
return False
|
||||
|
||||
def test_botss_gauntlet(self) -> None:
|
||||
self.assertAccessDependency([location_names["CO25"]], [["Dash Ability", "Wall Climb Ability"]], True)
|
||||
|
||||
|
||||
class BackgroundZonesTest(BlasphemousTestBase):
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
return False
|
||||
|
||||
def test_dc_shroud(self) -> None:
|
||||
self.assertAccessDependency([location_names["RB03"]], [["Shroud of Dreamt Sins"]], True)
|
||||
|
||||
def test_wothp_bronze_cells(self) -> None:
|
||||
bronze_locations = [
|
||||
location_names["QI70"],
|
||||
location_names["RESCUED_CHERUB_03"]
|
||||
]
|
||||
|
||||
self.assertAccessDependency(bronze_locations, [["Key of the Secular"]], True)
|
||||
|
||||
def test_wothp_silver_cells(self) -> None:
|
||||
silver_locations = [
|
||||
location_names["CO24"],
|
||||
location_names["RESCUED_CHERUB_34"],
|
||||
location_names["CO37"],
|
||||
location_names["RESCUED_CHERUB_04"]
|
||||
]
|
||||
|
||||
self.assertAccessDependency(silver_locations, [["Key of the Scribe"]], True)
|
||||
|
||||
def test_wothp_gold_cells(self) -> None:
|
||||
gold_locations = [
|
||||
location_names["QI51"],
|
||||
location_names["CO26"],
|
||||
location_names["CO02"]
|
||||
]
|
||||
|
||||
self.assertAccessDependency(gold_locations, [["Key of the Inquisitor"]], True)
|
||||
|
||||
def test_wothp_quirce(self) -> None:
|
||||
self.assertAccessDependency([location_names["BS14"]], [["Key of the Secular", "Key of the Scribe", "Key of the Inquisitor"]], True)
|
||||
@@ -1,135 +0,0 @@
|
||||
from . import BlasphemousTestBase
|
||||
|
||||
|
||||
class TestBrotherhoodEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "brotherhood",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestBrotherhoodNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "brotherhood",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestBrotherhoodHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "brotherhood",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestAlberoEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestAlberoNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestAlberoHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestConventEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "convent",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestConventNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "convent",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestConventHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "convent",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestGrievanceEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "grievance",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestGrievanceNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "grievance",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestGrievanceHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "grievance",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
# mourning and havoc can't be selected on easy or normal. hard only
|
||||
class TestMourningHavocHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "mourning_havoc",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
@@ -28,7 +28,7 @@ An Example `AP.json` file:
|
||||
|
||||
```
|
||||
{
|
||||
"Url": "archipelago.gg:12345",
|
||||
"Url": "archipelago:12345",
|
||||
"SlotName": "Maddy",
|
||||
"Password": ""
|
||||
}
|
||||
|
||||
@@ -44,15 +44,15 @@ class ChecksFinderWorld(World):
|
||||
self.multiworld.regions += [menu, board]
|
||||
|
||||
def create_items(self):
|
||||
# Generate list of items
|
||||
items_to_create = []
|
||||
# Generate item pool
|
||||
itempool = []
|
||||
# Add the map width and height stuff
|
||||
items_to_create += ["Map Width"] * 5 # 10 - 5
|
||||
items_to_create += ["Map Height"] * 5 # 10 - 5
|
||||
itempool += ["Map Width"] * 5 # 10 - 5
|
||||
itempool += ["Map Height"] * 5 # 10 - 5
|
||||
# Add the map bombs
|
||||
items_to_create += ["Map Bombs"] * 15 # 20 - 5
|
||||
# Convert list into real items
|
||||
itempool = [self.create_item(item) for item in items_to_create]
|
||||
itempool += ["Map Bombs"] * 15 # 20 - 5
|
||||
# Convert itempool into real items
|
||||
itempool = [self.create_item(item) for item in itempool]
|
||||
|
||||
self.multiworld.itempool += itempool
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
|
||||
from typing import Callable, Dict, NamedTuple, Optional
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CliqueWorld
|
||||
from BaseClasses import Item, ItemClassification, MultiWorld
|
||||
|
||||
|
||||
class CliqueItem(Item):
|
||||
@@ -13,7 +10,7 @@ class CliqueItem(Item):
|
||||
class CliqueItemData(NamedTuple):
|
||||
code: Optional[int] = None
|
||||
type: ItemClassification = ItemClassification.filler
|
||||
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
|
||||
can_create: Callable[[MultiWorld, int], bool] = lambda multiworld, player: True
|
||||
|
||||
|
||||
item_data_table: Dict[str, CliqueItemData] = {
|
||||
@@ -24,11 +21,11 @@ item_data_table: Dict[str, CliqueItemData] = {
|
||||
"Button Activation": CliqueItemData(
|
||||
code=69696968,
|
||||
type=ItemClassification.progression,
|
||||
can_create=lambda world: world.options.hard_mode,
|
||||
can_create=lambda multiworld, player: bool(getattr(multiworld, "hard_mode")[player]),
|
||||
),
|
||||
"A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData(
|
||||
code=69696967,
|
||||
can_create=lambda world: False # Only created from `get_filler_item_name`.
|
||||
can_create=lambda multiworld, player: False # Only created from `get_filler_item_name`.
|
||||
),
|
||||
"The Urge to Push": CliqueItemData(
|
||||
type=ItemClassification.progression,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
|
||||
from typing import Callable, Dict, NamedTuple, Optional
|
||||
|
||||
from BaseClasses import Location
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CliqueWorld
|
||||
from BaseClasses import Location, MultiWorld
|
||||
|
||||
|
||||
class CliqueLocation(Location):
|
||||
@@ -13,7 +10,7 @@ class CliqueLocation(Location):
|
||||
class CliqueLocationData(NamedTuple):
|
||||
region: str
|
||||
address: Optional[int] = None
|
||||
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
|
||||
can_create: Callable[[MultiWorld, int], bool] = lambda multiworld, player: True
|
||||
locked_item: Optional[str] = None
|
||||
|
||||
|
||||
@@ -25,7 +22,7 @@ location_data_table: Dict[str, CliqueLocationData] = {
|
||||
"The Item on the Desk": CliqueLocationData(
|
||||
region="The Button Realm",
|
||||
address=69696968,
|
||||
can_create=lambda world: world.options.hard_mode,
|
||||
can_create=lambda multiworld, player: bool(getattr(multiworld, "hard_mode")[player]),
|
||||
),
|
||||
"In the Player's Mind": CliqueLocationData(
|
||||
region="The Button Realm",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Toggle, PerGameCommonOptions, StartInventoryPool
|
||||
from typing import Dict
|
||||
|
||||
from Options import Choice, Option, Toggle
|
||||
|
||||
|
||||
class HardMode(Toggle):
|
||||
@@ -24,11 +25,10 @@ class ButtonColor(Choice):
|
||||
option_black = 11
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliqueOptions(PerGameCommonOptions):
|
||||
color: ButtonColor
|
||||
hard_mode: HardMode
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
clique_options: Dict[str, type(Option)] = {
|
||||
"color": ButtonColor,
|
||||
"hard_mode": HardMode,
|
||||
|
||||
# DeathLink is always on. Always.
|
||||
# death_link: DeathLink
|
||||
# "death_link": DeathLink,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
from typing import Callable
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CliqueWorld
|
||||
from BaseClasses import CollectionState, MultiWorld
|
||||
|
||||
|
||||
def get_button_rule(world: "CliqueWorld") -> Callable[[CollectionState], bool]:
|
||||
if world.options.hard_mode:
|
||||
return lambda state: state.has("Button Activation", world.player)
|
||||
def get_button_rule(multiworld: MultiWorld, player: int) -> Callable[[CollectionState], bool]:
|
||||
if getattr(multiworld, "hard_mode")[player]:
|
||||
return lambda state: state.has("Button Activation", player)
|
||||
|
||||
return lambda state: True
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from typing import List, Dict, Any
|
||||
from typing import List
|
||||
|
||||
from BaseClasses import Region, Tutorial
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from .Items import CliqueItem, item_data_table, item_table
|
||||
from .Locations import CliqueLocation, location_data_table, location_table, locked_locations
|
||||
from .Options import CliqueOptions
|
||||
from .Options import clique_options
|
||||
from .Regions import region_data_table
|
||||
from .Rules import get_button_rule
|
||||
|
||||
@@ -38,8 +38,7 @@ class CliqueWorld(World):
|
||||
|
||||
game = "Clique"
|
||||
web = CliqueWebWorld()
|
||||
options: CliqueOptions
|
||||
options_dataclass = CliqueOptions
|
||||
option_definitions = clique_options
|
||||
location_name_to_id = location_table
|
||||
item_name_to_id = item_table
|
||||
|
||||
@@ -49,7 +48,7 @@ class CliqueWorld(World):
|
||||
def create_items(self) -> None:
|
||||
item_pool: List[CliqueItem] = []
|
||||
for name, item in item_data_table.items():
|
||||
if item.code and item.can_create(self):
|
||||
if item.code and item.can_create(self.multiworld, self.player):
|
||||
item_pool.append(self.create_item(name))
|
||||
|
||||
self.multiworld.itempool += item_pool
|
||||
@@ -62,40 +61,41 @@ class CliqueWorld(World):
|
||||
|
||||
# Create locations.
|
||||
for region_name, region_data in region_data_table.items():
|
||||
region = self.get_region(region_name)
|
||||
region = self.multiworld.get_region(region_name, self.player)
|
||||
region.add_locations({
|
||||
location_name: location_data.address for location_name, location_data in location_data_table.items()
|
||||
if location_data.region == region_name and location_data.can_create(self)
|
||||
if location_data.region == region_name and location_data.can_create(self.multiworld, self.player)
|
||||
}, CliqueLocation)
|
||||
region.add_exits(region_data_table[region_name].connecting_regions)
|
||||
|
||||
# Place locked locations.
|
||||
for location_name, location_data in locked_locations.items():
|
||||
# Ignore locations we never created.
|
||||
if not location_data.can_create(self):
|
||||
if not location_data.can_create(self.multiworld, self.player):
|
||||
continue
|
||||
|
||||
locked_item = self.create_item(location_data_table[location_name].locked_item)
|
||||
self.get_location(location_name).place_locked_item(locked_item)
|
||||
self.multiworld.get_location(location_name, self.player).place_locked_item(locked_item)
|
||||
|
||||
# Set priority location for the Big Red Button!
|
||||
self.options.priority_locations.value.add("The Big Red Button")
|
||||
self.multiworld.priority_locations[self.player].value.add("The Big Red Button")
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return "A Cool Filler Item (No Satisfaction Guaranteed)"
|
||||
|
||||
def set_rules(self) -> None:
|
||||
button_rule = get_button_rule(self)
|
||||
self.get_location("The Big Red Button").access_rule = button_rule
|
||||
self.get_location("In the Player's Mind").access_rule = button_rule
|
||||
button_rule = get_button_rule(self.multiworld, self.player)
|
||||
self.multiworld.get_location("The Big Red Button", self.player).access_rule = button_rule
|
||||
self.multiworld.get_location("In the Player's Mind", self.player).access_rule = button_rule
|
||||
|
||||
# Do not allow button activations on buttons.
|
||||
self.get_location("The Big Red Button").item_rule = lambda item: item.name != "Button Activation"
|
||||
self.multiworld.get_location("The Big Red Button", self.player).item_rule =\
|
||||
lambda item: item.name != "Button Activation"
|
||||
|
||||
# Completion condition.
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("The Urge to Push", self.player)
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
def fill_slot_data(self):
|
||||
return {
|
||||
"color": self.options.color.current_key
|
||||
"color": getattr(self.multiworld, "color")[self.player].current_key
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
# In almost all cases, we leave boss and enemy randomization up to the static randomizer. But for
|
||||
# Yhorm specifically we need to know where he ends up in order to ensure that the Storm Ruler is
|
||||
# available before his fight.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Set
|
||||
|
||||
|
||||
@dataclass
|
||||
class DS3BossInfo:
|
||||
"""The set of locations a given boss location blocks access to."""
|
||||
|
||||
name: str
|
||||
"""The boss's name."""
|
||||
|
||||
id: int
|
||||
"""The game's ID for this particular boss."""
|
||||
|
||||
dlc: bool = False
|
||||
"""This boss appears in one of the game's DLCs."""
|
||||
|
||||
before_storm_ruler: bool = False
|
||||
"""Whether this location appears before it's possible to get Storm Ruler in vanilla.
|
||||
|
||||
This is used to determine whether it's safe to place Yhorm here if weapons
|
||||
aren't randomized.
|
||||
"""
|
||||
|
||||
locations: Set[str] = field(default_factory=set)
|
||||
"""Additional individual locations that can't be accessed until the boss is dead."""
|
||||
|
||||
|
||||
# Note: the static randomizer splits up some bosses into separate fights for separate phases, each
|
||||
# of which can be individually replaced by Yhorm.
|
||||
all_bosses = [
|
||||
DS3BossInfo("Iudex Gundyr", 4000800, before_storm_ruler = True, locations = {
|
||||
"CA: Coiled Sword - boss drop"
|
||||
}),
|
||||
DS3BossInfo("Vordt of the Boreal Valley", 3000800, before_storm_ruler = True, locations = {
|
||||
"HWL: Soul of Boreal Valley Vordt"
|
||||
}),
|
||||
DS3BossInfo("Curse-rotted Greatwood", 3100800, locations = {
|
||||
"US: Soul of the Rotted Greatwood",
|
||||
"US: Transposing Kiln - boss drop",
|
||||
"US: Wargod Wooden Shield - Pit of Hollows",
|
||||
"FS: Hawkwood's Shield - gravestone after Hawkwood leaves",
|
||||
"FS: Sunset Shield - by grave after killing Hodrick w/Sirris",
|
||||
"US: Sunset Helm - Pit of Hollows after killing Hodrick w/Sirris",
|
||||
"US: Sunset Armor - pit of hollows after killing Hodrick w/Sirris",
|
||||
"US: Sunset Gauntlets - pit of hollows after killing Hodrick w/Sirris",
|
||||
"US: Sunset Leggings - pit of hollows after killing Hodrick w/Sirris",
|
||||
"FS: Sunless Talisman - Sirris, kill GA boss",
|
||||
"FS: Sunless Veil - shop, Sirris quest, kill GA boss",
|
||||
"FS: Sunless Armor - shop, Sirris quest, kill GA boss",
|
||||
"FS: Sunless Gauntlets - shop, Sirris quest, kill GA boss",
|
||||
"FS: Sunless Leggings - shop, Sirris quest, kill GA boss",
|
||||
}),
|
||||
DS3BossInfo("Crystal Sage", 3300850, locations = {
|
||||
"RS: Soul of a Crystal Sage",
|
||||
"FS: Sage's Big Hat - shop after killing RS boss",
|
||||
"FS: Hawkwood's Shield - gravestone after Hawkwood leaves",
|
||||
}),
|
||||
DS3BossInfo("Deacons of the Deep", 3500800, locations = {
|
||||
"CD: Soul of the Deacons of the Deep",
|
||||
"CD: Small Doll - boss drop",
|
||||
"CD: Archdeacon White Crown - boss room after killing boss",
|
||||
"CD: Archdeacon Holy Garb - boss room after killing boss",
|
||||
"CD: Archdeacon Skirt - boss room after killing boss",
|
||||
"FS: Hawkwood's Shield - gravestone after Hawkwood leaves",
|
||||
}),
|
||||
DS3BossInfo("Abyss Watchers", 3300801, before_storm_ruler = True, locations = {
|
||||
"FK: Soul of the Blood of the Wolf",
|
||||
"FK: Cinders of a Lord - Abyss Watcher",
|
||||
"FS: Undead Legion Helm - shop after killing FK boss",
|
||||
"FS: Undead Legion Armor - shop after killing FK boss",
|
||||
"FS: Undead Legion Gauntlet - shop after killing FK boss",
|
||||
"FS: Undead Legion Leggings - shop after killing FK boss",
|
||||
"FS: Farron Ring - Hawkwood",
|
||||
"FS: Hawkwood's Shield - gravestone after Hawkwood leaves",
|
||||
}),
|
||||
DS3BossInfo("High Lord Wolnir", 3800800, before_storm_ruler = True, locations = {
|
||||
"CC: Soul of High Lord Wolnir",
|
||||
"FS: Wolnir's Crown - shop after killing CC boss",
|
||||
"CC: Homeward Bone - Irithyll bridge",
|
||||
"CC: Pontiff's Right Eye - Irithyll bridge, miniboss drop",
|
||||
}),
|
||||
DS3BossInfo("Pontiff Sulyvahn", 3700850, locations = {
|
||||
"IBV: Soul of Pontiff Sulyvahn",
|
||||
}),
|
||||
DS3BossInfo("Old Demon King", 3800830, locations = {
|
||||
"SL: Soul of the Old Demon King",
|
||||
}),
|
||||
DS3BossInfo("Aldrich, Devourer of Gods", 3700800, locations = {
|
||||
"AL: Soul of Aldrich",
|
||||
"AL: Cinders of a Lord - Aldrich",
|
||||
"FS: Smough's Helm - shop after killing AL boss",
|
||||
"FS: Smough's Armor - shop after killing AL boss",
|
||||
"FS: Smough's Gauntlets - shop after killing AL boss",
|
||||
"FS: Smough's Leggings - shop after killing AL boss",
|
||||
"AL: Sun Princess Ring - dark cathedral, after boss",
|
||||
"FS: Leonhard's Garb - shop after killing Leonhard",
|
||||
"FS: Leonhard's Gauntlets - shop after killing Leonhard",
|
||||
"FS: Leonhard's Trousers - shop after killing Leonhard",
|
||||
}),
|
||||
DS3BossInfo("Dancer of the Boreal Valley", 3000899, locations = {
|
||||
"HWL: Soul of the Dancer",
|
||||
"FS: Dancer's Crown - shop after killing LC entry boss",
|
||||
"FS: Dancer's Armor - shop after killing LC entry boss",
|
||||
"FS: Dancer's Gauntlets - shop after killing LC entry boss",
|
||||
"FS: Dancer's Leggings - shop after killing LC entry boss",
|
||||
}),
|
||||
DS3BossInfo("Dragonslayer Armour", 3010800, locations = {
|
||||
"LC: Soul of Dragonslayer Armour",
|
||||
"FS: Morne's Helm - shop after killing Eygon or LC boss",
|
||||
"FS: Morne's Armor - shop after killing Eygon or LC boss",
|
||||
"FS: Morne's Gauntlets - shop after killing Eygon or LC boss",
|
||||
"FS: Morne's Leggings - shop after killing Eygon or LC boss",
|
||||
"LC: Titanite Chunk - down stairs after boss",
|
||||
}),
|
||||
DS3BossInfo("Consumed King Oceiros", 3000830, locations = {
|
||||
"CKG: Soul of Consumed Oceiros",
|
||||
"CKG: Titanite Scale - tomb, chest #1",
|
||||
"CKG: Titanite Scale - tomb, chest #2",
|
||||
"CKG: Drakeblood Helm - tomb, after killing AP mausoleum NPC",
|
||||
"CKG: Drakeblood Armor - tomb, after killing AP mausoleum NPC",
|
||||
"CKG: Drakeblood Gauntlets - tomb, after killing AP mausoleum NPC",
|
||||
"CKG: Drakeblood Leggings - tomb, after killing AP mausoleum NPC",
|
||||
}),
|
||||
DS3BossInfo("Champion Gundyr", 4000830, locations = {
|
||||
"UG: Soul of Champion Gundyr",
|
||||
"FS: Gundyr's Helm - shop after killing UG boss",
|
||||
"FS: Gundyr's Armor - shop after killing UG boss",
|
||||
"FS: Gundyr's Gauntlets - shop after killing UG boss",
|
||||
"FS: Gundyr's Leggings - shop after killing UG boss",
|
||||
"UG: Hornet Ring - environs, right of main path after killing FK boss",
|
||||
"UG: Chaos Blade - environs, left of shrine",
|
||||
"UG: Blacksmith Hammer - shrine, Andre's room",
|
||||
"UG: Eyes of a Fire Keeper - shrine, Irina's room",
|
||||
"UG: Coiled Sword Fragment - shrine, dead bonfire",
|
||||
"UG: Soul of a Crestfallen Knight - environs, above shrine entrance",
|
||||
"UG: Life Ring+3 - shrine, behind big throne",
|
||||
"UG: Ring of Steel Protection+1 - environs, behind bell tower",
|
||||
"FS: Ring of Sacrifice - Yuria shop",
|
||||
"UG: Ember - shop",
|
||||
"UG: Priestess Ring - shop",
|
||||
"UG: Wolf Knight Helm - shop after killing FK boss",
|
||||
"UG: Wolf Knight Armor - shop after killing FK boss",
|
||||
"UG: Wolf Knight Gauntlets - shop after killing FK boss",
|
||||
"UG: Wolf Knight Leggings - shop after killing FK boss",
|
||||
}),
|
||||
DS3BossInfo("Ancient Wyvern", 3200800),
|
||||
DS3BossInfo("King of the Storm", 3200850, locations = {
|
||||
"AP: Soul of the Nameless King",
|
||||
"FS: Golden Crown - shop after killing AP boss",
|
||||
"FS: Dragonscale Armor - shop after killing AP boss",
|
||||
"FS: Golden Bracelets - shop after killing AP boss",
|
||||
"FS: Dragonscale Waistcloth - shop after killing AP boss",
|
||||
"AP: Titanite Slab - plaza",
|
||||
"AP: Covetous Gold Serpent Ring+2 - plaza",
|
||||
"AP: Dragonslayer Helm - plaza",
|
||||
"AP: Dragonslayer Armor - plaza",
|
||||
"AP: Dragonslayer Gauntlets - plaza",
|
||||
"AP: Dragonslayer Leggings - plaza",
|
||||
}),
|
||||
DS3BossInfo("Nameless King", 3200851, locations = {
|
||||
"AP: Soul of the Nameless King",
|
||||
"FS: Golden Crown - shop after killing AP boss",
|
||||
"FS: Dragonscale Armor - shop after killing AP boss",
|
||||
"FS: Golden Bracelets - shop after killing AP boss",
|
||||
"FS: Dragonscale Waistcloth - shop after killing AP boss",
|
||||
"AP: Titanite Slab - plaza",
|
||||
"AP: Covetous Gold Serpent Ring+2 - plaza",
|
||||
"AP: Dragonslayer Helm - plaza",
|
||||
"AP: Dragonslayer Armor - plaza",
|
||||
"AP: Dragonslayer Gauntlets - plaza",
|
||||
"AP: Dragonslayer Leggings - plaza",
|
||||
}),
|
||||
DS3BossInfo("Lothric, Younger Prince", 3410830, locations = {
|
||||
"GA: Soul of the Twin Princes",
|
||||
"GA: Cinders of a Lord - Lothric Prince",
|
||||
}),
|
||||
DS3BossInfo("Lorian, Elder Prince", 3410832, locations = {
|
||||
"GA: Soul of the Twin Princes",
|
||||
"GA: Cinders of a Lord - Lothric Prince",
|
||||
"FS: Lorian's Helm - shop after killing GA boss",
|
||||
"FS: Lorian's Armor - shop after killing GA boss",
|
||||
"FS: Lorian's Gauntlets - shop after killing GA boss",
|
||||
"FS: Lorian's Leggings - shop after killing GA boss",
|
||||
}),
|
||||
DS3BossInfo("Champion's Gravetender and Gravetender Greatwolf", 4500860, dlc = True,
|
||||
locations = {"PW1: Valorheart - boss drop"}),
|
||||
DS3BossInfo("Sister Friede", 4500801, dlc = True, locations = {
|
||||
"PW2: Soul of Sister Friede",
|
||||
"PW2: Titanite Slab - boss drop",
|
||||
"PW1: Titanite Slab - Corvian",
|
||||
"FS: Ordained Hood - shop after killing PW2 boss",
|
||||
"FS: Ordained Dress - shop after killing PW2 boss",
|
||||
"FS: Ordained Trousers - shop after killing PW2 boss",
|
||||
}),
|
||||
DS3BossInfo("Blackflame Friede", 4500800, dlc = True, locations = {
|
||||
"PW2: Soul of Sister Friede",
|
||||
"PW1: Titanite Slab - Corvian",
|
||||
"FS: Ordained Hood - shop after killing PW2 boss",
|
||||
"FS: Ordained Dress - shop after killing PW2 boss",
|
||||
"FS: Ordained Trousers - shop after killing PW2 boss",
|
||||
}),
|
||||
DS3BossInfo("Demon Prince", 5000801, dlc = True, locations = {
|
||||
"DH: Soul of the Demon Prince",
|
||||
"DH: Small Envoy Banner - boss drop",
|
||||
}),
|
||||
DS3BossInfo("Halflight, Spear of the Church", 5100800, dlc = True, locations = {
|
||||
"RC: Titanite Slab - mid boss drop",
|
||||
"RC: Titanite Slab - ashes, NPC drop",
|
||||
"RC: Titanite Slab - ashes, mob drop",
|
||||
"RC: Filianore's Spear Ornament - mid boss drop",
|
||||
"RC: Crucifix of the Mad King - ashes, NPC drop",
|
||||
"RC: Shira's Crown - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Armor - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Gloves - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Trousers - Shira's room after killing ashes NPC",
|
||||
}),
|
||||
DS3BossInfo("Darkeater Midir", 5100850, dlc = True, locations = {
|
||||
"RC: Soul of Darkeater Midir",
|
||||
"RC: Spears of the Church - hidden boss drop",
|
||||
}),
|
||||
DS3BossInfo("Slave Knight Gael 1", 5110801, dlc = True, locations = {
|
||||
"RC: Soul of Slave Knight Gael",
|
||||
"RC: Blood of the Dark Soul - end boss drop",
|
||||
# These are accessible before you trigger the boss, but once you do you
|
||||
# have to beat it before getting them.
|
||||
"RC: Titanite Slab - ashes, mob drop",
|
||||
"RC: Titanite Slab - ashes, NPC drop",
|
||||
"RC: Sacred Chime of Filianore - ashes, NPC drop",
|
||||
"RC: Crucifix of the Mad King - ashes, NPC drop",
|
||||
"RC: Shira's Crown - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Armor - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Gloves - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Trousers - Shira's room after killing ashes NPC",
|
||||
}),
|
||||
DS3BossInfo("Slave Knight Gael 2", 5110800, dlc = True, locations = {
|
||||
"RC: Soul of Slave Knight Gael",
|
||||
"RC: Blood of the Dark Soul - end boss drop",
|
||||
# These are accessible before you trigger the boss, but once you do you
|
||||
# have to beat it before getting them.
|
||||
"RC: Titanite Slab - ashes, mob drop",
|
||||
"RC: Titanite Slab - ashes, NPC drop",
|
||||
"RC: Sacred Chime of Filianore - ashes, NPC drop",
|
||||
"RC: Crucifix of the Mad King - ashes, NPC drop",
|
||||
"RC: Shira's Crown - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Armor - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Gloves - Shira's room after killing ashes NPC",
|
||||
"RC: Shira's Trousers - Shira's room after killing ashes NPC",
|
||||
}),
|
||||
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"
|
||||
}),
|
||||
]
|
||||
|
||||
default_yhorm_location = DS3BossInfo("Yhorm the Giant", 3900800, locations = {
|
||||
"PC: Soul of Yhorm the Giant",
|
||||
"PC: Cinders of a Lord - Yhorm the Giant",
|
||||
"PC: Siegbräu - Siegward after killing boss",
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,78 +1,80 @@
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
import typing
|
||||
|
||||
from Options import Choice, DeathLink, DefaultOnToggle, ExcludeLocations, NamedRange, OptionDict, \
|
||||
OptionGroup, PerGameCommonOptions, Range, Removed, Toggle
|
||||
|
||||
## Game Options
|
||||
from Options import Toggle, DefaultOnToggle, Option, Range, Choice, ItemDict, DeathLink
|
||||
|
||||
|
||||
class EarlySmallLothricBanner(Choice):
|
||||
"""Force Small Lothric Banner into an early sphere in your world or across all worlds."""
|
||||
display_name = "Early Small Lothric Banner"
|
||||
option_off = 0
|
||||
option_early_global = 1
|
||||
option_early_local = 2
|
||||
default = option_off
|
||||
class RandomizeWeaponLocations(DefaultOnToggle):
|
||||
"""Randomizes weapons (+76 locations)"""
|
||||
display_name = "Randomize Weapon Locations"
|
||||
|
||||
|
||||
class LateBasinOfVowsOption(Choice):
|
||||
"""Guarantee that you don't need to enter Lothric Castle until later in the run.
|
||||
|
||||
- **Off:** You may have to enter Lothric Castle and the areas beyond it immediately after High
|
||||
Wall of Lothric.
|
||||
- **After Small Lothric Banner:** You may have to enter Lothric Castle after Catacombs of
|
||||
Carthus.
|
||||
- **After Small Doll:** You won't have to enter Lothric Castle until after Irithyll of the
|
||||
Boreal Valley.
|
||||
"""
|
||||
display_name = "Late Basin of Vows"
|
||||
option_off = 0
|
||||
alias_false = 0
|
||||
option_after_small_lothric_banner = 1
|
||||
alias_true = 1
|
||||
option_after_small_doll = 2
|
||||
class RandomizeShieldLocations(DefaultOnToggle):
|
||||
"""Randomizes shields (+24 locations)"""
|
||||
display_name = "Randomize Shield Locations"
|
||||
|
||||
|
||||
class LateDLCOption(Choice):
|
||||
"""Guarantee that you don't need to enter the DLC until later in the run.
|
||||
|
||||
- **Off:** You may have to enter the DLC after Catacombs of Carthus.
|
||||
- **After Small Doll:** You may have to enter the DLC after Irithyll of the Boreal Valley.
|
||||
- **After Basin:** You won't have to enter the DLC until after Lothric Castle.
|
||||
"""
|
||||
display_name = "Late DLC"
|
||||
option_off = 0
|
||||
alias_false = 0
|
||||
option_after_small_doll = 1
|
||||
alias_true = 1
|
||||
option_after_basin = 2
|
||||
class RandomizeArmorLocations(DefaultOnToggle):
|
||||
"""Randomizes armor pieces (+97 locations)"""
|
||||
display_name = "Randomize Armor Locations"
|
||||
|
||||
|
||||
class EnableDLCOption(Toggle):
|
||||
"""Include DLC locations, items, and enemies in the randomized pools.
|
||||
|
||||
To use this option, you must own both the "Ashes of Ariandel" and the "Ringed City" DLCs.
|
||||
"""
|
||||
display_name = "Enable DLC"
|
||||
class RandomizeRingLocations(DefaultOnToggle):
|
||||
"""Randomizes rings (+49 locations)"""
|
||||
display_name = "Randomize Ring Locations"
|
||||
|
||||
|
||||
class EnableNGPOption(Toggle):
|
||||
"""Include items and locations exclusive to NG+ cycles."""
|
||||
display_name = "Enable NG+"
|
||||
class RandomizeSpellLocations(DefaultOnToggle):
|
||||
"""Randomizes spells (+18 locations)"""
|
||||
display_name = "Randomize Spell Locations"
|
||||
|
||||
|
||||
## Equipment
|
||||
|
||||
class RandomizeStartingLoadout(DefaultOnToggle):
|
||||
"""Randomizes the equipment characters begin with."""
|
||||
display_name = "Randomize Starting Loadout"
|
||||
class RandomizeKeyLocations(DefaultOnToggle):
|
||||
"""Randomizes items which unlock doors or bypass barriers"""
|
||||
display_name = "Randomize Key Locations"
|
||||
|
||||
|
||||
class RequireOneHandedStartingWeapons(DefaultOnToggle):
|
||||
"""Require starting equipment to be usable one-handed."""
|
||||
display_name = "Require One-Handed Starting Weapons"
|
||||
class RandomizeBossSoulLocations(DefaultOnToggle):
|
||||
"""Randomizes Boss Souls (+18 Locations)"""
|
||||
display_name = "Randomize Boss Soul Locations"
|
||||
|
||||
|
||||
class RandomizeNPCLocations(Toggle):
|
||||
"""Randomizes friendly NPC drops (meaning you will probably have to kill them) (+14 locations)"""
|
||||
display_name = "Randomize NPC Locations"
|
||||
|
||||
|
||||
class RandomizeMiscLocations(Toggle):
|
||||
"""Randomizes miscellaneous items (ashes, tomes, scrolls, etc.) to the pool. (+36 locations)"""
|
||||
display_name = "Randomize Miscellaneous Locations"
|
||||
|
||||
|
||||
class RandomizeHealthLocations(Toggle):
|
||||
"""Randomizes health upgrade items. (+21 locations)"""
|
||||
display_name = "Randomize Health Upgrade Locations"
|
||||
|
||||
|
||||
class RandomizeProgressiveLocationsOption(Toggle):
|
||||
"""Randomizes upgrade materials and consumables such as the titanite shards, firebombs, resin, etc...
|
||||
|
||||
Instead of specific locations, these are progressive, so Titanite Shard #1 is the first titanite shard
|
||||
you pick up, regardless of whether it's from an enemy drop late in the game or an item on the ground in the
|
||||
first 5 minutes."""
|
||||
display_name = "Randomize Progressive Locations"
|
||||
|
||||
|
||||
class PoolTypeOption(Choice):
|
||||
"""Changes which non-progression items you add to the pool
|
||||
|
||||
Shuffle: Items are picked from the locations being randomized
|
||||
Various: Items are picked from a list of all items in the game, but are the same type of item they replace"""
|
||||
display_name = "Pool Type"
|
||||
option_shuffle = 0
|
||||
option_various = 1
|
||||
|
||||
|
||||
class GuaranteedItemsOption(ItemDict):
|
||||
"""Guarantees that the specified items will be in the item pool"""
|
||||
display_name = "Guaranteed Items"
|
||||
|
||||
|
||||
class AutoEquipOption(Toggle):
|
||||
@@ -81,56 +83,47 @@ class AutoEquipOption(Toggle):
|
||||
|
||||
|
||||
class LockEquipOption(Toggle):
|
||||
"""Lock the equipment slots so you cannot change your armor or your left/right weapons.
|
||||
|
||||
Works great with the Auto-equip option.
|
||||
"""
|
||||
"""Lock the equipment slots so you cannot change your armor or your left/right weapons. Works great with the
|
||||
Auto-equip option."""
|
||||
display_name = "Lock Equipment Slots"
|
||||
|
||||
|
||||
class NoEquipLoadOption(Toggle):
|
||||
"""Disable the equip load constraint from the game."""
|
||||
display_name = "No Equip Load"
|
||||
|
||||
|
||||
class NoWeaponRequirementsOption(Toggle):
|
||||
"""Disable the weapon requirements by removing any movement or damage penalties, permitting you
|
||||
to use any weapon early.
|
||||
"""
|
||||
"""Disable the weapon requirements by removing any movement or damage penalties.
|
||||
Permitting you to use any weapon early"""
|
||||
display_name = "No Weapon Requirements"
|
||||
|
||||
|
||||
class NoSpellRequirementsOption(Toggle):
|
||||
"""Disable the spell requirements permitting you to use any spell."""
|
||||
"""Disable the spell requirements permitting you to use any spell"""
|
||||
display_name = "No Spell Requirements"
|
||||
|
||||
|
||||
## Weapons
|
||||
class NoEquipLoadOption(Toggle):
|
||||
"""Disable the equip load constraint from the game"""
|
||||
display_name = "No Equip Load"
|
||||
|
||||
|
||||
class RandomizeInfusionOption(Toggle):
|
||||
"""Enable this option to infuse a percentage of the pool of weapons and shields."""
|
||||
display_name = "Randomize Infusion"
|
||||
|
||||
|
||||
class RandomizeInfusionPercentageOption(NamedRange):
|
||||
"""The percentage of weapons/shields in the pool to be infused if Randomize Infusion is toggled.
|
||||
"""
|
||||
class RandomizeInfusionPercentageOption(Range):
|
||||
"""The percentage of weapons/shields in the pool to be infused if Randomize Infusion is toggled"""
|
||||
display_name = "Percentage of Infused Weapons"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
default = 33
|
||||
# 3/155 weapons are infused in the base game, or about 2%
|
||||
special_range_names = {"similar to base game": 2}
|
||||
|
||||
|
||||
class RandomizeWeaponLevelOption(Choice):
|
||||
"""Enable this option to upgrade a percentage of the pool of weapons to a random value between
|
||||
the minimum and maximum levels defined.
|
||||
"""Enable this option to upgrade a percentage of the pool of weapons to a random value between the minimum and
|
||||
maximum levels defined.
|
||||
|
||||
- **All:** All weapons are eligible, both basic and epic
|
||||
- **Basic:** Only weapons that can be upgraded to +10
|
||||
- **Epic:** Only weapons that can be upgraded to +5
|
||||
"""
|
||||
All: All weapons are eligible, both basic and epic
|
||||
Basic: Only weapons that can be upgraded to +10
|
||||
Epic: Only weapons that can be upgraded to +5"""
|
||||
display_name = "Randomize Weapon Level"
|
||||
option_none = 0
|
||||
option_all = 1
|
||||
@@ -139,7 +132,7 @@ class RandomizeWeaponLevelOption(Choice):
|
||||
|
||||
|
||||
class RandomizeWeaponLevelPercentageOption(Range):
|
||||
"""The percentage of weapons in the pool to be upgraded if randomize weapons level is toggled."""
|
||||
"""The percentage of weapons in the pool to be upgraded if randomize weapons level is toggled"""
|
||||
display_name = "Percentage of Randomized Weapons"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
@@ -147,7 +140,7 @@ class RandomizeWeaponLevelPercentageOption(Range):
|
||||
|
||||
|
||||
class MinLevelsIn5WeaponPoolOption(Range):
|
||||
"""The minimum upgraded value of a weapon in the pool of weapons that can only reach +5."""
|
||||
"""The minimum upgraded value of a weapon in the pool of weapons that can only reach +5"""
|
||||
display_name = "Minimum Level of +5 Weapons"
|
||||
range_start = 0
|
||||
range_end = 5
|
||||
@@ -155,7 +148,7 @@ class MinLevelsIn5WeaponPoolOption(Range):
|
||||
|
||||
|
||||
class MaxLevelsIn5WeaponPoolOption(Range):
|
||||
"""The maximum upgraded value of a weapon in the pool of weapons that can only reach +5."""
|
||||
"""The maximum upgraded value of a weapon in the pool of weapons that can only reach +5"""
|
||||
display_name = "Maximum Level of +5 Weapons"
|
||||
range_start = 0
|
||||
range_end = 5
|
||||
@@ -163,7 +156,7 @@ class MaxLevelsIn5WeaponPoolOption(Range):
|
||||
|
||||
|
||||
class MinLevelsIn10WeaponPoolOption(Range):
|
||||
"""The minimum upgraded value of a weapon in the pool of weapons that can reach +10."""
|
||||
"""The minimum upgraded value of a weapon in the pool of weapons that can reach +10"""
|
||||
display_name = "Minimum Level of +10 Weapons"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
@@ -171,308 +164,72 @@ class MinLevelsIn10WeaponPoolOption(Range):
|
||||
|
||||
|
||||
class MaxLevelsIn10WeaponPoolOption(Range):
|
||||
"""The maximum upgraded value of a weapon in the pool of weapons that can reach +10."""
|
||||
"""The maximum upgraded value of a weapon in the pool of weapons that can reach +10"""
|
||||
display_name = "Maximum Level of +10 Weapons"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 10
|
||||
|
||||
|
||||
## Item Smoothing
|
||||
|
||||
class SmoothSoulItemsOption(DefaultOnToggle):
|
||||
"""Distribute soul items in a similar order as the base game.
|
||||
|
||||
By default, soul items will be distributed totally randomly. If this is set, less valuable soul
|
||||
items will generally appear in earlier spheres and more valuable ones will generally appear
|
||||
later.
|
||||
"""
|
||||
display_name = "Smooth Soul Items"
|
||||
|
||||
|
||||
class SmoothUpgradeItemsOption(DefaultOnToggle):
|
||||
"""Distribute upgrade items in a similar order as the base game.
|
||||
|
||||
By default, upgrade items will be distributed totally randomly. If this is set, lower-level
|
||||
upgrade items will generally appear in earlier spheres and higher-level ones will generally
|
||||
appear later.
|
||||
"""
|
||||
display_name = "Smooth Upgrade Items"
|
||||
|
||||
|
||||
class SmoothUpgradedWeaponsOption(DefaultOnToggle):
|
||||
"""Distribute upgraded weapons in a similar order as the base game.
|
||||
|
||||
By default, upgraded weapons will be distributed totally randomly. If this is set, lower-level
|
||||
weapons will generally appear in earlier spheres and higher-level ones will generally appear
|
||||
later.
|
||||
"""
|
||||
display_name = "Smooth Upgraded Weapons"
|
||||
|
||||
|
||||
### Enemies
|
||||
|
||||
class RandomizeEnemiesOption(DefaultOnToggle):
|
||||
"""Randomize enemy and boss placements."""
|
||||
display_name = "Randomize Enemies"
|
||||
|
||||
|
||||
class SimpleEarlyBossesOption(DefaultOnToggle):
|
||||
"""Avoid replacing Iudex Gundyr and Vordt with late bosses.
|
||||
|
||||
This excludes all bosses after Dancer of the Boreal Valley from these two boss fights. Disable
|
||||
it for a chance at a much harder early game.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "Simple Early Bosses"
|
||||
|
||||
|
||||
class ScaleEnemiesOption(DefaultOnToggle):
|
||||
"""Scale randomized enemy stats to match the areas in which they appear.
|
||||
|
||||
Disabling this will tend to make the early game much more difficult and the late game much
|
||||
easier.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "Scale Enemies"
|
||||
|
||||
|
||||
class RandomizeMimicsWithEnemiesOption(Toggle):
|
||||
"""Mix Mimics into the main enemy pool.
|
||||
|
||||
If this is enabled, Mimics will be replaced by normal enemies who drop the Mimic rewards on
|
||||
death, and Mimics will be placed randomly in place of normal enemies. It's recommended to enable
|
||||
Impatient Mimics as well if you enable this.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "Randomize Mimics With Enemies"
|
||||
|
||||
|
||||
class RandomizeSmallCrystalLizardsWithEnemiesOption(Toggle):
|
||||
"""Mix small Crystal Lizards into the main enemy pool.
|
||||
|
||||
If this is enabled, Crystal Lizards will be replaced by normal enemies who drop the Crystal
|
||||
Lizard rewards on death, and Crystal Lizards will be placed randomly in place of normal enemies.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "Randomize Small Crystal Lizards With Enemies"
|
||||
|
||||
|
||||
class ReduceHarmlessEnemiesOption(Toggle):
|
||||
"""Reduce the frequency that "harmless" enemies appear.
|
||||
|
||||
Enable this to add a bit of extra challenge. This severely limits the number of enemies that are
|
||||
slow to aggro, slow to attack, and do very little damage that appear in the enemy pool.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "Reduce Harmless Enemies"
|
||||
|
||||
|
||||
class AllChestsAreMimicsOption(Toggle):
|
||||
"""Replace all chests with mimics that drop the same items.
|
||||
|
||||
If "Randomize Mimics With Enemies" is set, these chests will instead be replaced with random
|
||||
enemies that drop the same items.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "All Chests Are Mimics"
|
||||
|
||||
|
||||
class ImpatientMimicsOption(Toggle):
|
||||
"""Mimics attack as soon as you get close instead of waiting for you to open them.
|
||||
|
||||
This is ignored unless enemies are randomized.
|
||||
"""
|
||||
display_name = "Impatient Mimics"
|
||||
|
||||
|
||||
class RandomEnemyPresetOption(OptionDict):
|
||||
"""The YAML preset for the static enemy randomizer.
|
||||
|
||||
See the static randomizer documentation in `randomizer\\presets\\README.txt` for details.
|
||||
Include this as nested YAML. For example:
|
||||
|
||||
.. code-block:: YAML
|
||||
|
||||
random_enemy_preset:
|
||||
RemoveSource: Ancient Wyvern; Darkeater Midir
|
||||
DontRandomize: Iudex Gundyr
|
||||
"""
|
||||
display_name = "Random Enemy Preset"
|
||||
supports_weighting = False
|
||||
default = {}
|
||||
|
||||
valid_keys = ["Description", "RecommendFullRandomization", "RecommendNoEnemyProgression",
|
||||
"OopsAll", "Boss", "Miniboss", "Basic", "BuffBasicEnemiesAsBosses",
|
||||
"DontRandomize", "RemoveSource", "Enemies"]
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: Dict[str, Any]) -> str:
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
## Item & Location
|
||||
|
||||
class DS3ExcludeLocations(ExcludeLocations):
|
||||
"""Prevent these locations from having an important item."""
|
||||
default = frozenset({"Hidden", "Small Crystal Lizards", "Upgrade", "Small Souls", "Miscellaneous"})
|
||||
|
||||
|
||||
class ExcludedLocationBehaviorOption(Choice):
|
||||
"""How to choose items for excluded locations in DS3.
|
||||
|
||||
- **Allow Useful:** Excluded locations can't have progression items, but they can have useful
|
||||
items.
|
||||
- **Forbid Useful:** Neither progression items nor useful items can be placed in excluded
|
||||
locations.
|
||||
- **Do Not Randomize:** Excluded locations always contain the same item as in vanilla Dark Souls
|
||||
III.
|
||||
|
||||
A "progression item" is anything that's required to unlock another location in some game. A
|
||||
"useful item" is something each game defines individually, usually items that are quite
|
||||
desirable but not strictly necessary.
|
||||
"""
|
||||
display_name = "Excluded Locations Behavior"
|
||||
option_allow_useful = 1
|
||||
option_forbid_useful = 2
|
||||
option_do_not_randomize = 3
|
||||
default = 2
|
||||
|
||||
|
||||
class MissableLocationBehaviorOption(Choice):
|
||||
"""Which items can be placed in locations that can be permanently missed.
|
||||
|
||||
- **Allow Useful:** Missable locations can't have progression items, but they can have useful
|
||||
items.
|
||||
- **Forbid Useful:** Neither progression items nor useful items can be placed in missable
|
||||
locations.
|
||||
- **Do Not Randomize:** Missable locations always contain the same item as in vanilla Dark Souls
|
||||
III.
|
||||
|
||||
A "progression item" is anything that's required to unlock another location in some game. A
|
||||
"useful item" is something each game defines individually, usually items that are quite
|
||||
desirable but not strictly necessary.
|
||||
"""
|
||||
display_name = "Missable Locations Behavior"
|
||||
option_allow_useful = 1
|
||||
option_forbid_useful = 2
|
||||
option_do_not_randomize = 3
|
||||
default = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class DarkSouls3Options(PerGameCommonOptions):
|
||||
# Game Options
|
||||
early_banner: EarlySmallLothricBanner
|
||||
late_basin_of_vows: LateBasinOfVowsOption
|
||||
late_dlc: LateDLCOption
|
||||
death_link: DeathLink
|
||||
enable_dlc: EnableDLCOption
|
||||
enable_ngp: EnableNGPOption
|
||||
|
||||
# Equipment
|
||||
random_starting_loadout: RandomizeStartingLoadout
|
||||
require_one_handed_starting_weapons: RequireOneHandedStartingWeapons
|
||||
auto_equip: AutoEquipOption
|
||||
lock_equip: LockEquipOption
|
||||
no_equip_load: NoEquipLoadOption
|
||||
no_weapon_requirements: NoWeaponRequirementsOption
|
||||
no_spell_requirements: NoSpellRequirementsOption
|
||||
|
||||
# Weapons
|
||||
randomize_infusion: RandomizeInfusionOption
|
||||
randomize_infusion_percentage: RandomizeInfusionPercentageOption
|
||||
randomize_weapon_level: RandomizeWeaponLevelOption
|
||||
randomize_weapon_level_percentage: RandomizeWeaponLevelPercentageOption
|
||||
min_levels_in_5: MinLevelsIn5WeaponPoolOption
|
||||
max_levels_in_5: MaxLevelsIn5WeaponPoolOption
|
||||
min_levels_in_10: MinLevelsIn10WeaponPoolOption
|
||||
max_levels_in_10: MaxLevelsIn10WeaponPoolOption
|
||||
|
||||
# Item Smoothing
|
||||
smooth_soul_items: SmoothSoulItemsOption
|
||||
smooth_upgrade_items: SmoothUpgradeItemsOption
|
||||
smooth_upgraded_weapons: SmoothUpgradedWeaponsOption
|
||||
|
||||
# Enemies
|
||||
randomize_enemies: RandomizeEnemiesOption
|
||||
simple_early_bosses: SimpleEarlyBossesOption
|
||||
scale_enemies: ScaleEnemiesOption
|
||||
randomize_mimics_with_enemies: RandomizeMimicsWithEnemiesOption
|
||||
randomize_small_crystal_lizards_with_enemies: RandomizeSmallCrystalLizardsWithEnemiesOption
|
||||
reduce_harmless_enemies: ReduceHarmlessEnemiesOption
|
||||
all_chests_are_mimics: AllChestsAreMimicsOption
|
||||
impatient_mimics: ImpatientMimicsOption
|
||||
random_enemy_preset: RandomEnemyPresetOption
|
||||
|
||||
# Item & Location
|
||||
exclude_locations: DS3ExcludeLocations
|
||||
excluded_location_behavior: ExcludedLocationBehaviorOption
|
||||
missable_location_behavior: MissableLocationBehaviorOption
|
||||
|
||||
# Removed
|
||||
pool_type: Removed
|
||||
enable_weapon_locations: Removed
|
||||
enable_shield_locations: Removed
|
||||
enable_armor_locations: Removed
|
||||
enable_ring_locations: Removed
|
||||
enable_spell_locations: Removed
|
||||
enable_key_locations: Removed
|
||||
enable_boss_locations: Removed
|
||||
enable_npc_locations: Removed
|
||||
enable_misc_locations: Removed
|
||||
enable_health_upgrade_locations: Removed
|
||||
enable_progressive_locations: Removed
|
||||
guaranteed_items: Removed
|
||||
excluded_locations: Removed
|
||||
missable_locations: Removed
|
||||
|
||||
|
||||
option_groups = [
|
||||
OptionGroup("Equipment", [
|
||||
RandomizeStartingLoadout,
|
||||
RequireOneHandedStartingWeapons,
|
||||
AutoEquipOption,
|
||||
LockEquipOption,
|
||||
NoEquipLoadOption,
|
||||
NoWeaponRequirementsOption,
|
||||
NoSpellRequirementsOption,
|
||||
]),
|
||||
OptionGroup("Weapons", [
|
||||
RandomizeInfusionOption,
|
||||
RandomizeInfusionPercentageOption,
|
||||
RandomizeWeaponLevelOption,
|
||||
RandomizeWeaponLevelPercentageOption,
|
||||
MinLevelsIn5WeaponPoolOption,
|
||||
MaxLevelsIn5WeaponPoolOption,
|
||||
MinLevelsIn10WeaponPoolOption,
|
||||
MaxLevelsIn10WeaponPoolOption,
|
||||
]),
|
||||
OptionGroup("Item Smoothing", [
|
||||
SmoothSoulItemsOption,
|
||||
SmoothUpgradeItemsOption,
|
||||
SmoothUpgradedWeaponsOption,
|
||||
]),
|
||||
OptionGroup("Enemies", [
|
||||
RandomizeEnemiesOption,
|
||||
SimpleEarlyBossesOption,
|
||||
ScaleEnemiesOption,
|
||||
RandomizeMimicsWithEnemiesOption,
|
||||
RandomizeSmallCrystalLizardsWithEnemiesOption,
|
||||
ReduceHarmlessEnemiesOption,
|
||||
AllChestsAreMimicsOption,
|
||||
ImpatientMimicsOption,
|
||||
RandomEnemyPresetOption,
|
||||
]),
|
||||
OptionGroup("Item & Location Options", [
|
||||
DS3ExcludeLocations,
|
||||
ExcludedLocationBehaviorOption,
|
||||
MissableLocationBehaviorOption,
|
||||
])
|
||||
]
|
||||
class EarlySmallLothricBanner(Choice):
|
||||
"""This option makes it so the user can choose to force the Small Lothric Banner into an early sphere in their world or
|
||||
into an early sphere across all worlds."""
|
||||
display_name = "Early Small Lothric Banner"
|
||||
option_off = 0
|
||||
option_early_global = 1
|
||||
option_early_local = 2
|
||||
default = option_off
|
||||
|
||||
|
||||
class LateBasinOfVowsOption(Toggle):
|
||||
"""This option makes it so the Basin of Vows is still randomized, but guarantees you that you wont have to venture into
|
||||
Lothric Castle to find your Small Lothric Banner to get out of High Wall of Lothric. So you may find Basin of Vows early,
|
||||
but you wont have to fight Dancer to find your Small Lothric Banner."""
|
||||
display_name = "Late Basin of Vows"
|
||||
|
||||
|
||||
class LateDLCOption(Toggle):
|
||||
"""This option makes it so you are guaranteed to find your Small Doll without having to venture off into the DLC,
|
||||
effectively putting anything in the DLC in logic after finding both Contraption Key and Small Doll,
|
||||
and being able to get into Irithyll of the Boreal Valley."""
|
||||
display_name = "Late DLC"
|
||||
|
||||
|
||||
class EnableDLCOption(Toggle):
|
||||
"""To use this option, you must own both the ASHES OF ARIANDEL and the RINGED CITY DLC"""
|
||||
display_name = "Enable DLC"
|
||||
|
||||
|
||||
dark_souls_options: typing.Dict[str, Option] = {
|
||||
"enable_weapon_locations": RandomizeWeaponLocations,
|
||||
"enable_shield_locations": RandomizeShieldLocations,
|
||||
"enable_armor_locations": RandomizeArmorLocations,
|
||||
"enable_ring_locations": RandomizeRingLocations,
|
||||
"enable_spell_locations": RandomizeSpellLocations,
|
||||
"enable_key_locations": RandomizeKeyLocations,
|
||||
"enable_boss_locations": RandomizeBossSoulLocations,
|
||||
"enable_npc_locations": RandomizeNPCLocations,
|
||||
"enable_misc_locations": RandomizeMiscLocations,
|
||||
"enable_health_upgrade_locations": RandomizeHealthLocations,
|
||||
"enable_progressive_locations": RandomizeProgressiveLocationsOption,
|
||||
"pool_type": PoolTypeOption,
|
||||
"guaranteed_items": GuaranteedItemsOption,
|
||||
"auto_equip": AutoEquipOption,
|
||||
"lock_equip": LockEquipOption,
|
||||
"no_weapon_requirements": NoWeaponRequirementsOption,
|
||||
"randomize_infusion": RandomizeInfusionOption,
|
||||
"randomize_infusion_percentage": RandomizeInfusionPercentageOption,
|
||||
"randomize_weapon_level": RandomizeWeaponLevelOption,
|
||||
"randomize_weapon_level_percentage": RandomizeWeaponLevelPercentageOption,
|
||||
"min_levels_in_5": MinLevelsIn5WeaponPoolOption,
|
||||
"max_levels_in_5": MaxLevelsIn5WeaponPoolOption,
|
||||
"min_levels_in_10": MinLevelsIn10WeaponPoolOption,
|
||||
"max_levels_in_10": MaxLevelsIn10WeaponPoolOption,
|
||||
"early_banner": EarlySmallLothricBanner,
|
||||
"late_basin_of_vows": LateBasinOfVowsOption,
|
||||
"late_dlc": LateDLCOption,
|
||||
"no_spell_requirements": NoSpellRequirementsOption,
|
||||
"no_equip_load": NoEquipLoadOption,
|
||||
"death_link": DeathLink,
|
||||
"enable_dlc": EnableDLCOption,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
# python -m worlds.dark_souls_3.detailed_location_descriptions \
|
||||
# worlds/dark_souls_3/detailed_location_descriptions.py
|
||||
#
|
||||
# This script downloads the static randomizer's descriptions for each location and adds them to
|
||||
# the location documentation.
|
||||
|
||||
from collections import defaultdict
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from .Locations import location_dictionary
|
||||
|
||||
|
||||
location_re = re.compile(r'^([A-Z0-9]+): (.*?)(?:$| - )')
|
||||
|
||||
if __name__ == '__main__':
|
||||
# TODO: update this to the main branch of the main randomizer once Archipelago support is merged
|
||||
url = 'https://raw.githubusercontent.com/nex3/SoulsRandomizers/archipelago-server/dist/Base/annotations.txt'
|
||||
response = requests.get(url)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Got {response.status_code} when downloading static randomizer locations")
|
||||
annotations = yaml.load(response.text, Loader=yaml.Loader)
|
||||
|
||||
static_to_archi_regions = {
|
||||
area['Name']: area['Archipelago']
|
||||
for area in annotations['Areas']
|
||||
}
|
||||
|
||||
descriptions_by_key = {slot['Key']: slot['Text'] for slot in annotations['Slots']}
|
||||
|
||||
# A map from (region, item name) pairs to all the descriptions that match those pairs.
|
||||
descriptions_by_location = defaultdict(list)
|
||||
|
||||
# A map from item names to all the descriptions for those item names.
|
||||
descriptions_by_item = defaultdict(list)
|
||||
|
||||
for slot in annotations['Slots']:
|
||||
region = static_to_archi_regions[slot['Area']]
|
||||
for item in slot['DebugText']:
|
||||
name = item.split(" - ")[0]
|
||||
descriptions_by_location[(region, name)].append(slot['Text'])
|
||||
descriptions_by_item[name].append(slot['Text'])
|
||||
counts_by_location = {
|
||||
location: len(descriptions) for (location, descriptions) in descriptions_by_location.items()
|
||||
}
|
||||
|
||||
location_names_to_descriptions = {}
|
||||
for location in location_dictionary.values():
|
||||
if location.ap_code is None: continue
|
||||
if location.static:
|
||||
location_names_to_descriptions[location.name] = descriptions_by_key[location.static]
|
||||
continue
|
||||
|
||||
match = location_re.match(location.name)
|
||||
if not match:
|
||||
raise Exception(f"Location name \"{location.name}\" doesn't match expected format.")
|
||||
|
||||
item_candidates = descriptions_by_item[match[2]]
|
||||
if len(item_candidates) == 1:
|
||||
location_names_to_descriptions[location.name] = item_candidates[0]
|
||||
continue
|
||||
|
||||
key = (match[1], match[2])
|
||||
if key not in descriptions_by_location:
|
||||
raise Exception(f'No static randomizer location found matching "{match[1]}: {match[2]}".')
|
||||
|
||||
candidates = descriptions_by_location[key]
|
||||
if len(candidates) == 0:
|
||||
raise Exception(
|
||||
f'There are only {counts_by_location[key]} locations in the static randomizer ' +
|
||||
f'matching "{match[1]}: {match[2]}", but there are more in Archipelago.'
|
||||
)
|
||||
|
||||
location_names_to_descriptions[location.name] = candidates.pop(0)
|
||||
|
||||
table = "<table><tr><th>Location name</th><th>Detailed description</th>\n"
|
||||
for (name, description) in sorted(
|
||||
location_names_to_descriptions.items(),
|
||||
key = lambda pair: pair[0]
|
||||
):
|
||||
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:
|
||||
original = f.read()
|
||||
start_flag = "<!-- begin location table -->\n"
|
||||
start = original.index(start_flag) + len(start_flag)
|
||||
end = original.index("<!-- end location table -->")
|
||||
|
||||
f.seek(0)
|
||||
f.write(original[:start] + table + original[end:])
|
||||
f.truncate()
|
||||
|
||||
print("Updated docs/locations_en.md!")
|
||||
@@ -1,201 +1,28 @@
|
||||
# Dark Souls III
|
||||
|
||||
Game Page | [Items] | [Locations]
|
||||
|
||||
[Items]: /tutorial/Dark%20Souls%20III/items/en
|
||||
[Locations]: /tutorial/Dark%20Souls%20III/locations/en
|
||||
|
||||
## What do I need to do to randomize DS3?
|
||||
|
||||
See full instructions on [the setup page].
|
||||
|
||||
[the setup page]: /tutorial/Dark%20Souls%20III/setup/en
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
The [player options page for this game][options] contains all the options you
|
||||
need to configure and export a config file.
|
||||
|
||||
[options]: ../player-options
|
||||
The [player options page for this game](../player-options) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
1. All item locations are randomized, including those in the overworld, in
|
||||
shops, and dropped by enemies. Most locations can contain games from other
|
||||
worlds, and any items from your world can appear in other players' worlds.
|
||||
Items that can be picked up from static corpses, taken from chests, or earned from defeating enemies or NPCs can be
|
||||
randomized. Common pickups like titanite shards or firebombs can be randomized as "progressive" items. That is, the
|
||||
location "Titanite Shard #5" is the fifth titanite shard you pick up, no matter where it was from. This is also what
|
||||
happens when you randomize Estus Shards and Undead Bone Shards.
|
||||
|
||||
2. By default, all enemies and bosses are randomized. This can be disabled by
|
||||
setting "Randomize Enemies" to false.
|
||||
It's also possible to randomize the upgrade level of weapons and shields as well as their infusions (if they can have
|
||||
one). Additionally, there are options that can make the randomized experience more convenient or more interesting, such as
|
||||
removing weapon requirements or auto-equipping whatever equipment you most recently received.
|
||||
|
||||
3. By default, the starting equipment for each class is randomized. This can be
|
||||
disabled by setting "Randomize Starting Loadout" to false.
|
||||
The goal is to find the four "Cinders of a Lord" items randomized into the multiworld and defeat the Soul of Cinder.
|
||||
|
||||
4. By setting the "Randomize Weapon Level" or "Randomize Infusion" options, you
|
||||
can randomize whether the weapons you find will be upgraded or infused.
|
||||
## What Dark Souls III items can appear in other players' worlds?
|
||||
|
||||
There are also options that can make playing the game more convenient or
|
||||
bring a new experience, like removing equip loads or auto-equipping weapons as
|
||||
you pick them up. Check out [the options page][options] for more!
|
||||
Practically anything can be found in other worlds including pieces of armor, upgraded weapons, key items, consumables,
|
||||
spells, upgrade materials, etc...
|
||||
|
||||
## What's the goal?
|
||||
## What does another world's item look like in Dark Souls III?
|
||||
|
||||
Your goal is to find the four "Cinders of a Lord" items randomized into the
|
||||
multiworld and defeat the boss in the Kiln of the First Flame.
|
||||
|
||||
## Do I have to check every item in every area?
|
||||
|
||||
Dark Souls III has about 1500 item locations, which is a lot of checks for a
|
||||
single run! But you don't necessarily need to check all of them. Locations that
|
||||
you can potentially miss, such as rewards for failable quests or soul
|
||||
transposition items, will _never_ have items required for any game to progress.
|
||||
The following types of locations are also guaranteed not to contain progression
|
||||
items by default:
|
||||
|
||||
* **Hidden:** Locations that are particularly difficult to find, such as behind
|
||||
illusory walls, down hidden drops, and so on. Does not include large locations
|
||||
like Untended Graves or Archdragon Peak.
|
||||
|
||||
* **Small Crystal Lizards:** Drops from small crystal lizards.
|
||||
|
||||
* **Upgrade:** Locations that contain upgrade items in vanilla, including
|
||||
titanite, gems, and Shriving Stones.
|
||||
|
||||
* **Small Souls:** Locations that contain soul items in vanilla, not including
|
||||
boss souls.
|
||||
|
||||
* **Miscellaneous:** Locations that contain generic stackable items in vanilla,
|
||||
such as arrows, firebombs, buffs, and so on.
|
||||
|
||||
You can customize which locations are guaranteed not to contain progression
|
||||
items by setting the `exclude_locations` field in your YAML to the [location
|
||||
groups] you want to omit. For example, this is the default setting but without
|
||||
"Hidden" so that hidden locations can contain progression items:
|
||||
|
||||
[location groups]: /tutorial/Dark%20Souls%20III/locations/en#location-groups
|
||||
|
||||
```yaml
|
||||
Dark Souls III:
|
||||
exclude_locations:
|
||||
- Small Crystal Lizards
|
||||
- Upgrade
|
||||
- Small Souls
|
||||
- Miscellaneous
|
||||
```
|
||||
|
||||
This allows _all_ non-missable locations to have progression items, if you're in
|
||||
for the long haul:
|
||||
|
||||
```yaml
|
||||
Dark Souls III:
|
||||
exclude_locations: []
|
||||
```
|
||||
|
||||
## What if I don't want to do the whole game?
|
||||
|
||||
If you want a shorter DS3 randomizer experience, you can exclude entire regions
|
||||
from containing progression items. The items and enemies from those regions will
|
||||
still be included in the randomization pool, but none of them will be mandatory.
|
||||
For example, the following configuration just requires you to play the game
|
||||
through Irithyll of the Boreal Valley:
|
||||
|
||||
```yaml
|
||||
Dark Souls III:
|
||||
# Enable the DLC so it's included in the randomization pool
|
||||
enable_dlc: true
|
||||
|
||||
exclude_locations:
|
||||
# Exclude late-game and DLC regions
|
||||
- Anor Londo
|
||||
- Lothric Castle
|
||||
- Consumed King's Garden
|
||||
- Untended Graves
|
||||
- Grand Archives
|
||||
- Archdragon Peak
|
||||
- Painted World of Ariandel
|
||||
- Dreg Heap
|
||||
- Ringed City
|
||||
|
||||
# Default exclusions
|
||||
- Hidden
|
||||
- Small Crystal Lizards
|
||||
- Upgrade
|
||||
- Small Souls
|
||||
- Miscellaneous
|
||||
```
|
||||
|
||||
## Where can I learn more about Dark Souls III locations?
|
||||
|
||||
Location names have to pack a lot of information into very little space. To
|
||||
better understand them, check out the [location guide], which explains all the
|
||||
names used in locations and provides more detailed descriptions for each
|
||||
individual location.
|
||||
|
||||
[location guide]: /tutorial/Dark%20Souls%20III/locations/en
|
||||
|
||||
## Where can I learn more about Dark Souls III items?
|
||||
|
||||
Check out the [item guide], which explains the named groups available for items.
|
||||
|
||||
[item guide]: /tutorial/Dark%20Souls%20III/items/en
|
||||
|
||||
## What's new from 2.x.x?
|
||||
|
||||
Version 3.0.0 of the Dark Souls III Archipelago client has a number of
|
||||
substantial differences with the older 2.x.x versions. Improvements include:
|
||||
|
||||
* Support for randomizing all item locations, not just unique items.
|
||||
|
||||
* Support for randomizing items in shops, starting loadouts, Path of the Dragon,
|
||||
and more.
|
||||
|
||||
* Built-in integration with the enemy randomizer, including consistent seeding
|
||||
for races.
|
||||
|
||||
* Support for the latest patch for Dark Souls III, 1.15.2. Older patches are
|
||||
*not* supported.
|
||||
|
||||
* Optional smooth distribution for upgrade items, upgraded weapons, and soul
|
||||
items so you're more likely to see weaker items earlier and more powerful
|
||||
items later.
|
||||
|
||||
* More detailed location names that indicate where a location is, not just what
|
||||
it replaces.
|
||||
|
||||
* Other players' item names are visible in DS3.
|
||||
|
||||
* If you pick up items while static, they'll still send once you reconnect.
|
||||
|
||||
However, 2.x.x YAMLs are not compatible with 3.0.0. You'll need to [generate a
|
||||
new YAML configuration] for use with 3.x.x.
|
||||
|
||||
[generating a new YAML configuration]: /games/Dark%20Souls%20III/player-options
|
||||
|
||||
The following options have been removed:
|
||||
|
||||
* `enable_boss_locations` is now controlled by the `soul_locations` option.
|
||||
|
||||
* `enable_progressive_locations` was removed because all locations are now
|
||||
individually randomized rather than replaced with a progressive list.
|
||||
|
||||
* `pool_type` has been removed. Since there are no longer any non-randomized
|
||||
items in randomized categories, there's not a meaningful distinction between
|
||||
"shuffle" and "various" mode.
|
||||
|
||||
* `enable_*_locations` options have all been removed. Instead, you can now add
|
||||
[location group names] to the `exclude_locations` option to prevent them from
|
||||
containing important items.
|
||||
|
||||
[location group names]: /tutorial/Dark%20Souls%20III/locations/en#location-groups
|
||||
|
||||
By default, the Hidden, Small Crystal Lizards, Upgrade, Small Souls, and
|
||||
Miscellaneous groups are in `exclude_locations`. Once you've chosen your
|
||||
excluded locations, you can set `excluded_locations: unrandomized` to preserve
|
||||
the default vanilla item placements for all excluded locations.
|
||||
|
||||
* `guaranteed_items`: In almost all cases, all items from the base game are now
|
||||
included somewhere in the multiworld.
|
||||
|
||||
In addition, the following options have changed:
|
||||
|
||||
* The location names used in options like `exclude_locations` have changed. See
|
||||
the [location guide] for a full description.
|
||||
In Dark Souls III, items which are sent to other worlds appear as Prism Stones.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Dark Souls III Items
|
||||
|
||||
[Game Page] | Items | [Locations]
|
||||
|
||||
[Game Page]: /games/Dark%20Souls%20III/info/en
|
||||
[Locations]: /tutorial/Dark%20Souls%20III/locations/en
|
||||
|
||||
## Item Groups
|
||||
|
||||
The Dark Souls III randomizer supports a number of item group names, which can
|
||||
be used in YAML options like `local_items` to refer to many items at once:
|
||||
|
||||
* **Progression:** Items which unlock locations.
|
||||
* **Cinders:** All four Cinders of a Lord. Once you have these four, you can
|
||||
fight Soul of Cinder and win the game.
|
||||
* **Miscellaneous:** Generic stackable items, such as arrows, firebombs, buffs,
|
||||
and so on.
|
||||
* **Unique:** Items that are unique per NG cycle, such as scrolls, keys, ashes,
|
||||
and so on. Doesn't include equipment, spells, or souls.
|
||||
* **Boss Souls:** Souls that can be traded with Ludleth, including Soul of
|
||||
Rosaria.
|
||||
* **Small Souls:** Soul items, not including boss souls.
|
||||
* **Upgrade:** Upgrade items, including titanite, gems, and Shriving Stones.
|
||||
* **Healing:** Undead Bone Shards and Estus Shards.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,49 +7,48 @@
|
||||
|
||||
## Optional Software
|
||||
|
||||
- Map tracker not yet updated for 3.0.0
|
||||
- [Dark Souls III Maptracker Pack](https://github.com/Br00ty/DS3_AP_Maptracker/releases/latest), for use with [Poptracker](https://github.com/black-sliver/PopTracker/releases)
|
||||
|
||||
## Setting Up
|
||||
## General Concept
|
||||
|
||||
First, download the client from the link above. It doesn't need to go into any particular directory;
|
||||
it'll automatically locate _Dark Souls III_ in your Steam installation folder.
|
||||
<span style="color:#ff7800">
|
||||
**This mod can ban you permanently from the FromSoftware servers if used online.**
|
||||
</span>
|
||||
The Dark Souls III AP Client is a dinput8.dll triggered when launching Dark Souls III. This .dll file will launch a command
|
||||
prompt where you can read information about your run and write any command to interact with the Archipelago server.
|
||||
|
||||
Version 3.0.0 of the randomizer _only_ supports the latest version of _Dark Souls III_, 1.15.2. This
|
||||
is the latest version, so you don't need to do any downpatching! However, if you've already
|
||||
downpatched your game to use an older version of the randomizer, you'll need to reinstall the latest
|
||||
version before using this version.
|
||||
This client has only been tested with the Official Steam version of the game at version 1.15. It does not matter which DLCs are installed. However, you will have to downpatch your Dark Souls III installation from current patch.
|
||||
|
||||
### One-Time Setup
|
||||
## Downpatching Dark Souls III
|
||||
|
||||
Before you first connect to a multiworld, you need to generate the local data files for your world's
|
||||
randomized item and (optionally) enemy locations. You only need to do this once per multiworld.
|
||||
To downpatch DS3 for use with Archipelago, use the following instructions from the speedsouls wiki database.
|
||||
|
||||
1. Before you first connect to a multiworld, run `randomizer\DS3Randomizer.exe`.
|
||||
1. Launch Steam (in online mode).
|
||||
2. Press the Windows Key + R. This will open the Run window.
|
||||
3. Open the Steam console by typing the following string: `steam://open/console`. Steam should now open in Console Mode.
|
||||
4. Insert the string of the depot you wish to download. For the AP-supported v1.15, you will want to use: `download_depot 374320 374321 4471176929659548333`.
|
||||
5. Steam will now download the depot. Note: There is no progress bar for the download in Steam, but it is still downloading in the background.
|
||||
6. Back up your existing game executable (`DarkSoulsIII.exe`) found in `\Steam\steamapps\common\DARK SOULS III\Game`. Easiest way to do this is to move it to another directory. If you have file extensions enabled, you can instead rename the executable to `DarkSoulsIII.exe.bak`.
|
||||
7. Return to the Steam console. Once the download is complete, it should say so along with the temporary local directory in which the depot has been stored. This is usually something like `\Steam\steamapps\content\app_XXXXXX\depot_XXXXXX`.
|
||||
8. Take the `DarkSoulsIII.exe` from that folder and place it in `\Steam\steamapps\common\DARK SOULS III\Game`.
|
||||
9. Back up and delete your save file (`DS30000.sl2`) in AppData. AppData is hidden by default. To locate it, press Windows Key + R, type `%appdata%` and hit enter. Alternatively: open File Explorer > View > Hidden Items and follow `C:\Users\<your_username>\AppData\Roaming\DarkSoulsIII\<numbers>`.
|
||||
10. If you did all these steps correctly, you should be able to confirm your game version in the upper-left corner after launching Dark Souls III.
|
||||
|
||||
2. Put in your Archipelago room address (usually something like `archipelago.gg:12345`), your player
|
||||
name (also known as your "slot name"), and your password if you have one.
|
||||
|
||||
3. Click "Load" and wait a minute or two.
|
||||
## Installing the Archipelago mod
|
||||
|
||||
### Running and Connecting the Game
|
||||
Get the `dinput8.dll` from the [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) and
|
||||
add it at the root folder of your game (e.g. `SteamLibrary\steamapps\common\DARK SOULS III\Game`)
|
||||
|
||||
To run _Dark Souls III_ in Archipelago mode:
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Start Steam. **Do not run in offline mode.** The mod will make sure you don't connect to the
|
||||
DS3 servers, and running Steam in offline mode will make certain scripted invaders fail to spawn.
|
||||
1. Run Steam in offline mode to avoid being banned.
|
||||
2. Launch Dark Souls III.
|
||||
3. Type in `/connect {SERVER_IP}:{SERVER_PORT} {SLOT_NAME} password:{PASSWORD}` in the "Windows Command Prompt" that opened. For example: `/connect archipelago.gg:38281 "Example Name" password:"Example Password"`. The password parameter is only necessary if your game requires one.
|
||||
4. Once connected, create a new game, choose a class and wait for the others before starting.
|
||||
5. You can quit and launch at anytime during a game.
|
||||
|
||||
2. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that
|
||||
you can use to interact with the Archipelago server.
|
||||
|
||||
3. Type `/connect {SERVER_IP}:{SERVER_PORT} {SLOT_NAME}` into the command prompt, with the
|
||||
appropriate values filled in. For example: `/connect archipelago.gg:24242 PlayerName`.
|
||||
|
||||
4. Start playing as normal. An "Archipelago connected" message will appear onscreen once you have
|
||||
control of your character and the connection is established.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Where do I get a config file?
|
||||
## Where do I get a config file?
|
||||
|
||||
The [Player Options](/games/Dark%20Souls%20III/player-options) page on the website allows you to
|
||||
configure your personal options and export them into a config file.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user