Compare commits

..

1 Commits

Author SHA1 Message Date
Fabian Dill
6d4ce04067 Core/Subnautica: demonstrate a way to on-demand specify WebWorld 2024-01-26 23:51:11 +01:00
1226 changed files with 25421 additions and 105328 deletions

31
.github/labeler.yml vendored
View File

@@ -1,31 +0,0 @@
'is: documentation':
- changed-files:
- all-globs-to-all-files: '{**/docs/**,**/README.md}'
'affects: webhost':
- changed-files:
- all-globs-to-any-file: 'WebHost.py'
- all-globs-to-any-file: 'WebHostLib/**/*'
'affects: core':
- changed-files:
- all-globs-to-any-file:
- '!*Client.py'
- '!README.md'
- '!LICENSE'
- '!*.yml'
- '!.gitignore'
- '!**/docs/**'
- '!typings/kivy/**'
- '!test/**'
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'
- any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
- 'worlds/generic/**/*.py'
- 'worlds/*.py'
- 'CommonClient.py'

View File

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

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
name: Label Pull Request
on:
pull_request_target:
types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
branches: ['main']
permissions:
contents: read
pull-requests: write
jobs:
labeler:
name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
sync-labels: false
peer_review:
name: 'Apply peer review label'
needs: labeler
if: >-
(github.event.action == 'opened' || github.event.action == 'reopened' ||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- name: 'Add label'
run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
unblock_draft_prs:
name: 'Remove waiting-on labels'
needs: labeler
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: 'Remove labels'
run: |-
gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
--remove-label 'waiting-on: core-review' \
--remove-label 'waiting-on: world-maintainer' \
--remove-label 'waiting-on: author'
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

@@ -1,65 +0,0 @@
name: Native Code Static Analysis
on:
push:
paths:
- '**.c'
- '**.cc'
- '**.cpp'
- '**.cxx'
- '**.h'
- '**.hh'
- '**.hpp'
- '**.pyx'
- 'setup.py'
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
pull_request:
paths:
- '**.c'
- '**.cc'
- '**.cpp'
- '**.cxx'
- '**.h'
- '**.hh'
- '**.hpp'
- '**.pyx'
- 'setup.py'
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
jobs:
scan-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install newer Clang
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh
sudo ./llvm.sh 17
- name: Install scan-build command
run: |
sudo apt install clang-tools-17
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip -r requirements.txt
- name: scan-build
run: |
source venv/bin/activate
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4
with:
name: scan-build-reports
path: scan-build-reports

View File

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

View File

@@ -18,14 +18,11 @@ import NetUtils
import Options
import Utils
if typing.TYPE_CHECKING:
from worlds import AutoWorld
class Group(TypedDict, total=False):
name: str
game: str
world: "AutoWorld.World"
world: auto_world
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
@@ -58,7 +55,7 @@ class MultiWorld():
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"]
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
regions: RegionManager
itempool: List[Item]
@@ -85,7 +82,7 @@ class MultiWorld():
game: Dict[int, str]
random: random.Random
per_slot_randoms: Utils.DeprecateDict[int, random.Random]
per_slot_randoms: Dict[int, random.Random]
"""Deprecated. Please use `self.random` instead."""
class AttributeProxy():
@@ -110,14 +107,10 @@ class MultiWorld():
return self
def append(self, region: Region):
assert region.name not in self.region_cache[region.player], \
f"{region.name} already exists in region cache."
self.region_cache[region.player][region.name] = region
def extend(self, regions: Iterable[Region]):
for region in regions:
assert region.name not in self.region_cache[region.player], \
f"{region.name} already exists in region cache."
self.region_cache[region.player][region.name] = region
def add_group(self, new_id: int):
@@ -163,11 +156,11 @@ class MultiWorld():
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_palaceofdarkness_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_trock_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
for player in range(1, players + 1):
def set_player_attr(attr, val):
@@ -217,8 +210,7 @@ class MultiWorld():
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)")
self.per_slot_randoms = {}
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
@@ -227,8 +219,6 @@ class MultiWorld():
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
for group_id, group in self.groups.items():
if group["name"] == name:
group["players"] |= players
@@ -252,18 +242,17 @@ class MultiWorld():
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
assert not self.worlds, "seed needs to be initialized before Worlds"
self.seed = get_seed(seed)
if secure:
self.secure()
else:
self.random.seed(self.seed)
self.seed_name = name if name else str(self.seed)
self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
from worlds import AutoWorld
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
@@ -275,13 +264,12 @@ class MultiWorld():
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
def set_item_links(self):
from worlds import AutoWorld
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
@@ -584,10 +572,9 @@ class MultiWorld():
def location_condition(location: Location):
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
if location.player in players["locations"] or (location.item and location.item.player not in
players["minimal"]):
return True
return False
if location.player in players["minimal"]:
return False
return True
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
@@ -716,23 +703,14 @@ class CollectionState():
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
return self.can_reach_location(spot, player)
spot = self.multiworld.get_location(spot, player)
elif resolution_hint == 'Entrance':
return self.can_reach_entrance(spot, player)
spot = self.multiworld.get_entrance(spot, player)
else:
# default to Region
return self.can_reach_region(spot, player)
spot = self.multiworld.get_region(spot, player)
return spot.can_reach(self)
def can_reach_location(self, spot: str, player: int) -> bool:
return self.multiworld.get_location(spot, player).can_reach(self)
def can_reach_entrance(self, spot: str, player: int) -> bool:
return self.multiworld.get_entrance(spot, player).can_reach(self)
def can_reach_region(self, spot: str, player: int) -> bool:
return self.multiworld.get_region(spot, player).can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
@@ -845,8 +823,8 @@ class Entrance:
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})'
world = self.parent_region.multiworld if self.parent_region else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
class Region:
@@ -889,8 +867,6 @@ class Region:
del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None:
assert value.name not in self.region_manager.location_cache[value.player], \
f"{value.name} already exists in the location cache."
self._list.insert(index, value)
self.region_manager.location_cache[value.player][value.name] = value
@@ -901,8 +877,6 @@ class Region:
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None:
assert value.name not in self.region_manager.entrance_cache[value.player], \
f"{value.name} already exists in the entrance cache."
self._list.insert(index, value)
self.region_manager.entrance_cache[value.player][value.name] = value
@@ -1032,7 +1006,7 @@ class Location:
locked: bool = False
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda state, item: False)
always_allow = staticmethod(lambda item, state: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None
@@ -1066,8 +1040,8 @@ class Location:
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})'
world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
def __hash__(self):
return hash((self.name, self.player))
@@ -1201,7 +1175,7 @@ class Spoiler:
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
def create_playthrough(self, create_paths: bool = True) -> None:
"""Destructive to the multiworld while it is run, damage gets repaired afterwards."""
"""Destructive to the world while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
@@ -1288,12 +1262,12 @@ class Spoiler:
for location in sphere:
state.collect(location.item, True, location)
required_locations -= sphere
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
required_locations -= sphere
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -1352,8 +1326,6 @@ class Spoiler:
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str) -> None:
from worlds import AutoWorld
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
res = getattr(self.multiworld.worlds[player].options, option_key)
display_name = getattr(option_obj, "display_name", option_key)
@@ -1477,3 +1449,8 @@ def get_seed(seed: Optional[int] = None) -> int:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
return seed
from worlds import AutoWorld
auto_world = AutoWorld.World

View File

@@ -20,8 +20,8 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -72,16 +72,9 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
item: NetworkItem
self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
self.output(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
parts = []
add_json_item(parts, item.item, self.ctx.slot, item.flags)
add_json_text(parts, " from ")
add_json_location(parts, item.location, item.player)
add_json_text(parts, " by ")
add_json_text(parts, item.player, type=JSONTypes.player_id)
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self, filter_text = "") -> bool:
@@ -122,15 +115,6 @@ class ClientCommandProcessor(CommandProcessor):
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
if not self.ctx.game:
@@ -140,15 +124,6 @@ class ClientCommandProcessor(CommandProcessor):
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
@@ -643,13 +618,13 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.username = server_url.username
if server_url.password:
ctx.password = server_url.password
port = server_url.port or 38281
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}')
try:
port = server_url.port or 38281 # raises ValueError if invalid
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
@@ -758,10 +733,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible. '
@@ -968,5 +941,4 @@ def run_as_textclient():
if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
run_as_textclient()

226
Fill.py
View File

@@ -27,12 +27,12 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
return new_state
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
"""
:param multiworld: Multiworld to be filled.
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations
@@ -68,7 +68,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items)
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
while items_to_place:
# if we have run out of locations to fill,break out of this loop
@@ -80,8 +80,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
@@ -122,11 +122,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
multiworld.get_reachable_locations(swap_state))
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
@@ -156,7 +156,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else:
unplaced_items.append(item_to_place)
continue
multiworld.push_item(spot_to_fill, item_to_place, False)
world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
@@ -173,7 +173,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
for placement in placements:
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
@@ -188,7 +188,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
for location in excluded_locations:
if not location.item:
@@ -196,26 +196,19 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
if multiworld.can_beat_game():
if world.can_beat_game():
logging.warning(
f"Not all items placed. Game beatable anyway.\nCould not place:\n"
f"{', '.join(str(item) for item in unplaced_items)}")
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
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)}")
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
item_pool.extend(unplaced_items)
def remaining_fill(multiworld: MultiWorld,
def remaining_fill(world: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item],
name: str = "Remaining") -> None:
itempool: typing.List[Item]) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
@@ -268,41 +261,36 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items.append(item_to_place)
continue
multiworld.push_item(spot_to_fill, item_to_place, False)
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
_log_fill_progress("Remaining", placed, total)
if total > 1000:
_log_fill_progress(name, placed, total)
_log_fill_progress("Remaining", placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
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)}")
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def fast_fill(multiworld: MultiWorld,
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
multiworld.push_item(location, item, False)
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
@@ -316,36 +304,36 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(multiworld: MultiWorld,
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
for player in multiworld.player_ids:
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
multiworld.local_early_items[player].get(item, 0)]
early_items_count[item, player] = [world.early_items[player].get(item, 0),
world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = multiworld.state.copy()
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.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:
@@ -357,8 +345,8 @@ def distribute_early_items(multiworld: MultiWorld,
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
@@ -382,28 +370,28 @@ def distribute_early_items(multiworld: MultiWorld,
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in multiworld.player_ids:
for player in world.player_ids:
player_local = early_local_rest_items[player]
fill_restrictive(multiworld, base_state,
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
early_locations += early_priority_locations
for player in multiworld.player_ids:
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(multiworld, base_state,
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression")
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
@@ -412,18 +400,18 @@ def distribute_early_items(multiworld: MultiWorld,
itempool += unplaced_early_items
fill_locations.extend(early_locations)
multiworld.random.shuffle(fill_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(multiworld: MultiWorld) -> None:
fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations)
def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations)
# get items to distribute
itempool = sorted(multiworld.itempool)
multiworld.random.shuffle(itempool)
itempool = sorted(world.itempool)
world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
@@ -437,7 +425,7 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
else:
filleritempool.append(item)
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
@@ -458,79 +446,75 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
if prioritylocations:
# "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
name="Priority")
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression")
fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations."
)
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
accessibility_corrections(world, world.state, defaultlocations)
for location in lock_later:
if location.item:
location.locked = True
del mark_for_locking, lock_later
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
inaccessible_location_rules(world, world.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
remaining_fill(world, excludedlocations, filleritempool)
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."
)
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool)
remaining_fill(world, defaultlocations, restitempool)
unplaced = restitempool
unfilled = defaultlocations
if unplaced or unfilled:
logging.warning(
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
items_counter = Counter(location.item.player for location in multiworld.get_locations() if location.item)
locations_counter = Counter(location.player for location in multiworld.get_locations())
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
locations_counter = Counter(location.player for location in world.get_locations())
items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})")
logging.info(f'Per-Player counts: {print_data})')
def flood_items(multiworld: MultiWorld) -> None:
def flood_items(world: MultiWorld) -> None:
# get items to distribute
multiworld.random.shuffle(multiworld.itempool)
itempool = multiworld.itempool
world.random.shuffle(world.itempool)
itempool = world.itempool
progress_done = False
# sweep once to pick up preplaced items
multiworld.state.sweep_for_events()
world.state.sweep_for_events()
# fill multiworld from top of itempool while we can
# fill world from top of itempool while we can
while not progress_done:
location_list = multiworld.get_unfilled_locations()
multiworld.random.shuffle(location_list)
location_list = world.get_unfilled_locations()
world.random.shuffle(location_list)
spot_to_fill = None
for location in location_list:
if location.can_fill(multiworld.state, itempool[0]):
if location.can_fill(world.state, itempool[0]):
spot_to_fill = location
break
if spot_to_fill:
item = itempool.pop(0)
multiworld.push_item(spot_to_fill, item, True)
world.push_item(spot_to_fill, item, True)
continue
# ran out of spots, check if we need to step in and correct things
if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
if len(world.get_reachable_locations()) == len(world.get_locations()):
progress_done = True
continue
@@ -540,7 +524,7 @@ def flood_items(multiworld: MultiWorld) -> None:
for item in itempool:
if item.advancement:
candidate_item_to_place = item
if multiworld.unlocks_new_location(item):
if world.unlocks_new_location(item):
item_to_place = item
break
@@ -553,15 +537,15 @@ def flood_items(multiworld: MultiWorld) -> None:
raise FillError('No more progress items left to place.')
# find item to replace with progress item
location_list = multiworld.get_reachable_locations()
multiworld.random.shuffle(location_list)
location_list = world.get_reachable_locations()
world.random.shuffle(location_list)
for location in location_list:
if location.item is not None and not location.item.advancement:
# safe to replace
replace_item = location.item
replace_item.location = None
itempool.append(replace_item)
multiworld.push_item(location, item_to_place, True)
world.push_item(location, item_to_place, True)
itempool.remove(item_to_place)
break
@@ -771,7 +755,7 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(multiworld: MultiWorld) -> None:
def distribute_planned(world: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
@@ -784,24 +768,24 @@ def distribute_planned(multiworld: MultiWorld) -> None:
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state = world.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
reachable = frozenset(world.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)
for loc in multiworld.get_unfilled_locations():
for loc in world.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup
world_name_lookup = world.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
player_ids = set(world.player_ids)
for player in player_ids:
for block in multiworld.plando_items[player]:
for block in world.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
@@ -815,12 +799,12 @@ def distribute_planned(multiworld: MultiWorld) -> None:
else:
target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player}
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(multiworld.player_ids)
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
@@ -830,9 +814,9 @@ def distribute_planned(multiworld: MultiWorld) -> None:
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1):
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
block['force'])
continue
worlds = {target_world}
@@ -860,7 +844,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
value = world.itempool.count(world.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
@@ -910,17 +894,17 @@ def distribute_planned(multiworld: MultiWorld) -> None:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
if block['count']['target'] > 0:
plando_blocks.append(block)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks)
world.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
else len(world.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement['player']
@@ -931,19 +915,19 @@ def distribute_planned(multiworld: MultiWorld) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates)
multiworld.random.shuffle(items)
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
for item_name in items:
item = multiworld.worlds[player].create_item(item_name)
item = world.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
if location.can_fill(world.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
@@ -961,21 +945,21 @@ def distribute_planned(multiworld: MultiWorld) -> None:
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:
try:
multiworld.itempool.remove(item)
world.itempool.remove(item)
except ValueError:
warn(
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
placement['force'])
except Exception as e:
raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
f"Error running plando for player {player} ({world.player_name[player]})") from e

View File

@@ -26,7 +26,6 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
from worlds import failed_world_loads
def mystery_argparse():
@@ -303,9 +302,7 @@ def handle_name(name: str, player: int, name_counter: Counter):
NUMBER=(number if number > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
# Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip()
new_name = new_name.strip()[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
@@ -318,35 +315,33 @@ def prefer_int(input_data: str) -> Union[str, int]:
return input_data
goals = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
for option in new_weights:
option_name = option.lstrip("+")
if option.startswith("+") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, (set, dict)):
cleaned_value.update(new_value)
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
new_options = set(new_weights) - set(weights)
weights.update(new_weights)
if new_options:
for new_option in new_options:
logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
@@ -362,6 +357,15 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
@@ -459,11 +463,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types:
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. "
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"If so, it appears the world failed to initialize correctly.")
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.")
@@ -473,10 +473,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game]
if any(weight.startswith("+") for weight in game_weights) or \
any(weight.startswith("+") for weight in weights):
raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game]
@@ -489,23 +485,120 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
if PlandoOptions.connections in plando_options:
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
if PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
glitches_required = get_choice_legacy('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required]
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
if not ret.dark_room_logic: # None/False
ret.dark_room_logic = "none"
if ret.dark_room_logic == "sconces":
ret.dark_room_logic = "torches"
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
if entrance_shuffle.startswith('none-'):
ret.shuffle = 'vanilla'
else:
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice_legacy('goals', weights, 'ganon')
ret.goal = goals[goal]
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
# sum a percentage to required
if extra_pieces == 'percentage':
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are)
elif extra_pieces == 'available':
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
get_choice_legacy('triforce_pieces_available', weights, 30))
# required pieces + fixed extra
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
if not ret.shop_shuffle:
ret.shop_shuffle = ''
ret.mode = get_choice_legacy("mode", weights)
ret.difficulty = get_choice_legacy('item_pool', weights)
ret.item_functionality = get_choice_legacy('item_functionality', weights)
ret.enemy_damage = {None: 'default',
'default': 'default',
'shuffled': 'shuffled',
'random': 'chaos', # to be removed
'chaos': 'chaos',
}[get_choice_legacy('enemy_damage', weights)]
ret.enemy_health = get_choice_legacy('enemy_health', weights)
ret.timer = {'none': False,
None: False,
False: False,
'timed': 'timed',
'timed_ohko': 'timed-ohko',
'ohko': 'ohko',
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice_legacy('timer', weights, False)]
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
get_choice_legacy("turtle_rock_medallion", weights, "random")]
for index, medallion in enumerate(ret.required_medallions):
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
.get(medallion.lower(), None)
if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
@@ -519,6 +612,17 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice_legacy("entrance", placement),
get_choice_legacy("exit", placement),
get_choice_legacy("direction", placement, "both")
))
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:

View File

@@ -100,7 +100,7 @@ components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls),
Component("Generate Template Settings", func=generate_yamls),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
@@ -161,7 +161,7 @@ def launch(exe, in_terminal=False):
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kvui import App, ContainerLayout, GridLayout, Button, Label
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
@@ -185,16 +185,11 @@ def run_gui():
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
tool_layout = ScrollBox()
tool_layout.layout.orientation = "vertical"
self.grid.add_widget(tool_layout)
client_layout = ScrollBox()
client_layout.layout.orientation = "vertical"
self.grid.add_widget(client_layout)
self.grid.add_widget(Label(text="General"))
self.grid.add_widget(Label(text="Clients"))
button_layout = self.grid # make buttons fill the window
def build_button(component: Component) -> Widget:
def build_button(component: Component):
"""
Builds a button widget for a given component.
@@ -205,26 +200,31 @@ def run_gui():
None. The button is added to the parent grid layout.
"""
button = Button(text=component.display_name, size_hint_y=None, height=40)
button = Button(text=component.display_name)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout = RelativeLayout()
box_layout.add_widget(button)
box_layout.add_widget(image)
return box_layout
return button
button_layout.add_widget(box_layout)
else:
button_layout.add_widget(button)
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
tool_layout.layout.add_widget(build_button(tool[1]))
build_button(tool[1])
else:
button_layout.add_widget(Label())
# column 2
if client:
client_layout.layout.add_widget(build_button(client[1]))
build_button(client[1])
else:
button_layout.add_widget(Label())
return self.container

View File

@@ -348,8 +348,7 @@ class LinksAwakeningClient():
await asyncio.sleep(1.0)
continue
self.stop_bizhawk_spam = False
logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
f"running {rom_name.decode('ascii', errors='replace')}")
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
return
except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)

282
Main.py
View File

@@ -30,49 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_path.cached_path = args.outputpath
start = time.perf_counter()
# initialize the multiworld
multiworld = MultiWorld(args.multi)
# initialize the world
world = MultiWorld(args.multi)
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
world.plando_options = args.plando_options
multiworld.shuffle = args.shuffle.copy()
multiworld.logic = args.logic.copy()
multiworld.mode = args.mode.copy()
multiworld.difficulty = args.difficulty.copy()
multiworld.item_functionality = args.item_functionality.copy()
multiworld.timer = args.timer.copy()
multiworld.goal = args.goal.copy()
multiworld.boss_shuffle = args.shufflebosses.copy()
multiworld.enemy_health = args.enemy_health.copy()
multiworld.enemy_damage = args.enemy_damage.copy()
multiworld.beemizer_total_chance = args.beemizer_total_chance.copy()
multiworld.beemizer_trap_chance = args.beemizer_trap_chance.copy()
multiworld.countdown_start_time = args.countdown_start_time.copy()
multiworld.red_clock_time = args.red_clock_time.copy()
multiworld.blue_clock_time = args.blue_clock_time.copy()
multiworld.green_clock_time = args.green_clock_time.copy()
multiworld.dungeon_counters = args.dungeon_counters.copy()
multiworld.triforce_pieces_available = args.triforce_pieces_available.copy()
multiworld.triforce_pieces_required = args.triforce_pieces_required.copy()
multiworld.shop_shuffle = args.shop_shuffle.copy()
multiworld.shuffle_prizes = args.shuffle_prizes.copy()
multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.dark_room_logic = args.dark_room_logic.copy()
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.required_medallions = args.required_medallions.copy()
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
multiworld.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
world.mode = args.mode.copy()
world.difficulty = args.difficulty.copy()
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.goal = args.goal.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
world.green_clock_time = args.green_clock_time.copy()
world.dungeon_counters = args.dungeon_counters.copy()
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.copy()
world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy()
world.dark_room_logic = args.dark_room_logic.copy()
world.plando_items = args.plando_items.copy()
world.plando_texts = args.plando_texts.copy()
world.plando_connections = args.plando_connections.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
world.player_name = args.name.copy()
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
multiworld.set_options(args)
multiworld.set_item_links()
multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
world.set_options(args)
world.set_item_links()
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
@@ -103,93 +103,93 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output:
AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early")
AutoWorld.call_all(world, "generate_early")
logger.info('')
for player in multiworld.player_ids:
for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
for player in world.player_ids:
for item_name, count in world.worlds[player].options.start_inventory.value.items():
for _ in range(count):
multiworld.push_precollected(multiworld.create_item(item_name, player))
world.push_precollected(world.create_item(item_name, player))
for item_name, count in getattr(multiworld.worlds[player].options,
for item_name, count in getattr(world.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.items():
for _ in range(count):
multiworld.push_precollected(multiworld.create_item(item_name, player))
world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = multiworld.early_items[player].get(item_name, 0)
early = world.early_items[player].get(item_name, 0)
if early:
multiworld.early_items[player][item_name] = max(0, early-count)
world.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = multiworld.early_local_items[player].get(item_name, 0)
local_early = world.early_local_items[player].get(item_name, 0)
if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
world.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions")
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
logger.info('Creating Items.')
AutoWorld.call_all(multiworld, "create_items")
AutoWorld.call_all(world, "create_items")
logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
for player in world.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules")
AutoWorld.call_all(world, "set_rules")
for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
for location_name in multiworld.worlds[player].options.priority_locations.value:
for player in world.player_ids:
exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
for location_name in world.worlds[player].options.priority_locations.value:
try:
location = multiworld.get_location(location_name, player)
location = world.get_location(location_name, player)
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
if location_name not in multiworld.worlds[player].location_name_to_id:
if location_name not in world.worlds[player].location_name_to_id:
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
else:
location.progress_type = LocationProgressType.PRIORITY
# Set local and non-local item rules.
if multiworld.players > 1:
locality_rules(multiworld)
if world.players > 1:
locality_rules(world)
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
world.worlds[1].options.non_local_items.value = set()
world.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "generate_basic")
AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
if any(getattr(world.worlds[player].options, "start_inventory_from_pool", None) for player in world.player_ids):
new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options,
player: getattr(world.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.copy()
for player in multiworld.player_ids
for player in world.player_ids
}
for player, items in depletion_pool.items():
player_world: AutoWorld.World = multiworld.worlds[player]
player_world: AutoWorld.World = world.worlds[player]
for count in items.values():
for _ in range(count):
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(multiworld.itempool):
for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
new_items.extend(multiworld.itempool[i+1:])
new_items.extend(world.itempool[i+1:])
break
else:
new_items.append(item)
@@ -199,19 +199,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
raise Exception(f"{multiworld.get_player_name(player)}"
raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
for group_id, group in multiworld.groups.items():
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in multiworld.itempool:
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification
@@ -246,13 +246,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", group_id, multiworld, "ItemLink")
multiworld.regions.append(region)
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
locations = region.locations
for item in multiworld.itempool:
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
@@ -263,10 +263,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
new_itempool.append(item)
itemcount = len(multiworld.itempool)
multiworld.itempool = new_itempool
itemcount = len(world.itempool)
world.itempool = new_itempool
while itemcount > len(multiworld.itempool):
while itemcount > len(world.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
@@ -274,64 +274,64 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
multiworld.random.shuffle(items_to_add)
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(multiworld.item_links.values()):
multiworld._all_state = None
if any(world.item_links.values()):
world._all_state = None
logger.info("Running Item Plando.")
distribute_planned(multiworld)
distribute_planned(world)
logger.info('Running Pre Main Fill.')
AutoWorld.call_all(multiworld, "pre_fill")
AutoWorld.call_all(world, "pre_fill")
logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
logger.info(f'Filling the world with {len(world.itempool)} items.')
if multiworld.algorithm == 'flood':
flood_items(multiworld) # different algo, biased towards early game progress items
elif multiworld.algorithm == 'balanced':
distribute_items_restrictive(multiworld)
if world.algorithm == 'flood':
flood_items(world) # different algo, biased towards early game progress items
elif world.algorithm == 'balanced':
distribute_items_restrictive(world)
AutoWorld.call_all(multiworld, 'post_fill')
AutoWorld.call_all(world, 'post_fill')
if multiworld.players > 1 and not args.skip_prog_balancing:
balance_multiworld_progression(multiworld)
if world.players > 1 and not args.skip_prog_balancing:
balance_multiworld_progression(world)
else:
logger.info("Progression balancing skipped.")
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
multiworld.random.passthrough = False
world.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
return multiworld
return world
logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name
outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory()
with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
is not multiworld.worlds[player].generate_output.__code__]
output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__
is not world.worlds[player].generate_output.__code__]
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
check_accessibility_task = pool.submit(world.fulfills_accessibility)
output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
for player in output_players:
# skip starting a thread for methods that say "pass".
output_file_futures.append(
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
def write_multidata():
import NetUtils
@@ -340,38 +340,38 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot]
names = [[name for player, name in sorted(world.player_name.items())]]
for slot in world.player_ids:
player_world: AutoWorld.World = world.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version
games[slot] = multiworld.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot],
multiworld.player_types[slot])
for slot, group in multiworld.groups.items():
games[slot] = multiworld.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot],
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
world.player_types[slot])
for slot, group in world.groups.items():
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups:
if location.item.player not in world.groups:
precollected_hints[location.item.player].add(hint)
else:
for player in multiworld.groups[location.item.player]["players"]:
for player in world.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations():
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
@@ -381,18 +381,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.name in multiworld.worlds[location.player].options.start_location_hints:
if location.name in world.worlds[location.player].options.start_location_hints:
precollect_hint(location)
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
elif location.item.name in world.worlds[location.item.player].options.start_hints:
precollect_hint(location)
elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
elif any([location.item.name in world.worlds[player].options.start_hints
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
for game_world in world.worlds.values()
}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
@@ -400,7 +400,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
@@ -410,10 +410,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name,
"seed_name": world.seed_name,
"datapackage": data_package,
}
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
AutoWorld.call_all(world, "modify_multidata", multidata)
multidata = zlib.compress(pickle.dumps(multidata), 9)
@@ -423,7 +423,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():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")
@@ -436,12 +436,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if args.spoiler > 1:
logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler:
multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{multiworld.seed_name}.zip")
zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
@@ -449,4 +449,4 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return multiworld
return world

View File

@@ -656,8 +656,7 @@ class Context:
else:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None):
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -686,13 +685,12 @@ class Context:
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
if recipients is None or slot in recipients:
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
# "events"
@@ -707,17 +705,14 @@ class Context:
self.save() # save goal completion flag
def on_new_hint(self, team: int, slot: int):
self.on_changed_hints(team, slot)
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
def on_changed_hints(self, team: int, slot: int):
key: str = f"_read_hints_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
@@ -978,10 +973,7 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only
}])
old_hints = ctx.hints[team, slot].copy()
ctx.recheck_hints(team, slot)
if old_hints != ctx.hints[team, slot]:
ctx.on_changed_hints(team, slot)
ctx.save()
@@ -1058,19 +1050,17 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
return picks[0][0], False, f"Didn't find something that closely matches, " \
f"did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
return picks[0][0], False, f"Too many close matches, did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
return picks[0][0], False, f"Did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
class CommandMeta(type):
@@ -1439,13 +1429,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
self.ctx.hints[self.client.team, self.client.slot] = hints
self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,))
self.ctx.notify_hints(self.client.team, list(hints))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
if hints and Utils.version_tuple < (0, 5, 0):
self.output("It was recently changed, so that the above hints are only shown to you. "
"If you meant to alert another player of an above hint, "
"please let them know of the content or to run !hint themselves.")
return True
elif input_text.isnumeric():
@@ -1972,7 +1958,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forbid_release(self, player_name: str) -> bool:
"""Disallow the specified player from using the !release command."""
""""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player

View File

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

View File

@@ -195,10 +195,10 @@ def set_icon(window):
window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args):
# Create a fake multiworld and OOTWorld to use as a base
multiworld = MultiWorld(1)
multiworld.per_slot_randoms = {1: random}
ootworld = OOTWorld(multiworld, 1)
# Create a fake world and OOTWorld to use as a base
world = MultiWorld(1)
world.per_slot_randoms = {1: random}
ootworld = OOTWorld(world, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
result = getattr(args, name, None)

View File

@@ -1,18 +1,19 @@
from __future__ import annotations
import abc
import functools
import logging
from copy import deepcopy
from dataclasses import dataclass
import functools
import math
import numbers
import random
import typing
from copy import deepcopy
from dataclasses import dataclass
from schema import And, Optional, Or, Schema
from Utils import get_fuzzy_results, is_iterable_except_str
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
@@ -41,11 +42,6 @@ class AssembleOptions(abc.ABCMeta):
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
"default" in attrs or
any(hasattr(base, "default") for base in bases)
), f"Option class {name} needs default value"
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
@@ -63,7 +59,6 @@ class AssembleOptions(abc.ABCMeta):
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
@@ -101,7 +96,7 @@ T = typing.TypeVar('T')
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
default = 0
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
@@ -111,9 +106,8 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]]
name_lookup: typing.Dict[T, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
@@ -166,8 +160,6 @@ class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
default = ""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@@ -188,18 +180,9 @@ class FreeText(Option[str]):
def get_option_name(cls, value: str) -> str:
return value
def __eq__(self, other):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
return other == self.value
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True)
@@ -615,7 +598,7 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
if isinstance(self.value, int):
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.bosses & plando_options):
if not(PlandoOptions.bosses & plando_options):
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
@@ -744,7 +727,7 @@ class SpecialRange(NamedRange):
"Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In "
"NamedRange, range_start specifies the lower end of the regular range, while special values can be "
"placed anywhere (below, inside, or above the regular range).")
return super().__new__(cls)
return super().__new__(cls, value)
@classmethod
def weighted_range(cls, text) -> Range:
@@ -782,7 +765,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
value: typing.Any
@classmethod
def verify_keys(cls, data: typing.Iterable[str]) -> None:
def verify_keys(cls, data: typing.List[str]):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
@@ -819,7 +802,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
default: typing.Dict[str, typing.Any] = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
@@ -860,11 +843,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
# Not a docstring so it doesn't get grabbed by the options system.
default = ()
default: typing.List[typing.Any] = []
supports_weighting = False
def __init__(self, value: typing.Iterable[typing.Any]):
self.value = list(deepcopy(value))
def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value)
super(OptionList, self).__init__()
@classmethod
@@ -873,7 +856,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
if type(data) == list:
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -886,7 +869,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
@@ -899,7 +882,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
if isinstance(data, (list, set, frozenset)):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -949,7 +932,7 @@ class OptionsMetaProperty(type):
bases: typing.Tuple[type, ...],
attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
for attr_type in attrs.values():
assert not isinstance(attr_type, AssembleOptions), \
assert not isinstance(attr_type, AssembleOptions),\
f"Options for {name} should be type hinted on the class, not assigned"
return super().__new__(mcs, name, bases, attrs)
@@ -1127,11 +1110,6 @@ class PerGameCommonOptions(CommonOptions):
item_links: ItemLinks
@dataclass
class DeathLinkMixin:
death_link: DeathLink
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
import os

View File

@@ -8,7 +8,7 @@ if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APAutoPatchInterface
from worlds.Files import AutoPatchRegister, APDeltaPatch
class RomMeta(TypedDict):
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler:
handler: APAutoPatchInterface = auto_handler(patch_file)
handler: APDeltaPatch = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,

View File

@@ -25,7 +25,7 @@ Currently, the following games are supported:
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2
* Starcraft 2: Wings of Liberty
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
@@ -59,12 +59,6 @@ Currently, the following games are supported:
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
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
@@ -86,9 +80,9 @@ We recognize that there is a strong community of incredibly smart people that ha
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago
For most people, all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer, or AppImage for Linux-based systems.
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
If you are a developer or are running on a platform with no compiled releases available, please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
from worlds.sc2wol.Client import launch
import Utils
if __name__ == "__main__":

View File

@@ -19,13 +19,14 @@ import warnings
from argparse import Namespace
from settings import Settings, get_settings
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from typing_extensions import TypeGuard
from yaml import load, load_all, dump
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError:
from yaml import Loader as UnsafeLoader, SafeLoader, Dumper
from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING:
import tkinter
@@ -46,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.4.5"
__version__ = "0.4.4"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -225,9 +226,6 @@ class UniqueKeyLoader(SafeLoader):
if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key)
return super().construct_mapping(node, deep)
@@ -716,7 +714,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
import ctypes
style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk
try:
import tkinter
@@ -873,8 +871,8 @@ def visualize_regions(root_region: Region, file_name: str, *,
Example usage in Main code:
from Utils import visualize_regions
for player in multiworld.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
for player in world.player_ids:
visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
"""
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
@@ -970,10 +968,3 @@ class RepeatableChain:
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
""" `str` is `Iterable`, but that's not what we want """
if isinstance(obj, str):
return False
return isinstance(obj, typing.Iterable)

View File

@@ -28,7 +28,7 @@ def check():
results, _ = roll_options(options)
if len(options) > 1:
# offer combined file back
combined_yaml = "\n---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
for file_name, file_content in options.items())
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
else:

View File

@@ -25,16 +25,16 @@ window.addEventListener('load', () => {
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let category of categories) {
let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
category.addEventListener('click', function() {
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0];
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,160 +0,0 @@
#player-tracker-wrapper{
margin: 0;
}
#tracker-table td {
vertical-align: top;
}
.inventory-table-area{
border: 2px solid #000000;
border-radius: 4px;
padding: 3px 10px 3px 10px;
}
.inventory-table-area:has(.inventory-table-terran) {
width: 690px;
background-color: #525494;
}
.inventory-table-area:has(.inventory-table-zerg) {
width: 360px;
background-color: #9d60d2;
}
.inventory-table-area:has(.inventory-table-protoss) {
width: 400px;
background-color: #d2b260;
}
#tracker-table .inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
.inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
}
.inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
}
.inventory-table img.acquired{
filter: none;
background-color: black;
}
.inventory-table .tint-terran img.acquired {
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
}
.inventory-table .tint-protoss img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
}
.inventory-table .tint-level-1 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
}
.inventory-table .tint-level-2 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
}
.inventory-table .tint-level-3 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
}
.inventory-table div.counted-item {
position: relative;
}
.inventory-table div.item-count {
width: 160px;
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
border: 2px solid #000000;
border-radius: 4px;
background-color: #87b678;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
cursor: default;
}
#location-table table{
width: 100%;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
#location-table td:has(.location-column) {
vertical-align: top;
}
#location-table .location-column {
width: 100%;
height: 100%;
}
#location-table .location-column .spacer {
min-height: 24px;
}
.hide {
display: none;
}

View File

@@ -0,0 +1,112 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 710px;
background-color: #525494;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
}
#inventory-table img.acquired{
filter: none;
background-color: black;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
width: 710px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #525494;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -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 TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.data %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/sc2wolTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/sc2wolTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/jura" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td colspan="15" class="title">
Starting Resources
</td>
</tr>
<tr>
<td><img src="{{ icons['Starting Minerals'] }}" class="{{ 'acquired' if '+15 Starting Minerals' in acquired_items }}" title="Starting Minerals" /></td>
<td colspan="2"><div class="item-count">+{{ minerals_count }}</div></td>
<td><img src="{{ icons['Starting Vespene'] }}" class="{{ 'acquired' if '+15 Starting Vespene' in acquired_items }}" title="Starting Vespene" /></td>
<td colspan="2"><div class="item-count">+{{ vespene_count }}</div></td>
<!--
<td><img src="{{ icons['Starting Supply'] }}" class="{{ 'acquired' if '+2 Starting Supply' in acquired_items }}" title="Starting Supply" /></td>
<td colspan="2"><div class="item-count">+{{ supply_count }}</div></td>
-->
</tr>
<tr>
<td colspan="15" class="title">
Weapon & Armor Upgrades
</td>
</tr>
<tr>
<td><img src="{{ infantry_weapon_url }}" class="{{ 'acquired' if 'Progressive Infantry Weapon' in acquired_items }}" title="Progressive Infantry Weapons{% if infantry_weapon_level > 0 %} (Level {{ infantry_weapon_level }}){% endif %}" /></td>
<td><img src="{{ infantry_armor_url }}" class="{{ 'acquired' if 'Progressive Infantry Armor' in acquired_items }}" title="Progressive Infantry Armor{% if infantry_armor_level > 0 %} (Level {{ infantry_armor_level }}){% endif %}" /></td>
<td><img src="{{ vehicle_weapon_url }}" class="{{ 'acquired' if 'Progressive Vehicle Weapon' in acquired_items }}" title="Progressive Vehicle Weapons{% if vehicle_weapon_level > 0 %} (Level {{ vehicle_weapon_level }}){% endif %}" /></td>
<td><img src="{{ vehicle_armor_url }}" class="{{ 'acquired' if 'Progressive Vehicle Armor' in acquired_items }}" title="Progressive Vehicle Armor{% if vehicle_armor_level > 0 %} (Level {{ vehicle_armor_level }}){% endif %}" /></td>
<td><img src="{{ ship_weapon_url }}" class="{{ 'acquired' if 'Progressive Ship Weapon' in acquired_items }}" title="Progressive Ship Weapons{% if ship_weapon_level > 0 %} (Level {{ ship_weapon_level }}){% endif %}" /></td>
<td><img src="{{ ship_armor_url }}" class="{{ 'acquired' if 'Progressive Ship Armor' in acquired_items }}" title="Progressive Ship Armor{% if ship_armor_level > 0 %} (Level {{ ship_armor_level }}){% endif %}" /></td>
<td colspan="2"></td>
<td><img src="{{ icons['Ultra-Capacitors'] }}" class="{{ 'acquired' if 'Ultra-Capacitors' in acquired_items }}" title="Ultra-Capacitors" /></td>
<td><img src="{{ icons['Vanadium Plating'] }}" class="{{ 'acquired' if 'Vanadium Plating' in acquired_items }}" title="Vanadium Plating" /></td>
</tr>
<tr>
<td colspan="15" class="title">
Base
</td>
</tr>
<tr>
<td><img src="{{ icons['Bunker'] }}" class="{{ 'acquired' if 'Bunker' in acquired_items }}" title="Bunker" /></td>
<td><img src="{{ icons['Projectile Accelerator (Bunker)'] }}" class="{{ 'acquired' if 'Projectile Accelerator (Bunker)' in acquired_items }}" title="Projectile Accelerator (Bunker)" /></td>
<td><img src="{{ icons['Neosteel Bunker (Bunker)'] }}" class="{{ 'acquired' if 'Neosteel Bunker (Bunker)' in acquired_items }}" title="Neosteel Bunker (Bunker)" /></td>
<td><img src="{{ icons['Shrike Turret (Bunker)'] }}" class="{{ 'acquired' if 'Shrike Turret (Bunker)' in acquired_items }}" title="Shrike Turret (Bunker)" /></td>
<td><img src="{{ icons['Fortified Bunker (Bunker)'] }}" class="{{ 'acquired' if 'Fortified Bunker (Bunker)' in acquired_items }}" title="Fortified Bunker (Bunker)" /></td>
<td colspan="3"></td>
<td><img src="{{ icons['Missile Turret'] }}" class="{{ 'acquired' if 'Missile Turret' in acquired_items }}" title="Missile Turret" /></td>
<td><img src="{{ icons['Titanium Housing (Missile Turret)'] }}" class="{{ 'acquired' if 'Titanium Housing (Missile Turret)' in acquired_items }}" title="Titanium Housing (Missile Turret)" /></td>
<td><img src="{{ icons['Hellstorm Batteries (Missile Turret)'] }}" class="{{ 'acquired' if 'Hellstorm Batteries (Missile Turret)' in acquired_items }}" title="Hellstorm Batteries (Missile Turret)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Orbital Command (Building)'] }}" class="{{ 'acquired' if 'Orbital Command (Building)' in acquired_items }}" title="Orbital Command (Building)" /></td>
<td><img src="{{ icons['Command Center Reactor'] }}" class="{{ 'acquired' if 'Command Center Reactor' in acquired_items }}" title="Command Center Reactor" /></td>
<td><img src="{{ icons['Planetary Fortress'] }}" class="{{ 'acquired' if 'Planetary Fortress' in acquired_items }}" title="Planetary Fortress" /></td>
<td></td>
<td><img src="{{ icons['Advanced Construction (SCV)'] }}" class="{{ 'acquired' if 'Advanced Construction (SCV)' in acquired_items }}" title="Advanced Construction (SCV)" /></td>
<td><img src="{{ icons['Dual-Fusion Welders (SCV)'] }}" class="{{ 'acquired' if 'Dual-Fusion Welders (SCV)' in acquired_items }}" title="Dual-Fusion Welders (SCV)" /></td>
<td></td>
<td><img src="{{ icons['Micro-Filtering'] }}" class="{{ 'acquired' if 'Micro-Filtering' in acquired_items }}" title="Micro-Filtering" /></td>
<td><img src="{{ icons['Automated Refinery'] }}" class="{{ 'acquired' if 'Automated Refinery' in acquired_items }}" title="Automated Refinery" /></td>
<td></td>
<td><img src="{{ icons['Tech Reactor'] }}" class="{{ 'acquired' if 'Tech Reactor' in acquired_items }}" title="Tech Reactor" /></td>
<td></td>
<td><img src="{{ icons['Orbital Depots'] }}" class="{{ 'acquired' if 'Orbital Depots' in acquired_items }}" title="Orbital Depots" /></td>
</tr>
<tr>
<td><img src="{{ icons['Sensor Tower'] }}" class="{{ 'acquired' if 'Sensor Tower' in acquired_items }}" title="Sensor Tower" /></td>
<td></td>
<td><img src="{{ icons['Perdition Turret'] }}" class="{{ 'acquired' if 'Perdition Turret' in acquired_items }}" title="Perdition Turret" /></td>
<td></td>
<td><img src="{{ icons['Hive Mind Emulator'] }}" class="{{ 'acquired' if 'Hive Mind Emulator' in acquired_items }}" title="Hive Mind Emulator" /></td>
<td></td>
<td><img src="{{ icons['Psi Disrupter'] }}" class="{{ 'acquired' if 'Psi Disrupter' in acquired_items }}" title="Psi Disrupter" /></td>
</tr>
<tr>
<td colspan="7" class="title">
Infantry
</td>
<td></td>
<td colspan="7" class="title">
Vehicles
</td>
</tr>
<tr>
<td><img src="{{ icons['Marine'] }}" class="{{ 'acquired' if 'Marine' in acquired_items }}" title="Marine" /></td>
<td><img src="{{ stimpack_marine_url }}" class="{{ 'acquired' if 'Progressive Stimpack (Marine)' in acquired_items }}" title="{{ stimpack_marine_name }}" /></td>
<td><img src="{{ icons['Combat Shield (Marine)'] }}" class="{{ 'acquired' if 'Combat Shield (Marine)' in acquired_items }}" title="Combat Shield (Marine)" /></td>
<td><img src="{{ icons['Laser Targeting System (Marine)'] }}" class="{{ 'acquired' if 'Laser Targeting System (Marine)' in acquired_items }}" title="Laser Targeting System (Marine)" /></td>
<td><img src="{{ icons['Magrail Munitions (Marine)'] }}" class="{{ 'acquired' if 'Magrail Munitions (Marine)' in acquired_items }}" title="Magrail Munitions (Marine)" /></td>
<td><img src="{{ icons['Optimized Logistics (Marine)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Marine)' in acquired_items }}" title="Optimized Logistics (Marine)" /></td>
<td colspan="2"></td>
<td><img src="{{ icons['Hellion'] }}" class="{{ 'acquired' if 'Hellion' in acquired_items }}" title="Hellion" /></td>
<td><img src="{{ icons['Twin-Linked Flamethrower (Hellion)'] }}" class="{{ 'acquired' if 'Twin-Linked Flamethrower (Hellion)' in acquired_items }}" title="Twin-Linked Flamethrower (Hellion)" /></td>
<td><img src="{{ icons['Thermite Filaments (Hellion)'] }}" class="{{ 'acquired' if 'Thermite Filaments (Hellion)' in acquired_items }}" title="Thermite Filaments (Hellion)" /></td>
<td><img src="{{ icons['Hellbat Aspect (Hellion)'] }}" class="{{ 'acquired' if 'Hellbat Aspect (Hellion)' in acquired_items }}" title="Hellbat Aspect (Hellion)" /></td>
<td><img src="{{ icons['Smart Servos (Hellion)'] }}" class="{{ 'acquired' if 'Smart Servos (Hellion)' in acquired_items }}" title="Smart Servos (Hellion)" /></td>
<td><img src="{{ icons['Optimized Logistics (Hellion)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Hellion)' in acquired_items }}" title="Optimized Logistics (Hellion)" /></td>
<td><img src="{{ icons['Jump Jets (Hellion)'] }}" class="{{ 'acquired' if 'Jump Jets (Hellion)' in acquired_items }}" title="Jump Jets (Hellion)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Medic'] }}" class="{{ 'acquired' if 'Medic' in acquired_items }}" title="Medic" /></td>
<td><img src="{{ icons['Advanced Medic Facilities (Medic)'] }}" class="{{ 'acquired' if 'Advanced Medic Facilities (Medic)' in acquired_items }}" title="Advanced Medic Facilities (Medic)" /></td>
<td><img src="{{ icons['Stabilizer Medpacks (Medic)'] }}" class="{{ 'acquired' if 'Stabilizer Medpacks (Medic)' in acquired_items }}" title="Stabilizer Medpacks (Medic)" /></td>
<td><img src="{{ icons['Restoration (Medic)'] }}" class="{{ 'acquired' if 'Restoration (Medic)' in acquired_items }}" title="Restoration (Medic)" /></td>
<td><img src="{{ icons['Optical Flare (Medic)'] }}" class="{{ 'acquired' if 'Optical Flare (Medic)' in acquired_items }}" title="Optical Flare (Medic)" /></td>
<td><img src="{{ icons['Optimized Logistics (Medic)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Medic)' in acquired_items }}" title="Optimized Logistics (Medic)" /></td>
<td colspan="2"></td>
<td></td>
<td><img src="{{ stimpack_hellion_url }}" class="{{ 'acquired' if 'Progressive Stimpack (Hellion)' in acquired_items }}" title="{{ stimpack_hellion_name }}" /></td>
</tr>
<tr>
<td><img src="{{ icons['Firebat'] }}" class="{{ 'acquired' if 'Firebat' in acquired_items }}" title="Firebat" /></td>
<td><img src="{{ icons['Incinerator Gauntlets (Firebat)'] }}" class="{{ 'acquired' if 'Incinerator Gauntlets (Firebat)' in acquired_items }}" title="Incinerator Gauntlets (Firebat)" /></td>
<td><img src="{{ icons['Juggernaut Plating (Firebat)'] }}" class="{{ 'acquired' if 'Juggernaut Plating (Firebat)' in acquired_items }}" title="Juggernaut Plating (Firebat)" /></td>
<td><img src="{{ stimpack_firebat_url }}" class="{{ 'acquired' if 'Progressive Stimpack (Firebat)' in acquired_items }}" title="{{ stimpack_firebat_name }}" /></td>
<td><img src="{{ icons['Optimized Logistics (Firebat)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Firebat)' in acquired_items }}" title="Optimized Logistics (Firebat)" /></td>
<td colspan="3"></td>
<td><img src="{{ icons['Vulture'] }}" class="{{ 'acquired' if 'Vulture' in acquired_items }}" title="Vulture" /></td>
<td><img src="{{ icons['Replenishable Magazine (Vulture)'] }}" class="{{ 'acquired' if 'Replenishable Magazine (Vulture)' in acquired_items }}" title="Replenishable Magazine (Vulture)" /></td>
<td><img src="{{ icons['Ion Thrusters (Vulture)'] }}" class="{{ 'acquired' if 'Ion Thrusters (Vulture)' in acquired_items }}" title="Ion Thrusters (Vulture)" /></td>
<td><img src="{{ icons['Auto Launchers (Vulture)'] }}" class="{{ 'acquired' if 'Auto Launchers (Vulture)' in acquired_items }}" title="Auto Launchers (Vulture)" /></td>
<td></td>
<td><img src="{{ icons['Cerberus Mine (Spider Mine)'] }}" class="{{ 'acquired' if 'Cerberus Mine (Spider Mine)' in acquired_items }}" title="Cerberus Mine (Spider Mine)" /></td>
<td><img src="{{ icons['High Explosive Munition (Spider Mine)'] }}" class="{{ 'acquired' if 'High Explosive Munition (Spider Mine)' in acquired_items }}" title="High Explosive Munition (Spider Mine)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Marauder'] }}" class="{{ 'acquired' if 'Marauder' in acquired_items }}" title="Marauder" /></td>
<td><img src="{{ icons['Concussive Shells (Marauder)'] }}" class="{{ 'acquired' if 'Concussive Shells (Marauder)' in acquired_items }}" title="Concussive Shells (Marauder)" /></td>
<td><img src="{{ icons['Kinetic Foam (Marauder)'] }}" class="{{ 'acquired' if 'Kinetic Foam (Marauder)' in acquired_items }}" title="Kinetic Foam (Marauder)" /></td>
<td><img src="{{ stimpack_marauder_url }}" class="{{ 'acquired' if 'Progressive Stimpack (Marauder)' in acquired_items }}" title="{{ stimpack_marauder_name }}" /></td>
<td><img src="{{ icons['Laser Targeting System (Marauder)'] }}" class="{{ 'acquired' if 'Laser Targeting System (Marauder)' in acquired_items }}" title="Laser Targeting System (Marauder)" /></td>
<td><img src="{{ icons['Magrail Munitions (Marauder)'] }}" class="{{ 'acquired' if 'Magrail Munitions (Marauder)' in acquired_items }}" title="Magrail Munitions (Marauder)" /></td>
<td><img src="{{ icons['Internal Tech Module (Marauder)'] }}" class="{{ 'acquired' if 'Internal Tech Module (Marauder)' in acquired_items }}" title="Internal Tech Module (Marauder)" /></td>
<td></td>
<td><img src="{{ icons['Goliath'] }}" class="{{ 'acquired' if 'Goliath' in acquired_items }}" title="Goliath" /></td>
<td><img src="{{ icons['Multi-Lock Weapons System (Goliath)'] }}" class="{{ 'acquired' if 'Multi-Lock Weapons System (Goliath)' in acquired_items }}" title="Multi-Lock Weapons System (Goliath)" /></td>
<td><img src="{{ icons['Ares-Class Targeting System (Goliath)'] }}" class="{{ 'acquired' if 'Ares-Class Targeting System (Goliath)' in acquired_items }}" title="Ares-Class Targeting System (Goliath)" /></td>
<td><img src="{{ icons['Jump Jets (Goliath)'] }}" class="{{ 'acquired' if 'Jump Jets (Goliath)' in acquired_items }}" title="Jump Jets (Goliath)" /></td>
<td><img src="{{ icons['Optimized Logistics (Goliath)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Goliath)' in acquired_items }}" title="Optimized Logistics (Goliath)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Reaper'] }}" class="{{ 'acquired' if 'Reaper' in acquired_items }}" title="Reaper" /></td>
<td><img src="{{ icons['U-238 Rounds (Reaper)'] }}" class="{{ 'acquired' if 'U-238 Rounds (Reaper)' in acquired_items }}" title="U-238 Rounds (Reaper)" /></td>
<td><img src="{{ icons['G-4 Clusterbomb (Reaper)'] }}" class="{{ 'acquired' if 'G-4 Clusterbomb (Reaper)' in acquired_items }}" title="G-4 Clusterbomb (Reaper)" /></td>
<td><img src="{{ stimpack_reaper_url }}" class="{{ 'acquired' if 'Progressive Stimpack (Reaper)' in acquired_items }}" title="{{ stimpack_reaper_name }}" /></td>
<td><img src="{{ icons['Laser Targeting System (Reaper)'] }}" class="{{ 'acquired' if 'Laser Targeting System (Reaper)' in acquired_items }}" title="Laser Targeting System (Reaper)" /></td>
<td><img src="{{ icons['Advanced Cloaking Field (Reaper)'] }}" class="{{ 'acquired' if 'Advanced Cloaking Field (Reaper)' in acquired_items }}" title="Advanced Cloaking Field (Reaper)" /></td>
<td><img src="{{ icons['Spider Mines (Reaper)'] }}" class="{{ 'acquired' if 'Spider Mines (Reaper)' in acquired_items }}" title="Spider Mines (Reaper)" /></td>
<td></td>
<td><img src="{{ icons['Diamondback'] }}" class="{{ 'acquired' if 'Diamondback' in acquired_items }}" title="Diamondback" /></td>
<td><img src="{{ icons['Tri-Lithium Power Cell (Diamondback)'] }}" class="{{ 'acquired' if 'Tri-Lithium Power Cell (Diamondback)' in acquired_items }}" title="Tri-Lithium Power Cell (Diamondback)" /></td>
<td><img src="{{ icons['Shaped Hull (Diamondback)'] }}" class="{{ 'acquired' if 'Shaped Hull (Diamondback)' in acquired_items }}" title="Shaped Hull (Diamondback)" /></td>
<td><img src="{{ icons['Hyperfluxor (Diamondback)'] }}" class="{{ 'acquired' if 'Hyperfluxor (Diamondback)' in acquired_items }}" title="Hyperfluxor (Diamondback)" /></td>
<td><img src="{{ icons['Burst Capacitors (Diamondback)'] }}" class="{{ 'acquired' if 'Burst Capacitors (Diamondback)' in acquired_items }}" title="Burst Capacitors (Diamondback)" /></td>
<td><img src="{{ icons['Optimized Logistics (Diamondback)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Diamondback)' in acquired_items }}" title="Optimized Logistics (Diamondback)" /></td>
</tr>
<tr>
<td></td>
<td><img src="{{ icons['Combat Drugs (Reaper)'] }}" class="{{ 'acquired' if 'Combat Drugs (Reaper)' in acquired_items }}" title="Combat Drugs (Reaper)" /></td>
<td colspan="6"></td>
<td><img src="{{ icons['Siege Tank'] }}" class="{{ 'acquired' if 'Siege Tank' in acquired_items }}" title="Siege Tank" /></td>
<td><img src="{{ icons['Maelstrom Rounds (Siege Tank)'] }}" class="{{ 'acquired' if 'Maelstrom Rounds (Siege Tank)' in acquired_items }}" title="Maelstrom Rounds (Siege Tank)" /></td>
<td><img src="{{ icons['Shaped Blast (Siege Tank)'] }}" class="{{ 'acquired' if 'Shaped Blast (Siege Tank)' in acquired_items }}" title="Shaped Blast (Siege Tank)" /></td>
<td><img src="{{ icons['Jump Jets (Siege Tank)'] }}" class="{{ 'acquired' if 'Jump Jets (Siege Tank)' in acquired_items }}" title="Jump Jets (Siege Tank)" /></td>
<td><img src="{{ icons['Spider Mines (Siege Tank)'] }}" class="{{ 'acquired' if 'Spider Mines (Siege Tank)' in acquired_items }}" title="Spider Mines (Siege Tank)" /></td>
<td><img src="{{ icons['Smart Servos (Siege Tank)'] }}" class="{{ 'acquired' if 'Smart Servos (Siege Tank)' in acquired_items }}" title="Smart Servos (Siege Tank)" /></td>
<td><img src="{{ icons['Graduating Range (Siege Tank)'] }}" class="{{ 'acquired' if 'Graduating Range (Siege Tank)' in acquired_items }}" title="Graduating Range (Siege Tank)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Ghost'] }}" class="{{ 'acquired' if 'Ghost' in acquired_items }}" title="Ghost" /></td>
<td><img src="{{ icons['Ocular Implants (Ghost)'] }}" class="{{ 'acquired' if 'Ocular Implants (Ghost)' in acquired_items }}" title="Ocular Implants (Ghost)" /></td>
<td><img src="{{ icons['Crius Suit (Ghost)'] }}" class="{{ 'acquired' if 'Crius Suit (Ghost)' in acquired_items }}" title="Crius Suit (Ghost)" /></td>
<td><img src="{{ icons['EMP Rounds (Ghost)'] }}" class="{{ 'acquired' if 'EMP Rounds (Ghost)' in acquired_items }}" title="EMP Rounds (Ghost)" /></td>
<td><img src="{{ icons['Lockdown (Ghost)'] }}" class="{{ 'acquired' if 'Lockdown (Ghost)' in acquired_items }}" title="Lockdown (Ghost)" /></td>
<td colspan="3"></td>
<td></td>
<td><img src="{{ icons['Laser Targeting System (Siege Tank)'] }}" class="{{ 'acquired' if 'Laser Targeting System (Siege Tank)' in acquired_items }}" title="Laser Targeting System (Siege Tank)" /></td>
<td><img src="{{ icons['Advanced Siege Tech (Siege Tank)'] }}" class="{{ 'acquired' if 'Advanced Siege Tech (Siege Tank)' in acquired_items }}" title="Advanced Siege Tech (Siege Tank)" /></td>
<td><img src="{{ icons['Internal Tech Module (Siege Tank)'] }}" class="{{ 'acquired' if 'Internal Tech Module (Siege Tank)' in acquired_items }}" title="Internal Tech Module (Siege Tank)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Spectre'] }}" class="{{ 'acquired' if 'Spectre' in acquired_items }}" title="Spectre" /></td>
<td><img src="{{ icons['Psionic Lash (Spectre)'] }}" class="{{ 'acquired' if 'Psionic Lash (Spectre)' in acquired_items }}" title="Psionic Lash (Spectre)" /></td>
<td><img src="{{ icons['Nyx-Class Cloaking Module (Spectre)'] }}" class="{{ 'acquired' if 'Nyx-Class Cloaking Module (Spectre)' in acquired_items }}" title="Nyx-Class Cloaking Module (Spectre)" /></td>
<td><img src="{{ icons['Impaler Rounds (Spectre)'] }}" class="{{ 'acquired' if 'Impaler Rounds (Spectre)' in acquired_items }}" title="Impaler Rounds (Spectre)" /></td>
<td colspan="4"></td>
<td><img src="{{ icons['Thor'] }}" class="{{ 'acquired' if 'Thor' in acquired_items }}" title="Thor" /></td>
<td><img src="{{ icons['330mm Barrage Cannon (Thor)'] }}" class="{{ 'acquired' if '330mm Barrage Cannon (Thor)' in acquired_items }}" title="330mm Barrage Cannon (Thor)" /></td>
<td><img src="{{ icons['Immortality Protocol (Thor)'] }}" class="{{ 'acquired' if 'Immortality Protocol (Thor)' in acquired_items }}" title="Immortality Protocol (Thor)" /></td>
<td><img src="{{ high_impact_payload_thor_url }}" class="{{ 'acquired' if 'Progressive High Impact Payload (Thor)' in acquired_items }}" title="{{ high_impact_payload_thor_name }}" /></td>
</tr>
<tr>
<td colspan="8"></td>
<td><img src="{{ icons['Predator'] }}" class="{{ 'acquired' if 'Predator' in acquired_items }}" title="Predator" /></td>
<td><img src="{{ icons['Optimized Logistics (Predator)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Predator)' in acquired_items }}" title="Optimized Logistics (Predator)" /></td>
</tr>
<tr>
<td colspan="8"></td>
<td><img src="{{ icons['Widow Mine'] }}" class="{{ 'acquired' if 'Widow Mine' in acquired_items }}" title="Widow Mine" /></td>
<td><img src="{{ icons['Drilling Claws (Widow Mine)'] }}" class="{{ 'acquired' if 'Drilling Claws (Widow Mine)' in acquired_items }}" title="Drilling Claws (Widow Mine)" /></td>
<td><img src="{{ icons['Concealment (Widow Mine)'] }}" class="{{ 'acquired' if 'Concealment (Widow Mine)' in acquired_items }}" title="Concealment (Widow Mine)" /></td>
<td><img src="{{ icons['Black Market Launchers (Widow Mine)'] }}" class="{{ 'acquired' if 'Black Market Launchers (Widow Mine)' in acquired_items }}" title="Black Market Launchers (Widow Mine)" /></td>
<td><img src="{{ icons['Executioner Missiles (Widow Mine)'] }}" class="{{ 'acquired' if 'Executioner Missiles (Widow Mine)' in acquired_items }}" title="Executioner Missiles (Widow Mine)" /></td>
</tr>
<tr>
<td colspan="8"></td>
<td><img src="{{ icons['Cyclone'] }}" class="{{ 'acquired' if 'Cyclone' in acquired_items }}" title="Cyclone" /></td>
<td><img src="{{ icons['Mag-Field Accelerators (Cyclone)'] }}" class="{{ 'acquired' if 'Mag-Field Accelerators (Cyclone)' in acquired_items }}" title="Mag-Field Accelerators (Cyclone)" /></td>
<td><img src="{{ icons['Mag-Field Launchers (Cyclone)'] }}" class="{{ 'acquired' if 'Mag-Field Launchers (Cyclone)' in acquired_items }}" title="Mag-Field Launchers (Cyclone)" /></td>
<td><img src="{{ icons['Targeting Optics (Cyclone)'] }}" class="{{ 'acquired' if 'Targeting Optics (Cyclone)' in acquired_items }}" title="Targeting Optics (Cyclone)" /></td>
<td><img src="{{ icons['Rapid Fire Launchers (Cyclone)'] }}" class="{{ 'acquired' if 'Rapid Fire Launchers (Cyclone)' in acquired_items }}" title="Rapid Fire Launchers (Cyclone)" /></td>
</tr>
<tr>
<td colspan="15" class="title">
Starships
</td>
</tr>
<tr>
<td><img src="{{ icons['Medivac'] }}" class="{{ 'acquired' if 'Medivac' in acquired_items }}" title="Medivac" /></td>
<td><img src="{{ icons['Rapid Deployment Tube (Medivac)'] }}" class="{{ 'acquired' if 'Rapid Deployment Tube (Medivac)' in acquired_items }}" title="Rapid Deployment Tube (Medivac)" /></td>
<td><img src="{{ icons['Advanced Healing AI (Medivac)'] }}" class="{{ 'acquired' if 'Advanced Healing AI (Medivac)' in acquired_items }}" title="Advanced Healing AI (Medivac)" /></td>
<td><img src="{{ icons['Expanded Hull (Medivac)'] }}" class="{{ 'acquired' if 'Expanded Hull (Medivac)' in acquired_items }}" title="Expanded Hull (Medivac)" /></td>
<td><img src="{{ icons['Afterburners (Medivac)'] }}" class="{{ 'acquired' if 'Afterburners (Medivac)' in acquired_items }}" title="Afterburners (Medivac)" /></td>
<td colspan="3"></td>
<td><img src="{{ icons['Raven'] }}" class="{{ 'acquired' if 'Raven' in acquired_items }}" title="Raven" /></td>
<td><img src="{{ icons['Bio Mechanical Repair Drone (Raven)'] }}" class="{{ 'acquired' if 'Bio Mechanical Repair Drone (Raven)' in acquired_items }}" title="Bio Mechanical Repair Drone (Raven)" /></td>
<td><img src="{{ icons['Spider Mines (Raven)'] }}" class="{{ 'acquired' if 'Spider Mines (Raven)' in acquired_items }}" title="Spider Mines (Raven)" /></td>
<td><img src="{{ icons['Railgun Turret (Raven)'] }}" class="{{ 'acquired' if 'Railgun Turret (Raven)' in acquired_items }}" title="Railgun Turret (Raven)" /></td>
<td><img src="{{ icons['Hunter-Seeker Weapon (Raven)'] }}" class="{{ 'acquired' if 'Hunter-Seeker Weapon (Raven)' in acquired_items }}" title="Hunter-Seeker Weapon (Raven)" /></td>
<td><img src="{{ icons['Interference Matrix (Raven)'] }}" class="{{ 'acquired' if 'Interference Matrix (Raven)' in acquired_items }}" title="Interference Matrix (Raven)" /></td>
<td><img src="{{ icons['Anti-Armor Missile (Raven)'] }}" class="{{ 'acquired' if 'Anti-Armor Missile (Raven)' in acquired_items }}" title="Anti-Armor Missile (Raven)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Wraith'] }}" class="{{ 'acquired' if 'Wraith' in acquired_items }}" title="Wraith" /></td>
<td><img src="{{ icons['Tomahawk Power Cells (Wraith)'] }}" class="{{ 'acquired' if 'Tomahawk Power Cells (Wraith)' in acquired_items }}" title="Tomahawk Power Cells (Wraith)" /></td>
<td><img src="{{ icons['Displacement Field (Wraith)'] }}" class="{{ 'acquired' if 'Displacement Field (Wraith)' in acquired_items }}" title="Displacement Field (Wraith)" /></td>
<td><img src="{{ icons['Advanced Laser Technology (Wraith)'] }}" class="{{ 'acquired' if 'Advanced Laser Technology (Wraith)' in acquired_items }}" title="Advanced Laser Technology (Wraith)" /></td>
<td colspan="4"></td>
<td></td>
<td><img src="{{ icons['Internal Tech Module (Raven)'] }}" class="{{ 'acquired' if 'Internal Tech Module (Raven)' in acquired_items }}" title="Internal Tech Module (Raven)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Viking'] }}" class="{{ 'acquired' if 'Viking' in acquired_items }}" title="Viking" /></td>
<td><img src="{{ icons['Ripwave Missiles (Viking)'] }}" class="{{ 'acquired' if 'Ripwave Missiles (Viking)' in acquired_items }}" title="Ripwave Missiles (Viking)" /></td>
<td><img src="{{ icons['Phobos-Class Weapons System (Viking)'] }}" class="{{ 'acquired' if 'Phobos-Class Weapons System (Viking)' in acquired_items }}" title="Phobos-Class Weapons System (Viking)" /></td>
<td><img src="{{ icons['Smart Servos (Viking)'] }}" class="{{ 'acquired' if 'Smart Servos (Viking)' in acquired_items }}" title="Smart Servos (Viking)" /></td>
<td><img src="{{ icons['Magrail Munitions (Viking)'] }}" class="{{ 'acquired' if 'Magrail Munitions (Viking)' in acquired_items }}" title="Magrail Munitions (Viking)" /></td>
<td colspan="3"></td>
<td><img src="{{ icons['Science Vessel'] }}" class="{{ 'acquired' if 'Science Vessel' in acquired_items }}" title="Science Vessel" /></td>
<td><img src="{{ icons['EMP Shockwave (Science Vessel)'] }}" class="{{ 'acquired' if 'EMP Shockwave (Science Vessel)' in acquired_items }}" title="EMP Shockwave (Science Vessel)" /></td>
<td><img src="{{ icons['Defensive Matrix (Science Vessel)'] }}" class="{{ 'acquired' if 'Defensive Matrix (Science Vessel)' in acquired_items }}" title="Defensive Matrix (Science Vessel)" /></td>
</tr>
<tr>
<td><img src="{{ icons['Banshee'] }}" class="{{ 'acquired' if 'Banshee' in acquired_items }}" title="Banshee" /></td>
<td><img src="{{ crossspectrum_dampeners_banshee_url }}" class="{{ 'acquired' if 'Progressive Cross-Spectrum Dampeners (Banshee)' in acquired_items }}" title="{{ crossspectrum_dampeners_banshee_name }}" /></td>
<td><img src="{{ icons['Shockwave Missile Battery (Banshee)'] }}" class="{{ 'acquired' if 'Shockwave Missile Battery (Banshee)' in acquired_items }}" title="Shockwave Missile Battery (Banshee)" /></td>
<td><img src="{{ icons['Hyperflight Rotors (Banshee)'] }}" class="{{ 'acquired' if 'Hyperflight Rotors (Banshee)' in acquired_items }}" title="Hyperflight Rotors (Banshee)" /></td>
<td><img src="{{ icons['Laser Targeting System (Banshee)'] }}" class="{{ 'acquired' if 'Laser Targeting System (Banshee)' in acquired_items }}" title="Laser Targeting System (Banshee)" /></td>
<td><img src="{{ icons['Internal Tech Module (Banshee)'] }}" class="{{ 'acquired' if 'Internal Tech Module (Banshee)' in acquired_items }}" title="Internal Tech Module (Banshee)" /></td>
<td colspan="2"></td>
<td><img src="{{ icons['Hercules'] }}" class="{{ 'acquired' if 'Hercules' in acquired_items }}" title="Hercules" /></td>
</tr>
<tr>
<td><img src="{{ icons['Battlecruiser'] }}" class="{{ 'acquired' if 'Battlecruiser' in acquired_items }}" title="Battlecruiser" /></td>
<td><img src="{{ icons['Missile Pods (Battlecruiser)'] }}" class="{{ 'acquired' if 'Missile Pods (Battlecruiser)' in acquired_items }}" title="Missile Pods (Battlecruiser)" /></td>
<td><img src="{{ icons['Defensive Matrix (Battlecruiser)'] }}" class="{{ 'acquired' if 'Defensive Matrix (Battlecruiser)' in acquired_items }}" title="Defensive Matrix (Battlecruiser)" /></td>
<td><img src="{{ icons['Tactical Jump (Battlecruiser)'] }}" class="{{ 'acquired' if 'Tactical Jump (Battlecruiser)' in acquired_items }}" title="Tactical Jump (Battlecruiser)" /></td>
<td><img src="{{ icons['Cloak (Battlecruiser)'] }}" class="{{ 'acquired' if 'Cloak (Battlecruiser)' in acquired_items }}" title="Cloak (Battlecruiser)" /></td>
<td><img src="{{ icons['ATX Laser Battery (Battlecruiser)'] }}" class="{{ 'acquired' if 'ATX Laser Battery (Battlecruiser)' in acquired_items }}" title="ATX Laser Battery (Battlecruiser)" /></td>
<td><img src="{{ icons['Optimized Logistics (Battlecruiser)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Battlecruiser)' in acquired_items }}" title="Optimized Logistics (Battlecruiser)" /></td>
<td></td>
<td><img src="{{ icons['Liberator'] }}" class="{{ 'acquired' if 'Liberator' in acquired_items }}" title="Liberator" /></td>
<td><img src="{{ icons['Advanced Ballistics (Liberator)'] }}" class="{{ 'acquired' if 'Advanced Ballistics (Liberator)' in acquired_items }}" title="Advanced Ballistics (Liberator)" /></td>
<td><img src="{{ icons['Raid Artillery (Liberator)'] }}" class="{{ 'acquired' if 'Raid Artillery (Liberator)' in acquired_items }}" title="Raid Artillery (Liberator)" /></td>
<td><img src="{{ icons['Cloak (Liberator)'] }}" class="{{ 'acquired' if 'Cloak (Liberator)' in acquired_items }}" title="Cloak (Liberator)" /></td>
<td><img src="{{ icons['Laser Targeting System (Liberator)'] }}" class="{{ 'acquired' if 'Laser Targeting System (Liberator)' in acquired_items }}" title="Laser Targeting System (Liberator)" /></td>
<td><img src="{{ icons['Optimized Logistics (Liberator)'] }}" class="{{ 'acquired' if 'Optimized Logistics (Liberator)' in acquired_items }}" title="Optimized Logistics (Liberator)" /></td>
</tr>
<tr>
<td></td>
<td><img src="{{ icons['Internal Tech Module (Battlecruiser)'] }}" class="{{ 'acquired' if 'Internal Tech Module (Battlecruiser)' in acquired_items }}" title="Internal Tech Module (Battlecruiser)" /></td>
<td colspan="6"></td>
<td><img src="{{ icons['Valkyrie'] }}" class="{{ 'acquired' if 'Valkyrie' in acquired_items }}" title="Valkyrie" /></td>
<td><img src="{{ icons['Enhanced Cluster Launchers (Valkyrie)'] }}" class="{{ 'acquired' if 'Enhanced Cluster Launchers (Valkyrie)' in acquired_items }}" title="Enhanced Cluster Launchers (Valkyrie)" /></td>
<td><img src="{{ icons['Shaped Hull (Valkyrie)'] }}" class="{{ 'acquired' if 'Shaped Hull (Valkyrie)' in acquired_items }}" title="Shaped Hull (Valkyrie)" /></td>
<td><img src="{{ icons['Burst Lasers (Valkyrie)'] }}" class="{{ 'acquired' if 'Burst Lasers (Valkyrie)' in acquired_items }}" title="Burst Lasers (Valkyrie)" /></td>
<td><img src="{{ icons['Afterburners (Valkyrie)'] }}" class="{{ 'acquired' if 'Afterburners (Valkyrie)' in acquired_items }}" title="Afterburners (Valkyrie)" /></td>
</tr>
<tr>
<td colspan="15" class="title">
Mercenaries
</td>
</tr>
<tr>
<td><img src="{{ icons['War Pigs'] }}" class="{{ 'acquired' if 'War Pigs' in acquired_items }}" title="War Pigs" /></td>
<td><img src="{{ icons['Devil Dogs'] }}" class="{{ 'acquired' if 'Devil Dogs' in acquired_items }}" title="Devil Dogs" /></td>
<td><img src="{{ icons['Hammer Securities'] }}" class="{{ 'acquired' if 'Hammer Securities' in acquired_items }}" title="Hammer Securities" /></td>
<td><img src="{{ icons['Spartan Company'] }}" class="{{ 'acquired' if 'Spartan Company' in acquired_items }}" title="Spartan Company" /></td>
<td><img src="{{ icons['Siege Breakers'] }}" class="{{ 'acquired' if 'Siege Breakers' in acquired_items }}" title="Siege Breakers" /></td>
<td><img src="{{ icons['Hel\'s Angel'] }}" class="{{ 'acquired' if 'Hel\'s Angel' in acquired_items }}" title="Hel's Angel" /></td>
<td><img src="{{ icons['Dusk Wings'] }}" class="{{ 'acquired' if 'Dusk Wings' in acquired_items }}" title="Dusk Wings" /></td>
<td><img src="{{ icons['Jackson\'s Revenge'] }}" class="{{ 'acquired' if 'Jackson\'s Revenge' in acquired_items }}" title="Jackson's Revenge" /></td>
</tr>
<tr>
<td colspan="15" class="title">
General Upgrades
</td>
</tr>
<tr>
<td><img src="{{ icons['Fire-Suppression System (Building)'] }}" class="{{ 'acquired' if 'Fire-Suppression System (Building)' in acquired_items }}" title="Fire-Suppression System (Building)" /></td>
<td><img src="{{ icons['Orbital Strike'] }}" class="{{ 'acquired' if 'Orbital Strike' in acquired_items }}" title="Orbital Strike" /></td>
<td><img src="{{ icons['Cellular Reactor'] }}" class="{{ 'acquired' if 'Cellular Reactor' in acquired_items }}" title="Cellular Reactor" /></td>
<td><img src="{{ regenerative_biosteel_url }}" class="{{ 'acquired' if 'Progressive Regenerative Bio-Steel' in acquired_items }}" title="Progressive Regenerative Bio-Steel{% if regenerative_biosteel_level > 0 %} (Level {{ regenerative_biosteel_level }}){% endif %}" /></td>
</tr>
<tr>
<td colspan="15" class="title">
Protoss Units
</td>
</tr>
<tr>
<td><img src="{{ icons['Zealot'] }}" class="{{ 'acquired' if 'Zealot' in acquired_items }}" title="Zealot" /></td>
<td><img src="{{ icons['Stalker'] }}" class="{{ 'acquired' if 'Stalker' in acquired_items }}" title="Stalker" /></td>
<td><img src="{{ icons['High Templar'] }}" class="{{ 'acquired' if 'High Templar' in acquired_items }}" title="High Templar" /></td>
<td><img src="{{ icons['Dark Templar'] }}" class="{{ 'acquired' if 'Dark Templar' in acquired_items }}" title="Dark Templar" /></td>
<td><img src="{{ icons['Immortal'] }}" class="{{ 'acquired' if 'Immortal' in acquired_items }}" title="Immortal" /></td>
<td><img src="{{ icons['Colossus'] }}" class="{{ 'acquired' if 'Colossus' in acquired_items }}" title="Colossus" /></td>
<td><img src="{{ icons['Phoenix'] }}" class="{{ 'acquired' if 'Phoenix' in acquired_items }}" title="Phoenix" /></td>
<td><img src="{{ icons['Void Ray'] }}" class="{{ 'acquired' if 'Void Ray' in acquired_items }}" title="Void Ray" /></td>
<td><img src="{{ icons['Carrier'] }}" class="{{ 'acquired' if 'Carrier' in acquired_items }}" title="Carrier" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_in_area %}
{% if checks_in_area[area] > 0 %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endif %}
{% endfor %}
</table>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,6 @@ cdef struct IndexEntry:
size_t count
@cython.auto_pickle(False)
cdef class LocationStore:
"""Compact store for locations and their items in a MultiServer"""
# The original implementation uses Dict[int, Dict[int, Tuple(int, int, int]]
@@ -79,6 +78,18 @@ cdef class LocationStore:
size += sizeof(self._raw_proxies[0]) * self.sender_index_size
return size
def __cinit__(self, locations_dict: Dict[int, Dict[int, Sequence[int]]]) -> None:
self._mem = None
self._keys = None
self._items = None
self._proxies = None
self._len = 0
self.entries = NULL
self.entry_count = 0
self.sender_index = NULL
self.sender_index_size = 0
self._raw_proxies = NULL
def __init__(self, locations_dict: Dict[int, Dict[int, Sequence[int]]]) -> None:
self._mem = Pool()
cdef object key
@@ -270,7 +281,6 @@ cdef class LocationStore:
entry.location not in checked])
@cython.auto_pickle(False)
@cython.internal # unsafe. disable direct import
cdef class PlayerLocationProxy:
cdef LocationStore _store

Binary file not shown.

View File

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

View File

@@ -22,10 +22,6 @@ SOFTWARE.
local SCRIPT_VERSION = 1
-- Set to log incoming requests
-- Will cause lag due to large console output
local DEBUG = false
--[[
This script expects to receive JSON and will send JSON back. A message should
be a list of 1 or more requests which will be executed in order. Each request
@@ -275,6 +271,10 @@ local base64 = require("base64")
local socket = require("socket")
local json = require("json")
-- Set to log incoming requests
-- Will cause lag due to large console output
local DEBUG = false
local SOCKET_PORT_FIRST = 43055
local SOCKET_PORT_RANGE_SIZE = 5
local SOCKET_PORT_LAST = SOCKET_PORT_FIRST + SOCKET_PORT_RANGE_SIZE
@@ -330,28 +330,18 @@ function unlock ()
client_socket:settimeout(0)
end
request_handlers = {
["PING"] = function (req)
local res = {}
function process_request (req)
local res = {}
if req["type"] == "PING" then
res["type"] = "PONG"
return res
end,
["SYSTEM"] = function (req)
local res = {}
elseif req["type"] == "SYSTEM" then
res["type"] = "SYSTEM_RESPONSE"
res["value"] = emu.getsystemid()
return res
end,
["PREFERRED_CORES"] = function (req)
local res = {}
elseif req["type"] == "PREFERRED_CORES" then
local preferred_cores = client.getconfig().PreferredCores
res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {}
res["value"]["NES"] = preferred_cores.NES
@@ -364,21 +354,14 @@ request_handlers = {
res["value"]["PCECD"] = preferred_cores.PCECD
res["value"]["SGX"] = preferred_cores.SGX
return res
end,
["HASH"] = function (req)
local res = {}
elseif req["type"] == "HASH" then
res["type"] = "HASH_RESPONSE"
res["value"] = rom_hash
return res
end,
["GUARD"] = function (req)
local res = {}
elseif req["type"] == "GUARD" then
res["type"] = "GUARD_RESPONSE"
local expected_data = base64.decode(req["expected_data"])
local actual_data = memory.read_bytes_as_array(req["address"], #expected_data, req["domain"])
local data_is_validated = true
@@ -389,83 +372,39 @@ request_handlers = {
end
end
res["type"] = "GUARD_RESPONSE"
res["value"] = data_is_validated
res["address"] = req["address"]
return res
end,
["LOCK"] = function (req)
local res = {}
elseif req["type"] == "LOCK" then
res["type"] = "LOCKED"
lock()
return res
end,
["UNLOCK"] = function (req)
local res = {}
elseif req["type"] == "UNLOCK" then
res["type"] = "UNLOCKED"
unlock()
return res
end,
["READ"] = function (req)
local res = {}
elseif req["type"] == "READ" then
res["type"] = "READ_RESPONSE"
res["value"] = base64.encode(memory.read_bytes_as_array(req["address"], req["size"], req["domain"]))
return res
end,
["WRITE"] = function (req)
local res = {}
elseif req["type"] == "WRITE" then
res["type"] = "WRITE_RESPONSE"
memory.write_bytes_as_array(req["address"], base64.decode(req["value"]), req["domain"])
return res
end,
["DISPLAY_MESSAGE"] = function (req)
local res = {}
elseif req["type"] == "DISPLAY_MESSAGE" then
res["type"] = "DISPLAY_MESSAGE_RESPONSE"
message_queue:push(req["message"])
return res
end,
["SET_MESSAGE_INTERVAL"] = function (req)
local res = {}
elseif req["type"] == "SET_MESSAGE_INTERVAL" then
res["type"] = "SET_MESSAGE_INTERVAL_RESPONSE"
message_interval = req["value"]
return res
end,
["default"] = function (req)
local res = {}
else
res["type"] = "ERROR"
res["err"] = "Unknown command: "..req["type"]
return res
end,
}
function process_request (req)
if request_handlers[req["type"]] then
return request_handlers[req["type"]](req)
else
return request_handlers["default"](req)
end
return res
end
-- Receive data from AP client and send message back

View File

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

View File

@@ -17,10 +17,10 @@
# A. This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
# You can also verify that your Archipelago options are valid at this site:
# You can also verify your Archipelago settings are valid at this site:
# https://archipelago.gg/check
# Your name in-game, limited to 16 characters.
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
# {player} will be replaced with the player's slot number.
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1.
# {number} will be replaced with the counter value of the name.

View File

@@ -28,12 +28,6 @@
# Bumper Stickers
/worlds/bumpstik/ @FelicitusNeko
# Castlevania 64
/worlds/cv64/ @LiquidCat64
# Celeste 64
/worlds/celeste64/ @PoryGone
# ChecksFinder
/worlds/checksfinder/ @jonloveslegos
@@ -73,9 +67,6 @@
# Hylics 2
/worlds/hylics2/ @TRPG0
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris
# Kingdom Hearts 2
/worlds/kh2/ @JaredWeakStrike
@@ -134,14 +125,11 @@
# Shivers
/worlds/shivers/ @GodlFire
# A Short Hike
/worlds/shorthike/ @chandler05
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace
# Starcraft 2
/worlds/sc2/ @Ziktofel
# Starcraft 2 Wings of Liberty
/worlds/sc2wol/ @Ziktofel
# Super Metroid
/worlds/sm/ @lordlou
@@ -177,7 +165,7 @@
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
# TUNIC
/worlds/tunic/ @silent-destroyer @ScipioWright
/worlds/tunic/ @silent-destroyer
# Undertale
/worlds/undertale/ @jonloveslegos
@@ -191,15 +179,9 @@
# The Witness
/worlds/witness/ @NewSoupVi @blastron
# Yoshi's Island
/worlds/yoshisisland/ @PinkSwitch
# Zillion
/worlds/zillion/ @beauxq
# Zork Grand Inquisitor
/worlds/zork_grand_inquisitor/ @nbrochu
##################################
## Disabled Unmaintained Worlds ##
##################################

View File

@@ -1,78 +1,269 @@
# Adding Games
# How do I add a game to Archipelago?
Adding a new game to Archipelago has two major parts:
This guide is going to try and be a broad summary of how you can do just that.
There are two key steps to incorporating a game into Archipelago:
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
* Archipelago Generation and Server integration plugin (hereafter referred to as "world")
- Game Modification
- Archipelago Server Integration
This document will attempt to illustrate the bare minimum requirements and expectations of both parts of a new world
integration. As game modification wildly varies by system and engine, and has no bearing on the Archipelago protocol,
it will not be detailed here.
Refer to the following documents as well:
## Client
- [network protocol.md](/docs/network%20protocol.md) for network communication between client and server.
- [world api.md](/docs/world%20api.md) for documentation on server side code and creating a world package.
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow
to behave as expected are:
# Game Modification
* Handle both secure and unsecure websocket connections
* Detect and react when a location has been "checked" by the player by sending a network packet to the server
* Receive and parse network packets when the player receives an item from the server, and reward it to the player on
demand
* **Any** of your items can be received any number of times, up to and far surpassing those that the game might
normally expect from features such as starting inventory, item link replacement, or item cheating
* Players and the admin can cheat items to the player at any time with a server command, and these items may not have
a player or location attributed to them
* Be able to change the port for saved connection info
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
privilege can be lost, requiring the room to be moved to a new port
* Reconnect if the connection is unstable and lost while playing
* Keep an index for items received in order to resync. The ItemsReceived Packets are a single list with guaranteed
order.
* Receive items that were sent to the player while they were not connected to the server
* The player being able to complete checks while offline and sending them when reconnecting is a good bonus, but not
strictly required
* Send a status update packet alerting the server that the player has completed their goal
One half of the work required to integrate a game into Archipelago is the development of the game client. This is
typically done through a modding API or other modification process, described further down.
Libraries for most modern languages and the spec for various packets can be found in the
[network protocol](/docs/network%20protocol.md) API reference document.
As an example, modifications to a game typically include (more on this later):
## World
- Hooking into when a 'location check' is completed.
- Networking with the Archipelago server.
- Optionally, UI or HUD updates to show status of the multiworld session or Archipelago server connection.
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
information necessary for creating the items and locations to be randomized, the logic for item placement, the
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the
following requirements:
In order to determine how to modify a game, refer to the following sections.
* A folder within `/worlds/` that contains an `__init__.py`
* A `World` subclass where you create your world and define all of its rules
* A unique game name
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class
definition
* The game_info doc must follow the format `{language_code}_{game_name}.md`
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
`item_name_to_id` and `location_name_to_id`, respectively.
* Create an item when `create_item` is called both by your code and externally
* An `options_dataclass` defining the options players have available to them
* A `Region` for your player with the name "Menu" to start from
* Create a non-zero number of locations and add them to your regions
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool
* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific
items, there are multiple ways to do so, but they should not be added to the multiworld itempool.
## Engine Identification
Notable caveats:
* The "Menu" region will always be considered the "start" for the player
* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the
start of the game from anywhere
* When submitting regions or items to the multiworld (multiworld.regions and multiworld.itempool respectively), use
`append`, `extend`, or `+=`. **Do not use `=`**
* Regions are simply containers for locations that share similar access rules. They do not have to map to
concrete, physical areas within your game and can be more abstract like tech trees or a questline.
This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is
critical. The first step is to look at a game's files. Let's go over what some game files might look like. Its
important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice.
Examples are provided below.
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call during
generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
regarding the API can be found in the [world api doc](/docs/world%20api.md).
Before publishing, make sure to also check out [world maintainer.md](/docs/world%20maintainer.md).
### Creepy Castle
![Creepy Castle Root Directory in Windows Explorer](/docs/img/creepy-castle-directory.png)
This is the delightful title Creepy Castle, which is a fantastic game that I highly recommend. Its also your worst-case
scenario as a modder. All thats present here is an executable file and some meta-information that Steam uses. You have
basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty
nasty disassembly and reverse engineering work, which is outside the scope of this tutorial. Lets look at some other
examples of game releases.
### Heavy Bullets
![Heavy Bullets Root Directory in Window's Explorer](/docs/img/heavy-bullets-directory.png)
Heres the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files.
“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually
with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing
information, credits, and general info about the game. You usually wont find anything too helpful here, but it never
hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important.
“steam_api.dll” is a file you can safely ignore, its just some code used to interface with Steam.
The directory “HEAVY_BULLETS_Data”, however, has some good news.
![Heavy Bullets Data Directory in Window's Explorer](/docs/img/heavy-bullets-data-directory.png)
Jackpot! It might not be obvious what youre looking at here, but I can instantly tell from this folders contents that
what we have is a game made in the Unity Engine. If you look in the sub-folders, youll seem some .dll files which
affirm our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered,
extension-less level files and the sharedassets files. If you've identified the game as a Unity game, some useful tools
and information to help you on your journey can be found at this
[Unity Game Hacking guide.](https://github.com/imadr/Unity-game-hacking)
### Stardew Valley
![Stardew Valley Root Directory in Window's Explorer](/docs/img/stardew-valley-directory.png)
This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways.
Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good
news. Many games made in C# can be modified using the same tools found in our Unity game hacking toolset; namely BepInEx
and MonoMod.
### Gato Roboto
![Gato Roboto Root Directory in Window's Explorer](/docs/img/gato-roboto-directory.png)
Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for.
The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker. For
modifying GameMaker games the [Undertale Mod Tool](https://github.com/krzys-h/UndertaleModTool) is incredibly helpful.
This isn't all you'll ever see looking at game files, but it's a good place to start.
As a general rule, the more files a game has out in plain sight, the more you'll be able to change.
This especially applies in the case of code or script files - always keep a lookout for anything you can use to your
advantage!
## Open or Leaked Source Games
As a side note, many games have either been made open source, or have had source files leaked at some point.
This can be a boon to any would-be modder, for obvious reasons. Always be sure to check - a quick internet search for
"(Game) Source Code" might not give results often, but when it does, you're going to have a much better time.
Be sure never to distribute source code for games that you decompile or find if you do not have express permission to do
so, or to redistribute any materials obtained through similar methods, as this is illegal and unethical.
## Modifying Release Versions of Games
However, for now we'll assume you haven't been so lucky, and have to work with only whats sitting in your install
directory. Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools,
but these are often not geared to the kind of work you'll be doing and may not help much.
As a general rule, any modding tool that lets you write actual code is something worth using.
### Research
The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification,
it's possible other motivated parties have concocted useful tools for your game already.
Always be sure to search the Internet for the efforts of other modders.
### Other helpful tools
Depending on the games underlying engine, there may be some tools you can use either in lieu of or in addition to
existing game tools.
#### [CheatEngine](https://cheatengine.org/)
CheatEngine is a tool with a very long and storied history.
Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as
malware (because this behavior is most commonly found in malware and rarely used by other programs).
If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level,
including binary data formats, addressing, and assembly language programming.
The tool itself is highly complex and even I have not yet charted its expanses.
However, it can also be a very powerful tool in the right hands, allowing you to query and modify gamestate without ever
modifying the actual game itself.
In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do
anything with it.
### What Modifications You Should Make to the Game
We talked about this briefly in [Game Modification](#game-modification) section.
The next step is to know what you need to make the game do now that you can modify it. Here are your key goals:
- Know when the player has checked a location, and react accordingly
- Be able to receive items from the server on the fly
- Keep an index for items received in order to resync from disconnections
- Add interface for connecting to the Archipelago server with passwords and sessions
- Add commands for manually rewarding, re-syncing, releasing, and other actions
Refer to the [Network Protocol documentation](/docs/network%20protocol.md) for how to communicate with Archipelago's
servers.
## But my Game is a console game. Can I still add it?
That depends what console?
### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc
Most games for recent generations of console platforms are inaccessible to the typical modder. It is generally advised
that you do not attempt to work with these games as they are difficult to modify and are protected by their copyright
holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console
games.
### My Game isnt that old, its for the Wii/PS2/360/etc
This is very complex, but doable.
If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it.
There exist many disassembly and debugging tools, but more recent content may have lackluster support.
### My Game is a classic for the SNES/Sega Genesis/etc
Thats a lot more feasible.
There are many good tools available for understanding and modifying games on these older consoles, and the emulation
community will have figured out the bulk of the consoles secrets.
Look for debugging tools, but be ready to learn assembly.
Old consoles usually have their own unique dialects of ASM youll need to get used to.
Also make sure theres a good way to interface with a running emulator, since thats the only way you can connect these
older consoles to the Internet.
There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a
computer, but these will require the same sort of interface software to be written in order to work properly; from your
perspective the two won't really look any different.
### My Game is an exclusive for the Super Baby Magic Dream Boy. Its this console from the Soviet Union that-
Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no.
Obscurity is your enemy there will likely be little to no emulator or modding information, and youd essentially be
working from scratch.
## How to Distribute Game Modifications
**NEVER EVER distribute anyone else's copyrighted work UNLESS THEY EXPLICITLY GIVE YOU PERMISSION TO DO SO!!!**
This is a good way to get any project you're working on sued out from under you.
The right way to distribute modified versions of a game's binaries, assuming that the licensing terms do not allow you
to copy them wholesale, is as patches.
There are many patch formats, which I'll cover in brief. The common theme is that you cant distribute anything that
wasn't made by you. Patches are files that describe how your modified file differs from the original one, thus avoiding
the issue of distributing someone elses original work.
Users who have a copy of the game just need to apply the patch, and those who dont are unable to play.
### Patches
#### IPS
IPS patches are a simple list of chunks to replace in the original to generate the output. It is not possible to encode
moving of a chunk, so they may inadvertently contain copyrighted material and should be avoided unless you know it's
fine.
#### UPS, BPS, VCDIFF (xdelta), bsdiff
Other patch formats generate the difference between two streams (delta patches) with varying complexity. This way it is
possible to insert bytes or move chunks without including any original data. Bsdiff is highly optimized and includes
compression, so this format is used by APBP.
Only a bsdiff module is integrated into AP. If the final patch requires or is based on any other patch, convert them to
bsdiff or APBP before adding it to the AP source code as "basepatch.bsdiff4" or "basepatch.apbp".
#### APBP Archipelago Binary Patch
Starting with version 4 of the APBP format, this is a ZIP file containing metadata in `archipelago.json` and additional
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
bsdiff between the original and the randomized ROM.
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
### Mod files
Games which support modding will usually just let you drag and drop the mods files into a folder somewhere.
Mod files come in many forms, but the rules about not distributing other people's content remain the same.
They can either be generic and modify the game using a seed or `slot_data` from the AP websocket, or they can be
generated per seed. If at all possible, it's generally best practice to collect your world information from `slot_data`
so that the users don't have to move files around in order to play.
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
## Archipelago Integration
In order for your game to communicate with the Archipelago server and generate the necessary randomized information,
you must create a world package in the main Archipelago repo. This section will cover the requisites and expectations
and show the basics of a world. More in depth documentation on the available API can be read in
the [world api doc.](/docs/world%20api.md)
For setting up your working environment with Archipelago refer
to [running from source](/docs/running%20from%20source.md) and the [style guide](/docs/style.md).
### Requirements
A world implementation requires a few key things from its implementation
- A folder within `worlds` that contains an `__init__.py`
- This is what defines it as a Python package and how it's able to be imported
into Archipelago's generation system. During generation time only code that is
defined within this file will be run. It's suggested to split up your information
into more files to improve readability, but all of that information can be
imported at its base level within your world.
- A `World` subclass where you create your world and define all of its rules
and the following requirements:
- Your items and locations need a `item_name_to_id` and `location_name_to_id`,
respectively, mapping.
- An `option_definitions` mapping of your game options with the format
`{name: Class}`, where `name` uses Python snake_case.
- You must define your world's `create_item` method, because this may be called
by the generator in certain circumstances
- When creating your world you submit items and regions to the Multiworld.
- These are lists of said objects which you can access at
`self.multiworld.itempool` and `self.multiworld.regions`. Best practice for
adding to these lists is with either `append` or `extend`, where `append` is a
single object and `extend` is a list.
- Do not use `=` as this will delete other worlds' items and regions.
- Regions are containers for holding your world's Locations.
- Locations are where players will "check" for items and must exist within
a region. It's also important for your world's submitted items to be the same as
its submitted locations count.
- You must always have a "Menu" Region from which the generation algorithm
uses to enter the game and access locations.
- Make sure to check out [world maintainer.md](/docs/world%20maintainer.md) before publishing.

View File

@@ -17,17 +17,7 @@ It is recommended that automated github actions are turned on in your fork to ha
You can turn them on here:
![Github actions example](./img/github-actions-example.png)
* **When reviewing PRs, please leave a message about what was done.**
We don't have full test coverage, so manual testing can help.
For code changes that could affect multiple worlds or that could have changes in unexpected code paths, manual testing
or checking if all code paths are covered by automated tests is desired. The original author may not have been able
to test all possibly affected worlds, or didn't know it would affect another world. In such cases, it is helpful to
state which games or settings were rolled, if any.
Please also tell us if you looked at code, just did functional testing, did both, or did neither.
If testing the PR depends on other PRs, please state what you merged into what for testing.
We cannot determine what "LGTM" means without additional context, so that should not be the norm.
Other than these requests, we tend to judge code on a case-by-case basis.
Other than these requests, we tend to judge code on a case by case basis.
For contribution to the website, please refer to the [WebHost README](/WebHostLib/README.md).

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -27,13 +27,8 @@ There are also a number of community-supported libraries available that implemen
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | |
| Lua | [lua-apclientpp](https://github.com/black-sliver/lua-apclientpp) | |
| Game Maker + Studio 1.x | [gm-apclientpp](https://github.com/black-sliver/gm-apclientpp) | For GM7, GM8 and GMS1.x, maybe older |
| GameMaker: Studio 2.x+ | [see Discord](https://discord.com/channels/731205301247803413/1166418532519653396) | |
## Synchronizing Items
After a client connects, it will receive all previously collected items for its associated slot in a [ReceivedItems](#ReceivedItems) packet. This will include items the client may have already processed in a previous play session.
To ensure the client is able to reject those items if it needs to, each item in the packet has an associated `index` argument. You will need to find a way to save the "last processed item index" to the player's local savegame, a local file, or something to that effect. Before connecting, you should load that "last processed item index" value and compare against it in your received items handling.
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
Even if the client detects a desync, it can still accept the items provided in this packet to prevent gameplay interruption.
@@ -330,11 +325,7 @@ Sent to server to inform it of locations that the client has checked. Used to in
| locations | list\[int\] | The ids of the locations checked by the client. May contain any number of checks, even ones sent before; duplicates do not cause issues with the Archipelago server. |
### LocationScouts
Sent to the server to retrieve the items that are on a specified list of locations. The server will respond with a [LocationInfo](#LocationInfo) packet containing the items located in the scouted locations.
Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup.
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
Sent to the server to inform it of locations the client has seen, but not checked. Useful in cases in which the item may appear in the game world, such as 'ledge items' in A Link to the Past. The server will always respond with a [LocationInfo](#LocationInfo) packet with the items located in the scouted location.
#### Arguments
| Name | Type | Notes |
@@ -348,7 +339,7 @@ Sent to the server to update on the sender's status. Examples include readiness
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| status | ClientStatus\[int\] | One of [Client States](#ClientStatus). Send as int. Follow the link for more information. |
| status | ClientStatus\[int\] | One of [Client States](#Client-States). Send as int. Follow the link for more information. |
### Say
Basic chat command which sends text to the server to be distributed to other clients.

View File

@@ -10,9 +10,10 @@ Archipelago will be abbreviated as "AP" from now on.
## Option Definitions
Option parsing in AP is done using different Option classes. For each option you would like to have in your game, you
need to create:
- A new option class, with a docstring detailing what the option does, to be exposed to the user.
- A new entry in the `options_dataclass` definition for your World.
By style and convention, the dataclass attributes should be `snake_case`.
- A new option class with a docstring detailing what the option will do to your user.
- A `display_name` to be displayed on the webhost.
- A new entry in the `option_definitions` dict for your World.
By style and convention, the internal names should be snake_case.
### Option Creation
- If the option supports having multiple sub_options, such as Choice options, these can be defined with
@@ -23,55 +24,31 @@ display as `Value1` on the webhost.
(i.e. `alias_value_1 = option_value1`) which will allow users to use either `value_1` or `value1` in their yaml
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/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.
- All options 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`.
As an example, suppose we want an option that lets the user start their game with a sword in their inventory, an option
to let the player choose the difficulty, and an option to choose how much health the final boss has. Let's create our
option classes (with a docstring), give them a `display_name`, and add them to our game's options dataclass:
As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's
create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass:
```python
# options.py
from dataclasses import dataclass
from Options import Toggle, Range, Choice, PerGameCommonOptions
from Options import Toggle, PerGameCommonOptions
class StartingSword(Toggle):
"""Adds a sword to your starting inventory."""
display_name = "Start With Sword" # this is the option name as it's displayed to the user on the webhost and in the spoiler log
class Difficulty(Choice):
"""Sets overall game difficulty."""
display_name = "Difficulty"
option_easy = 0
option_normal = 1
option_hard = 2
alias_beginner = 0 # same as easy but allows the player to use beginner as an alternative for easy in the result in their options
alias_expert = 2 # same as hard
default = 1 # default to normal
class FinalBossHP(Range):
"""Sets the HP of the final boss"""
display_name = "Final Boss HP"
range_start = 100
range_end = 10000
default = 2000
display_name = "Start With Sword"
@dataclass
class ExampleGameOptions(PerGameCommonOptions):
starting_sword: StartingSword
difficulty: Difficulty
final_boss_health: FinalBossHP
```
To then submit this to the multiworld, we add it to our world's `__init__.py`:
This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it
to our world's `__init__.py`:
```python
from worlds.AutoWorld import World
@@ -203,7 +180,7 @@ For example:
```python
range_start = 1
range_end = 99
special_range_names = {
special_range_names: {
"normal": 20,
"extreme": 99,
"unlimited": -1,

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