diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 000000000000..17a60ad125f7
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,5 @@
+[report]
+exclude_lines =
+ pragma: no cover
+ if TYPE_CHECKING:
+ if typing.TYPE_CHECKING:
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 000000000000..2743104f410e
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,31 @@
+'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'
diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml
index ba2660809aaa..c9995fa2d043 100644
--- a/.github/workflows/analyze-modified-files.yml
+++ b/.github/workflows/analyze-modified-files.yml
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- 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@v4
+ - uses: actions/setup-python@v5
if: env.diff != ''
with:
python-version: 3.8
@@ -71,7 +71,7 @@ jobs:
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
- flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
+ flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a40084b9ab72..80aaf70c215e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,11 +8,13 @@ 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:
@@ -25,9 +27,9 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Install python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: '3.8'
- name: Download run-time dependencies
@@ -46,25 +48,42 @@ 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@v3
+ uses: actions/upload-artifact@v4
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@v3
+ - uses: actions/checkout@v4
- 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@v4
+ uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build-time dependencies
@@ -100,13 +119,13 @@ jobs:
source venv/bin/activate
python setup.py build_exe --yes
- name: Store AppImage
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 6aeb477a22d7..b0cfe35d2bc5 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -43,7 +43,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml
new file mode 100644
index 000000000000..bc0f6999b6a8
--- /dev/null
+++ b/.github/workflows/label-pull-requests.yml
@@ -0,0 +1,46 @@
+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 }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cc68a88b7651..2d7f1253b760 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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@b7e450da2a4b4cb4bfbae528f788167786cfcedf
+ uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
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@v3
+ - uses: actions/checkout@v4
- 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@v4
+ uses: actions/setup-python@v5
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@b7e450da2a4b4cb4bfbae528f788167786cfcedf
+ uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml
new file mode 100644
index 000000000000..5234d862b4d3
--- /dev/null
+++ b/.github/workflows/scan-build.yml
@@ -0,0 +1,65 @@
+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
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index 1a76a7f47160..b2530bd06c7d 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -46,9 +46,9 @@ jobs:
os: macos-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
diff --git a/.run/Archipelago Unittests.run.xml b/.run/Archipelago Unittests.run.xml
new file mode 100644
index 000000000000..24fea0f73fec
--- /dev/null
+++ b/.run/Archipelago Unittests.run.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BaseClasses.py b/BaseClasses.py
index 855e69c5d48c..24dc074b63d4 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -18,11 +18,14 @@
import Options
import Utils
+if typing.TYPE_CHECKING:
+ from worlds import AutoWorld
+
class Group(TypedDict, total=False):
name: str
game: str
- world: auto_world
+ world: "AutoWorld.World"
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
@@ -55,7 +58,7 @@ class MultiWorld():
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
- worlds: Dict[int, auto_world]
+ worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
regions: RegionManager
itempool: List[Item]
@@ -82,7 +85,7 @@ class MultiWorld():
game: Dict[int, str]
random: random.Random
- per_slot_randoms: Dict[int, random.Random]
+ per_slot_randoms: Utils.DeprecateDict[int, random.Random]
"""Deprecated. Please use `self.random` instead."""
class AttributeProxy():
@@ -107,10 +110,14 @@ def __iadd__(self, other: Iterable[Region]):
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):
@@ -156,11 +163,11 @@ def __init__(self, players: int):
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', 'dungeonssimple'])
+ lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
self.fix_palaceofdarkness_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
+ lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
self.fix_trock_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
+ lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
for player in range(1, players + 1):
def set_player_attr(attr, val):
@@ -210,7 +217,8 @@ def set_player_attr(attr, val):
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
- self.per_slot_randoms = {}
+ 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.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
@@ -219,6 +227,8 @@ def get_all_ids(self) -> Tuple[int, ...]:
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
@@ -242,17 +252,18 @@ def get_player_groups(self, player) -> Set[int]:
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
+ 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:
@@ -264,12 +275,13 @@ def set_options(self, args: Namespace) -> None:
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:
@@ -572,9 +584,10 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None):
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["minimal"]:
- return False
- return True
+ if location.player in players["locations"] or (location.item and location.item.player not in
+ players["minimal"]):
+ return True
+ return False
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
@@ -703,14 +716,23 @@ def can_reach(self,
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
- spot = self.multiworld.get_location(spot, player)
+ return self.can_reach_location(spot, player)
elif resolution_hint == 'Entrance':
- spot = self.multiworld.get_entrance(spot, player)
+ return self.can_reach_entrance(spot, player)
else:
# default to Region
- spot = self.multiworld.get_region(spot, player)
+ return self.can_reach_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()
@@ -823,8 +845,8 @@ def __repr__(self):
return self.__str__()
def __str__(self):
- 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})'
+ 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})'
class Region:
@@ -867,6 +889,8 @@ def __delitem__(self, index: int) -> None:
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
@@ -877,6 +901,8 @@ def __delitem__(self, index: int) -> None:
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
@@ -1006,7 +1032,7 @@ class Location:
locked: bool = False
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
- always_allow = staticmethod(lambda item, state: False)
+ always_allow = staticmethod(lambda state, item: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None
@@ -1040,8 +1066,8 @@ def __repr__(self):
return self.__str__()
def __str__(self):
- 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})'
+ 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})'
def __hash__(self):
return hash((self.name, self.player))
@@ -1056,9 +1082,6 @@ def native_item(self) -> bool:
@property
def hint_text(self) -> str:
- hint_text = getattr(self, "_hint_text", None)
- if hint_text:
- return hint_text
return "at " + self.name.replace("_", " ").replace("-", " ")
@@ -1178,7 +1201,7 @@ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
def create_playthrough(self, create_paths: bool = True) -> None:
- """Destructive to the world while it is run, damage gets repaired afterwards."""
+ """Destructive to the multiworld while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
@@ -1265,12 +1288,12 @@ def create_playthrough(self, create_paths: bool = True) -> None:
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}')
@@ -1329,6 +1352,8 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
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)
@@ -1452,8 +1477,3 @@ 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
diff --git a/CommonClient.py b/CommonClient.py
index c4d80f341611..b6f8e43b181b 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -20,8 +20,8 @@
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
-from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
- ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
+from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
+ RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -72,9 +72,16 @@ def _cmd_disconnect(self) -> bool:
def _cmd_received(self) -> bool:
"""List all received items"""
- self.output(f'{len(self.ctx.items_received)} received items:')
+ item: NetworkItem
+ self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
for index, item in enumerate(self.ctx.items_received, 1):
- self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
+ 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"})
return True
def _cmd_missing(self, filter_text = "") -> bool:
@@ -115,6 +122,15 @@ def _cmd_items(self):
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:
@@ -124,6 +140,15 @@ def _cmd_locations(self):
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
@@ -460,7 +485,7 @@ async def prepare_data_package(self, relevant_games: typing.Set[str],
else:
self.update_game(cached_game)
if needed_updates:
- await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
+ await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict):
for item_name, item_id in game_package["item_name_to_id"].items():
@@ -477,6 +502,7 @@ def consume_network_data_package(self, data_package: dict):
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
+ logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
@@ -727,14 +753,15 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
- logger.info("Got new ID/Name DataPackage")
ctx.consume_network_data_package(args['data'])
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. '
@@ -941,4 +968,5 @@ async def main(args):
if __name__ == '__main__':
+ logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
run_as_textclient()
diff --git a/Fill.py b/Fill.py
index 525d27d3388e..2d6257eae30a 100644
--- a/Fill.py
+++ b/Fill.py
@@ -27,12 +27,12 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
return new_state
-def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
+def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
"""
- :param world: Multiworld to be filled.
+ :param multiworld: 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(world: MultiWorld, base_state: CollectionState, locations:
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items)
- has_beaten_game = world.has_beaten_game(maximum_exploration_state)
+ has_beaten_game = multiworld.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(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
- if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
- perform_access_check = not world.has_beaten_game(maximum_exploration_state,
+ if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
+ perform_access_check = not multiworld.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(world: MultiWorld, base_state: CollectionState, locations:
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
- world.get_reachable_locations(prev_state))
+ multiworld.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
- world.get_reachable_locations(swap_state))
+ multiworld.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(world: MultiWorld, base_state: CollectionState, locations:
else:
unplaced_items.append(item_to_place)
continue
- world.push_item(spot_to_fill, item_to_place, False)
+ multiworld.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(world: MultiWorld, base_state: CollectionState, locations:
# validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
for placement in placements:
- if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
+ if multiworld.worlds[placement.item.player].options.accessibility != "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(world: MultiWorld, base_state: CollectionState, locations:
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
- fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
+ fill_restrictive(multiworld, 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,7 +196,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
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 world.can_beat_game():
+ if multiworld.can_beat_game():
logging.warning(
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
else:
@@ -206,9 +206,10 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
item_pool.extend(unplaced_items)
-def remaining_fill(world: MultiWorld,
+def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
- itempool: typing.List[Item]) -> None:
+ itempool: typing.List[Item],
+ name: str = "Remaining") -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
@@ -261,14 +262,14 @@ def remaining_fill(world: MultiWorld,
unplaced_items.append(item_to_place)
continue
- world.push_item(spot_to_fill, item_to_place, False)
+ multiworld.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
- _log_fill_progress("Remaining", placed, total)
+ _log_fill_progress(name, placed, total)
if total > 1000:
- _log_fill_progress("Remaining", placed, total)
+ _log_fill_progress(name, placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
@@ -278,19 +279,19 @@ def remaining_fill(world: MultiWorld,
itempool.extend(unplaced_items)
-def fast_fill(world: MultiWorld,
+def fast_fill(multiworld: 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):
- world.push_item(location, item, False)
+ multiworld.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
-def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
+def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
- 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
+ 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
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
@@ -304,36 +305,36 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
- fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
+ fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
-def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
+def inaccessible_location_rules(multiworld: 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 world.worlds[item.player].options.accessibility != 'minimal')
+ return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
-def distribute_early_items(world: MultiWorld,
+def distribute_early_items(multiworld: 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 world.player_ids:
- items = itertools.chain(world.early_items[player], world.local_early_items[player])
+ for player in multiworld.player_ids:
+ items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
for item in items:
- early_items_count[item, player] = [world.early_items[player].get(item, 0),
- world.local_early_items[player].get(item, 0)]
+ early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
+ multiworld.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 = world.state.copy()
- base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
+ base_state = multiworld.state.copy()
+ base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -345,8 +346,8 @@ def distribute_early_items(world: 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 world.player_ids}
- early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
+ 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}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
@@ -370,28 +371,28 @@ def distribute_early_items(world: 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 world.player_ids:
+ for player in multiworld.player_ids:
player_local = early_local_rest_items[player]
- fill_restrictive(world, base_state,
+ fill_restrictive(multiworld, 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(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
+ fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
early_locations += early_priority_locations
- for player in world.player_ids:
+ for player in multiworld.player_ids:
player_local = early_local_prog_items[player]
- fill_restrictive(world, base_state,
+ fill_restrictive(multiworld, 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(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
+ fill_restrictive(multiworld, 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:
@@ -400,18 +401,18 @@ def distribute_early_items(world: MultiWorld,
itempool += unplaced_early_items
fill_locations.extend(early_locations)
- world.random.shuffle(fill_locations)
+ multiworld.random.shuffle(fill_locations)
return fill_locations, itempool
-def distribute_items_restrictive(world: MultiWorld) -> None:
- fill_locations = sorted(world.get_unfilled_locations())
- world.random.shuffle(fill_locations)
+def distribute_items_restrictive(multiworld: MultiWorld) -> None:
+ fill_locations = sorted(multiworld.get_unfilled_locations())
+ multiworld.random.shuffle(fill_locations)
# get items to distribute
- itempool = sorted(world.itempool)
- world.random.shuffle(itempool)
+ itempool = sorted(multiworld.itempool)
+ multiworld.random.shuffle(itempool)
- fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
+ fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
@@ -425,7 +426,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
else:
filleritempool.append(item)
- call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
+ call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
@@ -446,34 +447,34 @@ def mark_for_locking(location: Location):
if prioritylocations:
# "priority fill"
- fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
+ fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
name="Priority")
- accessibility_corrections(world, world.state, prioritylocations, progitempool)
+ accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
- fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
+ fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
- accessibility_corrections(world, world.state, defaultlocations)
+ accessibility_corrections(multiworld, multiworld.state, defaultlocations)
for location in lock_later:
if location.item:
location.locked = True
del mark_for_locking, lock_later
- inaccessible_location_rules(world, world.state, defaultlocations)
+ inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
- remaining_fill(world, excludedlocations, filleritempool)
+ remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = filleritempool + usefulitempool
- remaining_fill(world, defaultlocations, restitempool)
+ remaining_fill(multiworld, defaultlocations, restitempool)
unplaced = restitempool
unfilled = defaultlocations
@@ -481,40 +482,40 @@ def mark_for_locking(location: Location):
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 world.get_locations() if location.item)
- locations_counter = Counter(location.player for location in world.get_locations())
+ 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())
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})')
-def flood_items(world: MultiWorld) -> None:
+def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute
- world.random.shuffle(world.itempool)
- itempool = world.itempool
+ multiworld.random.shuffle(multiworld.itempool)
+ itempool = multiworld.itempool
progress_done = False
# sweep once to pick up preplaced items
- world.state.sweep_for_events()
+ multiworld.state.sweep_for_events()
- # fill world from top of itempool while we can
+ # fill multiworld from top of itempool while we can
while not progress_done:
- location_list = world.get_unfilled_locations()
- world.random.shuffle(location_list)
+ location_list = multiworld.get_unfilled_locations()
+ multiworld.random.shuffle(location_list)
spot_to_fill = None
for location in location_list:
- if location.can_fill(world.state, itempool[0]):
+ if location.can_fill(multiworld.state, itempool[0]):
spot_to_fill = location
break
if spot_to_fill:
item = itempool.pop(0)
- world.push_item(spot_to_fill, item, True)
+ multiworld.push_item(spot_to_fill, item, True)
continue
# ran out of spots, check if we need to step in and correct things
- if len(world.get_reachable_locations()) == len(world.get_locations()):
+ if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
progress_done = True
continue
@@ -524,7 +525,7 @@ def flood_items(world: MultiWorld) -> None:
for item in itempool:
if item.advancement:
candidate_item_to_place = item
- if world.unlocks_new_location(item):
+ if multiworld.unlocks_new_location(item):
item_to_place = item
break
@@ -537,15 +538,15 @@ def flood_items(world: MultiWorld) -> None:
raise FillError('No more progress items left to place.')
# find item to replace with progress item
- location_list = world.get_reachable_locations()
- world.random.shuffle(location_list)
+ location_list = multiworld.get_reachable_locations()
+ multiworld.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)
- world.push_item(location, item_to_place, True)
+ multiworld.push_item(location, item_to_place, True)
itempool.remove(item_to_place)
break
@@ -755,7 +756,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(world: MultiWorld) -> None:
+def distribute_planned(multiworld: 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}')
@@ -768,24 +769,24 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
else:
warn(warning, force)
- swept_state = world.state.copy()
+ swept_state = multiworld.state.copy()
swept_state.sweep_for_events()
- reachable = frozenset(world.get_reachable_locations(swept_state))
+ reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
- for loc in world.get_unfilled_locations():
+ for loc in multiworld.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 = world.world_name_lookup
+ world_name_lookup = multiworld.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(world.player_ids)
+ player_ids = set(multiworld.player_ids)
for player in player_ids:
- for block in world.plando_items[player]:
+ for block in multiworld.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
@@ -799,12 +800,12 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
else:
target_world = block['world']
- if target_world is False or world.players == 1: # target own world
+ if target_world is False or multiworld.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
- worlds = set(world.player_ids) - {player}
+ worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
- worlds = set(world.player_ids)
+ worlds = set(multiworld.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
@@ -814,9 +815,9 @@ def failed(warning: str, force: typing.Union[bool, str]) -> 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, world.players + 1):
+ if target_world not in range(1, multiworld.players + 1):
failed(
- f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
+ f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block['force'])
continue
worlds = {target_world}
@@ -844,7 +845,7 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
- value = world.itempool.count(world.worlds[player].create_item(key))
+ value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
@@ -894,17 +895,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
- block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
+ block['count']['target'] = multiworld.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
- world.random.shuffle(plando_blocks)
+ multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
- else len(world.get_unfilled_locations(player)) - block['count']['target']))
+ else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement['player']
@@ -915,19 +916,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
- candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
- world.random.shuffle(candidates)
- world.random.shuffle(items)
+ candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
+ multiworld.random.shuffle(candidates)
+ multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
for item_name in items:
- item = world.worlds[player].create_item(item_name)
+ item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
- if location.can_fill(world.state, item, False):
+ if location.can_fill(multiworld.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
@@ -945,21 +946,21 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
if count < placement['count']['min']:
m = placement['count']['min']
failed(
- f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
+ f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
- world.push_item(location, item, collect=False)
+ multiworld.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:
- world.itempool.remove(item)
+ multiworld.itempool.remove(item)
except ValueError:
warn(
- f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
+ f"Could not remove {item} from pool for {multiworld.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} ({world.player_name[player]})") from e
+ f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
diff --git a/Generate.py b/Generate.py
index e19a7a973f23..56979334b547 100644
--- a/Generate.py
+++ b/Generate.py
@@ -302,7 +302,9 @@ def handle_name(name: str, player: int, name_counter: Counter):
NUMBER=(number if number > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
- new_name = new_name.strip()[:16]
+ # 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()
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
@@ -315,33 +317,35 @@ 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, type: str, name: str) -> dict:
+def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
- new_options = set(new_weights) - set(weights)
- weights.update(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)
if new_options:
for new_option in new_options:
- logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
+ logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
@@ -357,15 +361,6 @@ 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}.")
@@ -473,6 +468,10 @@ 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]
@@ -485,120 +484,23 @@ 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 == "Minecraft" or ret.game == "Ocarina of Time":
- # bad hardcoded behavior to make this work for now
- ret.plando_connections = []
- 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":
+ if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
+ if PlandoOptions.connections in plando_options:
+ 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")
+ ))
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:
@@ -612,17 +514,6 @@ 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:
diff --git a/Launcher.py b/Launcher.py
index 9e184bf1088d..890957958391 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -161,7 +161,7 @@ def launch(exe, in_terminal=False):
def run_gui():
- from kvui import App, ContainerLayout, GridLayout, Button, Label
+ from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
@@ -185,11 +185,16 @@ def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
- 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):
+ 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)
+
+ def build_button(component: Component) -> Widget:
"""
Builds a button widget for a given component.
@@ -200,31 +205,26 @@ def build_button(component: Component):
None. The button is added to the parent grid layout.
"""
- button = Button(text=component.display_name)
+ button = Button(text=component.display_name, size_hint_y=None, height=40)
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()
+ box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)
- button_layout.add_widget(box_layout)
- else:
- button_layout.add_widget(button)
+ return box_layout
+ return 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:
- build_button(tool[1])
- else:
- button_layout.add_widget(Label())
+ tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
- build_button(client[1])
- else:
- button_layout.add_widget(Label())
+ client_layout.layout.add_widget(build_button(client[1]))
return self.container
diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py
index f3fc9d2cdb72..a51645feac92 100644
--- a/LinksAwakeningClient.py
+++ b/LinksAwakeningClient.py
@@ -348,7 +348,8 @@ async def wait_for_retroarch_connection(self):
await asyncio.sleep(1.0)
continue
self.stop_bizhawk_spam = False
- logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
+ logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
+ f"running {rom_name.decode('ascii', errors='replace')}")
return
except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)
diff --git a/Main.py b/Main.py
index 8dac8f7d20eb..f1d2f63692d6 100644
--- a/Main.py
+++ b/Main.py
@@ -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 world
- world = MultiWorld(args.multi)
+ # initialize the multiworld
+ multiworld = MultiWorld(args.multi)
logger = logging.getLogger()
- world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
- world.plando_options = args.plando_options
-
- 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.
-
- world.set_options(args)
- world.set_item_links()
- world.state = CollectionState(world)
- logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
+ multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
+ multiworld.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.
+
+ multiworld.set_options(args)
+ multiworld.set_item_links()
+ multiworld.state = CollectionState(multiworld)
+ logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.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,87 +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(world, "assert_generate")
+ AutoWorld.call_stage(multiworld, "assert_generate")
- AutoWorld.call_all(world, "generate_early")
+ AutoWorld.call_all(multiworld, "generate_early")
logger.info('')
- for player in world.player_ids:
- for item_name, count in world.worlds[player].options.start_inventory.value.items():
+ for player in multiworld.player_ids:
+ for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
for _ in range(count):
- world.push_precollected(world.create_item(item_name, player))
+ multiworld.push_precollected(multiworld.create_item(item_name, player))
- for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
+ for item_name, count in getattr(multiworld.worlds[player].options,
+ "start_inventory_from_pool",
+ StartInventoryPool({})).value.items():
for _ in range(count):
- world.push_precollected(world.create_item(item_name, player))
+ multiworld.push_precollected(multiworld.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
- early = world.early_items[player].get(item_name, 0)
+ early = multiworld.early_items[player].get(item_name, 0)
if early:
- world.early_items[player][item_name] = max(0, early-count)
+ multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
- local_early = world.early_local_items[player].get(item_name, 0)
+ local_early = multiworld.early_local_items[player].get(item_name, 0)
if local_early:
- world.early_items[player][item_name] = max(0, local_early - remaining_count)
+ multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
- logger.info('Creating World.')
- AutoWorld.call_all(world, "create_regions")
+ logger.info('Creating MultiWorld.')
+ AutoWorld.call_all(multiworld, "create_regions")
logger.info('Creating Items.')
- AutoWorld.call_all(world, "create_items")
+ AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.')
- for player in world.player_ids:
+ for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
- 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])
+ 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])
- AutoWorld.call_all(world, "set_rules")
+ AutoWorld.call_all(multiworld, "set_rules")
- 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:
+ 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:
try:
- location = world.get_location(location_name, player)
+ location = multiworld.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 world.worlds[player].location_name_to_id:
+ if location_name not in multiworld.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 world.players > 1:
- locality_rules(world)
+ if multiworld.players > 1:
+ locality_rules(multiworld)
else:
- world.worlds[1].options.non_local_items.value = set()
- world.worlds[1].options.local_items.value = set()
+ multiworld.worlds[1].options.non_local_items.value = set()
+ multiworld.worlds[1].options.local_items.value = set()
- AutoWorld.call_all(world, "generate_basic")
+ AutoWorld.call_all(multiworld, "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(world.start_inventory_from_pool[player].value for player in world.player_ids):
+ if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
- player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
+ player: getattr(multiworld.worlds[player].options,
+ "start_inventory_from_pool",
+ StartInventoryPool({})).value.copy()
+ for player in multiworld.player_ids
+ }
for player, items in depletion_pool.items():
- player_world: AutoWorld.World = world.worlds[player]
+ player_world: AutoWorld.World = multiworld.worlds[player]
for count in items.values():
for _ in range(count):
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
- for i, item in enumerate(world.itempool):
+ for i, item in enumerate(multiworld.itempool):
if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
- new_items.extend(world.itempool[i+1:])
+ new_items.extend(multiworld.itempool[i+1:])
break
else:
new_items.append(item)
@@ -193,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"{world.get_player_name(player)}"
+ raise Exception(f"{multiworld.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
- assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
- world.itempool[:] = new_items
+ assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
+ multiworld.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
- for group_id, group in world.groups.items():
+ for group_id, group in multiworld.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 world.itempool:
+ for item in multiworld.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification
@@ -240,13 +246,13 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
- region = Region("Menu", group_id, world, "ItemLink")
- world.regions.append(region)
+ region = Region("Menu", group_id, multiworld, "ItemLink")
+ multiworld.regions.append(region)
locations = region.locations
- for item in world.itempool:
+ for item in multiworld.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
- loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
+ loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.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_)
@@ -257,10 +263,10 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
else:
new_itempool.append(item)
- itemcount = len(world.itempool)
- world.itempool = new_itempool
+ itemcount = len(multiworld.itempool)
+ multiworld.itempool = new_itempool
- while itemcount > len(world.itempool):
+ while itemcount > len(multiworld.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
@@ -268,64 +274,64 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
else:
item_player = player
if group["replacement_items"][player]:
- items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
+ items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
group["replacement_items"][player]))
else:
- 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)])
+ 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)])
- if any(world.item_links.values()):
- world._all_state = None
+ if any(multiworld.item_links.values()):
+ multiworld._all_state = None
logger.info("Running Item Plando.")
- distribute_planned(world)
+ distribute_planned(multiworld)
logger.info('Running Pre Main Fill.')
- AutoWorld.call_all(world, "pre_fill")
+ AutoWorld.call_all(multiworld, "pre_fill")
- logger.info(f'Filling the world with {len(world.itempool)} items.')
+ logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
- if world.algorithm == 'flood':
- flood_items(world) # different algo, biased towards early game progress items
- elif world.algorithm == 'balanced':
- distribute_items_restrictive(world)
+ if multiworld.algorithm == 'flood':
+ flood_items(multiworld) # different algo, biased towards early game progress items
+ elif multiworld.algorithm == 'balanced':
+ distribute_items_restrictive(multiworld)
- AutoWorld.call_all(world, 'post_fill')
+ AutoWorld.call_all(multiworld, 'post_fill')
- if world.players > 1 and not args.skip_prog_balancing:
- balance_multiworld_progression(world)
+ if multiworld.players > 1 and not args.skip_prog_balancing:
+ balance_multiworld_progression(multiworld)
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
- world.random.passthrough = False
+ multiworld.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
- return world
+ return multiworld
logger.info(f'Beginning output...')
- outfilebase = 'AP_' + world.seed_name
+ outfilebase = 'AP_' + multiworld.seed_name
output = tempfile.TemporaryDirectory()
with output as temp_dir:
- output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__
- is not world.worlds[player].generate_output.__code__]
+ output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
+ is not multiworld.worlds[player].generate_output.__code__]
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
- check_accessibility_task = pool.submit(world.fulfills_accessibility)
+ check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
- output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
+ output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "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, world, "generate_output", player, temp_dir))
+ pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
- AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
+ AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata():
import NetUtils
@@ -334,38 +340,38 @@ def write_multidata():
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
- names = [[name for player, name in sorted(world.player_name.items())]]
- for slot in world.player_ids:
- player_world: AutoWorld.World = world.worlds[slot]
+ names = [[name for player, name in sorted(multiworld.player_name.items())]]
+ for slot in multiworld.player_ids:
+ player_world: AutoWorld.World = multiworld.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version
- 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],
+ 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],
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 world.precollected_items.items()}
- precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
+ 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 slot in world.player_ids:
- slot_data[slot] = world.worlds[slot].fill_slot_data()
+ for slot in multiworld.player_ids:
+ slot_data[slot] = multiworld.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 world.groups:
+ if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint)
else:
- for player in world.groups[location.item.player]["players"]:
+ for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
- locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
- for location in world.get_filled_locations():
+ locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
+ for location in multiworld.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: " \
@@ -375,18 +381,18 @@ def precollect_hint(location):
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 world.worlds[location.player].options.start_location_hints:
+ if location.name in multiworld.worlds[location.player].options.start_location_hints:
precollect_hint(location)
- elif location.item.name in world.worlds[location.item.player].options.start_hints:
+ elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
precollect_hint(location)
- elif any([location.item.name in world.worlds[player].options.start_hints
- for player in world.groups.get(location.item.player, {}).get("players", [])]):
+ elif any([location.item.name in multiworld.worlds[player].options.start_hints
+ for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
- for game_world in world.worlds.values()
+ for game_world in multiworld.worlds.values()
}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
@@ -394,7 +400,7 @@ def precollect_hint(location):
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
- "connect_names": {name: (0, player) for player, name in world.player_name.items()},
+ "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
@@ -404,10 +410,10 @@ def precollect_hint(location):
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
- "seed_name": world.seed_name,
+ "seed_name": multiworld.seed_name,
"datapackage": data_package,
}
- AutoWorld.call_all(world, "modify_multidata", multidata)
+ AutoWorld.call_all(multiworld, "modify_multidata", multidata)
multidata = zlib.compress(pickle.dumps(multidata), 9)
@@ -417,7 +423,7 @@ def precollect_hint(location):
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
- if not world.can_beat_game():
+ if not multiworld.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")
@@ -430,12 +436,12 @@ def precollect_hint(location):
if args.spoiler > 1:
logger.info('Calculating playthrough.')
- world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
+ multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler:
- world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
+ multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
- zipfilename = output_path(f"AP_{world.seed_name}.zip")
+ zipfilename = output_path(f"AP_{multiworld.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:
@@ -443,4 +449,4 @@ def precollect_hint(location):
zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
- return world
+ return multiworld
diff --git a/ModuleUpdate.py b/ModuleUpdate.py
index c33e894e8b5f..c3dc8c8a87b2 100644
--- a/ModuleUpdate.py
+++ b/ModuleUpdate.py
@@ -4,14 +4,29 @@
import multiprocessing
import warnings
-local_dir = os.path.dirname(__file__)
-requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
-update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
+_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
+update_ran = _skip_update
+
+
+class RequirementsSet(set):
+ def add(self, e):
+ global update_ran
+ update_ran &= _skip_update
+ super().add(e)
+
+ def update(self, *s):
+ global update_ran
+ update_ran &= _skip_update
+ super().update(*s)
+
+
+local_dir = os.path.dirname(__file__)
+requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
diff --git a/MultiServer.py b/MultiServer.py
index 9d2e9b564e75..395577b663c5 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -656,7 +656,8 @@ def get_aliased_name(self, team: int, slot: int):
else:
return self.player_names[team, slot]
- def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
+ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
+ recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -685,12 +686,13 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
- 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))
+ 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))
# "events"
@@ -705,15 +707,18 @@ def on_goal_achieved(self, client: Client):
self.save() # save goal completion flag
def on_new_hint(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.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]}])
+
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
@@ -973,7 +978,10 @@ 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()
@@ -1050,17 +1058,19 @@ 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, " \
- f"did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
+ 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)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
- return picks[0][0], False, f"Too many close matches, did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
+ return picks[0][0], False, f"Too many close matches for '{input_text}', " \
+ f"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"Did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
+ 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)"
class CommandMeta(type):
@@ -1429,9 +1439,13 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
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))
+ self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,))
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():
@@ -1958,7 +1972,7 @@ def _cmd_allow_release(self, player_name: str) -> bool:
@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
@@ -2210,25 +2224,24 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
+
+ def inactivity_shutdown():
+ ctx.server.ws_server.close()
+ ctx.exit_event.set()
+ if to_cancel:
+ for task in to_cancel:
+ task.cancel()
+ logging.info("Shutting down due to inactivity.")
+
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
- ctx.server.ws_server.close()
- ctx.exit_event.set()
- if to_cancel:
- for task in to_cancel:
- task.cancel()
- logging.info("Shutting down due to inactivity.")
+ inactivity_shutdown()
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
- ctx.server.ws_server.close()
- ctx.exit_event.set()
- if to_cancel:
- for task in to_cancel:
- task.cancel()
- logging.info("Shutting down due to inactivity.")
+ inactivity_shutdown()
else:
await asyncio.sleep(seconds)
diff --git a/NetUtils.py b/NetUtils.py
index a2db6a2ac5c4..8fc3929e60b4 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -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, item_id: int, player: int = 0, **kwargs) -> None:
- parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_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})
class Hint(typing.NamedTuple):
diff --git a/OoTAdjuster.py b/OoTAdjuster.py
index 38ebe62e2ae1..9519b191e704 100644
--- a/OoTAdjuster.py
+++ b/OoTAdjuster.py
@@ -195,10 +195,10 @@ def set_icon(window):
window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args):
- # Create a fake world and OOTWorld to use as a base
- world = MultiWorld(1)
- world.per_slot_randoms = {1: random}
- ootworld = OOTWorld(world, 1)
+ # Create a fake multiworld and OOTWorld to use as a base
+ multiworld = MultiWorld(1)
+ multiworld.per_slot_randoms = {1: random}
+ ootworld = OOTWorld(multiworld, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
result = getattr(args, name, None)
diff --git a/Options.py b/Options.py
index 2e3927aae3f3..e1ae33914332 100644
--- a/Options.py
+++ b/Options.py
@@ -1,19 +1,18 @@
from __future__ import annotations
import abc
-import logging
-from copy import deepcopy
-from dataclasses import dataclass
import functools
+import logging
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
+from Utils import get_fuzzy_results, is_iterable_except_str
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
@@ -42,6 +41,11 @@ def __new__(mcs, name, bases, attrs):
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
@@ -59,6 +63,7 @@ def __new__(mcs, name, bases, attrs):
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"
@@ -96,7 +101,7 @@ def meta__init__(self, *args, **kwargs):
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
- default = 0
+ default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
@@ -106,8 +111,9 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
- name_lookup: typing.Dict[T, str]
- options: typing.Dict[str, int]
+ 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]]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
@@ -160,6 +166,8 @@ 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
@@ -180,9 +188,18 @@ def from_any(cls, data: typing.Any) -> FreeText:
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)
@@ -598,7 +615,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
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]
@@ -727,7 +744,7 @@ def __new__(cls, value: int) -> SpecialRange:
"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, value)
+ return super().__new__(cls)
@classmethod
def weighted_range(cls, text) -> Range:
@@ -765,7 +782,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
value: typing.Any
@classmethod
- def verify_keys(cls, data: typing.List[str]):
+ def verify_keys(cls, data: typing.Iterable[str]) -> None:
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
@@ -802,7 +819,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
- default: typing.Dict[str, typing.Any] = {}
+ default = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
@@ -843,11 +860,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: typing.List[typing.Any] = []
+ default = ()
supports_weighting = False
- def __init__(self, value: typing.List[typing.Any]):
- self.value = deepcopy(value)
+ def __init__(self, value: typing.Iterable[typing.Any]):
+ self.value = list(deepcopy(value))
super(OptionList, self).__init__()
@classmethod
@@ -856,7 +873,7 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
- if type(data) == list:
+ if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -869,7 +886,7 @@ def __contains__(self, item):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
- default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
+ default = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
@@ -882,7 +899,7 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
- if isinstance(data, (list, set, frozenset)):
+ if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -932,7 +949,7 @@ def __new__(mcs,
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)
@@ -1110,6 +1127,11 @@ 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
diff --git a/Patch.py b/Patch.py
index 113d0658c6b7..9b49876bb72d 100644
--- a/Patch.py
+++ b/Patch.py
@@ -8,7 +8,7 @@
import ModuleUpdate
ModuleUpdate.update()
-from worlds.Files import AutoPatchRegister, APDeltaPatch
+from worlds.Files import AutoPatchRegister, APAutoPatchInterface
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: APDeltaPatch = auto_handler(patch_file)
+ handler: APAutoPatchInterface = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,
diff --git a/README.md b/README.md
index a1e03293d587..975f0ce75a7b 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Currently, the following games are supported:
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
-* Starcraft 2: Wings of Liberty
+* Starcraft 2
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
@@ -58,6 +58,10 @@ Currently, the following games are supported:
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
+* TUNIC
+* Kirby's Dream Land 3
+* Celeste 64
+* Zork Grand Inquisitor
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
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
index 87b50d35063e..fb219a690460 100644
--- a/Starcraft2Client.py
+++ b/Starcraft2Client.py
@@ -3,7 +3,7 @@
import ModuleUpdate
ModuleUpdate.update()
-from worlds.sc2wol.Client import launch
+from worlds.sc2.Client import launch
import Utils
if __name__ == "__main__":
diff --git a/Utils.py b/Utils.py
index f6e4a9ab6052..10e6e504b5c2 100644
--- a/Utils.py
+++ b/Utils.py
@@ -19,14 +19,13 @@
from argparse import Namespace
from settings import Settings, get_settings
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
-from yaml import load, load_all, dump, SafeLoader
+from typing_extensions import TypeGuard
+from yaml import load, load_all, dump
try:
- from yaml import CLoader as UnsafeLoader
- from yaml import CDumper as Dumper
+ from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
except ImportError:
- from yaml import Loader as UnsafeLoader
- from yaml import Dumper
+ from yaml import Loader as UnsafeLoader, SafeLoader, Dumper
if typing.TYPE_CHECKING:
import tkinter
@@ -47,7 +46,7 @@ def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)
-__version__ = "0.4.4"
+__version__ = "0.4.5"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -226,6 +225,9 @@ def construct_mapping(self, node, deep=False):
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)
@@ -714,7 +716,7 @@ def is_kivy_running():
import ctypes
style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
-
+
# fall back to tk
try:
import tkinter
@@ -871,8 +873,8 @@ def visualize_regions(root_region: Region, file_name: str, *,
Example usage in Main code:
from Utils import visualize_regions
- for player in world.player_ids:
- visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
+ for player in multiworld.player_ids:
+ visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.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
@@ -968,3 +970,10 @@ def __bool__(self):
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)
diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py
index 61e9164e2652..5a66d1e69331 100644
--- a/WebHostLib/api/generate.py
+++ b/WebHostLib/api/generate.py
@@ -20,8 +20,8 @@ def generate_api():
race = False
meta_options_source = {}
if 'file' in request.files:
- file = request.files['file']
- options = get_yaml_data(file)
+ files = request.files.getlist('file')
+ options = get_yaml_data(files)
if isinstance(options, Markup):
return {"text": options.striptags()}, 400
if isinstance(options, str):
diff --git a/WebHostLib/static/assets/sc2wolTracker.js b/WebHostLib/static/assets/sc2Tracker.js
similarity index 85%
rename from WebHostLib/static/assets/sc2wolTracker.js
rename to WebHostLib/static/assets/sc2Tracker.js
index a698214b8dd6..30d4acd60b7e 100644
--- a/WebHostLib/static/assets/sc2wolTracker.js
+++ b/WebHostLib/static/assets/sc2Tracker.js
@@ -25,16 +25,16 @@ window.addEventListener('load', () => {
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
- for (let i = 0; i < categories.length; i++) {
- let hide_id = categories[i].id.split('-')[0];
- if (hide_id == 'Total') {
+ for (let category of categories) {
+ let hide_id = category.id.split('_')[0];
+ if (hide_id === 'Total') {
continue;
}
- categories[i].addEventListener('click', function() {
+ category.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("▼")) {
diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png
deleted file mode 100644
index 8fb366b93ff0..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png
deleted file mode 100644
index 336dc5f77af2..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/advanceballistics.png b/WebHostLib/static/static/icons/sc2/advanceballistics.png
deleted file mode 100644
index 1bf7df9fb74c..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/advanceballistics.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/autoturretblackops.png b/WebHostLib/static/static/icons/sc2/autoturretblackops.png
deleted file mode 100644
index 552707831a00..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/autoturretblackops.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png
deleted file mode 100644
index e7ebf4031619..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/burstcapacitors.png b/WebHostLib/static/static/icons/sc2/burstcapacitors.png
deleted file mode 100644
index 3af9b20a1698..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/burstcapacitors.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png
deleted file mode 100644
index d1c0c6c9a010..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/cyclone.png b/WebHostLib/static/static/icons/sc2/cyclone.png
deleted file mode 100644
index d2016116ea3b..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/cyclone.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png
deleted file mode 100644
index 351be570d11b..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/drillingclaws.png b/WebHostLib/static/static/icons/sc2/drillingclaws.png
deleted file mode 100644
index 2b067a6e44d4..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/drillingclaws.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/emergencythrusters.png b/WebHostLib/static/static/icons/sc2/emergencythrusters.png
deleted file mode 100644
index 159fba37c903..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/emergencythrusters.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png
deleted file mode 100644
index 56bfd98c924c..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png
deleted file mode 100644
index 40a5991ebb80..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png
deleted file mode 100644
index 375325845876..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/hyperfluxor.png b/WebHostLib/static/static/icons/sc2/hyperfluxor.png
deleted file mode 100644
index cdd95bb515be..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/hyperfluxor.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/impalerrounds.png b/WebHostLib/static/static/icons/sc2/impalerrounds.png
deleted file mode 100644
index b00e0c475827..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/impalerrounds.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png
deleted file mode 100644
index 8a48e38e874d..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png
deleted file mode 100644
index f19dad952bb5..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/interferencematrix.png b/WebHostLib/static/static/icons/sc2/interferencematrix.png
deleted file mode 100644
index ced928aa57a9..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/interferencematrix.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png
deleted file mode 100644
index e97f3db0d29a..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/jotunboosters.png b/WebHostLib/static/static/icons/sc2/jotunboosters.png
deleted file mode 100644
index 25720306e5c2..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/jotunboosters.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/jumpjets.png b/WebHostLib/static/static/icons/sc2/jumpjets.png
deleted file mode 100644
index dfdfef4052ca..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/jumpjets.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png
deleted file mode 100644
index c57899b270ff..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/liberator.png b/WebHostLib/static/static/icons/sc2/liberator.png
deleted file mode 100644
index 31507be5fe68..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/liberator.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/lockdown.png b/WebHostLib/static/static/icons/sc2/lockdown.png
deleted file mode 100644
index a2e7f5dc3e3f..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/lockdown.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png
deleted file mode 100644
index 0272b4b73892..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/magrailmunitions.png b/WebHostLib/static/static/icons/sc2/magrailmunitions.png
deleted file mode 100644
index ec303498ccdb..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/magrailmunitions.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png
deleted file mode 100644
index 1c7ce9d6ab1a..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png
deleted file mode 100644
index 04d68d35dc46..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/opticalflare.png b/WebHostLib/static/static/icons/sc2/opticalflare.png
deleted file mode 100644
index f888fd518b99..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/opticalflare.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png
deleted file mode 100644
index dcf5fd72da86..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png
deleted file mode 100644
index b9f2f055c265..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/restoration.png b/WebHostLib/static/static/icons/sc2/restoration.png
deleted file mode 100644
index f5c94e1aeefd..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/restoration.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png
deleted file mode 100644
index f68e82039765..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/shreddermissile.png b/WebHostLib/static/static/icons/sc2/shreddermissile.png
deleted file mode 100644
index 40899095fe3a..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/shreddermissile.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png
deleted file mode 100644
index 1b9f8cf06097..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/siegetankrange.png b/WebHostLib/static/static/icons/sc2/siegetankrange.png
deleted file mode 100644
index 5aef00a656c9..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/siegetankrange.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/specialordance.png b/WebHostLib/static/static/icons/sc2/specialordance.png
deleted file mode 100644
index 4f7410d7ca9e..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/specialordance.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/spidermine.png b/WebHostLib/static/static/icons/sc2/spidermine.png
deleted file mode 100644
index bb39cf0bf8ce..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/spidermine.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/staticempblast.png b/WebHostLib/static/static/icons/sc2/staticempblast.png
deleted file mode 100644
index 38f361510775..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/staticempblast.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/superstimpack.png b/WebHostLib/static/static/icons/sc2/superstimpack.png
deleted file mode 100644
index 0fba8ce5749a..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/superstimpack.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/targetingoptics.png b/WebHostLib/static/static/icons/sc2/targetingoptics.png
deleted file mode 100644
index 057a40f08e30..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/targetingoptics.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png
deleted file mode 100644
index 44d1bb9541fb..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/terran-emp-color.png b/WebHostLib/static/static/icons/sc2/terran-emp-color.png
deleted file mode 100644
index 972b828c75e2..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/terran-emp-color.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png
deleted file mode 100644
index 9d5982655183..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/thorsiegemode.png b/WebHostLib/static/static/icons/sc2/thorsiegemode.png
deleted file mode 100644
index a298fb57de5a..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/thorsiegemode.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/transformationservos.png b/WebHostLib/static/static/icons/sc2/transformationservos.png
deleted file mode 100644
index f7f0524ac15c..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/transformationservos.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/valkyrie.png b/WebHostLib/static/static/icons/sc2/valkyrie.png
deleted file mode 100644
index 9cbf339b10db..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/valkyrie.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/warpjump.png b/WebHostLib/static/static/icons/sc2/warpjump.png
deleted file mode 100644
index ff0a7b1af4aa..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/warpjump.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png
deleted file mode 100644
index 8f5e09c6a593..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png
deleted file mode 100644
index 7097db05e6c0..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/widowmine.png b/WebHostLib/static/static/icons/sc2/widowmine.png
deleted file mode 100644
index 802c49a83d88..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/widowmine.png and /dev/null differ
diff --git a/WebHostLib/static/static/icons/sc2/widowminehidden.png b/WebHostLib/static/static/icons/sc2/widowminehidden.png
deleted file mode 100644
index e568742e8a50..000000000000
Binary files a/WebHostLib/static/static/icons/sc2/widowminehidden.png and /dev/null differ
diff --git a/WebHostLib/static/styles/sc2Tracker.css b/WebHostLib/static/styles/sc2Tracker.css
new file mode 100644
index 000000000000..29a719a110c8
--- /dev/null
+++ b/WebHostLib/static/styles/sc2Tracker.css
@@ -0,0 +1,160 @@
+#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;
+}
diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css
deleted file mode 100644
index a7d8bd28c4f8..000000000000
--- a/WebHostLib/static/styles/sc2wolTracker.css
+++ /dev/null
@@ -1,112 +0,0 @@
-#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;
-}
diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html
index 33f8dbc09e6c..53d98dfae6ba 100644
--- a/WebHostLib/templates/generate.html
+++ b/WebHostLib/templates/generate.html
@@ -69,8 +69,8 @@
Generate Game{% if race %} (Race Mode){% endif %}
|
@@ -185,12 +185,12 @@ Generate Game{% if race %} (Race Mode){% endif %}
+
+
+
-
-
-
diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html
index 7b89c4a9e079..08cf227990b8 100644
--- a/WebHostLib/templates/islandFooter.html
+++ b/WebHostLib/templates/islandFooter.html
@@ -1,6 +1,6 @@
{% block footer %}
|