Compare commits

..

3 Commits

403 changed files with 8618 additions and 35319 deletions

View File

@@ -1,35 +0,0 @@
name: Bug Report
description: File a bug report.
title: "Bug: "
labels:
- bug / fix
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
and upload it with this report, as well as all yaml files used.
- type: textarea
id: what-happened
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: expected-results
attributes:
label: What were the expected results?
validations:
required: true
- type: dropdown
id: version
attributes:
label: Software
description: Where did this bug occur?
options:
- Website
- Local generation
- While playing
validations:
required: true

View File

@@ -1,17 +0,0 @@
name: Feature Request
description: Request a feature!
title: "Category: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
website, documentation, or a game.
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
ask is about it is in the [discord](https://archipelago.gg/discord).
- type: textarea
id: feature
attributes:
label: What feature would you like to see?

View File

@@ -1,10 +0,0 @@
name: Task
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
title: "Core: "
labels:
- core
- enhancement
body:
- type: textarea
attributes:
label: What task needs to be completed?

View File

@@ -1,12 +0,0 @@
Please format your title with what portion of the project this pull request is
targeting and what it's changing.
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
## What is this fixing or adding?
## How was this tested?
## If this makes graphical changes, please attach screenshots.

View File

@@ -4,11 +4,6 @@ name: Build
on: workflow_dispatch on: workflow_dispatch
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
@@ -22,15 +17,15 @@ jobs:
python-version: '3.8' python-version: '3.8'
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip setuptools python -m pip install --upgrade pip setuptools
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build_exe --yes python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1] $NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
@@ -48,7 +43,6 @@ jobs:
build-ubuntu1804: build-ubuntu1804:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04
steps: steps:
# - copy code below to release.yml -
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install base dependencies - name: Install base dependencies
run: | run: |
@@ -62,18 +56,18 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz tar xf sni-*.tar.xz
rm sni-*.tar.xz rm sni-*.tar.xz
mv sni-* SNI mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build - name: Build
run: | run: |
@@ -82,7 +76,7 @@ jobs:
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build_exe --yes bdist_appimage --yes python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
@@ -90,7 +84,6 @@ jobs:
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:

View File

@@ -18,8 +18,8 @@ jobs:
python-version: 3.9 python-version: 3.9
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip
pip install flake8 pytest pytest-subtests pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8 - name: Lint with flake8
run: | run: |

View File

@@ -7,11 +7,6 @@ on:
tags: tags:
- '*.*.*' - '*.*.*'
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs: jobs:
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -49,23 +44,22 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz tar xf sni-*.tar.xz
rm sni-*.tar.xz rm sni-*.tar.xz
mv sni-* SNI mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build - name: Build
run: | run: |
# pygobject is an optional dependency for kivy that's not in requirements "${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt

View File

@@ -32,8 +32,8 @@ jobs:
python-version: ${{ matrix.python.version }} python-version: ${{ matrix.python.version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip
pip install flake8 pytest pytest-subtests pip install flake8 pytest
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests - name: Unittests
run: | run: |

7
.gitignore vendored
View File

@@ -13,10 +13,6 @@
*.z64 *.z64
*.n64 *.n64
*.nes *.nes
*.sms
*.gb
*.gbc
*.gba
*.wixobj *.wixobj
*.lck *.lck
*.db3 *.db3
@@ -32,7 +28,6 @@ README.html
.vs/ .vs/
EnemizerCLI/ EnemizerCLI/
/Players/ /Players/
/SNI/
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/
@@ -129,7 +124,7 @@ ipython_config.py
# Environments # Environments
.env .env
.venv* .venv
env/ env/
venv/ venv/
ENV/ ENV/

View File

@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from argparse import Namespace
import copy import copy
from enum import unique, IntEnum, IntFlag from enum import unique, IntEnum, IntFlag
@@ -41,23 +40,16 @@ class MultiWorld():
plando_connections: List plando_connections: List
worlds: Dict[int, auto_world] worlds: Dict[int, auto_world]
groups: Dict[int, Group] groups: Dict[int, Group]
regions: List[Region]
itempool: List[Item] itempool: List[Item]
is_race: bool = False is_race: bool = False
precollected_items: Dict[int, List[Item]] precollected_items: Dict[int, List[Item]]
state: CollectionState state: CollectionState
accessibility: Dict[int, Options.Accessibility] accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems] local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems] non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing] progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]] completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
game: Dict[int, str]
class AttributeProxy(): class AttributeProxy():
def __init__(self, rule): def __init__(self, rule):
@@ -95,9 +87,6 @@ class MultiWorld():
self.customitemarray = [] self.customitemarray = []
self.shuffle_ganon = True self.shuffle_ganon = True
self.spoiler = Spoiler(self) self.spoiler = Spoiler(self)
self.early_items = {player: {} for player in self.player_ids}
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.fix_trock_doors = self.AttributeProxy( self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy( self.fix_skullwoods_exit = self.AttributeProxy(
@@ -206,7 +195,7 @@ class MultiWorld():
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)} range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None: def set_options(self, args):
for option_key in Options.common_options: for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {})) setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options: for option_key in Options.per_game_common_options:
@@ -306,16 +295,9 @@ class MultiWorld():
def get_file_safe_player_name(self, player: int) -> str: def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*') return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
def get_out_file_name_base(self, player: int) -> str:
""" the base name (without file extension) for each player's output file for a seed """
return f"AP_{self.seed_name}_P{player}" \
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
if (self.player_name[player] != f"Player{player}")
else '')
def initialize_regions(self, regions=None): def initialize_regions(self, regions=None):
for region in regions if regions else self.regions: for region in regions if regions else self.regions:
region.multiworld = self region.world = self
self._region_cache[region.player][region.name] = region self._region_cache[region.player][region.name] = region
@functools.cached_property @functools.cached_property
@@ -422,11 +404,6 @@ class MultiWorld():
def clear_entrance_cache(self): def clear_entrance_cache(self):
self._cached_entrances = None self._cached_entrances = None
def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this Entrance,
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self) -> List[Location]: def get_locations(self) -> List[Location]:
if self._cached_locations is None: if self._cached_locations is None:
self._cached_locations = [location for region in self.regions for location in region.locations] self._cached_locations = [location for region in self.regions for location in region.locations]
@@ -544,17 +521,15 @@ class MultiWorld():
"""Check if accessibility rules are fulfilled with current or supplied state.""" """Check if accessibility rules are fulfilled with current or supplied state."""
if not state: if not state:
state = CollectionState(self) state = CollectionState(self)
players: Dict[str, Set[int]] = { players = {"minimal": set(),
"minimal": set(), "items": set(),
"items": set(), "locations": set()}
"locations": set()
}
for player, access in self.accessibility.items(): for player, access in self.accessibility.items():
players[access.current_key].add(player) players[access.current_key].add(player)
beatable_fulfilled = False beatable_fulfilled = False
def location_condition(location: Location): def location_conditition(location: Location):
"""Determine if this location has to be accessible, location is already filtered by location_relevant""" """Determine if this location has to be accessible, location is already filtered by location_relevant"""
if location.player in players["minimal"]: if location.player in players["minimal"]:
return False return False
@@ -568,21 +543,20 @@ class MultiWorld():
return True return True
return False return False
def all_done() -> bool: def all_done():
"""Check if all access rules are fulfilled""" """Check if all access rules are fulfilled"""
if not beatable_fulfilled: if beatable_fulfilled:
return False if any(location_conditition(location) for location in locations):
if any(location_condition(location) for location in locations): return False # still locations required to be collected
return False # still locations required to be collected return True
return True
locations = [location for location in self.get_locations() if location_relevant(location)] locations = {location for location in self.get_locations() if location_relevant(location)}
while locations: while locations:
sphere: List[Location] = [] sphere = set()
for n in range(len(locations) - 1, -1, -1): for location in locations:
if locations[n].can_reach(state): if location.can_reach(state):
sphere.append(locations.pop(n)) sphere.add(location)
if not sphere: if not sphere:
# ran out of places and did not finish yet, quit # ran out of places and did not finish yet, quit
@@ -591,8 +565,8 @@ class MultiWorld():
return False return False
for location in sphere: for location in sphere:
if location.item: locations.remove(location)
state.collect(location.item, True, location) state.collect(location.item, True, location)
if self.has_beaten_game(state): if self.has_beaten_game(state):
beatable_fulfilled = True beatable_fulfilled = True
@@ -608,7 +582,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState(): class CollectionState():
prog_items: typing.Counter[Tuple[str, int]] prog_items: typing.Counter[Tuple[str, int]]
multiworld: MultiWorld world: MultiWorld
reachable_regions: Dict[int, Set[Region]] reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]] blocked_connections: Dict[int, Set[Entrance]]
events: Set[Location] events: Set[Location]
@@ -620,7 +594,7 @@ class CollectionState():
def __init__(self, parent: MultiWorld): def __init__(self, parent: MultiWorld):
self.prog_items = Counter() self.prog_items = Counter()
self.multiworld = parent self.world = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()}
self.events = set() self.events = set()
@@ -634,14 +608,15 @@ class CollectionState():
self.collect(item, True) self.collect(item, True)
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
from worlds.alttp.EntranceShuffle import indirect_connections
self.stale[player] = False self.stale[player] = False
rrp = self.reachable_regions[player] rrp = self.reachable_regions[player]
bc = self.blocked_connections[player] bc = self.blocked_connections[player]
queue = deque(self.blocked_connections[player]) queue = deque(self.blocked_connections[player])
start = self.multiworld.get_region('Menu', player) start = self.world.get_region('Menu', player)
# init on first call - this can't be done on construction since the regions don't exist yet # init on first call - this can't be done on construction since the regions don't exist yet
if start not in rrp: if not start in rrp:
rrp.add(start) rrp.add(start)
bc.update(start.exits) bc.update(start.exits)
queue.extend(start.exits) queue.extend(start.exits)
@@ -653,7 +628,7 @@ class CollectionState():
if new_region in rrp: if new_region in rrp:
bc.remove(connection) bc.remove(connection)
elif connection.can_reach(self): elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" assert new_region, "tried to search through an Entrance with no Region"
rrp.add(new_region) rrp.add(new_region)
bc.remove(connection) bc.remove(connection)
bc.update(new_region.exits) bc.update(new_region.exits)
@@ -661,12 +636,13 @@ class CollectionState():
self.path[new_region] = (new_region.name, self.path.get(connection, None)) self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them # Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): if new_region.name in indirect_connections:
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
if new_entrance in bc and new_entrance not in queue: if new_entrance in bc and new_entrance not in queue:
queue.append(new_entrance) queue.append(new_entrance)
def copy(self) -> CollectionState: def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld) ret = CollectionState(self.world)
ret.prog_items = self.prog_items.copy() ret.prog_items = self.prog_items.copy()
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions} self.reachable_regions}
@@ -687,25 +663,25 @@ class CollectionState():
assert isinstance(player, int), "can_reach: player is required if spot is str" assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name # try to resolve a name
if resolution_hint == 'Location': if resolution_hint == 'Location':
spot = self.multiworld.get_location(spot, player) spot = self.world.get_location(spot, player)
elif resolution_hint == 'Entrance': elif resolution_hint == 'Entrance':
spot = self.multiworld.get_entrance(spot, player) spot = self.world.get_entrance(spot, player)
else: else:
# default to Region # default to Region
spot = self.multiworld.get_region(spot, player) spot = self.world.get_region(spot, player)
return spot.can_reach(self) return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None: def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None: if locations is None:
locations = self.multiworld.get_filled_locations() locations = self.world.get_filled_locations()
reachable_events = True new_locations = True
# since the loop has a good chance to run more than once, only filter the events once # since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.event and location not in self.events and locations = {location for location in locations if location.event and
not key_only or getattr(location.item, "locked_dungeon_item", False)} not key_only or getattr(location.item, "locked_dungeon_item", False)}
while reachable_events: while new_locations:
reachable_events = {location for location in locations if location.can_reach(self)} reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events new_locations = reachable_events - self.events
for event in reachable_events: for event in new_locations:
self.events.add(event) self.events.add(event)
assert isinstance(event.item, Item), "tried to collect Event with no Item" assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event) self.collect(event.item, True, event)
@@ -724,7 +700,7 @@ class CollectionState():
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
found: int = 0 found: int = 0
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
if found >= count: if found >= count:
return True return True
@@ -732,17 +708,17 @@ class CollectionState():
def count_group(self, item_name_group: str, player: int) -> int: def count_group(self, item_name_group: str, player: int) -> int:
found: int = 0 found: int = 0
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
return found return found
def can_buy_unlimited(self, item: str, player: int) -> bool: def can_buy_unlimited(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops) shop in self.world.shops)
def can_buy(self, item: str, player: int) -> bool: def can_buy(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops) shop in self.world.shops)
def item_count(self, item: str, player: int) -> int: def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player] return self.prog_items[item, player]
@@ -762,7 +738,7 @@ class CollectionState():
return self.has('Power Glove', player) or self.has('Titans Mitts', player) return self.has('Power Glove', player) or self.has('Titans Mitts', player)
def bottle_count(self, player: int) -> int: def bottle_count(self, player: int) -> int:
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit, return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
self.count_group("Bottles", player)) self.count_group("Bottles", player))
def has_hearts(self, player: int, count: int) -> int: def has_hearts(self, player: int, count: int) -> int:
@@ -771,7 +747,7 @@ class CollectionState():
def heart_count(self, player: int) -> int: def heart_count(self, player: int) -> int:
# Warning: This only considers items that are marked as advancement items # Warning: This only considers items that are marked as advancement items
diff = self.multiworld.difficulty_requirements[player] diff = self.world.difficulty_requirements[player]
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ self.item_count('Sanctuary Heart Container', player) \ + self.item_count('Sanctuary Heart Container', player) \
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ + min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
@@ -788,9 +764,9 @@ class CollectionState():
elif self.has('Magic Upgrade (1/2)', player): elif self.has('Magic Upgrade (1/2)', player):
basemagic = 16 basemagic = 16
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player): if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill: if self.world.item_functionality[player] == 'hard' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player)) basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill: elif self.world.item_functionality[player] == 'expert' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player)) basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
else: else:
basemagic = basemagic + basemagic * self.bottle_count(player) basemagic = basemagic + basemagic * self.bottle_count(player)
@@ -805,12 +781,12 @@ class CollectionState():
or (self.has('Bombs (10)', player) and enemies < 6)) or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool: def can_shoot_arrows(self, player: int) -> bool:
if self.multiworld.retro_bow[player]: if self.world.retro_bow[player]:
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player) return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player) return self.has('Bow', player) or self.has('Silver Bow', player)
def can_get_good_bee(self, player: int) -> bool: def can_get_good_bee(self, player: int) -> bool:
cave = self.multiworld.get_region('Good Bee Cave', player) cave = self.world.get_region('Good Bee Cave', player)
return ( return (
self.has_group("Bottles", player) and self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and self.has('Bug Catching Net', player) and
@@ -821,7 +797,7 @@ class CollectionState():
def can_retrieve_tablet(self, player: int) -> bool: def can_retrieve_tablet(self, player: int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.multiworld.swordless[player] and (self.world.swordless[player] and
self.has("Hammer", player))) self.has("Hammer", player)))
def has_sword(self, player: int) -> bool: def has_sword(self, player: int) -> bool:
@@ -843,7 +819,7 @@ class CollectionState():
def can_melt_things(self, player: int) -> bool: def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \ return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and (self.has('Bombos', player) and
(self.multiworld.swordless[player] or (self.world.swordless[player] or
self.has_sword(player))) self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool: def can_avoid_lasers(self, player: int) -> bool:
@@ -853,7 +829,7 @@ class CollectionState():
if self.has('Moon Pearl', player): if self.has('Moon Pearl', player):
return True return True
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world return region.is_light_world if self.world.mode[player] != 'inverted' else region.is_dark_world
def can_reach_light_world(self, player: int) -> bool: def can_reach_light_world(self, player: int) -> bool:
if True in [i.is_light_world for i in self.reachable_regions[player]]: if True in [i.is_light_world for i in self.reachable_regions[player]]:
@@ -866,24 +842,24 @@ class CollectionState():
return False return False
def has_misery_mire_medallion(self, player: int) -> bool: def has_misery_mire_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][0], player) return self.has(self.world.required_medallions[player][0], player)
def has_turtle_rock_medallion(self, player: int) -> bool: def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][1], player) return self.has(self.world.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int) -> bool: def can_boots_clip_lw(self, player: int) -> bool:
if self.multiworld.mode[player] == 'inverted': if self.world.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player) return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int) -> bool: def can_boots_clip_dw(self, player: int) -> bool:
if self.multiworld.mode[player] != 'inverted': if self.world.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player) return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int) -> bool: def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] == 'inverted': if self.world.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
@@ -892,7 +868,7 @@ class CollectionState():
def can_get_glitched_speed_dw(self, player: int) -> bool: def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] != 'inverted': if self.world.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
@@ -903,7 +879,7 @@ class CollectionState():
if location: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item) changed = self.world.worlds[item.player].collect(self, item)
if not changed and event: if not changed and event:
self.prog_items[item.name, item.player] += 1 self.prog_items[item.name, item.player] += 1
@@ -917,7 +893,7 @@ class CollectionState():
return changed return changed
def remove(self, item: Item): def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item) changed = self.world.worlds[item.player].remove(self, item)
if changed: if changed:
# invalidate caches, nothing can be trusted anymore now # invalidate caches, nothing can be trusted anymore now
self.reachable_regions[item.player] = set() self.reachable_regions[item.player] = set()
@@ -944,7 +920,7 @@ class Region:
type: RegionType type: RegionType
hint_text: str hint_text: str
player: int player: int
multiworld: Optional[MultiWorld] world: Optional[MultiWorld]
entrances: List[Entrance] entrances: List[Entrance]
exits: List[Entrance] exits: List[Entrance]
locations: List[Location] locations: List[Location]
@@ -962,7 +938,7 @@ class Region:
self.entrances = [] self.entrances = []
self.exits = [] self.exits = []
self.locations = [] self.locations = []
self.multiworld = world self.world = world
self.hint_text = hint self.hint_text = hint
self.player = player self.player = player
@@ -979,18 +955,11 @@ class Region:
return True return True
return False return False
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
for entrance in self.entrances:
if is_main_entrance(entrance):
return entrance
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def __repr__(self): def __repr__(self):
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
class Entrance: class Entrance:
@@ -1017,7 +986,7 @@ class Entrance:
return False return False
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: def connect(self, region: Region, addresses=None, target=None):
self.connected_region = region self.connected_region = region
self.target = target self.target = target
self.addresses = addresses self.addresses = addresses
@@ -1027,7 +996,7 @@ class Entrance:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
world = self.parent_region.multiworld if self.parent_region else None world = self.parent_region.world if self.parent_region else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
@@ -1041,7 +1010,7 @@ class Dungeon(object):
self.dungeon_items = dungeon_items self.dungeon_items = dungeon_items
self.bosses = dict() self.bosses = dict()
self.player = player self.player = player
self.multiworld = None self.world = None
@property @property
def boss(self) -> Optional[Boss]: def boss(self) -> Optional[Boss]:
@@ -1071,7 +1040,7 @@ class Dungeon(object):
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
class Boss(): class Boss():
@@ -1105,7 +1074,7 @@ class Location:
show_in_spoiler: bool = True show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda item, state: False) always_allow = staticmethod(lambda item, state: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) access_rule = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True) item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None item: Optional[Item] = None
@@ -1116,15 +1085,13 @@ class Location:
self.parent_region = parent self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (self.always_allow(state, item) return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
def can_reach(self, state: CollectionState) -> bool: def can_reach(self, state: CollectionState) -> bool:
# self.access_rule computes faster on average, so placing it first for faster abort # self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, "Can't reach location without region" if self.access_rule(state) and self.parent_region.can_reach(state):
return self.access_rule(state) and self.parent_region.can_reach(state) return True
return False
def place_locked_item(self, item: Item): def place_locked_item(self, item: Item):
if self.item: if self.item:
@@ -1138,7 +1105,7 @@ class Location:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None world = self.parent_region.world if self.parent_region and self.parent_region.world else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
def __hash__(self): def __hash__(self):
@@ -1235,17 +1202,17 @@ class Item:
return self.__str__() return self.__str__()
def __str__(self) -> str: def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.multiworld: if self.location and self.location.parent_region and self.location.parent_region.world:
return self.location.parent_region.multiworld.get_name_string_for_object(self) return self.location.parent_region.world.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})" return f"{self.name} (Player {self.player})"
class Spoiler(): class Spoiler():
multiworld: MultiWorld world: MultiWorld
unreachables: Set[Location] unreachables: Set[Location]
def __init__(self, world): def __init__(self, world):
self.multiworld = world self.world = world
self.hashes = {} self.hashes = {}
self.entrances = OrderedDict() self.entrances = OrderedDict()
self.medallions = {} self.medallions = {}
@@ -1257,7 +1224,7 @@ class Spoiler():
self.bosses = OrderedDict() self.bosses = OrderedDict()
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int): def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.multiworld.players == 1: if self.world.players == 1:
self.entrances[(entrance, direction, player)] = OrderedDict( self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit_), ('direction', direction)]) [('entrance', entrance), ('exit', exit_), ('direction', direction)])
else: else:
@@ -1266,45 +1233,45 @@ class Spoiler():
def parse_data(self): def parse_data(self):
self.medallions = OrderedDict() self.medallions = OrderedDict()
for player in self.multiworld.get_game_players("A Link to the Past"): for player in self.world.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \ self.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][0] self.world.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \ self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][1] self.world.required_medallions[player][1]
self.locations = OrderedDict() self.locations = OrderedDict()
listed_locations = set() listed_locations = set()
lw_locations = [loc for loc in self.multiworld.get_locations() if lw_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
self.locations['Light World'] = OrderedDict( self.locations['Light World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
lw_locations]) lw_locations])
listed_locations.update(lw_locations) listed_locations.update(lw_locations)
dw_locations = [loc for loc in self.multiworld.get_locations() if dw_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
self.locations['Dark World'] = OrderedDict( self.locations['Dark World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dw_locations]) dw_locations])
listed_locations.update(dw_locations) listed_locations.update(dw_locations)
cave_locations = [loc for loc in self.multiworld.get_locations() if cave_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
self.locations['Caves'] = OrderedDict( self.locations['Caves'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
cave_locations]) cave_locations])
listed_locations.update(cave_locations) listed_locations.update(cave_locations)
for dungeon in self.multiworld.dungeons.values(): for dungeon in self.world.dungeons.values():
dungeon_locations = [loc for loc in self.multiworld.get_locations() if dungeon_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
self.locations[str(dungeon)] = OrderedDict( self.locations[str(dungeon)] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dungeon_locations]) dungeon_locations])
listed_locations.update(dungeon_locations) listed_locations.update(dungeon_locations)
other_locations = [loc for loc in self.multiworld.get_locations() if other_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.show_in_spoiler] loc not in listed_locations and loc.show_in_spoiler]
if other_locations: if other_locations:
self.locations['Other Locations'] = OrderedDict( self.locations['Other Locations'] = OrderedDict(
@@ -1314,7 +1281,7 @@ class Spoiler():
self.shops = [] self.shops = []
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
for shop in self.multiworld.shops: for shop in self.world.shops:
if not shop.custom: if not shop.custom:
continue continue
shopdata = { shopdata = {
@@ -1343,34 +1310,34 @@ class Spoiler():
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}" index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
self.shops.append(shopdata) self.shops.append(shopdata)
for player in self.multiworld.get_game_players("A Link to the Past"): for player in self.world.get_game_players("A Link to the Past"):
self.bosses[str(player)] = OrderedDict() self.bosses[str(player)] = OrderedDict()
self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name self.bosses[str(player)]["Tower Of Hera"] = self.world.get_dungeon("Tower of Hera", player).boss.name
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim" self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness", self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness",
player).boss.name player).boss.name
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name self.bosses[str(player)]["Swamp Palace"] = self.world.get_dungeon("Swamp Palace", player).boss.name
self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name self.bosses[str(player)]["Skull Woods"] = self.world.get_dungeon("Skull Woods", player).boss.name
self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name self.bosses[str(player)]["Thieves Town"] = self.world.get_dungeon("Thieves Town", player).boss.name
self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name self.bosses[str(player)]["Ice Palace"] = self.world.get_dungeon("Ice Palace", player).boss.name
self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name self.bosses[str(player)]["Misery Mire"] = self.world.get_dungeon("Misery Mire", player).boss.name
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name self.bosses[str(player)]["Turtle Rock"] = self.world.get_dungeon("Turtle Rock", player).boss.name
if self.multiworld.mode[player] != 'inverted': if self.world.mode[player] != 'inverted':
self.bosses[str(player)]["Ganons Tower Basement"] = \ self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[ self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[
'middle'].name 'middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[ self.bosses[str(player)]["Ganons Tower Top"] = self.world.get_dungeon('Ganons Tower', player).bosses[
'top'].name 'top'].name
else: else:
self.bosses[str(player)]["Ganons Tower Basement"] = \ self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name self.world.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = \ self.bosses[str(player)]["Ganons Tower Middle"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name self.world.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = \ self.bosses[str(player)]["Ganons Tower Top"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name self.world.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2" self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
self.bosses[str(player)]["Ganon"] = "Ganon" self.bosses[str(player)]["Ganon"] = "Ganon"
@@ -1400,7 +1367,7 @@ class Spoiler():
return 'Yes' if variable else 'No' return 'Yes' if variable else 'No'
def write_option(option_key: str, option_obj: type(Options.Option)): def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.multiworld, option_key)[player] res = getattr(self.world, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key) display_name = getattr(option_obj, "display_name", option_key)
try: try:
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
@@ -1410,59 +1377,60 @@ class Spoiler():
with open(filename, 'w', encoding="utf-8-sig") as outfile: with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write( outfile.write(
'Archipelago Version %s - Seed: %s\n\n' % ( 'Archipelago Version %s - Seed: %s\n\n' % (
Utils.__version__, self.multiworld.seed)) Utils.__version__, self.world.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm) outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players) outfile.write('Players: %d\n' % self.world.players)
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile) AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
for player in range(1, self.multiworld.players + 1): for player in range(1, self.world.players + 1):
if self.multiworld.players > 1: if self.world.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player]) outfile.write('Game: %s\n' % self.world.game[player])
for f_option, option in Options.per_game_common_options.items(): for f_option, option in Options.per_game_common_options.items():
write_option(f_option, option) write_option(f_option, option)
options = self.multiworld.worlds[player].option_definitions options = self.world.worlds[player].option_definitions
if options: if options:
for f_option, option in options.items(): for f_option, option in options.items():
write_option(f_option, option) write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile)
if player in self.multiworld.get_game_players("A Link to the Past"): if player in self.world.get_game_players("A Link to the Past"):
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player])) outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
outfile.write('Logic: %s\n' % self.multiworld.logic[player]) outfile.write('Logic: %s\n' % self.world.logic[player])
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player]) outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player])
outfile.write('Mode: %s\n' % self.multiworld.mode[player]) outfile.write('Mode: %s\n' % self.world.mode[player])
outfile.write('Goal: %s\n' % self.multiworld.goal[player]) outfile.write('Goal: %s\n' % self.world.goal[player])
if "triforce" in self.multiworld.goal[player]: # triforce hunt if "triforce" in self.world.goal[player]: # triforce hunt
outfile.write("Pieces available for Triforce: %s\n" % outfile.write("Pieces available for Triforce: %s\n" %
self.multiworld.triforce_pieces_available[player]) self.world.triforce_pieces_available[player])
outfile.write("Pieces required for Triforce: %s\n" % outfile.write("Pieces required for Triforce: %s\n" %
self.multiworld.triforce_pieces_required[player]) self.world.triforce_pieces_required[player])
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player]) outfile.write('Difficulty: %s\n' % self.world.difficulty[player])
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player]) outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player])
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player]) outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
if self.multiworld.shuffle[player] != "vanilla": if self.world.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed) outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
outfile.write('Shop inventory shuffle: %s\n' % outfile.write('Shop inventory shuffle: %s\n' %
bool_to_text("i" in self.multiworld.shop_shuffle[player])) bool_to_text("i" in self.world.shop_shuffle[player]))
outfile.write('Shop price shuffle: %s\n' % outfile.write('Shop price shuffle: %s\n' %
bool_to_text("p" in self.multiworld.shop_shuffle[player])) bool_to_text("p" in self.world.shop_shuffle[player]))
outfile.write('Shop upgrade shuffle: %s\n' % outfile.write('Shop upgrade shuffle: %s\n' %
bool_to_text("u" in self.multiworld.shop_shuffle[player])) bool_to_text("u" in self.world.shop_shuffle[player]))
outfile.write('New Shop inventory: %s\n' % outfile.write('New Shop inventory: %s\n' %
bool_to_text("g" in self.multiworld.shop_shuffle[player] or bool_to_text("g" in self.world.shop_shuffle[player] or
"f" in self.multiworld.shop_shuffle[player])) "f" in self.world.shop_shuffle[player]))
outfile.write('Custom Potion Shop: %s\n' % outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.multiworld.shop_shuffle[player])) bool_to_text("w" in self.world.shop_shuffle[player]))
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player]) outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player])
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player]) outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
outfile.write('Prize shuffle %s\n' % outfile.write('Prize shuffle %s\n' %
self.multiworld.shuffle_prizes[player]) self.world.shuffle_prizes[player])
if self.entrances: if self.entrances:
outfile.write('\n\nEntrances:\n\n') outfile.write('\n\nEntrances:\n\n')
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: ' outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: '
if self.multiworld.players > 1 else '', entry['entrance'], if self.world.players > 1 else '', entry['entrance'],
'<=>' if entry['direction'] == 'both' else '<=>' if entry['direction'] == 'both' else
'<=' if entry['direction'] == 'exit' else '=>', '<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()])) entry['exit']) for entry in self.entrances.values()]))
@@ -1472,7 +1440,7 @@ class Spoiler():
for dungeon, medallion in self.medallions.items(): for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}') outfile.write(f'\n{dungeon}: {medallion}')
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile) AutoWorld.call_all(self.world, "write_spoiler", outfile)
outfile.write('\n\nLocations:\n\n') outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join( outfile.write('\n'.join(
@@ -1485,11 +1453,11 @@ class Spoiler():
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
item)) for shop in self.shops)) item)) for shop in self.shops))
for player in self.multiworld.get_game_players("A Link to the Past"): for player in self.world.get_game_players("A Link to the Past"):
if self.multiworld.boss_shuffle[player] != 'none': if self.world.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
outfile.write( outfile.write(
f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n') f'\n\nBosses{(f" ({self.world.get_player_name(player)})" if self.world.players > 1 else "")}:\n')
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()])) outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
outfile.write('\n\nPlaythrough:\n\n') outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join( outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
@@ -1513,7 +1481,7 @@ class Spoiler():
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines))) path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings)) outfile.write('\n'.join(path_listings))
AutoWorld.call_all(self.multiworld, "write_spoiler_end", outfile) AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
class Tutorial(NamedTuple): class Tutorial(NamedTuple):

View File

@@ -5,7 +5,6 @@ import urllib.parse
import sys import sys
import typing import typing
import time import time
import functools
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -18,15 +17,11 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser from Utils import Version, stream_input
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
if typing.TYPE_CHECKING:
import kvui
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
# without terminal, we have to use gui mode # without terminal, we have to use gui mode
@@ -47,18 +42,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool: def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server""" """Connect to a MultiWorld Server"""
if address: self.ctx.server_address = None
self.ctx.server_address = None self.ctx.username = None
self.ctx.username = None asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
async_start(self.ctx.connect(address if address else None), name="connecting")
return True return True
def _cmd_disconnect(self) -> bool: def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server""" """Disconnect from a MultiWorld Server"""
async_start(self.ctx.disconnect(), name="disconnecting") self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True return True
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
@@ -96,18 +89,12 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_items(self): def _cmd_items(self):
"""List all item names for the currently running game.""" """List all item names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing items.")
return False
self.output(f"Item Names for {self.ctx.game}") self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name) self.output(item_name)
def _cmd_locations(self): def _cmd_locations(self):
"""List all location names for the currently running game.""" """List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}") self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name) self.output(location_name)
@@ -121,12 +108,12 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str): def default(self, raw: str):
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: class CommonContext:
@@ -143,48 +130,37 @@ class CommonContext:
# defaults # defaults
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional["asyncio.Task[None]"] = None input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional["asyncio.Task[None]"] = None server_task: typing.Optional[asyncio.Task] = None
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
disconnected_intentionally: bool = False
server: typing.Optional[Endpoint] = None server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0) server_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server current_energy_link_value: int = 0 # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str] server_address: str
password: typing.Optional[str] password: typing.Optional[str]
hint_cost: typing.Optional[int] hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str] player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations # locations
locations_checked: typing.Set[int] # local state locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int] locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem] missing_locations: typing.Set[int]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem] locations_info: typing.Dict[int, NetworkItem]
# internals # internals
# current message box through kvui # current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None _messagebox = None
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: def __init__(self, server_address, password):
# server state # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -208,9 +184,8 @@ class CommonContext:
self.locations_checked = set() # local state self.locations_checked = set() # local state
self.locations_scouted = set() self.locations_scouted = set()
self.items_received = [] self.items_received = []
self.missing_locations = set() # server state self.missing_locations = set()
self.checked_locations = set() # server state self.checked_locations = set() # server state
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {} self.locations_info = {}
self.input_queue = asyncio.Queue() self.input_queue = asyncio.Queue()
@@ -227,16 +202,6 @@ class CommonContext:
# execution # execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def suggested_address(self) -> str:
if self.server_address:
return self.server_address
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
@functools.cached_property
def raw_text_parser(self) -> RawJSONtoTextParser:
return RawJSONtoTextParser(self)
@property @property
def total_locations(self) -> typing.Optional[int]: def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected.""" """Will return None until connected."""
@@ -244,9 +209,9 @@ class CommonContext:
return len(self.checked_locations | self.missing_locations) return len(self.checked_locations | self.missing_locations)
async def connection_closed(self): async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None: if self.server and self.server.socket is not None:
await self.server.socket.close() await self.server.socket.close()
self.reset_server_state()
def reset_server_state(self): def reset_server_state(self):
self.auth = None self.auth = None
@@ -264,18 +229,13 @@ class CommonContext:
"remaining": "disabled", "remaining": "disabled",
} }
async def disconnect(self, allow_autoreconnect: bool = False): async def disconnect(self):
if not allow_autoreconnect:
self.disconnected_intentionally = True
if self.cancel_autoreconnect():
logger.info("Cancelled auto-reconnect.")
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs):
""" `msgs` JSON serializable """
if not self.server or not self.server.socket.open or self.server.socket.closed: if not self.server or not self.server.socket.open or self.server.socket.closed:
return return
await self.server.socket.send(encode(msgs)) await self.server.socket.send(encode(msgs))
@@ -303,8 +263,7 @@ class CommonContext:
logger.info('Enter slot name:') logger.info('Enter slot name:')
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None: async def send_connect(self, **kwargs):
""" send `Connect` packet to log in to server """
payload = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -315,24 +274,14 @@ class CommonContext:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) await self.send_msgs([payload])
async def console_input(self) -> str: async def console_input(self):
if self.ui:
self.ui.focus_textinput()
self.input_requests += 1 self.input_requests += 1
return await self.input_queue.get() return await self.input_queue.get()
async def connect(self, address: typing.Optional[str] = None) -> None: async def connect(self, address=None):
""" disconnect any previous connection, and open new connection to the server """
await self.disconnect() await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def cancel_autoreconnect(self) -> bool:
if self.autoreconnect_task:
self.autoreconnect_task.cancel()
self.autoreconnect_task = None
return True
return False
def slot_concerns_self(self, slot) -> bool: def slot_concerns_self(self, slot) -> bool:
if slot == self.slot: if slot == self.slot:
return True return True
@@ -340,12 +289,6 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members return self.slot in self.slot_info[slot].group_members
return False return False
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def on_print(self, args: dict): def on_print(self, args: dict):
logger.info(args["text"]) logger.info(args["text"])
@@ -377,7 +320,6 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task: if self.server_task:
@@ -403,8 +345,6 @@ class CommonContext:
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {}) cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set() needed_updates: typing.Set[str] = set()
for game in relevant_games: for game in relevant_games:
if game not in remote_datepackage_versions:
continue
remote_version: int = remote_datepackage_versions[game] remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game if remote_version == 0: # custom datapackage for this game
@@ -440,7 +380,7 @@ class CommonContext:
# DeathLink hooks # DeathLink hooks
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player.""" """Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link) self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "") text = data.get("cause", "")
@@ -471,10 +411,10 @@ class CommonContext:
if old_tags != self.tags and self.server and not self.server.socket.closed: if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: def gui_error(self, title: str, text: typing.Union[Exception, str]):
"""Displays an error messagebox""" """Displays an error messagebox"""
if not self.ui: if not self.ui:
return None return
title = title or "Error" title = title or "Error"
from kvui import MessageBox from kvui import MessageBox
if self._messagebox: if self._messagebox:
@@ -491,13 +431,6 @@ class CommonContext:
# display error # display error
self._messagebox = MessageBox(title, text, error=True) self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open() self._messagebox.open()
return self._messagebox
def _handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
exc_info = sys.exc_info()
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
@@ -534,7 +467,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
seconds_elapsed = 0 seconds_elapsed = 0
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None: async def server_loop(ctx: CommonContext, address=None):
if ctx.server and ctx.server.socket: if ctx.server and ctx.server.socket:
logger.error('Already connected') logger.error('Already connected')
return return
@@ -547,11 +480,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Please connect to an Archipelago server.') logger.info('Please connect to an Archipelago server.')
return return
ctx.cancel_autoreconnect()
if ctx._messagebox_connection_loss:
ctx._messagebox_connection_loss.dismiss()
ctx._messagebox_connection_loss = None
address = f"ws://{address}" if "://" not in address \ address = f"ws://{address}" if "://" not in address \
else address.replace("archipelago://", "ws://") else address.replace("archipelago://", "ws://")
@@ -562,9 +490,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.password = server_url.password ctx.password = server_url.password
port = server_url.port or 38281 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}') logger.info(f'Connecting to Archipelago server at {address}')
try: try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
@@ -574,25 +499,31 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Connected') logger.info('Connected')
ctx.server_address = address ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay ctx.current_reconnect_delay = ctx.starting_reconnect_delay
ctx.disconnected_intentionally = False
async for data in ctx.server.socket: async for data in ctx.server.socket:
for msg in decode(data): for msg in decode(data):
await process_server_cmd(ctx, msg) await process_server_cmd(ctx, msg)
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}") logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError: except ConnectionRefusedError as e:
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.") msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
except websockets.InvalidURI: logger.exception(msg, extra={'compact_gui': True})
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)") ctx.gui_error(msg, e)
except OSError: except websockets.InvalidURI as e:
ctx._handle_connection_loss("Failed to connect to the multiworld server") msg = 'Failed to connect to the multiworld server (invalid URI)'
except Exception: logger.exception(msg, extra={'compact_gui': True})
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}") ctx.gui_error(msg, e)
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
finally: finally:
await ctx.connection_closed() await ctx.connection_closed()
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally: if ctx.server_address:
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds") logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
assert ctx.autoreconnect_task is None asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2 ctx.current_reconnect_delay *= 2
@@ -701,10 +632,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# when /missing is used for the client side view of what is missing. # when /missing is used for the client side view of what is missing.
ctx.missing_locations = set(args["missing_locations"]) ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"]) ctx.checked_locations = set(args["checked_locations"])
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
server_url = urllib.parse.urlparse(ctx.server_address)
Utils.persistent_store("client", "last_server_address", server_url.netloc)
elif cmd == 'ReceivedItems': elif cmd == 'ReceivedItems':
start_index = args["index"] start_index = args["index"]
@@ -784,7 +711,7 @@ async def console_loop(ctx: CommonContext):
logger.exception(e) logger.exception(e)
def get_base_parser(description: typing.Optional[str] = None): def get_base_parser(description=None):
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -816,6 +743,7 @@ if __name__ == '__main__':
async def main(args): async def main(args):
ctx = TextContext(args.connect, args.password) ctx = TextContext(args.connect, args.password)
ctx.auth = args.name ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:

View File

@@ -1,5 +1,4 @@
import asyncio import asyncio
import copy
import json import json
import time import time
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
@@ -7,8 +6,7 @@ from typing import List
import Utils import Utils
from Utils import async_start from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser get_base_parser
SYSTEM_MESSAGE_ID = 0 SYSTEM_MESSAGE_ID = 0
@@ -66,37 +64,41 @@ class FF1Context(CommonContext):
def _set_message(self, msg: str, msg_id: int): def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS: if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True)) asyncio.create_task(parse_locations(self.locations_array, self, True))
elif cmd == 'Print': elif cmd == 'Print':
msg = args['text'] msg = args['text']
if ': !' not in msg: if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
def on_print_json(self, args: dict): msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
if self.ui: self._set_message(msg, SYSTEM_MESSAGE_ID)
self.ui.print_json(copy.deepcopy(args["data"])) elif cmd == 'PrintJSON':
else: print_type = args['type']
text = self.jsontotextparser(copy.deepcopy(args["data"])) item = args['item']
logger.info(text) receiving_player_id = args['receiving']
relevant = args.get("type", None) in {"Hint", "ItemSend"} receiving_player_name = self.player_names[receiving_player_id]
if relevant: sending_player_id = item.player
item = args["item"] sending_player_name = self.player_names[item.player]
# goes to this world if print_type == 'Hint':
if self.slot_concerns_self(args["receiving"]): msg = f"Hint: Your {self.item_names[item.item]} is at" \
relevant = True f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
# found in this world self._set_message(msg, item.item)
elif self.slot_concerns_self(item.player): elif print_type == 'ItemSend' and receiving_player_id != self.slot:
relevant = True if sending_player_id == self.slot:
# not related if receiving_player_id == self.slot:
else: msg = f"You found your own {self.item_names[item.item]}"
relevant = False else:
if relevant: msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
item = args["item"] else:
msg = self.raw_text_parser(copy.deepcopy(args["data"])) if receiving_player_id == sending_player_id:
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
else:
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
f"{receiving_player_name}"
self._set_message(msg, item.item) self._set_message(msg, item.item)
def run_gui(self): def run_gui(self):
@@ -181,7 +183,7 @@ async def nes_sync_task(ctx: FF1Context):
# print(data_decoded) # print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False)) asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '': if ctx.auth == '':

View File

@@ -4,12 +4,9 @@ import logging
import json import json
import string import string
import copy import copy
import re
import subprocess import subprocess
import sys
import time import time
import random import random
import typing
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -20,18 +17,12 @@ import asyncio
from queue import Queue from queue import Queue
import Utils import Utils
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client") Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio from worlds.factorio import Factorio
@@ -39,10 +30,6 @@ from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor): class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext ctx: FactorioContext
def _cmd_energy_link(self):
"""Print the status of the energy link."""
self.output(f"Energy Link: {self.ctx.energy_link_status}")
@mark_raw @mark_raw
def _cmd_factorio(self, text: str) -> bool: def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server.""" """Send the following command to the bound Factorio Server."""
@@ -59,13 +46,6 @@ class FactorioCommandProcessor(ClientCommandProcessor):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.ctx.awaiting_bridge = True self.ctx.awaiting_bridge = True
def _cmd_toggle_send_filter(self):
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
self.ctx.toggle_filter_item_sends()
def _cmd_toggle_chat(self):
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
self.ctx.toggle_bridge_chat_out()
class FactorioContext(CommonContext): class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor command_processor = FactorioCommandProcessor
@@ -85,9 +65,6 @@ class FactorioContext(CommonContext):
self.factorio_json_text_parser = FactorioJSONtoTextParser(self) self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0 self.energy_link_increment = 0
self.last_deplete = 0 self.last_deplete = 0
self.filter_item_sends: bool = False
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = True
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -104,15 +81,12 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict): def on_print(self, args: dict):
super(FactorioContext, self).on_print(args) super(FactorioContext, self).on_print(args)
if self.rcon_client: if self.rcon_client:
if not args['text'].startswith(self.player_names[self.slot] + ":"): self.print_to_game(args['text'])
self.print_to_game(args['text'])
def on_print_json(self, args: dict): def on_print_json(self, args: dict):
if self.rcon_client: if self.rcon_client:
if not self.filter_item_sends or not self.is_uninteresting_item_send(args): text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) self.print_to_game(text)
if not text.startswith(self.player_names[self.slot] + ":"):
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args) super(FactorioContext, self).on_print_json(args)
@property @property
@@ -123,15 +97,6 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}") f"{text}")
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
return "Disabled"
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict): def on_deathlink(self, data: dict):
if self.rcon_client: if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}") self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
@@ -144,7 +109,7 @@ class FactorioContext(CommonContext):
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]}) item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment: if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{ asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"] "cmd": "SetNotify", "keys": ["EnergyLink"]
}])) }]))
elif cmd == "SetReply": elif cmd == "SetReply":
@@ -158,45 +123,6 @@ class FactorioContext(CommonContext):
f"{Utils.format_SI_prefix(args['value'])}J remaining.") f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}") self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# Mirror chat sent from the UI to the Factorio server.
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
return text
async def chat_from_factorio(self, user: str, message: str) -> None:
if not self.bridge_chat_out:
return
# Pass through commands
if message.startswith("!"):
await self.send_msgs([{"cmd": "Say", "text": message}])
return
# Omit messages that contain local coordinates
if "[gps=" in message:
return
prefix = f"({user}) " if self.multiplayer else ""
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
def toggle_filter_item_sends(self) -> None:
self.filter_item_sends = not self.filter_item_sends
if self.filter_item_sends:
announcement = "Item sends are now filtered."
else:
announcement = "Item sends are no longer filtered."
logger.info(announcement)
self.print_to_game(announcement)
def toggle_bridge_chat_out(self) -> None:
self.bridge_chat_out = not self.bridge_chat_out
if self.bridge_chat_out:
announcement = "Chat is now bridged to Archipelago."
else:
announcement = "Chat is no longer bridged to Archipelago."
logger.info(announcement)
self.print_to_game(announcement)
def run_gui(self): def run_gui(self):
from kvui import GameManager from kvui import GameManager
@@ -214,6 +140,7 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext): async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher") bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1 next_bridge = time.perf_counter() + 1
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
@@ -235,7 +162,6 @@ async def game_watcher(ctx: FactorioContext):
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"] victory = data["victory"]
await ctx.update_death_link(data["death_link"]) await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory: if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -244,14 +170,14 @@ async def game_watcher(ctx: FactorioContext):
if ctx.locations_checked != research_data: if ctx.locations_checked != research_data:
bridge_logger.debug( bridge_logger.debug(
f"New researches done: " f"New researches done: "
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}") f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0) death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick: if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags: if "DeathLink" in ctx.tags:
async_start(ctx.send_death()) asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment: if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"] in_world_bridges = data["energy_bridges"]
if in_world_bridges: if in_world_bridges:
@@ -259,7 +185,7 @@ async def game_watcher(ctx: FactorioContext):
if in_world_energy < (ctx.energy_link_increment * in_world_bridges): if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill # attempt to refill
ctx.last_deplete = time.time() ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{ asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}], {"operation": "max", "value": 0}],
@@ -269,7 +195,7 @@ async def game_watcher(ctx: FactorioContext):
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges: ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges value = ctx.energy_link_increment * in_world_bridges
async_start(ctx.send_msgs([{ asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}] [{"operation": "add", "value": value}]
}])) }]))
@@ -285,8 +211,6 @@ async def game_watcher(ctx: FactorioContext):
def stream_factorio_output(pipe, queue, process): def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer(): def queuer():
while process.poll() is None: while process.poll() is None:
text = pipe.readline().strip() text = pipe.readline().strip()
@@ -319,7 +243,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if factorio_process.poll() is not None: if factorio_process.poll():
factorio_server_logger.info("Factorio server has exited.") factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set() ctx.exit_event.set()
@@ -332,25 +256,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.server: if not ctx.server:
logger.info("Established bridge to Factorio Server. " logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect") "Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True ctx.awaiting_bridge = True
factorio_server_logger.debug(msg) factorio_server_logger.debug(msg)
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
factorio_server_logger.debug(msg)
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_filter_item_sends()
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_bridge_chat_out()
else: else:
factorio_server_logger.info(msg) factorio_server_logger.info(msg)
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
if match:
await ctx.chat_from_factorio(match.group(1), match.group(2))
if ctx.rcon_client: if ctx.rcon_client:
commands = {} commands = {}
while ctx.send_index < len(ctx.items_received): while ctx.send_index < len(ctx.items_received):
@@ -371,34 +282,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.error("Aborted Factorio Server Bridge") logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set() ctx.exit_event.set()
finally: finally:
if factorio_process.poll() is not None: factorio_process.terminate()
if ctx.rcon_client: factorio_process.wait(5)
ctx.rcon_client.close()
ctx.rcon_client = None
return
sent_quit = False
if ctx.rcon_client:
# Attempt clean quit through RCON.
try:
ctx.rcon_client.send_command("/quit")
except factorio_rcon.RCONNetworkError:
pass
else:
sent_quit = True
ctx.rcon_client.close()
ctx.rcon_client = None
if not sent_quit:
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
factorio_process.terminate()
try:
factorio_process.wait(10)
except subprocess.TimeoutExpired:
factorio_process.kill()
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
@@ -472,8 +361,6 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args): async def main(args):
ctx = FactorioContext(args.connect, args.password) ctx = FactorioContext(args.connect, args.password)
ctx.filter_item_sends = initial_filter_item_sends
ctx.bridge_chat_out = initial_bridge_chat_out
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled: if gui_enabled:
@@ -526,12 +413,6 @@ if __name__ == '__main__':
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings: if server_settings:
server_settings = os.path.abspath(server_settings) server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)): if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")

466
Fill.py
View File

@@ -4,10 +4,9 @@ import collections
import itertools import itertools
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
@@ -23,8 +22,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
@@ -71,66 +69,60 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else: else:
# we filled all reachable spots. # we filled all reachable spots.
if swap: # try swapping this item with previously placed items
# try swapping this item with previously placed items for (i, location) in enumerate(placements):
for (i, location) in enumerate(placements): placed_item = location.item
placed_item = location.item # Unplaceable items can sometimes be swapped infinitely. Limit the
# Unplaceable items can sometimes be swapped infinitely. Limit the # number of times we will swap an individual item to prevent this
# number of times we will swap an individual item to prevent this swap_count = swapped_items[placed_item.player,
swap_count = swapped_items[placed_item.player, placed_item.name]
placed_item.name] if swap_count > 1:
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item])
# swap_state assumes we can collect placed item before item_to_place
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations, which could happen with rules
# that want to not have both items. Left in until removal is proven useful.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue continue
else:
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state)
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations
prev_state = swap_state.copy()
prev_state.collect(placed_item)
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place) unplaced_items.append(item_to_place)
continue continue
world.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock spot_to_fill.locked = lock
placements.append(spot_to_fill) placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement spot_to_fill.event = item_to_place.advancement
if on_place:
on_place(spot_to_fill)
if len(unplaced_items) > 0 and len(locations) > 0: if len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
@@ -144,207 +136,33 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
def remaining_fill(world: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item]) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations):
if location.item_rule(item_to_place):
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
break
else:
# we filled all reachable spots.
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
if swapped_items[placed_item.player,
placed_item.name] > 1:
continue
location.item = None
placed_item.location = None
if location.item_rule(item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swapped_items[placed_item.player,
placed_item.name] += 1
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
early_priority_locations.append(loc)
else:
early_locations.append(loc)
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
if early_items_count[(item.name, item.player)][1]:
early_local_prog_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
else:
early_prog_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
else:
if early_items_count[(item.name, item.player)][1]:
early_local_rest_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
else:
early_rest_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
item_indexes_to_remove.add(i)
if early_items_count[(item.name, item.player)] == [0, 0]:
del early_items_count[(item.name, item.player)]
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in world.player_ids:
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_rest_items[player], lock=True)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
early_locations += early_priority_locations
for player in world.player_ids:
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_prog_items[player], lock=True)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
f"{len(unplaced_early_items)} items early.")
itempool += unplaced_early_items
fill_locations.extend(early_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(world: MultiWorld) -> None: def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations()) fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
itempool = sorted(world.itempool) itempool = sorted(world.itempool)
world.random.shuffle(itempool) world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = [] progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = [] nonexcludeditempool: typing.List[Item] = []
filleritempool: typing.List[Item] = [] localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool: typing.List[Item] = []
restitempool: typing.List[Item] = []
for item in itempool: for item in itempool:
if item.advancement: if item.advancement:
progitempool.append(item) progitempool.append(item)
elif item.useful: elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
usefulitempool.append(item) nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player].value:
localrestitempool[item.player].append(item)
elif item.name in world.non_local_items[item.player].value:
nonlocalrestitempool.append(item)
else: else:
filleritempool.append(item) restitempool.append(item)
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) call_all(world, "fill_hook", progitempool, nonexcludeditempool,
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = { locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType} loc_type: [] for loc_type in LocationProgressType}
@@ -356,44 +174,60 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT] defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED] excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
lock_later = []
def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
if prioritylocations: if prioritylocations:
# "priority fill"
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool) fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool: if progitempool:
raise FillError( raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
accessibility_corrections(world, world.state, defaultlocations)
for location in lock_later: if nonexcludeditempool:
if location.item: world.random.shuffle(defaultlocations)
location.locked = True # needs logical fill to not conflict with local items
del mark_for_locking, lock_later fill_restrictive(
world, world.state, defaultlocations, nonexcludeditempool)
if nonexcludeditempool:
raise FillError(
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
inaccessible_location_rules(world, world.state, defaultlocations) defaultlocations = defaultlocations + excludedlocations
world.random.shuffle(defaultlocations)
remaining_fill(world, excludedlocations, filleritempool) if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
if excludedlocations: local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
raise FillError( for location in defaultlocations:
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") local_locations[location.player].append(location)
for player_locations in local_locations.values():
world.random.shuffle(player_locations)
restitempool = usefulitempool + filleritempool for player, items in localrestitempool.items(): # items already shuffled
player_local_locations = local_locations[player]
for item_to_place in items:
if not player_local_locations:
logging.warning(f"Ran out of local locations for player {player}, "
f"cannot place {item_to_place}.")
break
spot_to_fill = player_local_locations.pop()
world.push_item(spot_to_fill, item_to_place, False)
defaultlocations.remove(spot_to_fill)
remaining_fill(world, defaultlocations, restitempool) for item_to_place in nonlocalrestitempool:
for i, location in enumerate(defaultlocations):
if location.player != item_to_place.player:
world.push_item(defaultlocations.pop(i), item_to_place, False)
break
else:
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
f"Too many non-local items for too few remaining locations.")
unplaced = restitempool world.random.shuffle(defaultlocations)
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = defaultlocations unfilled = defaultlocations
if unplaced or unfilled: if unplaced or unfilled:
@@ -407,6 +241,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
logging.info(f'Per-Player counts: {print_data})') logging.info(f'Per-Player counts: {print_data})')
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world: MultiWorld) -> None: def flood_items(world: MultiWorld) -> None:
# get items to distribute # get items to distribute
world.random.shuffle(world.itempool) world.random.shuffle(world.itempool)
@@ -683,17 +526,6 @@ def distribute_planned(world: MultiWorld) -> None:
else: else:
warn(warning, force) warn(warning, force)
swept_state = world.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(world.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in world.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
# TODO: remove. Preferably by implementing key drop # TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup world_name_lookup = world.world_name_lookup
@@ -709,39 +541,7 @@ def distribute_planned(world: MultiWorld) -> None:
if 'from_pool' not in block: if 'from_pool' not in block:
block['from_pool'] = True block['from_pool'] = True
if 'world' not in block: if 'world' not in block:
target_world = False block['world'] = False
else:
target_world = block['world']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
block['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds = {world_name_lookup[target_world]}
block['world'] = worlds
items: block_value = [] items: block_value = []
if "items" in block: if "items" in block:
items = block["items"] items = block["items"]
@@ -778,17 +578,6 @@ def distribute_planned(world: MultiWorld) -> None:
for key, value in locations.items(): for key, value in locations.items():
location_list += [key] * value location_list += [key] * value
locations = location_list locations = location_list
if "early_locations" in locations:
locations.remove("early_locations")
for player in worlds:
locations += early_locations[player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations block['locations'] = locations
if not block['count']: if not block['count']:
@@ -824,11 +613,38 @@ def distribute_planned(world: MultiWorld) -> None:
for placement in plando_blocks: for placement in plando_blocks:
player = placement['player'] player = placement['player']
try: try:
worlds = placement['world'] target_world = placement['world']
locations = placement['locations'] locations = placement['locations']
items = placement['items'] items = placement['items']
maxcount = placement['count']['target'] maxcount = placement['count']['target']
from_pool = placement['from_pool'] from_pool = placement['from_pool']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
placement['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds = {world_name_lookup[target_world]}
candidates = list(location for location in world.get_unfilled_locations_for_players(locations, candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds)) worlds))

View File

@@ -23,6 +23,7 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain from Main import main as ERmain
from BaseClasses import seeddigits, get_seed from BaseClasses import seeddigits, get_seed
import Options import Options
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import copy import copy
@@ -62,7 +63,7 @@ class PlandoSettings(enum.IntFlag):
def __str__(self) -> str: def __str__(self) -> str:
if self.value: if self.value:
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value) return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
return "Off" return "Off"
@@ -83,6 +84,11 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1)) parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
help="Path to the 1.0 JP SM Baserom.")
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path), parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults["race"]) parser.add_argument('--race', action='store_true', default=defaults["race"])
@@ -154,12 +160,11 @@ def main(args=None, callback=ERmain):
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())} weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items(): for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}: for yaml in yaml_data:
for yaml in yaml_data: print(f"P{player_id} Weights: {filename} >> "
print(f"P{player_id} Weights: {filename} >> " f"{get_choice('description', yaml, 'No description specified')}")
f"{get_choice('description', yaml, 'No description specified')}") player_files[player_id] = filename
player_files[player_id] = filename player_id += 1
player_id += 1
args.multi = max(player_id - 1, args.multi) args.multi = max(player_id - 1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
@@ -178,6 +183,10 @@ def main(args=None, callback=ERmain):
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
@@ -233,8 +242,8 @@ def main(args=None, callback=ERmain):
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name): if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output: if args.yaml_output:
import yaml import yaml
@@ -317,11 +326,11 @@ class SafeDict(dict):
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1 name_counter[name] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number, new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(number if number > 1 else ''), NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player, player=player,
PLAYER=(player if player > 1 else ''))) PLAYER=(player if player > 1 else '')))
new_name = new_name.strip()[:16] new_name = new_name.strip()[:16]
@@ -337,6 +346,19 @@ def prefer_int(input_data: str) -> Union[str, int]:
return input_data return input_data
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none',
'none': 'none',
'basic': 'basic',
'full': 'full',
'chaos': 'chaos',
'singularity': 'singularity'
}
goals = { goals = {
'ganon': 'ganon', 'ganon': 'ganon',
'crystals': 'crystals', 'crystals': 'crystals',
@@ -378,7 +400,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if option_key in options: if option_key in options:
if options[option_key].supports_weighting: if options[option_key].supports_weighting:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
return category_dict[option_key] return options[option_key]
if game == "A Link to the Past": # TODO wow i hate this if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode", if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra", "triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
@@ -443,7 +465,42 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings): def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif PlandoSettings.bosses in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
loc, boss_name = boss.split("-")
if boss_name not in available_boss_names:
raise ValueError(f"Unknown Boss name {boss_name}")
if loc not in available_boss_locations:
raise ValueError(f"Unknown Boss Location {loc}")
level = ''
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
# split off level
loc = loc.split(" ")
level = f" {loc[-1]}"
loc = " ".join(loc[:-1])
loc = loc.title().replace("Of", "of")
if not Bosses.can_place_boss(boss_name.title(), loc, level):
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
bosses.append(boss)
elif boss not in available_boss_names:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
else:
bosses.append(boss)
return ";".join(bosses + [remainder_shuffle])
else:
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
if option_key in game_weights: if option_key in game_weights:
try: try:
if not option.supports_weighting: if not option.supports_weighting:
@@ -454,9 +511,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e: except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else: else:
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) if hasattr(player_option, "verify"):
player_option.verify(AutoWorldRegister.world_types[ret.game])
else: else:
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random" setattr(ret, option_key, option(option.default))
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses): def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
@@ -500,11 +558,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
if ret.game in AutoWorldRegister.world_types: if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.option_definitions.items(): for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option)
for option_key, option in Options.per_game_common_options.items(): for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option # skip setting this option if already set from common_options, defaulting to root option
if not (option_key in Options.common_options and option_key not in game_weights): if not (option_key in Options.common_options and option_key not in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option)
if PlandoSettings.items in plando_options: if PlandoSettings.items in plando_options:
ret.plando_items = game_weights.get("plando_items", []) ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time": if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
@@ -587,6 +645,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.item_functionality = get_choice_legacy('item_functionality', weights) ret.item_functionality = get_choice_legacy('item_functionality', weights)
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
ret.enemy_damage = {None: 'default', ret.enemy_damage = {None: 'default',
'default': 'default', 'default': 'default',

View File

@@ -10,21 +10,16 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse import argparse
import itertools
import shlex
import subprocess
import sys
from enum import Enum, auto
from os.path import isfile from os.path import isfile
from shutil import which import sys
from typing import Iterable, Sequence, Callable, Union, Optional from typing import Iterable, Sequence, Callable, Union, Optional
import subprocess
if __name__ == "__main__": import itertools
import ModuleUpdate from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
ModuleUpdate.update()
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
is_windows, is_macos, is_linux is_windows, is_macos, is_linux
from shutil import which
import shlex
from enum import Enum, auto
def open_host_yaml(): def open_host_yaml():
@@ -70,7 +65,6 @@ def browse_files():
webbrowser.open(file) webbrowser.open(file)
# noinspection PyArgumentList
class Type(Enum): class Type(Enum):
TOOL = auto() TOOL = auto()
FUNC = auto() # not a real component FUNC = auto() # not a real component
@@ -132,7 +126,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI # SNI
Component('SNI Client', 'SNIClient', Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')), file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio # Factorio
Component('Factorio Client', 'FactorioClient'), Component('Factorio Client', 'FactorioClient'),
@@ -145,15 +139,10 @@ components: Iterable[Component] = (
Component('OoT Adjuster', 'OoTAdjuster'), Component('OoT Adjuster', 'OoTAdjuster'),
# FF1 # FF1
Component('FF1 Client', 'FF1Client'), Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder # ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'), Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2 # Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'), Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions # Functions
Component('Open host.yaml', func=open_host_yaml), Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch), Component('Open Patch', func=open_patch),

View File

@@ -26,9 +26,7 @@ ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
GAME_ALTTP = "A Link to the Past"
class AdjusterWorld(object): class AdjusterWorld(object):
@@ -85,9 +83,9 @@ def main():
parser.add_argument('--ow_palettes', default='default', parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) 'sick'])
# parser.add_argument('--link_palettes', default='default', parser.add_argument('--link_palettes', default='default',
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
# 'sick']) 'sick'])
parser.add_argument('--shield_palettes', default='default', parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) 'sick'])
@@ -141,7 +139,7 @@ def adjust(args):
vanillaRom = args.baserom vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom): if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom) vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() == '.aplttp': if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch import Patch
meta, args.rom = Patch.create_rom_file(args.rom) meta, args.rom = Patch.create_rom_file(args.rom)
@@ -197,7 +195,7 @@ def adjustGUI():
romEntry2 = Entry(romDialogFrame, textvariable=romVar2) romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
def RomSelect2(): def RomSelect2():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")]) rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
romVar2.set(rom) romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2) romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
@@ -727,7 +725,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply) vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame) autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W) autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files") filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
filler.pack(side=TOP, expand=True, fill=X) filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask') askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5) askRadio.pack(side=LEFT, padx=5, pady=5)
@@ -754,7 +752,6 @@ class SpriteSelector():
self.window['pady'] = 5 self.window['pady'] = 5
self.spritesPerRow = 32 self.spritesPerRow = 32
self.all_sprites = [] self.all_sprites = []
self.invalid_sprites = []
self.sprite_pool = spritePool self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt): def open_custom_sprite_dir(_evt):
@@ -836,13 +833,6 @@ class SpriteSelector():
self.window.focus() self.window.focus()
tkinter_center_window(self.window) tkinter_center_window(self.window)
if self.invalid_sprites:
invalid = sorted(self.invalid_sprites)
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
msg = f"{invalid[0]} "
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
def remove_from_sprite_pool(self, button, spritename): def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename)) self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button) self.spritePoolButtons.buttons.remove(button)
@@ -907,13 +897,7 @@ class SpriteSelector():
sprites = [] sprites = []
for file in os.listdir(path): for file in os.listdir(path):
if file == '.gitignore': sprites.append((file, Sprite(os.path.join(path, file))))
continue
sprite = Sprite(os.path.join(path, file))
if sprite.valid:
sprites.append((file, sprite))
else:
self.invalid_sprites.append(file)
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip()) sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())

109
Main.py
View File

@@ -8,15 +8,15 @@ import concurrent.futures
import pickle import pickle
import tempfile import tempfile
import zipfile import zipfile
from typing import Dict, List, Tuple, Optional, Set from typing import Dict, Tuple, Optional, Set
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import is_main_entrance from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
from worlds import AutoWorld from worlds import AutoWorld
ordered_areas = ( ordered_areas = (
@@ -70,6 +70,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.required_medallions = args.required_medallions.copy() world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy() world.game = args.game.copy()
world.player_name = args.name.copy() world.player_name = args.name.copy()
world.enemizer = args.enemizercli
world.sprite = args.sprite.copy() world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
@@ -80,30 +81,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Found World Types:") logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
numlength = 8
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | " f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):{location_count}} " f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{location_digits}})") f"{max(cls.location_id_to_name):{numlength}})")
del item_digits, location_digits, item_count, location_count
AutoWorld.call_stage(world, "assert_generate") AutoWorld.call_stage(world, "assert_generate")
@@ -122,7 +108,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].value.add('Triforce Piece') world.local_items[player].value.add('Triforce Piece')
# Not possible to place pendants/crystals outside boss prizes yet. # Not possible to place pendants/crystals out side of boss prizes yet.
world.non_local_items[player].value -= item_name_groups['Pendants'] world.non_local_items[player].value -= item_name_groups['Pendants']
world.non_local_items[player].value -= item_name_groups['Crystals'] world.non_local_items[player].value -= item_name_groups['Crystals']
@@ -137,7 +123,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
if world.players > 1: if world.players > 1:
locality_rules(world) for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else: else:
world.non_local_items[1].value = set() world.non_local_items[1].value = set()
world.local_items[1].value = set() world.local_items[1].value = set()
@@ -154,10 +142,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# temporary home for item links, should be moved out of Main # temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items(): for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ def find_common_pool(players: Set[int], shared_pool: Set[str]):
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]] classifications = collections.defaultdict(int)
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players} counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool: for item in world.itempool:
if item.player in counters and item.name in shared_pool: if item.player in counters and item.name in shared_pool:
@@ -167,7 +153,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in players.copy(): for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]): if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player) players.remove(player)
del (counters[player]) del(counters[player])
if not players: if not players:
return None, None return None, None
@@ -179,14 +165,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
counters[player][item] = count counters[player][item] = count
else: else:
for player in players: for player in players:
del (counters[player][item]) del(counters[player][item])
return counters, classifications return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"]) common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count: if not common_item_count:
continue continue
new_itempool: List[Item] = [] new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items(): for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count): for _ in range(item_count):
new_item = group["world"].create_item(item_name) new_item = group["world"].create_item(item_name)
@@ -264,9 +250,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append( output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
# collect ER hint info # collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {} er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) world.shuffle[player] != "vanilla" or world.retro_caves[player]}
for region in world.regions:
if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
checks_in_area = {player: {area: list() for area in ordered_areas} checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)} for player in range(1, world.players + 1)}
@@ -276,23 +277,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for location in world.get_filled_locations(): for location in world.get_filled_locations():
if type(location.address) is int: if type(location.address) is int:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != "A Link to the Past": if location.game != "A Link to the Past":
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
else: elif location.parent_region.dungeon:
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance) dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
if location.parent_region.dungeon: 'Inverted Ganons Tower': 'Ganons Tower'} \
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
'Inverted Ganons Tower': 'Ganons Tower'} \ checks_in_area[location.player][dungeonname].append(location.address)
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) elif location.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player][dungeonname].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.LightWorld: elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Dark World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld: elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld: elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1 checks_in_area[location.player]["Total"] += 1
oldmancaves = [] oldmancaves = []
@@ -306,7 +306,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
player = region.player player = region.player
location_id = SHOP_ID_START + total_shop_slots + index location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = region.get_connecting_entrance(is_main_entrance) main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld: if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id) checks_in_area[player]["Light World"].append(location_id)
else: else:
@@ -341,6 +341,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, world_precollected in world.precollected_items.items()} for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in world.player_ids: for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data() slot_data[slot] = world.worlds[slot].fill_slot_data()

View File

@@ -13,12 +13,10 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
if not update_ran: if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")): for entry in os.scandir(os.path.join(local_dir, "worlds")):
# skip .* (hidden / disabled) folders if entry.is_dir():
if not entry.name.startswith("."): req_file = os.path.join(entry.path, "requirements.txt")
if entry.is_dir(): if os.path.exists(req_file):
req_file = os.path.join(entry.path, "requirements.txt") requirements_files.add(req_file)
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command(): def update_command():
@@ -39,25 +37,11 @@ def update(yes=False, force=False):
path = os.path.join(os.path.dirname(__file__), req_file) path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile: with open(path) as requirementsfile:
for line in requirementsfile: for line in requirementsfile:
if line.startswith(("https://", "git+https://")): if line.startswith('https://'):
# extract name and version for url # extract name and version from url
rest = line.split('/')[-1] wheel = line.split('/')[-1]
line = "" name, version, _ = wheel.split('-', 2)
if "#egg=" in rest: line = f'{name}=={version}'
# from egg info
rest, egg = rest.split("#egg=", 1)
egg = egg.split(";", 1)[0]
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
line = egg
else:
egg = ""
if "@" in rest and not line:
raise ValueError("Can't deduce version from requirement")
elif not line:
# from filename
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
name, version, _ = rest.split("-", 2)
line = f'{egg or name}=={version}'
requirements = pkg_resources.parse_requirements(line) requirements = pkg_resources.parse_requirements(line)
for requirement in requirements: for requirement in requirements:
requirement = str(requirement) requirement = str(requirement)

View File

@@ -31,12 +31,11 @@ except ImportError:
import NetUtils import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start from Utils import version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType SlotType
min_client_version = Version(0, 1, 6) min_client_version = Version(0, 1, 6)
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
colorama.init() colorama.init()
# functions callable on storable data on the server by clients # functions callable on storable data on the server by clients
@@ -126,7 +125,6 @@ class Context:
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_item_and_group_names: typing.Dict[str, typing.Set[str]]
forced_auto_forfeits: typing.Dict[str, bool] forced_auto_forfeits: typing.Dict[str, bool]
non_hintable_names: typing.Dict[str, typing.Set[str]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
@@ -197,7 +195,7 @@ class Context:
self.item_name_groups = {} self.item_name_groups = {}
self.all_item_and_group_names = {} self.all_item_and_group_names = {}
self.forced_auto_forfeits = collections.defaultdict(lambda: False) self.forced_auto_forfeits = collections.defaultdict(lambda: False)
self.non_hintable_names = collections.defaultdict(frozenset) self.non_hintable_names = {}
self._load_game_data() self._load_game_data()
self._init_game_data() self._init_game_data()
@@ -222,11 +220,11 @@ class Context:
self.all_item_and_group_names[game_name] = \ self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: def item_names_for_game(self, game: str) -> typing.Dict[str, int]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None return self.gamespackage[game]["item_name_to_id"]
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: def location_names_for_game(self, game: str) -> typing.Dict[str, int]:
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None return self.gamespackage[game]["location_name_to_id"]
# General networking # General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool: async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
@@ -273,16 +271,16 @@ class Context:
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
async def disconnect(self, endpoint: Client): async def disconnect(self, endpoint: Client):
if endpoint in self.endpoints: if endpoint in self.endpoints:
@@ -293,27 +291,20 @@ class Context:
# text # text
def notify_all(self, text: str): def notify_all(self, text):
logging.info("Notice (all): %s" % text) logging.info("Notice (all): %s" % text)
broadcast_text_all(self, text) self.broadcast_all([{"cmd": "Print", "text": text}])
def notify_client(self, client: Client, text: str): def notify_client(self, client: Client, text: str):
if not client.auth: if not client.auth:
return return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
if client.version >= print_command_compatability_threshold: asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]): def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth: if not client.auth:
return return
if client.version >= print_command_compatability_threshold: asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading # loading
@@ -594,7 +585,6 @@ class Context:
forfeit_player(self, client.team, client.slot) forfeit_player(self, client.team, client.slot)
elif self.forced_auto_forfeits[self.games[client.slot]]: elif self.forced_auto_forfeits[self.games[client.slot]]:
forfeit_player(self, client.team, client.slot) forfeit_player(self, client.team, client.slot)
self.save() # save goal completion flag
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
@@ -627,7 +617,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
continue continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients: for client in clients:
async_start(ctx.send_msgs(client, client_hints)) asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int): def update_aliases(ctx: Context, team: int):
@@ -636,7 +626,7 @@ def update_aliases(ctx: Context, team: int):
for clients in ctx.clients[team].values(): for clients in ctx.clients[team].values():
for client in clients: for client in clients:
async_start(ctx.send_encoded_msgs(client, cmd)) asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path: str = "/", ctx: Context = None): async def server(websocket, path: str = "/", ctx: Context = None):
@@ -731,37 +721,20 @@ async def on_client_left(ctx: Context, client: Client):
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def countdown(ctx: Context, timer: int): async def countdown(ctx: Context, timer):
broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s") ctx.notify_all(f'[Server]: Starting countdown of {timer}s')
if ctx.countdown_timer: if ctx.countdown_timer:
ctx.countdown_timer = timer # timer is already running, set it to a different time ctx.countdown_timer = timer # timer is already running, set it to a different time
else: else:
ctx.countdown_timer = timer ctx.countdown_timer = timer
while ctx.countdown_timer > 0: while ctx.countdown_timer > 0:
broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}") ctx.notify_all(f'[Server]: {ctx.countdown_timer}')
ctx.countdown_timer -= 1 ctx.countdown_timer -= 1
await asyncio.sleep(1) await asyncio.sleep(1)
broadcast_countdown(ctx, 0, f"[Server]: GO") ctx.notify_all(f'[Server]: GO')
ctx.countdown_timer = 0 ctx.countdown_timer = 0
def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}):
old_clients, new_clients = [], []
for teams in ctx.clients.values():
for clients in teams.values():
for client in clients:
new_clients.append(client) if client.version >= print_command_compatability_threshold \
else old_clients.append(client)
ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }])
ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_countdown(ctx: Context, timer: int, message: str):
broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer})
def get_players_string(ctx: Context): def get_players_string(ctx: Context):
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth} auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
@@ -814,7 +787,7 @@ def send_new_items(ctx: Context):
items = get_received_items(ctx, team, slot, client.remote_items) items = get_received_items(ctx, team, slot, client.remote_items)
if len(start_inventory) + len(items) > client.send_index: if len(start_inventory) + len(items) > client.send_index:
first_new_item = max(0, client.send_index - len(start_inventory)) first_new_item = max(0, client.send_index - len(start_inventory))
async_start(ctx.send_msgs(client, [{ asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems", "cmd": "ReceivedItems",
"index": client.send_index, "index": client.send_index,
"items": start_inventory[client.send_index:] + items[first_new_item:]}])) "items": start_inventory[client.send_index:] + items[first_new_item:]}]))
@@ -901,14 +874,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save() ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]: def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]:
hints = [] hints = []
slots: typing.Set[int] = {slot} slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items(): for group_id, group in ctx.groups.items():
if slot in group: if slot in group:
slots.add(group_id) slots.add(group_id)
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name]
for finding_player, check_data in ctx.locations.items(): for finding_player, check_data in ctx.locations.items():
for location_id, (item_id, receiving_player, item_flags) in check_data.items(): for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id: if receiving_player in slots and item_id == seeked_item_id:
@@ -998,11 +971,7 @@ class CommandMeta(type):
return super(CommandMeta, cls).__new__(cls, name, bases, attrs) return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
_Return = typing.TypeVar("_Return") def mark_raw(function):
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
function.raw_text = True function.raw_text = True
return function return function
@@ -1090,7 +1059,7 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10) timer = int(seconds, 10)
except ValueError: except ValueError:
timer = 10 timer = 10
async_start(countdown(self.ctx, timer)) asyncio.create_task(countdown(self.ctx, timer))
return True return True
def _cmd_options(self): def _cmd_options(self):
@@ -1332,8 +1301,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
if not input_text: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot]}
@@ -1342,33 +1309,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. " self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.") f"You have {points_available} points.")
return True return True
elif input_text.isnumeric():
game = self.ctx.games[self.client.slot]
hint_id = int(input_text)
hint_name = self.ctx.item_names[hint_id] \
if not for_location and hint_id in self.ctx.item_names \
else self.ctx.location_names[hint_id] \
if for_location and hint_id in self.ctx.location_names \
else None
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
else: else:
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
if game not in self.ctx.all_item_and_group_names:
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
return False
names = self.ctx.location_names_for_game(game) \ names = self.ctx.location_names_for_game(game) \
if for_location else \ if for_location else \
self.ctx.all_item_and_group_names[game] self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, names) hint_name, usable, response = get_intended_text(input_text,
names)
if usable: if usable:
if hint_name in self.ctx.non_hintable_names[game]: if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1382,69 +1329,63 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
cost = self.ctx.get_hint_cost(self.client.slot)
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
notify_hints(self.ctx, self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
hint = not_found_hints.pop()
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
f" You have {points_available} and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
elif hints:
self.output(
"There may be more hintables, you can rerun the command to find more.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
else:
self.output("Nothing found. Item/Location may not exist.")
return False
else: else:
self.output(response) self.output(response)
return False return False
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
notify_hints(self.ctx, self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
hint = not_found_hints.pop()
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
f" You have {points_available} and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
elif hints:
self.output(
"There may be more hintables, you can rerun the command to find more.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
else:
if points_available >= cost:
self.output("Nothing found. Item/Location may not exist.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
return False
@mark_raw @mark_raw
def _cmd_hint(self, item_name: str = "") -> bool: def _cmd_hint(self, item_name: str = "") -> bool:
"""Use !hint {item_name}, """Use !hint {item_name},
@@ -1771,7 +1712,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
async_start(self.ctx.server.ws_server._close()) asyncio.create_task(self.ctx.server.ws_server._close())
if self.ctx.shutdown_task: if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel() self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set() self.ctx.exit_event.set()
@@ -1802,33 +1743,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response) self.output(response)
return False return False
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
""" returns (team, slot, player name) """
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
# first match case
for (team, slot), name in self.ctx.player_names.items():
if name == input_name:
return team, slot, name
# if no case-sensitive match, then match without case only if there's only 1 match
input_lower = input_name.lower()
match: typing.Optional[typing.Tuple[int, int, str]] = None
for (team, slot), name in self.ctx.player_names.items():
lowered = name.lower()
if lowered == input_lower:
if match:
return None # ambiguous input_name
match = (team, slot, name)
return match
@mark_raw @mark_raw
def _cmd_collect(self, player_name: str) -> bool: def _cmd_collect(self, player_name: str) -> bool:
"""Send out the remaining items to player.""" """Send out the remaining items to player."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, _ = player if name.lower() == seeked_player:
collect_player(self.ctx, team, slot) collect_player(self.ctx, team, slot)
return True return True
self.output(f"Could not find player {player_name} to collect") self.output(f"Could not find player {player_name} to collect")
return False return False
@@ -1841,11 +1763,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_forfeit(self, player_name: str) -> bool: def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients.""" """Send out the remaining items from a player to their intended recipients."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, _ = player if name.lower() == seeked_player:
forfeit_player(self.ctx, team, slot) forfeit_player(self.ctx, team, slot)
return True return True
self.output(f"Could not find player {player_name} to release") self.output(f"Could not find player {player_name} to release")
return False return False
@@ -1853,12 +1775,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool: def _cmd_allow_forfeit(self, player_name: str) -> bool:
"""Allow the specified player to use the !release command.""" """Allow the specified player to use the !release command."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, name = player if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = True self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {name} is now allowed to use the !release command at any time.") self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
return True return True
self.output(f"Could not find player {player_name} to allow the !release command for.") self.output(f"Could not find player {player_name} to allow the !release command for.")
return False return False
@@ -1866,12 +1788,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool: def _cmd_forbid_forfeit(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) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, name = player if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.") self.output(
return True f"Player {player_name} has to follow the server restrictions on use of the !release command.")
return True
self.output(f"Could not find player {player_name} to forbid the !release command for.") self.output(f"Could not find player {player_name} to forbid the !release command for.")
return False return False
@@ -1910,25 +1833,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
team, slot = self.ctx.player_name_lookup[seeked_player] team, slot = self.ctx.player_name_lookup[seeked_player]
item_name = " ".join(item_name)
game = self.ctx.games[slot] game = self.ctx.games[slot]
full_name = " ".join(item_name) item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game])
if full_name.isnumeric():
item, usable, response = int(full_name), True, None
elif game in self.ctx.all_item_and_group_names:
item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game])
else:
self.output("Can't look up item for unknown game. Hint for ID instead.")
return False
if usable: if usable:
if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]: if item_name in self.ctx.item_name_groups[game]:
hints = [] hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]: for item_name_from_group in self.ctx.item_name_groups[game][item_name]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group)) hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name or id else: # item name
hints = collect_hints(self.ctx, team, slot, item) hints = collect_hints(self.ctx, team, slot, item_name)
if hints: if hints:
notify_hints(self.ctx, team, hints) notify_hints(self.ctx, team, hints)
@@ -1949,22 +1864,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
team, slot = self.ctx.player_name_lookup[seeked_player] team, slot = self.ctx.player_name_lookup[seeked_player]
game = self.ctx.games[slot] location_name = " ".join(location_name)
full_name = " ".join(location_name) location_name, usable, response = get_intended_text(location_name,
self.ctx.location_names_for_game(self.ctx.games[slot]))
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
elif self.ctx.location_names_for_game(game) is not None:
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
else:
self.output("Can't look up location for unknown game. Hint for ID instead.")
return False
if usable: if usable:
if isinstance(location, int): hints = collect_hint_location_name(self.ctx, team, slot, location_name)
hints = collect_hint_location_id(self.ctx, team, slot, location)
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints: if hints:
notify_hints(self.ctx, team, hints) notify_hints(self.ctx, team, hints)
else: else:
@@ -2084,7 +1988,7 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown) await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values(): if not ctx.client_activity_timers.values():
async_start(ctx.server.ws_server._close()) asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set() ctx.exit_event.set()
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:
@@ -2095,7 +1999,7 @@ async def auto_shutdown(ctx, to_cancel=None):
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds() seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0: if seconds < 0:
async_start(ctx.server.ws_server._close()) asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set() ctx.exit_event.set()
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:
@@ -2114,28 +2018,15 @@ async def main(args: argparse.Namespace):
args.auto_shutdown, args.compatibility, args.log_network) args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata data_filename = args.multidata
if not data_filename: try:
try: if not data_filename:
filetypes = (("Multiworld data", (".archipelago", ".zip")),) filetypes = (("Multiworld data", (".archipelago", ".zip")),)
data_filename = Utils.open_filename("Select multiworld data", filetypes) data_filename = Utils.open_filename("Select multiworld data", filetypes)
except Exception as e:
if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)):
if not isinstance(e, ImportError):
logging.error(f"Failed to load tkinter ({e})")
logging.info("Pass a multidata filename on command line to run headless.")
exit(1)
raise
if not data_filename:
logging.info("No file selected. Exiting.")
exit(1)
try:
ctx.load(data_filename, args.use_embedded_options) ctx.load(data_filename, args.use_embedded_options)
except Exception as e: except Exception as e:
logging.exception(f"Failed to read multiworld data ({e})") logging.exception('Failed to read multiworld data (%s)' % e)
raise raise
ctx.init_save(not args.disable_save) ctx.init_save(not args.disable_save)

View File

@@ -100,7 +100,7 @@ _encode = JSONEncoder(
).encode ).encode
def encode(obj: typing.Any) -> str: def encode(obj):
return _encode(_scan_for_TypedTuples(obj)) return _encode(_scan_for_TypedTuples(obj))

View File

@@ -5,11 +5,9 @@ import multiprocessing
import subprocess import subprocess
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
# CommonClient import first to trigger ModuleUpdater from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser ClientCommandProcessor, logger, get_base_parser
import Utils import Utils
from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
@@ -70,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext): if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True self.ctx.deathlink_client_override = True
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink") asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
class OoTContext(CommonContext): class OoTContext(CommonContext):
@@ -134,19 +132,6 @@ def get_payload(ctx: OoTContext):
async def parse_payload(payload: dict, ctx: OoTContext, force: bool): async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Refuse to do anything if ROM is detected as changed
if ctx.auth and payload['playerName'] != ctx.auth:
logger.warning("ROM change detected. Disconnecting and reconnecting...")
ctx.deathlink_enabled = False
ctx.deathlink_client_override = False
ctx.finished_game = False
ctx.location_table = {}
ctx.deathlink_pending = False
ctx.deathlink_sent_this_death = False
ctx.auth = payload['playerName']
await ctx.send_connect()
return
# Turn on deathlink if it is on, and if the client hasn't overriden it # Turn on deathlink if it is on, and if the client hasn't overriden it
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override: if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True) await ctx.update_death_link(True)
@@ -204,7 +189,7 @@ async def n64_sync_task(ctx: OoTContext):
if reported_version >= script_version: if reported_version >= script_version:
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
async_start(parse_payload(data_decoded, ctx, False)) asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = data_decoded['playerName'] ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom: if ctx.awaiting_rom:
@@ -280,7 +265,7 @@ async def patch_and_run_game(apz5_file):
os.chdir(data_path("Compress")) os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path) compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path) os.remove(decomp_path)
async_start(run_game(comp_path)) asyncio.create_task(run_game(comp_path))
if __name__ == '__main__': if __name__ == '__main__':
@@ -296,7 +281,7 @@ if __name__ == '__main__':
if args.apz5_file: if args.apz5_file:
logger.info("APZ5 file supplied, beginning patching process...") logger.info("APZ5 file supplied, beginning patching process...")
async_start(patch_and_run_game(args.apz5_file)) asyncio.create_task(patch_and_run_game(args.apz5_file))
ctx = OoTContext(args.connect, args.password) ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
from copy import deepcopy
import math import math
import numbers import numbers
import typing import typing
@@ -27,31 +26,15 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options) options.update(new_options)
# apply aliases, without name_lookup # apply aliases, without name_lookup
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")} name.startswith("alias_")}
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned." assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
if "off" in options:
options["false"] = options["off"]
if "on" in options:
options["true"] = options["on"]
options.update(aliases) options.update(aliases)
if "verify" not in attrs:
# not overridden by class -> look up bases
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
if len(verifiers) > 1: # verify multiple bases/mixins
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
# auto-validate schema on __init__ # auto-validate schema on __init__
if "schema" in attrs.keys(): if "schema" in attrs.keys():
@@ -79,9 +62,6 @@ class AssembleOptions(abc.ABCMeta):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@abc.abstractclassmethod
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
T = typing.TypeVar('T') T = typing.TypeVar('T')
@@ -132,44 +112,8 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def from_any(cls, data: typing.Any) -> Option[T]: def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError raise NotImplementedError
if typing.TYPE_CHECKING:
from Generate import PlandoSettings
from worlds.AutoWorld import World
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
class FreeText(Option):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@property
def current_key(self) -> str:
return self.value
@classmethod
def from_text(cls, text: str) -> FreeText:
return cls(text)
@classmethod
def from_any(cls, data: typing.Any) -> FreeText:
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: T) -> str:
return value
class NumericOption(Option[int], numbers.Integral): class NumericOption(Option[int], numbers.Integral):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards # note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs # `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True) # (even though isinstance(5, numbers.Integral) == True)
@@ -354,7 +298,7 @@ class Toggle(NumericOption):
if type(data) == str: if type(data) == str:
return cls.from_text(data) return cls.from_text(data)
else: else:
return cls(int(data)) return cls(data)
@classmethod @classmethod
def get_option_name(cls, value): def get_option_name(cls, value):
@@ -424,170 +368,6 @@ class Choice(NumericOption):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value
@property
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
else:
return self.name_lookup[self.value]
@classmethod
def from_text(cls, text: str) -> TextChoice:
if text.lower() == "random": # chooses a random defined option but won't use any free text options
return cls(random.choice(list(cls.name_lookup)))
for option_name, value in cls.options.items():
if option_name.lower() == text.lower():
return cls(value)
return cls(text)
@classmethod
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return cls.name_lookup[value]
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
if other in self.options:
return other == self.current_key
return other == self.value
elif isinstance(other, int):
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
return other == self.value
elif isinstance(other, bool):
return other == bool(self.value)
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class BossMeta(AssembleOptions):
def __new__(mcs, name, bases, attrs):
if name != "PlandoBosses":
assert "bosses" in attrs, f"Please define valid bosses for {name}"
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
assert "locations" in attrs, f"Please define valid locations for {name}"
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
cls = super().__new__(mcs, name, bases, attrs)
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
return cls
class PlandoBosses(TextChoice, metaclass=BossMeta):
"""Generic boss shuffle option that supports plando. Format expected is
'location1-boss1;location2-boss2;shuffle_mode'.
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
which passes a plando boss and location. Check if the placement is valid for your game here."""
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
duplicate_bosses: bool = False
@classmethod
def from_text(cls, text: str):
# set all of our text to lower case for name checking
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.options.values())))
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
options = text.split(";")
# since plando exists in the option verify the plando values given are valid
cls.validate_plando_bosses(options)
return cls.get_shuffle_mode(options)
@classmethod
def get_shuffle_mode(cls, option_list: typing.List[str]):
# find out what mode of boss shuffle we should use for placing bosses after plando
# and add as a string to look nice in the spoiler
if "random" in option_list:
shuffle = random.choice(list(cls.options))
option_list.remove("random")
options = ";".join(option_list) + f";{shuffle}"
boss_class = cls(options)
else:
for option in option_list:
if option in cls.options:
options = ";".join(option_list)
break
else:
if cls.duplicate_bosses and len(option_list) == 1:
if cls.valid_boss_name(option_list[0]):
# this doesn't exist in this class but it's a forced option for classes where this is called
options = option_list[0] + ";singularity"
else:
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
else:
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
boss_class = cls(options)
return boss_class
@classmethod
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
used_locations = []
used_bosses = []
for option in options:
# check if a shuffle mode was provided in the incorrect location
if option == "random" or option in cls.options:
if option != options[-1]:
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
elif "-" in option:
location, boss = option.split("-")
if location in used_locations:
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
if not cls.duplicate_bosses and boss in used_bosses:
raise ValueError(f"Duplicate Boss {boss} not allowed.")
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
raise ValueError(f"{boss.title()} is not a valid boss name.")
if not cls.valid_location_name(location):
raise ValueError(f"{location.title()} is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
raise ValueError(f"{option} is not a valid boss name.")
else:
raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
raise NotImplementedError
@classmethod
def valid_boss_name(cls, value: str) -> bool:
return value in cls.bosses
@classmethod
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
if isinstance(self.value, int):
return
from Generate import PlandoSettings
if not(PlandoSettings.bosses & plando_options):
import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
f"boss shuffle will be used for player {player_name}.")
class Range(NumericOption): class Range(NumericOption):
range_start = 0 range_start = 0
range_end = 1 range_end = 1
@@ -605,7 +385,7 @@ class Range(NumericOption):
if text.startswith("random"): if text.startswith("random"):
return cls.weighted_range(text) return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"): elif text == "default" and hasattr(cls, "default"):
return cls.from_any(cls.default) return cls(cls.default)
elif text == "high": elif text == "high":
return cls(cls.range_end) return cls(cls.range_end)
elif text == "low": elif text == "low":
@@ -616,7 +396,7 @@ class Range(NumericOption):
and text in ("true", "false"): and text in ("true", "false"):
# these are the conditions where "true" and "false" make sense # these are the conditions where "true" and "false" make sense
if text == "true": if text == "true":
return cls.from_any(cls.default) return cls(cls.default)
else: # "false" else: # "false"
return cls(0) return cls(0)
return cls(int(text)) return cls(int(text))
@@ -727,7 +507,7 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.") f"Allowed keys: {cls.valid_keys}.")
def verify(self, world, player_name: str, plando_options) -> None: def verify(self, world):
if self.convert_name_groups and self.verify_item_name: if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value: for item_name in self.value:
@@ -750,11 +530,11 @@ class VerifyKeys:
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default: typing.Dict[str, typing.Any] = {} default = {}
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]): def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = deepcopy(value) self.value = value
@classmethod @classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
@@ -781,11 +561,11 @@ class ItemDict(OptionDict):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default: typing.List[typing.Any] = [] default = []
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.List[typing.Any]): def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value) self.value = value or []
super(OptionList, self).__init__() super(OptionList, self).__init__()
@classmethod @classmethod
@@ -807,11 +587,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys):
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() default = frozenset()
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Iterable[str]): def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(deepcopy(value)) self.value = set(value)
super(OptionSet, self).__init__() super(OptionSet, self).__init__()
@classmethod @classmethod
@@ -820,7 +600,10 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if isinstance(data, (list, set, frozenset)): if type(data) == list:
cls.verify_keys(data)
return cls(data)
elif type(data) == set:
cls.verify_keys(data) cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -850,7 +633,7 @@ class Accessibility(Choice):
class ProgressionBalancing(SpecialRange): class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck.""" [0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
@@ -949,8 +732,8 @@ class ItemLinks(OptionList):
pool |= {item_name} pool |= {item_name}
return pool return pool
def verify(self, world, player_name: str, plando_options) -> None: def verify(self, world):
super(ItemLinks, self).verify(world, player_name, plando_options) super(ItemLinks, self).verify(world)
existing_links = set() existing_links = set()
for link in self.value: for link in self.value:
if link["name"] in existing_links: if link["name"] in existing_links:

427
Patch.py
View File

@@ -1,23 +1,265 @@
from __future__ import annotations from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os import os
import lzma
import threading
import concurrent.futures
import zipfile
import sys import sys
from typing import Tuple, Optional, TypedDict from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
if __name__ == "__main__": import ModuleUpdate
import ModuleUpdate ModuleUpdate.update()
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APDeltaPatch import Utils
current_patch_version = 4
class RomMeta(TypedDict): class AutoPatchRegister(type):
server: str patch_types: Dict[str, APDeltaPatch] = {}
file_endings: Dict[str, APDeltaPatch] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
# construct class
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class
@staticmethod
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int] player: Optional[int]
player_name: str player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None):
if not self.path and not file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = self.get_manifest()
try:
manifest = json.dumps(manifest)
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr("archipelago.json", manifest)
def read(self, file: Optional[Union[str, BinaryIO]] = None):
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
if not self.path and not file:
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "r") as zf:
if file:
self.path = zf.filename
self.read_contents(zf)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> dict:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 4,
"version": current_patch_version,
}
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]: class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
"""An APContainer that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
hash = Optional[str] # base checksum of source file
patch_file_ending: str = ""
delta: Optional[bytes] = None
result_file_ending: str = ".sfc"
source_data: bytes
def __init__(self, *args, patched_path: str = "", **kwargs):
self.patched_path = patched_path
super(APDeltaPatch, self).__init__(*args, **kwargs)
def get_manifest(self) -> dict:
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
return manifest
@classmethod
def get_source_data(cls) -> bytes:
"""Get Base data"""
raise NotImplementedError()
@classmethod
def get_source_data_with_cache(cls) -> bytes:
if not hasattr(cls, "source_data"):
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 read_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).read_contents(opened_zipfile)
self.delta = opened_zipfile.read("delta.bsdiff4")
def patch(self, target: str):
"""Base + Delta -> Patched"""
if not self.delta:
self.read()
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
with open(target, "wb") as f:
f.write(result)
# legacy patch handling follows:
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
GAME_SMZ3 = "SMZ3"
GAME_DKC3 = "Donkey Kong Country 3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz",
GAME_DKC3: "apdkc3"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import SMJUHASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH
elif game == GAME_DKC3:
from worlds.dkc3.Rom import USHASH as HASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 3,
"version": current_patch_version,
"base_checksum": HASH})
return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP
else ".apsmz" if game == GAME_SMZ3
else ".apdkc3" if game == GAME_DKC3
else ".apm3")
write_lzma(bytes, target)
return target
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"]
if not ignore_version and data["compatible_version"] > current_patch_version:
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def get_base_rom_data(game: str):
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
from worlds.soe.Patch import get_base_rom_path
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes
elif game == GAME_DKC3:
from worlds.dkc3.Rom import get_base_rom_bytes
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file) auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler: if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file) handler: APDeltaPatch = auto_handler(patch_file)
@@ -26,10 +268,171 @@ def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
return {"server": handler.server, return {"server": handler.server,
"player": handler.player, "player": handler.player,
"player_name": handler.player_name}, target "player_name": handler.player_name}, target
raise NotImplementedError(f"No Handler for {patch_file} found.") else:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data, target
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
data["meta"]["server"] = server
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
return lzma.compress(bytes)
def load_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def write_lzma(data: bytes, path: str):
with lzma.LZMAFile(path, 'wb') as f:
f.write(data)
def read_rom(stream, strip_header=True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
if __name__ == "__main__": if __name__ == "__main__":
for file in sys.argv[1:]: host = Utils.get_public_ipv4()
meta_data, result_file = create_rom_file(file) options = Utils.get_options()['server_options']
print(f"Patch with meta-data {meta_data} was written to {result_file}") if options['host']:
host = options['host']
address = f"{host}:{options['port']}"
ziplock = threading.Lock()
print(f"Host for patches to be created is {address}")
with concurrent.futures.ThreadPoolExecutor() as pool:
for rom in sys.argv:
try:
if rom.endswith(".sfc"):
print(f"Creating patch for {rom}")
result = pool.submit(create_patch_file, rom, address)
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
elif rom.endswith(".apbp"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
#romfile, adjusted = Utils.get_adjuster_settings(target)
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
if adjuster_settings:
import pprint
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = target
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
sprite_pool = {}
for sprite in getattr(adjuster_settings, "sprite_pool"):
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
adjust_wanted = str('no')
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
adjust_wanted = 'no'
elif adjuster_settings.auto_apply == 'always':
adjust_wanted = 'yes'
if adjust_wanted and "never" in adjust_wanted:
adjuster_settings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
elif adjust_wanted and "always" in adjust_wanted:
adjuster_settings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
else:
adjusted = False
if adjusted:
try:
shutil.move(romfile, target)
romfile = target
except Exception as e:
print(e)
print(f"Created rom {romfile if adjusted else target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apm3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apsmz"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apdkc3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}")
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp") or \
zfinfo.filename.endswith(".apm3") or \
zfinfo.filename.endswith(".apdkc3"):
data = update_patch_data(data, server)
with ziplock:
zfw.writestr(zfinfo, data)
return zfinfo.filename
futures = []
with zipfile.ZipFile(rom, "r") as zfr:
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zfw:
for zfname in zfr.namelist():
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
for future in futures:
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
except:
import traceback
traceback.print_exc()
input("Press enter to close.")

View File

@@ -1,304 +0,0 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
items_handling = 0b101
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to Bizhawk to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
# Check for clear problems
if len(flags['Rod']) > 1:
return
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
return
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
#print(data_decoded)
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM.")
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
base_rom = bytes(stream.read())
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -28,11 +28,6 @@ Currently, the following games are supported:
* Starcraft 2: Wings of Liberty * Starcraft 2: Wings of Liberty
* Donkey Kong Country 3 * Donkey Kong Country 3
* Dark Souls 3 * Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
* Hylics 2
* Overcooked! 2
* Zillion
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -66,10 +61,26 @@ This project makes use of multiple other projects. We wouldn't be here without t
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing ## Contributing
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md) Contributions are welcome. We have a few asks of any new contributors.
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
## FAQ ## FAQ
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/) For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
## Code of Conduct ## Code of Conduct
Please refer to our [code of conduct.](/docs/code_of_conduct.md) We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import typing import typing
import builtins import builtins
import os import os
@@ -12,8 +11,6 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
from yaml import load, load_all, dump, SafeLoader from yaml import load, load_all, dump, SafeLoader
try: try:
@@ -38,7 +35,7 @@ class Version(typing.NamedTuple):
build: int build: int
__version__ = "0.3.6" __version__ = "0.3.4"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -142,7 +139,7 @@ def user_path(*path: str) -> str:
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
def output_path(*path: str) -> str: def output_path(*path: str):
if hasattr(output_path, 'cached_path'): if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path) return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
@@ -220,11 +217,8 @@ def get_public_ipv6() -> str:
return ip return ip
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@cache_argsless @cache_argsless
def get_default_options() -> OptionsType: def get_default_options() -> dict:
# Refer to host.yaml for comments as to what all these options mean. # Refer to host.yaml for comments as to what all these options mean.
options = { options = {
"general_options": { "general_options": {
@@ -232,21 +226,20 @@ def get_default_options() -> OptionsType:
}, },
"factorio_options": { "factorio_options": {
"executable": os.path.join("factorio", "bin", "x64", "factorio"), "executable": os.path.join("factorio", "bin", "x64", "factorio"),
"filter_item_sends": False,
"bridge_chat_out": True,
},
"sni_options": {
"sni": "SNI",
"snes_rom_start": True,
}, },
"sm_options": { "sm_options": {
"rom_file": "Super Metroid (JU).sfc", "rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"soe_options": { "soe_options": {
"rom_file": "Secret of Evermore (USA).sfc", "rom_file": "Secret of Evermore (USA).sfc",
}, },
"lttp_options": { "lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"server_options": { "server_options": {
"host": None, "host": None,
@@ -289,27 +282,15 @@ def get_default_options() -> OptionsType:
}, },
"dkc3_options": { "dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"smw_options": {
"rom_file": "Super Mario World (USA).sfc",
},
"zillion_options": {
"rom_file": "Zillion (UE) [!].sms",
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
"rom_start": "retroarch",
},
"pokemon_rb_options": {
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
"rom_start": True
}
} }
return options return options
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
for key, value in src.items(): for key, value in src.items():
new_keys = keys.copy() new_keys = keys.copy()
new_keys.append(key) new_keys.append(key)
@@ -329,9 +310,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsT
@cache_argsless @cache_argsless
def get_options() -> OptionsType: def get_options() -> dict:
filenames = ("options.yaml", "host.yaml") filenames = ("options.yaml", "host.yaml")
locations: typing.List[str] = [] locations = []
if os.path.join(os.getcwd()) != local_path(): if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames] locations += [user_path(filename) for filename in filenames]
@@ -372,7 +353,7 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage return storage
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]: def get_adjuster_settings(game_name: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings return adjuster_settings
@@ -411,8 +392,7 @@ class RestrictedUnpickler(pickle.Unpickler):
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options) if module.endswith("Options"):
if module.lower().endswith("options"):
if module == "Options": if module == "Options":
mod = self.options_module mod = self.options_module
else: else:
@@ -639,36 +619,7 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
def sorter(element: str) -> str: def sorter(element: str) -> str:
parts = element.split(maxsplit=1) parts = element.split(maxsplit=1)
if parts[0].lower() in ignore: if parts[0].lower() in ignore:
return parts[1].lower() return parts[1]
else: else:
return element.lower() return element
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i)) return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
# to avoid a task disappearing mid-execution.
# ```
# This implementation follows the pattern given in that documentation.
task = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)

View File

@@ -1,4 +1,5 @@
import os import os
import sys
import multiprocessing import multiprocessing
import logging import logging
import typing import typing
@@ -11,7 +12,7 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn # in case app gets imported by something like gunicorn
import Utils import Utils
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 Utils.local_path.cached_path = os.path.dirname(__file__)
from WebHostLib import register, app as raw_app from WebHostLib import register, app as raw_app
from waitress import serve from waitress import serve
@@ -103,7 +104,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for games in data: for games in data:
if 'Archipelago' in games['gameTitle']: if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games)) generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"]) sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"].lower())
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False) json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data return sorted_data

View File

@@ -1,15 +1,16 @@
import base64
import os import os
import socket
import uuid import uuid
import base64
import socket
from pony.flask import Pony
from flask import Flask from flask import Flask
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from pony.flask import Pony
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from Utils import title_sorted from Utils import title_sorted
from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -31,10 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key # if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread # at what amount of worlds should scheduling be used, instead of rolling in the webthread
app.config["JOB_THRESHOLD"] = 2 app.config["JOB_THRESHOLD"] = 2
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
app.config['SESSION_PERMANENT'] = True app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -74,10 +73,8 @@ def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
# has automatic patch integration # has automatic patch integration
import worlds.AutoWorld import Patch
import worlds.Files app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it # to trigger app routing picking up on it

View File

@@ -1,11 +1,11 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple
from uuid import UUID from uuid import UUID
from typing import List, Tuple
from flask import Blueprint, abort from flask import Blueprint, abort
from .. import cache
from ..models import Room, Seed from ..models import Room, Seed
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")

View File

@@ -1,15 +1,15 @@
import json import json
import pickle import pickle
from uuid import UUID from uuid import UUID
from . import api_endpoints
from flask import request, session, url_for from flask import request, session, url_for
from pony.orm import commit from pony.orm import commit
from WebHostLib import app from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints
@api_endpoints.route('/generate', methods=['POST']) @api_endpoints.route('/generate', methods=['POST'])

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select
from WebHostLib.models import Room, Seed from WebHostLib.models import *
from . import api_endpoints, get_players from . import api_endpoints, get_players

View File

@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import json
import multiprocessing import multiprocessing
import os
import sys
import threading import threading
import time
import typing
from datetime import timedelta, datetime from datetime import timedelta, datetime
import sys
import typing
import time
import os
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads

View File

@@ -1,7 +1,7 @@
import zipfile import zipfile
from typing import * from typing import *
from flask import request, flash, redirect, url_for, render_template from flask import request, flash, redirect, url_for, session, render_template
from WebHostLib import app from WebHostLib import app

View File

@@ -1,23 +1,21 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import collections
import datetime
import functools import functools
import logging import websockets
import pickle import asyncio
import random
import socket import socket
import threading import threading
import time import time
import random
import websockets import pickle
from pony.orm import db_session, commit, select import logging
import datetime
import Utils import Utils
from .models import db_session, Room, select, commit, Command, db
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Room, Command, db
class CustomClientMessageProcessor(ClientMessageProcessor): class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -51,8 +49,6 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context): class WebHostContext(Context):
room_id: int
def __init__(self, static_server_data: dict): def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data, # static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory # without needing to import worlds system, which takes quite a bit of memory
@@ -66,8 +62,6 @@ class WebHostContext(Context):
def _load_game_data(self): def _load_game_data(self):
for key, value in self.static_server_data.items(): for key, value in self.static_server_data.items():
setattr(self, key, value) setattr(self, key, value)
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
def listen_to_db_commands(self): def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self) cmdprocessor = DBCommandProcessor(self)
@@ -109,7 +103,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save()) room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.datetime.utcnow() room.last_activity = datetime.utcnow()
return True return True
def get_save(self) -> dict: def get_save(self) -> dict:
@@ -184,12 +178,4 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
from .autolauncher import Locker from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
try: asyncio.run(main())
asyncio.run(main())
except:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
raise

View File

@@ -1,13 +1,12 @@
import json
import zipfile import zipfile
import json
from io import BytesIO from io import BytesIO
from flask import send_file, Response, render_template from flask import send_file, Response, render_template
from pony.orm import select from pony.orm import select
from worlds.Files import AutoPatchRegister from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from . import app, cache from WebHostLib import app, Slot, Room, Seed, cache
from .models import Slot, Room, Seed
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>") @app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -33,16 +32,18 @@ def download_patch(room_id, patch_id):
new_zip.writestr("archipelago.json", json.dumps(manifest)) new_zip.writestr("archipelago.json", json.dumps(manifest))
else: else:
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9) new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
if "patch_file_ending" in manifest:
patch_file_ending = manifest["patch_file_ending"]
else:
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \ fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
f"{patch_file_ending}" f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
new_file.seek(0) new_file.seek(0)
return send_file(new_file, as_attachment=True, download_name=fname) return send_file(new_file, as_attachment=True, download_name=fname)
else: else:
return "Old Patch file, no longer compatible." patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, download_name=fname)
@app.route("/dl_spoiler/<suuid:seed_id>") @app.route("/dl_spoiler/<suuid:seed_id>")
@@ -75,8 +76,6 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV": elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
elif slot_data.game == "Super Mario 64": elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III": elif slot_data.game == "Dark Souls III":

View File

@@ -1,24 +1,23 @@
import json
import os import os
import pickle
import random
import tempfile import tempfile
import random
import json
import zipfile import zipfile
import concurrent.futures
from collections import Counter from collections import Counter
from typing import Dict, Optional, Any from typing import Dict, Optional, Any
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings from Generate import handle_name, PlandoSettings
from Main import main as ERmain import pickle
from Utils import __version__
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
@@ -99,7 +98,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False) race = meta.setdefault("race", False)
def task(): try:
target = tempfile.TemporaryDirectory() target = tempfile.TemporaryDirectory()
playercount = len(gen_options) playercount = len(gen_options)
seed = get_seed() seed = get_seed()
@@ -139,23 +138,6 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
ERmain(erargs, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:

View File

@@ -1,11 +1,7 @@
from datetime import timedelta, datetime
from flask import render_template from flask import render_template
from pony.orm import count
from WebHostLib import app, cache from WebHostLib import app, cache
from .models import Room, Seed from .models import *
from datetime import timedelta
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work @cache.cached(timeout=300) # cache has to appear under app route for caching to work

View File

@@ -32,7 +32,7 @@ def update_sprites_lttp():
spriteData = [] spriteData = []
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")): for file in os.listdir(input_dir):
sprite = Sprite(os.path.join(input_dir, file)) sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name: if not sprite.name:

View File

@@ -3,11 +3,10 @@ import os
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
def get_world_theme(game_name: str): def get_world_theme(game_name: str):
@@ -152,7 +151,7 @@ def favicon():
@app.route('/discord') @app.route('/discord')
def discord(): def discord():
return redirect("https://discord.gg/8Z65BR2") return redirect("https://discord.gg/archipelago")
@app.route('/datapackage') @app.route('/datapackage')

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr from pony.orm import *
db = Database() db = Database()
@@ -29,7 +29,6 @@ class Room(db.Entity):
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True) tracker = Optional(UUID, index=True)
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
last_port = Optional(int, default=lambda: 0) last_port = Optional(int, default=lambda: 0)

View File

@@ -1,38 +1,41 @@
import json
import logging import logging
import os import os
from Utils import __version__
from jinja2 import Template
import yaml
import json
import typing import typing
import yaml
from jinja2 import Template
import Options
from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import Options
target_folder = os.path.join("WebHostLib", "static", "generated")
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"} "exclude_locations"}
def create(): def create():
target_folder = local_path("WebHostLib", "static", "generated") os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
yaml_folder = os.path.join(target_folder, "configs")
os.makedirs(yaml_folder, exist_ok=True)
for file in os.listdir(yaml_folder):
full_path: str = os.path.join(yaml_folder, file)
if os.path.isfile(full_path):
os.unlink(full_path)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {option.default: 50} data = {}
for sub_option in ["random", "random-low", "random-high"]: special = getattr(option, "special_range_cutoff", None)
if sub_option != option.default: if special is not None:
data[sub_option] = 0 data[special] = 0
data.update({
option.range_start: 0,
option.range_end: 0,
"random": 0, "random-low": 0, "random-high": 0,
option.default: 50
})
notes = {
special: "minimum value without special meaning",
option.range_start: "minimum value",
option.range_end: "maximum value"
}
notes = {}
for name, number in getattr(option, "special_range_names", {}).items(): for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data: if number in data:
data[name] = data[number] data[name] = data[number]
del data[number] del data[number]
@@ -41,10 +44,10 @@ def create():
return data, notes return data, notes
def get_html_doc(option_type: type(Options.Option)) -> str: def default_converter(default_value):
if not option_type.__doc__: if isinstance(default_value, (set, frozenset)):
return "Please document me!" return list(default_value)
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip() return default_value
weighted_settings = { weighted_settings = {
"baseOptions": { "baseOptions": {
@@ -57,20 +60,13 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
all_options: typing.Dict[str, Options.AssembleOptions] = { all_options = {**Options.per_game_common_options, **world.option_definitions}
**Options.per_game_common_options, res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
**world.option_definitions
}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
options=all_options, options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range, dictify_range=dictify_range, default_converter=default_converter,
) )
del file_data
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f: with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
f.write(res) f.write(res)
@@ -92,7 +88,7 @@ def create():
game_options[option_name] = this_option = { game_options[option_name] = this_option = {
"type": "select", "type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": None, "defaultValue": None,
"options": [] "options": []
} }
@@ -106,6 +102,11 @@ def create():
if sub_option_id == option.default: if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name this_option["defaultValue"] = sub_option_name
this_option["options"].append({
"name": "Random",
"value": "random",
})
if option.default == "random": if option.default == "random":
this_option["defaultValue"] = "random" this_option["defaultValue"] = "random"
@@ -113,7 +114,7 @@ def create():
game_options[option_name] = { game_options[option_name] = {
"type": "range", "type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": option.default if hasattr( "defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start, option, "default") and option.default != "random" else option.range_start,
"min": option.range_start, "min": option.range_start,
@@ -130,14 +131,14 @@ def create():
game_options[option_name] = { game_options[option_name] = {
"type": "items-list", "type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
} }
elif getattr(option, "verify_location_name", False): elif getattr(option, "verify_location_name", False):
game_options[option_name] = { game_options[option_name] = {
"type": "locations-list", "type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
} }
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet): elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
@@ -145,7 +146,7 @@ def create():
game_options[option_name] = { game_options[option_name] = {
"type": "custom-list", "type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
"options": list(option.valid_keys), "options": list(option.valid_keys),
} }

View File

@@ -1,7 +1,7 @@
flask>=2.2.2 flask>=2.1.3
pony>=0.7.16 pony>=0.7.16
waitress>=2.1.2 waitress>=2.1.1
Flask-Caching>=2.0.1 Flask-Caching>=2.0.1
Flask-Compress>=1.13 Flask-Compress>=1.12
Flask-Limiter>=2.7.0 Flask-Limiter>=2.5.0
bokeh>=3.0.0 bokeh>=2.4.3

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -46,7 +46,7 @@ the website is not required to generate them.
## How do I get started? ## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
any questions you might have. any questions you might have.
## What are some common terms I should know? ## What are some common terms I should know?

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
gameInfo.innerHTML = gameInfo.innerHTML =

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -102,15 +102,9 @@ const buildOptionsTable = (settings, romOpts = false) => {
// td Left // td Left
const tdl = document.createElement('td'); const tdl = document.createElement('td');
const label = document.createElement('label'); const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting); label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
const questionSpan = document.createElement('span'); label.innerText = `${settings[setting].displayName}:`;
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label); tdl.appendChild(label);
tr.appendChild(tdl); tr.appendChild(tdl);
@@ -118,8 +112,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
const tdr = document.createElement('td'); const tdr = document.createElement('td');
let element = null; let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){ switch(settings[setting].type){
case 'select': case 'select':
element = document.createElement('div'); element = document.createElement('div');
@@ -140,21 +132,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
} }
select.appendChild(option); select.appendChild(option);
}); });
select.addEventListener('change', (event) => updateGameSetting(event.target)); select.addEventListener('change', (event) => updateGameSetting(event));
element.appendChild(select); element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break; break;
case 'range': case 'range':
@@ -169,29 +148,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
range.value = currentSettings[gameName][setting]; range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => { range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
element.appendChild(range); element.appendChild(range);
let rangeVal = document.createElement('span'); let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value'); rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`); rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
currentSettings[gameName][setting] : settings[setting].defaultValue;
element.appendChild(rangeVal); element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break; break;
case 'special_range': case 'special_range':
@@ -230,8 +195,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
let specialRangeVal = document.createElement('span'); let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value'); specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`); specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
currentSettings[gameName][setting] : settings[setting].defaultValue;
// Configure select event listener // Configure select event listener
specialRangeSelect.addEventListener('change', (event) => { specialRangeSelect.addEventListener('change', (event) => {
@@ -240,7 +204,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider // Update range slider
specialRange.value = event.target.value; specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
// Configure range event handler // Configure range event handler
@@ -250,29 +214,13 @@ const buildOptionsTable = (settings, romOpts = false) => {
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ? (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom'; parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
element.appendChild(specialRangeSelect); element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange); specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal); specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper); element.appendChild(specialRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, [specialRange, specialRangeSelect])
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
break; break;
default: default:
@@ -289,25 +237,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table; return table;
}; };
const toggleRandomize = (event, inputElements) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
}
} else {
randomButton.classList.add('active');
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
}
}
};
const updateBaseSetting = (event) => { const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
@@ -315,17 +244,10 @@ const updateBaseSetting = (event) => {
localStorage.setItem(gameName, JSON.stringify(options)); localStorage.setItem(gameName, JSON.stringify(options));
}; };
const updateGameSetting = (settingElement) => { const updateGameSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
if (settingElement.classList.contains('randomize-button')) { event.target.value : parseInt(event.target.value, 10);
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][settingElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options)); localStorage.setItem(gameName, JSON.stringify(options));
}; };

View File

@@ -27,28 +27,25 @@ window.addEventListener('load', () => {
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth(); adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -78,16 +78,13 @@ const createDefaultSettings = (settingData) => {
break; break;
case 'range': case 'range':
case 'special_range': case 'special_range':
newSettings[game][gameSetting][setting.min] = 0; for (let i = setting.min; i <= setting.max; ++i){
newSettings[game][gameSetting][setting.max] = 0; newSettings[game][gameSetting][i] =
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
}
newSettings[game][gameSetting]['random'] = 0; newSettings[game][gameSetting]['random'] = 0;
newSettings[game][gameSetting]['random-low'] = 0; newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0; newSettings[game][gameSetting]['random-high'] = 0;
if (setting.hasOwnProperty('defaultValue')) {
newSettings[game][gameSetting][setting.defaultValue] = 25;
} else {
newSettings[game][gameSetting][setting.min] = 25;
}
break; break;
case 'items-list': case 'items-list':
@@ -404,17 +401,11 @@ const buildWeightedSettingsDiv = (game, settings) => {
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
// Save new option to settings
range.dispatchEvent(new Event('change'));
}); });
Object.keys(currentSettings[game][settingName]).forEach((option) => { Object.keys(currentSettings[game][settingName]).forEach((option) => {
// These options are statically generated below, and should always appear even if they are deleted if (currentSettings[game][settingName][option] > 0) {
// from localStorage const tr = document.createElement('tr');
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td'); const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left'); tdLeft.classList.add('td-left');
tdLeft.innerText = option; tdLeft.innerText = option;
@@ -448,15 +439,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
deleteButton.innerText = '❌'; deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => { deleteButton.addEventListener('click', () => {
range.value = 0; range.value = 0;
const changeEvent = new Event('change'); range.dispatchEvent(new Event('change'));
changeEvent.action = 'rangeDelete';
range.dispatchEvent(changeEvent);
rangeTbody.removeChild(tr); rangeTbody.removeChild(tr);
}); });
tdDelete.appendChild(deleteButton); tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
}
}); });
} }
@@ -914,12 +904,8 @@ const updateGameSetting = (evt) => {
const setting = evt.target.getAttribute('data-setting'); const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option'); const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
console.log(event); options[game][setting][option] = isNaN(evt.target.value) ?
if (evt.action && evt.action === 'rangeDelete') { evt.target.value : parseInt(evt.target.value, 10);
delete options[game][setting][option];
} else {
options[game][setting][option] = parseInt(evt.target.value, 10);
}
localStorage.setItem('weighted-settings', JSON.stringify(options)); localStorage.setItem('weighted-settings', JSON.stringify(options));
}; };

View File

@@ -56,3 +56,7 @@
#file-input{ #file-input{
display: none; display: none;
} }
.interactive{
color: #ffef00;
}

View File

@@ -105,7 +105,3 @@ h5, h6{
margin-bottom: 20px; margin-bottom: 20px;
background-color: #ffff00; background-color: #ffff00;
} }
.interactive{
color: #ffef00;
}

View File

@@ -55,6 +55,4 @@
border: 1px solid #2a6c2f; border: 1px solid #2a6c2f;
border-radius: 6px; border-radius: 6px;
color: #000000; color: #000000;
overflow-y: auto;
max-height: 400px;
} }

View File

@@ -116,10 +116,6 @@ html{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{ #player-settings table .range-container{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -142,27 +138,12 @@ html{
#player-settings table .special-range-wrapper{ #player-settings table .special-range-wrapper{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-top: 0.25rem;
} }
#player-settings table .special-range-wrapper input[type=range]{ #player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
margin: 0 0 0 0.25rem;
font-size: 12px;
border: 1px solid black;
border-radius: 3px;
}
#player-settings table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-settings table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;

View File

@@ -1,7 +1,5 @@
html{ html{
padding-top: 110px; padding-top: 110px;
scroll-padding-top: 100px;
scroll-behavior: smooth;
} }
#base-header{ #base-header{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -53,7 +53,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -50,7 +50,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -14,6 +14,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/* Base styles for the element that has a tooltip */ /* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip { [data-tooltip], .tooltip {
position: relative; position: relative;
cursor: pointer;
} }
/* Base styles for the entire tooltip */ /* Base styles for the entire tooltip */
@@ -54,15 +55,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/** Content styles */ /** Content styles */
.tooltip:after, [data-tooltip]:after { .tooltip:after, [data-tooltip]:after {
width: 260px;
z-index: 10000; z-index: 10000;
padding: 8px; padding: 8px;
width: 160px;
border-radius: 4px; border-radius: 4px;
background-color: #000; background-color: #000;
background-color: hsla(0, 0%, 20%, 0.9); background-color: hsla(0, 0%, 20%, 0.9);
color: #fff; color: #fff;
content: attr(data-tooltip); content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
} }

View File

@@ -1,14 +1,14 @@
import typing
from collections import Counter, defaultdict from collections import Counter, defaultdict
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from math import tau from math import tau
import typing
from bokeh.colors import RGB
from bokeh.embed import components from bokeh.embed import components
from bokeh.models import HoverTool from bokeh.models import HoverTool
from bokeh.plotting import figure, ColumnDataSource from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE from bokeh.resources import INLINE
from bokeh.colors import RGB
from flask import render_template from flask import render_template
from pony.orm import select from pony.orm import select
@@ -18,8 +18,7 @@ from .models import Room
PLOT_WIDTH = 600 PLOT_WIDTH = 600
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str], def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter) games_played = defaultdict(Counter)
total_games = Counter() total_games = Counter()
cutoff = date.today()-timedelta(days=30) cutoff = date.today()-timedelta(days=30)
@@ -94,7 +93,7 @@ def stats():
occurences, legend_label=game, line_width=2, color=game_to_color[game]) occurences, legend_label=game, line_width=2, color=game_to_color[game])
total = sum(total_games.values()) total = sum(total_games.values())
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None, pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")], tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2)) sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
pie.axis.visible = False pie.axis.visible = False
@@ -122,8 +121,7 @@ def stats():
start_angle="start_angles", end_angle="end_angles", fill_color="colors", start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games") source=ColumnDataSource(data=data), legend_field="games")
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
sorted(total_games, key=lambda game: total_games[game])
if total_games[game] > 1] if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts)) script, charts = components((plot, pie, *per_game_charts))

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Mystery Check Result</title> <title>Mystery Check Result</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Generate Game</title> <title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
@@ -40,11 +41,12 @@
<tbody> <tbody>
<tr> <tr>
<td> <td>
<label for="forfeit_mode">Forfeit Permission: <label for="forfeit_mode">Forfeit Permission:</label>
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world."> <span
(?) class="interactive"
</span> data-tooltip="A forfeit releases all remaining items from the locations
</label> in your world.">(?)
</span>
</td> </td>
<td> <td>
<select name="forfeit_mode" id="forfeit_mode"> <select name="forfeit_mode" id="forfeit_mode">
@@ -61,11 +63,12 @@
<tr> <tr>
<td> <td>
<label for="collect_mode">Collect Permission: <label for="collect_mode">Collect Permission:</label>
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld."> <span
(?) class="interactive"
</span> data-tooltip="A collect releases all of your remaining items to you
</label> from across the multiworld.">(?)
</span>
</td> </td>
<td> <td>
<select name="collect_mode" id="collect_mode"> <select name="collect_mode" id="collect_mode">
@@ -82,11 +85,12 @@
<tr> <tr>
<td> <td>
<label for="remaining_mode">Remaining Permission: <label for="remaining_mode">Remaining Permission:</label>
<span class="interactive" data-tooltip="Remaining lists all items still in your world by name only."> <span
(?) class="interactive"
</span> data-tooltip="Remaining lists all items still in your world by name only."
</label> >(?)
</span>
</td> </td>
<td> <td>
<select name="remaining_mode" id="remaining_mode"> <select name="remaining_mode" id="remaining_mode">
@@ -102,11 +106,11 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="item_cheat">Item Cheat: <label for="item_cheat">Item Cheat:</label>
<span class="interactive" data-tooltip="Allows players to use the !getitem command."> <span
(?) class="interactive"
</span> data-tooltip="Allows players to use the !getitem command.">(?)
</label> </span>
</td> </td>
<td> <td>
<select name="item_cheat" id="item_cheat"> <select name="item_cheat" id="item_cheat">
@@ -127,11 +131,12 @@
<tbody> <tbody>
<tr> <tr>
<td> <td>
<label for="hint_cost"> Hint Cost: <label for="hint_cost"> Hint Cost:</label>
<span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item."> <span
(?) class="interactive"
</span> data-tooltip="After gathering this many checks, players can !hint <itemname>
</label> to get the location of that hint item.">(?)
</span>
</td> </td>
<td> <td>
<select name="hint_cost" id="hint_cost"> <select name="hint_cost" id="hint_cost">
@@ -145,11 +150,11 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="server_password">Server Password: <label for="server_password">Server Password:</label>
<span class="interactive" data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command."> <span
(?) class="interactive"
</span> data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
</label> </span>
</td> </td>
<td> <td>
<input id="server_password" name="server_password"> <input id="server_password" name="server_password">
@@ -157,22 +162,23 @@
</tr> </tr>
<tr> <tr>
<td> <td>
Plando Options: <label for="plando_options">Plando Options:</label>
<span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information."> <span
(?) class="interactive"
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
</span> </span>
</td> </td>
<td> <td>
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked> <input type="checkbox" name="plando_bosses" value="bosses" checked>
<label for="plando_bosses">Bosses</label><br> <label for="plando_bosses">Bosses</label><br>
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked> <input type="checkbox" name="plando_items" value="items" checked>
<label for="plando_items">Items</label><br> <label for="plando_items">Items</label><br>
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked> <input type="checkbox" name="plando_connections" value="connections" checked>
<label for="plando_connections">Connections</label><br> <label for="plando_connections">Connections</label><br>
<input type="checkbox" id="plando_texts" name="plando_texts" value="texts" checked> <input type="checkbox" name="plando_texts" value="texts" checked>
<label for="plando_texts">Text</label> <label for="plando_texts">Text</label>
</td> </td>
</tr> </tr>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Upload Multidata</title> <title>Upload Multidata</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>

View File

@@ -20,16 +20,12 @@
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later, Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br> anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port == -1 %} {% if room.last_port %}
There was an error hosting this Room. Another attempt will be made on refreshing this page.
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive" You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}."> data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span> </span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br> in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{% endif %}
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<form method=post> <form method=post>

View File

@@ -6,6 +6,8 @@
- -
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a> <a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
- -
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a> <a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
- -
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a> <a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>

View File

@@ -43,7 +43,7 @@
{% elif patch.game | supports_apdeltapatch %} {% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>
{% elif patch.game == "Dark Souls III" and patch.data %} {% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a> Download JSON File...</a>
{% else %} {% else %}

View File

@@ -43,19 +43,6 @@
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td> <td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td> <td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td> <td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr> </tr>
</table> </table>
<table id="location-table"> <table id="location-table">

View File

@@ -1,85 +1,56 @@
# Q. What is this file? # What is this file?
# A. This file contains options which allow you to configure your multiworld experience while allowing # This file contains options which allow you to configure your multiworld experience while allowing others
# others to play how they want as well. # to play how they want as well.
#
# Q. How do I use it?
# A. The options in this file are weighted. This means the higher number you assign to a value, the
# more chances you have for that option to be chosen. For example, an option like this:
#
# map_shuffle:
# on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned
# off.
#
# Q. I've never seen a file like this before. What characters am I allowed to use?
# A. This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
# You can also verify your Archipelago settings are valid at this site:
# https://archipelago.gg/check
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit. # How do I use it?
# {player} will be replaced with the player's slot number. # The options in this file are weighted. This means the higher number you assign to a value, the more
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1. # chances you have for that option to be chosen. For example, an option like this:
# {number} will be replaced with the counter value of the name. #
# {NUMBER} will be replaced with the counter value of the name, if the counter value is greater than 1. # map_shuffle:
name: Player{number} # on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
# Used to describe your yaml. Useful if you have multiple files. # I've never seen a file like this before. What characters am I allowed to use?
description: Default {{ game }} Template # This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
game: {{ game }} description: Default {{ game }} Template # Used to describe your yaml. Useful if you have multiple files
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
name: YourName{number}
#{player} will be replaced with the player's slot number.
#{PLAYER} will be replaced with the player's slot number if that slot number is greater than 1.
#{number} will be replaced with the counter value of the name.
#{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1.
game:
{{ game }}: 1
requires: requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected. version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
{%- macro range_option(option) %} {%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values. # you can add additional values between minimum and maximum
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
{%- set data, notes = dictify_range(option) %} {%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %} {%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %} {{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{{ game }}: {{ game }}:
{%- for option_key, option in options.items() %} {%- for option_key, option in options.items() %}
{{ option_key }}: {{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
{%- if option.__doc__ %}
# {{ option.__doc__
| trim
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}
{%- if option.__doc__ and option.range_start is defined %}
#
{%- endif -%}
{%- if option.range_start is defined and option.range_start is number %} {%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}} {{- range_option(option) -}}
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{% if option.default == "random" %}
{%- if option.name_lookup[option.default] not in option.options %} random: 50
{{ option.default }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %}
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %} {%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }} {{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
{%- endif -%} {%- endif -%}
{{ "\n" }}
{%- endfor %} {%- endfor %}
{% if not options %}{}{% endif %}

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Start Playing</title> <title>Start Playing</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
<title>Supported Games</title> <title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
{% endblock %} {% endblock %}

View File

@@ -41,7 +41,7 @@
<td></td> <td></td>
{% endif %} {% endif %}
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td> <td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
{% if 'EyeSpy' in options %} {% if 'FacebookMode' in options %}
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td> <td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
{% else %} {% else %}
<td></td> <td></td>

View File

@@ -1,19 +1,18 @@
import collections import collections
import datetime
import typing import typing
from typing import Counter, Optional, Dict, Any, Tuple from typing import Counter, Optional, Dict, Any, Tuple
from uuid import UUID
from flask import render_template from flask import render_template
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
import datetime
from uuid import UUID
from MultiServer import Context from worlds.alttp import Items
from NetUtils import SlotType from WebHostLib import app, cache, Room
from Utils import restricted_loads from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from worlds.alttp import Items from MultiServer import Context
from . import app, cache from NetUtils import SlotType
from .models import Room
alttp_icons = { alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png", "Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -443,23 +442,17 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif", "Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png", "Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png", "Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
} }
minecraft_location_ids = { minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077], 42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021, "Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014], 42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046], "The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020, "Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100], 42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112, "Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095], 42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091], "Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
} }
@@ -488,8 +481,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
# Multi-items # Multi-items
multi_items = { multi_items = {
"3 Ender Pearls": 45029, "3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015, "8 Netherite Scrap": 45015
"Dragon Egg Shard": 45043
} }
for item_name, item_id in multi_items.items(): for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower() base_name = item_name.split()[-1].lower()
@@ -827,27 +819,27 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str: seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
icons = { icons = {
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png", "Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png", "Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png", "Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png", "Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png", "Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png", "Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png", "Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png", "Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png", "Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png", "Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png", "Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png", "Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png", "Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png", "Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png", "Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png", "Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png", "Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png", "Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png", "X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png", "Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png", "Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
"Nothing": "", "Nothing": "",
"No Energy": "", "No Energy": "",
"Kraid": "", "Kraid": "",

View File

@@ -1,19 +1,19 @@
import base64
import json
import typing import typing
import uuid
import zipfile import zipfile
import lzma
import json
import base64
import MultiServer
import uuid
from io import BytesIO from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import flush, select from pony.orm import flush, select
import MultiServer from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml, VersionException, __version__
from Patch import preferred_endings, AutoPatchRegister
from NetUtils import NetworkSlot, SlotType from NetUtils import NetworkSlot, SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
banned_zip_contents = (".sfc",) banned_zip_contents = (".sfc",)
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if not owner: if not owner:
owner = session["_id"] owner = session["_id"]
infolist = zfile.infolist() infolist = zfile.infolist()
slots: typing.Set[Slot] = set() slots = set()
spoiler = "" spoiler = ""
multidata = None multidata = None
for file in infolist: for file in infolist:
@@ -38,6 +38,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
player_name=patch.player_name, player_name=patch.player_name,
player_id=patch.player, player_id=patch.player,
game=patch.game)) game=patch.game))
elif file.filename.endswith(tuple(preferred_endings.values())):
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)"
metadata = yaml_data["meta"]
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game=yaml_data["game"]))
elif file.filename.endswith(".apmc"): elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read() data = zfile.open(file, "r").read()

View File

@@ -1,495 +0,0 @@
import asyncio
import base64
import platform
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
from NetUtils import ClientStatus
import Utils
from Utils import async_start
import colorama # type: ignore
from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
from zilliandomizer.utils.loc_name_maps import id_to_loc
from zilliandomizer.options import Chars
from zilliandomizer.patch import RescueInfo
from worlds.zillion.id_maps import make_id_to_others
from worlds.zillion.config import base_id, zillion_map
class ZillionCommandProcessor(ClientCommandProcessor):
ctx: "ZillionContext"
def _cmd_sms(self) -> None:
""" Tell the client that Zillion is running in RetroArch. """
logger.info("ready to look for game")
self.ctx.look_for_retroarch.set()
def _cmd_map(self) -> None:
""" Toggle view of the map tracker. """
self.ctx.ui_toggle_map()
class ToggleCallback(Protocol):
def __call__(self) -> None: ...
class SetRoomCallback(Protocol):
def __call__(self, rooms: List[List[int]]) -> None: ...
class ZillionContext(CommonContext):
game = "Zillion"
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
items_handling = 1 # receive items from other players
from_game: "asyncio.Queue[events.EventFromGame]"
to_game: "asyncio.Queue[events.EventToGame]"
ap_local_count: int
""" local checks watched by server """
next_item: int
""" index in `items_received` """
ap_id_to_name: Dict[int, str]
ap_id_to_zz_id: Dict[int, int]
start_char: Chars = "JJ"
rescues: Dict[int, RescueInfo] = {}
loc_mem_to_id: Dict[int, int] = {}
got_room_info: asyncio.Event
""" flag for connected to server """
got_slot_data: asyncio.Event
""" serves as a flag for whether I am logged in to the server """
look_for_retroarch: asyncio.Event
"""
There is a bug in Python in Windows
https://github.com/python/cpython/issues/91227
that makes it so if I look for RetroArch before it's ready,
it breaks the asyncio udp transport system.
As a workaround, we don't look for RetroArch until this event is set.
"""
ui_toggle_map: ToggleCallback
ui_set_rooms: SetRoomCallback
""" parameter is y 16 x 8 numbers to show in each room """
def __init__(self,
server_address: str,
password: str) -> None:
super().__init__(server_address, password)
self.from_game = asyncio.Queue()
self.to_game = asyncio.Queue()
self.got_room_info = asyncio.Event()
self.got_slot_data = asyncio.Event()
self.ui_toggle_map = lambda: None
self.ui_set_rooms = lambda rooms: None
self.look_for_retroarch = asyncio.Event()
if platform.system() != "Windows":
# asyncio udp bug is only on Windows
self.look_for_retroarch.set()
self.reset_game_state()
def reset_game_state(self) -> None:
for _ in range(self.from_game.qsize()):
self.from_game.get_nowait()
for _ in range(self.to_game.qsize()):
self.to_game.get_nowait()
self.got_slot_data.clear()
self.ap_local_count = 0
self.next_item = 0
self.ap_id_to_name = {}
self.ap_id_to_zz_id = {}
self.rescues = {}
self.loc_mem_to_id = {}
self.locations_checked.clear()
self.missing_locations.clear()
self.checked_locations.clear()
self.finished_game = False
self.items_received.clear()
# override
def on_deathlink(self, data: Dict[str, Any]) -> None:
self.to_game.put_nowait(events.DeathEventToGame())
return super().on_deathlink(data)
# override
async def server_auth(self, password_requested: bool = False) -> None:
if password_requested and not self.password:
await super().server_auth(password_requested)
if not self.auth:
logger.info('waiting for connection to game...')
return
logger.info("logging in to server...")
await self.send_connect()
# override
def run_gui(self) -> None:
from kvui import GameManager
from kivy.core.text import Label as CoreLabel
from kivy.graphics import Ellipse, Color, Rectangle
from kivy.uix.layout import Layout
from kivy.uix.widget import Widget
class ZillionManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zillion Client"
class MapPanel(Widget):
MAP_WIDTH: ClassVar[int] = 281
_number_textures: List[Any] = []
rooms: List[List[int]] = []
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.rooms = [[0 for _ in range(8)] for _ in range(16)]
self._make_numbers()
self.update_map()
self.bind(pos=self.update_map)
# self.bind(size=self.update_bg)
def _make_numbers(self) -> None:
self._number_textures = []
for n in range(10):
label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1))
label.refresh()
self._number_textures.append(label.texture)
def update_map(self, *args: Any) -> None:
self.canvas.clear()
with self.canvas:
Color(1, 1, 1, 1)
Rectangle(source=zillion_map,
pos=self.pos,
size=(ZillionManager.MapPanel.MAP_WIDTH,
int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image
for y in range(16):
for x in range(8):
num = self.rooms[15 - y][x]
if num > 0:
Color(0, 0, 0, 0.4)
pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24]
Ellipse(size=[22, 22], pos=pos)
Color(1, 1, 1, 1)
pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24]
num_texture = self._number_textures[num]
Rectangle(texture=num_texture, size=num_texture.size, pos=pos)
def build(self) -> Layout:
container = super().build()
self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0)
self.main_area_container.add_widget(self.map_widget)
return container
def toggle_map_width(self) -> None:
if self.map_widget.width == 0:
self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH
else:
self.map_widget.width = 0
self.container.do_layout()
def set_rooms(self, rooms: List[List[int]]) -> None:
self.map_widget.rooms = rooms
self.map_widget.update_map()
self.ui = ZillionManager(self)
self.ui_toggle_map = lambda: self.ui.toggle_map_width()
self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms)
run_co: Coroutine[Any, Any, None] = self.ui.async_run()
self.ui_task = asyncio.create_task(run_co, name="UI")
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
self.room_item_numbers_to_ui()
if cmd == "Connected":
logger.info("logged in to Archipelago server")
if "slot_data" not in args:
logger.warn("`Connected` packet missing `slot_data`")
return
slot_data = args["slot_data"]
if "start_char" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `start_char`")
return
self.start_char = slot_data['start_char']
if self.start_char not in {"Apple", "Champ", "JJ"}:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` `start_char` has invalid value: {self.start_char}")
if "rescues" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `rescues`")
return
rescues = slot_data["rescues"]
self.rescues = {}
for rescue_id, json_info in rescues.items():
assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}"
# TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch?
assert json_info["start_char"] == self.start_char, \
f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}'
ri = RescueInfo(json_info["start_char"],
json_info["room_code"],
json_info["mask"])
self.rescues[0 if rescue_id == "0" else 1] = ri
if "loc_mem_to_id" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`")
return
loc_mem_to_id = slot_data["loc_mem_to_id"]
self.loc_mem_to_id = {}
for mem_str, id_str in loc_mem_to_id.items():
mem = int(mem_str)
id_ = int(id_str)
room_i = mem // 256
assert 0 <= room_i < 74
assert id_ in id_to_loc
self.loc_mem_to_id[mem] = id_
self.got_slot_data.set()
payload = {
"cmd": "Get",
"keys": [f"zillion-{self.auth}-doors"]
}
async_start(self.send_msgs([payload]))
elif cmd == "Retrieved":
if "keys" not in args:
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
return
keys = cast(Dict[str, Optional[str]], args["keys"])
doors_b64 = keys[f"zillion-{self.auth}-doors"]
if doors_b64:
logger.info("received door data from server")
doors = base64.b64decode(doors_b64)
self.to_game.put_nowait(events.DoorEventToGame(doors))
elif cmd == "RoomInfo":
self.seed_name = args["seed_name"]
self.got_room_info.set()
def room_item_numbers_to_ui(self) -> None:
rooms = [[0 for _ in range(8)] for _ in range(16)]
for loc_id in self.missing_locations:
loc_id_small = loc_id - base_id
loc_name = id_to_loc[loc_id_small]
y = ord(loc_name[0]) - 65
x = ord(loc_name[2]) - 49
if y == 9 and x == 5:
# don't show main computer in numbers
continue
assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}"
rooms[y][x] += 1
# TODO: also add locations with locals lost from loading save state or reset
self.ui_set_rooms(rooms)
def process_from_game_queue(self) -> None:
if self.from_game.qsize():
event_from_game = self.from_game.get_nowait()
if isinstance(event_from_game, events.AcquireLocationEventFromGame):
server_id = event_from_game.id + base_id
loc_name = id_to_loc[event_from_game.id]
self.locations_checked.add(server_id)
if server_id in self.missing_locations:
self.ap_local_count += 1
n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win
logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})')
async_start(self.send_msgs([
{"cmd": 'LocationChecks', "locations": [server_id]}
]))
else:
# This will happen a lot in Zillion,
# because all the key words are local and unwatched by the server.
logger.debug(f"DEBUG: {loc_name} not in missing")
elif isinstance(event_from_game, events.DeathEventFromGame):
async_start(self.send_death())
elif isinstance(event_from_game, events.WinEventFromGame):
if not self.finished_game:
async_start(self.send_msgs([
{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}
]))
self.finished_game = True
elif isinstance(event_from_game, events.DoorEventFromGame):
if self.auth:
doors_b64 = base64.b64encode(event_from_game.doors).decode()
payload = {
"cmd": "Set",
"key": f"zillion-{self.auth}-doors",
"operations": [{"operation": "replace", "value": doors_b64}]
}
async_start(self.send_msgs([payload]))
else:
logger.warning(f"WARNING: unhandled event from game {event_from_game}")
def process_items_received(self) -> None:
if len(self.items_received) > self.next_item:
zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received]
for index in range(self.next_item, len(self.items_received)):
ap_id = self.items_received[index].item
from_name = self.player_names[self.items_received[index].player]
# TODO: colors in this text, like sni client?
logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}')
self.to_game.put_nowait(
events.ItemEventToGame(zz_item_ids)
)
self.next_item = len(self.items_received)
def name_seed_from_ram(data: bytes) -> Tuple[str, str]:
""" returns player name, and end of seed string """
if len(data) == 0:
# no connection to game
return "", "xxx"
null_index = data.find(b'\x00')
if null_index == -1:
logger.warning(f"invalid game id in rom {repr(data)}")
null_index = len(data)
name = data[:null_index].decode()
null_index_2 = data.find(b'\x00', null_index + 1)
if null_index_2 == -1:
null_index_2 = len(data)
seed_name = data[null_index + 1:null_index_2].decode()
return name, seed_name
async def zillion_sync_task(ctx: ZillionContext) -> None:
logger.info("started zillion sync task")
# to work around the Python bug where we can't check for RetroArch
if not ctx.look_for_retroarch.is_set():
logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.")
await asyncio.wait((
asyncio.create_task(ctx.look_for_retroarch.wait()),
asyncio.create_task(ctx.exit_event.wait())
), return_when=asyncio.FIRST_COMPLETED)
last_log = ""
def log_no_spam(msg: str) -> None:
nonlocal last_log
if msg != last_log:
last_log = msg
logger.info(msg)
# to only show this message once per client run
help_message_shown = False
with Memory(ctx.from_game, ctx.to_game) as memory:
while not ctx.exit_event.is_set():
ram = await memory.read()
game_id = memory.get_rom_to_ram_data(ram)
name, seed_end = name_seed_from_ram(game_id)
if len(name):
if name == ctx.auth:
# this is the name we know
if ctx.server and ctx.server.socket: # type: ignore
if ctx.got_room_info.is_set():
if ctx.seed_name and ctx.seed_name.endswith(seed_end):
# correct seed
if memory.have_generation_info():
log_no_spam("everything connected")
await memory.process_ram(ram)
ctx.process_from_game_queue()
ctx.process_items_received()
else: # no generation info
if ctx.got_slot_data.is_set():
memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id)
ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \
make_id_to_others(ctx.start_char)
ctx.next_item = 0
ctx.ap_local_count = len(ctx.checked_locations)
else: # no slot data yet
async_start(ctx.send_connect())
log_no_spam("logging in to server...")
await asyncio.wait((
ctx.got_slot_data.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
else: # not correct seed name
log_no_spam("incorrect seed - did you mix up roms?")
else: # no room info
# If we get here, it looks like `RoomInfo` packet got lost
log_no_spam("waiting for room info from server...")
else: # server not connected
log_no_spam("waiting for server connection...")
else: # new game
log_no_spam("connected to new game")
await ctx.disconnect()
ctx.reset_server_state()
ctx.seed_name = None
ctx.got_room_info.clear()
ctx.reset_game_state()
memory.reset_game_state()
ctx.auth = name
async_start(ctx.connect())
await asyncio.wait((
ctx.got_room_info.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED)
else: # no name found in game
if not help_message_shown:
logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.')
help_message_shown = True
log_no_spam("looking for connection to game...")
await asyncio.sleep(0.3)
await asyncio.sleep(0.09375)
logger.info("zillion sync task ending")
async def main() -> None:
parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apzl Archipelago Binary Patch file')
# SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
args = parser.parse_args()
print(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating sms rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
ctx = ZillionContext(args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
sync_task = asyncio.create_task(zillion_sync_task(ctx))
await ctx.exit_event.wait()
ctx.server_address = None
logger.debug("waiting for sync task to end")
await sync_task
logger.debug("sync task ended")
await ctx.shutdown()
if __name__ == "__main__":
Utils.init_logging("ZillionClient", exception_logger="Client")
colorama.init()
asyncio.run(main())
colorama.deinit()

BIN
data/basepatch.apbp Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -15,8 +15,6 @@
<UILog>: <UILog>:
viewclass: 'SelectableLabel' viewclass: 'SelectableLabel'
scroll_y: 0 scroll_y: 0
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect" effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout: SelectableRecycleBoxLayout:
default_size: None, dp(20) default_size: None, dp(20)

View File

@@ -97,11 +97,6 @@ local extensionConsumableLookup = {
[443] = 0x3F [443] = 0x3F
} }
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local itemMessages = {} local itemMessages = {}
local consumableStacks = nil local consumableStacks = nil
local prevstate = "" local prevstate = ""
@@ -346,7 +341,7 @@ function processBlock(block)
-- This is a key item -- This is a key item
memoryLocation = memoryLocation - 0x0E0 memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01) wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then elseif v >= 0x1E0 then
-- This is a movement item -- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0) -- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0 memoryLocation = memoryLocation - 0x1E0
@@ -356,10 +351,7 @@ function processBlock(block)
else else
wU8(memoryLocation, 0x01) wU8(memoryLocation, 0x01)
end end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item -- This is a gold item
amountToAdd = goldLookup[v] amountToAdd = goldLookup[v]

View File

@@ -77,13 +77,12 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x10) return scene_check(scene_offset, bit_to_check, 0x10)
end end
-- Why is there an extra offset of 3 for temp context checks? Who knows.
local cow_check = function(scene_offset, bit_to_check) local cow_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC) return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03}) or check_temp_context({scene_offset, 0x00, bit_to_check})
end end
-- DMT and DMC fairies are weird, their temp context check is special-coded for them -- Haven't been able to get DMT and DMC fairy to send instantly
local great_fairy_magic_check = function(scene_offset, bit_to_check) local great_fairy_magic_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x4) return scene_check(scene_offset, bit_to_check, 0x4)
or check_temp_context({scene_offset, 0x05, bit_to_check}) or check_temp_context({scene_offset, 0x05, bit_to_check})
@@ -101,18 +100,6 @@ local bean_sale_check = function(scene_offset, bit_to_check)
or check_temp_context({scene_offset, 0x00, 0x16}) or check_temp_context({scene_offset, 0x00, 0x16})
end end
-- Medigoron reports 0x00620028 to 0x40002C
local medigoron_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x28})
end
-- Bombchu salesman reports 0x005E0003 to 0x40002C
local salesman_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x03})
end
--Helper method to resolve skulltula lookup location --Helper method to resolve skulltula lookup location
local function skulltula_scene_to_array_index(i) local function skulltula_scene_to_array_index(i)
return (i + 3) - 2 * (i % 4) return (i + 3) - 2 * (i % 4)
@@ -588,7 +575,7 @@ local read_death_mountain_trail_checks = function()
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E) checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
checks["DMT Chest"] = chest_check(0x60, 0x01) checks["DMT Chest"] = chest_check(0x60, 0x01)
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17) checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) or check_temp_context({0xFF, 0x05, 0x13}) checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18)
checks["DMT Biggoron"] = big_goron_sword_check() checks["DMT Biggoron"] = big_goron_sword_check()
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18) checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
@@ -605,7 +592,7 @@ local read_goron_city_checks = function()
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F) checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6) checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1) checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
checks["GC Medigoron"] = medigoron_check(0x62, 0x1) checks["GC Medigoron"] = on_the_ground_check(0x62, 0x1)
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00) checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01) checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02) checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
@@ -627,7 +614,7 @@ local read_death_mountain_crater_checks = function()
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08) checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02) checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A) checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) or check_temp_context({0xFF, 0x05, 0x14}) checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10)
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6) checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1) checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
@@ -974,7 +961,7 @@ end
local read_haunted_wasteland_checks = function() local read_haunted_wasteland_checks = function()
local checks = {} local checks = {}
checks["Wasteland Bombchu Salesman"] = salesman_check(0x5E, 0x01) checks["Wasteland Bombchu Salesman"] = on_the_ground_check(0x5E, 0x01)
checks["Wasteland Chest"] = chest_check(0x5E, 0x00) checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
checks["Wasteland GS"] = skulltula_check(0x15, 0x1) checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
return checks return checks

Binary file not shown.

View File

@@ -1,389 +0,0 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
function error(err)
print(err)
end
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
print("invalid table: sparse array")
print(n)
print("VAL:")
print(val)
print("STACK:")
print(stack)
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

View File

@@ -1,226 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local APIndex = 0x1A6E
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local InGame = 0x1A71
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local u16
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
memDomain["rom"] = function() memory.usememorydomain("ROM") end
memDomain["wram"] = function() memory.usememorydomain("WRAM") end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRange(address, bytes)
data = memory.readbyterange(address - 1, bytes + 1)
data[0] = nil
return data
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
-- print(itemsBlock)
ItemsReceived = itemsBlock
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = u8(RodAddress)
data = {}
table.foreach(events, function(k, v) table.insert(data, v) end)
table.foreach(missables, function(k, v) table.insert(data, v) end)
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
return data
end
function generateSerialData()
memDomain.wram()
status = u8(0x1A73)
if status == 0 then
return nil
end
return uRange(0x1A76, u8(0x1A74))
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
function receive()
l, e = gbSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
processBlock(json.decode(l))
end
-- Determine Message to send back
memDomain.rom()
newPlayerName = uRange(0xFFF0, 0x10)
newSeedName = uRange(0xFFDB, 21)
if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
playerName = newPlayerName
seedName = newSeedName
local retTable = {}
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
if u8(InGame) == 0xAC then
retTable["locations"] = generateLocationsChecked()
serialData = generateSerialData()
if serialData ~= nil then
retTable["serial"] = serialData
end
end
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
curstate = STATE_OK
end
end
function main()
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
return
end
server, error = socket.bind('localhost', 17242)
while true do
frame = frame + 1
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if ItemsReceived[ItemIndex + 1] ~= nil then
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -1,132 +0,0 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -221,7 +221,7 @@ Starting with version 4 of the APBP format, this is a ZIP file containing metada
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
bsdiff between the original and the randomized ROM. bsdiff between the original and the randomized ROM.
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`. To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
### Mod files ### Mod files
Games which support modding will usually just let you drag and drop the mods files into a folder somewhere. Games which support modding will usually just let you drag and drop the mods files into a folder somewhere.
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
generated per seed. generated per seed.
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
integration into the Webhost by inheriting from `worlds.Files.APContainer`. integration into the Webhost by inheriting from `Patch.APContainer`.
## Archipelago Integration ## Archipelago Integration

View File

@@ -23,10 +23,3 @@ No metadata is specified yet.
## Extra Data ## Extra Data
The zip can contain arbitrary files in addition what was specified above. The zip can contain arbitrary files in addition what was specified above.
## Caveats
Imports from other files inside the apworld have to use relative imports.
Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.

View File

@@ -1,11 +0,0 @@
# Code of Conduct
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private ones, such as private messaging or emails.
Any incidents of abuse may be reported directly to ijwu at hmfarran@gmail.com.

View File

@@ -1,12 +0,0 @@
# Contributing
Contributions are welcome. We have a few requests of any new contributors.
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
* Follow styling as designated in our [styling documentation](/docs/style.md).
Otherwise, we tend to judge code on a case to case basis.
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
channel in our [Discord](https://archipelago.gg/discord).

View File

@@ -13,19 +13,9 @@ These steps should be followed in order to establish a gameplay connection with
In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet. In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet.
There are also a number of community-supported libraries available that implement this network protocol to make integrating with Archipelago easier. There are libraries available that implement this network protocol in [Python](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py), [Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java), [.Net](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Net) and [C++](https://github.com/black-sliver/apclientpp)
| Language/Runtime | Project | Remarks | For Super Nintendo games there are clients available in either [Node](https://github.com/ArchipelagoMW/SuperNintendoClient) or [Python](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py), There are also game specific clients available for [The Legend of Zelda: Ocarina of Time](https://github.com/ArchipelagoMW/Z5Client) or [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/FF1Client.py)
|-------------------------------|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| Python | [Archipelago CommonClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py) | |
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only |
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | |
## Synchronizing Items ## Synchronizing Items
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet. When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
@@ -163,7 +153,6 @@ All arguments for this packet are optional, only changes are sent.
### Print ### Print
Sent to clients purely to display a message to the player. Sent to clients purely to display a message to the player.
* *Deprecation warning: clients that connect with version 0.3.5 or higher will nolonger recieve Print packets, instead all messsages are send as [PrintJSON](#PrintJSON)*
#### Arguments #### Arguments
| Name | Type | Notes | | Name | Type | Notes |
| ---- | ---- | ----- | | ---- | ---- | ----- |
@@ -175,21 +164,10 @@ Sent to clients purely to display a message to the player. This packet differs f
| Name | Type | Notes | | Name | Type | Notes |
| ---- | ---- | ----- | | ---- | ---- | ----- |
| data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. | | data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. |
| type | str | May be present to indicate the [PrintJsonType](#PrintJsonType) of this message. | | type | str | May be present to indicate the nature of this message. Known types are Hint and ItemSend. |
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. | | receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. |
| item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. | | item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. |
| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. | | found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. |
| countdown | int | Is present if type is `Countdown`, denotes the amount of seconds remaining on the countdown. |
##### PrintJsonType
PrintJsonType indicates the type of [PrintJson](#PrintJson) packet, different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown type the data's list\[[JSONMessagePart](#JSONMessagePart)\] should still be printed as normal.
Currently defined types are:
| Type | Notes |
| ---- | ----- |
| ItemSend | The message is in response to a player receiving an item. |
| Hint | The message is in response to a player hinting. |
| Countdown | The message contains information about the current server Countdown. |
### DataPackage ### DataPackage
Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info. Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.
@@ -235,8 +213,6 @@ Sent to clients as a response the a [Get](#Get) package.
| ---- | ---- | ----- | | ---- | ---- | ----- |
| keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. | | keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. |
If a requested key was not present in the server's data, the associated value will be `null`.
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along. Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
### SetReply ### SetReply
@@ -374,7 +350,7 @@ Used to write data to the server's data storage, that data can then be shared ac
| ------ | ----- | ------ | | ------ | ----- | ------ |
| key | str | The key to manipulate. | | key | str | The key to manipulate. |
| default | any | The default value to use in case the key has no value on the server. | | default | any | The default value to use in case the key has no value on the server. |
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. | | want_reply | bool | If set, the server will send a [SetReply](#SetReply) response back to the client. |
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. | | operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers. Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.

View File

@@ -16,14 +16,6 @@ Then run any of the starting point scripts, like Generate.py, and the included M
required modules and after pressing enter proceed to install everything automatically. required modules and after pressing enter proceed to install everything automatically.
After this, you should be able to run the programs. After this, you should be able to run the programs.
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
* `--log_network` is a command line parameter useful for debugging.
* `WebHost.py` will host the website on your computer.
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
to change WebHost options (like the web hosting port number).
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
## Windows ## Windows
@@ -64,8 +56,3 @@ SNI is required to use SNIClient. If not integrated into the project, it has to
You can get the latest SNI release at [SNI Github releases](https://github.com/alttpo/sni/releases). You can get the latest SNI release at [SNI Github releases](https://github.com/alttpo/sni/releases).
It should be dropped as "SNI" into the root folder of the project. Alternatively, you can point the sni setting in It should be dropped as "SNI" into the root folder of the project. Alternatively, you can point the sni setting in
host.yaml at your SNI folder. host.yaml at your SNI folder.
## Running tests
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.

View File

@@ -15,9 +15,7 @@
* Strings in worlds should use double quotes as well, but imported code may differ. * Strings in worlds should use double quotes as well, but imported code may differ.
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation, * Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
use single quotes inside them: `f"Like {dct['key']}"` use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotations where possible for function signatures and class members. * Use type annotation where possible.
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
## Markdown ## Markdown

View File

@@ -102,18 +102,13 @@ Locations are places where items can be located in your game. This may be chests
or boss drops for RPG-like games but could also be progress in a research tree. or boss drops for RPG-like games but could also be progress in a research tree.
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
in a Region, has access rules and a classification. in a Region and has access rules.
The name needs to be unique in each game and must not be numeric (has to The name needs to be unique in each game, the ID needs to be unique across all
contain least 1 letter or symbol). The ID needs to be unique across all games games and is best in the same range as the item IDs.
and is best in the same range as the item IDs.
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved. World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
Special locations with ID `None` can hold events. Special locations with ID `None` can hold events.
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
The Fill algorithm will fill priority first, giving higher chance of it being
required, and not place progression or useful items in excluded locations.
### Items ### Items
Items are all things that can "drop" for your game. This may be RPG items like Items are all things that can "drop" for your game. This may be RPG items like
@@ -126,9 +121,6 @@ their world. Progression items will be assigned to locations with higher
priority and moved around to meet defined rules and accomplish progression priority and moved around to meet defined rules and accomplish progression
balancing. balancing.
The name needs to be unique in each game, meaning a duplicate item has the
same ID. Name must not be numeric (has to contain at least 1 letter or symbol).
Special items with ID `None` can mark events (read below). Special items with ID `None` can mark events (read below).
Other classifications include Other classifications include
@@ -196,17 +188,15 @@ the `/worlds` directory. The starting point for the package is `__init.py__`.
Conventionally, your world class is placed in that file. Conventionally, your world class is placed in that file.
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`, World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
which can be imported as `worlds.AutoWorld.World` from your package. which can be imported as `..AutoWorld.World` from your package.
AP will pick up your world automatically due to the `AutoWorld` implementation. AP will pick up your world automatically due to the `AutoWorld` implementation.
### Requirements ### Requirements
If your world needs specific python packages, they can be listed in If your world needs specific python packages, they can be listed in
`world/[world_name]/requirements.txt`. ModuleUpdate.py will automatically `world/[world_name]/requirements.txt`.
pick up and install them. See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format)
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format).
### Relative Imports ### Relative Imports
@@ -219,10 +209,6 @@ e.g. `from .Options import mygame_options` from your `__init__.py` will load
When imported names pile up it may be easier to use `from . import Options` When imported names pile up it may be easier to use `from . import Options`
and access the variable as `Options.mygame_options`. and access the variable as `Options.mygame_options`.
Imports from directories outside your world should use absolute imports.
Correct use of relative / absolute imports is required for zipped worlds to
function, see [apworld specification.md](apworld%20specification.md).
### Your Item Type ### Your Item Type
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
@@ -288,12 +274,14 @@ Define a property `option_<name> = <number>` per selectable value and
`default = <number>` to set the default selection. Aliases can be set by `default = <number>` to set the default selection. Aliases can be set by
defining a property `alias_<name> = <same number>`. defining a property `alias_<name> = <same number>`.
One special case where aliases are required is when option name is `yes`, `no`,
`on` or `off` because they parse to `True` or `False`:
```python ```python
option_off = 0 option_off = 0
option_on = 1 option_on = 1
option_some = 2 option_some = 2
alias_disabled = 0 alias_false = 0
alias_enabled = 1 alias_true = 1
default = 0 default = 0
``` ```
@@ -335,7 +323,7 @@ mygame_options: typing.Dict[str, type(Option)] = {
```python ```python
# __init__.py # __init__.py
from worlds.AutoWorld import World from ..AutoWorld import World
from .Options import mygame_options # import the options dict from .Options import mygame_options # import the options dict
class MyGameWorld(World): class MyGameWorld(World):
@@ -364,7 +352,7 @@ more natural. These games typically have been edited to 'bake in' the items.
from .Options import mygame_options # the options we defined earlier from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above from .Locations import mygame_locations # same as above
from worlds.AutoWorld import World from ..AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
from Utils import get_options, output_path from Utils import get_options, output_path
@@ -459,7 +447,7 @@ In addition, the following methods can be implemented and attributes can be set
```python ```python
def generate_early(self) -> None: def generate_early(self) -> None:
# read player settings to world instance # read player settings to world instance
self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value self.final_boss_hp = self.world.final_boss_hp[self.player].value
``` ```
#### create_item #### create_item
@@ -494,19 +482,19 @@ def create_items(self) -> None:
# If an item can't have duplicates it has to be excluded manually. # If an item can't have duplicates it has to be excluded manually.
# List of items to exclude, as a copy since it will be destroyed below # List of items to exclude, as a copy since it will be destroyed below
exclude = [item for item in self.multiworld.precollected_items[self.player]] exclude = [item for item in self.world.precollected_items[self.player]]
for item in map(self.create_item, mygame_items): for item in map(self.create_item, mygame_items):
if item in exclude: if item in exclude:
exclude.remove(item) # this is destructive. create unique list above exclude.remove(item) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item("nothing")) self.world.itempool.append(self.create_item("nothing"))
else: else:
self.multiworld.itempool.append(item) self.world.itempool.append(item)
# itempool and number of locations should match up. # itempool and number of locations should match up.
# If this is not the case we want to fill the itempool with junk. # If this is not the case we want to fill the itempool with junk.
junk = 0 # calculate this based on player settings junk = 0 # calculate this based on player settings
self.multiworld.itempool += [self.create_item("nothing") for _ in range(junk)] self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
``` ```
#### create_regions #### create_regions
@@ -515,30 +503,30 @@ def create_items(self) -> None:
def create_regions(self) -> None: def create_regions(self) -> None:
# Add regions to the multiworld. "Menu" is the required starting point. # Add regions to the multiworld. "Menu" is the required starting point.
# Arguments to Region() are name, type, human_readable_name, player, world # Arguments to Region() are name, type, human_readable_name, player, world
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld) r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
# Set Region.exits to a list of entrances that are reachable from region # Set Region.exits to a list of entrances that are reachable from region
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
# Append region to MultiWorld's regions # Append region to MultiWorld's regions
self.multiworld.regions.append(r) # or use += [r...] self.world.regions.append(r) # or use += [r...]
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.multiworld) r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
# Add main area's locations to main area (all but final boss) # Add main area's locations to main area (all but final boss)
r.locations = [MyGameLocation(self.player, location.name, r.locations = [MyGameLocation(self.player, location.name,
self.location_name_to_id[location.name], r)] self.location_name_to_id[location.name], r)]
r.exits = [Entrance(self.player, "Boss Door", r)] r.exits = [Entrance(self.player, "Boss Door", r)]
self.multiworld.regions.append(r) self.world.regions.append(r)
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.multiworld) r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
# add event to Boss Room # add event to Boss Room
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)] r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
self.multiworld.regions.append(r) self.world.regions.append(r)
# If entrances are not randomized, they should be connected here, otherwise # If entrances are not randomized, they should be connected here, otherwise
# they can also be connected at a later stage. # they can also be connected at a later stage.
self.multiworld.get_entrance("New Game", self.player) self.world.get_entrance("New Game", self.player)\
.connect(self.multiworld.get_region("Main Area", self.player)) .connect(self.world.get_region("Main Area", self.player))
self.multiworld.get_entrance("Boss Door", self.player) self.world.get_entrance("Boss Door", self.player)\
.connect(self.multiworld.get_region("Boss Room", self.player)) .connect(self.world.get_region("Boss Room", self.player))
# If setting location access rules from data is easier here, set_rules can # If setting location access rules from data is easier here, set_rules can
# possibly omitted. # possibly omitted.
@@ -549,14 +537,14 @@ def create_regions(self) -> None:
```python ```python
def generate_basic(self) -> None: def generate_basic(self) -> None:
# place "Victory" at "Final Boss" and set collection as win condition # place "Victory" at "Final Boss" and set collection as win condition
self.multiworld.get_location("Final Boss", self.player) self.world.get_location("Final Boss", self.player)\
.place_locked_item(self.create_event("Victory")) .place_locked_item(self.create_event("Victory"))
self.multiworld.completion_condition[self.player] = self.world.completion_condition[self.player] = \
lambda state: state.has("Victory", self.player) lambda state: state.has("Victory", self.player)
# place item Herb into location Chest1 for some reason # place item Herb into location Chest1 for some reason
item = self.create_item("Herb") item = self.create_item("Herb")
self.multiworld.get_location("Chest1", self.player).place_locked_item(item) self.world.get_location("Chest1", self.player).place_locked_item(item)
# in most cases it's better to do this at the same time the itempool is # in most cases it's better to do this at the same time the itempool is
# filled to avoid accidental duplicates: # filled to avoid accidental duplicates:
# manually placed and still in the itempool # manually placed and still in the itempool
@@ -565,45 +553,44 @@ def generate_basic(self) -> None:
### Setting Rules ### Setting Rules
```python ```python
from worlds.generic.Rules import add_rule, set_rule, forbid_item from ..generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type from Items import get_item_type
def set_rules(self) -> None: def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin # For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during # (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic # location generation or everything is in generate_basic
# set a simple rule for an region # set a simple rule for an region
set_rule(self.multiworld.get_entrance("Boss Door", self.player), set_rule(self.world.get_entrance("Boss Door", self.player),
lambda state: state.has("Boss Key", self.player)) lambda state: state.has("Boss Key", self.player))
# combine rules to require two items # combine rules to require two items
add_rule(self.multiworld.get_location("Chest2", self.player), add_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Sword", self.player)) lambda state: state.has("Sword", self.player))
add_rule(self.multiworld.get_location("Chest2", self.player), add_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Shield", self.player)) lambda state: state.has("Shield", self.player))
# or simply combine yourself # or simply combine yourself
set_rule(self.multiworld.get_location("Chest2", self.player), set_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Sword", self.player) and lambda state: state.has("Sword", self.player) and
state.has("Shield", self.player)) state.has("Shield", self.player))
# require two of an item # require two of an item
set_rule(self.multiworld.get_location("Chest3", self.player), set_rule(self.world.get_location("Chest3", self.player),
lambda state: state.has("Key", self.player, 2)) lambda state: state.has("Key", self.player, 2))
# require one item from an item group # require one item from an item group
add_rule(self.multiworld.get_location("Chest3", self.player), add_rule(self.world.get_location("Chest3", self.player),
lambda state: state.has_group("weapons", self.player)) lambda state: state.has_group("weapons", self.player))
# state also has .item_count() for items, .has_any() and.has_all() for sets # state also has .item_count() for items, .has_any() and.has_all() for sets
# and .count_group() for groups # and .count_group() for groups
# set_rule is likely to be a bit faster than add_rule # set_rule is likely to be a bit faster than add_rule
# disallow placing a specific local item at a specific location # disallow placing a specific local item at a specific location
forbid_item(self.multiworld.get_location("Chest4", self.player), "Sword") forbid_item(self.world.get_location("Chest4", self.player), "Sword")
# disallow placing items with a specific property # disallow placing items with a specific property
add_item_rule(self.multiworld.get_location("Chest5", self.player), add_item_rule(self.world.get_location("Chest5", self.player),
lambda item: get_item_type(item) == "weapon") lambda item: get_item_type(item) == "weapon")
# get_item_type needs to take player/world into account # get_item_type needs to take player/world into account
# if MyGameItem has a type property, a more direct implementation would be # if MyGameItem has a type property, a more direct implementation would be
add_item_rule(self.multiworld.get_location("Chest5", self.player), add_item_rule(self.world.get_location("Chest5", self.player),
lambda item: item.player != self.player or\ lambda item: item.player != self.player or\
item.my_type == "weapon") item.my_type == "weapon")
# location.item_rule = ... is likely to be a bit faster # location.item_rule = ... is likely to be a bit faster
@@ -616,16 +603,14 @@ implement more complex logic in logic mixins, even if there is no need to add
properties to the `BaseClasses.CollectionState` state object. properties to the `BaseClasses.CollectionState` state object.
When importing a file that defines a class that inherits from When importing a file that defines a class that inherits from
`worlds.AutoWorld.LogicMixin` the state object's class is automatically extended by `..AutoWorld.LogicMixin` the state object's class is automatically extended by
the mixin's members. These members should be prefixed with underscore following the mixin's members. These members should be prefixed with underscore following
the name of the implementing world. This is due to sharing a namespace with all the name of the implementing world. This is due to sharing a namespace with all
other logic mixins. other logic mixins.
Typical uses are defining methods that are used instead of `state.has` Typical uses are defining methods that are used instead of `state.has`
in lambdas, e.g.`state.mygame_has(custom, player)` or recurring checks in lambdas, e.g.`state._mygame_has(custom, world, player)` or recurring checks
like `state.mygame_can_do_something(player)` to simplify lambdas. like `state._mygame_can_do_something(world, player)` to simplify lambdas.
Private members, only accessible from mixins, should start with `_mygame_`,
public members with `mygame_`.
More advanced uses could be to add additional variables to the state object, More advanced uses could be to add additional variables to the state object,
override `World.collect(self, state, item)` and `remove(self, state, item)` override `World.collect(self, state, item)` and `remove(self, state, item)`
@@ -637,26 +622,25 @@ Please do this with caution and only when neccessary.
```python ```python
# Logic.py # Logic.py
from worlds.AutoWorld import LogicMixin from ..AutoWorld import LogicMixin
class MyGameLogic(LogicMixin): class MyGameLogic(LogicMixin):
def mygame_has_key(self, player: int): def _mygame_has_key(self, world: MultiWorld, player: int):
# Arguments above are free to choose # Arguments above are free to choose
# MultiWorld can be accessed through self.world, explicitly passing in # it may make sense to use World as argument instead of MultiWorld
# MyGameWorld instance for easy options access is also a valid approach
return self.has("key", player) # or whatever return self.has("key", player) # or whatever
``` ```
```python ```python
# __init__.py # __init__.py
from worlds.generic.Rules import set_rule from ..generic.Rules import set_rule
import .Logic # apply the mixin by importing its file import .Logic # apply the mixin by importing its file
class MyGameWorld(World): class MyGameWorld(World):
# ... # ...
def set_rules(self): def set_rules(self):
set_rule(self.world.get_location("A Door", self.player), set_rule(self.world.get_location("A Door", self.player),
lamda state: state.mygame_has_key(self.player)) lamda state: state._mygame_has_key(self.world, self.player))
``` ```
### Generate Output ### Generate Output
@@ -664,33 +648,32 @@ class MyGameWorld(World):
```python ```python
from .Mod import generate_mod from .Mod import generate_mod
def generate_output(self, output_directory: str): def generate_output(self, output_directory: str):
# How to generate the mod or ROM highly depends on the game # How to generate the mod or ROM highly depends on the game
# if the mod is written in Lua, Jinja can be used to fill a template # if the mod is written in Lua, Jinja can be used to fill a template
# if the mod reads a json file, `json.dump()` can be used to generate that # if the mod reads a json file, `json.dump()` can be used to generate that
# code below is a dummy # code below is a dummy
data = { data = {
"seed": self.multiworld.seed_name, # to verify the server's multiworld "seed": self.world.seed_name, # to verify the server's multiworld
"slot": self.multiworld.player_name[self.player], # to connect to server "slot": self.world.player_name[self.player], # to connect to server
"items": {location.name: location.item.name "items": {location.name: location.item.name
if location.item.player == self.player else "Remote" if location.item.player == self.player else "Remote"
for location in self.multiworld.get_filled_locations(self.player)}, for location in self.world.get_filled_locations(self.player)},
# store start_inventory from player's .yaml # store start_inventory from player's .yaml
"starter_items": [item.name for item "starter_items": [item.name for item
in self.multiworld.precollected_items[self.player]], in self.world.precollected_items[self.player]],
"final_boss_hp": self.final_boss_hp, "final_boss_hp": self.final_boss_hp,
# store option name "easy", "normal" or "hard" for difficuly # store option name "easy", "normal" or "hard" for difficuly
"difficulty": self.multiworld.difficulty[self.player].current_key, "difficulty": self.world.difficulty[self.player].current_key,
# store option value True or False for fixing a glitch # store option value True or False for fixing a glitch
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value "fix_xyz_glitch": self.world.fix_xyz_glitch[self.player].value
} }
# point to a ROM specified by the installation # point to a ROM specified by the installation
src = Utils.get_options()["mygame_options"]["rom_file"] src = Utils.get_options()["mygame_options"]["rom_file"]
# or point to worlds/mygame/data/mod_template # or point to worlds/mygame/data/mod_template
src = os.path.join(os.path.dirname(__file__), "data", "mod_template") src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
# generate output path # generate output path
mod_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.player_name[self.player]}" mod_name = f"AP-{self.world.seed_name}-P{self.player}-{self.world.player_name[self.player]}"
out_file = os.path.join(output_directory, mod_name + ".zip") out_file = os.path.join(output_directory, mod_name + ".zip")
# generate the file # generate the file
generate_mod(src, out_file, data) generate_mod(src, out_file, data)

View File

@@ -82,27 +82,28 @@ generator:
# List of options that can be plando'd. Can be combined, for example "bosses, items" # List of options that can be plando'd. Can be combined, for example "bosses, items"
# Available options: bosses, items, texts, connections # Available options: bosses, items, texts, connections
plando_options: "bosses" plando_options: "bosses"
sni_options:
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni_path: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
snes_rom_start: true
lttp_options: lttp_options:
# File name of the v1.0 J rom # File name of the v1.0 J rom
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
sm_options: sm_options:
# File name of the v1.0 J rom # File name of the v1.0 J rom
rom_file: "Super Metroid (JU).sfc" rom_file: "Super Metroid (JU).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
factorio_options: factorio_options:
executable: "factorio/bin/x64/factorio" executable: "factorio/bin/x64/factorio"
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used. # by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
# server_settings: "factorio\\data\\server-settings.json" # server_settings: "factorio\\data\\server-settings.json"
# Whether to filter item send messages displayed in-game to only those that involve you.
filter_item_sends: false
# Whether to send chat messages from players on the Factorio server to Archipelago.
bridge_chat_out: true
minecraft_options: minecraft_options:
forge_directory: "Minecraft Forge server" forge_directory: "Minecraft Forge server"
max_heap_size: "2G" max_heap_size: "2G"
@@ -121,26 +122,19 @@ soe_options:
rom_file: "Secret of Evermore (USA).sfc" rom_file: "Secret of Evermore (USA).sfc"
ffr_options: ffr_options:
display_msgs: true display_msgs: true
dkc3_options: smz3_options:
# File name of the DKC3 US rom # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc" sni: "SNI"
smw_options:
# File name of the SMW US rom
rom_file: "Super Mario World (USA).sfc"
pokemon_rb_options:
# File names of the Pokemon Red and Blue roms
red_rom_file: "Pokemon Red (UE) [S][!].gb"
blue_rom_file: "Pokemon Blue (UE) [S][!].gb"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .gb file with
rom_start: true
zillion_options:
# File name of the Zillion US rom
rom_file: "Zillion (UE) [!].sms"
# Set this to false to never autostart a rom (such as after patching) # Set this to false to never autostart a rom (such as after patching)
# True for operating system default program # True for operating system default program
# Alternatively, a path to a program to open the .sfc file with # Alternatively, a path to a program to open the .sfc file with
# RetroArch doesn't make it easy to launch a game from the command line. rom_start: true
# You have to know the path to the emulator core library on the user's computer. dkc3_options:
rom_start: "retroarch" # File name of the DKC3 US rom
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true

View File

@@ -55,30 +55,21 @@ Name: "core"; Description: "Core Files"; Types: full hosting playing
Name: "generator"; Description: "Generator"; Types: full hosting Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
Name: "server"; Description: "Server"; Types: full hosting Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/pkmn"; Description: "Pokemon Client"
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/zl"; Description: "Zillion"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs] [Dirs]
@@ -88,12 +79,8 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3 Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
@@ -107,9 +94,7 @@ Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: i
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1 Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2 Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
@@ -122,9 +107,7 @@ Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.e
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1 Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2 Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
@@ -134,9 +117,7 @@ Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNI
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1 Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2 Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
@@ -170,16 +151,6 @@ Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
@@ -200,16 +171,6 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archip
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/red
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/red
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/red
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/red
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/blue
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/blue
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/blue
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/blue
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
@@ -235,7 +196,7 @@ begin
begin begin
// Is the installed version at least the packaged one ? // Is the installed version at least the packaged one ?
Log('VC Redist x64 Version : found ' + strVersion); Log('VC Redist x64 Version : found ' + strVersion);
Result := (CompareStr(strVersion, 'v14.32.31332') < 0); Result := (CompareStr(strVersion, 'v14.29.30037') < 0);
end end
else else
begin begin
@@ -256,24 +217,12 @@ var SMRomFilePage: TInputFileWizardPage;
var dkc3rom: string; var dkc3rom: string;
var DKC3RomFilePage: TInputFileWizardPage; var DKC3RomFilePage: TInputFileWizardPage;
var smwrom: string;
var SMWRomFilePage: TInputFileWizardPage;
var soerom: string; var soerom: string;
var SoERomFilePage: TInputFileWizardPage; var SoERomFilePage: TInputFileWizardPage;
var ootrom: string; var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage; var OoTROMFilePage: TInputFileWizardPage;
var zlrom: string;
var ZlROMFilePage: TInputFileWizardPage;
var redrom: string;
var RedROMFilePage: TInputFileWizardPage;
var bluerom: string;
var BlueROMFilePage: TInputFileWizardPage;
function GetSNESMD5OfFile(const rom: string): string; function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString; var data: AnsiString;
begin begin
@@ -287,15 +236,6 @@ begin
end; end;
end; end;
function GetSMSMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string; function CheckRom(name: string; hash: string): string;
var rom: string; var rom: string;
begin begin
@@ -315,25 +255,6 @@ begin
end; end;
end; end;
function CheckSMSRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function AddRomPage(name: string): TInputFileWizardPage; function AddRomPage(name: string): TInputFileWizardPage;
begin begin
Result := Result :=
@@ -349,37 +270,6 @@ begin
'.sfc'); '.sfc');
end; end;
function AddGBRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'GB ROM files|*.gb;*.gbc|All files|*.*',
'.gb');
end;
function AddSMSRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SMS ROM files|*.sms|All files|*.*',
'.sms');
end;
procedure AddOoTRomPage(); procedure AddOoTRomPage();
begin begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue()); ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
@@ -418,14 +308,10 @@ begin
Result := not (SMROMFilePage.Values[0] = '') Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
Result := not (DKC3ROMFilePage.Values[0] = '') Result := not (DKC3ROMFilePage.Values[0] = '')
else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then
Result := not (SMWROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '') Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '') Result := not (OoTROMFilePage.Values[0] = '')
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
Result := not (ZlROMFilePage.Values[0] = '')
else else
Result := True; Result := True;
end; end;
@@ -478,22 +364,6 @@ begin
Result := ''; Result := '';
end; end;
function GetSMWROMPath(Param: string): string;
begin
if Length(smwrom) > 0 then
Result := smwrom
else if Assigned(SMWRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804')
if R <> 0 then
MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SMWROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSoEROMPath(Param: string): string; function GetSoEROMPath(Param: string): string;
begin begin
if Length(soerom) > 0 then if Length(soerom) > 0 then
@@ -526,54 +396,6 @@ begin
Result := ''; Result := '';
end; end;
function GetZlROMPath(Param: string): string;
begin
if Length(zlrom) > 0 then
Result := zlrom
else if Assigned(ZlROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if R <> 0 then
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := ZlROMFilePage.Values[0]
end
else
Result := '';
end;
function GetRedROMPath(Param: string): string;
begin
if Length(redrom) > 0 then
Result := redrom
else if Assigned(RedRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
if R <> 0 then
MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := RedROMFilePage.Values[0]
end
else
Result := '';
end;
function GetBlueROMPath(Param: string): string;
begin
if Length(bluerom) > 0 then
Result := bluerom
else if Assigned(BlueRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
if R <> 0 then
MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := BlueROMFilePage.Values[0]
end
else
Result := '';
end;
procedure InitializeWizard(); procedure InitializeWizard();
begin begin
AddOoTRomPage(); AddOoTRomPage();
@@ -590,25 +412,9 @@ begin
if Length(dkc3rom) = 0 then if Length(dkc3rom) = 0 then
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc'); DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804');
if Length(smwrom) = 0 then
SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc');
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a'); soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc'); SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if Length(zlrom) = 0 then
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
if Length(redrom) = 0 then
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
if Length(bluerom) = 0 then
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
end; end;
@@ -621,16 +427,8 @@ begin
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm')); Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3')); Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/soe')); Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot')); Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl'));
if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
end; end;

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