Merge branch 'main' into rework_accessibility

# Conflicts:
#	worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py
#	worlds/alttp/test/inverted_owg/TestInvertedOWG.py
This commit is contained in:
alwaysintreble
2024-03-24 01:45:19 -05:00
1017 changed files with 87022 additions and 19147 deletions

View File

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

View File

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

View File

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

View File

@@ -15,9 +15,10 @@ jobs:
steps:
- uses: actions/labeler@v5
with:
sync-labels: true
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
@@ -30,6 +31,7 @@ jobs:
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:

View File

@@ -18,7 +18,7 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@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

View File

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

View File

@@ -84,7 +84,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():
@@ -216,7 +216,8 @@ class MultiWorld():
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = {}
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, ...]:
@@ -250,14 +251,13 @@ class MultiWorld():
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
assert not self.worlds, "seed needs to be initialized before Worlds"
self.seed = get_seed(seed)
if secure:
self.secure()
else:
self.random.seed(self.seed)
self.seed_name = name if name else str(self.seed)
self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
@@ -274,7 +274,6 @@ class MultiWorld():
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
@@ -1027,7 +1026,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

View File

@@ -20,8 +20,8 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
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 @@ class ClientCommandProcessor(CommandProcessor):
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 @@ class ClientCommandProcessor(CommandProcessor):
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
if not self.ctx.game:
@@ -124,6 +140,15 @@ class ClientCommandProcessor(CommandProcessor):
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
@@ -618,13 +643,13 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.username = server_url.username
if server_url.password:
ctx.password = server_url.password
port = server_url.port or 38281
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}')
try:
port = server_url.port or 38281 # raises ValueError if invalid
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
@@ -733,8 +758,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible. '

View File

@@ -208,7 +208,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
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()
@@ -265,10 +266,10 @@ def remaining_fill(multiworld: MultiWorld,
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
@@ -466,7 +467,7 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, 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")

View File

@@ -323,13 +323,29 @@ def roll_percentage(percentage: Union[int, float]) -> bool:
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
@@ -452,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]

View File

@@ -707,15 +707,18 @@ class Context:
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])
@@ -975,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()
@@ -1052,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):
@@ -1964,7 +1972,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forbid_release(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command."""
"""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player

View File

@@ -290,8 +290,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
def add_json_location(parts: list, 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):

View File

@@ -12,7 +12,7 @@ from dataclasses import dataclass
from schema import And, Optional, Or, Schema
from Utils import get_fuzzy_results, is_iterable_of_str
from Utils import get_fuzzy_results, is_iterable_except_str
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
@@ -41,6 +41,11 @@ class AssembleOptions(abc.ABCMeta):
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
"default" in attrs or
any(hasattr(base, "default") for base in bases)
), f"Option class {name} needs default value"
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
@@ -96,7 +101,7 @@ T = typing.TypeVar('T')
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,6 +188,14 @@ class FreeText(Option[str]):
def get_option_name(cls, value: str) -> str:
return value
def __eq__(self, other):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
return other == self.value
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
@@ -803,7 +819,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
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]):
@@ -844,10 +860,10 @@ 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.Union[typing.List[typing.Any], typing.Tuple[typing.Any, ...]] = ()
default = ()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
def __init__(self, value: typing.Iterable[typing.Any]):
self.value = list(deepcopy(value))
super(OptionList, self).__init__()
@@ -857,7 +873,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_of_str(data):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -870,7 +886,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
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]):
@@ -883,7 +899,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_of_str(data):
if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))

View File

@@ -8,7 +8,7 @@ if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APPatch
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: APPatch = 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,

View File

@@ -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
@@ -61,6 +61,10 @@ Currently, the following games are supported:
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

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

View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
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")
@@ -225,6 +225,9 @@ class UniqueKeyLoader(SafeLoader):
if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key)
return super().construct_mapping(node, deep)
@@ -713,7 +716,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
import ctypes
style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk
try:
import tkinter
@@ -969,11 +972,8 @@ class RepeatableChain:
return sum(len(iterable) for iterable in self.iterable)
def is_iterable_of_str(obj: object) -> TypeGuard[typing.Iterable[str]]:
""" but not a `str` (because technically, `str` is `Iterable[str]`) """
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
if not isinstance(obj, typing.Iterable):
return False
obj_it: typing.Iterable[object] = obj
return all(isinstance(v, str) for v in obj_it)
return isinstance(obj, typing.Iterable)

View File

@@ -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("▼")) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -28,6 +28,9 @@
# Bumper Stickers
/worlds/bumpstik/ @FelicitusNeko
# Castlevania 64
/worlds/cv64/ @LiquidCat64
# Celeste 64
/worlds/celeste64/ @PoryGone
@@ -131,11 +134,14 @@
# Shivers
/worlds/shivers/ @GodlFire
# A Short Hike
/worlds/shorthike/ @chandler05
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace
# Starcraft 2 Wings of Liberty
/worlds/sc2wol/ @Ziktofel
# Starcraft 2
/worlds/sc2/ @Ziktofel
# Super Metroid
/worlds/sm/ @lordlou
@@ -171,7 +177,7 @@
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
# TUNIC
/worlds/tunic/ @silent-destroyer
/worlds/tunic/ @silent-destroyer @ScipioWright
# Undertale
/worlds/undertale/ @jonloveslegos
@@ -185,9 +191,15 @@
# The Witness
/worlds/witness/ @NewSoupVi @blastron
# Yoshi's Island
/worlds/yoshisisland/ @PinkSwitch
# Zillion
/worlds/zillion/ @beauxq
# Zork Grand Inquisitor
/worlds/zork_grand_inquisitor/ @nbrochu
##################################
## Disabled Unmaintained Worlds ##
##################################

View File

@@ -204,7 +204,7 @@ For example:
```python
range_start = 1
range_end = 99
special_range_names: {
special_range_names = {
"normal": 20,
"extreme": 99,
"unlimited": -1,

View File

@@ -31,8 +31,11 @@ ArchitecturesAllowed=x64 arm64
AllowNoIcons=yes
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
; you will likely have to remove the following signtool line when testing/debugging locally. Don't include that change in PRs.
#ifndef NO_SIGNTOOL
; You will likely have to remove the SignTool= line when testing/debugging locally or run with iscc.exe /DNO_SIGNTOOL.
; Don't include that change in PRs.
SignTool= signtool
#endif
LicenseFile= LICENSE
WizardStyle= modern
SetupLogging=yes
@@ -131,10 +134,10 @@ Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apkdl3"; ValueData: "{#MyAppName}kdl3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Archipelago Kirby's Dream Land 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apkdl3"; ValueData: "{#MyAppName}kdl3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Archipelago Kirby's Dream Land 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
@@ -166,6 +169,11 @@ Root: HKCR; Subkey: "{#MyAppName}pkmnepatch"; ValueData: "Ar
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apcv64"; ValueData: "{#MyAppName}cv64patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Archipelago Castlevania 64 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
@@ -181,6 +189,11 @@ Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apyi"; ValueData: "{#MyAppName}yipatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}yipatch"; ValueData: "Archipelago Yoshi's Island Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}yipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}yipatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";

71
kvui.py
View File

@@ -2,6 +2,7 @@ import os
import logging
import sys
import typing
import re
if sys.platform == "win32":
import ctypes
@@ -72,6 +73,8 @@ if typing.TYPE_CHECKING:
else:
context_type = object
remove_between_brackets = re.compile(r"\[.*?]")
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
@@ -129,6 +132,8 @@ class ScrollBox(ScrollView):
self.layout.bind(minimum_height=self.layout.setter("height"))
self.add_widget(self.layout)
self.effect_cls = ScrollEffect
self.bar_width = dp(12)
self.scroll_type = ["content", "bars"]
class HovererableLabel(HoverBehavior, Label):
@@ -303,7 +308,6 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
no_select = []
def __init__(self):
super(HintLabel, self).__init__()
@@ -321,9 +325,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
def refresh_view_attrs(self, rv, index, data):
self.index = index
if "select" in data and not data["select"] and index not in self.no_select:
self.no_select.append(index)
self.striped = data["striped"]
self.striped = data.get("striped", False)
self.receiving_text = data["receiving"]["text"]
self.item_text = data["item"]["text"]
self.finding_text = data["finding"]["text"]
@@ -337,24 +339,44 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
""" Add selection on touch down """
if super(HintLabel, self).on_touch_down(touch):
return True
if self.index not in self.no_select:
if self.index: # skip header
if self.collide_point(*touch.pos):
if self.selected:
self.parent.clear_selection()
else:
text = "".join([self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
text = "".join((self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
self.finding_text, "\'s World", (" at " + self.entrance_text)
if self.entrance_text != "Vanilla"
else "", ". (", self.found_text.lower(), ")"])
else "", ". (", self.found_text.lower(), ")"))
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
else:
parent = self.parent
parent.clear_selection()
parent: HintLog = parent.parent
# find correct column
for child in self.children:
if child.collide_point(*touch.pos):
key = child.sort_key
parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
if key == parent.sort_key:
# second click reverses order
parent.reversed = not parent.reversed
else:
parent.sort_key = key
parent.reversed = False
break
else:
logging.warning("Did not find clicked header for sorting.")
App.get_running_app().update_hints()
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
if self.index not in self.no_select:
if self.index:
self.selected = is_selected
@@ -646,20 +668,20 @@ class HintLog(RecycleView):
"entrance": {"text": "[u]Entrance[/u]"},
"found": {"text": "[u]Status[/u]"},
"striped": True,
"select": False,
}
sort_key: str = ""
reversed: bool = False
def __init__(self, parser):
super(HintLog, self).__init__()
self.data = [self.header]
self.parser = parser
def refresh_hints(self, hints):
self.data = [self.header]
striped = False
data = []
for hint in hints:
self.data.append({
"striped": striped,
data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node(
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
@@ -672,7 +694,22 @@ class HintLog(RecycleView):
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red",
"text": "Found" if hint["found"] else "Not Found"})},
})
striped = not striped
data.sort(key=self.hint_sorter, reverse=self.reversed)
for i in range(0, len(data), 2):
data[i]["striped"] = True
data.insert(0, self.header)
self.data = data
@staticmethod
def hint_sorter(element: dict) -> str:
return ""
def fix_heights(self):
"""Workaround fix for divergent texture and layout heights"""
for element in self.children[0].children:
max_height = max(child.texture_size[1] for child in element.children)
element.height = max_height
class E(ExceptionHandler):
@@ -721,8 +758,10 @@ class KivyJSONtoTextParser(JSONtoTextParser):
text = f"Game: {slot_info.game}<br>" \
f"Type: {SlotType(slot_info.type).name}"
if slot_info.group_members:
text += f"<br>Members:<br> " + \
"<br> ".join(self.ctx.player_names[player] for player in slot_info.group_members)
text += f"<br>Members:<br> " + "<br> ".join(
escape_markup(self.ctx.player_names[player])
for player in slot_info.group_members
)
node.setdefault("refs", []).append(text)
return super(KivyJSONtoTextParser, self)._handle_player_id(node)

View File

@@ -68,7 +68,6 @@ non_apworlds: set = {
"Archipelago",
"ChecksFinder",
"Clique",
"DLCQuest",
"Final Fantasy",
"Lufia II Ancient Cave",
"Meritous",
@@ -85,7 +84,6 @@ non_apworlds: set = {
# LogicMixin is broken before 3.10 import revamp
if sys.version_info < (3,10):
non_apworlds.add("Hollow Knight")
non_apworlds.add("Starcraft 2 Wings of Liberty")
def download_SNI():
print("Updating SNI")

View File

@@ -10,7 +10,7 @@ from worlds import AutoWorld
from worlds.AutoWorld import World, call_all
from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Items import item_factory
class TestBase(unittest.TestCase):
@@ -91,15 +91,15 @@ class TestBase(unittest.TestCase):
items = self.multiworld.itempool[:]
items = [item for item in items if
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(ItemFactory(item_pool[0], 1))
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
else:
items = ItemFactory(item_pool[0], 1)
items = item_factory(item_pool[0], self.multiworld.worlds[1])
return self.get_state(items)
def _get_items_partial(self, item_pool, missing_item):
new_items = item_pool[0].copy()
new_items.remove(missing_item)
items = ItemFactory(new_items, 1)
items = item_factory(new_items, self.multiworld.worlds[1])
return self.get_state(items)

View File

@@ -1,28 +1,50 @@
from argparse import Namespace
from typing import Type, Tuple
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import MultiWorld, CollectionState
from worlds.AutoWorld import call_all, World
from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_steps) -> MultiWorld:
def setup_solo_multiworld(
world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None
) -> MultiWorld:
"""
Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps.
:param world_type: Type of the world to generate a multiworld for
:param steps: The gen steps that should be called on the generated multiworld before returning. Default calls
steps through pre_fill
:param seed: The seed to be used when creating this multiworld
"""
multiworld = MultiWorld(1)
multiworld.game[1] = world_type.game
multiworld.player_name = {1: "Tester"}
multiworld.set_seed()
return setup_multiworld(world_type, steps, seed)
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
seed: Optional[int] = None) -> MultiWorld:
"""
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
calling the provided gen steps.
:param worlds: type/s of worlds to generate a multiworld for
:param steps: gen steps that should be called before returning. Default calls through pre_fill
:param seed: The seed to be used when creating this multiworld
"""
if not isinstance(worlds, list):
worlds = [worlds]
players = len(worlds)
multiworld = MultiWorld(players)
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed)
multiworld.state = CollectionState(multiworld)
args = Namespace()
for name, option in world_type.options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(option.default)})
for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items():
updated_options = getattr(args, key, {})
updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options)
multiworld.set_options(args)
for step in steps:
call_all(multiworld, step)

View File

@@ -13,6 +13,7 @@ from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules,
def generate_multiworld(players: int = 1) -> MultiWorld:
multiworld = MultiWorld(players)
multiworld.set_seed(0)
multiworld.player_name = {}
multiworld.state = CollectionState(multiworld)
for i in range(players):
@@ -32,8 +33,6 @@ def generate_multiworld(players: int = 1) -> MultiWorld:
world.options = world.options_dataclass(**{option_key: getattr(multiworld, option_key)[player_id]
for option_key in world.options_dataclass.type_hints})
multiworld.set_seed(0)
return multiworld

View File

@@ -8,7 +8,7 @@ class TestBase(unittest.TestCase):
def test_create_item(self):
"""Test that a world can successfully create all items in its datapackage"""
for game_name, world_type in AutoWorldRegister.world_types.items():
proxy_world = world_type(None, 0) # this is identical to MultiServer.py creating worlds
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
for item_name in world_type.item_name_to_id:
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name)
@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
{"Pendants", "Crystals"},
"Ocarina of Time":
{"medallions", "stones", "rewards", "logic_bottles"},
"Starcraft 2 Wings of Liberty":
{"Missions"},
"Starcraft 2":
{"Missions", "WoL Missions"},
}
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game_name, game_name=game_name):

View File

@@ -0,0 +1,39 @@
import unittest
import Generate
class TestPlayerOptions(unittest.TestCase):
def test_update_weights(self):
original_weights = {
"scalar_1": 50,
"scalar_2": 25,
"list_1": ["string"],
"dict_1": {"option_a": 50, "option_b": 50},
"dict_2": {"option_f": 50},
"set_1": {"option_c"}
}
# test that we don't allow +merge syntax on scalar variables
with self.assertRaises(BaseException):
Generate.update_weights(original_weights, {"+scalar_1": 0}, "Tested", "")
new_weights = Generate.update_weights(original_weights, {"scalar_2": 0,
"+list_1": ["string_2"],
"+dict_1": {"option_b": 0, "option_c": 50},
"+set_1": {"option_c", "option_d"},
"dict_2": {"option_g": 50},
"+list_2": ["string_3"]},
"Tested", "")
self.assertEqual(new_weights["scalar_1"], 50)
self.assertEqual(new_weights["scalar_2"], 0)
self.assertEqual(new_weights["list_2"], ["string_3"])
self.assertEqual(new_weights["list_1"], ["string", "string_2"])
self.assertEqual(new_weights["dict_1"]["option_a"], 50)
self.assertEqual(new_weights["dict_1"]["option_b"], 0)
self.assertEqual(new_weights["dict_1"]["option_c"], 50)
self.assertNotIn("option_f", new_weights["dict_2"])
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
self.assertEqual(len(new_weights["set_1"]), 2)
self.assertIn("option_d", new_weights["set_1"])

View File

@@ -0,0 +1,77 @@
import unittest
from typing import List, Tuple
from unittest import TestCase
from BaseClasses import CollectionState, Location, MultiWorld
from Fill import distribute_items_restrictive
from Options import Accessibility
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
from ..general import gen_steps, setup_multiworld
class MultiworldTestBase(TestCase):
multiworld: MultiWorld
# similar to the implementation in WorldTestBase.test_fill
# but for multiple players and doesn't allow minimal accessibility
def fulfills_accessibility(self) -> bool:
"""
Checks that the multiworld satisfies locations accessibility requirements, failing if all locations are cleared
but not beatable, or some locations are unreachable.
"""
locations = [loc for loc in self.multiworld.get_locations()]
state = CollectionState(self.multiworld)
while locations:
sphere: List[Location] = []
for n in range(len(locations) - 1, -1, -1):
if locations[n].can_reach(state):
sphere.append(locations.pop(n))
self.assertTrue(sphere, f"Unreachable locations: {locations}")
if not sphere:
return False
for location in sphere:
if location.item:
state.collect(location.item, True, location)
return self.multiworld.has_beaten_game(state, 1)
def assertSteps(self, steps: Tuple[str, ...]) -> None:
"""Calls each step individually, continuing if a step for a specific world step fails."""
world_types = {world.__class__ for world in self.multiworld.worlds.values()}
for step in steps:
for player, world in self.multiworld.worlds.items():
with self.subTest(game=world.game, step=step):
call_single(self.multiworld, step, player)
for world_type in sorted(world_types, key=lambda world: world.__name__):
with self.subTest(game=world_type.game, step=f"stage_{step}"):
stage_callable = getattr(world_type, f"stage_{step}", None)
if stage_callable:
stage_callable(self.multiworld)
@unittest.skip("too slow for main")
class TestAllGamesMultiworld(MultiworldTestBase):
def test_fills(self) -> None:
"""Tests that a multiworld with one of every registered game world can generate."""
all_worlds = list(AutoWorldRegister.world_types.values())
self.multiworld = setup_multiworld(all_worlds, ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_locations
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
class TestTwoPlayerMulti(MultiworldTestBase):
def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate."""
for world in AutoWorldRegister.world_types.values():
self.multiworld = setup_multiworld([world, world], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_locations
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

View File

@@ -1,11 +1,35 @@
from __future__ import annotations
import abc
from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, Any, Optional
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union
from typing_extensions import TypeGuard
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
if TYPE_CHECKING:
from SNIClient import SNIContext
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"))
components.append(component)
def valid_patch_suffix(obj: object) -> TypeGuard[Union[str, Iterable[str]]]:
""" make sure this is a valid value for the class variable `patch_suffix` """
def valid_individual(one: object) -> TypeGuard[str]:
""" check an individual suffix """
# TODO: decide: len(one) > 3 and one.startswith(".ap") ?
# or keep it more general?
return isinstance(one, str) and len(one) > 1 and one.startswith(".")
if isinstance(obj, str):
return valid_individual(obj)
if not isinstance(obj, Iterable):
return False
obj_it: Iterable[object] = obj
return all(valid_individual(each) for each in obj_it)
class AutoSNIClientRegister(abc.ABCMeta):
game_handlers: ClassVar[Dict[str, SNIClient]] = {}
@@ -15,6 +39,22 @@ class AutoSNIClientRegister(abc.ABCMeta):
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoSNIClientRegister.game_handlers[dct["game"]] = new_class()
if "patch_suffix" in dct:
patch_suffix = dct["patch_suffix"]
assert valid_patch_suffix(patch_suffix), f"class {name} defining invalid {patch_suffix=}"
existing_identifier = component.file_identifier
assert isinstance(existing_identifier, SuffixIdentifier), f"{existing_identifier=}"
new_suffixes = [*existing_identifier.suffixes]
if isinstance(patch_suffix, str):
new_suffixes.append(patch_suffix)
else:
new_suffixes.extend(patch_suffix)
component.file_identifier = SuffixIdentifier(*new_suffixes)
return new_class
@staticmethod
@@ -27,6 +67,9 @@ class AutoSNIClientRegister(abc.ABCMeta):
class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
patch_suffix: ClassVar[Union[str, Iterable[str]]] = ()
"""The file extension(s) this client is meant to open and patch (e.g. ".aplttp")"""
@abc.abstractmethod
async def validate_rom(self, ctx: SNIContext) -> bool:
""" TODO: interface documentation here """

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import logging
import pathlib
import random
import re
import sys
import time
@@ -296,8 +297,11 @@ class World(metaclass=AutoWorldRegister):
"""path it was loaded from"""
def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None
self.multiworld = multiworld
self.player = player
self.random = random.Random(multiworld.random.getrandbits(64))
multiworld.per_slot_randoms[player] = self.random
def __getattr__(self, item: str) -> Any:
if item == "settings":
@@ -306,13 +310,15 @@ class World(metaclass=AutoWorldRegister):
# overridable methods that get called by Main.py, sorted by execution order
# can also be implemented as a classmethod and called "stage_<original_name>",
# in that case the MultiWorld object is passed as an argument and it gets called once for the entire multiworld.
# in that case the MultiWorld object is passed as an argument, and it gets called once for the entire multiworld.
# An example of this can be found in alttp as stage_pre_fill
@classmethod
def stage_assert_generate(cls, multiworld: "MultiWorld") -> None:
"""Checks that a game is capable of generating, usually checks for some base file like a ROM.
This gets called once per present world type. Not run for unittests since they don't produce output"""
"""
Checks that a game is capable of generating, such as checking for some base file like a ROM.
This gets called once per present world type. Not run for unittests since they don't produce output.
"""
pass
def generate_early(self) -> None:
@@ -357,16 +363,21 @@ class World(metaclass=AutoWorldRegister):
pass
def post_fill(self) -> None:
"""Optional Method that is called after regular fill. Can be used to do adjustments before output generation.
This happens before progression balancing, so the items may not be in their final locations yet."""
"""
Optional Method that is called after regular fill. Can be used to do adjustments before output generation.
This happens before progression balancing, so the items may not be in their final locations yet.
"""
def generate_output(self, output_directory: str) -> None:
"""This method gets called from a threadpool, do not use multiworld.random here.
If you need any last-second randomization, use self.random instead."""
"""
This method gets called from a threadpool, do not use multiworld.random here.
If you need any last-second randomization, use self.random instead.
"""
pass
def fill_slot_data(self) -> Mapping[str, Any]: # json of WebHostLib.models.Slot
"""What is returned from this function will be in the `slot_data` field
"""
What is returned from this function will be in the `slot_data` field
in the `Connected` network package.
It should be a `dict` with `str` keys, and should be serializable with json.
@@ -374,15 +385,18 @@ class World(metaclass=AutoWorldRegister):
The client will receive this as JSON in the `Connected` response.
The generation does not wait for `generate_output` to complete before calling this.
`threading.Event` can be used if you need to wait for something from `generate_output`."""
`threading.Event` can be used if you need to wait for something from `generate_output`.
"""
# The reason for the `Mapping` type annotation, rather than `dict`
# is so that type checkers won't worry about the mutability of `dict`,
# so you can have more specific typing in your world implementation.
return {}
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
"""Fill in additional entrance information text into locations, which is displayed when hinted.
structure is {player_id: {location_id: text}} You will need to insert your own player_id."""
"""
Fill in additional entrance information text into locations, which is displayed when hinted.
structure is {player_id: {location_id: text}} You will need to insert your own player_id.
"""
pass
def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata?
@@ -391,13 +405,17 @@ class World(metaclass=AutoWorldRegister):
# Spoiler writing is optional, these may not get called.
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
"""Write to the spoiler header. If individual it's right at the end of that player's options,
if as stage it's right under the common header before per-player options."""
"""
Write to the spoiler header. If individual it's right at the end of that player's options,
if as stage it's right under the common header before per-player options.
"""
pass
def write_spoiler(self, spoiler_handle: TextIO) -> None:
"""Write to the spoiler "middle", this is after the per-player options and before locations,
meant for useful or interesting info."""
"""
Write to the spoiler "middle", this is after the per-player options and before locations,
meant for useful or interesting info.
"""
pass
def write_spoiler_end(self, spoiler_handle: TextIO) -> None:
@@ -407,8 +425,10 @@ class World(metaclass=AutoWorldRegister):
# end of ordered Main.py calls
def create_item(self, name: str) -> "Item":
"""Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer"""
"""
Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer
"""
raise NotImplementedError
def get_filler_item_name(self) -> str:
@@ -418,34 +438,42 @@ class World(metaclass=AutoWorldRegister):
@classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
"""Creates a group, which is an instance of World that is responsible for multiple others.
An example case is ItemLinks creating these."""
"""
Creates a group, which is an instance of World that is responsible for multiple others.
An example case is ItemLinks creating these.
"""
# TODO remove loop when worlds use options dataclass
for option_key, option in cls.options_dataclass.type_hints.items():
getattr(multiworld, option_key)[new_player_id] = option(option.default)
getattr(multiworld, option_key)[new_player_id] = option.from_any(option.default)
group = cls(multiworld, new_player_id)
group.options = cls.options_dataclass(**{option_key: option(option.default)
group.options = cls.options_dataclass(**{option_key: option.from_any(option.default)
for option_key, option in cls.options_dataclass.type_hints.items()})
return group
# decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]:
"""Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
"""
Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
Collect None to skip item.
:param state: CollectionState to collect into
:param item: Item to decide on if it should be collected into state
:param remove: indicate if this is meant to remove from state instead of adding."""
:param remove: indicate if this is meant to remove from state instead of adding.
"""
if item.advancement:
return item.name
return None
# called to create all_state, return Items that are created during pre_fill
def get_pre_fill_items(self) -> List["Item"]:
"""
Used to return items that need to be collected when creating a fresh all_state, but don't exist in the
multiworld itempool.
"""
return []
# these two methods can be extended for pseudo-items on state
def collect(self, state: "CollectionState", item: "Item") -> bool:
"""Called when an item is collected in to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item)
if name:
state.prog_items[self.player][name] += 1
@@ -453,6 +481,7 @@ class World(metaclass=AutoWorldRegister):
return False
def remove(self, state: "CollectionState", item: "Item") -> bool:
"""Called when an item is removed from to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item, True)
if name:
state.prog_items[self.player][name] -= 1
@@ -461,6 +490,7 @@ class World(metaclass=AutoWorldRegister):
return True
return False
# following methods should not need to be overridden.
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())
@@ -509,7 +539,8 @@ def data_package_checksum(data: "GamesPackage") -> str:
def _normalize_description(description):
"""Normalizes a description in item_descriptions or location_descriptions.
"""
Normalizes a description in item_descriptions or location_descriptions.
This allows authors to write descritions with nice indentation and line lengths in their world
definitions without having it affect the rendered format.

View File

@@ -3,10 +3,11 @@ from __future__ import annotations
import abc
import json
import zipfile
from enum import IntEnum
import os
import threading
from typing import ClassVar, Dict, Tuple, Any, Optional, Union, BinaryIO
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
import bsdiff4
@@ -38,7 +39,35 @@ class AutoPatchRegister(abc.ABCMeta):
return None
current_patch_version: int = 5
class AutoPatchExtensionRegister(abc.ABCMeta):
extension_types: ClassVar[Dict[str, AutoPatchExtensionRegister]] = {}
required_extensions: Tuple[str, ...] = ()
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoPatchExtensionRegister:
# construct class
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
AutoPatchExtensionRegister.extension_types[dct["game"]] = new_class
return new_class
@staticmethod
def get_handler(game: Optional[str]) -> Union[AutoPatchExtensionRegister, List[AutoPatchExtensionRegister]]:
if not game:
return APPatchExtension
handler = AutoPatchExtensionRegister.extension_types.get(game, APPatchExtension)
if handler.required_extensions:
handlers = [handler]
for required in handler.required_extensions:
ext = AutoPatchExtensionRegister.extension_types.get(required)
if not ext:
raise NotImplementedError(f"No handler for {required}.")
handlers.append(ext)
return handlers
else:
return handler
container_version: int = 6
class InvalidDataError(Exception):
@@ -50,7 +79,7 @@ class InvalidDataError(Exception):
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
version: int = container_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
@@ -124,14 +153,31 @@ class APContainer:
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 5,
"version": current_patch_version,
"version": container_version,
}
class APPatch(APContainer, abc.ABC, metaclass=AutoPatchRegister):
class APPatch(APContainer):
"""
An abstract `APContainer` that defines the requirements for an object
to be used by the `Patch.create_rom_file` function.
An `APContainer` that represents a patch file.
It includes the `procedure` key in the manifest to indicate that it is a patch.
Your implementation should inherit from this if your output file
represents a patch file, but will not be applied with AP's `Patch.py`
"""
procedure: Union[Literal["custom"], List[Tuple[str, List[Any]]]] = "custom"
def get_manifest(self) -> Dict[str, Any]:
manifest = super(APPatch, self).get_manifest()
manifest["procedure"] = self.procedure
manifest["compatible_version"] = 6
return manifest
class APAutoPatchInterface(APPatch, abc.ABC, metaclass=AutoPatchRegister):
"""
An abstract `APPatch` that defines the requirements for a patch
to be applied with AP's `Patch.py`
"""
result_file_ending: str = ".sfc"
@@ -140,25 +186,14 @@ class APPatch(APContainer, abc.ABC, metaclass=AutoPatchRegister):
""" create the output file with the file name `target` """
class APDeltaPatch(APPatch):
"""An APPatch that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
class APProcedurePatch(APAutoPatchInterface):
"""
An APPatch that defines a procedure to produce the desired file.
"""
hash: Optional[str] # base checksum of source file
patch_file_ending: str = ""
delta: Optional[bytes] = None
source_data: bytes
def __init__(self, *args: Any, patched_path: str = "", **kwargs: Any) -> None:
self.patched_path = patched_path
super(APDeltaPatch, self).__init__(*args, **kwargs)
def get_manifest(self) -> Dict[str, Any]:
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
return manifest
patch_file_ending: str = ""
files: Dict[str, bytes]
@classmethod
def get_source_data(cls) -> bytes:
@@ -171,21 +206,223 @@ class APDeltaPatch(APPatch):
cls.source_data = cls.get_source_data()
return cls.source_data
def write_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).write_contents(opened_zipfile)
# write Delta
opened_zipfile.writestr("delta.bsdiff4",
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
def __init__(self, *args: Any, **kwargs: Any):
super(APProcedurePatch, self).__init__(*args, **kwargs)
self.files = {}
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).read_contents(opened_zipfile)
self.delta = opened_zipfile.read("delta.bsdiff4")
def get_manifest(self) -> Dict[str, Any]:
manifest = super(APProcedurePatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
manifest["procedure"] = self.procedure
if self.procedure == APDeltaPatch.procedure:
manifest["compatible_version"] = 5
return manifest
def patch(self, target: str):
"""Base + Delta -> Patched"""
if not self.delta:
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).read_contents(opened_zipfile)
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if "procedure" not in manifest:
# support patching files made before moving to procedures
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
else:
self.procedure = manifest["procedure"]
for file in opened_zipfile.namelist():
if file not in ["archipelago.json"]:
self.files[file] = opened_zipfile.read(file)
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).write_contents(opened_zipfile)
for file in self.files:
opened_zipfile.writestr(file, self.files[file],
compress_type=zipfile.ZIP_STORED if file.endswith(".bsdiff4") else None)
def get_file(self, file: str) -> bytes:
""" Retrieves a file from the patch container."""
if file not in self.files:
self.read()
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
with open(target, "wb") as f:
f.write(result)
return self.files[file]
def write_file(self, file_name: str, file: bytes) -> None:
""" Writes a file to the patch container, to be retrieved upon patching. """
self.files[file_name] = file
def patch(self, target: str) -> None:
self.read()
base_data = self.get_source_data_with_cache()
patch_extender = AutoPatchExtensionRegister.get_handler(self.game)
assert not isinstance(self.procedure, str), f"{type(self)} must define procedures"
for step, args in self.procedure:
if isinstance(patch_extender, list):
extension = next((item for item in [getattr(extender, step, None) for extender in patch_extender]
if item is not None), None)
else:
extension = getattr(patch_extender, step, None)
if extension is not None:
base_data = extension(self, base_data, *args)
else:
raise NotImplementedError(f"Unknown procedure {step} for {self.game}.")
with open(target, 'wb') as f:
f.write(base_data)
class APDeltaPatch(APProcedurePatch):
"""An APProcedurePatch that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
procedure = [
("apply_bsdiff4", ["delta.bsdiff4"])
]
def __init__(self, *args: Any, patched_path: str = "", **kwargs: Any) -> None:
super(APDeltaPatch, self).__init__(*args, **kwargs)
self.patched_path = patched_path
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
self.write_file("delta.bsdiff4",
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()))
super(APDeltaPatch, self).write_contents(opened_zipfile)
class APTokenTypes(IntEnum):
WRITE = 0
COPY = 1
RLE = 2
AND_8 = 3
OR_8 = 4
XOR_8 = 5
class APTokenMixin:
"""
A class that defines functions for generating a token binary, for use in patches.
"""
_tokens: Sequence[
Tuple[APTokenTypes, int, Union[
bytes, # WRITE
Tuple[int, int], # COPY, RLE
int # AND_8, OR_8, XOR_8
]]] = ()
def get_token_binary(self) -> bytes:
"""
Returns the token binary created from stored tokens.
:return: A bytes object representing the token data.
"""
data = bytearray()
data.extend(len(self._tokens).to_bytes(4, "little"))
for token_type, offset, args in self._tokens:
data.append(token_type)
data.extend(offset.to_bytes(4, "little"))
if token_type in [APTokenTypes.AND_8, APTokenTypes.OR_8, APTokenTypes.XOR_8]:
assert isinstance(args, int), f"Arguments to AND/OR/XOR must be of type int, not {type(args)}"
data.extend(int.to_bytes(1, 4, "little"))
data.append(args)
elif token_type in [APTokenTypes.COPY, APTokenTypes.RLE]:
assert isinstance(args, tuple), f"Arguments to COPY/RLE must be of type tuple, not {type(args)}"
data.extend(int.to_bytes(8, 4, "little"))
data.extend(args[0].to_bytes(4, "little"))
data.extend(args[1].to_bytes(4, "little"))
elif token_type == APTokenTypes.WRITE:
assert isinstance(args, bytes), f"Arguments to WRITE must be of type bytes, not {type(args)}"
data.extend(len(args).to_bytes(4, "little"))
data.extend(args)
else:
raise ValueError(f"Unknown token type {token_type}")
return bytes(data)
@overload
def write_token(self,
token_type: Literal[APTokenTypes.AND_8, APTokenTypes.OR_8, APTokenTypes.XOR_8],
offset: int,
data: int) -> None:
...
@overload
def write_token(self,
token_type: Literal[APTokenTypes.COPY, APTokenTypes.RLE],
offset: int,
data: Tuple[int, int]) -> None:
...
@overload
def write_token(self,
token_type: Literal[APTokenTypes.WRITE],
offset: int,
data: bytes) -> None:
...
def write_token(self, token_type: APTokenTypes, offset: int, data: Union[bytes, Tuple[int, int], int]) -> None:
"""
Stores a token to be used by patching.
"""
if not isinstance(self._tokens, list):
assert len(self._tokens) == 0, f"{type(self)}._tokens was tampered with."
self._tokens = []
self._tokens.append((token_type, offset, data))
class APPatchExtension(metaclass=AutoPatchExtensionRegister):
"""Class that defines patch extension functions for a given game.
Patch extension functions must have the following two arguments in the following order:
caller: APProcedurePatch (used to retrieve files from the patch container)
rom: bytes (the data to patch)
Further arguments are passed in from the procedure as defined.
Patch extension functions must return the changed bytes.
"""
game: str
required_extensions: ClassVar[Tuple[str, ...]] = ()
@staticmethod
def apply_bsdiff4(caller: APProcedurePatch, rom: bytes, patch: str) -> bytes:
"""Applies the given bsdiff4 from the patch onto the current file."""
return bsdiff4.patch(rom, caller.get_file(patch))
@staticmethod
def apply_tokens(caller: APProcedurePatch, rom: bytes, token_file: str) -> bytes:
"""Applies the given token file from the patch onto the current file."""
token_data = caller.get_file(token_file)
rom_data = bytearray(rom)
token_count = int.from_bytes(token_data[0:4], "little")
bpr = 4
for _ in range(token_count):
token_type = token_data[bpr:bpr + 1][0]
offset = int.from_bytes(token_data[bpr + 1:bpr + 5], "little")
size = int.from_bytes(token_data[bpr + 5:bpr + 9], "little")
data = token_data[bpr + 9:bpr + 9 + size]
if token_type in [APTokenTypes.AND_8, APTokenTypes.OR_8, APTokenTypes.XOR_8]:
arg = data[0]
if token_type == APTokenTypes.AND_8:
rom_data[offset] = rom_data[offset] & arg
elif token_type == APTokenTypes.OR_8:
rom_data[offset] = rom_data[offset] | arg
else:
rom_data[offset] = rom_data[offset] ^ arg
elif token_type in [APTokenTypes.COPY, APTokenTypes.RLE]:
length = int.from_bytes(data[:4], "little")
value = int.from_bytes(data[4:], "little")
if token_type == APTokenTypes.COPY:
rom_data[offset: offset + length] = rom_data[value: value + length]
else:
rom_data[offset: offset + length] = bytes([value] * length)
else:
rom_data[offset:offset + len(data)] = data
bpr += 9 + size
return bytes(rom_data)
@staticmethod
def calc_snes_crc(caller: APProcedurePatch, rom: bytes) -> bytes:
"""Calculates and applies a valid CRC for the SNES rom header."""
rom_data = bytearray(rom)
if len(rom) < 0x8000:
raise Exception("Tried to calculate SNES CRC on file too small to be a SNES ROM.")
crc = (sum(rom_data[:0x7FDC] + rom_data[0x7FE0:]) + 0x01FE) & 0xFFFF
inv = crc ^ 0xFFFF
rom_data[0x7FDC:0x7FE0] = [inv & 0xFF, (inv >> 8) & 0xFF, crc & 0xFF, (crc >> 8) & 0xFF]
return bytes(rom_data)

View File

@@ -85,10 +85,6 @@ components: List[Component] = [
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
'.apsmw', '.apl2ac', '.apkdl3')),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),

View File

@@ -7,7 +7,6 @@ checking or launching the client, otherwise it will probably cause circular impo
import asyncio
import enum
import subprocess
import traceback
from typing import Any, Dict, Optional
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
@@ -260,7 +259,7 @@ def launch() -> None:
try:
await watcher_task
except Exception as e:
logger.error("".join(traceback.format_exception(e)))
logger.exception(e)
await ctx.exit_event.wait()
await ctx.shutdown()

View File

@@ -12,6 +12,8 @@ from .position import Point2
from .unit import Unit
from .units import Units
from worlds._sc2common.bot import logger
if TYPE_CHECKING:
from .game_info import Ramp
@@ -310,6 +312,7 @@ class BotAI(BotAIInternal):
:param message:
:param team_only:"""
assert isinstance(message, str), f"{message} is not a string"
logger.debug("Sending message: " + message)
await self.client.chat_send(message, team_only)
def in_map_bounds(self, pos: Union[Point2, tuple, list]) -> bool:

View File

@@ -7,7 +7,7 @@ from typing import Optional, Any
import Utils
from .Locations import AdventureLocation, LocationData
from settings import get_settings
from worlds.Files import APDeltaPatch, AutoPatchRegister, APContainer
from worlds.Files import APPatch, AutoPatchRegister
import bsdiff4
@@ -78,7 +78,7 @@ class BatNoTouchLocation:
return ret_dict
class AdventureDeltaPatch(APContainer, metaclass=AutoPatchRegister):
class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
hash = ADVENTUREHASH
game = "Adventure"
patch_file_ending = ".apadvn"

View File

@@ -1,11 +1,11 @@
# Adventure
## Where is the settings page?
The [player settings page for Adventure](../player-settings) contains all the options you need to configure and export a config file.
## Where is the options page?
The [player options page for Adventure](../player-options) contains all the options you need to configure and export a config file.
## What does randomization do to this game?
Adventure items may be distributed into additional locations not possible in the vanilla Adventure randomizer. All
Adventure items are added to the multiworld item pool. Depending on the settings, dragon locations may be randomized,
Adventure items are added to the multiworld item pool. Depending on the `dragon_rando_type` value, dragon locations may be randomized,
slaying dragons may award items, difficulty switches may require items to unlock, and limited use 'freeincarnates'
can allow reincarnation without resurrecting dragons. Dragon speeds may also be randomized, and items may exist
to reduce their speeds.
@@ -15,7 +15,7 @@ Same as vanilla; Find the Enchanted Chalice and return it to the Yellow Castle
## Which items can be in another player's world?
All three keys, the chalice, the sword, the magnet, and the bridge can be found in another player's world. Depending on
settings, dragon slowdowns, difficulty switch unlocks, and freeincarnates may also be found.
options, dragon slowdowns, difficulty switch unlocks, and freeincarnates may also be found.
## What is considered a location check in Adventure?
Most areas in Adventure have one or more locations which can contain an Adventure item or an Archipelago item.
@@ -41,7 +41,7 @@ A message is shown in the client log. While empty handed, the player can press
order they were received. Once an item is retrieved this way, it cannot be retrieved again until pressing select to
return to the 'GO' screen or doing a hard reset, either one of which will reset all items to their original positions.
## What are recommended settings to tweak for beginners to the rando?
## What are recommended options to tweak for beginners to the rando?
Setting difficulty_switch_a and lowering the dragons' speeds makes the dragons easier to avoid. Adding Chalice to
local_items guarantees you'll visit at least one of the interesting castles, as it can only be placed in a castle or
the credits room.

View File

@@ -41,7 +41,7 @@ an experience customized for their taste, and different players in the same mult
### Where do I get a YAML file?
You can generate a yaml or download a template by visiting the [Adventure Settings Page](/games/Adventure/player-settings)
You can generate a yaml or download a template by visiting the [Adventure Options Page](/games/Adventure/player-options)
### What are recommended settings to tweak for beginners to the rando?
Setting difficulty_switch_a and lowering the dragons' speeds makes the dragons easier to avoid. Adding Chalice to

View File

@@ -471,6 +471,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
class ALTTPSNIClient(SNIClient):
game = "A Link to the Past"
patch_suffix = [".aplttp", ".apz3"]
async def deathlink_kill_player(self, ctx):
from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes

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