mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-13 11:03:49 -07:00
Compare commits
1 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cfdf4b340 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
7
.github/workflows/unittests.yml
vendored
7
.github/workflows/unittests.yml
vendored
@@ -37,13 +37,12 @@ jobs:
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
python:
|
||||
- {version: '3.12'} # current
|
||||
- {version: '3.11'} # current
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
189
BaseClasses.py
189
BaseClasses.py
@@ -11,10 +11,8 @@ from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, Type)
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
||||
TypedDict, Union, Type, ClassVar
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
@@ -24,16 +22,16 @@ if typing.TYPE_CHECKING:
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
class Group(TypedDict):
|
||||
class Group(TypedDict, total=False):
|
||||
name: str
|
||||
game: str
|
||||
world: "AutoWorld.World"
|
||||
players: AbstractSet[int]
|
||||
item_pool: NotRequired[Set[str]]
|
||||
replacement_items: NotRequired[Dict[int, Optional[str]]]
|
||||
local_items: NotRequired[Set[str]]
|
||||
non_local_items: NotRequired[Set[str]]
|
||||
link_replacement: NotRequired[bool]
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
link_replacement: bool
|
||||
|
||||
|
||||
class ThreadBarrierProxy:
|
||||
@@ -50,11 +48,6 @@ class ThreadBarrierProxy:
|
||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||
|
||||
|
||||
class HasNameAndPlayer(Protocol):
|
||||
name: str
|
||||
player: int
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
debug_types = False
|
||||
player_name: Dict[int, str]
|
||||
@@ -163,7 +156,7 @@ class MultiWorld():
|
||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr: str, val) -> None:
|
||||
def set_player_attr(attr, val):
|
||||
self.__dict__.setdefault(attr, {})[player] = val
|
||||
set_player_attr('plando_items', [])
|
||||
set_player_attr('plando_texts', {})
|
||||
@@ -172,13 +165,13 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||
"world's random object instead (usually self.random)")
|
||||
"world's random object instead (usually self.random)")
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
"""Create a group with name and return the assigned player ID and group.
|
||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||
from worlds import AutoWorld
|
||||
@@ -202,7 +195,7 @@ class MultiWorld():
|
||||
|
||||
return new_id, new_group
|
||||
|
||||
def get_player_groups(self, player: int) -> Set[int]:
|
||||
def get_player_groups(self, player) -> Set[int]:
|
||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||
|
||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||
@@ -265,7 +258,7 @@ class MultiWorld():
|
||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||
}
|
||||
|
||||
for _name, item_link in item_links.items():
|
||||
for name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
local_items = set()
|
||||
@@ -342,8 +335,6 @@ class MultiWorld():
|
||||
region = Region("Menu", group_id, self, "ItemLink")
|
||||
self.regions.append(region)
|
||||
locations = region.locations
|
||||
# ensure that progression items are linked first, then non-progression
|
||||
self.itempool.sort(key=lambda item: item.advancement)
|
||||
for item in self.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
@@ -397,7 +388,7 @@ class MultiWorld():
|
||||
return tuple(world for player, world in self.worlds.items() if
|
||||
player not in self.groups and self.game[player] == game_name)
|
||||
|
||||
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
|
||||
def get_name_string_for_object(self, obj) -> str:
|
||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||
|
||||
def get_player_name(self, player: int) -> str:
|
||||
@@ -439,7 +430,7 @@ class MultiWorld():
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_advancements()
|
||||
ret.sweep_for_events()
|
||||
|
||||
if use_cache:
|
||||
self._all_state = ret
|
||||
@@ -448,7 +439,7 @@ class MultiWorld():
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
@@ -457,7 +448,7 @@ class MultiWorld():
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player]
|
||||
|
||||
def find_item(self, item: str, player: int) -> Location:
|
||||
def find_item(self, item, player: int) -> Location:
|
||||
return next(location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player)
|
||||
|
||||
@@ -550,9 +541,9 @@ class MultiWorld():
|
||||
return True
|
||||
state = starting_state.copy()
|
||||
else:
|
||||
state = CollectionState(self)
|
||||
if self.has_beaten_game(state):
|
||||
if self.has_beaten_game(self.state):
|
||||
return True
|
||||
state = CollectionState(self)
|
||||
prog_locations = {location for location in self.get_locations() if location.item
|
||||
and location.item.advancement and location not in state.locations_checked}
|
||||
|
||||
@@ -625,7 +616,8 @@ class MultiWorld():
|
||||
|
||||
def location_relevant(location: Location) -> bool:
|
||||
"""Determine if this location is relevant to sweep."""
|
||||
return location.player in players["full"] or location.advancement
|
||||
return location.progress_type != LocationProgressType.EXCLUDED \
|
||||
and (location.player in players["full"] or location.advancement)
|
||||
|
||||
def all_done() -> bool:
|
||||
"""Check if all access rules are fulfilled"""
|
||||
@@ -670,7 +662,7 @@ class CollectionState():
|
||||
multiworld: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
advancements: Set[Location]
|
||||
events: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
@@ -682,7 +674,7 @@ class CollectionState():
|
||||
self.multiworld = parent
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.advancements = set()
|
||||
self.events = set()
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
@@ -694,25 +686,17 @@ class CollectionState():
|
||||
|
||||
def update_reachable_regions(self, player: int):
|
||||
self.stale[player] = False
|
||||
world: AutoWorld.World = self.multiworld.worlds[player]
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
queue = deque(self.blocked_connections[player])
|
||||
start: Region = world.get_region(world.origin_region_name)
|
||||
start = self.multiworld.get_region("Menu", player)
|
||||
|
||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||
if start not in reachable_regions:
|
||||
reachable_regions.add(start)
|
||||
self.blocked_connections[player].update(start.exits)
|
||||
blocked_connections.update(start.exits)
|
||||
queue.extend(start.exits)
|
||||
|
||||
if world.explicit_indirect_conditions:
|
||||
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
|
||||
else:
|
||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
while queue:
|
||||
connection = queue.popleft()
|
||||
@@ -720,7 +704,7 @@ class CollectionState():
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
@@ -732,29 +716,6 @@ class CollectionState():
|
||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
new_connection: bool = True
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
while new_connection:
|
||||
new_connection = False
|
||||
while queue:
|
||||
connection = queue.popleft()
|
||||
new_region = connection.connected_region
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
new_connection = True
|
||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||
queue.extend(blocked_connections)
|
||||
|
||||
def copy(self) -> CollectionState:
|
||||
ret = CollectionState(self.multiworld)
|
||||
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
|
||||
@@ -762,7 +723,7 @@ class CollectionState():
|
||||
self.reachable_regions.items()}
|
||||
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
|
||||
self.blocked_connections.items()}
|
||||
ret.advancements = self.advancements.copy()
|
||||
ret.events = self.events.copy()
|
||||
ret.path = self.path.copy()
|
||||
ret.locations_checked = self.locations_checked.copy()
|
||||
for function in self.additional_copy_functions:
|
||||
@@ -795,24 +756,19 @@ class CollectionState():
|
||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
||||
|
||||
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
|
||||
"Please switch over to sweep_for_advancements.")
|
||||
return self.sweep_for_advancements(locations)
|
||||
|
||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.multiworld.get_filled_locations()
|
||||
reachable_advancements = True
|
||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||
reachable_events = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.advancement and location not in self.events}
|
||||
|
||||
while reachable_advancements:
|
||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_advancements
|
||||
for advancement in reachable_advancements:
|
||||
self.advancements.add(advancement)
|
||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(advancement.item, True, advancement)
|
||||
while reachable_events:
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_events
|
||||
for event in reachable_events:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
|
||||
# item name related
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
@@ -846,7 +802,7 @@ class CollectionState():
|
||||
if found >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||
Ignores duplicates of the same item."""
|
||||
@@ -861,7 +817,7 @@ class CollectionState():
|
||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
|
||||
|
||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||
@@ -907,16 +863,20 @@ class CollectionState():
|
||||
)
|
||||
|
||||
# Item related
|
||||
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||
|
||||
if not changed and event:
|
||||
self.prog_items[item.player][item.name] += 1
|
||||
changed = True
|
||||
|
||||
self.stale[item.player] = True
|
||||
|
||||
if changed and not prevent_sweep:
|
||||
self.sweep_for_advancements()
|
||||
if changed and not event:
|
||||
self.sweep_for_events()
|
||||
|
||||
return changed
|
||||
|
||||
@@ -940,13 +900,12 @@ class Entrance:
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.player = player
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||
if not self.hide_path and not self in state.path:
|
||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||
@@ -961,6 +920,9 @@ class Entrance:
|
||||
region.entrances.append(self)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1086,7 +1048,7 @@ class Region:
|
||||
self.locations.append(location_type(self.player, location, address, self))
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
@@ -1126,6 +1088,9 @@ class Region:
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
@@ -1144,9 +1109,9 @@ class Location:
|
||||
locked: bool = False
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
always_allow = staticmethod(lambda state, item: False)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||
@@ -1155,19 +1120,15 @@ class Location:
|
||||
self.address = address
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
|
||||
return ((
|
||||
self.always_allow(state, item)
|
||||
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
|
||||
) or (
|
||||
(self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))
|
||||
))
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))))
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
||||
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
|
||||
assert self.parent_region, "Can't reach location without region"
|
||||
return self.parent_region.can_reach(state) and self.access_rule(state)
|
||||
|
||||
def place_locked_item(self, item: Item):
|
||||
@@ -1178,6 +1139,9 @@ class Location:
|
||||
self.locked = True
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1199,7 +1163,7 @@ class Location:
|
||||
@property
|
||||
def native_item(self) -> bool:
|
||||
"""Returns True if the item in this location matches game."""
|
||||
return self.item is not None and self.item.game == self.game
|
||||
return self.item and self.item.game == self.game
|
||||
|
||||
@property
|
||||
def hint_text(self) -> str:
|
||||
@@ -1210,7 +1174,7 @@ class ItemClassification(IntFlag):
|
||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||
progression = 0b0001 # Item that is logically relevant
|
||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||
trap = 0b0100 # detrimental item
|
||||
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
||||
skip_balancing = 0b1000 # should technically never occur on its own
|
||||
# Item that is logically relevant, but progression balancing should not touch.
|
||||
# Typically currency or other counted items.
|
||||
@@ -1282,6 +1246,9 @@ class Item:
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||
return f"{self.name} (Player {self.player})"
|
||||
@@ -1359,9 +1326,9 @@ class Spoiler:
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later: Dict[Location, Item] = {}
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete: Set[Location] = set()
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
@@ -1379,7 +1346,7 @@ class Spoiler:
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected: List[Item] = []
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
@@ -1460,7 +1427,7 @@ class Spoiler:
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||
for (_, exit_path) in path):
|
||||
if multiworld.worlds[player].options.mode != 'inverted':
|
||||
if multiworld.mode[player] != 'inverted':
|
||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
@@ -1532,9 +1499,9 @@ class Spoiler:
|
||||
|
||||
if self.paths:
|
||||
outfile.write('\n\nPaths:\n\n')
|
||||
path_listings: List[str] = []
|
||||
path_listings = []
|
||||
for location, path in sorted(self.paths.items()):
|
||||
path_lines: List[str] = []
|
||||
path_lines = []
|
||||
for region, exit in path:
|
||||
if exit is not None:
|
||||
path_lines.append("{} -> {}".format(region, exit))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds._bizhawk.context import launch
|
||||
|
||||
if __name__ == "__main__":
|
||||
launch(*sys.argv[1:])
|
||||
launch()
|
||||
|
||||
@@ -252,7 +252,7 @@ class CommonContext:
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui: typing.Optional["kvui.GameManager"] = None
|
||||
ui = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
@@ -662,19 +662,17 @@ class CommonContext:
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
return TextManager
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
|
||||
ui_class = self.make_gui()
|
||||
self.ui = ui_class(self)
|
||||
self.ui = TextManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
@@ -996,7 +994,7 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
return parser
|
||||
|
||||
|
||||
def run_as_textclient(*args):
|
||||
def run_as_textclient():
|
||||
class TextContext(CommonContext):
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
tags = CommonContext.tags | {"TextOnly"}
|
||||
@@ -1035,18 +1033,15 @@ def run_as_textclient(*args):
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args(args)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
if url.scheme == "archipelago":
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
else:
|
||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
colorama.init()
|
||||
|
||||
@@ -1056,4 +1051,4 @@ def run_as_textclient(*args):
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
||||
run_as_textclient(*sys.argv[1:]) # default value for parse_args
|
||||
run_as_textclient()
|
||||
|
||||
53
Fill.py
53
Fill.py
@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
|
||||
if "multiworld" in kwargs and isinstance(args[0], str):
|
||||
placements = (args[0] + f"\nAll Placements:\n" +
|
||||
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
|
||||
args = (placements, *args[1:])
|
||||
super().__init__(*args)
|
||||
pass
|
||||
|
||||
|
||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||
@@ -29,7 +24,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_advancements(locations=locations)
|
||||
new_state.sweep_for_events(locations=locations)
|
||||
return new_state
|
||||
|
||||
|
||||
@@ -217,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
|
||||
item_pool.extend(unplaced_items)
|
||||
|
||||
@@ -304,7 +299,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
@@ -329,8 +324,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
||||
pool.append(location.item)
|
||||
state.remove(location.item)
|
||||
location.item = None
|
||||
if location in state.advancements:
|
||||
state.advancements.remove(location)
|
||||
if location in state.events:
|
||||
state.events.remove(location)
|
||||
locations.append(location)
|
||||
if pool and locations:
|
||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||
@@ -363,7 +358,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
loc_indexes_to_remove: typing.Set[int] = set()
|
||||
base_state = multiworld.state.copy()
|
||||
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
for i, loc in enumerate(fill_locations):
|
||||
if loc.can_reach(base_state):
|
||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||
@@ -475,26 +470,28 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
nonlocal lock_later
|
||||
lock_later.append(location)
|
||||
|
||||
single_player = multiworld.players == 1 and not multiworld.groups
|
||||
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
||||
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
||||
name="Priority")
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
# "advancement/progression fill"
|
||||
if panic_method == "swap":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
||||
name="Progression", single_player_placement=single_player)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "raise":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||
name="Progression", single_player_placement=single_player)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "start_inventory":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False, allow_partial=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
if progitempool:
|
||||
for item in progitempool:
|
||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||
@@ -509,8 +506,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f"Not enough locations for progression items. "
|
||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
||||
multiworld=multiworld,
|
||||
f"There are {len(progitempool)} more progression items than there are available locations."
|
||||
)
|
||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
@@ -527,8 +523,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. "
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
||||
multiworld=multiworld,
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
||||
)
|
||||
|
||||
restitempool = filleritempool + usefulitempool
|
||||
@@ -556,7 +551,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
progress_done = False
|
||||
|
||||
# sweep once to pick up preplaced items
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_events()
|
||||
|
||||
# fill multiworld from top of itempool while we can
|
||||
while not progress_done:
|
||||
@@ -594,7 +589,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
else:
|
||||
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
||||
raise FillError('No more progress items left to place.')
|
||||
|
||||
# find item to replace with progress item
|
||||
location_list = multiworld.get_reachable_locations()
|
||||
@@ -744,7 +739,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_advancements(locations=locations_to_test)
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
|
||||
if multiworld.has_beaten_game(balancing_state):
|
||||
if not multiworld.has_beaten_game(reducing_state):
|
||||
@@ -827,7 +822,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_advancements()
|
||||
swept_state.sweep_for_events()
|
||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
|
||||
36
Generate.py
36
Generate.py
@@ -43,10 +43,10 @@ def mystery_argparse():
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument("--csv_output", action="store_true",
|
||||
help="Output rolled player options to csv (made for async multiworld).")
|
||||
parser.add_argument("--plando", default=defaults.plando_options,
|
||||
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults.plando_options,
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||
help="Skip progression balancing step during generation.")
|
||||
parser.add_argument("--skip_output", action="store_true",
|
||||
@@ -155,8 +155,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
erargs.skip_output = args.skip_output
|
||||
erargs.name = {}
|
||||
erargs.csv_output = args.csv_output
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
@@ -204,7 +202,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif player not in erargs.name: # if name was not specified, generate it from filename
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
@@ -217,6 +215,28 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
return erargs, seed
|
||||
|
||||
|
||||
@@ -491,7 +511,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
if __name__ == '__main__':
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||
|
||||
from worlds.kh1.Client import launch
|
||||
launch()
|
||||
105
Launcher.py
105
Launcher.py
@@ -16,11 +16,10 @@ import multiprocessing
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||
from typing import Callable, Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
@@ -108,81 +107,7 @@ components.extend([
|
||||
])
|
||||
|
||||
|
||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
launch_args = (path, *launch_args)
|
||||
client_component = None
|
||||
text_client_component = None
|
||||
if "game" in queries:
|
||||
game = queries["game"][0]
|
||||
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
||||
game = "Archipelago"
|
||||
for component in components:
|
||||
if component.supports_uri and component.game_name == game:
|
||||
client_component = component
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
||||
|
||||
class Popup(App):
|
||||
timer_label: Label
|
||||
remaining_time: Optional[int]
|
||||
|
||||
def __init__(self):
|
||||
self.title = "Connect to Multiworld"
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
layout = BoxLayout(orientation="vertical")
|
||||
|
||||
if client_component is None:
|
||||
self.remaining_time = 7
|
||||
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
||||
f"Launching Text Client in 7 seconds...")
|
||||
self.timer_label = Label(text=label_text)
|
||||
layout.add_widget(self.timer_label)
|
||||
Clock.schedule_interval(self.update_label, 1)
|
||||
else:
|
||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||
|
||||
text_client_button = Button(
|
||||
text=text_client_component.display_name,
|
||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(text_client_button)
|
||||
|
||||
game_client_button = Button(
|
||||
text=client_component.display_name,
|
||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(game_client_button)
|
||||
|
||||
layout.add_widget(button_row)
|
||||
|
||||
return layout
|
||||
|
||||
def update_label(self, dt):
|
||||
if self.remaining_time > 1:
|
||||
# countdown the timer and string replace the number
|
||||
self.remaining_time -= 1
|
||||
self.timer_label.text = self.timer_label.text.replace(
|
||||
str(self.remaining_time + 1), str(self.remaining_time)
|
||||
)
|
||||
else:
|
||||
# our timer is finished so launch text client and close down
|
||||
run_component(text_client_component, *launch_args)
|
||||
Clock.unschedule(self.update_label)
|
||||
App.get_running_app().stop()
|
||||
Window.close()
|
||||
|
||||
Popup().run()
|
||||
|
||||
|
||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
@@ -341,7 +266,7 @@ def run_gui():
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {file}")
|
||||
logging.warning(f"unable to identify component for {filename}")
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
@@ -374,24 +299,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
path = args.get("Patch|Game|Component|url", None)
|
||||
if path is not None:
|
||||
if path.startswith("archipelago://"):
|
||||
handle_uri(path, args.get("args", ()))
|
||||
return
|
||||
file, component = identify(path)
|
||||
if args.get("Patch|Game|Component", None) is not None:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {path}")
|
||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||
|
||||
if args["update_settings"]:
|
||||
update_settings()
|
||||
if "file" in args:
|
||||
if 'file' in args:
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif "component" in args:
|
||||
elif 'component' in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
run_gui()
|
||||
@@ -401,16 +322,12 @@ if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Archipelago Launcher',
|
||||
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
run_group = parser.add_argument_group("Run")
|
||||
run_group.add_argument("--update_settings", action="store_true",
|
||||
help="Update host.yaml and exit.")
|
||||
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game, the component name to run, or a url to "
|
||||
"connect with.")
|
||||
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
run_group.add_argument("args", nargs="*",
|
||||
help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
|
||||
@@ -467,8 +467,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
@@ -566,8 +564,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
@@ -632,7 +628,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
self.magpie.slot_data = self.slot_data
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
|
||||
@@ -14,7 +14,7 @@ import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
|
||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
@@ -29,8 +29,7 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
@@ -243,17 +242,16 @@ def adjustGUI():
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
|
||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||
|
||||
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
|
||||
bottomFrame2 = Frame(adjustWindow)
|
||||
|
||||
romFrame, romVar = get_rom_frame(adjustWindow)
|
||||
|
||||
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
|
||||
romDialogFrame = Frame(adjustWindow)
|
||||
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
|
||||
romVar2 = StringVar()
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
@@ -263,9 +261,9 @@ def adjustGUI():
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
romDialogFrame.pack(side=TOP, expand=False, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT)
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton2.pack(side=LEFT)
|
||||
|
||||
def adjustRom():
|
||||
@@ -333,11 +331,12 @@ def adjustGUI():
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
||||
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
|
||||
rom_options_frame.pack(side=TOP)
|
||||
adjustButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||
saveButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||
|
||||
tkinter_center_window(adjustWindow)
|
||||
@@ -577,7 +576,7 @@ class AttachTooltip(object):
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romFrame = Frame(parent, padx=8, pady=8)
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value=adjuster_settings.baserom)
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
@@ -597,19 +596,20 @@ def get_rom_frame(parent=None):
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
baseRomLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
romFrame.pack(side=TOP, fill=X)
|
||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
romOptionsFrame.columnconfigure(1, weight=1)
|
||||
for i in range(5):
|
||||
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
|
||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||
|
||||
baseSpriteLabel.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
oofDialogFrame = Frame(romOptionsFrame)
|
||||
|
||||
10
Main.py
10
Main.py
@@ -11,8 +11,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||
flood_items
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from settings import get_settings
|
||||
@@ -46,9 +45,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
||||
|
||||
multiworld.set_options(args)
|
||||
if args.csv_output:
|
||||
from Options import dump_player_options
|
||||
dump_player_options(multiworld)
|
||||
multiworld.set_item_links()
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
||||
@@ -104,7 +100,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||
remaining_count = count-early
|
||||
if remaining_count > 0:
|
||||
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
||||
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
||||
if local_early:
|
||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
del local_early
|
||||
@@ -350,7 +346,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
if not multiworld.can_beat_game():
|
||||
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
|
||||
@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
prev = "" # if a line ends in \ we store here and merge later
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
|
||||
@@ -67,21 +67,6 @@ def update_dict(dictionary, entries):
|
||||
return dictionary
|
||||
|
||||
|
||||
def queue_gc():
|
||||
import gc
|
||||
from threading import Thread
|
||||
|
||||
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
|
||||
def async_collect():
|
||||
time.sleep(2)
|
||||
setattr(queue_gc, "_thread", None)
|
||||
gc.collect()
|
||||
if not gc_thread:
|
||||
gc_thread = Thread(target=async_collect)
|
||||
setattr(queue_gc, "_thread", gc_thread)
|
||||
gc_thread.start()
|
||||
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
modify_functions = {
|
||||
# generic:
|
||||
@@ -566,9 +551,6 @@ class Context:
|
||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
else:
|
||||
self.save_dirty = False
|
||||
if not atexit_save: # if atexit is used, that keeps a reference anyway
|
||||
queue_gc()
|
||||
|
||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||
self.auto_saver_thread.start()
|
||||
|
||||
@@ -1009,7 +991,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
collect_player(ctx, team, group, True)
|
||||
|
||||
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
@@ -1221,10 +1203,6 @@ class CommonCommandProcessor(CommandProcessor):
|
||||
timer = int(seconds, 10)
|
||||
except ValueError:
|
||||
timer = 10
|
||||
else:
|
||||
if timer > 60 * 60:
|
||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||
|
||||
async_start(countdown(self.ctx, timer))
|
||||
return True
|
||||
|
||||
@@ -1372,10 +1350,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def _cmd_remaining(self) -> bool:
|
||||
"""List remaining items in your game, but not their location or recipient"""
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -1385,10 +1363,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
else: # is goal
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -2061,8 +2039,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
item_name, usable, response = get_intended_text(item_name, names)
|
||||
if usable:
|
||||
amount: int = int(amount)
|
||||
if amount > 100:
|
||||
raise ValueError(f"{amount} is invalid. Maximum is 100.")
|
||||
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||
send_items_to(self.ctx, team, slot, *new_items)
|
||||
|
||||
|
||||
12
NetUtils.py
12
NetUtils.py
@@ -79,7 +79,6 @@ class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
|
||||
@@ -273,8 +272,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
|
||||
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
|
||||
|
||||
def color_code(*args):
|
||||
@@ -399,12 +397,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
location_id not in checked]
|
||||
|
||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[typing.Tuple[int, int]]:
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
player_locations = self[slot]
|
||||
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
return sorted([player_locations[location_id][0] for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||
|
||||
60
Options.py
60
Options.py
@@ -8,17 +8,16 @@ import numbers
|
||||
import random
|
||||
import typing
|
||||
import enum
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from schema import And, Optional, Or, Schema
|
||||
from typing_extensions import Self
|
||||
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str, output_path
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, PlandoOptions
|
||||
from BaseClasses import PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
import pathlib
|
||||
|
||||
@@ -974,19 +973,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
if random.random() < float(text.get("percentage", 100)/100):
|
||||
at = text.get("at", None)
|
||||
if at is not None:
|
||||
if isinstance(at, dict):
|
||||
if at:
|
||||
at = random.choices(list(at.keys()),
|
||||
weights=list(at.values()), k=1)[0]
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
given_text = text.get("text", [])
|
||||
if isinstance(given_text, dict):
|
||||
if not given_text:
|
||||
given_text = []
|
||||
else:
|
||||
given_text = random.choices(list(given_text.keys()),
|
||||
weights=list(given_text.values()), k=1)
|
||||
if isinstance(given_text, str):
|
||||
given_text = [given_text]
|
||||
texts.append(PlandoText(
|
||||
@@ -994,8 +981,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
given_text,
|
||||
text.get("percentage", 100)
|
||||
))
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
elif isinstance(text, PlandoText):
|
||||
if random.random() < float(text.percentage/100):
|
||||
texts.append(text)
|
||||
@@ -1336,7 +1321,7 @@ class PriorityLocations(LocationSet):
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
display_name = "Death Link"
|
||||
rich_text_doc = True
|
||||
|
||||
@@ -1533,42 +1518,3 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
from csv import DictWriter
|
||||
|
||||
game_players = defaultdict(list)
|
||||
for player, game in multiworld.game.items():
|
||||
game_players[game].append(player)
|
||||
game_players = dict(sorted(game_players.items()))
|
||||
|
||||
output = []
|
||||
per_game_option_names = [
|
||||
getattr(option, "display_name", option_key)
|
||||
for option_key, option in PerGameCommonOptions.type_hints.items()
|
||||
]
|
||||
all_option_names = per_game_option_names.copy()
|
||||
for game, players in game_players.items():
|
||||
game_option_names = per_game_option_names.copy()
|
||||
for player in players:
|
||||
world = multiworld.worlds[player]
|
||||
player_output = {
|
||||
"Game": multiworld.game[player],
|
||||
"Name": multiworld.get_player_name(player),
|
||||
}
|
||||
output.append(player_output)
|
||||
for option_key, option in world.options_dataclass.type_hints.items():
|
||||
if issubclass(Removed, option):
|
||||
continue
|
||||
display_name = getattr(option, "display_name", option_key)
|
||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||
if display_name not in game_option_names:
|
||||
all_option_names.append(display_name)
|
||||
game_option_names.append(display_name)
|
||||
|
||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||
fields = ["Game", "Name", *all_option_names]
|
||||
writer = DictWriter(file, fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(output)
|
||||
|
||||
@@ -73,9 +73,6 @@ Currently, the following games are supported:
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
* Old School Runescape
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
2
Utils.py
2
Utils.py
@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.5.1"
|
||||
__version__ = "0.5.0"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
|
||||
@@ -267,7 +267,9 @@ class WargrooveContext(CommonContext):
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
self.add_client_tab("Wargroove", self.build_tracker())
|
||||
panel = TabbedPanelItem(text="Wargroove")
|
||||
panel.content = self.build_tracker()
|
||||
self.tabs.add_widget(panel)
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
|
||||
10
WebHost.py
10
WebHost.py
@@ -1,4 +1,3 @@
|
||||
import argparse
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
@@ -32,15 +31,6 @@ def get_app() -> "Flask":
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
if args.config_override:
|
||||
import yaml
|
||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||
logging.info(f"Updated config from {args.config_override}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
"""API endpoints package."""
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, abort, url_for
|
||||
|
||||
from ..models import Seed
|
||||
import worlds.Files
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
# unsorted/misc endpoints
|
||||
|
||||
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
|
||||
|
||||
from . import datapackage, generate, room, user # trigger registration
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str):
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
|
||||
|
||||
from . import generate, user, datapackage # trigger registration
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from typing import Any, Dict
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, url_for
|
||||
|
||||
import worlds.Files
|
||||
from . import api_endpoints, get_players
|
||||
from ..models import Room
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room_id>')
|
||||
def room_info(room_id: UUID) -> Dict[str, Any]:
|
||||
room = Room.get(id=room_id)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str) -> bool:
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
@@ -72,14 +72,6 @@ class WebHostContext(Context):
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
import psutil
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
@@ -257,7 +249,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
@@ -288,7 +279,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
if ctx.saving:
|
||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||
assert ctx.shutdown_task is None
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
@@ -335,7 +325,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect()
|
||||
gc.collect(0)
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
task.add_done_callback(self._done)
|
||||
|
||||
@@ -134,7 +134,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
erargs.skip_output = False
|
||||
erargs.csv_output = False
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
|
||||
@@ -132,41 +132,26 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.post("/room/<suuid:room>")
|
||||
def host_room_command(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
@app.get("/room/<suuid:room>")
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||
automated = ("update" in request.args
|
||||
or "Discordbot" in request.user_agent.string
|
||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||
|
||||
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
||||
if max_size == 0:
|
||||
return "…"
|
||||
def get_log(max_size: int = 1024000) -> str:
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.4
|
||||
pony>=0.7.19
|
||||
werkzeug>=3.0.3
|
||||
pony>=0.7.17
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.8.0
|
||||
Flask-Limiter>=3.7.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.4.3; python_version == '3.9'
|
||||
bokeh>=3.5.2; python_version >= '3.10'
|
||||
bokeh>=3.4.1; python_version >= '3.9'
|
||||
markupsafe>=2.1.5
|
||||
|
||||
@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
|
||||
You may also find developer documentation in the `docs` folder:
|
||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
|
||||
@@ -58,28 +58,3 @@
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.loader{
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
margin-left: 5px;
|
||||
width: 40px;
|
||||
aspect-ratio: 4;
|
||||
--_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
|
||||
background:
|
||||
var(--_g) 0 50%,
|
||||
var(--_g) 50% 50%,
|
||||
var(--_g) 100% 50%;
|
||||
background-size: calc(100%/3) 100%;
|
||||
animation: l7 1s infinite linear;
|
||||
}
|
||||
|
||||
.loader.loading{
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@keyframes l7{
|
||||
33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
|
||||
50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
|
||||
66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
|
||||
}
|
||||
|
||||
@@ -99,18 +99,14 @@
|
||||
{% if hint.finding_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||
{% else %}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
</a>
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if hint.receiving_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||
{% else %}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
</a>
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
||||
|
||||
@@ -19,30 +19,28 @@
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="host-room">
|
||||
<span id="host-room-info">
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
{{ macros.list_patches_room(room) }}
|
||||
{% if room.owner == session["_id"] %}
|
||||
<div style="display: flex; align-items: center;">
|
||||
@@ -51,7 +49,6 @@
|
||||
<label for="cmd"></label>
|
||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||
placeholder="Server Command. /help to list them, list gets appended to log.">
|
||||
<span class="loader"></span>
|
||||
</div>
|
||||
</form>
|
||||
<a href="{{ url_for("display_log", room=room.id) }}">
|
||||
@@ -65,7 +62,6 @@
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
let bytesReceived = {{ log_len }};
|
||||
let updateLogTimeout;
|
||||
let updateLogImmediately = false;
|
||||
let awaitingCommandResponse = false;
|
||||
let logger = document.getElementById("logger");
|
||||
|
||||
@@ -82,36 +78,29 @@
|
||||
|
||||
async function updateLog() {
|
||||
try {
|
||||
if (!document.hidden) {
|
||||
updateLogImmediately = false;
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
|
||||
loader.classList.remove("loading");
|
||||
}
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
}
|
||||
} else {
|
||||
updateLogImmediately = true;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
@@ -136,62 +125,20 @@
|
||||
});
|
||||
ev.preventDefault(); // has to happen before first await
|
||||
form.reset();
|
||||
let loader = form.getElementsByClassName("loader")[0];
|
||||
loader.classList.add("loading");
|
||||
try {
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
loader.classList.remove("loading");
|
||||
window.alert(res.statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.classList.remove("loading");
|
||||
window.alert(e.message);
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
window.alert(res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("command-form").addEventListener("submit", postForm);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
||||
logger.scrollTop = logger.scrollHeight;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && updateLogImmediately) {
|
||||
updateLog();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
function updateInfo() {
|
||||
let url = new URL(window.location.href);
|
||||
url.search = "?update";
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error ${res.status}`);
|
||||
}
|
||||
return res.text()
|
||||
})
|
||||
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
||||
.then(newDocument => {
|
||||
let el = newDocument.getElementById("host-room-info");
|
||||
document.getElementById("host-room-info").innerHTML = el.innerHTML;
|
||||
});
|
||||
}
|
||||
|
||||
if (document.querySelector("meta[http-equiv='refresh']")) {
|
||||
console.log("Refresh!");
|
||||
window.addEventListener('load', function () {
|
||||
for (let i=0; i<3; i++) {
|
||||
window.setTimeout(updateInfo, Math.pow(2, i) * 2000); // 2, 4, 8s
|
||||
}
|
||||
window.stop(); // cancel meta refresh
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||
<tr>
|
||||
<td>{{ patch.player_id }}</td>
|
||||
<td data-tooltip="Connect via Game Client"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}?game={{ patch.game }}&room={{ room.id | suuid }}">{{ patch.player_name }}</a></td>
|
||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
||||
<td>{{ patch.game }}</td>
|
||||
<td>
|
||||
{% if patch.data %}
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
{% extends 'tablepage.html' %}
|
||||
|
||||
{%- macro games(slots) -%}
|
||||
{%- set gameList = [] -%}
|
||||
{%- set maxGamesToShow = 10 -%}
|
||||
|
||||
{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
|
||||
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
|
||||
{% set _ = gameList.append(player) -%}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if slots|length > maxGamesToShow -%}
|
||||
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
|
||||
{%- endif -%}
|
||||
|
||||
{{ gameList|join('\n') }}
|
||||
{%- endmacro -%}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>User Content</title>
|
||||
@@ -49,12 +33,10 @@
|
||||
<tr>
|
||||
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
|
||||
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
|
||||
<td title="{{ games(room.seed.slots) }}">
|
||||
{{ room.seed.slots|length }}
|
||||
</td>
|
||||
<td>{{ room.seed.slots|length }}</td>
|
||||
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td>
|
||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -78,21 +60,16 @@
|
||||
{% for seed in seeds %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
|
||||
<td title="{{ games(seed.slots) }}">
|
||||
{% if seed.multidata %}
|
||||
{{ seed.slots|length }}
|
||||
{% else %}
|
||||
1
|
||||
{% endif %}
|
||||
<td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
|
||||
</td>
|
||||
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td>
|
||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
You have not generated any seeds yet!
|
||||
You have no generated any seeds yet!
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
id="{{ option_name }}-{{ key }}"
|
||||
name="{{ option_name }}||{{ key }}"
|
||||
value="1"
|
||||
{{ "checked" if key in option.default }}
|
||||
checked="{{ "checked" if key in option.default else "" }}"
|
||||
/>
|
||||
<label for="{{ option_name }}-{{ key }}">
|
||||
{{ key }}
|
||||
|
||||
@@ -287,15 +287,15 @@ cdef class LocationStore:
|
||||
entry in self.entries[start:start + count] if
|
||||
entry.location not in checked]
|
||||
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[int]:
|
||||
cdef LocationEntry* entry
|
||||
cdef ap_player_t sender = slot
|
||||
cdef size_t start = self.sender_index[sender].start
|
||||
cdef size_t count = self.sender_index[sender].count
|
||||
cdef set checked = state[team, slot]
|
||||
return sorted([(entry.receiver, entry.item) for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
return sorted([entry.item for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
|
||||
|
||||
@cython.auto_pickle(False)
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
/worlds/clique/ @ThePhar
|
||||
|
||||
# Dark Souls III
|
||||
/worlds/dark_souls_3/ @Marechal-L @nex3
|
||||
/worlds/dark_souls_3/ @Marechal-L
|
||||
|
||||
# Donkey Kong Country 3
|
||||
/worlds/dkc3/ @PoryGone
|
||||
@@ -78,9 +78,6 @@
|
||||
# Kirby's Dream Land 3
|
||||
/worlds/kdl3/ @Silvris
|
||||
|
||||
# Kingdom Hearts
|
||||
/worlds/kh1/ @gaithern
|
||||
|
||||
# Kingdom Hearts 2
|
||||
/worlds/kh2/ @JaredWeakStrike
|
||||
|
||||
@@ -106,9 +103,6 @@
|
||||
# Minecraft
|
||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
||||
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
# MegaMan Battle Network 3
|
||||
/worlds/mmbn3/ @digiholic
|
||||
|
||||
@@ -118,6 +112,9 @@
|
||||
# Noita
|
||||
/worlds/noita/ @ScipioWright @heinermann
|
||||
|
||||
# Ocarina of Time
|
||||
/worlds/oot/ @espeon65536
|
||||
|
||||
# Old School Runescape
|
||||
/worlds/osrs @digiholic
|
||||
|
||||
@@ -199,9 +196,6 @@
|
||||
# The Witness
|
||||
/worlds/witness/ @NewSoupVi @blastron
|
||||
|
||||
# Yacht Dice
|
||||
/worlds/yachtdice/ @spinerak
|
||||
|
||||
# Yoshi's Island
|
||||
/worlds/yoshisisland/ @PinkSwitch
|
||||
|
||||
@@ -227,9 +221,6 @@
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
## Disabled Unmaintained Worlds
|
||||
|
||||
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
||||
|
||||
@@ -702,18 +702,14 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
||||
| checksum | str | A checksum hash of this game's data. |
|
||||
|
||||
### Tags
|
||||
Tags are represented as a list of strings, the common client tags follow:
|
||||
Tags are represented as a list of strings, the common Client tags follow:
|
||||
|
||||
| Name | Notes |
|
||||
|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets. |
|
||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
|
||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
|
||||
### DeathLink
|
||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||
|
||||
@@ -24,7 +24,7 @@ display as `Value1` on the webhost.
|
||||
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
|
||||
Choice, and defining `alias_true = option_full`.
|
||||
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
|
||||
`(Named)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||
`(Named/Special)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
|
||||
However, you can override `from_text` and handle `text == "random"` to customize its behavior or
|
||||
implement it for additional option types.
|
||||
@@ -129,23 +129,6 @@ class Difficulty(Choice):
|
||||
default = 1
|
||||
```
|
||||
|
||||
### Option Visibility
|
||||
Every option has a Visibility IntFlag, defaulting to `all` (`0b1111`). This lets you choose where the option will be
|
||||
displayed. This only impacts where options are displayed, not how they can be used. Hidden options are still valid
|
||||
options in a yaml. The flags are as follows:
|
||||
* `none` (`0b0000`): This option is not shown anywhere
|
||||
* `template` (`0b0001`): This option shows up in template yamls
|
||||
* `simple_ui` (`0b0010`): This option shows up on the options page
|
||||
* `complex_ui` (`0b0100`): This option shows up on the advanced/weighted options page
|
||||
* `spoiler` (`0b1000`): This option shows up in spoiler logs
|
||||
|
||||
```python
|
||||
from Options import Choice, Visibility
|
||||
|
||||
class HiddenChoiceOption(Choice):
|
||||
visibility = Visibility.none
|
||||
```
|
||||
|
||||
### Option Groups
|
||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
||||
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
||||
|
||||
@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
|
||||
|
||||
What you'll need:
|
||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* Python 3.12.x is currently the newest supported version
|
||||
* **Python 3.12 is currently unsupported**
|
||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||
* Matching C compiler
|
||||
* possibly optional, read operating system specific sections
|
||||
@@ -31,14 +31,14 @@ After this, you should be able to run the programs.
|
||||
|
||||
Recommended steps
|
||||
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
||||
* [read above](#General) which versions are supported
|
||||
* **Python 3.12 is currently unsupported**
|
||||
|
||||
* **Optional**: Download and install Visual Studio Build Tools from
|
||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
||||
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
|
||||
Generally, selecting the box for "Desktop Development with C++" will provide what you need.
|
||||
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
|
||||
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||
|
||||
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
||||
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
||||
|
||||
@@ -303,31 +303,6 @@ generation (entrance randomization).
|
||||
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
|
||||
(items that have been collected).
|
||||
|
||||
The two possible ways to make a [CollectionRule](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10) are:
|
||||
- `def rule(state: CollectionState) -> bool:`
|
||||
- `lambda state: ... boolean expression ...`
|
||||
|
||||
An access rule can be assigned through `set_rule(location, rule)`.
|
||||
|
||||
Access rules usually check for one of two things.
|
||||
- Items that have been collected (e.g. `state.has("Sword", player)`)
|
||||
- Locations, Regions or Entrances that have been reached (e.g. `state.can_reach_region("Boss Room")`)
|
||||
|
||||
Keep in mind that entrances and locations implicitly check for the accessibility of their parent region, so you do not need to check explicitly for it.
|
||||
|
||||
#### An important note on Entrance access rules:
|
||||
When using `state.can_reach` within an entrance access condition, you must also use `multiworld.register_indirect_condition`.
|
||||
|
||||
For efficiency reasons, every time reachable regions are searched, every entrance is only checked once in a somewhat non-deterministic order.
|
||||
This is fine when checking for items using `state.has`, because items do not change during a region sweep.
|
||||
However, `state.can_reach` checks for the very same thing we are updating: Regions.
|
||||
This can lead to non-deterministic behavior and, in the worst case, even generation failures.
|
||||
Even doing `state.can_reach_location` or `state.can_reach_entrance` is problematic, as these functions call `state.can_reach_region` on the respective parent region.
|
||||
|
||||
**Therefore, it is considered unsafe to perform `state.can_reach` from within an access condition for an entrance**, unless you are checking for something that sits in the source region of the entrance.
|
||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||
|
||||
### Item Rules
|
||||
|
||||
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
||||
@@ -655,7 +630,7 @@ def set_rules(self) -> None:
|
||||
|
||||
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
|
||||
Entrance should be
|
||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10).
|
||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9).
|
||||
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
|
||||
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
|
||||
For an example, see [The Messenger](/worlds/messenger/rules.py).
|
||||
|
||||
@@ -26,17 +26,8 @@ Unless these are shared between multiple people, we expect the following from ea
|
||||
### Adding a World
|
||||
|
||||
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
|
||||
nominate someone else (i.e. there are multiple devs).
|
||||
|
||||
### Being added as a maintainer to an existing implementation
|
||||
|
||||
At any point, a world maintainer can approve the addition of another maintainer to their world.
|
||||
In order to do this, either an existing maintainer or the new maintainer must open a PR updating the
|
||||
[CODEOWNERS](/docs/CODEOWNERS) file.
|
||||
This change must be approved by all existing maintainers of the affected world, the new maintainer candidate, and
|
||||
one core maintainer.
|
||||
To help the core team review the change, information about the new maintainer and their contributions should be
|
||||
included in the PR description.
|
||||
nominate someone else (i.e. there are multiple devs). You can define who is allowed to approve changes to your world
|
||||
in the [CODEOWNERS](/docs/CODEOWNERS) document.
|
||||
|
||||
### Getting Voted
|
||||
|
||||
@@ -44,7 +35,7 @@ When a world is unmaintained, the [core maintainers](https://github.com/orgs/Arc
|
||||
can vote for a new maintainer if there is a candidate.
|
||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||
The time limit is 1 week, but can end early if the majority is reached earlier.
|
||||
Voting shall be conducted on Discord in #ap-core-dev.
|
||||
Voting shall be conducted on Discord in #archipelago-dev.
|
||||
|
||||
## Dropping out
|
||||
|
||||
@@ -60,7 +51,7 @@ for example when they become unreachable.
|
||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
|
||||
made their case or was pinged and has been unreachable for more than 2 weeks already.
|
||||
Voting shall be conducted on Discord in #ap-core-dev. Commits that are a direct result of the voting shall include
|
||||
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
|
||||
date, voting members and final result in the commit message.
|
||||
|
||||
## Handling of Unmaintained Worlds
|
||||
|
||||
@@ -186,11 +186,6 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||
@@ -228,8 +223,8 @@ Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{a
|
||||
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0";
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
|
||||
|
||||
[Code]
|
||||
// See: https://stackoverflow.com/a/51614652/2287576
|
||||
|
||||
15
kvui.py
15
kvui.py
@@ -5,8 +5,6 @@ import typing
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
@@ -536,8 +534,9 @@ class GameManager(App):
|
||||
# show Archipelago tab if other logging is present
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
|
||||
self.log_panels["Hints"] = hint_panel.content
|
||||
hint_panel = TabbedPanelItem(text="Hints")
|
||||
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
|
||||
self.tabs.add_widget(hint_panel)
|
||||
|
||||
if len(self.logging_pairs) == 1:
|
||||
self.tabs.default_tab_text = "Archipelago"
|
||||
@@ -571,14 +570,6 @@ class GameManager(App):
|
||||
|
||||
return self.container
|
||||
|
||||
def add_client_tab(self, title: str, content: Widget) -> Widget:
|
||||
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
|
||||
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
||||
new_tab = TabbedPanelItem(text=title)
|
||||
new_tab.content = content
|
||||
self.tabs.add_widget(new_tab)
|
||||
return new_tab
|
||||
|
||||
def update_texts(self, dt):
|
||||
if hasattr(self.tabs.content.children[0], "fix_heights"):
|
||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
colorama>=0.4.6
|
||||
websockets>=13.0.1
|
||||
PyYAML>=6.0.2
|
||||
jellyfish>=1.1.0
|
||||
websockets>=12.0
|
||||
PyYAML>=6.0.1
|
||||
jellyfish>=1.0.3
|
||||
jinja2>=3.1.4
|
||||
schema>=0.7.7
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.8.30
|
||||
cython>=3.0.11
|
||||
certifi>=2024.6.2
|
||||
cython>=3.0.10
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.7
|
||||
typing_extensions>=4.12.2
|
||||
orjson>=3.10.3
|
||||
typing_extensions>=4.12.1
|
||||
|
||||
@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, prevent_sweep=True)
|
||||
state.sweep_for_advancements()
|
||||
state.collect(item, event=True)
|
||||
state.sweep_for_events()
|
||||
state.update_reachable_regions(1)
|
||||
self._state_cache[self.multiworld, tuple(items)] = state
|
||||
return state
|
||||
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.advancements:
|
||||
self.multiworld.state.advancements.remove(item.location)
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
@@ -293,11 +293,13 @@ class WorldTestBase(unittest.TestCase):
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
||||
state = self.multiworld.get_all_state(False)
|
||||
for location in self.multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
with self.subTest("Beatable"):
|
||||
self.multiworld.state = state
|
||||
self.assertBeatable(True)
|
||||
|
||||
@@ -192,7 +192,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
location_pool = player1.locations[1:] + player2.locations
|
||||
item_pool = player1.prog_items[:-1] + player2.prog_items
|
||||
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
|
||||
multiworld.state.sweep_for_advancements() # collect everything
|
||||
multiworld.state.sweep_for_events() # collect everything
|
||||
|
||||
# all of player2's locations and items should be accessible (not all of player1's)
|
||||
for item in player2.prog_items:
|
||||
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
item = player1.prog_items[0]
|
||||
item.code = None
|
||||
location.place_locked_item(item)
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_events()
|
||||
multiworld.state.sweep_for_events()
|
||||
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
|
||||
self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
|
||||
|
||||
|
||||
@@ -14,18 +14,6 @@ class TestBase(unittest.TestCase):
|
||||
"Desert Northern Cliffs", # on top of mountain, only reachable via OWG
|
||||
"Dark Death Mountain Bunny Descent Area" # OWG Mountain descent
|
||||
},
|
||||
# These Blasphemous regions are not reachable with default options
|
||||
"Blasphemous": {
|
||||
"D01Z04S13[SE]", # difficulty must be hard
|
||||
"D01Z05S25[E]", # difficulty must be hard
|
||||
"D02Z02S05[W]", # difficulty must be hard and purified_hand must be true
|
||||
"D04Z01S06[E]", # purified_hand must be true
|
||||
"D04Z02S02[NE]", # difficulty must be hard and purified_hand must be true
|
||||
"D05Z01S11[SW]", # difficulty must be hard
|
||||
"D06Z01S08[N]", # difficulty must be hard and purified_hand must be true
|
||||
"D20Z02S11[NW]", # difficulty must be hard
|
||||
"D20Z02S11[E]", # difficulty must be hard
|
||||
},
|
||||
"Ocarina of Time": {
|
||||
"Prelude of Light Warp", # Prelude is not progression by default
|
||||
"Serenade of Water Warp", # Serenade is not progression by default
|
||||
@@ -49,10 +37,12 @@ class TestBase(unittest.TestCase):
|
||||
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
excluded = multiworld.worlds[1].options.exclude_locations.value
|
||||
state = multiworld.get_all_state(False)
|
||||
for location in multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
|
||||
for region in multiworld.get_regions():
|
||||
if region.name in unreachable_regions:
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
all_worlds = list(AutoWorldRegister.world_types.values())
|
||||
self.multiworld = setup_multiworld(all_worlds, ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
world.options.accessibility.value = Accessibility.option_locations
|
||||
self.assertSteps(gen_steps)
|
||||
with self.subTest("filling multiworld", seed=self.multiworld.seed):
|
||||
distribute_items_restrictive(self.multiworld)
|
||||
@@ -66,8 +66,8 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
class TestTwoPlayerMulti(MultiworldTestBase):
|
||||
def test_two_player_single_game_fills(self) -> None:
|
||||
"""Tests that a multiworld of two players for each registered game world can generate."""
|
||||
for world_type in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world_type, world_type], ())
|
||||
for world in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world, world], ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
self.assertSteps(gen_steps)
|
||||
|
||||
@@ -130,9 +130,9 @@ class Base:
|
||||
|
||||
def test_get_remaining(self) -> None:
|
||||
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [13, 21])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [13, 21, 22])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99])
|
||||
|
||||
def test_location_set_intersection(self) -> None:
|
||||
locations = {10, 11, 12}
|
||||
|
||||
@@ -131,8 +131,7 @@ class TestHostFakeRoom(TestBase):
|
||||
f.write(text)
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("host_room", room=self.room_id),
|
||||
headers={"User-Agent": "Mozilla/5.0"})
|
||||
response = self.client.get(url_for("host_room", room=self.room_id))
|
||||
response_text = response.get_data(True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("href=\"/seed/", response_text)
|
||||
|
||||
@@ -292,14 +292,6 @@ class World(metaclass=AutoWorldRegister):
|
||||
web: ClassVar[WebWorld] = WebWorld()
|
||||
"""see WebWorld for options"""
|
||||
|
||||
origin_region_name: str = "Menu"
|
||||
"""Name of the Region from which accessibility is tested."""
|
||||
|
||||
explicit_indirect_conditions: bool = True
|
||||
"""If True, the world implementation is supposed to use MultiWorld.register_indirect_condition() correctly.
|
||||
If False, everything is rechecked at every step, which is slower computationally,
|
||||
but may be desirable in complex/dynamic worlds."""
|
||||
|
||||
multiworld: "MultiWorld"
|
||||
"""autoset on creation. The MultiWorld object for the currently generating multiworld."""
|
||||
player: int
|
||||
|
||||
@@ -26,13 +26,10 @@ class Component:
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
game_name: Optional[str]
|
||||
supports_uri: Optional[bool]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
|
||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
|
||||
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
|
||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
@@ -48,8 +45,6 @@ class Component:
|
||||
Type.ADJUSTER if "Adjuster" in display_name else Type.MISC)
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
self.game_name = game_name
|
||||
self.supports_uri = supports_uri
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
@@ -61,10 +56,10 @@ class Component:
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
|
||||
def launch_subprocess(func: Callable, name: str = None):
|
||||
global processes
|
||||
import multiprocessing
|
||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||
process = multiprocessing.Process(target=func, name=name)
|
||||
process.start()
|
||||
processes.add(process)
|
||||
|
||||
@@ -83,9 +78,9 @@ class SuffixIdentifier:
|
||||
return False
|
||||
|
||||
|
||||
def launch_textclient(*args):
|
||||
def launch_textclient():
|
||||
import CommonClient
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
@@ -137,8 +132,7 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
|
||||
break
|
||||
if found_already_loaded:
|
||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
||||
"so a Launcher restart is required to use the new installation.\n"
|
||||
"If the Launcher is not open, no action needs to be taken.")
|
||||
"so a Launcher restart is required to use the new installation.")
|
||||
world_source = worlds.WorldSource(str(target), is_zip=True)
|
||||
bisect.insort(worlds.world_sources, world_source)
|
||||
world_source.load()
|
||||
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def launch_client(*args) -> None:
|
||||
from .context import launch
|
||||
launch_subprocess(launch, name="BizHawkClient", args=args)
|
||||
launch_subprocess(launch, name="BizHawkClient")
|
||||
|
||||
|
||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||
|
||||
@@ -59,10 +59,14 @@ class BizHawkClientContext(CommonContext):
|
||||
self.bizhawk_ctx = BizHawkContext()
|
||||
self.watcher_timeout = 0.5
|
||||
|
||||
def make_gui(self):
|
||||
ui = super().make_gui()
|
||||
ui.base_title = "Archipelago BizHawk Client"
|
||||
return ui
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class BizHawkManager(GameManager):
|
||||
base_title = "Archipelago BizHawk Client"
|
||||
|
||||
self.ui = BizHawkManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == "Connected":
|
||||
@@ -239,11 +243,11 @@ async def _patch_and_run_game(patch_file: str):
|
||||
logger.exception(exc)
|
||||
|
||||
|
||||
def launch(*launch_args) -> None:
|
||||
def launch() -> None:
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
||||
args = parser.parse_args(launch_args)
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = BizHawkClientContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
@@ -4,7 +4,7 @@ import websockets
|
||||
import functools
|
||||
from copy import deepcopy
|
||||
from typing import List, Any, Iterable
|
||||
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem, NetworkPlayer
|
||||
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem
|
||||
from MultiServer import Endpoint
|
||||
from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||
|
||||
@@ -101,35 +101,12 @@ class AHITContext(CommonContext):
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
json = args
|
||||
# This data is not needed and causes the game to freeze for long periods of time in large asyncs.
|
||||
if "slot_info" in json.keys():
|
||||
json["slot_info"] = {}
|
||||
if "players" in json.keys():
|
||||
me: NetworkPlayer
|
||||
for n in json["players"]:
|
||||
if n.slot == json["slot"] and n.team == json["team"]:
|
||||
me = n
|
||||
break
|
||||
|
||||
# Only put our player info in there as we actually need it
|
||||
json["players"] = [me]
|
||||
if DEBUG:
|
||||
print(json)
|
||||
self.connected_msg = encode([json])
|
||||
self.connected_msg = encode([args])
|
||||
if self.awaiting_info:
|
||||
self.server_msgs.append(self.room_info)
|
||||
self.update_items()
|
||||
self.awaiting_info = False
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
# Same story as above
|
||||
json = args
|
||||
if "players" in json.keys():
|
||||
json["players"] = []
|
||||
|
||||
self.server_msgs.append(encode(json))
|
||||
|
||||
elif cmd == "ReceivedItems":
|
||||
if args["index"] == 0:
|
||||
self.full_inventory.clear()
|
||||
@@ -189,17 +166,6 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
||||
await ctx.disconnect_proxy()
|
||||
break
|
||||
|
||||
if ctx.auth:
|
||||
name = msg.get("name", "")
|
||||
if name != "" and name != ctx.auth:
|
||||
logger.info("Aborting proxy connection: player name mismatch from save file")
|
||||
logger.info(f"Expected: {ctx.auth}, got: {name}")
|
||||
text = encode([{"cmd": "PrintJSON",
|
||||
"data": [{"text": "Connection aborted - player name mismatch"}]}])
|
||||
await ctx.send_msgs_proxy(text)
|
||||
await ctx.disconnect_proxy()
|
||||
break
|
||||
|
||||
if ctx.connected_msg and ctx.is_connected():
|
||||
await ctx.send_msgs_proxy(ctx.connected_msg)
|
||||
ctx.update_items()
|
||||
|
||||
@@ -152,10 +152,10 @@ def create_dw_regions(world: "HatInTimeWorld"):
|
||||
for name in annoying_dws:
|
||||
world.excluded_dws.append(name)
|
||||
|
||||
if not world.options.DWEnableBonus and world.options.DWAutoCompleteBonuses:
|
||||
if not world.options.DWEnableBonus or world.options.DWAutoCompleteBonuses:
|
||||
for name in death_wishes:
|
||||
world.excluded_bonuses.append(name)
|
||||
if world.options.DWExcludeAnnoyingBonuses and not world.options.DWAutoCompleteBonuses:
|
||||
elif world.options.DWExcludeAnnoyingBonuses:
|
||||
for name in annoying_bonuses:
|
||||
world.excluded_bonuses.append(name)
|
||||
|
||||
|
||||
@@ -968,35 +968,40 @@ def get_act_by_number(world: "HatInTimeWorld", chapter_name: str, num: int) -> R
|
||||
def create_thug_shops(world: "HatInTimeWorld"):
|
||||
min_items: int = world.options.NyakuzaThugMinShopItems.value
|
||||
max_items: int = world.options.NyakuzaThugMaxShopItems.value
|
||||
|
||||
thug_location_counts: Dict[str, int] = {}
|
||||
count = -1
|
||||
step = 0
|
||||
old_name = ""
|
||||
|
||||
for key, data in shop_locations.items():
|
||||
thug_name = data.nyakuza_thug
|
||||
if thug_name == "":
|
||||
# Different shop type.
|
||||
if data.nyakuza_thug == "":
|
||||
continue
|
||||
|
||||
if thug_name not in world.nyakuza_thug_items:
|
||||
shop_item_count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items[thug_name] = shop_item_count
|
||||
else:
|
||||
shop_item_count = world.nyakuza_thug_items[thug_name]
|
||||
|
||||
if shop_item_count <= 0:
|
||||
if old_name != "" and old_name == data.nyakuza_thug:
|
||||
continue
|
||||
|
||||
location_count = thug_location_counts.setdefault(thug_name, 0)
|
||||
if location_count >= shop_item_count:
|
||||
# Already created all the locations for this thug.
|
||||
continue
|
||||
try:
|
||||
if world.nyakuza_thug_items[data.nyakuza_thug] <= 0:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Create the shop location.
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
thug_location_counts[thug_name] = location_count + 1
|
||||
if count == -1:
|
||||
count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items.setdefault(data.nyakuza_thug, count)
|
||||
if count <= 0:
|
||||
continue
|
||||
|
||||
if count >= 1:
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
|
||||
step += 1
|
||||
if step >= count:
|
||||
old_name = data.nyakuza_thug
|
||||
step = 0
|
||||
count = -1
|
||||
|
||||
|
||||
def create_events(world: "HatInTimeWorld") -> int:
|
||||
|
||||
@@ -381,8 +381,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
lambda state: can_use_hat(state, world, HatType.ICE), "or")
|
||||
|
||||
# Moderate: Clock Tower Chest + Ruined Tower with nothing
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
|
||||
# Moderate: enter and clear The Subcon Well without Hookshot and without hitting the bell
|
||||
for loc in world.multiworld.get_region("The Subcon Well", world.player).locations:
|
||||
@@ -432,8 +432,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
|
||||
if world.is_dlc1():
|
||||
# Moderate: clear Rock the Boat without Ice Hat
|
||||
set_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
|
||||
# Moderate: clear Deep Sea without Ice Hat
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Deep Sea)", world.player),
|
||||
@@ -855,9 +855,6 @@ def set_rift_rules(world: "HatInTimeWorld", regions: Dict[str, Region]):
|
||||
|
||||
for entrance in regions["Time Rift - Alpine Skyline"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
if entrance.parent_region.name == "Alpine Free Roam":
|
||||
add_rule(entrance,
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world, umbrella_only=True))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in regions["Time Rift - Balcony"].entrances:
|
||||
@@ -936,9 +933,6 @@ def set_default_rift_rules(world: "HatInTimeWorld"):
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Alpine Skyline", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
if entrance.parent_region.name == "Alpine Free Roam":
|
||||
add_rule(entrance,
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world, umbrella_only=True))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances:
|
||||
|
||||
@@ -253,8 +253,7 @@ class HatInTimeWorld(World):
|
||||
else:
|
||||
item_name = loc.item.name
|
||||
|
||||
shop_item_names.setdefault(str(loc.address),
|
||||
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
|
||||
shop_item_names.setdefault(str(loc.address), item_name)
|
||||
|
||||
slot_data["ShopItemNames"] = shop_item_names
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
pass
|
||||
for item in pre_fill_items:
|
||||
multiworld.worlds[item.player].collect(all_state_base, item)
|
||||
all_state_base.sweep_for_advancements()
|
||||
all_state_base.sweep_for_events()
|
||||
|
||||
# Remove completion condition so that minimal-accessibility worlds place keys properly
|
||||
for player in {item.player for item in in_dungeon_items}:
|
||||
@@ -262,8 +262,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
|
||||
loc = multiworld.get_location(key_loc, player)
|
||||
|
||||
if loc in all_state_base.advancements:
|
||||
all_state_base.advancements.remove(loc)
|
||||
if loc in all_state_base.events:
|
||||
all_state_base.events.remove(loc)
|
||||
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, lock=True, allow_excluded=True,
|
||||
name="LttP Dungeon Items")
|
||||
|
||||
|
||||
@@ -682,7 +682,7 @@ def get_pool_core(world, player: int):
|
||||
if 'triforce_hunt' in goal:
|
||||
|
||||
if world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_extra:
|
||||
treasure_hunt_total = (world.triforce_pieces_required[player].value
|
||||
treasure_hunt_total = (world.triforce_pieces_available[player].value
|
||||
+ world.triforce_pieces_extra[player].value)
|
||||
elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage:
|
||||
percentage = float(world.triforce_pieces_percentage[player].value) / 100
|
||||
|
||||
@@ -728,7 +728,7 @@ class ALttPPlandoConnections(PlandoConnections):
|
||||
entrances = set([connection[0] for connection in (
|
||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
||||
*inverted_default_dungeon_connections)])
|
||||
exits = set([connection[0] for connection in (
|
||||
exits = set([connection[1] for connection in (
|
||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
||||
*inverted_default_dungeon_connections)])
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player))
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Blind\'s Cell', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
|
||||
if multiworld.accessibility[player] != 'full' and not multiworld.key_drop_shuffle[player]:
|
||||
if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]:
|
||||
set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player)
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3))
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player),
|
||||
@@ -547,7 +547,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 6)))
|
||||
|
||||
# this seemed to be causing generation failure, disable for now
|
||||
# if world.accessibility[player] != 'full':
|
||||
# if world.accessibility[player] != 'locations':
|
||||
# set_always_allow(world.get_location('Ganons Tower - Map Chest', player), lambda state, item: item.name == 'Small Key (Ganons Tower)' and item.player == player and state._lttp_has_key('Small Key (Ganons Tower)', player, 7) and state.can_reach('Ganons Tower (Hookshot Room)', 'region', player))
|
||||
|
||||
# It is possible to need more than 6 keys to get through this entrance if you spend keys elsewhere. We reflect this in the chest requirements.
|
||||
|
||||
@@ -356,8 +356,6 @@ class ALTTPWorld(World):
|
||||
self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group]
|
||||
if option == "original_dungeon":
|
||||
self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group]
|
||||
else:
|
||||
self.options.local_items.value |= self.dungeon_local_item_names
|
||||
|
||||
self.difficulty_requirements = difficulties[multiworld.item_pool[player].current_key]
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Configuration
|
||||
|
||||
1. All plando options are enabled by default, except for "items plando" which has to be enabled before it can be used (opt-in).
|
||||
2. To enable it, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
1. Plando features have to be enabled first, before they can be used (opt-in).
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
file with a text editor.
|
||||
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the value
|
||||
to `bosses, items, texts, connections`
|
||||
@@ -66,7 +66,6 @@ boss_shuffle:
|
||||
- ignored if only one world is generated
|
||||
- can be a number, to target that slot in the multiworld
|
||||
- can be a name, to target that player's world
|
||||
- can be a list of names, to target those players' worlds
|
||||
- can be true, to target any other player's world
|
||||
- can be false, to target own world and is the default
|
||||
- can be null, to target a random world
|
||||
@@ -133,15 +132,17 @@ plando_items:
|
||||
|
||||
### Texts
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `text`, `at`, and `percentage`
|
||||
- All of these options support subweights
|
||||
- percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
|
||||
- text is the text to be placed.
|
||||
- can be weighted.
|
||||
- `\n` is a newline.
|
||||
- `@` is the entered player's name.
|
||||
- Warning: Text Mapper does not support full unicode.
|
||||
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758)
|
||||
- at is the location within the game to attach the text to.
|
||||
- can be weighted.
|
||||
- [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
|
||||
|
||||
#### Example
|
||||
@@ -161,6 +162,7 @@ and `uncle_dying_sewer`, then places the text "This is a plando. You've been war
|
||||
|
||||
### Connections
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `percentage`, `entrance`, `exit` and `direction`.
|
||||
- All options support subweights
|
||||
- percentage is the percentage chance for this to be connected, can be omitted entirely for 100%
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestDungeon(LTTPTestBase):
|
||||
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, prevent_sweep=True) # prevent_sweep=True prevents running sweep_for_advancements() and picking up
|
||||
state.sweep_for_advancements() # key drop keys repeatedly
|
||||
state.collect(item, event=True) # event=True prevents running sweep_for_events() and picking up
|
||||
state.sweep_for_events() # key drop keys repeatedly
|
||||
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||
@@ -1,60 +0,0 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from test.general import gen_steps, setup_multiworld
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import locality_rules
|
||||
from ... import ALTTPWorld
|
||||
from ...Options import DungeonItem
|
||||
|
||||
|
||||
class DungeonFillTestBase(TestCase):
|
||||
multiworld: MultiWorld
|
||||
world_1: ALTTPWorld
|
||||
world_2: ALTTPWorld
|
||||
options = (
|
||||
"big_key_shuffle",
|
||||
"small_key_shuffle",
|
||||
"key_drop_shuffle",
|
||||
"compass_shuffle",
|
||||
"map_shuffle",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.multiworld = setup_multiworld([ALTTPWorld, ALTTPWorld], ())
|
||||
self.world_1 = self.multiworld.worlds[1]
|
||||
self.world_2 = self.multiworld.worlds[2]
|
||||
|
||||
def generate_with_options(self, option_value: int):
|
||||
for option in self.options:
|
||||
getattr(self.world_1.options, option).value = getattr(self.world_2.options, option).value = option_value
|
||||
|
||||
for step in gen_steps:
|
||||
call_all(self.multiworld, step)
|
||||
# this is where locality rules are set in normal generation which we need to verify this test
|
||||
if step == "set_rules":
|
||||
locality_rules(self.multiworld)
|
||||
|
||||
def test_original_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_original_dungeon)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
with (self.subTest(location=location)):
|
||||
if location.parent_region.dungeon is None:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
self.assertEqual(location.player, location.item.player,
|
||||
f"{location.item} does not belong to {location}'s player")
|
||||
if location.item.dungeon is None:
|
||||
continue
|
||||
self.assertIs(location.item.dungeon, location.parent_region.dungeon,
|
||||
f"{location.item} was not placed in its original dungeon.")
|
||||
|
||||
def test_own_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_own_dungeons)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
with self.subTest(location=location):
|
||||
if location.parent_region.dungeon is None:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
self.assertEqual(location.player, location.item.player,
|
||||
f"{location.item} does not belong to {location}'s player")
|
||||
@@ -4,7 +4,7 @@ from BaseClasses import Tutorial
|
||||
from ..AutoWorld import WebWorld, World
|
||||
|
||||
class AP_SudokuWebWorld(WebWorld):
|
||||
options_page = False
|
||||
options_page = "games/Sudoku/info/en"
|
||||
theme = 'partyTime'
|
||||
|
||||
setup_en = Tutorial(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# APSudoku Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [APSudoku](https://github.com/APSudoku/APSudoku)
|
||||
- [APSudoku](https://github.com/EmilyV99/APSudoku)
|
||||
- Windows (most tested on Win10)
|
||||
- Other platforms might be able to build from source themselves; and may be included in the future.
|
||||
|
||||
## General Concept
|
||||
|
||||
@@ -11,33 +13,25 @@ Does not need to be added at the start of a seed, as it does not create any slot
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Go to the latest release from the [APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
|
||||
Go to the latest release from the [APSudoku Releases page](https://github.com/EmilyV99/APSudoku/releases). Download and extract the `APSudoku.zip` file.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run the APSudoku executable.
|
||||
2. Under `Settings` → `Connection` at the top-right:
|
||||
- Enter the server address and port number
|
||||
1. Run APSudoku.exe
|
||||
2. Under the 'Archipelago' tab at the top-right:
|
||||
- Enter the server url & port number
|
||||
- Enter the name of the slot you wish to connect to
|
||||
- Enter the room password (optional)
|
||||
- Select DeathLink related settings (optional)
|
||||
- Press `Connect`
|
||||
4. Under the `Sudoku` tab
|
||||
- Choose puzzle difficulty
|
||||
- Click `Start` to generate a puzzle
|
||||
5. Try to solve the Sudoku. Click `Check` when done
|
||||
- A correct solution rewards you with 1 hint for a location in the world you are connected to
|
||||
- An incorrect solution has no penalty, unless DeathLink is enabled (see below)
|
||||
- Press connect
|
||||
3. Go back to the 'Sudoku' tab
|
||||
- Click the various '?' buttons for information on how to play / control
|
||||
4. Choose puzzle difficulty
|
||||
5. Try to solve the Sudoku. Click 'Check' when done.
|
||||
|
||||
Info:
|
||||
- You can set various settings under `Settings` → `Sudoku`, and can change the colors used under `Settings` → `Theme`.
|
||||
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features
|
||||
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md)
|
||||
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted)
|
||||
- Click the various `?` buttons for information on controls/how to play
|
||||
## DeathLink Support
|
||||
|
||||
If `DeathLink` is enabled when you click `Connect`:
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
|
||||
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
If 'DeathLink' is enabled when you click 'Connect':
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or quit a puzzle without solving it (including disconnecting).
|
||||
- Life count customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
- On receiving a DeathLink from another player, your puzzle resets.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"type": "WorldDefinition",
|
||||
"configuration": "./output/StringWorldDefinition.json",
|
||||
"emptyRegionsToKeep": [
|
||||
"D17Z01S01",
|
||||
"D01Z02S01",
|
||||
"D02Z03S09",
|
||||
"D03Z03S11",
|
||||
"D04Z03S01",
|
||||
"D06Z01S09",
|
||||
"D20Z02S09",
|
||||
"D09Z01S09[Cell24]",
|
||||
"D09Z01S08[Cell7]",
|
||||
"D09Z01S08[Cell18]",
|
||||
"D09BZ01S01[Cell24]",
|
||||
"D09BZ01S01[Cell17]",
|
||||
"D09BZ01S01[Cell19]"
|
||||
]
|
||||
}
|
||||
@@ -637,35 +637,52 @@ item_table: List[ItemDict] = [
|
||||
'classification': ItemClassification.filler}
|
||||
]
|
||||
|
||||
event_table: Dict[str, str] = {
|
||||
"OpenedDCGateW": "D01Z05S24",
|
||||
"OpenedDCGateE": "D01Z05S12",
|
||||
"OpenedDCLadder": "D01Z05S20",
|
||||
"OpenedWOTWCave": "D02Z01S06",
|
||||
"RodeGOTPElevator": "D02Z02S11",
|
||||
"OpenedConventLadder": "D02Z03S11",
|
||||
"BrokeJondoBellW": "D03Z02S09",
|
||||
"BrokeJondoBellE": "D03Z02S05",
|
||||
"OpenedMOMLadder": "D04Z02S06",
|
||||
"OpenedTSCGate": "D05Z02S11",
|
||||
"OpenedARLadder": "D06Z01S23",
|
||||
"BrokeBOTTCStatue": "D08Z01S02",
|
||||
"OpenedWOTHPGate": "D09Z01S05",
|
||||
"OpenedBOTSSLadder": "D17Z01S04"
|
||||
}
|
||||
|
||||
group_table: Dict[str, Set[str]] = {
|
||||
"wounds" : {"Holy Wound of Attrition",
|
||||
"wounds" : ["Holy Wound of Attrition",
|
||||
"Holy Wound of Contrition",
|
||||
"Holy Wound of Compunction"},
|
||||
"Holy Wound of Compunction"],
|
||||
|
||||
"masks" : {"Deformed Mask of Orestes",
|
||||
"masks" : ["Deformed Mask of Orestes",
|
||||
"Mirrored Mask of Dolphos",
|
||||
"Embossed Mask of Crescente"},
|
||||
"Embossed Mask of Crescente"],
|
||||
|
||||
"marks" : {"Mark of the First Refuge",
|
||||
"marks" : ["Mark of the First Refuge",
|
||||
"Mark of the Second Refuge",
|
||||
"Mark of the Third Refuge"},
|
||||
"Mark of the Third Refuge"],
|
||||
|
||||
"tirso" : {"Bouquet of Rosemary",
|
||||
"tirso" : ["Bouquet of Rosemary",
|
||||
"Incense Garlic",
|
||||
"Olive Seeds",
|
||||
"Dried Clove",
|
||||
"Sooty Garlic",
|
||||
"Bouquet of Thyme"},
|
||||
"Bouquet of Thyme"],
|
||||
|
||||
"tentudia": {"Tentudia's Carnal Remains",
|
||||
"tentudia": ["Tentudia's Carnal Remains",
|
||||
"Remains of Tentudia's Hair",
|
||||
"Tentudia's Skeletal Remains"},
|
||||
"Tentudia's Skeletal Remains"],
|
||||
|
||||
"egg" : {"Melted Golden Coins",
|
||||
"egg" : ["Melted Golden Coins",
|
||||
"Torn Bridal Ribbon",
|
||||
"Black Grieving Veil"},
|
||||
"Black Grieving Veil"],
|
||||
|
||||
"bones" : {"Parietal bone of Lasser, the Inquisitor",
|
||||
"bones" : ["Parietal bone of Lasser, the Inquisitor",
|
||||
"Jaw of Ashgan, the Inquisitor",
|
||||
"Cervical vertebra of Zicher, the Brewmaster",
|
||||
"Clavicle of Dalhuisen, the Schoolchild",
|
||||
@@ -708,14 +725,14 @@ group_table: Dict[str, Set[str]] = {
|
||||
"Scaphoid of Fierce, the Leper",
|
||||
"Anklebone of Weston, the Pilgrim",
|
||||
"Calcaneum of Persian, the Bandit",
|
||||
"Navicular of Kahnnyhoo, the Murderer"},
|
||||
"Navicular of Kahnnyhoo, the Murderer"],
|
||||
|
||||
"power" : {"Life Upgrade",
|
||||
"power" : ["Life Upgrade",
|
||||
"Fervour Upgrade",
|
||||
"Empty Bile Vessel",
|
||||
"Quicksilver"},
|
||||
"Quicksilver"],
|
||||
|
||||
"prayer" : {"Seguiriya to your Eyes like Stars",
|
||||
"prayer" : ["Seguiriya to your Eyes like Stars",
|
||||
"Debla of the Lights",
|
||||
"Saeta Dolorosa",
|
||||
"Campanillero to the Sons of the Aurora",
|
||||
@@ -729,17 +746,10 @@ group_table: Dict[str, Set[str]] = {
|
||||
"Romance to the Crimson Mist",
|
||||
"Zambra to the Resplendent Crown",
|
||||
"Cantina of the Blue Rose",
|
||||
"Mirabras of the Return to Port"},
|
||||
|
||||
"toe" : {"Little Toe made of Limestone",
|
||||
"Big Toe made of Limestone",
|
||||
"Fourth Toe made of Limestone"},
|
||||
|
||||
"eye" : {"Severed Right Eye of the Traitor",
|
||||
"Broken Left Eye of the Traitor"}
|
||||
"Mirabras of the Return to Port"]
|
||||
}
|
||||
|
||||
tears_list: List[str] = [
|
||||
tears_set: Set[str] = [
|
||||
"Tears of Atonement (500)",
|
||||
"Tears of Atonement (625)",
|
||||
"Tears of Atonement (750)",
|
||||
@@ -762,16 +772,16 @@ tears_list: List[str] = [
|
||||
"Tears of Atonement (30000)"
|
||||
]
|
||||
|
||||
reliquary_set: Set[str] = {
|
||||
reliquary_set: Set[str] = [
|
||||
"Reliquary of the Fervent Heart",
|
||||
"Reliquary of the Suffering Heart",
|
||||
"Reliquary of the Sorrowful Heart"
|
||||
}
|
||||
]
|
||||
|
||||
skill_set: Set[str] = {
|
||||
skill_set: Set[str] = [
|
||||
"Combo Skill",
|
||||
"Charged Skill",
|
||||
"Ranged Skill",
|
||||
"Dive Skill",
|
||||
"Lunge Skill"
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, PerGameCommonOptions, OptionGroup
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool
|
||||
import random
|
||||
|
||||
|
||||
@@ -21,30 +20,23 @@ class ChoiceIsRandom(Choice):
|
||||
|
||||
|
||||
class PrieDieuWarp(DefaultOnToggle):
|
||||
"""
|
||||
Automatically unlocks the ability to warp between Prie Dieu shrines.
|
||||
"""
|
||||
"""Automatically unlocks the ability to warp between Prie Dieu shrines."""
|
||||
display_name = "Unlock Fast Travel"
|
||||
|
||||
|
||||
class SkipCutscenes(DefaultOnToggle):
|
||||
"""
|
||||
Automatically skips most cutscenes.
|
||||
"""
|
||||
"""Automatically skips most cutscenes."""
|
||||
display_name = "Auto Skip Cutscenes"
|
||||
|
||||
|
||||
class CorpseHints(DefaultOnToggle):
|
||||
"""
|
||||
Changes the 34 corpses in game to give various hints about item locations.
|
||||
"""
|
||||
"""Changes the 34 corpses in game to give various hints about item locations."""
|
||||
display_name = "Corpse Hints"
|
||||
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""
|
||||
Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses and advanced movement tricks or glitches.
|
||||
"""
|
||||
"""Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses
|
||||
and advanced movement tricks or glitches."""
|
||||
display_name = "Difficulty"
|
||||
option_easy = 0
|
||||
option_normal = 1
|
||||
@@ -53,18 +45,15 @@ class Difficulty(Choice):
|
||||
|
||||
|
||||
class Penitence(Toggle):
|
||||
"""
|
||||
Allows one of the three Penitences to be chosen at the beginning of the game.
|
||||
"""
|
||||
"""Allows one of the three Penitences to be chosen at the beginning of the game."""
|
||||
display_name = "Penitence"
|
||||
|
||||
|
||||
class StartingLocation(ChoiceIsRandom):
|
||||
"""
|
||||
Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain other options.
|
||||
|
||||
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends cannot be chosen if Shuffle Wall Climb is enabled.
|
||||
"""
|
||||
"""Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain
|
||||
other options.
|
||||
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends
|
||||
cannot be chosen if Shuffle Wall Climb is enabled."""
|
||||
display_name = "Starting Location"
|
||||
option_brotherhood = 0
|
||||
option_albero = 1
|
||||
@@ -77,15 +66,10 @@ class StartingLocation(ChoiceIsRandom):
|
||||
|
||||
|
||||
class Ending(Choice):
|
||||
"""
|
||||
Choose which ending is required to complete the game.
|
||||
|
||||
"""Choose which ending is required to complete the game.
|
||||
Talking to Tirso in Albero will tell you the selected ending for the current game.
|
||||
|
||||
Ending A: Collect all thorn upgrades.
|
||||
|
||||
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation.
|
||||
"""
|
||||
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation."""
|
||||
display_name = "Ending"
|
||||
option_any_ending = 0
|
||||
option_ending_a = 1
|
||||
@@ -94,18 +78,14 @@ class Ending(Choice):
|
||||
|
||||
|
||||
class SkipLongQuests(Toggle):
|
||||
"""
|
||||
Ensures that the rewards for long quests will be filler items.
|
||||
|
||||
Affected locations: "Albero: Donate 50000 Tears", "Ossuary: 11th reward", "AtTotS: Miriam's gift", "TSC: Jocinero's final reward"
|
||||
"""
|
||||
"""Ensures that the rewards for long quests will be filler items.
|
||||
Affected locations: \"Albero: Donate 50000 Tears\", \"Ossuary: 11th reward\", \"AtTotS: Miriam's gift\",
|
||||
\"TSC: Jocinero's final reward\""""
|
||||
display_name = "Skip Long Quests"
|
||||
|
||||
|
||||
class ThornShuffle(Choice):
|
||||
"""
|
||||
Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool.
|
||||
"""
|
||||
"""Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool."""
|
||||
display_name = "Shuffle Thorn"
|
||||
option_anywhere = 0
|
||||
option_local_only = 1
|
||||
@@ -114,68 +94,50 @@ class ThornShuffle(Choice):
|
||||
|
||||
|
||||
class DashShuffle(Toggle):
|
||||
"""
|
||||
Turns the ability to dash into an item that must be found in the multiworld.
|
||||
"""
|
||||
"""Turns the ability to dash into an item that must be found in the multiworld."""
|
||||
display_name = "Shuffle Dash"
|
||||
|
||||
|
||||
class WallClimbShuffle(Toggle):
|
||||
"""
|
||||
Turns the ability to climb walls with your sword into an item that must be found in the multiworld.
|
||||
"""
|
||||
"""Turns the ability to climb walls with your sword into an item that must be found in the multiworld."""
|
||||
display_name = "Shuffle Wall Climb"
|
||||
|
||||
|
||||
class ReliquaryShuffle(DefaultOnToggle):
|
||||
"""
|
||||
Adds the True Torment exclusive Reliquary rosary beads into the item pool.
|
||||
"""
|
||||
"""Adds the True Torment exclusive Reliquary rosary beads into the item pool."""
|
||||
display_name = "Shuffle Penitence Rewards"
|
||||
|
||||
|
||||
class CustomItem1(Toggle):
|
||||
"""
|
||||
Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes and survive.
|
||||
|
||||
Must have the "Boots of Pleading" mod installed to connect to a multiworld.
|
||||
"""
|
||||
"""Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes
|
||||
and survive.
|
||||
Must have the \"Blasphemous-Boots-of-Pleading\" mod installed to connect to a multiworld."""
|
||||
display_name = "Boots of Pleading"
|
||||
|
||||
|
||||
class CustomItem2(Toggle):
|
||||
"""
|
||||
Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump a second time in mid-air.
|
||||
|
||||
Must have the "Double Jump" mod installed to connect to a multiworld.
|
||||
"""
|
||||
"""Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump
|
||||
a second time in mid-air.
|
||||
Must have the \"Blasphemous-Double-Jump\" mod installed to connect to a multiworld."""
|
||||
display_name = "Purified Hand of the Nun"
|
||||
|
||||
|
||||
class StartWheel(Toggle):
|
||||
"""
|
||||
Changes the beginning gift to The Young Mason's Wheel.
|
||||
"""
|
||||
"""Changes the beginning gift to The Young Mason's Wheel."""
|
||||
display_name = "Start with Wheel"
|
||||
|
||||
|
||||
class SkillRando(Toggle):
|
||||
"""
|
||||
Randomizes the abilities from the skill tree into the item pool.
|
||||
"""
|
||||
"""Randomizes the abilities from the skill tree into the item pool."""
|
||||
display_name = "Skill Randomizer"
|
||||
|
||||
|
||||
class EnemyRando(Choice):
|
||||
"""
|
||||
Randomizes the enemies that appear in each room.
|
||||
|
||||
Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in a standard game.
|
||||
|
||||
"""Randomizes the enemies that appear in each room.
|
||||
Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in
|
||||
a standard game.
|
||||
Randomized: Every enemy is completely random, and can appear any number of times.
|
||||
|
||||
Some enemies will never be randomized.
|
||||
"""
|
||||
Some enemies will never be randomized."""
|
||||
display_name = "Enemy Randomizer"
|
||||
option_disabled = 0
|
||||
option_shuffled = 1
|
||||
@@ -184,75 +146,43 @@ class EnemyRando(Choice):
|
||||
|
||||
|
||||
class EnemyGroups(DefaultOnToggle):
|
||||
"""
|
||||
Randomized enemies will be chosen from sets of specific groups.
|
||||
|
||||
"""Randomized enemies will chosen from sets of specific groups.
|
||||
(Weak, normal, large, flying)
|
||||
|
||||
Has no effect if Enemy Randomizer is disabled.
|
||||
"""
|
||||
Has no effect if Enemy Randomizer is disabled."""
|
||||
display_name = "Enemy Groups"
|
||||
|
||||
|
||||
class EnemyScaling(DefaultOnToggle):
|
||||
"""
|
||||
Randomized enemies will have their stats increased or decreased depending on the area they appear in.
|
||||
|
||||
Has no effect if Enemy Randomizer is disabled.
|
||||
"""
|
||||
"""Randomized enemies will have their stats increased or decreased depending on the area they appear in.
|
||||
Has no effect if Enemy Randomizer is disabled."""
|
||||
display_name = "Enemy Scaling"
|
||||
|
||||
|
||||
class BlasphemousDeathLink(DeathLink):
|
||||
"""
|
||||
When you die, everyone dies. The reverse is also true.
|
||||
|
||||
Note that Guilt Fragments will not appear when killed by Death Link.
|
||||
"""
|
||||
"""When you die, everyone dies. The reverse is also true.
|
||||
Note that Guilt Fragments will not appear when killed by Death Link."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlasphemousOptions(PerGameCommonOptions):
|
||||
prie_dieu_warp: PrieDieuWarp
|
||||
skip_cutscenes: SkipCutscenes
|
||||
corpse_hints: CorpseHints
|
||||
difficulty: Difficulty
|
||||
penitence: Penitence
|
||||
starting_location: StartingLocation
|
||||
ending: Ending
|
||||
skip_long_quests: SkipLongQuests
|
||||
thorn_shuffle: ThornShuffle
|
||||
dash_shuffle: DashShuffle
|
||||
wall_climb_shuffle: WallClimbShuffle
|
||||
reliquary_shuffle: ReliquaryShuffle
|
||||
boots_of_pleading: CustomItem1
|
||||
purified_hand: CustomItem2
|
||||
start_wheel: StartWheel
|
||||
skill_randomizer: SkillRando
|
||||
enemy_randomizer: EnemyRando
|
||||
enemy_groups: EnemyGroups
|
||||
enemy_scaling: EnemyScaling
|
||||
death_link: BlasphemousDeathLink
|
||||
|
||||
|
||||
blas_option_groups = [
|
||||
OptionGroup("Quality of Life", [
|
||||
PrieDieuWarp,
|
||||
SkipCutscenes,
|
||||
CorpseHints,
|
||||
SkipLongQuests,
|
||||
StartWheel
|
||||
]),
|
||||
OptionGroup("Moveset", [
|
||||
DashShuffle,
|
||||
WallClimbShuffle,
|
||||
SkillRando,
|
||||
CustomItem1,
|
||||
CustomItem2
|
||||
]),
|
||||
OptionGroup("Enemy Randomizer", [
|
||||
EnemyRando,
|
||||
EnemyGroups,
|
||||
EnemyScaling
|
||||
])
|
||||
]
|
||||
blasphemous_options = {
|
||||
"prie_dieu_warp": PrieDieuWarp,
|
||||
"skip_cutscenes": SkipCutscenes,
|
||||
"corpse_hints": CorpseHints,
|
||||
"difficulty": Difficulty,
|
||||
"penitence": Penitence,
|
||||
"starting_location": StartingLocation,
|
||||
"ending": Ending,
|
||||
"skip_long_quests": SkipLongQuests,
|
||||
"thorn_shuffle" : ThornShuffle,
|
||||
"dash_shuffle": DashShuffle,
|
||||
"wall_climb_shuffle": WallClimbShuffle,
|
||||
"reliquary_shuffle": ReliquaryShuffle,
|
||||
"boots_of_pleading": CustomItem1,
|
||||
"purified_hand": CustomItem2,
|
||||
"start_wheel": StartWheel,
|
||||
"skill_randomizer": SkillRando,
|
||||
"enemy_randomizer": EnemyRando,
|
||||
"enemy_groups": EnemyGroups,
|
||||
"enemy_scaling": EnemyScaling,
|
||||
"death_link": BlasphemousDeathLink,
|
||||
"start_inventory": StartInventoryPool
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
# Preprocessor to convert Blasphemous Randomizer logic into a StringWorldDefinition for use with APHKLogicExtractor
|
||||
# https://github.com/BrandenEK/Blasphemous.Randomizer
|
||||
# https://github.com/ArchipelagoMW-HollowKnight/APHKLogicExtractor
|
||||
|
||||
|
||||
import json, requests, argparse
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def load_resource_local(file: str) -> List[Dict[str, Any]]:
|
||||
print(f"Reading from {file}")
|
||||
loaded = []
|
||||
with open(file, encoding="utf-8") as f:
|
||||
loaded = read_json(f.readlines())
|
||||
f.close()
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load_resource_from_web(url: str) -> List[Dict[str, Any]]:
|
||||
req = requests.get(url, timeout=1)
|
||||
print(f"Reading from {url}")
|
||||
req.encoding = "utf-8"
|
||||
lines: List[str] = []
|
||||
for line in req.text.splitlines():
|
||||
while "\t" in line:
|
||||
line = line[1::]
|
||||
if line != "":
|
||||
lines.append(line)
|
||||
return read_json(lines)
|
||||
|
||||
|
||||
def read_json(lines: List[str]) -> List[Dict[str, Any]]:
|
||||
loaded = []
|
||||
creating_object: bool = False
|
||||
obj: str = ""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if "{" in stripped:
|
||||
creating_object = True
|
||||
obj += stripped
|
||||
continue
|
||||
elif "}," in stripped or "}" in stripped and "]" in lines[lines.index(line)+1]:
|
||||
creating_object = False
|
||||
obj += "}"
|
||||
#print(f"obj = {obj}")
|
||||
loaded.append(json.loads(obj))
|
||||
obj = ""
|
||||
continue
|
||||
|
||||
if not creating_object:
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
if "}," in lines[lines.index(line)+1] and stripped[-1] == ",":
|
||||
obj += stripped[:-1]
|
||||
else:
|
||||
obj += stripped
|
||||
except IndexError:
|
||||
obj += stripped
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def get_room_from_door(door: str) -> str:
|
||||
return door[:door.find("[")]
|
||||
|
||||
|
||||
def preprocess_logic(is_door: bool, id: str, logic: str) -> str:
|
||||
if id in logic and not is_door:
|
||||
index: int = logic.find(id)
|
||||
logic = logic[:index] + logic[index+len(id)+4:]
|
||||
|
||||
while ">=" in logic:
|
||||
index: int = logic.find(">=")
|
||||
logic = logic[:index-1] + logic[index+3:]
|
||||
|
||||
while ">" in logic:
|
||||
index: int = logic.find(">")
|
||||
count = int(logic[index+2])
|
||||
count += 1
|
||||
logic = logic[:index-1] + str(count) + logic[index+3:]
|
||||
|
||||
while "<=" in logic:
|
||||
index: int = logic.find("<=")
|
||||
logic = logic[:index-1] + logic[index+3:]
|
||||
|
||||
while "<" in logic:
|
||||
index: int = logic.find("<")
|
||||
count = int(logic[index+2])
|
||||
count += 1
|
||||
logic = logic[:index-1] + str(count) + logic[index+3:]
|
||||
|
||||
#print(logic)
|
||||
return logic
|
||||
|
||||
|
||||
def build_logic_conditions(logic: str) -> List[List[str]]:
|
||||
all_conditions: List[List[str]] = []
|
||||
|
||||
parts = logic.split()
|
||||
sub_part: str = ""
|
||||
current_index: int = 0
|
||||
parens: int = -1
|
||||
current_condition: List[str] = []
|
||||
parens_conditions: List[List[List[str]]] = []
|
||||
|
||||
for index, part in enumerate(parts):
|
||||
#print(current_index, index, parens, part)
|
||||
|
||||
# skip parts that have already been handled
|
||||
if index < current_index:
|
||||
continue
|
||||
|
||||
# break loop if reached final part
|
||||
try:
|
||||
parts[index+1]
|
||||
except IndexError:
|
||||
#print("INDEXERROR", part)
|
||||
if parens < 0:
|
||||
current_condition.append(part)
|
||||
if len(parens_conditions) > 0:
|
||||
for i in parens_conditions:
|
||||
for j in i:
|
||||
all_conditions.append(j + current_condition)
|
||||
else:
|
||||
all_conditions.append(current_condition)
|
||||
break
|
||||
|
||||
#print(current_condition, parens, sub_part)
|
||||
|
||||
# prepare for subcondition
|
||||
if "(" in part:
|
||||
# keep track of nested parentheses
|
||||
if parens == -1:
|
||||
parens = 0
|
||||
for char in part:
|
||||
if char == "(":
|
||||
parens += 1
|
||||
|
||||
# add to sub part
|
||||
if sub_part == "":
|
||||
sub_part = part
|
||||
else:
|
||||
sub_part += f" {part}"
|
||||
#if not ")" in part:
|
||||
continue
|
||||
|
||||
# end of subcondition
|
||||
if ")" in part:
|
||||
# read every character in case of multiple closing parentheses
|
||||
for char in part:
|
||||
if char == ")":
|
||||
parens -= 1
|
||||
|
||||
sub_part += f" {part}"
|
||||
|
||||
# if reached end of parentheses, handle subcondition
|
||||
if parens == 0:
|
||||
#print(current_condition, sub_part)
|
||||
parens = -1
|
||||
|
||||
try:
|
||||
parts[index+1]
|
||||
except IndexError:
|
||||
#print("END OF LOGIC")
|
||||
if len(parens_conditions) > 0:
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
|
||||
temp_conditions: List[List[str]] = []
|
||||
|
||||
for i in parens_conditions[0]:
|
||||
for j in parens_conditions[1]:
|
||||
temp_conditions.append(i + j)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
parens_conditions.pop(0)
|
||||
|
||||
while len(parens_conditions) > 0:
|
||||
temp_conditions2 = temp_conditions
|
||||
temp_conditions = []
|
||||
for k in temp_conditions2:
|
||||
for l in parens_conditions[0]:
|
||||
temp_conditions.append(k + l)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
|
||||
#print("TEMP:", remove_duplicates(temp_conditions))
|
||||
all_conditions += temp_conditions
|
||||
else:
|
||||
all_conditions += build_logic_subconditions(current_condition, sub_part)
|
||||
else:
|
||||
#print("NEXT PARTS:", parts[index+1], parts[index+2])
|
||||
if parts[index+1] == "&&":
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
else:
|
||||
if len(parens_conditions) > 0:
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
|
||||
temp_conditions: List[List[str]] = []
|
||||
|
||||
for i in parens_conditions[0]:
|
||||
for j in parens_conditions[1]:
|
||||
temp_conditions.append(i + j)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
parens_conditions.pop(0)
|
||||
|
||||
while len(parens_conditions) > 0:
|
||||
temp_conditions2 = temp_conditions
|
||||
temp_conditions = []
|
||||
for k in temp_conditions2:
|
||||
for l in parens_conditions[0]:
|
||||
temp_conditions.append(k + l)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
|
||||
#print("TEMP:", remove_duplicates(temp_conditions))
|
||||
all_conditions += temp_conditions
|
||||
else:
|
||||
all_conditions += build_logic_subconditions(current_condition, sub_part)
|
||||
|
||||
current_index = index+2
|
||||
|
||||
current_condition = []
|
||||
sub_part = ""
|
||||
|
||||
continue
|
||||
|
||||
# collect all parts until reaching end of parentheses
|
||||
if parens > 0:
|
||||
sub_part += f" {part}"
|
||||
continue
|
||||
|
||||
current_condition.append(part)
|
||||
|
||||
# continue with current condition
|
||||
if parts[index+1] == "&&":
|
||||
current_index = index+2
|
||||
continue
|
||||
|
||||
# add condition to list and start new one
|
||||
elif parts[index+1] == "||":
|
||||
if len(parens_conditions) > 0:
|
||||
for i in parens_conditions:
|
||||
for j in i:
|
||||
all_conditions.append(j + current_condition)
|
||||
parens_conditions = []
|
||||
else:
|
||||
all_conditions.append(current_condition)
|
||||
current_condition = []
|
||||
current_index = index+2
|
||||
continue
|
||||
|
||||
return remove_duplicates(all_conditions)
|
||||
|
||||
|
||||
def build_logic_subconditions(current_condition: List[str], subcondition: str) -> List[List[str]]:
|
||||
#print("STARTED SUBCONDITION", current_condition, subcondition)
|
||||
subconditions = build_logic_conditions(subcondition[1:-1])
|
||||
final_conditions = []
|
||||
|
||||
for condition in subconditions:
|
||||
final_condition = current_condition + condition
|
||||
final_conditions.append(final_condition)
|
||||
|
||||
#print("ENDED SUBCONDITION")
|
||||
#print(final_conditions)
|
||||
return final_conditions
|
||||
|
||||
|
||||
def remove_duplicates(conditions: List[List[str]]) -> List[List[str]]:
|
||||
final_conditions: List[List[str]] = []
|
||||
for condition in conditions:
|
||||
final_conditions.append(list(dict.fromkeys(condition)))
|
||||
|
||||
return final_conditions
|
||||
|
||||
|
||||
def handle_door_visibility(door: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if door.get("visibilityFlags") == None:
|
||||
return door
|
||||
else:
|
||||
flags: List[str] = str(door.get("visibilityFlags")).split(", ")
|
||||
#print(flags)
|
||||
temp_flags: List[str] = []
|
||||
this_door: bool = False
|
||||
#required_doors: str = ""
|
||||
|
||||
if "ThisDoor" in flags:
|
||||
this_door = True
|
||||
|
||||
#if "requiredDoors" in flags:
|
||||
# required_doors: str = " || ".join(door.get("requiredDoors"))
|
||||
|
||||
if "DoubleJump" in flags:
|
||||
temp_flags.append("DoubleJump")
|
||||
|
||||
if "NormalLogic" in flags:
|
||||
temp_flags.append("NormalLogic")
|
||||
|
||||
if "NormalLogicAndDoubleJump" in flags:
|
||||
temp_flags.append("NormalLogicAndDoubleJump")
|
||||
|
||||
if "HardLogic" in flags:
|
||||
temp_flags.append("HardLogic")
|
||||
|
||||
if "HardLogicAndDoubleJump" in flags:
|
||||
temp_flags.append("HardLogicAndDoubleJump")
|
||||
|
||||
if "EnemySkips" in flags:
|
||||
temp_flags.append("EnemySkips")
|
||||
|
||||
if "EnemySkipsAndDoubleJump" in flags:
|
||||
temp_flags.append("EnemySkipsAndDoubleJump")
|
||||
|
||||
# remove duplicates
|
||||
temp_flags = list(dict.fromkeys(temp_flags))
|
||||
|
||||
original_logic: str = door.get("logic")
|
||||
temp_logic: str = ""
|
||||
|
||||
if this_door:
|
||||
temp_logic = door.get("id")
|
||||
|
||||
if temp_flags != []:
|
||||
if temp_logic != "":
|
||||
temp_logic += " || "
|
||||
temp_logic += ' && '.join(temp_flags)
|
||||
|
||||
if temp_logic != "" and original_logic != None:
|
||||
if len(original_logic.split()) == 1:
|
||||
if len(temp_logic.split()) == 1:
|
||||
door["logic"] = f"{temp_logic} && {original_logic}"
|
||||
else:
|
||||
door["logic"] = f"({temp_logic}) && {original_logic}"
|
||||
else:
|
||||
if len(temp_logic.split()) == 1:
|
||||
door["logic"] = f"{temp_logic} && ({original_logic})"
|
||||
else:
|
||||
door["logic"] = f"({temp_logic}) && ({original_logic})"
|
||||
elif temp_logic != "" and original_logic == None:
|
||||
door["logic"] = temp_logic
|
||||
|
||||
return door
|
||||
|
||||
|
||||
def get_state_provider_for_condition(condition: List[str]) -> str:
|
||||
for item in condition:
|
||||
if (item[0] == "D" and item[3] == "Z" and item[6] == "S")\
|
||||
or (item[0] == "D" and item[3] == "B" and item[4] == "Z" and item[7] == "S"):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('-l', '--local', action="store_true", help="Use local files in the same directory instead of reading resource files from the BrandenEK/Blasphemous-Randomizer repository.")
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def main(args: argparse.Namespace):
|
||||
doors = []
|
||||
locations = []
|
||||
|
||||
if (args.local):
|
||||
doors = load_resource_local("doors.json")
|
||||
locations = load_resource_local("locations_items.json")
|
||||
|
||||
else:
|
||||
doors = load_resource_from_web("https://raw.githubusercontent.com/BrandenEK/Blasphemous-Randomizer/main/resources/data/Randomizer/doors.json")
|
||||
locations = load_resource_from_web("https://raw.githubusercontent.com/BrandenEK/Blasphemous-Randomizer/main/resources/data/Randomizer/locations_items.json")
|
||||
|
||||
original_connections: Dict[str, str] = {}
|
||||
rooms: Dict[str, List[str]] = {}
|
||||
output: Dict[str, Any] = {}
|
||||
logic_objects: List[Dict[str, Any]] = []
|
||||
|
||||
for door in doors:
|
||||
if door.get("originalDoor") != None:
|
||||
if not door.get("id") in original_connections:
|
||||
original_connections[door.get("id")] = door.get("originalDoor")
|
||||
original_connections[door.get("originalDoor")] = door.get("id")
|
||||
|
||||
room: str = get_room_from_door(door.get("originalDoor"))
|
||||
if not room in rooms.keys():
|
||||
rooms[room] = [door.get("id")]
|
||||
else:
|
||||
rooms[room].append(door.get("id"))
|
||||
|
||||
def flip_doors_in_condition(condition: List[str]) -> List[str]:
|
||||
new_condition = []
|
||||
for item in condition:
|
||||
if item in original_connections:
|
||||
new_condition.append(original_connections[item])
|
||||
else:
|
||||
new_condition.append(item)
|
||||
|
||||
return new_condition
|
||||
|
||||
for room in rooms.keys():
|
||||
obj = {
|
||||
"Name": room,
|
||||
"Logic": [],
|
||||
"Handling": "Default"
|
||||
}
|
||||
|
||||
for door in rooms[room]:
|
||||
logic = {
|
||||
"StateProvider": door,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
for door in doors:
|
||||
if door.get("direction") == 5:
|
||||
continue
|
||||
|
||||
handling: str = "Transition"
|
||||
if "Cell" in door.get("id"):
|
||||
handling = "Default"
|
||||
obj = {
|
||||
"Name": door.get("id"),
|
||||
"Logic": [],
|
||||
"Handling": handling
|
||||
}
|
||||
|
||||
visibility_flags: List[str] = []
|
||||
if door.get("visibilityFlags") != None:
|
||||
visibility_flags = str(door.get("visibilityFlags")).split(", ")
|
||||
if "1" in visibility_flags:
|
||||
visibility_flags.remove("1")
|
||||
visibility_flags.append("ThisDoor")
|
||||
|
||||
required_doors: List[str] = []
|
||||
if door.get("requiredDoors"):
|
||||
required_doors = door.get("requiredDoors")
|
||||
|
||||
if len(visibility_flags) > 0:
|
||||
for flag in visibility_flags:
|
||||
if flag == "RequiredDoors":
|
||||
continue
|
||||
|
||||
if flag == "ThisDoor":
|
||||
flag = original_connections[door.get("id")]
|
||||
|
||||
if door.get("logic") != None:
|
||||
logic: str = door.get("logic")
|
||||
logic = f"{flag} && ({logic})"
|
||||
logic = preprocess_logic(True, door.get("id"), logic)
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
state_provider: str = get_room_from_door(door.get("id"))
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
state_provider = get_state_provider_for_condition(condition)
|
||||
condition.remove(state_provider)
|
||||
|
||||
logic = {
|
||||
"StateProvider": state_provider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": get_room_from_door(door.get("id")),
|
||||
"Conditions": [flag],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
if "RequiredDoors" in visibility_flags:
|
||||
for d in required_doors:
|
||||
flipped = original_connections[d]
|
||||
if door.get("logic") != None:
|
||||
logic: str = preprocess_logic(True, door.get("id"), door.get("logic"))
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
state_provider: str = flipped
|
||||
|
||||
if flipped in condition:
|
||||
condition.remove(flipped)
|
||||
|
||||
logic = {
|
||||
"StateProvider": state_provider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": flipped,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
else:
|
||||
if door.get("logic") != None:
|
||||
logic: str = preprocess_logic(True, door.get("id"), door.get("logic"))
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
stateProvider: str = get_room_from_door(door.get("id"))
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
stateProvider = get_state_provider_for_condition(condition)
|
||||
condition.remove(stateProvider)
|
||||
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": get_room_from_door(door.get("id")),
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
for location in locations:
|
||||
obj = {
|
||||
"Name": location.get("id"),
|
||||
"Logic": [],
|
||||
"Handling": "Location"
|
||||
}
|
||||
|
||||
if location.get("logic") != None:
|
||||
for condition in build_logic_conditions(preprocess_logic(False, location.get("id"), location.get("logic"))):
|
||||
condition = flip_doors_in_condition(condition)
|
||||
stateProvider: str = location.get("room")
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
stateProvider = get_state_provider_for_condition(condition)
|
||||
condition.remove(stateProvider)
|
||||
|
||||
if stateProvider == "Initial":
|
||||
stateProvider = None
|
||||
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
stateProvider: str = location.get("room")
|
||||
if stateProvider == "Initial":
|
||||
stateProvider = None
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
output["LogicObjects"] = logic_objects
|
||||
|
||||
with open("StringWorldDefinition.json", "w") as file:
|
||||
print("Writing to StringWorldDefinition.json")
|
||||
file.write(json.dumps(output, indent=4))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(parse_args())
|
||||
5405
worlds/blasphemous/Rooms.py
Normal file
5405
worlds/blasphemous/Rooms.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,12 @@ unrandomized_dict: Dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
junk_locations: Set[str] = {
|
||||
junk_locations: Set[str] = [
|
||||
"Albero: Donate 50000 Tears",
|
||||
"Ossuary: 11th reward",
|
||||
"AtTotS: Miriam's gift",
|
||||
"TSC: Jocinero's final reward"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
thorn_set: Set[str] = {
|
||||
@@ -44,4 +44,4 @@ skill_dict: Dict[str, str] = {
|
||||
"Skill 5, Tier 1": "Lunge Skill",
|
||||
"Skill 5, Tier 2": "Lunge Skill",
|
||||
"Skill 5, Tier 3": "Lunge Skill",
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
from typing import Dict, List, Set, Any
|
||||
from collections import Counter
|
||||
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
|
||||
from Options import OptionError
|
||||
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from .Items import base_id, item_table, group_table, tears_list, reliquary_set
|
||||
from .Locations import location_names
|
||||
from .Rules import BlasRules
|
||||
from worlds.generic.Rules import set_rule
|
||||
from .Options import BlasphemousOptions, blas_option_groups
|
||||
from .Items import base_id, item_table, group_table, tears_set, reliquary_set, event_table
|
||||
from .Locations import location_table
|
||||
from .Rooms import room_table, door_table
|
||||
from .Rules import rules
|
||||
from worlds.generic.Rules import set_rule, add_rule
|
||||
from .Options import blasphemous_options
|
||||
from .Vanilla import unrandomized_dict, junk_locations, thorn_set, skill_dict
|
||||
from .region_data import regions, locations
|
||||
|
||||
|
||||
class BlasphemousWeb(WebWorld):
|
||||
theme = "stone"
|
||||
@@ -21,33 +21,39 @@ class BlasphemousWeb(WebWorld):
|
||||
"setup/en",
|
||||
["TRPG"]
|
||||
)]
|
||||
option_groups = blas_option_groups
|
||||
|
||||
|
||||
class BlasphemousWorld(World):
|
||||
"""
|
||||
Blasphemous is a challenging Metroidvania set in the cursed land of Cvstodia. Play as the Penitent One, trapped
|
||||
in an endless cycle of death and rebirth, and free the world from its terrible fate in your quest to break
|
||||
in an endless cycle of death and rebirth, and free the world from it's terrible fate in your quest to break
|
||||
your eternal damnation!
|
||||
"""
|
||||
|
||||
game = "Blasphemous"
|
||||
game: str = "Blasphemous"
|
||||
web = BlasphemousWeb()
|
||||
|
||||
item_name_to_id = {item["name"]: (base_id + index) for index, item in enumerate(item_table)}
|
||||
location_name_to_id = {loc: (base_id + index) for index, loc in enumerate(location_names.values())}
|
||||
location_name_to_id = {loc["name"]: (base_id + index) for index, loc in enumerate(location_table)}
|
||||
location_name_to_game_id = {loc["name"]: loc["game_id"] for loc in location_table}
|
||||
|
||||
item_name_groups = group_table
|
||||
options_dataclass = BlasphemousOptions
|
||||
options: BlasphemousOptions
|
||||
option_definitions = blasphemous_options
|
||||
|
||||
required_client_version = (0, 4, 7)
|
||||
required_client_version = (0, 4, 2)
|
||||
|
||||
|
||||
def __init__(self, multiworld, player):
|
||||
super(BlasphemousWorld, self).__init__(multiworld, player)
|
||||
self.start_room: str = "D17Z01S01"
|
||||
self.disabled_locations: List[str] = []
|
||||
self.door_connections: Dict[str, str] = {}
|
||||
|
||||
|
||||
def set_rules(self):
|
||||
rules(self)
|
||||
for door in door_table:
|
||||
add_rule(self.multiworld.get_location(door["Id"], self.player),
|
||||
lambda state: state.can_reach(self.get_connected_door(door["Id"])), self.player)
|
||||
|
||||
|
||||
def create_item(self, name: str) -> "BlasphemousItem":
|
||||
@@ -62,56 +68,64 @@ class BlasphemousWorld(World):
|
||||
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.random.choice(tears_list)
|
||||
return self.multiworld.random.choice(tears_set)
|
||||
|
||||
|
||||
def generate_early(self):
|
||||
if not self.options.starting_location.randomized:
|
||||
if self.options.starting_location == "mourning_havoc" and self.options.difficulty < 2:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Difficulty is lower than Hard.")
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
|
||||
if (self.options.starting_location == "brotherhood" or self.options.starting_location == "mourning_havoc") \
|
||||
and self.options.dash_shuffle:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Shuffle Dash is enabled.")
|
||||
if not world.starting_location[player].randomized:
|
||||
if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
|
||||
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
|
||||
" cannot be chosen if Difficulty is lower than Hard.")
|
||||
|
||||
if (world.starting_location[player].value == 0 or world.starting_location[player].value == 6) \
|
||||
and world.dash_shuffle[player]:
|
||||
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
|
||||
" cannot be chosen if Shuffle Dash is enabled.")
|
||||
|
||||
if self.options.starting_location == "grievance" and self.options.wall_climb_shuffle:
|
||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||
f"{self.options.starting_location} cannot be chosen if Shuffle Wall Climb is enabled.")
|
||||
if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
|
||||
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
|
||||
" cannot be chosen if Shuffle Wall Climb is enabled.")
|
||||
else:
|
||||
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ]
|
||||
invalid: bool = False
|
||||
|
||||
if self.options.difficulty < 2:
|
||||
if world.difficulty[player].value < 2:
|
||||
locations.remove(6)
|
||||
|
||||
if self.options.dash_shuffle:
|
||||
if world.dash_shuffle[player]:
|
||||
locations.remove(0)
|
||||
if 6 in locations:
|
||||
locations.remove(6)
|
||||
|
||||
if self.options.wall_climb_shuffle:
|
||||
if world.wall_climb_shuffle[player]:
|
||||
locations.remove(3)
|
||||
|
||||
if self.options.starting_location.value not in locations:
|
||||
self.options.starting_location.value = self.random.choice(locations)
|
||||
if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
|
||||
invalid = True
|
||||
|
||||
if (world.starting_location[player].value == 0 or world.starting_location[player].value == 6) \
|
||||
and world.dash_shuffle[player]:
|
||||
invalid = True
|
||||
|
||||
if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
|
||||
invalid = True
|
||||
|
||||
if invalid:
|
||||
world.starting_location[player].value = world.random.choice(locations)
|
||||
|
||||
|
||||
if not self.options.dash_shuffle:
|
||||
self.multiworld.push_precollected(self.create_item("Dash Ability"))
|
||||
if not world.dash_shuffle[player]:
|
||||
world.push_precollected(self.create_item("Dash Ability"))
|
||||
|
||||
if not self.options.wall_climb_shuffle:
|
||||
self.multiworld.push_precollected(self.create_item("Wall Climb Ability"))
|
||||
if not world.wall_climb_shuffle[player]:
|
||||
world.push_precollected(self.create_item("Wall Climb Ability"))
|
||||
|
||||
if not self.options.boots_of_pleading:
|
||||
self.disabled_locations.append("RE401")
|
||||
|
||||
if not self.options.purified_hand:
|
||||
self.disabled_locations.append("RE402")
|
||||
|
||||
if self.options.skip_long_quests:
|
||||
if world.skip_long_quests[player]:
|
||||
for loc in junk_locations:
|
||||
self.options.exclude_locations.value.add(loc)
|
||||
world.exclude_locations[player].value.add(loc)
|
||||
|
||||
start_rooms: Dict[int, str] = {
|
||||
0: "D17Z01S01",
|
||||
@@ -123,10 +137,13 @@ class BlasphemousWorld(World):
|
||||
6: "D20Z02S09"
|
||||
}
|
||||
|
||||
self.start_room = start_rooms[self.options.starting_location.value]
|
||||
self.start_room = start_rooms[world.starting_location[player].value]
|
||||
|
||||
|
||||
def create_items(self):
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
|
||||
removed: int = 0
|
||||
to_remove: List[str] = [
|
||||
"Tears of Atonement (250)",
|
||||
@@ -139,46 +156,46 @@ class BlasphemousWorld(World):
|
||||
skipped_items = []
|
||||
junk: int = 0
|
||||
|
||||
for item, count in self.options.start_inventory.value.items():
|
||||
for item, count in world.start_inventory[player].value.items():
|
||||
for _ in range(count):
|
||||
skipped_items.append(item)
|
||||
junk += 1
|
||||
|
||||
skipped_items.extend(unrandomized_dict.values())
|
||||
|
||||
if self.options.thorn_shuffle == "vanilla":
|
||||
for _ in range(8):
|
||||
if world.thorn_shuffle[player] == 2:
|
||||
for i in range(8):
|
||||
skipped_items.append("Thorn Upgrade")
|
||||
|
||||
if self.options.dash_shuffle:
|
||||
if world.dash_shuffle[player]:
|
||||
skipped_items.append(to_remove[removed])
|
||||
removed += 1
|
||||
elif not self.options.dash_shuffle:
|
||||
elif not world.dash_shuffle[player]:
|
||||
skipped_items.append("Dash Ability")
|
||||
|
||||
if self.options.wall_climb_shuffle:
|
||||
if world.wall_climb_shuffle[player]:
|
||||
skipped_items.append(to_remove[removed])
|
||||
removed += 1
|
||||
elif not self.options.wall_climb_shuffle:
|
||||
elif not world.wall_climb_shuffle[player]:
|
||||
skipped_items.append("Wall Climb Ability")
|
||||
|
||||
if not self.options.reliquary_shuffle:
|
||||
if not world.reliquary_shuffle[player]:
|
||||
skipped_items.extend(reliquary_set)
|
||||
elif self.options.reliquary_shuffle:
|
||||
for _ in range(3):
|
||||
elif world.reliquary_shuffle[player]:
|
||||
for i in range(3):
|
||||
skipped_items.append(to_remove[removed])
|
||||
removed += 1
|
||||
|
||||
if not self.options.boots_of_pleading:
|
||||
if not world.boots_of_pleading[player]:
|
||||
skipped_items.append("Boots of Pleading")
|
||||
|
||||
if not self.options.purified_hand:
|
||||
if not world.purified_hand[player]:
|
||||
skipped_items.append("Purified Hand of the Nun")
|
||||
|
||||
if self.options.start_wheel:
|
||||
if world.start_wheel[player]:
|
||||
skipped_items.append("The Young Mason's Wheel")
|
||||
|
||||
if not self.options.skill_randomizer:
|
||||
if not world.skill_randomizer[player]:
|
||||
skipped_items.extend(skill_dict.values())
|
||||
|
||||
counter = Counter(skipped_items)
|
||||
@@ -191,138 +208,184 @@ class BlasphemousWorld(World):
|
||||
if count <= 0:
|
||||
continue
|
||||
else:
|
||||
for _ in range(count):
|
||||
for i in range(count):
|
||||
pool.append(self.create_item(item["name"]))
|
||||
|
||||
for _ in range(junk):
|
||||
pool.append(self.create_item(self.get_filler_item_name()))
|
||||
|
||||
self.multiworld.itempool += pool
|
||||
world.itempool += pool
|
||||
|
||||
|
||||
def pre_fill(self):
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
|
||||
self.place_items_from_dict(unrandomized_dict)
|
||||
|
||||
if self.options.thorn_shuffle == "vanilla":
|
||||
if world.thorn_shuffle[player] == 2:
|
||||
self.place_items_from_set(thorn_set, "Thorn Upgrade")
|
||||
|
||||
if self.options.start_wheel:
|
||||
self.get_location("Beginning gift").place_locked_item(self.create_item("The Young Mason's Wheel"))
|
||||
if world.start_wheel[player]:
|
||||
world.get_location("Beginning gift", player)\
|
||||
.place_locked_item(self.create_item("The Young Mason's Wheel"))
|
||||
|
||||
if not self.options.skill_randomizer:
|
||||
if not world.skill_randomizer[player]:
|
||||
self.place_items_from_dict(skill_dict)
|
||||
|
||||
if self.options.thorn_shuffle == "local_only":
|
||||
self.options.local_items.value.add("Thorn Upgrade")
|
||||
if world.thorn_shuffle[player] == 1:
|
||||
world.local_items[player].value.add("Thorn Upgrade")
|
||||
|
||||
|
||||
def place_items_from_set(self, location_set: Set[str], name: str):
|
||||
for loc in location_set:
|
||||
self.get_location(loc).place_locked_item(self.create_item(name))
|
||||
self.multiworld.get_location(loc, self.player)\
|
||||
.place_locked_item(self.create_item(name))
|
||||
|
||||
|
||||
def place_items_from_dict(self, option_dict: Dict[str, str]):
|
||||
for loc, item in option_dict.items():
|
||||
self.get_location(loc).place_locked_item(self.create_item(item))
|
||||
self.multiworld.get_location(loc, self.player)\
|
||||
.place_locked_item(self.create_item(item))
|
||||
|
||||
|
||||
def create_regions(self) -> None:
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
|
||||
created_regions: List[str] = []
|
||||
|
||||
for r in regions:
|
||||
multiworld.regions.append(Region(r["name"], player, multiworld))
|
||||
created_regions.append(r["name"])
|
||||
|
||||
self.get_region("Menu").add_exits({self.start_room: "New Game"})
|
||||
|
||||
blas_logic = BlasRules(self)
|
||||
|
||||
for r in regions:
|
||||
region = self.get_region(r["name"])
|
||||
|
||||
for e in r["exits"]:
|
||||
region.add_exits({e["target"]}, {e["target"]: blas_logic.load_rule(True, r["name"], e)})
|
||||
|
||||
for l in [l for l in r["locations"] if l not in self.disabled_locations]:
|
||||
region.add_locations({location_names[l]: self.location_name_to_id[location_names[l]]}, BlasphemousLocation)
|
||||
|
||||
for t in r["transitions"]:
|
||||
if t == r["name"]:
|
||||
continue
|
||||
|
||||
if t in created_regions:
|
||||
region.add_exits({t})
|
||||
else:
|
||||
multiworld.regions.append(Region(t, player, multiworld))
|
||||
created_regions.append(t)
|
||||
region.add_exits({t})
|
||||
|
||||
|
||||
for l in [l for l in locations if l["name"] not in self.disabled_locations]:
|
||||
location = self.get_location(location_names[l["name"]])
|
||||
set_rule(location, blas_logic.load_rule(False, l["name"], l))
|
||||
|
||||
for rname, ename in blas_logic.indirect_conditions:
|
||||
self.multiworld.register_indirect_condition(self.get_region(rname), self.get_entrance(ename))
|
||||
#from Utils import visualize_regions
|
||||
#visualize_regions(self.get_region("Menu"), "blasphemous_regions.puml")
|
||||
world = self.multiworld
|
||||
|
||||
victory = Location(player, "His Holiness Escribar", None, self.get_region("D07Z01S03[W]"))
|
||||
victory.place_locked_item(self.create_event("Victory"))
|
||||
self.get_region("D07Z01S03[W]").locations.append(victory)
|
||||
menu_region = Region("Menu", player, world)
|
||||
misc_region = Region("Misc", player, world)
|
||||
world.regions += [menu_region, misc_region]
|
||||
|
||||
if self.options.ending == "ending_a":
|
||||
for room in room_table:
|
||||
region = Region(room, player, world)
|
||||
world.regions.append(region)
|
||||
|
||||
menu_region.add_exits({self.start_room: "New Game"})
|
||||
world.get_region(self.start_room, player).add_exits({"Misc": "Misc"})
|
||||
|
||||
for door in door_table:
|
||||
if door.get("OriginalDoor") is None:
|
||||
continue
|
||||
else:
|
||||
if not door["Id"] in self.door_connections.keys():
|
||||
self.door_connections[door["Id"]] = door["OriginalDoor"]
|
||||
self.door_connections[door["OriginalDoor"]] = door["Id"]
|
||||
|
||||
parent_region: Region = self.get_room_from_door(door["Id"])
|
||||
target_region: Region = self.get_room_from_door(door["OriginalDoor"])
|
||||
parent_region.add_exits({
|
||||
target_region.name: door["Id"]
|
||||
}, {
|
||||
target_region.name: lambda x: door.get("VisibilityFlags") != 1
|
||||
})
|
||||
|
||||
for index, loc in enumerate(location_table):
|
||||
if not world.boots_of_pleading[player] and loc["name"] == "BotSS: 2nd meeting with Redento":
|
||||
continue
|
||||
if not world.purified_hand[player] and loc["name"] == "MoM: Western room ledge":
|
||||
continue
|
||||
|
||||
region: Region = world.get_region(loc["room"], player)
|
||||
region.add_locations({loc["name"]: base_id + index})
|
||||
#id = base_id + location_table.index(loc)
|
||||
#reg.locations.append(BlasphemousLocation(player, loc["name"], id, reg))
|
||||
|
||||
for e, r in event_table.items():
|
||||
region: Region = world.get_region(r, player)
|
||||
event = BlasphemousLocation(player, e, None, region)
|
||||
event.show_in_spoiler = False
|
||||
event.place_locked_item(self.create_event(e))
|
||||
region.locations.append(event)
|
||||
|
||||
for door in door_table:
|
||||
region: Region = self.get_room_from_door(self.door_connections[door["Id"]])
|
||||
event = BlasphemousLocation(player, door["Id"], None, region)
|
||||
event.show_in_spoiler = False
|
||||
event.place_locked_item(self.create_event(door["Id"]))
|
||||
region.locations.append(event)
|
||||
|
||||
victory = Location(player, "His Holiness Escribar", None, world.get_region("D07Z01S03", player))
|
||||
victory.place_locked_item(self.create_event("Victory"))
|
||||
world.get_region("D07Z01S03", player).locations.append(victory)
|
||||
|
||||
if world.ending[self.player].value == 1:
|
||||
set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8))
|
||||
elif self.options.ending == "ending_c":
|
||||
elif world.ending[self.player].value == 2:
|
||||
set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8) and
|
||||
state.has("Holy Wound of Abnegation", player))
|
||||
|
||||
multiworld.completion_condition[self.player] = lambda state: state.has("Victory", player)
|
||||
world.completion_condition[self.player] = lambda state: state.has("Victory", player)
|
||||
|
||||
|
||||
def get_room_from_door(self, door: str) -> Region:
|
||||
return self.multiworld.get_region(door.split("[")[0], self.player)
|
||||
|
||||
|
||||
def get_connected_door(self, door: str) -> Entrance:
|
||||
return self.multiworld.get_entrance(self.door_connections[door], self.player)
|
||||
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
slot_data: Dict[str, Any] = {}
|
||||
locations = []
|
||||
doors: Dict[str, str] = {}
|
||||
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
thorns: bool = True
|
||||
|
||||
if self.options.thorn_shuffle == "vanilla":
|
||||
if world.thorn_shuffle[player].value == 2:
|
||||
thorns = False
|
||||
|
||||
for loc in world.get_filled_locations(player):
|
||||
if loc.item.code == None:
|
||||
continue
|
||||
else:
|
||||
data = {
|
||||
"id": self.location_name_to_game_id[loc.name],
|
||||
"ap_id": loc.address,
|
||||
"name": loc.item.name,
|
||||
"player_name": world.player_name[loc.item.player],
|
||||
"type": int(loc.item.classification)
|
||||
}
|
||||
|
||||
locations.append(data)
|
||||
|
||||
config = {
|
||||
"LogicDifficulty": self.options.difficulty.value,
|
||||
"StartingLocation": self.options.starting_location.value,
|
||||
"LogicDifficulty": world.difficulty[player].value,
|
||||
"StartingLocation": world.starting_location[player].value,
|
||||
"VersionCreated": "AP",
|
||||
|
||||
"UnlockTeleportation": bool(self.options.prie_dieu_warp.value),
|
||||
"AllowHints": bool(self.options.corpse_hints.value),
|
||||
"AllowPenitence": bool(self.options.penitence.value),
|
||||
"UnlockTeleportation": bool(world.prie_dieu_warp[player].value),
|
||||
"AllowHints": bool(world.corpse_hints[player].value),
|
||||
"AllowPenitence": bool(world.penitence[player].value),
|
||||
|
||||
"ShuffleReliquaries": bool(self.options.reliquary_shuffle.value),
|
||||
"ShuffleBootsOfPleading": bool(self.options.boots_of_pleading.value),
|
||||
"ShufflePurifiedHand": bool(self.options.purified_hand.value),
|
||||
"ShuffleDash": bool(self.options.dash_shuffle.value),
|
||||
"ShuffleWallClimb": bool(self.options.wall_climb_shuffle.value),
|
||||
"ShuffleReliquaries": bool(world.reliquary_shuffle[player].value),
|
||||
"ShuffleBootsOfPleading": bool(world.boots_of_pleading[player].value),
|
||||
"ShufflePurifiedHand": bool(world.purified_hand[player].value),
|
||||
"ShuffleDash": bool(world.dash_shuffle[player].value),
|
||||
"ShuffleWallClimb": bool(world.wall_climb_shuffle[player].value),
|
||||
|
||||
"ShuffleSwordSkills": bool(self.options.wall_climb_shuffle.value),
|
||||
"ShuffleSwordSkills": bool(world.skill_randomizer[player].value),
|
||||
"ShuffleThorns": thorns,
|
||||
"JunkLongQuests": bool(self.options.skip_long_quests.value),
|
||||
"StartWithWheel": bool(self.options.start_wheel.value),
|
||||
"JunkLongQuests": bool(world.skip_long_quests[player].value),
|
||||
"StartWithWheel": bool(world.start_wheel[player].value),
|
||||
|
||||
"EnemyShuffleType": self.options.enemy_randomizer.value,
|
||||
"MaintainClass": bool(self.options.enemy_groups.value),
|
||||
"AreaScaling": bool(self.options.enemy_scaling.value),
|
||||
"EnemyShuffleType": world.enemy_randomizer[player].value,
|
||||
"MaintainClass": bool(world.enemy_groups[player].value),
|
||||
"AreaScaling": bool(world.enemy_scaling[player].value),
|
||||
|
||||
"BossShuffleType": 0,
|
||||
"DoorShuffleType": 0
|
||||
}
|
||||
|
||||
slot_data = {
|
||||
"locationinfo": [{"gameId": loc, "apId": (base_id + index)} for index, loc in enumerate(location_names)],
|
||||
"locations": locations,
|
||||
"doors": doors,
|
||||
"cfg": config,
|
||||
"ending": self.options.ending.value,
|
||||
"death_link": bool(self.options.death_link.value)
|
||||
"ending": world.ending[self.player].value,
|
||||
"death_link": bool(world.death_link[self.player].value)
|
||||
}
|
||||
|
||||
return slot_data
|
||||
@@ -333,4 +396,4 @@ class BlasphemousItem(Item):
|
||||
|
||||
|
||||
class BlasphemousLocation(Location):
|
||||
game: str = "Blasphemous"
|
||||
game: str = "Blasphemous"
|
||||
@@ -1,17 +1,48 @@
|
||||
# Blasphemous Multiworld Setup Guide
|
||||
|
||||
It is recommended to use the [Mod Installer](https://github.com/BrandenEK/Blasphemous.Modding.Installer) to handle installing and updating mods. If you would prefer to install mods manually, instructions can also be found at the Mod Installer repository.
|
||||
## Useful Links
|
||||
|
||||
You will need the [Multiworld](https://github.com/BrandenEK/Blasphemous.Randomizer.Multiworld) mod to play an Archipelago randomizer.
|
||||
Required:
|
||||
- Blasphemous: [Steam](https://store.steampowered.com/app/774361/Blasphemous/)
|
||||
- The GOG version of Blasphemous will also work.
|
||||
- Blasphemous Mod Installer: [GitHub](https://github.com/BrandenEK/Blasphemous-Mod-Installer)
|
||||
- Blasphemous Modding API: [GitHub](https://github.com/BrandenEK/Blasphemous-Modding-API)
|
||||
- Blasphemous Randomizer: [GitHub](https://github.com/BrandenEK/Blasphemous-Randomizer)
|
||||
- Blasphemous Multiworld: [GitHub](https://github.com/BrandenEK/Blasphemous-Multiworld)
|
||||
|
||||
Some optional mods are also recommended:
|
||||
- [Rando Map](https://github.com/BrandenEK/Blasphemous.Randomizer.MapTracker)
|
||||
- [Boots of Pleading](https://github.com/BrandenEK/Blasphemous.BootsOfPleading) (Required if the "Boots of Pleading" option is enabled)
|
||||
- [Double Jump](https://github.com/BrandenEK/Blasphemous.DoubleJump) (Required if the "Purified Hand of the Nun" option is enabled)
|
||||
Optional:
|
||||
- In-game map tracker: [GitHub](https://github.com/BrandenEK/Blasphemous-Rando-Map)
|
||||
- Quick Prie Dieu warp mod: [GitHub](https://github.com/BadMagic100/Blasphemous-PrieWarp)
|
||||
- Boots of Pleading mod: [GitHub](https://github.com/BrandenEK/Blasphemous-Boots-of-Pleading)
|
||||
- Double Jump mod: [GitHub](https://github.com/BrandenEK/Blasphemous-Double-Jump)
|
||||
|
||||
To connect to a multiworld: Choose a save file and enter the address, your name, and the password (if the server has one) into the menu.
|
||||
## Mod Installer (Recommended)
|
||||
|
||||
After connecting, there are some commands you can use in the console, which can be opened by pressing backslash `\`:
|
||||
- `ap status` - Display connection status.
|
||||
- `ap say [message]` - Send a message to the server.
|
||||
- `ap hint [item]` - Request a hint for an item from the server.
|
||||
1. Download the [Mod Installer](https://github.com/BrandenEK/Blasphemous-Mod-Installer),
|
||||
and point it to your install directory for Blasphemous.
|
||||
|
||||
2. Install the `Modding API`, `Randomizer`, and `Multiworld` mods. Optionally, you can also install the
|
||||
`Rando Map`, `PrieWarp`, `Boots of Pleading`, and `Double Jump` mods, and set up the PopTracker pack if desired.
|
||||
|
||||
3. Start Blasphemous. To verfy that the mods are working, look for a version number for both
|
||||
the Randomizer and Multiworld on the title screen.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
1. Download the [Modding API](https://github.com/BrandenEK/Blasphemous-Modding-API/releases), and follow
|
||||
the [installation instructions](https://github.com/BrandenEK/Blasphemous-Modding-API#installation) on the GitHub page.
|
||||
|
||||
2. After the Modding API has been installed, download the
|
||||
[Randomizer](https://github.com/BrandenEK/Blasphemous-Randomizer/releases) and
|
||||
[Multiworld](https://github.com/BrandenEK/Blasphemous-Multiworld/releases) archives, and extract the contents of both
|
||||
into the `Modding` folder. Then, add any desired additional mods.
|
||||
|
||||
3. Start Blasphemous. To verfy that the mods are working, look for a version number for both
|
||||
the Randomizer and Multiworld on the title screen.
|
||||
|
||||
## Connecting
|
||||
|
||||
To connect to an Archipelago server, open the in-game console by pressing backslash `\` and use
|
||||
the command `multiworld connect [address:port] [name] [password]`.
|
||||
The port and password are both optional - if no port is provided then the default port of 38281 is used.
|
||||
**Make sure to connect to the server before attempting to start a new save file.**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
from test.bases import WorldTestBase
|
||||
from .. import BlasphemousWorld
|
||||
|
||||
|
||||
class BlasphemousTestBase(WorldTestBase):
|
||||
game = "Blasphemous"
|
||||
world: BlasphemousWorld
|
||||
@@ -1,56 +0,0 @@
|
||||
from . import BlasphemousTestBase
|
||||
from ..Locations import location_names
|
||||
|
||||
|
||||
class BotSSGauntletTest(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"wall_climb_shuffle": True,
|
||||
"dash_shuffle": True
|
||||
}
|
||||
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
return False
|
||||
|
||||
def test_botss_gauntlet(self) -> None:
|
||||
self.assertAccessDependency([location_names["CO25"]], [["Dash Ability", "Wall Climb Ability"]], True)
|
||||
|
||||
|
||||
class BackgroundZonesTest(BlasphemousTestBase):
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
return False
|
||||
|
||||
def test_dc_shroud(self) -> None:
|
||||
self.assertAccessDependency([location_names["RB03"]], [["Shroud of Dreamt Sins"]], True)
|
||||
|
||||
def test_wothp_bronze_cells(self) -> None:
|
||||
bronze_locations = [
|
||||
location_names["QI70"],
|
||||
location_names["RESCUED_CHERUB_03"]
|
||||
]
|
||||
|
||||
self.assertAccessDependency(bronze_locations, [["Key of the Secular"]], True)
|
||||
|
||||
def test_wothp_silver_cells(self) -> None:
|
||||
silver_locations = [
|
||||
location_names["CO24"],
|
||||
location_names["RESCUED_CHERUB_34"],
|
||||
location_names["CO37"],
|
||||
location_names["RESCUED_CHERUB_04"]
|
||||
]
|
||||
|
||||
self.assertAccessDependency(silver_locations, [["Key of the Scribe"]], True)
|
||||
|
||||
def test_wothp_gold_cells(self) -> None:
|
||||
gold_locations = [
|
||||
location_names["QI51"],
|
||||
location_names["CO26"],
|
||||
location_names["CO02"]
|
||||
]
|
||||
|
||||
self.assertAccessDependency(gold_locations, [["Key of the Inquisitor"]], True)
|
||||
|
||||
def test_wothp_quirce(self) -> None:
|
||||
self.assertAccessDependency([location_names["BS14"]], [["Key of the Secular", "Key of the Scribe", "Key of the Inquisitor"]], True)
|
||||
@@ -1,135 +0,0 @@
|
||||
from . import BlasphemousTestBase
|
||||
|
||||
|
||||
class TestBrotherhoodEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "brotherhood",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestBrotherhoodNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "brotherhood",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestBrotherhoodHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "brotherhood",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestAlberoEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestAlberoNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestAlberoHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "albero",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestConventEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "convent",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestConventNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "convent",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestConventHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "convent",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestGrievanceEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "grievance",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestGrievanceNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "grievance",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestGrievanceHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "grievance",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
class TestRooftopsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "rooftops",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
|
||||
|
||||
# mourning and havoc can't be selected on easy or normal. hard only
|
||||
class TestMourningHavocHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "mourning_havoc",
|
||||
"difficulty": "hard"
|
||||
}
|
||||
@@ -28,7 +28,7 @@ An Example `AP.json` file:
|
||||
|
||||
```
|
||||
{
|
||||
"Url": "archipelago.gg:12345",
|
||||
"Url": "archipelago:12345",
|
||||
"SlotName": "Maddy",
|
||||
"Password": ""
|
||||
}
|
||||
|
||||
@@ -44,15 +44,15 @@ class ChecksFinderWorld(World):
|
||||
self.multiworld.regions += [menu, board]
|
||||
|
||||
def create_items(self):
|
||||
# Generate list of items
|
||||
items_to_create = []
|
||||
# Generate item pool
|
||||
itempool = []
|
||||
# Add the map width and height stuff
|
||||
items_to_create += ["Map Width"] * 5 # 10 - 5
|
||||
items_to_create += ["Map Height"] * 5 # 10 - 5
|
||||
itempool += ["Map Width"] * 5 # 10 - 5
|
||||
itempool += ["Map Height"] * 5 # 10 - 5
|
||||
# Add the map bombs
|
||||
items_to_create += ["Map Bombs"] * 15 # 20 - 5
|
||||
# Convert list into real items
|
||||
itempool = [self.create_item(item) for item in items_to_create]
|
||||
itempool += ["Map Bombs"] * 15 # 20 - 5
|
||||
# Convert itempool into real items
|
||||
itempool = [self.create_item(item) for item in itempool]
|
||||
|
||||
self.multiworld.itempool += itempool
|
||||
|
||||
|
||||
@@ -63,9 +63,6 @@ all_bosses = [
|
||||
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 = {
|
||||
|
||||
@@ -238,6 +238,15 @@ class DS3ItemData:
|
||||
ds3_code = cast(int, self.ds3_code) + level,
|
||||
filler = False,
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return (self.name, self.ds3_code).__hash__()
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if isinstance(other, self.__class__):
|
||||
return self.name == other.name and self.ds3_code == other.ds3_code
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class DarkSouls3Item(Item):
|
||||
|
||||
@@ -612,7 +612,9 @@ class DarkSouls3World(World):
|
||||
self._add_entrance_rule("Painted World of Ariandel (Before Contraption)", "Basin of Vows")
|
||||
|
||||
# Define the access rules to some specific locations
|
||||
self._add_location_rule("HWL: Red Eye Orb - wall tower, miniboss", "Lift Chamber Key")
|
||||
if self._is_location_available("FS: Lift Chamber Key - Leonhard"):
|
||||
self._add_location_rule("HWL: Red Eye Orb - wall tower, miniboss",
|
||||
"Lift Chamber Key")
|
||||
self._add_location_rule("ID: Bellowing Dragoncrest Ring - drop from B1 towards pit",
|
||||
"Jailbreaker's Key")
|
||||
self._add_location_rule("ID: Covetous Gold Serpent Ring - Siegward's cell", "Old Cell Key")
|
||||
@@ -1250,9 +1252,6 @@ class DarkSouls3World(World):
|
||||
lambda item: not item.advancement
|
||||
)
|
||||
|
||||
# Prevent the player from prioritizing and "excluding" the same location
|
||||
self.options.priority_locations.value -= allow_useful_locations
|
||||
|
||||
if self.options.excluded_location_behavior == "allow_useful":
|
||||
self.options.exclude_locations.value.clear()
|
||||
|
||||
@@ -1293,10 +1292,10 @@ class DarkSouls3World(World):
|
||||
locations = location if isinstance(location, list) else [location]
|
||||
for location in locations:
|
||||
data = location_dictionary[location]
|
||||
if data.dlc and not self.options.enable_dlc: continue
|
||||
if data.ngp and not self.options.enable_ngp: continue
|
||||
if data.dlc and not self.options.enable_dlc: return
|
||||
if data.ngp and not self.options.enable_ngp: return
|
||||
|
||||
if not self._is_location_available(location): continue
|
||||
if not self._is_location_available(location): return
|
||||
if isinstance(rule, str):
|
||||
assert item_dictionary[rule].classification == ItemClassification.progression
|
||||
rule = lambda state, item=rule: state.has(item, self.player)
|
||||
@@ -1505,19 +1504,16 @@ class DarkSouls3World(World):
|
||||
# We include all the items the game knows about so that users can manually request items
|
||||
# that aren't randomized, and then we _also_ include all the items that are placed in
|
||||
# practice `item_dictionary.values()` doesn't include upgraded or infused weapons.
|
||||
items_by_name = {
|
||||
location.item.name: cast(DarkSouls3Item, location.item).data
|
||||
all_items = {
|
||||
cast(DarkSouls3Item, location.item).data
|
||||
for location in self.multiworld.get_filled_locations()
|
||||
# item.code None is used for events, which we want to skip
|
||||
if location.item.code is not None and location.item.player == self.player
|
||||
}
|
||||
for item in item_dictionary.values():
|
||||
if item.name not in items_by_name:
|
||||
items_by_name[item.name] = item
|
||||
}.union(item_dictionary.values())
|
||||
|
||||
ap_ids_to_ds3_ids: Dict[str, int] = {}
|
||||
item_counts: Dict[str, int] = {}
|
||||
for item in items_by_name.values():
|
||||
for item in all_items:
|
||||
if item.ap_code is None: continue
|
||||
if item.ds3_code: ap_ids_to_ds3_ids[str(item.ap_code)] = item.ds3_code
|
||||
if item.count != 1: item_counts[str(item.ap_code)] = item.count
|
||||
|
||||
@@ -2214,13 +2214,13 @@ location_table: Dict[int, LocationDict] = {
|
||||
'map': 2,
|
||||
'index': 217,
|
||||
'doom_type': 2006,
|
||||
'region': "Perfect Hatred (E4M2) Upper"},
|
||||
'region': "Perfect Hatred (E4M2) Blue"},
|
||||
351367: {'name': 'Perfect Hatred (E4M2) - Exit',
|
||||
'episode': 4,
|
||||
'map': 2,
|
||||
'index': -1,
|
||||
'doom_type': -1,
|
||||
'region': "Perfect Hatred (E4M2) Upper"},
|
||||
'region': "Perfect Hatred (E4M2) Blue"},
|
||||
351368: {'name': 'Sever the Wicked (E4M3) - Invulnerability',
|
||||
'episode': 4,
|
||||
'map': 3,
|
||||
|
||||
@@ -502,12 +502,13 @@ regions:List[RegionDict] = [
|
||||
"episode":4,
|
||||
"connections":[
|
||||
{"target":"Perfect Hatred (E4M2) Blue","pro":False},
|
||||
{"target":"Perfect Hatred (E4M2) Yellow","pro":False},
|
||||
{"target":"Perfect Hatred (E4M2) Upper","pro":True}]},
|
||||
{"target":"Perfect Hatred (E4M2) Yellow","pro":False}]},
|
||||
{"name":"Perfect Hatred (E4M2) Blue",
|
||||
"connects_to_hub":False,
|
||||
"episode":4,
|
||||
"connections":[{"target":"Perfect Hatred (E4M2) Upper","pro":False}]},
|
||||
"connections":[
|
||||
{"target":"Perfect Hatred (E4M2) Main","pro":False},
|
||||
{"target":"Perfect Hatred (E4M2) Cave","pro":False}]},
|
||||
{"name":"Perfect Hatred (E4M2) Yellow",
|
||||
"connects_to_hub":False,
|
||||
"episode":4,
|
||||
@@ -517,13 +518,7 @@ regions:List[RegionDict] = [
|
||||
{"name":"Perfect Hatred (E4M2) Cave",
|
||||
"connects_to_hub":False,
|
||||
"episode":4,
|
||||
"connections":[{"target":"Perfect Hatred (E4M2) Main","pro":False}]},
|
||||
{"name":"Perfect Hatred (E4M2) Upper",
|
||||
"connects_to_hub":False,
|
||||
"episode":4,
|
||||
"connections":[
|
||||
{"target":"Perfect Hatred (E4M2) Cave","pro":False},
|
||||
{"target":"Perfect Hatred (E4M2) Main","pro":False}]},
|
||||
"connections":[]},
|
||||
|
||||
# Sever the Wicked (E4M3)
|
||||
{"name":"Sever the Wicked (E4M3) Main",
|
||||
|
||||
@@ -403,8 +403,9 @@ def set_episode4_rules(player, multiworld, pro):
|
||||
state.has("Chaingun", player, 1)) and (state.has("Plasma gun", player, 1) or
|
||||
state.has("BFG9000", player, 1)))
|
||||
set_rule(multiworld.get_entrance("Hell Beneath (E4M1) Main -> Hell Beneath (E4M1) Blue", player), lambda state:
|
||||
(state.has("Hell Beneath (E4M1) - Blue skull key", player, 1)) and (state.has("Shotgun", player, 1) or
|
||||
state.has("Chaingun", player, 1)))
|
||||
state.has("Shotgun", player, 1) or
|
||||
state.has("Chaingun", player, 1) or
|
||||
state.has("Hell Beneath (E4M1) - Blue skull key", player, 1))
|
||||
|
||||
# Perfect Hatred (E4M2)
|
||||
set_rule(multiworld.get_entrance("Hub -> Perfect Hatred (E4M2) Main", player), lambda state:
|
||||
|
||||
@@ -1470,7 +1470,7 @@ location_table: Dict[int, LocationDict] = {
|
||||
'map': 6,
|
||||
'index': 102,
|
||||
'doom_type': 2006,
|
||||
'region': "Tenements (MAP17) Yellow"},
|
||||
'region': "Tenements (MAP17) Main"},
|
||||
361243: {'name': 'Tenements (MAP17) - Plasma gun',
|
||||
'episode': 2,
|
||||
'map': 6,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Outputs a Factorio Mod to facilitate integration with Archipelago"""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -89,8 +88,6 @@ class FactorioModFile(worlds.Files.APContainer):
|
||||
def generate_mod(world: "Factorio", output_directory: str):
|
||||
player = world.player
|
||||
multiworld = world.multiworld
|
||||
random = world.random
|
||||
|
||||
global data_final_template, locale_template, control_template, data_template, settings_template
|
||||
with template_load_lock:
|
||||
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)}"
|
||||
versioned_mod_name = mod_name + "_" + Utils.__version__
|
||||
|
||||
random = multiworld.per_slot_randoms[player]
|
||||
|
||||
def flop_random(low, high, base=None):
|
||||
"""Guarantees 50% below base and 50% above base, uniform distribution in each direction."""
|
||||
if base:
|
||||
@@ -130,43 +129,43 @@ def generate_mod(world: "Factorio", output_directory: str):
|
||||
"base_tech_table": base_tech_table,
|
||||
"tech_to_progressive_lookup": tech_to_progressive_lookup,
|
||||
"mod_name": mod_name,
|
||||
"allowed_science_packs": world.options.max_science_pack.get_allowed_packs(),
|
||||
"custom_technologies": world.custom_technologies,
|
||||
"allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(),
|
||||
"custom_technologies": multiworld.worlds[player].custom_technologies,
|
||||
"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,
|
||||
"starting_items": world.options.starting_items, "recipes": recipes,
|
||||
"starting_items": multiworld.starting_items[player], "recipes": recipes,
|
||||
"random": random, "flop_random": flop_random,
|
||||
"recipe_time_scale": recipe_time_scales.get(world.options.recipe_time.value, None),
|
||||
"recipe_time_range": recipe_time_ranges.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(multiworld.recipe_time[player].value, None),
|
||||
"free_sample_blacklist": {item: 1 for item in free_sample_exclusions},
|
||||
"progressive_technology_table": {tech.name: tech.progressive for tech in
|
||||
progressive_technology_table.values()},
|
||||
"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,
|
||||
"goal": world.options.goal.value,
|
||||
"energy_link": world.options.energy_link.value,
|
||||
"goal": multiworld.goal[player].value,
|
||||
"energy_link": multiworld.energy_link[player].value,
|
||||
"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"]:
|
||||
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
|
||||
|
||||
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"].update({item: 1 for item in world.options.free_sample_blacklist.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: 1 for item in multiworld.free_sample_blacklist[player].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")
|
||||
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:
|
||||
with zipfile.ZipFile(world.zip_path) as zf:
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
from schema import Schema, Optional, And, Or
|
||||
import datetime
|
||||
|
||||
from Options import Choice, OptionDict, OptionSet, Option, DefaultOnToggle, Range, DeathLink, Toggle, \
|
||||
StartInventoryPool, PerGameCommonOptions
|
||||
StartInventoryPool
|
||||
from schema import Schema, Optional, And, Or
|
||||
|
||||
# schema helpers
|
||||
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
|
||||
@@ -425,37 +422,50 @@ class EnergyLink(Toggle):
|
||||
display_name = "EnergyLink"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FactorioOptions(PerGameCommonOptions):
|
||||
max_science_pack: MaxSciencePack
|
||||
goal: Goal
|
||||
tech_tree_layout: TechTreeLayout
|
||||
min_tech_cost: MinTechCost
|
||||
max_tech_cost: MaxTechCost
|
||||
tech_cost_distribution: TechCostDistribution
|
||||
tech_cost_mix: TechCostMix
|
||||
ramping_tech_costs: RampingTechCosts
|
||||
silo: Silo
|
||||
satellite: Satellite
|
||||
free_samples: FreeSamples
|
||||
tech_tree_information: TechTreeInformation
|
||||
starting_items: FactorioStartItems
|
||||
free_sample_blacklist: FactorioFreeSampleBlacklist
|
||||
free_sample_whitelist: FactorioFreeSampleWhitelist
|
||||
recipe_time: RecipeTime
|
||||
recipe_ingredients: RecipeIngredients
|
||||
recipe_ingredients_offset: RecipeIngredientsOffset
|
||||
imported_blueprints: ImportedBlueprint
|
||||
world_gen: FactorioWorldGen
|
||||
progressive: Progressive
|
||||
teleport_traps: TeleportTrapCount
|
||||
grenade_traps: GrenadeTrapCount
|
||||
cluster_grenade_traps: ClusterGrenadeTrapCount
|
||||
artillery_traps: ArtilleryTrapCount
|
||||
atomic_rocket_traps: AtomicRocketTrapCount
|
||||
attack_traps: AttackTrapCount
|
||||
evolution_traps: EvolutionTrapCount
|
||||
evolution_trap_increase: EvolutionTrapIncrease
|
||||
death_link: DeathLink
|
||||
energy_link: EnergyLink
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
factorio_options: typing.Dict[str, type(Option)] = {
|
||||
"max_science_pack": MaxSciencePack,
|
||||
"goal": Goal,
|
||||
"tech_tree_layout": TechTreeLayout,
|
||||
"min_tech_cost": MinTechCost,
|
||||
"max_tech_cost": MaxTechCost,
|
||||
"tech_cost_distribution": TechCostDistribution,
|
||||
"tech_cost_mix": TechCostMix,
|
||||
"ramping_tech_costs": RampingTechCosts,
|
||||
"silo": Silo,
|
||||
"satellite": Satellite,
|
||||
"free_samples": FreeSamples,
|
||||
"tech_tree_information": TechTreeInformation,
|
||||
"starting_items": FactorioStartItems,
|
||||
"free_sample_blacklist": FactorioFreeSampleBlacklist,
|
||||
"free_sample_whitelist": FactorioFreeSampleWhitelist,
|
||||
"recipe_time": RecipeTime,
|
||||
"recipe_ingredients": RecipeIngredients,
|
||||
"recipe_ingredients_offset": RecipeIngredientsOffset,
|
||||
"imported_blueprints": ImportedBlueprint,
|
||||
"world_gen": FactorioWorldGen,
|
||||
"progressive": Progressive,
|
||||
"teleport_traps": TeleportTrapCount,
|
||||
"grenade_traps": GrenadeTrapCount,
|
||||
"cluster_grenade_traps": ClusterGrenadeTrapCount,
|
||||
"artillery_traps": ArtilleryTrapCount,
|
||||
"atomic_rocket_traps": AtomicRocketTrapCount,
|
||||
"attack_traps": AttackTrapCount,
|
||||
"evolution_traps": EvolutionTrapCount,
|
||||
"evolution_trap_increase": EvolutionTrapIncrease,
|
||||
"death_link": DeathLink,
|
||||
"energy_link": EnergyLink,
|
||||
"start_inventory_from_pool": StartInventoryPool,
|
||||
}
|
||||
|
||||
# spoilers below. If you spoil it for yourself, please at least don't spoil it for anyone else.
|
||||
if datetime.datetime.today().month == 4:
|
||||
|
||||
class ChunkShuffle(Toggle):
|
||||
"""Entrance Randomizer."""
|
||||
display_name = "Chunk Shuffle"
|
||||
|
||||
|
||||
if datetime.datetime.today().day > 1:
|
||||
ChunkShuffle.__doc__ += """
|
||||
2023 April Fool's option. Shuffles chunk border transitions."""
|
||||
factorio_options["chunk_shuffle"] = ChunkShuffle
|
||||
|
||||
@@ -19,10 +19,12 @@ def _sorter(location: "FactorioScienceLocation"):
|
||||
return location.complexity, location.rel_cost
|
||||
|
||||
|
||||
def get_shapes(world: "Factorio") -> Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]]:
|
||||
def get_shapes(factorio_world: "Factorio") -> Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]]:
|
||||
world = factorio_world.multiworld
|
||||
player = factorio_world.player
|
||||
prerequisites: Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]] = {}
|
||||
layout = world.options.tech_tree_layout.value
|
||||
locations: List["FactorioScienceLocation"] = sorted(world.science_locations, key=lambda loc: loc.name)
|
||||
layout = world.tech_tree_layout[player].value
|
||||
locations: List["FactorioScienceLocation"] = sorted(factorio_world.science_locations, key=lambda loc: loc.name)
|
||||
world.random.shuffle(locations)
|
||||
|
||||
if layout == TechTreeLayout.option_single:
|
||||
@@ -245,5 +247,5 @@ def get_shapes(world: "Factorio") -> Dict["FactorioScienceLocation", Set["Factor
|
||||
else:
|
||||
raise NotImplementedError(f"Layout {layout} is not implemented.")
|
||||
|
||||
world.tech_tree_layout_prerequisites = prerequisites
|
||||
factorio_world.tech_tree_layout_prerequisites = prerequisites
|
||||
return prerequisites
|
||||
|
||||
@@ -13,11 +13,12 @@ import Utils
|
||||
from . import Options
|
||||
|
||||
factorio_tech_id = factorio_base_id = 2 ** 17
|
||||
# Factorio technologies are imported from a .json document in /data
|
||||
source_folder = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
pool = ThreadPoolExecutor(1)
|
||||
|
||||
|
||||
# Factorio technologies are imported from a .json document in /data
|
||||
def load_json_data(data_name: str) -> Union[List[str], Dict[str, Any]]:
|
||||
return orjson.loads(pkgutil.get_data(__name__, "data/" + data_name + ".json"))
|
||||
|
||||
@@ -98,7 +99,7 @@ class CustomTechnology(Technology):
|
||||
and ((ingredients & {"chemical-science-pack", "production-science-pack", "utility-science-pack"})
|
||||
or origin.name == "rocket-silo")
|
||||
self.player = player
|
||||
if origin.name not in world.special_nodes:
|
||||
if origin.name not in world.worlds[player].special_nodes:
|
||||
if military_allowed:
|
||||
ingredients.add("military-science-pack")
|
||||
ingredients = list(ingredients)
|
||||
|
||||
@@ -11,7 +11,7 @@ from worlds.LauncherComponents import Component, components, Type, launch_subpro
|
||||
from worlds.generic import Rules
|
||||
from .Locations import location_pools, location_table
|
||||
from .Mod import generate_mod
|
||||
from .Options import FactorioOptions, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution
|
||||
from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution
|
||||
from .Shapes import get_shapes
|
||||
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
|
||||
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, \
|
||||
@@ -89,21 +89,18 @@ class Factorio(World):
|
||||
advancement_technologies: typing.Set[str]
|
||||
|
||||
web = FactorioWeb()
|
||||
options_dataclass = FactorioOptions
|
||||
options: FactorioOptions
|
||||
|
||||
item_name_to_id = all_items
|
||||
location_name_to_id = location_table
|
||||
item_name_groups = {
|
||||
"Progressive": set(progressive_tech_table.keys()),
|
||||
}
|
||||
required_client_version = (0, 5, 0)
|
||||
required_client_version = (0, 4, 2)
|
||||
|
||||
ordered_science_packs: typing.List[str] = MaxSciencePack.get_ordered_science_packs()
|
||||
tech_tree_layout_prerequisites: typing.Dict[FactorioScienceLocation, typing.Set[FactorioScienceLocation]]
|
||||
tech_mix: int = 0
|
||||
skip_silo: bool = False
|
||||
origin_region_name = "Nauvis"
|
||||
science_locations: typing.List[FactorioScienceLocation]
|
||||
|
||||
settings: typing.ClassVar[FactorioSettings]
|
||||
@@ -119,32 +116,35 @@ class Factorio(World):
|
||||
|
||||
def generate_early(self) -> None:
|
||||
# if max < min, then swap max and min
|
||||
if self.options.max_tech_cost < self.options.min_tech_cost:
|
||||
self.options.min_tech_cost.value, self.options.max_tech_cost.value = \
|
||||
self.options.max_tech_cost.value, self.options.min_tech_cost.value
|
||||
self.tech_mix = self.options.tech_cost_mix.value
|
||||
self.skip_silo = self.options.silo.value == Silo.option_spawn
|
||||
if self.multiworld.max_tech_cost[self.player] < self.multiworld.min_tech_cost[self.player]:
|
||||
self.multiworld.min_tech_cost[self.player].value, self.multiworld.max_tech_cost[self.player].value = \
|
||||
self.multiworld.max_tech_cost[self.player].value, self.multiworld.min_tech_cost[self.player].value
|
||||
self.tech_mix = self.multiworld.tech_cost_mix[self.player]
|
||||
self.skip_silo = self.multiworld.silo[self.player].value == Silo.option_spawn
|
||||
|
||||
def create_regions(self):
|
||||
player = self.player
|
||||
random = self.random
|
||||
random = self.multiworld.random
|
||||
menu = Region("Menu", player, self.multiworld)
|
||||
crash = Entrance(player, "Crash Land", menu)
|
||||
menu.exits.append(crash)
|
||||
nauvis = Region("Nauvis", player, self.multiworld)
|
||||
|
||||
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
|
||||
self.options.evolution_traps + \
|
||||
self.options.attack_traps + \
|
||||
self.options.teleport_traps + \
|
||||
self.options.grenade_traps + \
|
||||
self.options.cluster_grenade_traps + \
|
||||
self.options.atomic_rocket_traps + \
|
||||
self.options.artillery_traps
|
||||
self.multiworld.evolution_traps[player] + \
|
||||
self.multiworld.attack_traps[player] + \
|
||||
self.multiworld.teleport_traps[player] + \
|
||||
self.multiworld.grenade_traps[player] + \
|
||||
self.multiworld.cluster_grenade_traps[player] + \
|
||||
self.multiworld.atomic_rocket_traps[player] + \
|
||||
self.multiworld.artillery_traps[player]
|
||||
|
||||
location_pool = []
|
||||
|
||||
for pack in sorted(self.options.max_science_pack.get_allowed_packs()):
|
||||
for pack in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
|
||||
location_pool.extend(location_pools[pack])
|
||||
try:
|
||||
location_names = random.sample(location_pool, location_count)
|
||||
location_names = self.multiworld.random.sample(location_pool, location_count)
|
||||
except ValueError as e:
|
||||
# should be "ValueError: Sample larger than population or is negative"
|
||||
raise Exception("Too many traps for too few locations. Either decrease the trap count, "
|
||||
@@ -152,9 +152,9 @@ class Factorio(World):
|
||||
|
||||
self.science_locations = [FactorioScienceLocation(player, loc_name, self.location_name_to_id[loc_name], nauvis)
|
||||
for loc_name in location_names]
|
||||
distribution: TechCostDistribution = self.options.tech_cost_distribution
|
||||
min_cost = self.options.min_tech_cost.value
|
||||
max_cost = self.options.max_tech_cost.value
|
||||
distribution: TechCostDistribution = self.multiworld.tech_cost_distribution[self.player]
|
||||
min_cost = self.multiworld.min_tech_cost[self.player]
|
||||
max_cost = self.multiworld.max_tech_cost[self.player]
|
||||
if distribution == distribution.option_even:
|
||||
rand_values = (random.randint(min_cost, max_cost) for _ in self.science_locations)
|
||||
else:
|
||||
@@ -163,7 +163,7 @@ class Factorio(World):
|
||||
distribution.option_high: max_cost}[distribution.value]
|
||||
rand_values = (random.triangular(min_cost, max_cost, mode) for _ in self.science_locations)
|
||||
rand_values = sorted(rand_values)
|
||||
if self.options.ramping_tech_costs:
|
||||
if self.multiworld.ramping_tech_costs[self.player]:
|
||||
def sorter(loc: FactorioScienceLocation):
|
||||
return loc.complexity, loc.rel_cost
|
||||
else:
|
||||
@@ -178,32 +178,34 @@ class Factorio(World):
|
||||
event = FactorioItem("Victory", ItemClassification.progression, None, player)
|
||||
location.place_locked_item(event)
|
||||
|
||||
for ingredient in sorted(self.options.max_science_pack.get_allowed_packs()):
|
||||
for ingredient in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
|
||||
location = FactorioLocation(player, f"Automate {ingredient}", None, nauvis)
|
||||
nauvis.locations.append(location)
|
||||
event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player)
|
||||
location.place_locked_item(event)
|
||||
|
||||
self.multiworld.regions.append(nauvis)
|
||||
crash.connect(nauvis)
|
||||
self.multiworld.regions += [menu, nauvis]
|
||||
|
||||
def create_items(self) -> None:
|
||||
player = self.player
|
||||
self.custom_technologies = self.set_custom_technologies()
|
||||
self.set_custom_recipes()
|
||||
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket")
|
||||
for trap_name in traps:
|
||||
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
|
||||
range(getattr(self.options,
|
||||
f"{trap_name.lower().replace(' ', '_')}_traps")))
|
||||
range(getattr(self.multiworld,
|
||||
f"{trap_name.lower().replace(' ', '_')}_traps")[player]))
|
||||
|
||||
want_progressives = collections.defaultdict(lambda: self.options.progressive.
|
||||
want_progressives(self.random))
|
||||
want_progressives = collections.defaultdict(lambda: self.multiworld.progressive[player].
|
||||
want_progressives(self.multiworld.random))
|
||||
|
||||
cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name)
|
||||
special_index = {"automation": 0,
|
||||
"logistics": 1,
|
||||
"rocket-silo": -1}
|
||||
loc: FactorioScienceLocation
|
||||
if self.options.tech_tree_information == TechTreeInformation.option_full:
|
||||
if self.multiworld.tech_tree_information[player] == TechTreeInformation.option_full:
|
||||
# mark all locations as pre-hinted
|
||||
for loc in self.science_locations:
|
||||
loc.revealed = True
|
||||
@@ -230,13 +232,14 @@ class Factorio(World):
|
||||
loc.revealed = True
|
||||
|
||||
def set_rules(self):
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
shapes = get_shapes(self)
|
||||
|
||||
for ingredient in self.options.max_science_pack.get_allowed_packs():
|
||||
location = self.get_location(f"Automate {ingredient}")
|
||||
for ingredient in self.multiworld.max_science_pack[self.player].get_allowed_packs():
|
||||
location = world.get_location(f"Automate {ingredient}", player)
|
||||
|
||||
if self.options.recipe_ingredients:
|
||||
if self.multiworld.recipe_ingredients[self.player]:
|
||||
custom_recipe = self.custom_recipes[ingredient]
|
||||
|
||||
location.access_rule = lambda state, ingredient=ingredient, custom_recipe=custom_recipe: \
|
||||
@@ -257,30 +260,30 @@ class Factorio(World):
|
||||
prerequisites: all(state.can_reach(loc) for loc in locations))
|
||||
|
||||
silo_recipe = None
|
||||
if self.options.silo == Silo.option_spawn:
|
||||
if self.multiworld.silo[self.player] == Silo.option_spawn:
|
||||
silo_recipe = self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \
|
||||
else next(iter(all_product_sources.get("rocket-silo")))
|
||||
part_recipe = self.custom_recipes["rocket-part"]
|
||||
satellite_recipe = None
|
||||
if self.options.goal == Goal.option_satellite:
|
||||
if self.multiworld.goal[self.player] == Goal.option_satellite:
|
||||
satellite_recipe = self.custom_recipes["satellite"] if "satellite" in self.custom_recipes \
|
||||
else next(iter(all_product_sources.get("satellite")))
|
||||
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe)
|
||||
if self.options.silo != Silo.option_spawn:
|
||||
if self.multiworld.silo[self.player] != Silo.option_spawn:
|
||||
victory_tech_names.add("rocket-silo")
|
||||
self.get_location("Rocket Launch").access_rule = lambda state: all(state.has(technology, player)
|
||||
for technology in
|
||||
victory_tech_names)
|
||||
world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player)
|
||||
for technology in
|
||||
victory_tech_names)
|
||||
|
||||
self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player)
|
||||
world.completion_condition[player] = lambda state: state.has('Victory', player)
|
||||
|
||||
def generate_basic(self):
|
||||
map_basic_settings = self.options.world_gen.value["basic"]
|
||||
map_basic_settings = self.multiworld.world_gen[self.player].value["basic"]
|
||||
if map_basic_settings.get("seed", None) is None: # allow seed 0
|
||||
# 32 bit uint
|
||||
map_basic_settings["seed"] = self.random.randint(0, 2 ** 32 - 1)
|
||||
map_basic_settings["seed"] = self.multiworld.per_slot_randoms[self.player].randint(0, 2 ** 32 - 1)
|
||||
|
||||
start_location_hints: typing.Set[str] = self.options.start_location_hints.value
|
||||
start_location_hints: typing.Set[str] = self.multiworld.start_location_hints[self.player].value
|
||||
|
||||
for loc in self.science_locations:
|
||||
# show start_location_hints ingame
|
||||
@@ -304,6 +307,8 @@ class Factorio(World):
|
||||
|
||||
return super(Factorio, self).collect_item(state, item, remove)
|
||||
|
||||
option_definitions = factorio_options
|
||||
|
||||
@classmethod
|
||||
def stage_write_spoiler(cls, world, spoiler_handle):
|
||||
factorio_players = world.get_game_players(cls.game)
|
||||
@@ -343,7 +348,7 @@ class Factorio(World):
|
||||
# have to first sort for determinism, while filtering out non-stacking items
|
||||
pool: typing.List[str] = sorted(pool & valid_ingredients)
|
||||
# then sort with random data to shuffle
|
||||
self.random.shuffle(pool)
|
||||
self.multiworld.random.shuffle(pool)
|
||||
target_raw = int(sum((count for ingredient, count in original.base_cost.items())) * factor)
|
||||
target_energy = original.total_energy * factor
|
||||
target_num_ingredients = len(original.ingredients) + ingredients_offset
|
||||
@@ -387,7 +392,7 @@ class Factorio(World):
|
||||
if min_num > max_num:
|
||||
fallback_pool.append(ingredient)
|
||||
continue # can't use that ingredient
|
||||
num = self.random.randint(min_num, max_num)
|
||||
num = self.multiworld.random.randint(min_num, max_num)
|
||||
new_ingredients[ingredient] = num
|
||||
remaining_raw -= num * ingredient_raw
|
||||
remaining_energy -= num * ingredient_energy
|
||||
@@ -431,66 +436,66 @@ class Factorio(World):
|
||||
|
||||
def set_custom_technologies(self):
|
||||
custom_technologies = {}
|
||||
allowed_packs = self.options.max_science_pack.get_allowed_packs()
|
||||
allowed_packs = self.multiworld.max_science_pack[self.player].get_allowed_packs()
|
||||
for technology_name, technology in base_technology_table.items():
|
||||
custom_technologies[technology_name] = technology.get_custom(self, allowed_packs, self.player)
|
||||
custom_technologies[technology_name] = technology.get_custom(self.multiworld, allowed_packs, self.player)
|
||||
return custom_technologies
|
||||
|
||||
def set_custom_recipes(self):
|
||||
ingredients_offset = self.options.recipe_ingredients_offset
|
||||
ingredients_offset = self.multiworld.recipe_ingredients_offset[self.player]
|
||||
original_rocket_part = recipes["rocket-part"]
|
||||
science_pack_pools = get_science_pack_pools()
|
||||
valid_pool = sorted(science_pack_pools[self.options.max_science_pack.get_max_pack()] & valid_ingredients)
|
||||
self.random.shuffle(valid_pool)
|
||||
valid_pool = sorted(science_pack_pools[self.multiworld.max_science_pack[self.player].get_max_pack()] & valid_ingredients)
|
||||
self.multiworld.random.shuffle(valid_pool)
|
||||
self.custom_recipes = {"rocket-part": Recipe("rocket-part", original_rocket_part.category,
|
||||
{valid_pool[x]: 10 for x in range(3 + ingredients_offset)},
|
||||
original_rocket_part.products,
|
||||
original_rocket_part.energy)}
|
||||
|
||||
if self.options.recipe_ingredients:
|
||||
if self.multiworld.recipe_ingredients[self.player]:
|
||||
valid_pool = []
|
||||
for pack in self.options.max_science_pack.get_ordered_science_packs():
|
||||
for pack in self.multiworld.max_science_pack[self.player].get_ordered_science_packs():
|
||||
valid_pool += sorted(science_pack_pools[pack])
|
||||
self.random.shuffle(valid_pool)
|
||||
self.multiworld.random.shuffle(valid_pool)
|
||||
if pack in recipes: # skips over space science pack
|
||||
new_recipe = self.make_quick_recipe(recipes[pack], valid_pool, ingredients_offset=
|
||||
ingredients_offset.value)
|
||||
ingredients_offset)
|
||||
self.custom_recipes[pack] = new_recipe
|
||||
|
||||
if self.options.silo.value == Silo.option_randomize_recipe \
|
||||
or self.options.satellite.value == Satellite.option_randomize_recipe:
|
||||
if self.multiworld.silo[self.player].value == Silo.option_randomize_recipe \
|
||||
or self.multiworld.satellite[self.player].value == Satellite.option_randomize_recipe:
|
||||
valid_pool = set()
|
||||
for pack in sorted(self.options.max_science_pack.get_allowed_packs()):
|
||||
for pack in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
|
||||
valid_pool |= science_pack_pools[pack]
|
||||
|
||||
if self.options.silo.value == Silo.option_randomize_recipe:
|
||||
if self.multiworld.silo[self.player].value == Silo.option_randomize_recipe:
|
||||
new_recipe = self.make_balanced_recipe(
|
||||
recipes["rocket-silo"], valid_pool,
|
||||
factor=(self.options.max_science_pack.value + 1) / 7,
|
||||
ingredients_offset=ingredients_offset.value)
|
||||
factor=(self.multiworld.max_science_pack[self.player].value + 1) / 7,
|
||||
ingredients_offset=ingredients_offset)
|
||||
self.custom_recipes["rocket-silo"] = new_recipe
|
||||
|
||||
if self.options.satellite.value == Satellite.option_randomize_recipe:
|
||||
if self.multiworld.satellite[self.player].value == Satellite.option_randomize_recipe:
|
||||
new_recipe = self.make_balanced_recipe(
|
||||
recipes["satellite"], valid_pool,
|
||||
factor=(self.options.max_science_pack.value + 1) / 7,
|
||||
ingredients_offset=ingredients_offset.value)
|
||||
factor=(self.multiworld.max_science_pack[self.player].value + 1) / 7,
|
||||
ingredients_offset=ingredients_offset)
|
||||
self.custom_recipes["satellite"] = new_recipe
|
||||
bridge = "ap-energy-bridge"
|
||||
new_recipe = self.make_quick_recipe(
|
||||
Recipe(bridge, "crafting", {"replace_1": 1, "replace_2": 1, "replace_3": 1,
|
||||
"replace_4": 1, "replace_5": 1, "replace_6": 1},
|
||||
{bridge: 1}, 10),
|
||||
sorted(science_pack_pools[self.options.max_science_pack.get_ordered_science_packs()[0]]),
|
||||
ingredients_offset=ingredients_offset.value)
|
||||
sorted(science_pack_pools[self.multiworld.max_science_pack[self.player].get_ordered_science_packs()[0]]),
|
||||
ingredients_offset=ingredients_offset)
|
||||
for ingredient_name in new_recipe.ingredients:
|
||||
new_recipe.ingredients[ingredient_name] = self.random.randint(50, 500)
|
||||
new_recipe.ingredients[ingredient_name] = self.multiworld.random.randint(50, 500)
|
||||
self.custom_recipes[bridge] = new_recipe
|
||||
|
||||
needed_recipes = self.options.max_science_pack.get_allowed_packs() | {"rocket-part"}
|
||||
if self.options.silo != Silo.option_spawn:
|
||||
needed_recipes = self.multiworld.max_science_pack[self.player].get_allowed_packs() | {"rocket-part"}
|
||||
if self.multiworld.silo[self.player] != Silo.option_spawn:
|
||||
needed_recipes |= {"rocket-silo"}
|
||||
if self.options.goal.value == Goal.option_satellite:
|
||||
if self.multiworld.goal[self.player].value == Goal.option_satellite:
|
||||
needed_recipes |= {"satellite"}
|
||||
|
||||
for recipe in needed_recipes:
|
||||
@@ -540,8 +545,7 @@ class FactorioScienceLocation(FactorioLocation):
|
||||
|
||||
self.ingredients = {Factorio.ordered_science_packs[self.complexity]: 1}
|
||||
for complexity in range(self.complexity):
|
||||
if (parent.multiworld.worlds[self.player].options.tech_cost_mix >
|
||||
parent.multiworld.worlds[self.player].random.randint(0, 99)):
|
||||
if parent.multiworld.tech_cost_mix[self.player] > parent.multiworld.random.randint(0, 99):
|
||||
self.ingredients[Factorio.ordered_science_packs[complexity]] = 1
|
||||
|
||||
@property
|
||||
|
||||
@@ -21,16 +21,6 @@ from .Charms import names as charm_names
|
||||
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, CollectionState
|
||||
from worlds.AutoWorld import World, LogicMixin, WebWorld
|
||||
|
||||
from settings import Group, Bool
|
||||
|
||||
|
||||
class HollowKnightSettings(Group):
|
||||
class DisableMapModSpoilers(Bool):
|
||||
"""Disallows the APMapMod from showing spoiler placements."""
|
||||
|
||||
disable_spoilers: typing.Union[DisableMapModSpoilers, bool] = False
|
||||
|
||||
|
||||
path_of_pain_locations = {
|
||||
"Soul_Totem-Path_of_Pain_Below_Thornskip",
|
||||
"Lore_Tablet-Path_of_Pain_Entrance",
|
||||
@@ -134,25 +124,14 @@ shop_cost_types: typing.Dict[str, typing.Tuple[str, ...]] = {
|
||||
|
||||
|
||||
class HKWeb(WebWorld):
|
||||
setup_en = Tutorial(
|
||||
tutorials = [Tutorial(
|
||||
"Mod Setup and Use Guide",
|
||||
"A guide to playing Hollow Knight with Archipelago.",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["Ijwu"]
|
||||
)
|
||||
|
||||
setup_pt_br = Tutorial(
|
||||
setup_en.tutorial_name,
|
||||
setup_en.description,
|
||||
"Português Brasileiro",
|
||||
"setup_pt_br.md",
|
||||
"setup/pt_br",
|
||||
["JoaoVictor-FA"]
|
||||
)
|
||||
|
||||
tutorials = [setup_en, setup_pt_br]
|
||||
)]
|
||||
|
||||
bug_report_page = "https://github.com/Ijwu/Archipelago.HollowKnight/issues/new?assignees=&labels=bug%2C+needs+investigation&template=bug_report.md&title="
|
||||
|
||||
@@ -166,7 +145,6 @@ class HKWorld(World):
|
||||
game: str = "Hollow Knight"
|
||||
options_dataclass = HKOptions
|
||||
options: HKOptions
|
||||
settings: typing.ClassVar[HollowKnightSettings]
|
||||
|
||||
web = HKWeb()
|
||||
|
||||
@@ -566,8 +544,6 @@ class HKWorld(World):
|
||||
|
||||
slot_data["grub_count"] = self.grub_count
|
||||
|
||||
slot_data["is_race"] = int(self.settings.disable_spoilers or self.multiworld.is_race)
|
||||
|
||||
return slot_data
|
||||
|
||||
def create_item(self, name: str) -> HKItem:
|
||||
@@ -625,11 +601,11 @@ class HKWorld(World):
|
||||
if change:
|
||||
for effect_name, effect_value in item_effects.get(item.name, {}).items():
|
||||
state.prog_items[item.player][effect_name] += effect_value
|
||||
if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}:
|
||||
if state.prog_items[item.player].get('RIGHTDASH', 0) and \
|
||||
state.prog_items[item.player].get('LEFTDASH', 0):
|
||||
(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"]) = \
|
||||
([max(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"])] * 2)
|
||||
if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}:
|
||||
if state.prog_items[item.player].get('RIGHTDASH', 0) and \
|
||||
state.prog_items[item.player].get('LEFTDASH', 0):
|
||||
(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"]) = \
|
||||
([max(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"])] * 2)
|
||||
return change
|
||||
|
||||
def remove(self, state, item: HKItem) -> bool:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
### What to do if Lumafly fails to find your installation directory
|
||||
1. Find the directory manually.
|
||||
* Xbox Game Pass:
|
||||
1. Enter the Xbox app and move your mouse over "Hollow Knight" on the left sidebar.
|
||||
1. Enter the XBox app and move your mouse over "Hollow Knight" on the left sidebar.
|
||||
2. Click the three points then click "Manage".
|
||||
3. Go to the "Files" tab and select "Browse...".
|
||||
4. Click "Hollow Knight", then "Content", then click the path bar and copy it.
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Guia de configuração para Hollow Knight no Archipelago
|
||||
|
||||
## Programas obrigatórios
|
||||
* Baixe e extraia o Lumafly Mod Manager (gerenciador de mods Lumafly) do [Site Lumafly](https://themulhima.github.io/Lumafly/).
|
||||
* Uma cópia legal de Hollow Knight.
|
||||
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
|
||||
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
|
||||
|
||||
## Instalando o mod Archipelago Mod usando Lumafly
|
||||
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
|
||||
2. Clique em "Install (instalar)" perto da opção "Archipelago" mod.
|
||||
* Se quiser, instale também o "Archipelago Map Mod (mod do mapa do archipelago)" para usá-lo como rastreador dentro do jogo.
|
||||
3. Abra o jogo, tudo preparado!
|
||||
|
||||
### O que fazer se o Lumafly falha em encontrar a sua pasta de instalação
|
||||
1. Encontre a pasta manualmente.
|
||||
* Xbox Game Pass:
|
||||
1. Entre no seu aplicativo Xbox e mova seu mouse em cima de "Hollow Knight" na sua barra da esquerda.
|
||||
2. Clique nos 3 pontos depois clique gerenciar.
|
||||
3. Vá nos arquivos e selecione procurar.
|
||||
4. Clique em "Hollow Knight", depois em "Content (Conteúdo)", depois clique na barra com o endereço e a copie.
|
||||
* Steam:
|
||||
1. Você provavelmente colocou sua biblioteca Steam num local não padrão. Se esse for o caso você provavelmente sabe onde está.
|
||||
. Encontre sua biblioteca Steam, depois encontre a pasta do Hollow Knight e copie seu endereço.
|
||||
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
|
||||
* Linux/Steam Deck - `~/.local/share/Steam/steamapps/common/Hollow Knight`
|
||||
* Mac - `~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app`
|
||||
2. Rode o Lumafly como administrador e, quando ele perguntar pelo endereço do arquivo, cole o endereço do arquivo que você copiou.
|
||||
|
||||
## Configurando seu arquivo YAML
|
||||
### O que é um YAML e por que eu preciso de um?
|
||||
Um arquivo YAML é a forma que você informa suas configurações do jogador para o Archipelago.
|
||||
Olhe o [guia de configuração básica de um multiworld](/tutorial/Archipelago/setup/en) aqui no site do Archipelago para aprender mais.
|
||||
|
||||
### Onde eu consigo o YAML?
|
||||
Você pode usar a [página de configurações do jogador para Hollow Knight](/games/Hollow%20Knight/player-options) aqui no site do Archipelago
|
||||
para gerar o YAML usando a interface gráfica.
|
||||
|
||||
### Entrando numa partida de Archipelago no Hollow Knight
|
||||
1. Começe o jogo depois de instalar todos os mods necessários.
|
||||
2. Crie um **novo jogo salvo.**
|
||||
3. Selecione o modo de jogo **Archipelago** do menu de seleção.
|
||||
4. Coloque as configurações corretas do seu servidor Archipelago.
|
||||
5. Aperte em **Começar**. O jogo vai travar por uns segundos enquanto ele coloca todos itens.
|
||||
6. O jogo vai te colocar imediatamente numa partida randomizada.
|
||||
* Se você está esperando uma contagem então espere ele cair antes de apertar começar.
|
||||
* Ou clique em começar e pause o jogo enquanto estiver nele.
|
||||
|
||||
## Dicas e outros comandos
|
||||
Enquanto jogar um multiworld, você pode interagir com o servidor usando vários comandos listados no
|
||||
[Guia de comandos](/tutorial/Archipelago/commands/en). Você pode usar o cliente de texto do Archipelago para isso,
|
||||
que está incluido na ultima versão do [Archipelago software](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||
@@ -1,9 +1,5 @@
|
||||
import struct
|
||||
from .options import KirbyFlavorPreset, GooeyFlavorPreset
|
||||
from typing import TYPE_CHECKING, Optional, Dict, List, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import KDL3World
|
||||
from .Options import KirbyFlavorPreset, GooeyFlavorPreset
|
||||
|
||||
kirby_flavor_presets = {
|
||||
1: {
|
||||
@@ -227,23 +223,6 @@ kirby_flavor_presets = {
|
||||
"14": "E6E6FA",
|
||||
"15": "976FBD",
|
||||
},
|
||||
14: {
|
||||
"1": "373B3E",
|
||||
"2": "98d5d3",
|
||||
"3": "1aa5ab",
|
||||
"4": "168f95",
|
||||
"5": "4f5559",
|
||||
"6": "1dbac2",
|
||||
"7": "137a7f",
|
||||
"8": "093a3c",
|
||||
"9": "86cecb",
|
||||
"10": "a0afbc",
|
||||
"11": "62bfbb",
|
||||
"12": "50b8b4",
|
||||
"13": "bec8d1",
|
||||
"14": "bce4e2",
|
||||
"15": "91a2b1",
|
||||
}
|
||||
}
|
||||
|
||||
gooey_flavor_presets = {
|
||||
@@ -419,21 +398,21 @@ gooey_target_palettes = {
|
||||
}
|
||||
|
||||
|
||||
def get_kirby_palette(world: "KDL3World") -> Optional[Dict[str, str]]:
|
||||
def get_kirby_palette(world):
|
||||
palette = world.options.kirby_flavor_preset.value
|
||||
if palette == KirbyFlavorPreset.option_custom:
|
||||
return world.options.kirby_flavor.value
|
||||
return kirby_flavor_presets.get(palette, None)
|
||||
|
||||
|
||||
def get_gooey_palette(world: "KDL3World") -> Optional[Dict[str, str]]:
|
||||
def get_gooey_palette(world):
|
||||
palette = world.options.gooey_flavor_preset.value
|
||||
if palette == GooeyFlavorPreset.option_custom:
|
||||
return world.options.gooey_flavor.value
|
||||
return gooey_flavor_presets.get(palette, None)
|
||||
|
||||
|
||||
def rgb888_to_bgr555(red: int, green: int, blue: int) -> bytes:
|
||||
def rgb888_to_bgr555(red, green, blue) -> bytes:
|
||||
red = red >> 3
|
||||
green = green >> 3
|
||||
blue = blue >> 3
|
||||
@@ -441,15 +420,15 @@ def rgb888_to_bgr555(red: int, green: int, blue: int) -> bytes:
|
||||
return struct.pack("H", outcol)
|
||||
|
||||
|
||||
def get_palette_bytes(palette: Dict[str, str], target: List[str], offset: int, factor: float) -> bytes:
|
||||
def get_palette_bytes(palette, target, offset, factor):
|
||||
output_data = bytearray()
|
||||
for color in target:
|
||||
hexcol = palette[color]
|
||||
if hexcol.startswith("#"):
|
||||
hexcol = hexcol.replace("#", "")
|
||||
colint = int(hexcol, 16)
|
||||
col: Tuple[int, ...] = ((colint & 0xFF0000) >> 16, (colint & 0xFF00) >> 8, colint & 0xFF)
|
||||
col = ((colint & 0xFF0000) >> 16, (colint & 0xFF00) >> 8, colint & 0xFF)
|
||||
col = tuple(int(int(factor*x) + offset) for x in col)
|
||||
byte_data = rgb888_to_bgr555(col[0], col[1], col[2])
|
||||
output_data.extend(bytearray(byte_data))
|
||||
return bytes(output_data)
|
||||
return output_data
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user