Compare commits

..

2 Commits

Author SHA1 Message Date
NewSoupVi
a2e1c66f1d update docs as well 2024-08-09 00:29:45 +02:00
NewSoupVi
03442621f4 Add slot to datastorage set response 2024-08-09 00:11:11 +02:00
348 changed files with 20459 additions and 84441 deletions

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
worlds/blasphemous/region_data.py linguist-generated=true

View File

@@ -37,13 +37,12 @@ jobs:
- {version: '3.9'} - {version: '3.9'}
- {version: '3.10'} - {version: '3.10'}
- {version: '3.11'} - {version: '3.11'}
- {version: '3.12'}
include: include:
- python: {version: '3.8'} # win7 compat - python: {version: '3.8'} # win7 compat
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.11'} # current
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.11'} # current
os: macos-latest os: macos-latest
steps: steps:
@@ -71,7 +70,7 @@ jobs:
os: os:
- ubuntu-latest - ubuntu-latest
python: python:
- {version: '3.12'} # current - {version: '3.11'} # current
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import collections import collections
import copy
import itertools import itertools
import functools import functools
import logging import logging
@@ -11,10 +12,8 @@ from argparse import Namespace
from collections import Counter, deque from collections import Counter, deque
from collections.abc import Collection, MutableSequence from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple, from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
Optional, Protocol, Set, Tuple, Union, Type) TypedDict, Union, Type, ClassVar
from typing_extensions import NotRequired, TypedDict
import NetUtils import NetUtils
import Options import Options
@@ -24,16 +23,16 @@ if typing.TYPE_CHECKING:
from worlds import AutoWorld from worlds import AutoWorld
class Group(TypedDict): class Group(TypedDict, total=False):
name: str name: str
game: str game: str
world: "AutoWorld.World" world: "AutoWorld.World"
players: AbstractSet[int] players: Set[int]
item_pool: NotRequired[Set[str]] item_pool: Set[str]
replacement_items: NotRequired[Dict[int, Optional[str]]] replacement_items: Dict[int, Optional[str]]
local_items: NotRequired[Set[str]] local_items: Set[str]
non_local_items: NotRequired[Set[str]] non_local_items: Set[str]
link_replacement: NotRequired[bool] link_replacement: bool
class ThreadBarrierProxy: class ThreadBarrierProxy:
@@ -50,11 +49,6 @@ class ThreadBarrierProxy:
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.") "Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
class HasNameAndPlayer(Protocol):
name: str
player: int
class MultiWorld(): class MultiWorld():
debug_types = False debug_types = False
player_name: Dict[int, str] player_name: Dict[int, str]
@@ -163,7 +157,7 @@ class MultiWorld():
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
for player in range(1, players + 1): 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 self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_items', []) set_player_attr('plando_items', [])
set_player_attr('plando_texts', {}) set_player_attr('plando_texts', {})
@@ -172,13 +166,13 @@ class MultiWorld():
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
self.worlds = {} self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " 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 self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]: def get_all_ids(self) -> Tuple[int, ...]:
return self.player_ids + tuple(self.groups) 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. """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.""" If a group of this name already exists, the set of players is extended instead of creating a new one."""
from worlds import AutoWorld from worlds import AutoWorld
@@ -202,7 +196,7 @@ class MultiWorld():
return new_id, new_group 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"]} 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): 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"]), "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 current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set() pool = set()
local_items = set() local_items = set()
@@ -342,8 +336,6 @@ class MultiWorld():
region = Region("Menu", group_id, self, "ItemLink") region = Region("Menu", group_id, self, "ItemLink")
self.regions.append(region) self.regions.append(region)
locations = region.locations 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: for item in self.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0) count = common_item_count.get(item.player, {}).get(item.name, 0)
if count: if count:
@@ -397,7 +389,7 @@ class MultiWorld():
return tuple(world for player, world in self.worlds.items() if return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name) 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)})' 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: def get_player_name(self, player: int) -> str:
@@ -439,7 +431,7 @@ class MultiWorld():
subworld = self.worlds[player] subworld = self.worlds[player]
for item in subworld.get_pre_fill_items(): for item in subworld.get_pre_fill_items():
subworld.collect(ret, item) subworld.collect(ret, item)
ret.sweep_for_advancements() ret.sweep_for_events()
if use_cache: if use_cache:
self._all_state = ret self._all_state = ret
@@ -448,7 +440,7 @@ class MultiWorld():
def get_items(self) -> List[Item]: def get_items(self) -> List[Item]:
return [loc.item for loc in self.get_filled_locations()] + self.itempool 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: if resolve_group_locations:
player_groups = self.get_player_groups(player) player_groups = self.get_player_groups(player)
return [location for location in self.get_locations() if return [location for location in self.get_locations() if
@@ -457,7 +449,7 @@ class MultiWorld():
return [location for location in self.get_locations() if return [location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player] 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 return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player) location.item and location.item.name == item and location.item.player == player)
@@ -550,9 +542,9 @@ class MultiWorld():
return True return True
state = starting_state.copy() state = starting_state.copy()
else: else:
state = CollectionState(self) if self.has_beaten_game(self.state):
if self.has_beaten_game(state):
return True return True
state = CollectionState(self)
prog_locations = {location for location in self.get_locations() if location.item prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked} and location.item.advancement and location not in state.locations_checked}
@@ -625,7 +617,8 @@ class MultiWorld():
def location_relevant(location: Location) -> bool: def location_relevant(location: Location) -> bool:
"""Determine if this location is relevant to sweep.""" """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: def all_done() -> bool:
"""Check if all access rules are fulfilled""" """Check if all access rules are fulfilled"""
@@ -670,7 +663,7 @@ class CollectionState():
multiworld: MultiWorld multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]] reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]] blocked_connections: Dict[int, Set[Entrance]]
advancements: Set[Location] events: Set[Location]
path: Dict[Union[Region, Entrance], PathValue] path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location] locations_checked: Set[Location]
stale: Dict[int, bool] stale: Dict[int, bool]
@@ -682,7 +675,7 @@ class CollectionState():
self.multiworld = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} 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.blocked_connections = {player: set() for player in parent.get_all_ids()}
self.advancements = set() self.events = set()
self.path = {} self.path = {}
self.locations_checked = set() self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()} self.stale = {player: True for player in parent.get_all_ids()}
@@ -694,25 +687,17 @@ class CollectionState():
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
self.stale[player] = False self.stale[player] = False
world: AutoWorld.World = self.multiworld.worlds[player]
reachable_regions = self.reachable_regions[player] reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
queue = deque(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 # init on first call - this can't be done on construction since the regions don't exist yet
if start not in reachable_regions: if start not in reachable_regions:
reachable_regions.add(start) reachable_regions.add(start)
self.blocked_connections[player].update(start.exits) blocked_connections.update(start.exits)
queue.extend(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 # run BFS on all connections, and keep track of those blocked by missing items
while queue: while queue:
connection = queue.popleft() connection = queue.popleft()
@@ -732,39 +717,16 @@ class CollectionState():
if new_entrance in blocked_connections and new_entrance not in queue: if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance) 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: def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld) ret = CollectionState(self.multiworld)
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()} ret.prog_items = copy.deepcopy(self.prog_items)
ret.reachable_regions = {player: region_set.copy() for player, region_set in ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions.items()} self.reachable_regions}
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
self.blocked_connections.items()} self.blocked_connections}
ret.advancements = self.advancements.copy() ret.events = copy.copy(self.events)
ret.path = self.path.copy() ret.path = copy.copy(self.path)
ret.locations_checked = self.locations_checked.copy() ret.locations_checked = copy.copy(self.locations_checked)
for function in self.additional_copy_functions: for function in self.additional_copy_functions:
ret = function(self, ret) ret = function(self, ret)
return ret return ret
@@ -795,24 +757,19 @@ class CollectionState():
return self.multiworld.get_region(spot, player).can_reach(self) return self.multiworld.get_region(spot, player).can_reach(self)
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None: 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: if locations is None:
locations = self.multiworld.get_filled_locations() locations = self.multiworld.get_filled_locations()
reachable_advancements = True reachable_events = True
# since the loop has a good chance to run more than once, only filter the advancements once # 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.advancements} locations = {location for location in locations if location.advancement and location not in self.events}
while reachable_advancements: while reachable_events:
reachable_advancements = {location for location in locations if location.can_reach(self)} reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_advancements locations -= reachable_events
for advancement in reachable_advancements: for event in reachable_events:
self.advancements.add(advancement) self.events.add(event)
assert isinstance(advancement.item, Item), "tried to collect Event with no Item" assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(advancement.item, True, advancement) self.collect(event.item, True, event)
# item name related # item name related
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -846,7 +803,7 @@ class CollectionState():
if found >= count: if found >= count:
return True return True
return False return False
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool: 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. """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.""" Ignores duplicates of the same item."""
@@ -861,7 +818,7 @@ class CollectionState():
def count_from_list(self, items: Iterable[str], player: int) -> int: def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state.""" """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) 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: 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.""" """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) return sum(self.prog_items[player][item_name] > 0 for item_name in items)
@@ -907,16 +864,20 @@ class CollectionState():
) )
# Item related # 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: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item) 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 self.stale[item.player] = True
if changed and not prevent_sweep: if changed and not event:
self.sweep_for_advancements() self.sweep_for_events()
return changed return changed
@@ -940,7 +901,7 @@ class Entrance:
addresses = None addresses = None
target = 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.name = name
self.parent_region = parent self.parent_region = parent
self.player = player self.player = player
@@ -960,6 +921,9 @@ class Entrance:
region.entrances.append(self) region.entrances.append(self)
def __repr__(self): def __repr__(self):
return self.__str__()
def __str__(self):
multiworld = self.parent_region.multiworld if self.parent_region else None 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})' return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1085,7 +1049,7 @@ class Region:
self.locations.append(location_type(self.player, location, address, self)) self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None, 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. Connects this Region to another Region, placing the provided rule on the connection.
@@ -1125,6 +1089,9 @@ class Region:
rules[connecting_region] if rules and connecting_region in rules else None) rules[connecting_region] if rules and connecting_region in rules else None)
def __repr__(self): 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})' return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -1143,9 +1110,9 @@ class Location:
locked: bool = False locked: bool = False
show_in_spoiler: bool = True show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT 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) 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 item: Optional[Item] = None
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None): def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
@@ -1154,20 +1121,16 @@ class Location:
self.address = address self.address = address
self.parent_region = parent self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (( return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
self.always_allow(state, item) or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and item.name not in state.multiworld.worlds[item.player].options.non_local_items and self.item_rule(item)
) or ( and (not check_access or self.can_reach(state))))
(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: def can_reach(self, state: CollectionState) -> bool:
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average # self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, "Can't reach location without region" assert self.parent_region, "Can't reach location without region"
return self.parent_region.can_reach(state) and self.access_rule(state) return self.access_rule(state) and self.parent_region.can_reach(state)
def place_locked_item(self, item: Item): def place_locked_item(self, item: Item):
if self.item: if self.item:
@@ -1177,6 +1140,9 @@ class Location:
self.locked = True self.locked = True
def __repr__(self): 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 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})' return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -1198,7 +1164,7 @@ class Location:
@property @property
def native_item(self) -> bool: def native_item(self) -> bool:
"""Returns True if the item in this location matches game.""" """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 @property
def hint_text(self) -> str: def hint_text(self) -> str:
@@ -1209,7 +1175,7 @@ class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc, filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical 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 skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch. # Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items. # Typically currency or other counted items.
@@ -1281,6 +1247,9 @@ class Item:
return hash((self.name, self.player)) return hash((self.name, self.player))
def __repr__(self) -> str: 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: 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 self.location.parent_region.multiworld.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})" return f"{self.name} (Player {self.player})"
@@ -1358,9 +1327,9 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable, # 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 # 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))): for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set() to_delete = set()
for location in sphere: for location in sphere:
# we remove the item at location and check if game is still beatable # 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, logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
@@ -1378,7 +1347,7 @@ class Spoiler:
sphere -= to_delete sphere -= to_delete
# second phase, sphere 0 # 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): 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) logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
multiworld.precollected_items[item.player].remove(item) multiworld.precollected_items[item.player].remove(item)
@@ -1459,7 +1428,7 @@ class Spoiler:
# Maybe move the big bomb over to the Event system instead? # Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values() if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
for (_, exit_path) in path): 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))] = \ self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Big Bomb Shop', player)) get_path(state, multiworld.get_region('Big Bomb Shop', player))
else: else:
@@ -1531,9 +1500,9 @@ class Spoiler:
if self.paths: if self.paths:
outfile.write('\n\nPaths:\n\n') outfile.write('\n\nPaths:\n\n')
path_listings: List[str] = [] path_listings = []
for location, path in sorted(self.paths.items()): for location, path in sorted(self.paths.items()):
path_lines: List[str] = [] path_lines = []
for region, exit in path: for region, exit in path:
if exit is not None: if exit is not None:
path_lines.append("{} -> {}".format(region, exit)) path_lines.append("{} -> {}".format(region, exit))

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import sys
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds._bizhawk.context import launch from worlds._bizhawk.context import launch
if __name__ == "__main__": if __name__ == "__main__":
launch(*sys.argv[1:]) launch()

View File

@@ -252,7 +252,7 @@ class CommonContext:
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
ui: typing.Optional["kvui.GameManager"] = None ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_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}) logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]: def run_gui(self):
"""To return the Kivy App class needed for run_gui so it can be overridden before being built""" """Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager from kvui import GameManager
class TextManager(GameManager): class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client" base_title = "Archipelago Text Client"
return TextManager self.ui = TextManager(self)
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_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self): def run_cli(self):
@@ -996,7 +994,7 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser return parser
def run_as_textclient(*args): def run_as_textclient():
class TextContext(CommonContext): class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"} 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 = 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('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url") parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args) args = parser.parse_args()
if args.url: if args.url:
url = urllib.parse.urlparse(args.url) url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago": args.connect = url.netloc
args.connect = url.netloc if url.username:
if url.username: args.name = urllib.parse.unquote(url.username)
args.name = urllib.parse.unquote(url.username) if url.password:
if url.password: args.password = urllib.parse.unquote(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")
colorama.init() colorama.init()
@@ -1056,4 +1051,4 @@ def run_as_textclient(*args):
if __name__ == '__main__': if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING 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
View File

@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None: pass
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)
def _log_fill_progress(name: str, placed: int, total_items: int) -> None: 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() new_state = base_state.copy()
for item in itempool: for item in itempool:
new_state.collect(item, True) new_state.collect(item, True)
new_state.sweep_for_advancements(locations=locations) new_state.sweep_for_events(locations=locations)
return new_state return new_state
@@ -217,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
f"Unfilled locations:\n" f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n" f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\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) item_pool.extend(unplaced_items)
@@ -304,7 +299,7 @@ def remaining_fill(multiworld: MultiWorld,
f"Unfilled locations:\n" f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n" f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\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) itempool.extend(unplaced_items)
@@ -329,8 +324,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item) pool.append(location.item)
state.remove(location.item) state.remove(location.item)
location.item = None location.item = None
if location in state.advancements: if location in state.events:
state.advancements.remove(location) state.events.remove(location)
locations.append(location) locations.append(location)
if pool and locations: if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) 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] = [] early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set() loc_indexes_to_remove: typing.Set[int] = set()
base_state = multiworld.state.copy() 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): for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state): if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY: if loc.progress_type == LocationProgressType.PRIORITY:
@@ -475,26 +470,28 @@ def distribute_items_restrictive(multiworld: MultiWorld,
nonlocal lock_later nonlocal lock_later
lock_later.append(location) lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations: if prioritylocations:
# "priority fill" # "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, 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) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "advancement/progression fill" # "advancement/progression fill"
if panic_method == "swap": if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
name="Progression", single_player_placement=single_player) swap=True,
name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise": elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
name="Progression", single_player_placement=single_player) swap=False,
name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory": elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
allow_partial=True, name="Progression", single_player_placement=single_player) swap=False, allow_partial=True,
name="Progression", single_player_placement=multiworld.players == 1)
if progitempool: if progitempool:
for item in progitempool: for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.") logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
@@ -509,8 +506,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool: if progitempool:
raise FillError( raise FillError(
f"Not enough locations for progression items. " f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.", f"There are {len(progitempool)} more progression items than there are available locations."
multiworld=multiworld,
) )
accessibility_corrections(multiworld, multiworld.state, defaultlocations) accessibility_corrections(multiworld, multiworld.state, defaultlocations)
@@ -527,8 +523,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if excludedlocations: if excludedlocations:
raise FillError( raise FillError(
f"Not enough filler items for excluded locations. " f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.", f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
multiworld=multiworld,
) )
restitempool = filleritempool + usefulitempool restitempool = filleritempool + usefulitempool
@@ -556,7 +551,7 @@ def flood_items(multiworld: MultiWorld) -> None:
progress_done = False progress_done = False
# sweep once to pick up preplaced items # 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 # fill multiworld from top of itempool while we can
while not progress_done: while not progress_done:
@@ -594,7 +589,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None: if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place item_to_place = candidate_item_to_place
else: 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 # find item to replace with progress item
location_list = multiworld.get_reachable_locations() location_list = multiworld.get_reachable_locations()
@@ -744,7 +739,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
), items_to_test): ), items_to_test):
reducing_state.collect(location.item, True, location) 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 multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state): if not multiworld.has_beaten_game(reducing_state):
@@ -827,7 +822,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
warn(warning, force) warn(warning, force)
swept_state = multiworld.state.copy() swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements() swept_state.sweep_for_events()
reachable = frozenset(multiworld.get_reachable_locations(swept_state)) reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)

View File

@@ -43,10 +43,10 @@ def mystery_argparse():
parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path) 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('--log_level', default='info', help='Sets log level')
parser.add_argument("--csv_output", action="store_true", parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help="Output rolled player options to csv (made for async multiworld).") help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument("--plando", default=defaults.plando_options, parser.add_argument('--plando', default=defaults.plando_options,
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"") 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", parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.") help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true", 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.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {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 if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}" 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] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) 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): 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())}") 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 return erargs, seed
@@ -491,7 +511,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
continue continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.") 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: 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": if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights) roll_alttp_settings(ret, game_weights)

View File

@@ -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()

View File

@@ -16,11 +16,10 @@ import multiprocessing
import shlex import shlex
import subprocess import subprocess
import sys import sys
import urllib.parse
import webbrowser import webbrowser
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union from typing import Callable, Sequence, Union, Optional
import Utils import Utils
import settings import settings
@@ -108,81 +107,7 @@ components.extend([
]) ])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: def identify(path: Union[None, str]):
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]]:
if path is None: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -341,7 +266,7 @@ def run_gui():
if file and component: if file and component:
run_component(component, file) run_component(component, file)
else: else:
logging.warning(f"unable to identify component for {file}") logging.warning(f"unable to identify component for {filename}")
def _stop(self, *largs): def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # 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: elif not args:
args = {} args = {}
path = args.get("Patch|Game|Component|url", None) if args.get("Patch|Game|Component", None) is not None:
if path is not None: file, component = identify(args["Patch|Game|Component"])
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file: if file:
args['file'] = file args['file'] = file
if component: if component:
args['component'] = component args['component'] = component
if not 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"]: if args["update_settings"]:
update_settings() update_settings()
if "file" in args: if 'file' in args:
run_component(args["component"], args["file"], *args["args"]) run_component(args["component"], args["file"], *args["args"])
elif "component" in args: elif 'component' in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui() run_gui()
@@ -401,16 +322,12 @@ if __name__ == '__main__':
init_logging('Launcher') init_logging('Launcher')
Utils.freeze_support() Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description='Archipelago Launcher')
description='Archipelago Launcher',
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
)
run_group = parser.add_argument_group("Run") run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true", run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.") help="Update host.yaml and exit.")
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?", run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game, the component name to run, or a url to " help="Pass either a patch file, a generated game or the name of a component to run.")
"connect with.")
run_group.add_argument("args", nargs="*", run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.") help="Arguments to pass to component.")
main(parser.parse_args()) main(parser.parse_args())

View File

@@ -467,8 +467,6 @@ class LinksAwakeningContext(CommonContext):
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient() self.client = LinksAwakeningClient()
self.slot_data = {}
if magpie: if magpie:
self.magpie_enabled = True self.magpie_enabled = True
self.magpie = MagpieBridge() self.magpie = MagpieBridge()
@@ -566,8 +564,6 @@ class LinksAwakeningContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): 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) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker) await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker) await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
except Exception: except Exception:
# Don't let magpie errors take out the client # Don't let magpie errors take out the client
pass pass

View File

@@ -14,7 +14,7 @@ import tkinter as tk
from argparse import Namespace from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob 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 IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse 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" GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object): class AdjusterWorld(object):
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
@@ -243,17 +242,16 @@ def adjustGUI():
from argparse import Namespace from argparse import Namespace
from Utils import __version__ as MWVersion from Utils import __version__ as MWVersion
adjustWindow = Tk() adjustWindow = Tk()
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion) adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow) set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(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) romFrame, romVar = get_rom_frame(adjustWindow)
romDialogFrame = Frame(adjustWindow, padx=8, pady=2) romDialogFrame = Frame(adjustWindow)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust') baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar() romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2) romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
@@ -263,9 +261,9 @@ def adjustGUI():
romVar2.set(rom) romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2) romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
romDialogFrame.pack(side=TOP, expand=False, fill=X) romDialogFrame.pack(side=TOP, expand=True, fill=X)
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8)) baseRomLabel2.pack(side=LEFT)
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1) romEntry2.pack(side=LEFT, expand=True, fill=X)
romSelectButton2.pack(side=LEFT) romSelectButton2.pack(side=LEFT)
def adjustRom(): def adjustRom():
@@ -333,11 +331,12 @@ def adjustGUI():
messagebox.showinfo(title="Success", message="Settings saved to persistent storage") messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom) 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)) adjustButton.pack(side=LEFT, padx=(5,5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings) saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5)) saveButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=TOP, pady=(5,5)) bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow) tkinter_center_window(adjustWindow)
@@ -577,7 +576,7 @@ class AttachTooltip(object):
def get_rom_frame(parent=None): def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP) adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romFrame = Frame(parent, padx=8, pady=8) romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ') baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom) romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar) romEntry = Entry(romFrame, textvariable=romVar)
@@ -597,19 +596,20 @@ def get_rom_frame(parent=None):
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect) romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT) 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) romSelectButton.pack(side=LEFT)
romFrame.pack(side=TOP, fill=X) romFrame.pack(side=TOP, expand=True, fill=X)
return romFrame, romVar return romFrame, romVar
def get_rom_options_frame(parent=None): def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP) 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): for i in range(5):
romOptionsFrame.rowconfigure(i, weight=0, pad=4) romOptionsFrame.rowconfigure(i, weight=1)
vars = Namespace() vars = Namespace()
vars.MusicVar = IntVar() vars.MusicVar = IntVar()
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect) spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT) baseSpriteLabel.pack(side=LEFT)
spriteEntry.pack(side=LEFT, expand=True, fill=X) spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT) spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame) oofDialogFrame = Frame(romOptionsFrame)

25
Main.py
View File

@@ -11,8 +11,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \ from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
flood_items
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings from Utils import __version__, output_path, version_tuple, get_settings
from settings import 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.sprite_pool = args.sprite_pool.copy()
multiworld.set_options(args) multiworld.set_options(args)
if args.csv_output:
from Options import dump_player_options
dump_player_options(multiworld)
multiworld.set_item_links() multiworld.set_item_links()
multiworld.state = CollectionState(multiworld) multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) 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) multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early remaining_count = count-early
if remaining_count > 0: 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: if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early 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. # 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): if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = [] new_items: List[Item] = []
old_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = { depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, player: getattr(multiworld.worlds[player].options,
"start_inventory_from_pool", "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 depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items # quick abort if we have found all items
if not target: if not target:
old_items.extend(multiworld.itempool[i+1:]) new_items.extend(multiworld.itempool[i+1:])
break break
else: else:
old_items.append(item) new_items.append(item)
# leftovers? # leftovers?
if target: if target:
for player, remaining_items in depletion_pool.items(): for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count} remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items: 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}") 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 assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
removables = [item for item in new_items if item.player == player] multiworld.itempool[:] = new_items
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
multiworld.link_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)) output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result(): if not check_accessibility_task.result():
if not multiworld.can_beat_game(): if not multiworld.can_beat_game():
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld) raise Exception("Game appears as unbeatable. Aborting.")
else: else:
logger.warning("Location Accessibility requirements not fulfilled.") logger.warning("Location Accessibility requirements not fulfilled.")

View File

@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
if not update_ran: if not update_ran:
update_ran = True update_ran = True
install_pkg_resources(yes=yes)
import pkg_resources
if force: if force:
update_command() update_command()
return return
install_pkg_resources(yes=yes)
import pkg_resources
prev = "" # if a line ends in \ we store here and merge later prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files: for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file) path = os.path.join(os.path.dirname(sys.argv[0]), req_file)

View File

@@ -67,21 +67,6 @@ def update_dict(dictionary, entries):
return dictionary 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 # functions callable on storable data on the server by clients
modify_functions = { modify_functions = {
# generic: # generic:
@@ -566,9 +551,6 @@ class Context:
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.") self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else: else:
self.save_dirty = False 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 = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start() 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) 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) return ctx.locations.get_remaining(ctx.location_checks, team, slot)
@@ -1221,10 +1203,6 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10) timer = int(seconds, 10)
except ValueError: except ValueError:
timer = 10 timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer)) async_start(countdown(self.ctx, timer))
return True return True
@@ -1372,10 +1350,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_remaining(self) -> bool: def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient""" """List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
for slot, item_id in rest_locations)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
return True return True
@@ -1385,10 +1363,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False return False
else: # is goal else: # is goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_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) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
for slot, item_id in rest_locations)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
return True return True
@@ -1879,6 +1857,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
args["cmd"] = "SetReply" args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0)) value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = copy.copy(value) args["original_value"] = copy.copy(value)
args["slot"] = client.slot
for operation in args["operations"]: for operation in args["operations"]:
func = modify_functions[operation["operation"]] func = modify_functions[operation["operation"]]
value = func(value, operation["value"]) value = func(value, operation["value"])
@@ -2061,8 +2040,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
item_name, usable, response = get_intended_text(item_name, names) item_name, usable, response = get_intended_text(item_name, names)
if usable: if usable:
amount: int = int(amount) 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))] new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items) send_items_to(self.ctx, team, slot, *new_items)

View File

@@ -79,7 +79,6 @@ class NetworkItem(typing.NamedTuple):
item: int item: int
location: int location: int
player: int player: int
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0 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, 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, '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, 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
def color_code(*args): def color_code(*args):
@@ -399,12 +397,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked] location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int 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] checked = state[team, slot]
player_locations = self[slot] player_locations = self[slot]
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for return sorted([player_locations[location_id][0] for
location_id in player_locations if location_id in player_locations if
location_id not in checked]) location_id not in checked])
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub

View File

@@ -8,17 +8,16 @@ import numbers
import random import random
import typing import typing
import enum import enum
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from schema import And, Optional, Or, Schema from schema import And, Optional, Or, Schema
from typing_extensions import Self 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: if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions from BaseClasses import PlandoOptions
from worlds.AutoWorld import World from worlds.AutoWorld import World
import pathlib import pathlib
@@ -974,19 +973,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if random.random() < float(text.get("percentage", 100)/100): if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None) at = text.get("at", None)
if at is not 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", []) 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): if isinstance(given_text, str):
given_text = [given_text] given_text = [given_text]
texts.append(PlandoText( texts.append(PlandoText(
@@ -994,8 +981,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
given_text, given_text,
text.get("percentage", 100) text.get("percentage", 100)
)) ))
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText): elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100): if random.random() < float(text.percentage/100):
texts.append(text) texts.append(text)
@@ -1251,7 +1236,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
:param option_names: names of the options to return :param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` :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 = {} option_results = {}
for option_name in option_names: for option_name in option_names:
if option_name in type(self).type_hints: if option_name in type(self).type_hints:
@@ -1336,7 +1320,7 @@ class PriorityLocations(LocationSet):
class DeathLink(Toggle): 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" display_name = "Death Link"
rich_text_doc = True rich_text_doc = True
@@ -1535,40 +1519,29 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
f.write(res) f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None: if __name__ == "__main__":
from csv import DictWriter
game_players = defaultdict(list) from worlds.alttp.Options import Logic
for player, game in multiworld.game.items(): import argparse
game_players[game].append(player)
game_players = dict(sorted(game_players.items()))
output = [] map_shuffle = Toggle
per_game_option_names = [ compass_shuffle = Toggle
getattr(option, "display_name", option_key) key_shuffle = Toggle
for option_key, option in PerGameCommonOptions.type_hints.items() big_key_shuffle = Toggle
] hints = Toggle
all_option_names = per_game_option_names.copy() test = argparse.Namespace()
for game, players in game_players.items(): test.logic = Logic.from_text("no_logic")
game_option_names = per_game_option_names.copy() test.map_shuffle = map_shuffle.from_text("ON")
for player in players: test.hints = hints.from_text('OFF')
world = multiworld.worlds[player] try:
player_output = { test.logic = Logic.from_text("overworld_glitches_typo")
"Game": multiworld.game[player], except KeyError as e:
"Name": multiworld.get_player_name(player), print(e)
} try:
output.append(player_output) test.logic_owg = Logic.from_text("owg")
for option_key, option in world.options_dataclass.type_hints.items(): except KeyError as e:
if issubclass(Removed, option): print(e)
continue if test.map_shuffle:
display_name = getattr(option, "display_name", option_key) print("map_shuffle is on")
player_output[display_name] = getattr(world.options, option_key).current_option_name print(f"Hints are {bool(test.hints)}")
if display_name not in game_option_names: print(test)
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)

View File

@@ -73,9 +73,6 @@ Currently, the following games are supported:
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006 * Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time * A Hat in Time
* Old School Runescape * Old School Runescape
* Kingdom Hearts 1
* Mega Man 2
* Yacht Dice
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). 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 Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.5.1" __version__ = "0.5.0"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")

View File

@@ -1,4 +1,3 @@
import argparse
import os import os
import multiprocessing import multiprocessing
import logging import logging
@@ -32,15 +31,6 @@ def get_app() -> "Flask":
import yaml import yaml
app.config.from_file(configpath, yaml.safe_load) app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}") 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"]: if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.") logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()

View File

@@ -1,15 +1,51 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple 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") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots] 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

View File

@@ -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,
}

View File

@@ -72,14 +72,6 @@ class WebHostContext(Context):
self.video = {} self.video = {}
self.tags = ["AP", "WebHost"] 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): def _load_game_data(self):
for key, value in self.static_server_data.items(): 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 # 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 = WebHostContext(static_server_data, logger)
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()
assert ctx.server is None
try: try:
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) 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 ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving: if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True)) setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
assert ctx.shutdown_task is None
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task await ctx.shutdown_task
@@ -335,7 +325,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
def run(self): def run(self):
while 1: while 1:
next_room = rooms_to_run.get(block=True, timeout=None) 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) task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task) self._tasks.append(task)
task.add_done_callback(self._done) task.add_done_callback(self._done)

View File

@@ -134,7 +134,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False erargs.skip_prog_balancing = False
erargs.skip_output = False erargs.skip_output = False
erargs.csv_output = False
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -132,41 +132,26 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
return "Access Denied", 403 return "Access Denied", 403
@app.post("/room/<suuid:room>") @app.route('/room/<suuid:room>', methods=['GET', 'POST'])
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>")
def host_room(room: UUID): def host_room(room: UUID):
room: Room = Room.get(id=room) room: Room = Room.get(id=room)
if room is None: if room is None:
return abort(404) 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() now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port # 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)) 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))
with db_session: with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari" def get_log(max_size: int = 1024000) -> str:
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 ""
try: try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log: with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0 raw_size = 0

View File

@@ -1,11 +1,10 @@
flask>=3.0.3 flask>=3.0.3
werkzeug>=3.0.4 werkzeug>=3.0.3
pony>=0.7.19 pony>=0.7.17
waitress>=3.0.0 waitress>=3.0.0
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.15 Flask-Compress>=1.15
Flask-Limiter>=3.8.0 Flask-Limiter>=3.7.0
bokeh>=3.1.1; python_version <= '3.8' bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.4.3; python_version == '3.9' bokeh>=3.4.1; python_version >= '3.9'
bokeh>=3.5.2; python_version >= '3.10'
markupsafe>=2.1.5 markupsafe>=2.1.5

View File

@@ -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: You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). [/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.

View File

@@ -58,28 +58,3 @@
overflow-y: auto; overflow-y: auto;
max-height: 400px; 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 }
}

View File

@@ -19,30 +19,28 @@
{% block body %} {% block body %}
{% include 'header/grassHeader.html' %} {% include 'header/grassHeader.html' %}
<div id="host-room"> <div id="host-room">
<span id="host-room-info"> {% if room.owner == session["_id"] %}
{% if room.owner == session["_id"] %} Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a> <br />
<br /> {% endif %}
{% endif %} {% if room.tracker %}
{% if room.tracker %} This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
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.
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled. <br />
<br /> {% endif %}
{% endif %} The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue later,
Should you wish to continue later, anyone can simply refresh this page and the server will resume.<br>
anyone can simply refresh this page and the server will resume.<br> {% if room.last_port == -1 %}
{% if room.last_port == -1 %} There was an error hosting this Room. Another attempt will be made on refreshing this page.
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.
The most likely failure reason is that the multiworld is too old to be loaded now. {% elif room.last_port %}
{% elif room.last_port %} You can connect to this room by using <span class="interactive"
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 }}.">
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}."> '/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}' </span>
</span> in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br> {% endif %}
{% endif %}
</span>
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
@@ -51,7 +49,6 @@
<label for="cmd"></label> <label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd" <input class="form-control" type="text" id="cmd" name="cmd"
placeholder="Server Command. /help to list them, list gets appended to log."> placeholder="Server Command. /help to list them, list gets appended to log.">
<span class="loader"></span>
</div> </div>
</form> </form>
<a href="{{ url_for("display_log", room=room.id) }}"> <a href="{{ url_for("display_log", room=room.id) }}">
@@ -65,7 +62,6 @@
let url = '{{ url_for('display_log', room = room.id) }}'; let url = '{{ url_for('display_log', room = room.id) }}';
let bytesReceived = {{ log_len }}; let bytesReceived = {{ log_len }};
let updateLogTimeout; let updateLogTimeout;
let updateLogImmediately = false;
let awaitingCommandResponse = false; let awaitingCommandResponse = false;
let logger = document.getElementById("logger"); let logger = document.getElementById("logger");
@@ -82,36 +78,29 @@
async function updateLog() { async function updateLog() {
try { try {
if (!document.hidden) { let res = await fetch(url, {
updateLogImmediately = false; headers: {
let res = await fetch(url, { 'Range': `bytes=${bytesReceived}-`,
headers: { }
'Range': `bytes=${bytesReceived}-`, });
} if (res.ok) {
}); let text = await res.text();
if (res.ok) { if (text.length > 0) {
let text = await res.text(); awaitingCommandResponse = false;
if (text.length > 0) { if (bytesReceived === 0 || res.status !== 206) {
awaitingCommandResponse = false; logger.innerHTML = '';
if (bytesReceived === 0 || res.status !== 206) { }
logger.innerHTML = ''; if (res.status !== 206) {
} bytesReceived = 0;
if (res.status !== 206) { } else {
bytesReceived = 0; bytesReceived += new Blob([text]).size;
} else { }
bytesReceived += new Blob([text]).size; if (logger.innerHTML.endsWith('…')) {
} logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
if (logger.innerHTML.endsWith('…')) { }
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1); logger.appendChild(document.createTextNode(text));
} scrollToBottom(logger);
logger.appendChild(document.createTextNode(text));
scrollToBottom(logger);
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
loader.classList.remove("loading");
}
} }
} else {
updateLogImmediately = true;
} }
} }
finally { finally {
@@ -136,62 +125,20 @@
}); });
ev.preventDefault(); // has to happen before first await ev.preventDefault(); // has to happen before first await
form.reset(); form.reset();
let loader = form.getElementsByClassName("loader")[0]; let res = await req;
loader.classList.add("loading"); if (res.ok || res.type === 'opaqueredirect') {
try { awaitingCommandResponse = true;
let res = await req; window.clearTimeout(updateLogTimeout);
if (res.ok || res.type === 'opaqueredirect') { updateLogTimeout = window.setTimeout(updateLog, 100);
awaitingCommandResponse = true; } else {
window.clearTimeout(updateLogTimeout); window.alert(res.statusText);
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);
} }
} }
document.getElementById("command-form").addEventListener("submit", postForm); document.getElementById("command-form").addEventListener("submit", postForm);
updateLogTimeout = window.setTimeout(updateLog, 1000); updateLogTimeout = window.setTimeout(updateLog, 1000);
logger.scrollTop = logger.scrollHeight; logger.scrollTop = logger.scrollHeight;
document.addEventListener("visibilitychange", () => {
if (!document.hidden && updateLogImmediately) {
updateLog();
}
})
</script> </script>
{% endif %} {% 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> </div>
{% endblock %} {% endblock %}

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %} {% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr> <tr>
<td>{{ patch.player_id }}</td> <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>{{ patch.game }}</td>
<td> <td>
{% if patch.data %} {% if patch.data %}

View File

@@ -69,7 +69,7 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
You have not generated any seeds yet! You have no generated any seeds yet!
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -138,7 +138,7 @@
id="{{ option_name }}-{{ key }}" id="{{ option_name }}-{{ key }}"
name="{{ option_name }}||{{ key }}" name="{{ option_name }}||{{ key }}"
value="1" value="1"
{{ "checked" if key in option.default }} checked="{{ "checked" if key in option.default else "" }}"
/> />
<label for="{{ option_name }}-{{ key }}"> <label for="{{ option_name }}-{{ key }}">
{{ key }} {{ key }}

View File

@@ -287,15 +287,15 @@ cdef class LocationStore:
entry in self.entries[start:start + count] if entry in self.entries[start:start + count] if
entry.location not in checked] 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 LocationEntry* entry
cdef ap_player_t sender = slot cdef ap_player_t sender = slot
cdef size_t start = self.sender_index[sender].start cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count cdef size_t count = self.sender_index[sender].count
cdef set checked = state[team, slot] cdef set checked = state[team, slot]
return sorted([(entry.receiver, entry.item) for return sorted([entry.item for
entry in self.entries[start:start+count] if entry in self.entries[start:start+count] if
entry.location not in checked]) entry.location not in checked])
@cython.auto_pickle(False) @cython.auto_pickle(False)

View File

@@ -46,7 +46,7 @@
/worlds/clique/ @ThePhar /worlds/clique/ @ThePhar
# Dark Souls III # Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3 /worlds/dark_souls_3/ @Marechal-L
# Donkey Kong Country 3 # Donkey Kong Country 3
/worlds/dkc3/ @PoryGone /worlds/dkc3/ @PoryGone
@@ -78,9 +78,6 @@
# Kirby's Dream Land 3 # Kirby's Dream Land 3
/worlds/kdl3/ @Silvris /worlds/kdl3/ @Silvris
# Kingdom Hearts
/worlds/kh1/ @gaithern
# Kingdom Hearts 2 # Kingdom Hearts 2
/worlds/kh2/ @JaredWeakStrike /worlds/kh2/ @JaredWeakStrike
@@ -106,9 +103,6 @@
# Minecraft # Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536 /worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
# MegaMan Battle Network 3 # MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic /worlds/mmbn3/ @digiholic
@@ -118,6 +112,9 @@
# Noita # Noita
/worlds/noita/ @ScipioWright @heinermann /worlds/noita/ @ScipioWright @heinermann
# Ocarina of Time
/worlds/oot/ @espeon65536
# Old School Runescape # Old School Runescape
/worlds/osrs @digiholic /worlds/osrs @digiholic
@@ -199,9 +196,6 @@
# The Witness # The Witness
/worlds/witness/ @NewSoupVi @blastron /worlds/witness/ @NewSoupVi @blastron
# Yacht Dice
/worlds/yachtdice/ @spinerak
# Yoshi's Island # Yoshi's Island
/worlds/yoshisisland/ @PinkSwitch /worlds/yoshisisland/ @PinkSwitch
@@ -227,9 +221,6 @@
# Links Awakening DX # Links Awakening DX
# /worlds/ladx/ # /worlds/ladx/
# Ocarina of Time
# /worlds/oot/
## Disabled Unmaintained Worlds ## Disabled Unmaintained Worlds
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are # The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are

View File

@@ -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. | | key | str | The key that was updated. |
| value | any | The new value for the key. | | 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. | | 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. 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. | | checksum | str | A checksum hash of this game's data. |
### Tags ### 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 | | Name | Notes |
|-----------|--------------------------------------------------------------------------------------------------------------------------------------| |------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. | | 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. | | 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 | 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. |
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² | | 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. |
| 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.
### DeathLink ### 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: A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:

View File

@@ -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 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`. 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 - 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`. 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 However, you can override `from_text` and handle `text == "random"` to customize its behavior or
implement it for additional option types. implement it for additional option types.
@@ -129,23 +129,6 @@ class Difficulty(Choice):
default = 1 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 ### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified 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 by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment

View File

@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
What you'll need: What you'll need:
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version * [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 * pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler * Matching C compiler
* possibly optional, read operating system specific sections * possibly optional, read operating system specific sections
@@ -31,14 +31,14 @@ After this, you should be able to run the programs.
Recommended steps Recommended steps
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads) * 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 * **Optional**: Download and install Visual Studio Build Tools from
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). [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. * 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. 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 * 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/) * 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 * Run Generate.py which will prompt installation of missing modules, press enter to confirm

View File

@@ -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` 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). (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 ### 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 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 Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
Entrance should be 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 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. 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). For an example, see [The Messenger](/worlds/messenger/rules.py).
@@ -696,92 +671,9 @@ When importing a file that defines a class that inherits from `worlds.AutoWorld.
is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing
world since the namespace is shared with all other logic mixins. world since the namespace is shared with all other logic mixins.
LogicMixin is handy when your logic is more complex than one-to-one location-item relationships. Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified
A game in which "The red key opens the red door" can just express this relationship through a one-line access rule. with the state.
But now, consider a game with a heavy focus on combat, where the main logical consideration is which enemies you can Please do this with caution and only when necessary.
defeat with your current items.
There could be dozens of weapons, armor pieces, or consumables that each improve your ability to defeat
specific enemies to varying degrees. It would be useful to be able to keep track of "defeatable enemies" as a state variable,
and have this variable be recalculated as necessary based on newly collected/removed items.
This is the capability of LogicMixin: Adding custom variables to state that get recalculated as necessary.
In general, a LogicMixin class should have at least one mutable variable that is tracking some custom state per player,
as well as `init_mixin` and `copy_mixin` functions so that this variable gets initialized and copied correctly when
`CollectionState()` and `CollectionState.copy()` are called respectively.
```python
from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import LogicMixin
class MyGameState(LogicMixin):
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
def init_mixin(self, multiworld: MultiWorld) -> None:
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
# You can also use something like Collections.defaultdict
self.mygame_defeatable_enemies = {
player: set() for player in multiworld.get_game_players("My Game")
}
def copy_mixin(self, new_state: CollectionState) -> CollectionState:
# Be careful to make a "deep enough" copy here!
new_state.mygame_defeatable_enemies = {
player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items()
}
```
After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules.
Usually, doing this coincides with an override of `World.collect` and `World.remove`, where the custom state variable
gets recalculated when a relevant item is collected or removed.
```python
# __init__.py
def collect(self, state: CollectionState, item: Item) -> bool:
change = super().collect(state, item)
if change and item in COMBAT_ITEMS:
state.mygame_defeatable_enemies[self.player] |= get_newly_unlocked_enemies(state)
return change
def remove(self, state: CollectionState, item: Item) -> bool:
change = super().remove(state, item)
if change and item in COMBAT_ITEMS:
state.mygame_defeatable_enemies[self.player] -= get_newly_locked_enemies(state)
return change
```
Using LogicMixin can greatly slow down your code if you don't use it intelligently. This is because `collect`
and `remove` are called very frequently during fill. If your `collect` & `remove` cause a heavy calculation
every time, your code might end up being *slower* than just doing calculations in your access rules.
One way to optimise recalculations is to make use of the fact that `collect` should only unlock things,
and `remove` should only lock things.
In our example, we have two different functions: `get_newly_unlocked_enemies` and `get_newly_locked_enemies`.
`get_newly_unlocked_enemies` should only consider enemies that are *not already in the set*
and check whether they were **unlocked**.
`get_newly_locked_enemies` should only consider enemies that are *already in the set*
and check whether they **became locked**.
Another impactful way to optimise LogicMixin is to use caching.
Your custom state variables don't actually need to be recalculated on every `collect` / `remove`, because there are
often multiple calls to `collect` / `remove` between access rule calls. Thus, it would be much more efficient to hold
off on recaculating until the an actual access rule call happens.
A common way to realize this is to define a `mygame_state_is_stale` variable that is set to True in `collect`, `remove`,
and `init_mixin`. The calls to the actual recalculating functions are then moved to the start of the relevant
access rules like this:
```python
def can_defeat_enemy(state: CollectionState, player: int, enemy: str) -> bool:
if state.mygame_state_is_stale[player]:
state.mygame_defeatable_enemies[player] = recalculate_defeatable_enemies(state)
state.mygame_state_is_stale[player] = False
return enemy in state.mygame_defeatable_enemies[player]
```
Only use LogicMixin if necessary. There are often other ways to achieve what it does, like making clever use of
`state.prog_items`, using event items, pseudo-regions, etc.
#### pre_fill #### pre_fill

View File

@@ -26,17 +26,8 @@ Unless these are shared between multiple people, we expect the following from ea
### Adding a World ### Adding a World
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you 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). 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.
### 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.
### Getting Voted ### 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. 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. 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. 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 ## 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. 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 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. 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. date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds ## Handling of Unmaintained Worlds

View File

@@ -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\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: "{#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: ".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"; 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: ""; 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"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; 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\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
[Code] [Code]
// See: https://stackoverflow.com/a/51614652/2287576 // See: https://stackoverflow.com/a/51614652/2287576

View File

@@ -5,8 +5,6 @@ import typing
import re import re
from collections import deque from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
if sys.platform == "win32": if sys.platform == "win32":
import ctypes import ctypes

View File

@@ -1,14 +1,14 @@
colorama>=0.4.6 colorama>=0.4.6
websockets>=13.0.1 websockets>=12.0
PyYAML>=6.0.2 PyYAML>=6.0.1
jellyfish>=1.1.0 jellyfish>=1.0.3
jinja2>=3.1.4 jinja2>=3.1.4
schema>=0.7.7 schema>=0.7.7
kivy>=2.3.0 kivy>=2.3.0
bsdiff4>=1.2.4 bsdiff4>=1.2.4
platformdirs>=4.2.2 platformdirs>=4.2.2
certifi>=2024.8.30 certifi>=2024.6.2
cython>=3.0.11 cython>=3.0.10
cymem>=2.0.8 cymem>=2.0.8
orjson>=3.10.7 orjson>=3.10.3
typing_extensions>=4.12.2 typing_extensions>=4.12.1

View File

@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
state = CollectionState(self.multiworld) state = CollectionState(self.multiworld)
for item in items: for item in items:
item.classification = ItemClassification.progression item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True) state.collect(item, event=True)
state.sweep_for_advancements() state.sweep_for_events()
state.update_reachable_regions(1) state.update_reachable_regions(1)
self._state_cache[self.multiworld, tuple(items)] = state self._state_cache[self.multiworld, tuple(items)] = state
return state return state
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
if isinstance(items, Item): if isinstance(items, Item):
items = (items,) items = (items,)
for item in items: for item in items:
if item.location and item.advancement and item.location in self.multiworld.state.advancements: if item.location and item.advancement and item.location in self.multiworld.state.events:
self.multiworld.state.advancements.remove(item.location) self.multiworld.state.events.remove(item.location)
self.multiworld.state.remove(item) self.multiworld.state.remove(item)
def can_reach_location(self, location: str) -> bool: 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): if not (self.run_default_tests and self.constructed):
return return
with self.subTest("Game", game=self.game, seed=self.multiworld.seed): 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) state = self.multiworld.get_all_state(False)
for location in self.multiworld.get_locations(): for location in self.multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name): if location.name not in excluded:
reachable = location.can_reach(state) with self.subTest("Location should be reached", location=location.name):
self.assertTrue(reachable, f"{location.name} unreachable") reachable = location.can_reach(state)
self.assertTrue(reachable, f"{location.name} unreachable")
with self.subTest("Beatable"): with self.subTest("Beatable"):
self.multiworld.state = state self.multiworld.state = state
self.assertBeatable(True) self.assertBeatable(True)

View File

@@ -192,7 +192,7 @@ class TestFillRestrictive(unittest.TestCase):
location_pool = player1.locations[1:] + player2.locations location_pool = player1.locations[1:] + player2.locations
item_pool = player1.prog_items[:-1] + player2.prog_items item_pool = player1.prog_items[:-1] + player2.prog_items
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool) 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) # all of player2's locations and items should be accessible (not all of player1's)
for item in player2.prog_items: for item in player2.prog_items:
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
item = player1.prog_items[0] item = player1.prog_items[0]
item.code = None item.code = None
location.place_locked_item(item) location.place_locked_item(item)
multiworld.state.sweep_for_advancements() multiworld.state.sweep_for_events()
multiworld.state.sweep_for_advancements() multiworld.state.sweep_for_events()
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed") 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") self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")

View File

@@ -14,18 +14,6 @@ class TestBase(unittest.TestCase):
"Desert Northern Cliffs", # on top of mountain, only reachable via OWG "Desert Northern Cliffs", # on top of mountain, only reachable via OWG
"Dark Death Mountain Bunny Descent Area" # OWG Mountain descent "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": { "Ocarina of Time": {
"Prelude of Light Warp", # Prelude is not progression by default "Prelude of Light Warp", # Prelude is not progression by default
"Serenade of Water Warp", # Serenade 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()) unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
with self.subTest("Game", game=game_name): with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type) multiworld = setup_solo_multiworld(world_type)
excluded = multiworld.worlds[1].options.exclude_locations.value
state = multiworld.get_all_state(False) state = multiworld.get_all_state(False)
for location in multiworld.get_locations(): for location in multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name): if location.name not in excluded:
self.assertTrue(location.can_reach(state), f"{location.name} unreachable") 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(): for region in multiworld.get_regions():
if region.name in unreachable_regions: if region.name in unreachable_regions:

View File

@@ -55,7 +55,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
all_worlds = list(AutoWorldRegister.world_types.values()) all_worlds = list(AutoWorldRegister.world_types.values())
self.multiworld = setup_multiworld(all_worlds, ()) self.multiworld = setup_multiworld(all_worlds, ())
for world in self.multiworld.worlds.values(): 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) self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed): with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld) distribute_items_restrictive(self.multiworld)
@@ -66,8 +66,8 @@ class TestAllGamesMultiworld(MultiworldTestBase):
class TestTwoPlayerMulti(MultiworldTestBase): class TestTwoPlayerMulti(MultiworldTestBase):
def test_two_player_single_game_fills(self) -> None: def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate.""" """Tests that a multiworld of two players for each registered game world can generate."""
for world_type in AutoWorldRegister.world_types.values(): for world in AutoWorldRegister.world_types.values():
self.multiworld = setup_multiworld([world_type, world_type], ()) self.multiworld = setup_multiworld([world, world], ())
for world in self.multiworld.worlds.values(): for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full world.options.accessibility.value = Accessibility.option_full
self.assertSteps(gen_steps) self.assertSteps(gen_steps)

View File

@@ -130,9 +130,9 @@ class Base:
def test_get_remaining(self) -> None: def test_get_remaining(self) -> None:
self.assertEqual(self.store.get_remaining(full_state, 0, 1), []) 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(one_state, 0, 1), [13, 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, 1), [13, 21, 22])
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)]) self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99])
def test_location_set_intersection(self) -> None: def test_location_set_intersection(self) -> None:
locations = {10, 11, 12} locations = {10, 11, 12}

View File

@@ -131,8 +131,7 @@ class TestHostFakeRoom(TestBase):
f.write(text) f.write(text)
with self.app.app_context(), self.app.test_request_context(): with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("host_room", room=self.room_id), response = self.client.get(url_for("host_room", room=self.room_id))
headers={"User-Agent": "Mozilla/5.0"})
response_text = response.get_data(True) response_text = response.get_data(True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn("href=\"/seed/", response_text) self.assertIn("href=\"/seed/", response_text)

View File

@@ -292,14 +292,6 @@ class World(metaclass=AutoWorldRegister):
web: ClassVar[WebWorld] = WebWorld() web: ClassVar[WebWorld] = WebWorld()
"""see WebWorld for options""" """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" multiworld: "MultiWorld"
"""autoset on creation. The MultiWorld object for the currently generating multiworld.""" """autoset on creation. The MultiWorld object for the currently generating multiworld."""
player: int player: int

View File

@@ -26,13 +26,10 @@ class Component:
cli: bool cli: bool
func: Optional[Callable] func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]] 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, 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, cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None, func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None):
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
self.display_name = display_name self.display_name = display_name
self.script_name = script_name self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None 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) Type.ADJUSTER if "Adjuster" in display_name else Type.MISC)
self.func = func self.func = func
self.file_identifier = file_identifier self.file_identifier = file_identifier
self.game_name = game_name
self.supports_uri = supports_uri
def handles_file(self, path: str): def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False return self.file_identifier(path) if self.file_identifier else False
@@ -61,10 +56,10 @@ class Component:
processes = weakref.WeakSet() 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 global processes
import multiprocessing import multiprocessing
process = multiprocessing.Process(target=func, name=name, args=args) process = multiprocessing.Process(target=func, name=name)
process.start() process.start()
processes.add(process) processes.add(process)
@@ -83,9 +78,9 @@ class SuffixIdentifier:
return False return False
def launch_textclient(*args): def launch_textclient():
import CommonClient 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]]: 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 break
if found_already_loaded: if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n" 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" "so a Launcher restart is required to use the new installation.")
"If the Launcher is not open, no action needs to be taken.")
world_source = worlds.WorldSource(str(target), is_zip=True) world_source = worlds.WorldSource(str(target), is_zip=True)
bisect.insort(worlds.world_sources, world_source) bisect.insort(worlds.world_sources, world_source)
world_source.load() world_source.load()

View File

@@ -73,12 +73,7 @@ class WorldSource:
else: # TODO: remove with 3.8 support else: # TODO: remove with 3.8 support
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0]) mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
if mod.__package__ is not None: mod.__package__ = f"worlds.{mod.__package__}"
mod.__package__ = f"worlds.{mod.__package__}"
else:
# load_module does not populate package, we'll have to assume mod.__name__ is correct here
# probably safe to remove with 3.8 support
mod.__package__ = f"worlds.{mod.__name__}"
mod.__name__ = f"worlds.{mod.__name__}" mod.__name__ = f"worlds.{mod.__name__}"
sys.modules[mod.__name__] = mod sys.modules[mod.__name__] = mod
with warnings.catch_warnings(): with warnings.catch_warnings():

View File

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
def launch_client(*args) -> None: def launch_client(*args) -> None:
from .context import launch 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, component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,

View File

@@ -59,10 +59,14 @@ class BizHawkClientContext(CommonContext):
self.bizhawk_ctx = BizHawkContext() self.bizhawk_ctx = BizHawkContext()
self.watcher_timeout = 0.5 self.watcher_timeout = 0.5
def make_gui(self): def run_gui(self):
ui = super().make_gui() from kvui import GameManager
ui.base_title = "Archipelago BizHawk Client"
return ui 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): def on_package(self, cmd, args):
if cmd == "Connected": if cmd == "Connected":
@@ -239,11 +243,11 @@ async def _patch_and_run_game(patch_file: str):
logger.exception(exc) logger.exception(exc)
def launch(*launch_args) -> None: def launch() -> None:
async def main(): async def main():
parser = get_base_parser() parser = get_base_parser()
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file") 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 = BizHawkClientContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")

View File

@@ -968,35 +968,40 @@ def get_act_by_number(world: "HatInTimeWorld", chapter_name: str, num: int) -> R
def create_thug_shops(world: "HatInTimeWorld"): def create_thug_shops(world: "HatInTimeWorld"):
min_items: int = world.options.NyakuzaThugMinShopItems.value min_items: int = world.options.NyakuzaThugMinShopItems.value
max_items: int = world.options.NyakuzaThugMaxShopItems.value max_items: int = world.options.NyakuzaThugMaxShopItems.value
count = -1
thug_location_counts: Dict[str, int] = {} step = 0
old_name = ""
for key, data in shop_locations.items(): for key, data in shop_locations.items():
thug_name = data.nyakuza_thug if data.nyakuza_thug == "":
if thug_name == "":
# Different shop type.
continue continue
if thug_name not in world.nyakuza_thug_items: if old_name != "" and old_name == data.nyakuza_thug:
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:
continue continue
location_count = thug_location_counts.setdefault(thug_name, 0) try:
if location_count >= shop_item_count: if world.nyakuza_thug_items[data.nyakuza_thug] <= 0:
# Already created all the locations for this thug. continue
continue except KeyError:
pass
# Create the shop location. if count == -1:
region = world.multiworld.get_region(data.region, world.player) count = world.random.randint(min_items, max_items)
loc = HatInTimeLocation(world.player, key, data.id, region) world.nyakuza_thug_items.setdefault(data.nyakuza_thug, count)
region.locations.append(loc) if count <= 0:
world.shop_locs.append(loc.name) continue
thug_location_counts[thug_name] = location_count + 1
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: def create_events(world: "HatInTimeWorld") -> int:

View File

@@ -381,8 +381,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
lambda state: can_use_hat(state, world, HatType.ICE), "or") lambda state: can_use_hat(state, world, HatType.ICE), "or")
# Moderate: Clock Tower Chest + Ruined Tower with nothing # Moderate: Clock Tower Chest + Ruined Tower with nothing
set_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True) add_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 - Top of Ruined Tower", world.player), lambda state: True)
# Moderate: enter and clear The Subcon Well without Hookshot and without hitting the bell # 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: 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(): if world.is_dlc1():
# Moderate: clear Rock the Boat without Ice Hat # 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) add_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("Act Completion (Rock the Boat)", world.player), lambda state: True)
# Moderate: clear Deep Sea without Ice Hat # Moderate: clear Deep Sea without Ice Hat
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Deep Sea)", world.player), 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: for entrance in regions["Time Rift - Alpine Skyline"].entrances:
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon")) 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(): if world.is_dlc1():
for entrance in regions["Time Rift - Balcony"].entrances: 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: 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")) 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(): if world.is_dlc1():
for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances: for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances:

View File

@@ -248,7 +248,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
pass pass
for item in pre_fill_items: for item in pre_fill_items:
multiworld.worlds[item.player].collect(all_state_base, item) 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 # Remove completion condition so that minimal-accessibility worlds place keys properly
for player in {item.player for item in in_dungeon_items}: 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])) all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
loc = multiworld.get_location(key_loc, player) loc = multiworld.get_location(key_loc, player)
if loc in all_state_base.advancements: if loc in all_state_base.events:
all_state_base.advancements.remove(loc) all_state_base.events.remove(loc)
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, lock=True, allow_excluded=True, fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, lock=True, allow_excluded=True,
name="LttP Dungeon Items") name="LttP Dungeon Items")

View File

@@ -682,7 +682,7 @@ def get_pool_core(world, player: int):
if 'triforce_hunt' in goal: if 'triforce_hunt' in goal:
if world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_extra: 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) + world.triforce_pieces_extra[player].value)
elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage: elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage:
percentage = float(world.triforce_pieces_percentage[player].value) / 100 percentage = float(world.triforce_pieces_percentage[player].value) / 100

View File

@@ -728,7 +728,7 @@ class ALttPPlandoConnections(PlandoConnections):
entrances = set([connection[0] for connection in ( entrances = set([connection[0] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections, *default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_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, *default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)]) *inverted_default_dungeon_connections)])

View File

@@ -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)) 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), set_rule(multiworld.get_location('Thieves\' Town - Blind\'s Cell', player),
lambda state: state._lttp_has_key('Small Key (Thieves Town)', 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_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 - 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), 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))) 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 # 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)) # 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. # 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.

View File

@@ -356,8 +356,6 @@ class ALTTPWorld(World):
self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group] self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group]
if option == "original_dungeon": if option == "original_dungeon":
self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group] 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] self.difficulty_requirements = difficulties[multiworld.item_pool[player].current_key]

View File

@@ -2,8 +2,8 @@
## Configuration ## 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). 1. Plando features have to be enabled first, before they can be used (opt-in).
2. To enable it, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml 2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
file with a text editor. 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 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` to `bosses, items, texts, connections`
@@ -66,7 +66,6 @@ boss_shuffle:
- ignored if only one world is generated - ignored if only one world is generated
- can be a number, to target that slot in the multiworld - can be a number, to target that slot in the multiworld
- can be a name, to target that player's world - 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 true, to target any other player's world
- can be false, to target own world and is the default - can be false, to target own world and is the default
- can be null, to target a random world - can be null, to target a random world
@@ -133,15 +132,17 @@ plando_items:
### Texts ### Texts
- This module is disabled by default.
- Has the options `text`, `at`, and `percentage` - 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% - percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
- text is the text to be placed. - text is the text to be placed.
- can be weighted.
- `\n` is a newline. - `\n` is a newline.
- `@` is the entered player's name. - `@` is the entered player's name.
- Warning: Text Mapper does not support full unicode. - Warning: Text Mapper does not support full unicode.
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758) - [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. - 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) - [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
#### Example #### Example
@@ -161,6 +162,7 @@ and `uncle_dying_sewer`, then places the text "This is a plando. You've been war
### Connections ### Connections
- This module is disabled by default.
- Has the options `percentage`, `entrance`, `exit` and `direction`. - Has the options `percentage`, `entrance`, `exit` and `direction`.
- All options support subweights - All options support subweights
- percentage is the percentage chance for this to be connected, can be omitted entirely for 100% - percentage is the percentage chance for this to be connected, can be omitted entirely for 100%

View File

@@ -54,7 +54,7 @@ class TestDungeon(LTTPTestBase):
for item in items: for item in items:
item.classification = ItemClassification.progression item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True) # prevent_sweep=True prevents running sweep_for_advancements() and picking up state.collect(item, event=True) # event=True prevents running sweep_for_events() and picking up
state.sweep_for_advancements() # key drop keys repeatedly 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}")

View File

@@ -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")

View File

@@ -4,7 +4,7 @@ from BaseClasses import Tutorial
from ..AutoWorld import WebWorld, World from ..AutoWorld import WebWorld, World
class AP_SudokuWebWorld(WebWorld): class AP_SudokuWebWorld(WebWorld):
options_page = False options_page = "games/Sudoku/info/en"
theme = 'partyTime' theme = 'partyTime'
setup_en = Tutorial( setup_en = Tutorial(

View File

@@ -1,7 +1,9 @@
# APSudoku Setup Guide # APSudoku Setup Guide
## Required Software ## 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 ## 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 ## 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 ## Joining a MultiWorld Game
1. Run the APSudoku executable. 1. Run APSudoku.exe
2. Under `Settings` &rarr; `Connection` at the top-right: 2. Under the 'Archipelago' tab at the top-right:
- Enter the server address and port number - Enter the server url & port number
- Enter the name of the slot you wish to connect to - Enter the name of the slot you wish to connect to
- Enter the room password (optional) - Enter the room password (optional)
- Select DeathLink related settings (optional) - Select DeathLink related settings (optional)
- Press `Connect` - Press connect
4. Under the `Sudoku` tab 3. Go back to the 'Sudoku' tab
- Choose puzzle difficulty - Click the various '?' buttons for information on how to play / control
- Click `Start` to generate a puzzle 4. Choose puzzle difficulty
5. Try to solve the Sudoku. Click `Check` when done 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)
Info:
- You can set various settings under `Settings` &rarr; `Sudoku`, and can change the colors used under `Settings` &rarr; `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 ## DeathLink Support
If `DeathLink` is enabled when you click `Connect`: 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). - 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).
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle. - 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. - On receiving a DeathLink from another player, your puzzle resets.

View File

@@ -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]"
]
}

View File

@@ -637,35 +637,52 @@ item_table: List[ItemDict] = [
'classification': ItemClassification.filler} '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]] = { group_table: Dict[str, Set[str]] = {
"wounds" : {"Holy Wound of Attrition", "wounds" : ["Holy Wound of Attrition",
"Holy Wound of Contrition", "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", "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 Second Refuge",
"Mark of the Third Refuge"}, "Mark of the Third Refuge"],
"tirso" : {"Bouquet of Rosemary", "tirso" : ["Bouquet of Rosemary",
"Incense Garlic", "Incense Garlic",
"Olive Seeds", "Olive Seeds",
"Dried Clove", "Dried Clove",
"Sooty Garlic", "Sooty Garlic",
"Bouquet of Thyme"}, "Bouquet of Thyme"],
"tentudia": {"Tentudia's Carnal Remains", "tentudia": ["Tentudia's Carnal Remains",
"Remains of Tentudia's Hair", "Remains of Tentudia's Hair",
"Tentudia's Skeletal Remains"}, "Tentudia's Skeletal Remains"],
"egg" : {"Melted Golden Coins", "egg" : ["Melted Golden Coins",
"Torn Bridal Ribbon", "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", "Jaw of Ashgan, the Inquisitor",
"Cervical vertebra of Zicher, the Brewmaster", "Cervical vertebra of Zicher, the Brewmaster",
"Clavicle of Dalhuisen, the Schoolchild", "Clavicle of Dalhuisen, the Schoolchild",
@@ -708,14 +725,14 @@ group_table: Dict[str, Set[str]] = {
"Scaphoid of Fierce, the Leper", "Scaphoid of Fierce, the Leper",
"Anklebone of Weston, the Pilgrim", "Anklebone of Weston, the Pilgrim",
"Calcaneum of Persian, the Bandit", "Calcaneum of Persian, the Bandit",
"Navicular of Kahnnyhoo, the Murderer"}, "Navicular of Kahnnyhoo, the Murderer"],
"power" : {"Life Upgrade", "power" : ["Life Upgrade",
"Fervour Upgrade", "Fervour Upgrade",
"Empty Bile Vessel", "Empty Bile Vessel",
"Quicksilver"}, "Quicksilver"],
"prayer" : {"Seguiriya to your Eyes like Stars", "prayer" : ["Seguiriya to your Eyes like Stars",
"Debla of the Lights", "Debla of the Lights",
"Saeta Dolorosa", "Saeta Dolorosa",
"Campanillero to the Sons of the Aurora", "Campanillero to the Sons of the Aurora",
@@ -729,17 +746,10 @@ group_table: Dict[str, Set[str]] = {
"Romance to the Crimson Mist", "Romance to the Crimson Mist",
"Zambra to the Resplendent Crown", "Zambra to the Resplendent Crown",
"Cantina of the Blue Rose", "Cantina of the Blue Rose",
"Mirabras of the Return to Port"}, "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"}
} }
tears_list: List[str] = [ tears_set: Set[str] = [
"Tears of Atonement (500)", "Tears of Atonement (500)",
"Tears of Atonement (625)", "Tears of Atonement (625)",
"Tears of Atonement (750)", "Tears of Atonement (750)",
@@ -762,16 +772,16 @@ tears_list: List[str] = [
"Tears of Atonement (30000)" "Tears of Atonement (30000)"
] ]
reliquary_set: Set[str] = { reliquary_set: Set[str] = [
"Reliquary of the Fervent Heart", "Reliquary of the Fervent Heart",
"Reliquary of the Suffering Heart", "Reliquary of the Suffering Heart",
"Reliquary of the Sorrowful Heart" "Reliquary of the Sorrowful Heart"
} ]
skill_set: Set[str] = { skill_set: Set[str] = [
"Combo Skill", "Combo Skill",
"Charged Skill", "Charged Skill",
"Ranged Skill", "Ranged Skill",
"Dive Skill", "Dive Skill",
"Lunge Skill" "Lunge Skill"
} ]

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass from Options import Choice, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, PerGameCommonOptions, OptionGroup
import random import random
@@ -21,30 +20,23 @@ class ChoiceIsRandom(Choice):
class PrieDieuWarp(DefaultOnToggle): 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" display_name = "Unlock Fast Travel"
class SkipCutscenes(DefaultOnToggle): class SkipCutscenes(DefaultOnToggle):
""" """Automatically skips most cutscenes."""
Automatically skips most cutscenes.
"""
display_name = "Auto Skip Cutscenes" display_name = "Auto Skip Cutscenes"
class CorpseHints(DefaultOnToggle): 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" display_name = "Corpse Hints"
class Difficulty(Choice): class Difficulty(Choice):
""" """Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses
Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses and advanced movement tricks or glitches. and advanced movement tricks or glitches."""
"""
display_name = "Difficulty" display_name = "Difficulty"
option_easy = 0 option_easy = 0
option_normal = 1 option_normal = 1
@@ -53,18 +45,15 @@ class Difficulty(Choice):
class Penitence(Toggle): 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" display_name = "Penitence"
class StartingLocation(ChoiceIsRandom): class StartingLocation(ChoiceIsRandom):
""" """Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain
Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain other options. other options.
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends
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. cannot be chosen if Shuffle Wall Climb is enabled."""
"""
display_name = "Starting Location" display_name = "Starting Location"
option_brotherhood = 0 option_brotherhood = 0
option_albero = 1 option_albero = 1
@@ -77,15 +66,10 @@ class StartingLocation(ChoiceIsRandom):
class Ending(Choice): 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. Talking to Tirso in Albero will tell you the selected ending for the current game.
Ending A: Collect all thorn upgrades. 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" display_name = "Ending"
option_any_ending = 0 option_any_ending = 0
option_ending_a = 1 option_ending_a = 1
@@ -94,18 +78,14 @@ class Ending(Choice):
class SkipLongQuests(Toggle): class SkipLongQuests(Toggle):
""" """Ensures that the rewards for long quests will be filler items.
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\""""
Affected locations: "Albero: Donate 50000 Tears", "Ossuary: 11th reward", "AtTotS: Miriam's gift", "TSC: Jocinero's final reward"
"""
display_name = "Skip Long Quests" display_name = "Skip Long Quests"
class ThornShuffle(Choice): 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" display_name = "Shuffle Thorn"
option_anywhere = 0 option_anywhere = 0
option_local_only = 1 option_local_only = 1
@@ -114,68 +94,50 @@ class ThornShuffle(Choice):
class DashShuffle(Toggle): 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" display_name = "Shuffle Dash"
class WallClimbShuffle(Toggle): 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" display_name = "Shuffle Wall Climb"
class ReliquaryShuffle(DefaultOnToggle): 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" display_name = "Shuffle Penitence Rewards"
class CustomItem1(Toggle): class CustomItem1(Toggle):
""" """Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes
Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes and survive. and survive.
Must have the \"Blasphemous-Boots-of-Pleading\" mod installed to connect to a multiworld."""
Must have the "Boots of Pleading" mod installed to connect to a multiworld.
"""
display_name = "Boots of Pleading" display_name = "Boots of Pleading"
class CustomItem2(Toggle): class CustomItem2(Toggle):
""" """Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump
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. a second time in mid-air.
Must have the \"Blasphemous-Double-Jump\" mod installed to connect to a multiworld."""
Must have the "Double Jump" mod installed to connect to a multiworld.
"""
display_name = "Purified Hand of the Nun" display_name = "Purified Hand of the Nun"
class StartWheel(Toggle): 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" display_name = "Start with Wheel"
class SkillRando(Toggle): 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" display_name = "Skill Randomizer"
class EnemyRando(Choice): class EnemyRando(Choice):
""" """Randomizes the enemies that appear in each room.
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.
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. 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" display_name = "Enemy Randomizer"
option_disabled = 0 option_disabled = 0
option_shuffled = 1 option_shuffled = 1
@@ -184,75 +146,43 @@ class EnemyRando(Choice):
class EnemyGroups(DefaultOnToggle): class EnemyGroups(DefaultOnToggle):
""" """Randomized enemies will chosen from sets of specific groups.
Randomized enemies will be chosen from sets of specific groups.
(Weak, normal, large, flying) (Weak, normal, large, flying)
Has no effect if Enemy Randomizer is disabled."""
Has no effect if Enemy Randomizer is disabled.
"""
display_name = "Enemy Groups" display_name = "Enemy Groups"
class EnemyScaling(DefaultOnToggle): class EnemyScaling(DefaultOnToggle):
""" """Randomized enemies will have their stats increased or decreased depending on the area they appear in.
Randomized enemies will have their stats increased or decreased depending on the area they appear in. Has no effect if Enemy Randomizer is disabled."""
Has no effect if Enemy Randomizer is disabled.
"""
display_name = "Enemy Scaling" display_name = "Enemy Scaling"
class BlasphemousDeathLink(DeathLink): class BlasphemousDeathLink(DeathLink):
""" """When you die, everyone dies. The reverse is also true.
When you die, everyone dies. The reverse is also true. Note that Guilt Fragments will not appear when killed by Death Link."""
Note that Guilt Fragments will not appear when killed by Death Link.
"""
@dataclass blasphemous_options = {
class BlasphemousOptions(PerGameCommonOptions): "prie_dieu_warp": PrieDieuWarp,
prie_dieu_warp: PrieDieuWarp "skip_cutscenes": SkipCutscenes,
skip_cutscenes: SkipCutscenes "corpse_hints": CorpseHints,
corpse_hints: CorpseHints "difficulty": Difficulty,
difficulty: Difficulty "penitence": Penitence,
penitence: Penitence "starting_location": StartingLocation,
starting_location: StartingLocation "ending": Ending,
ending: Ending "skip_long_quests": SkipLongQuests,
skip_long_quests: SkipLongQuests "thorn_shuffle" : ThornShuffle,
thorn_shuffle: ThornShuffle "dash_shuffle": DashShuffle,
dash_shuffle: DashShuffle "wall_climb_shuffle": WallClimbShuffle,
wall_climb_shuffle: WallClimbShuffle "reliquary_shuffle": ReliquaryShuffle,
reliquary_shuffle: ReliquaryShuffle "boots_of_pleading": CustomItem1,
boots_of_pleading: CustomItem1 "purified_hand": CustomItem2,
purified_hand: CustomItem2 "start_wheel": StartWheel,
start_wheel: StartWheel "skill_randomizer": SkillRando,
skill_randomizer: SkillRando "enemy_randomizer": EnemyRando,
enemy_randomizer: EnemyRando "enemy_groups": EnemyGroups,
enemy_groups: EnemyGroups "enemy_scaling": EnemyScaling,
enemy_scaling: EnemyScaling "death_link": BlasphemousDeathLink,
death_link: BlasphemousDeathLink "start_inventory": StartInventoryPool
}
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
])
]

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,12 @@ unrandomized_dict: Dict[str, str] = {
} }
junk_locations: Set[str] = { junk_locations: Set[str] = [
"Albero: Donate 50000 Tears", "Albero: Donate 50000 Tears",
"Ossuary: 11th reward", "Ossuary: 11th reward",
"AtTotS: Miriam's gift", "AtTotS: Miriam's gift",
"TSC: Jocinero's final reward" "TSC: Jocinero's final reward"
} ]
thorn_set: Set[str] = { thorn_set: Set[str] = {
@@ -44,4 +44,4 @@ skill_dict: Dict[str, str] = {
"Skill 5, Tier 1": "Lunge Skill", "Skill 5, Tier 1": "Lunge Skill",
"Skill 5, Tier 2": "Lunge Skill", "Skill 5, Tier 2": "Lunge Skill",
"Skill 5, Tier 3": "Lunge Skill", "Skill 5, Tier 3": "Lunge Skill",
} }

View File

@@ -1,15 +1,15 @@
from typing import Dict, List, Set, Any from typing import Dict, List, Set, Any
from collections import Counter from collections import Counter
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification
from Options import OptionError
from worlds.AutoWorld import World, WebWorld from worlds.AutoWorld import World, WebWorld
from .Items import base_id, item_table, group_table, tears_list, reliquary_set from .Items import base_id, item_table, group_table, tears_set, reliquary_set, event_table
from .Locations import location_names from .Locations import location_table
from .Rules import BlasRules from .Rooms import room_table, door_table
from worlds.generic.Rules import set_rule from .Rules import rules
from .Options import BlasphemousOptions, blas_option_groups 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 .Vanilla import unrandomized_dict, junk_locations, thorn_set, skill_dict
from .region_data import regions, locations
class BlasphemousWeb(WebWorld): class BlasphemousWeb(WebWorld):
theme = "stone" theme = "stone"
@@ -21,33 +21,39 @@ class BlasphemousWeb(WebWorld):
"setup/en", "setup/en",
["TRPG"] ["TRPG"]
)] )]
option_groups = blas_option_groups
class BlasphemousWorld(World): class BlasphemousWorld(World):
""" """
Blasphemous is a challenging Metroidvania set in the cursed land of Cvstodia. Play as the Penitent One, trapped 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! your eternal damnation!
""" """
game = "Blasphemous" game: str = "Blasphemous"
web = BlasphemousWeb() web = BlasphemousWeb()
item_name_to_id = {item["name"]: (base_id + index) for index, item in enumerate(item_table)} 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 item_name_groups = group_table
options_dataclass = BlasphemousOptions option_definitions = blasphemous_options
options: BlasphemousOptions
required_client_version = (0, 4, 7) required_client_version = (0, 4, 2)
def __init__(self, multiworld, player): def __init__(self, multiworld, player):
super(BlasphemousWorld, self).__init__(multiworld, player) super(BlasphemousWorld, self).__init__(multiworld, player)
self.start_room: str = "D17Z01S01" 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": def create_item(self, name: str) -> "BlasphemousItem":
@@ -62,56 +68,64 @@ class BlasphemousWorld(World):
def get_filler_item_name(self) -> str: def get_filler_item_name(self) -> str:
return self.random.choice(tears_list) return self.multiworld.random.choice(tears_set)
def generate_early(self): def generate_early(self):
if not self.options.starting_location.randomized: world = self.multiworld
if self.options.starting_location == "mourning_havoc" and self.options.difficulty < 2: player = self.player
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
f"{self.options.starting_location} cannot be chosen if Difficulty is lower than Hard.")
if (self.options.starting_location == "brotherhood" or self.options.starting_location == "mourning_havoc") \ if not world.starting_location[player].randomized:
and self.options.dash_shuffle: if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
raise OptionError(f"[Blasphemous - '{self.player_name}'] " raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
f"{self.options.starting_location} cannot be chosen if Shuffle Dash is enabled.") " 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: if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
raise OptionError(f"[Blasphemous - '{self.player_name}'] " raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
f"{self.options.starting_location} cannot be chosen if Shuffle Wall Climb is enabled.") " cannot be chosen if Shuffle Wall Climb is enabled.")
else: else:
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ] 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) locations.remove(6)
if self.options.dash_shuffle: if world.dash_shuffle[player]:
locations.remove(0) locations.remove(0)
if 6 in locations: if 6 in locations:
locations.remove(6) locations.remove(6)
if self.options.wall_climb_shuffle: if world.wall_climb_shuffle[player]:
locations.remove(3) locations.remove(3)
if self.options.starting_location.value not in locations: if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
self.options.starting_location.value = self.random.choice(locations) 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: if not world.dash_shuffle[player]:
self.multiworld.push_precollected(self.create_item("Dash Ability")) world.push_precollected(self.create_item("Dash Ability"))
if not self.options.wall_climb_shuffle: if not world.wall_climb_shuffle[player]:
self.multiworld.push_precollected(self.create_item("Wall Climb Ability")) world.push_precollected(self.create_item("Wall Climb Ability"))
if not self.options.boots_of_pleading: if world.skip_long_quests[player]:
self.disabled_locations.append("RE401")
if not self.options.purified_hand:
self.disabled_locations.append("RE402")
if self.options.skip_long_quests:
for loc in junk_locations: for loc in junk_locations:
self.options.exclude_locations.value.add(loc) world.exclude_locations[player].value.add(loc)
start_rooms: Dict[int, str] = { start_rooms: Dict[int, str] = {
0: "D17Z01S01", 0: "D17Z01S01",
@@ -123,10 +137,13 @@ class BlasphemousWorld(World):
6: "D20Z02S09" 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): def create_items(self):
world = self.multiworld
player = self.player
removed: int = 0 removed: int = 0
to_remove: List[str] = [ to_remove: List[str] = [
"Tears of Atonement (250)", "Tears of Atonement (250)",
@@ -139,46 +156,46 @@ class BlasphemousWorld(World):
skipped_items = [] skipped_items = []
junk: int = 0 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): for _ in range(count):
skipped_items.append(item) skipped_items.append(item)
junk += 1 junk += 1
skipped_items.extend(unrandomized_dict.values()) skipped_items.extend(unrandomized_dict.values())
if self.options.thorn_shuffle == "vanilla": if world.thorn_shuffle[player] == 2:
for _ in range(8): for i in range(8):
skipped_items.append("Thorn Upgrade") skipped_items.append("Thorn Upgrade")
if self.options.dash_shuffle: if world.dash_shuffle[player]:
skipped_items.append(to_remove[removed]) skipped_items.append(to_remove[removed])
removed += 1 removed += 1
elif not self.options.dash_shuffle: elif not world.dash_shuffle[player]:
skipped_items.append("Dash Ability") skipped_items.append("Dash Ability")
if self.options.wall_climb_shuffle: if world.wall_climb_shuffle[player]:
skipped_items.append(to_remove[removed]) skipped_items.append(to_remove[removed])
removed += 1 removed += 1
elif not self.options.wall_climb_shuffle: elif not world.wall_climb_shuffle[player]:
skipped_items.append("Wall Climb Ability") skipped_items.append("Wall Climb Ability")
if not self.options.reliquary_shuffle: if not world.reliquary_shuffle[player]:
skipped_items.extend(reliquary_set) skipped_items.extend(reliquary_set)
elif self.options.reliquary_shuffle: elif world.reliquary_shuffle[player]:
for _ in range(3): for i in range(3):
skipped_items.append(to_remove[removed]) skipped_items.append(to_remove[removed])
removed += 1 removed += 1
if not self.options.boots_of_pleading: if not world.boots_of_pleading[player]:
skipped_items.append("Boots of Pleading") 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") 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") 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()) skipped_items.extend(skill_dict.values())
counter = Counter(skipped_items) counter = Counter(skipped_items)
@@ -191,138 +208,184 @@ class BlasphemousWorld(World):
if count <= 0: if count <= 0:
continue continue
else: else:
for _ in range(count): for i in range(count):
pool.append(self.create_item(item["name"])) pool.append(self.create_item(item["name"]))
for _ in range(junk): for _ in range(junk):
pool.append(self.create_item(self.get_filler_item_name())) 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) 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") self.place_items_from_set(thorn_set, "Thorn Upgrade")
if self.options.start_wheel: if world.start_wheel[player]:
self.get_location("Beginning gift").place_locked_item(self.create_item("The Young Mason's Wheel")) 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) self.place_items_from_dict(skill_dict)
if self.options.thorn_shuffle == "local_only": if world.thorn_shuffle[player] == 1:
self.options.local_items.value.add("Thorn Upgrade") world.local_items[player].value.add("Thorn Upgrade")
def place_items_from_set(self, location_set: Set[str], name: str): def place_items_from_set(self, location_set: Set[str], name: str):
for loc in location_set: 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]): def place_items_from_dict(self, option_dict: Dict[str, str]):
for loc, item in option_dict.items(): 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: def create_regions(self) -> None:
multiworld = self.multiworld
player = self.player player = self.player
world = self.multiworld
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")
victory = Location(player, "His Holiness Escribar", None, self.get_region("D07Z01S03[W]")) menu_region = Region("Menu", player, world)
victory.place_locked_item(self.create_event("Victory")) misc_region = Region("Misc", player, world)
self.get_region("D07Z01S03[W]").locations.append(victory) 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)) 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 set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8) and
state.has("Holy Wound of Abnegation", player)) 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]: def fill_slot_data(self) -> Dict[str, Any]:
slot_data: Dict[str, Any] = {} slot_data: Dict[str, Any] = {}
locations = []
doors: Dict[str, str] = {} doors: Dict[str, str] = {}
world = self.multiworld
player = self.player
thorns: bool = True thorns: bool = True
if self.options.thorn_shuffle == "vanilla": if world.thorn_shuffle[player].value == 2:
thorns = False 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 = { config = {
"LogicDifficulty": self.options.difficulty.value, "LogicDifficulty": world.difficulty[player].value,
"StartingLocation": self.options.starting_location.value, "StartingLocation": world.starting_location[player].value,
"VersionCreated": "AP", "VersionCreated": "AP",
"UnlockTeleportation": bool(self.options.prie_dieu_warp.value), "UnlockTeleportation": bool(world.prie_dieu_warp[player].value),
"AllowHints": bool(self.options.corpse_hints.value), "AllowHints": bool(world.corpse_hints[player].value),
"AllowPenitence": bool(self.options.penitence.value), "AllowPenitence": bool(world.penitence[player].value),
"ShuffleReliquaries": bool(self.options.reliquary_shuffle.value), "ShuffleReliquaries": bool(world.reliquary_shuffle[player].value),
"ShuffleBootsOfPleading": bool(self.options.boots_of_pleading.value), "ShuffleBootsOfPleading": bool(world.boots_of_pleading[player].value),
"ShufflePurifiedHand": bool(self.options.purified_hand.value), "ShufflePurifiedHand": bool(world.purified_hand[player].value),
"ShuffleDash": bool(self.options.dash_shuffle.value), "ShuffleDash": bool(world.dash_shuffle[player].value),
"ShuffleWallClimb": bool(self.options.wall_climb_shuffle.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, "ShuffleThorns": thorns,
"JunkLongQuests": bool(self.options.skip_long_quests.value), "JunkLongQuests": bool(world.skip_long_quests[player].value),
"StartWithWheel": bool(self.options.start_wheel.value), "StartWithWheel": bool(world.start_wheel[player].value),
"EnemyShuffleType": self.options.enemy_randomizer.value, "EnemyShuffleType": world.enemy_randomizer[player].value,
"MaintainClass": bool(self.options.enemy_groups.value), "MaintainClass": bool(world.enemy_groups[player].value),
"AreaScaling": bool(self.options.enemy_scaling.value), "AreaScaling": bool(world.enemy_scaling[player].value),
"BossShuffleType": 0, "BossShuffleType": 0,
"DoorShuffleType": 0 "DoorShuffleType": 0
} }
slot_data = { slot_data = {
"locationinfo": [{"gameId": loc, "apId": (base_id + index)} for index, loc in enumerate(location_names)], "locations": locations,
"doors": doors, "doors": doors,
"cfg": config, "cfg": config,
"ending": self.options.ending.value, "ending": world.ending[self.player].value,
"death_link": bool(self.options.death_link.value) "death_link": bool(world.death_link[self.player].value)
} }
return slot_data return slot_data
@@ -333,4 +396,4 @@ class BlasphemousItem(Item):
class BlasphemousLocation(Location): class BlasphemousLocation(Location):
game: str = "Blasphemous" game: str = "Blasphemous"

View File

@@ -1,17 +1,48 @@
# Blasphemous Multiworld Setup Guide # 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: Optional:
- [Rando Map](https://github.com/BrandenEK/Blasphemous.Randomizer.MapTracker) - In-game map tracker: [GitHub](https://github.com/BrandenEK/Blasphemous-Rando-Map)
- [Boots of Pleading](https://github.com/BrandenEK/Blasphemous.BootsOfPleading) (Required if the "Boots of Pleading" option is enabled) - Quick Prie Dieu warp mod: [GitHub](https://github.com/BadMagic100/Blasphemous-PrieWarp)
- [Double Jump](https://github.com/BrandenEK/Blasphemous.DoubleJump) (Required if the "Purified Hand of the Nun" option is enabled) - 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 `\`: 1. Download the [Mod Installer](https://github.com/BrandenEK/Blasphemous-Mod-Installer),
- `ap status` - Display connection status. and point it to your install directory for Blasphemous.
- `ap say [message]` - Send a message to the server.
- `ap hint [item]` - Request a hint for an item from the server. 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

View File

@@ -1,7 +0,0 @@
from test.bases import WorldTestBase
from .. import BlasphemousWorld
class BlasphemousTestBase(WorldTestBase):
game = "Blasphemous"
world: BlasphemousWorld

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -28,7 +28,7 @@ An Example `AP.json` file:
``` ```
{ {
"Url": "archipelago.gg:12345", "Url": "archipelago:12345",
"SlotName": "Maddy", "SlotName": "Maddy",
"Password": "" "Password": ""
} }

View File

@@ -44,15 +44,15 @@ class ChecksFinderWorld(World):
self.multiworld.regions += [menu, board] self.multiworld.regions += [menu, board]
def create_items(self): def create_items(self):
# Generate list of items # Generate item pool
items_to_create = [] itempool = []
# Add the map width and height stuff # Add the map width and height stuff
items_to_create += ["Map Width"] * 5 # 10 - 5 itempool += ["Map Width"] * 5 # 10 - 5
items_to_create += ["Map Height"] * 5 # 10 - 5 itempool += ["Map Height"] * 5 # 10 - 5
# Add the map bombs # Add the map bombs
items_to_create += ["Map Bombs"] * 15 # 20 - 5 itempool += ["Map Bombs"] * 15 # 20 - 5
# Convert list into real items # Convert itempool into real items
itempool = [self.create_item(item) for item in items_to_create] itempool = [self.create_item(item) for item in itempool]
self.multiworld.itempool += itempool self.multiworld.itempool += itempool

View File

@@ -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 from BaseClasses import Item, ItemClassification, MultiWorld
if TYPE_CHECKING:
from . import CliqueWorld
class CliqueItem(Item): class CliqueItem(Item):
@@ -13,7 +10,7 @@ class CliqueItem(Item):
class CliqueItemData(NamedTuple): class CliqueItemData(NamedTuple):
code: Optional[int] = None code: Optional[int] = None
type: ItemClassification = ItemClassification.filler 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] = { item_data_table: Dict[str, CliqueItemData] = {
@@ -24,11 +21,11 @@ item_data_table: Dict[str, CliqueItemData] = {
"Button Activation": CliqueItemData( "Button Activation": CliqueItemData(
code=69696968, code=69696968,
type=ItemClassification.progression, 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( "A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData(
code=69696967, 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( "The Urge to Push": CliqueItemData(
type=ItemClassification.progression, type=ItemClassification.progression,

View File

@@ -1,9 +1,6 @@
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING from typing import Callable, Dict, NamedTuple, Optional
from BaseClasses import Location from BaseClasses import Location, MultiWorld
if TYPE_CHECKING:
from . import CliqueWorld
class CliqueLocation(Location): class CliqueLocation(Location):
@@ -13,7 +10,7 @@ class CliqueLocation(Location):
class CliqueLocationData(NamedTuple): class CliqueLocationData(NamedTuple):
region: str region: str
address: Optional[int] = None 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 locked_item: Optional[str] = None
@@ -25,7 +22,7 @@ location_data_table: Dict[str, CliqueLocationData] = {
"The Item on the Desk": CliqueLocationData( "The Item on the Desk": CliqueLocationData(
region="The Button Realm", region="The Button Realm",
address=69696968, 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( "In the Player's Mind": CliqueLocationData(
region="The Button Realm", region="The Button Realm",

View File

@@ -1,5 +1,6 @@
from dataclasses import dataclass from typing import Dict
from Options import Choice, Toggle, PerGameCommonOptions, StartInventoryPool
from Options import Choice, Option, Toggle
class HardMode(Toggle): class HardMode(Toggle):
@@ -24,11 +25,10 @@ class ButtonColor(Choice):
option_black = 11 option_black = 11
@dataclass clique_options: Dict[str, type(Option)] = {
class CliqueOptions(PerGameCommonOptions): "color": ButtonColor,
color: ButtonColor "hard_mode": HardMode,
hard_mode: HardMode
start_inventory_from_pool: StartInventoryPool
# DeathLink is always on. Always. # DeathLink is always on. Always.
# death_link: DeathLink # "death_link": DeathLink,
}

View File

@@ -1,13 +1,10 @@
from typing import Callable, TYPE_CHECKING from typing import Callable
from BaseClasses import CollectionState from BaseClasses import CollectionState, MultiWorld
if TYPE_CHECKING:
from . import CliqueWorld
def get_button_rule(world: "CliqueWorld") -> Callable[[CollectionState], bool]: def get_button_rule(multiworld: MultiWorld, player: int) -> Callable[[CollectionState], bool]:
if world.options.hard_mode: if getattr(multiworld, "hard_mode")[player]:
return lambda state: state.has("Button Activation", world.player) return lambda state: state.has("Button Activation", player)
return lambda state: True return lambda state: True

View File

@@ -1,10 +1,10 @@
from typing import List, Dict, Any from typing import List
from BaseClasses import Region, Tutorial from BaseClasses import Region, Tutorial
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from .Items import CliqueItem, item_data_table, item_table from .Items import CliqueItem, item_data_table, item_table
from .Locations import CliqueLocation, location_data_table, location_table, locked_locations 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 .Regions import region_data_table
from .Rules import get_button_rule from .Rules import get_button_rule
@@ -38,8 +38,7 @@ class CliqueWorld(World):
game = "Clique" game = "Clique"
web = CliqueWebWorld() web = CliqueWebWorld()
options: CliqueOptions option_definitions = clique_options
options_dataclass = CliqueOptions
location_name_to_id = location_table location_name_to_id = location_table
item_name_to_id = item_table item_name_to_id = item_table
@@ -49,7 +48,7 @@ class CliqueWorld(World):
def create_items(self) -> None: def create_items(self) -> None:
item_pool: List[CliqueItem] = [] item_pool: List[CliqueItem] = []
for name, item in item_data_table.items(): 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)) item_pool.append(self.create_item(name))
self.multiworld.itempool += item_pool self.multiworld.itempool += item_pool
@@ -62,40 +61,41 @@ class CliqueWorld(World):
# Create locations. # Create locations.
for region_name, region_data in region_data_table.items(): 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({ region.add_locations({
location_name: location_data.address for location_name, location_data in location_data_table.items() 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) }, CliqueLocation)
region.add_exits(region_data_table[region_name].connecting_regions) region.add_exits(region_data_table[region_name].connecting_regions)
# Place locked locations. # Place locked locations.
for location_name, location_data in locked_locations.items(): for location_name, location_data in locked_locations.items():
# Ignore locations we never created. # Ignore locations we never created.
if not location_data.can_create(self): if not location_data.can_create(self.multiworld, self.player):
continue continue
locked_item = self.create_item(location_data_table[location_name].locked_item) 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! # 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: def get_filler_item_name(self) -> str:
return "A Cool Filler Item (No Satisfaction Guaranteed)" return "A Cool Filler Item (No Satisfaction Guaranteed)"
def set_rules(self) -> None: def set_rules(self) -> None:
button_rule = get_button_rule(self) button_rule = get_button_rule(self.multiworld, self.player)
self.get_location("The Big Red Button").access_rule = button_rule self.multiworld.get_location("The Big Red Button", self.player).access_rule = button_rule
self.get_location("In the Player's Mind").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. # 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. # Completion condition.
self.multiworld.completion_condition[self.player] = lambda state: state.has("The Urge to Push", self.player) 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 { return {
"color": self.options.color.current_key "color": getattr(self.multiworld, "color")[self.player].current_key
} }

View File

@@ -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

View File

@@ -1,78 +1,80 @@
from dataclasses import dataclass import typing
import json
from typing import Any, Dict
from Options import Choice, DeathLink, DefaultOnToggle, ExcludeLocations, NamedRange, OptionDict, \ from Options import Toggle, DefaultOnToggle, Option, Range, Choice, ItemDict, DeathLink
OptionGroup, PerGameCommonOptions, Range, Removed, Toggle
## Game Options
class EarlySmallLothricBanner(Choice): class RandomizeWeaponLocations(DefaultOnToggle):
"""Force Small Lothric Banner into an early sphere in your world or across all worlds.""" """Randomizes weapons (+76 locations)"""
display_name = "Early Small Lothric Banner" display_name = "Randomize Weapon Locations"
option_off = 0
option_early_global = 1
option_early_local = 2
default = option_off
class LateBasinOfVowsOption(Choice): class RandomizeShieldLocations(DefaultOnToggle):
"""Guarantee that you don't need to enter Lothric Castle until later in the run. """Randomizes shields (+24 locations)"""
display_name = "Randomize Shield Locations"
- **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 LateDLCOption(Choice): class RandomizeArmorLocations(DefaultOnToggle):
"""Guarantee that you don't need to enter the DLC until later in the run. """Randomizes armor pieces (+97 locations)"""
display_name = "Randomize Armor Locations"
- **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 EnableDLCOption(Toggle): class RandomizeRingLocations(DefaultOnToggle):
"""Include DLC locations, items, and enemies in the randomized pools. """Randomizes rings (+49 locations)"""
display_name = "Randomize Ring Locations"
To use this option, you must own both the "Ashes of Ariandel" and the "Ringed City" DLCs.
"""
display_name = "Enable DLC"
class EnableNGPOption(Toggle): class RandomizeSpellLocations(DefaultOnToggle):
"""Include items and locations exclusive to NG+ cycles.""" """Randomizes spells (+18 locations)"""
display_name = "Enable NG+" display_name = "Randomize Spell Locations"
## Equipment class RandomizeKeyLocations(DefaultOnToggle):
"""Randomizes items which unlock doors or bypass barriers"""
class RandomizeStartingLoadout(DefaultOnToggle): display_name = "Randomize Key Locations"
"""Randomizes the equipment characters begin with."""
display_name = "Randomize Starting Loadout"
class RequireOneHandedStartingWeapons(DefaultOnToggle): class RandomizeBossSoulLocations(DefaultOnToggle):
"""Require starting equipment to be usable one-handed.""" """Randomizes Boss Souls (+18 Locations)"""
display_name = "Require One-Handed Starting Weapons" 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): class AutoEquipOption(Toggle):
@@ -81,56 +83,47 @@ class AutoEquipOption(Toggle):
class LockEquipOption(Toggle): class LockEquipOption(Toggle):
"""Lock the equipment slots so you cannot change your armor or your left/right weapons. """Lock the equipment slots so you cannot change your armor or your left/right weapons. Works great with the
Auto-equip option."""
Works great with the Auto-equip option.
"""
display_name = "Lock Equipment Slots" display_name = "Lock Equipment Slots"
class NoEquipLoadOption(Toggle):
"""Disable the equip load constraint from the game."""
display_name = "No Equip Load"
class NoWeaponRequirementsOption(Toggle): class NoWeaponRequirementsOption(Toggle):
"""Disable the weapon requirements by removing any movement or damage penalties, permitting you """Disable the weapon requirements by removing any movement or damage penalties.
to use any weapon early. Permitting you to use any weapon early"""
"""
display_name = "No Weapon Requirements" display_name = "No Weapon Requirements"
class NoSpellRequirementsOption(Toggle): 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" 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): class RandomizeInfusionOption(Toggle):
"""Enable this option to infuse a percentage of the pool of weapons and shields.""" """Enable this option to infuse a percentage of the pool of weapons and shields."""
display_name = "Randomize Infusion" display_name = "Randomize Infusion"
class RandomizeInfusionPercentageOption(NamedRange): class RandomizeInfusionPercentageOption(Range):
"""The percentage of weapons/shields in the pool to be infused if Randomize Infusion is toggled. """The percentage of weapons/shields in the pool to be infused if Randomize Infusion is toggled"""
"""
display_name = "Percentage of Infused Weapons" display_name = "Percentage of Infused Weapons"
range_start = 0 range_start = 0
range_end = 100 range_end = 100
default = 33 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): class RandomizeWeaponLevelOption(Choice):
"""Enable this option to upgrade a percentage of the pool of weapons to a random value between """Enable this option to upgrade a percentage of the pool of weapons to a random value between the minimum and
the minimum and maximum levels defined. maximum levels defined.
- **All:** All weapons are eligible, both basic and epic All: All weapons are eligible, both basic and epic
- **Basic:** Only weapons that can be upgraded to +10 Basic: Only weapons that can be upgraded to +10
- **Epic:** Only weapons that can be upgraded to +5 Epic: Only weapons that can be upgraded to +5"""
"""
display_name = "Randomize Weapon Level" display_name = "Randomize Weapon Level"
option_none = 0 option_none = 0
option_all = 1 option_all = 1
@@ -139,7 +132,7 @@ class RandomizeWeaponLevelOption(Choice):
class RandomizeWeaponLevelPercentageOption(Range): 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" display_name = "Percentage of Randomized Weapons"
range_start = 0 range_start = 0
range_end = 100 range_end = 100
@@ -147,7 +140,7 @@ class RandomizeWeaponLevelPercentageOption(Range):
class MinLevelsIn5WeaponPoolOption(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" display_name = "Minimum Level of +5 Weapons"
range_start = 0 range_start = 0
range_end = 5 range_end = 5
@@ -155,7 +148,7 @@ class MinLevelsIn5WeaponPoolOption(Range):
class MaxLevelsIn5WeaponPoolOption(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" display_name = "Maximum Level of +5 Weapons"
range_start = 0 range_start = 0
range_end = 5 range_end = 5
@@ -163,7 +156,7 @@ class MaxLevelsIn5WeaponPoolOption(Range):
class MinLevelsIn10WeaponPoolOption(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" display_name = "Minimum Level of +10 Weapons"
range_start = 0 range_start = 0
range_end = 10 range_end = 10
@@ -171,308 +164,72 @@ class MinLevelsIn10WeaponPoolOption(Range):
class MaxLevelsIn10WeaponPoolOption(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" display_name = "Maximum Level of +10 Weapons"
range_start = 0 range_start = 0
range_end = 10 range_end = 10
default = 10 default = 10
## Item Smoothing 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
class SmoothSoulItemsOption(DefaultOnToggle): into an early sphere across all worlds."""
"""Distribute soul items in a similar order as the base game. display_name = "Early Small Lothric Banner"
option_off = 0
By default, soul items will be distributed totally randomly. If this is set, less valuable soul option_early_global = 1
items will generally appear in earlier spheres and more valuable ones will generally appear option_early_local = 2
later. default = option_off
"""
display_name = "Smooth Soul Items"
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
class SmoothUpgradeItemsOption(DefaultOnToggle): Lothric Castle to find your Small Lothric Banner to get out of High Wall of Lothric. So you may find Basin of Vows early,
"""Distribute upgrade items in a similar order as the base game. but you wont have to fight Dancer to find your Small Lothric Banner."""
display_name = "Late Basin of Vows"
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. class LateDLCOption(Toggle):
""" """This option makes it so you are guaranteed to find your Small Doll without having to venture off into the DLC,
display_name = "Smooth Upgrade Items" 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 SmoothUpgradedWeaponsOption(DefaultOnToggle):
"""Distribute upgraded weapons in a similar order as the base game.
class EnableDLCOption(Toggle):
By default, upgraded weapons will be distributed totally randomly. If this is set, lower-level """To use this option, you must own both the ASHES OF ARIANDEL and the RINGED CITY DLC"""
weapons will generally appear in earlier spheres and higher-level ones will generally appear display_name = "Enable DLC"
later.
"""
display_name = "Smooth Upgraded Weapons" dark_souls_options: typing.Dict[str, Option] = {
"enable_weapon_locations": RandomizeWeaponLocations,
"enable_shield_locations": RandomizeShieldLocations,
### Enemies "enable_armor_locations": RandomizeArmorLocations,
"enable_ring_locations": RandomizeRingLocations,
class RandomizeEnemiesOption(DefaultOnToggle): "enable_spell_locations": RandomizeSpellLocations,
"""Randomize enemy and boss placements.""" "enable_key_locations": RandomizeKeyLocations,
display_name = "Randomize Enemies" "enable_boss_locations": RandomizeBossSoulLocations,
"enable_npc_locations": RandomizeNPCLocations,
"enable_misc_locations": RandomizeMiscLocations,
class SimpleEarlyBossesOption(DefaultOnToggle): "enable_health_upgrade_locations": RandomizeHealthLocations,
"""Avoid replacing Iudex Gundyr and Vordt with late bosses. "enable_progressive_locations": RandomizeProgressiveLocationsOption,
"pool_type": PoolTypeOption,
This excludes all bosses after Dancer of the Boreal Valley from these two boss fights. Disable "guaranteed_items": GuaranteedItemsOption,
it for a chance at a much harder early game. "auto_equip": AutoEquipOption,
"lock_equip": LockEquipOption,
This is ignored unless enemies are randomized. "no_weapon_requirements": NoWeaponRequirementsOption,
""" "randomize_infusion": RandomizeInfusionOption,
display_name = "Simple Early Bosses" "randomize_infusion_percentage": RandomizeInfusionPercentageOption,
"randomize_weapon_level": RandomizeWeaponLevelOption,
"randomize_weapon_level_percentage": RandomizeWeaponLevelPercentageOption,
class ScaleEnemiesOption(DefaultOnToggle): "min_levels_in_5": MinLevelsIn5WeaponPoolOption,
"""Scale randomized enemy stats to match the areas in which they appear. "max_levels_in_5": MaxLevelsIn5WeaponPoolOption,
"min_levels_in_10": MinLevelsIn10WeaponPoolOption,
Disabling this will tend to make the early game much more difficult and the late game much "max_levels_in_10": MaxLevelsIn10WeaponPoolOption,
easier. "early_banner": EarlySmallLothricBanner,
"late_basin_of_vows": LateBasinOfVowsOption,
This is ignored unless enemies are randomized. "late_dlc": LateDLCOption,
""" "no_spell_requirements": NoSpellRequirementsOption,
display_name = "Scale Enemies" "no_equip_load": NoEquipLoadOption,
"death_link": DeathLink,
"enable_dlc": EnableDLCOption,
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,
])
]

File diff suppressed because it is too large Load Diff

View File

@@ -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!")

View File

@@ -1,201 +1,28 @@
# Dark Souls III # 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? ## Where is the options page?
The [player options page for this game][options] contains all the options you The [player options page for this game](../player-options) contains all the options you need to configure and export a
need to configure and export a config file. config file.
[options]: ../player-options
## What does randomization do to this game? ## What does randomization do to this game?
1. All item locations are randomized, including those in the overworld, in Items that can be picked up from static corpses, taken from chests, or earned from defeating enemies or NPCs can be
shops, and dropped by enemies. Most locations can contain games from other randomized. Common pickups like titanite shards or firebombs can be randomized as "progressive" items. That is, the
worlds, and any items from your world can appear in other players' worlds. 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 It's also possible to randomize the upgrade level of weapons and shields as well as their infusions (if they can have
setting "Randomize Enemies" to false. 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 The goal is to find the four "Cinders of a Lord" items randomized into the multiworld and defeat the Soul of Cinder.
disabled by setting "Randomize Starting Loadout" to false.
4. By setting the "Randomize Weapon Level" or "Randomize Infusion" options, you ## What Dark Souls III items can appear in other players' worlds?
can randomize whether the weapons you find will be upgraded or infused.
There are also options that can make playing the game more convenient or Practically anything can be found in other worlds including pieces of armor, upgraded weapons, key items, consumables,
bring a new experience, like removing equip loads or auto-equipping weapons as spells, upgrade materials, etc...
you pick them up. Check out [the options page][options] for more!
## 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 In Dark Souls III, items which are sent to other worlds appear as Prism Stones.
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.

View File

@@ -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

View File

@@ -7,49 +7,48 @@
## Optional Software ## 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; <span style="color:#ff7800">
it'll automatically locate _Dark Souls III_ in your Steam installation folder. **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 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.
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.
### 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 To downpatch DS3 for use with Archipelago, use the following instructions from the speedsouls wiki database.
randomized item and (optionally) enemy locations. You only need to do this once per multiworld.
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 1. Run Steam in offline mode to avoid being banned.
DS3 servers, and running Steam in offline mode will make certain scripted invaders fail to spawn. 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 ## Where do I get a config file?
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?
The [Player Options](/games/Dark%20Souls%20III/player-options) page on the website allows you to 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. configure your personal options and export them into a config file.

View File

@@ -1,27 +0,0 @@
from test.TestBase import WorldTestBase
from worlds.dark_souls_3.Items import item_dictionary
from worlds.dark_souls_3.Locations import location_tables
from worlds.dark_souls_3.Bosses import all_bosses
class DarkSouls3Test(WorldTestBase):
game = "Dark Souls III"
def testLocationDefaultItems(self):
for locations in location_tables.values():
for location in locations:
if location.default_item_name:
self.assertIn(location.default_item_name, item_dictionary)
def testLocationsUnique(self):
names = set()
for locations in location_tables.values():
for location in locations:
self.assertNotIn(location.name, names)
names.add(location.name)
def testBossLocations(self):
all_locations = {location.name for locations in location_tables.values() for location in locations}
for boss in all_bosses:
for location in boss.locations:
self.assertIn(location, all_locations)

View File

@@ -2,7 +2,7 @@
## Required Software ## Required Software
- [DOOM 1993 (e.g. Steam version)](https://store.steampowered.com/app/2280/DOOM__DOOM_II/) - [DOOM 1993 (e.g. Steam version)](https://store.steampowered.com/app/2280/DOOM_1993/)
- [Archipelago Crispy DOOM](https://github.com/Daivuk/apdoom/releases) - [Archipelago Crispy DOOM](https://github.com/Daivuk/apdoom/releases)
## Optional Software ## Optional Software

View File

@@ -1470,7 +1470,7 @@ location_table: Dict[int, LocationDict] = {
'map': 6, 'map': 6,
'index': 102, 'index': 102,
'doom_type': 2006, 'doom_type': 2006,
'region': "Tenements (MAP17) Yellow"}, 'region': "Tenements (MAP17) Main"},
361243: {'name': 'Tenements (MAP17) - Plasma gun', 361243: {'name': 'Tenements (MAP17) - Plasma gun',
'episode': 2, 'episode': 2,
'map': 6, 'map': 6,

View File

@@ -2,7 +2,7 @@
## Required Software ## Required Software
- [DOOM II (e.g. Steam version)](https://store.steampowered.com/app/2280/DOOM__DOOM_II/) - [DOOM II (e.g. Steam version)](https://store.steampowered.com/app/2300/DOOM_II/)
- [Archipelago Crispy DOOM](https://github.com/Daivuk/apdoom/releases) - [Archipelago Crispy DOOM](https://github.com/Daivuk/apdoom/releases)
## Optional Software ## Optional Software

View File

@@ -1,6 +1,5 @@
"""Outputs a Factorio Mod to facilitate integration with Archipelago""" """Outputs a Factorio Mod to facilitate integration with Archipelago"""
import dataclasses
import json import json
import os import os
import shutil import shutil
@@ -89,8 +88,6 @@ class FactorioModFile(worlds.Files.APContainer):
def generate_mod(world: "Factorio", output_directory: str): def generate_mod(world: "Factorio", output_directory: str):
player = world.player player = world.player
multiworld = world.multiworld multiworld = world.multiworld
random = world.random
global data_final_template, locale_template, control_template, data_template, settings_template global data_final_template, locale_template, control_template, data_template, settings_template
with template_load_lock: with template_load_lock:
if not data_final_template: if not data_final_template:
@@ -113,6 +110,8 @@ def generate_mod(world: "Factorio", output_directory: str):
mod_name = f"AP-{multiworld.seed_name}-P{player}-{multiworld.get_file_safe_player_name(player)}" mod_name = f"AP-{multiworld.seed_name}-P{player}-{multiworld.get_file_safe_player_name(player)}"
versioned_mod_name = mod_name + "_" + Utils.__version__ versioned_mod_name = mod_name + "_" + Utils.__version__
random = multiworld.per_slot_randoms[player]
def flop_random(low, high, base=None): def flop_random(low, high, base=None):
"""Guarantees 50% below base and 50% above base, uniform distribution in each direction.""" """Guarantees 50% below base and 50% above base, uniform distribution in each direction."""
if base: if base:
@@ -130,43 +129,43 @@ def generate_mod(world: "Factorio", output_directory: str):
"base_tech_table": base_tech_table, "base_tech_table": base_tech_table,
"tech_to_progressive_lookup": tech_to_progressive_lookup, "tech_to_progressive_lookup": tech_to_progressive_lookup,
"mod_name": mod_name, "mod_name": mod_name,
"allowed_science_packs": world.options.max_science_pack.get_allowed_packs(), "allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(),
"custom_technologies": world.custom_technologies, "custom_technologies": multiworld.worlds[player].custom_technologies,
"tech_tree_layout_prerequisites": world.tech_tree_layout_prerequisites, "tech_tree_layout_prerequisites": world.tech_tree_layout_prerequisites,
"slot_name": world.player_name, "seed_name": multiworld.seed_name, "slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name,
"slot_player": player, "slot_player": player,
"starting_items": world.options.starting_items, "recipes": recipes, "starting_items": multiworld.starting_items[player], "recipes": recipes,
"random": random, "flop_random": flop_random, "random": random, "flop_random": flop_random,
"recipe_time_scale": recipe_time_scales.get(world.options.recipe_time.value, None), "recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None),
"recipe_time_range": recipe_time_ranges.get(world.options.recipe_time.value, None), "recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None),
"free_sample_blacklist": {item: 1 for item in free_sample_exclusions}, "free_sample_blacklist": {item: 1 for item in free_sample_exclusions},
"progressive_technology_table": {tech.name: tech.progressive for tech in "progressive_technology_table": {tech.name: tech.progressive for tech in
progressive_technology_table.values()}, progressive_technology_table.values()},
"custom_recipes": world.custom_recipes, "custom_recipes": world.custom_recipes,
"max_science_pack": world.options.max_science_pack.value, "max_science_pack": multiworld.max_science_pack[player].value,
"liquids": fluids, "liquids": fluids,
"goal": world.options.goal.value, "goal": multiworld.goal[player].value,
"energy_link": world.options.energy_link.value, "energy_link": multiworld.energy_link[player].value,
"useless_technologies": useless_technologies, "useless_technologies": useless_technologies,
"chunk_shuffle": 0, "chunk_shuffle": multiworld.chunk_shuffle[player].value if hasattr(multiworld, "chunk_shuffle") else 0,
} }
for factorio_option, factorio_option_instance in dataclasses.asdict(world.options).items(): for factorio_option in Options.factorio_options:
if factorio_option in ["free_sample_blacklist", "free_sample_whitelist"]: if factorio_option in ["free_sample_blacklist", "free_sample_whitelist"]:
continue continue
template_data[factorio_option] = factorio_option_instance.value template_data[factorio_option] = getattr(multiworld, factorio_option)[player].value
if world.options.silo == Options.Silo.option_randomize_recipe: if getattr(multiworld, "silo")[player].value == Options.Silo.option_randomize_recipe:
template_data["free_sample_blacklist"]["rocket-silo"] = 1 template_data["free_sample_blacklist"]["rocket-silo"] = 1
if world.options.satellite == Options.Satellite.option_randomize_recipe: if getattr(multiworld, "satellite")[player].value == Options.Satellite.option_randomize_recipe:
template_data["free_sample_blacklist"]["satellite"] = 1 template_data["free_sample_blacklist"]["satellite"] = 1
template_data["free_sample_blacklist"].update({item: 1 for item in world.options.free_sample_blacklist.value}) template_data["free_sample_blacklist"].update({item: 1 for item in multiworld.free_sample_blacklist[player].value})
template_data["free_sample_blacklist"].update({item: 0 for item in world.options.free_sample_whitelist.value}) template_data["free_sample_blacklist"].update({item: 0 for item in multiworld.free_sample_whitelist[player].value})
zf_path = os.path.join(output_directory, versioned_mod_name + ".zip") zf_path = os.path.join(output_directory, versioned_mod_name + ".zip")
mod = FactorioModFile(zf_path, player=player, player_name=world.player_name) mod = FactorioModFile(zf_path, player=player, player_name=multiworld.player_name[player])
if world.zip_path: if world.zip_path:
with zipfile.ZipFile(world.zip_path) as zf: with zipfile.ZipFile(world.zip_path) as zf:

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