Compare commits

..

5 Commits

Author SHA1 Message Date
CaitSith2
87152f17f5 Merge branch 'main' into show_all_hints 2022-07-02 06:56:15 -07:00
CaitSith2
c1b099d44e Merge branch 'main' into show_all_hints 2022-06-27 02:19:24 -07:00
CaitSith2
d20ade7ff8 Automatically allow spectator slots to see all hints. 2022-06-22 16:18:36 -07:00
CaitSith2
df90ff4ddb Don't need this line. 2022-06-22 04:53:38 -07:00
CaitSith2
a798e8aea2 Add a means to allow a client to opt into seeing ALL hints. 2022-06-22 04:49:31 -07:00
299 changed files with 7059 additions and 17780 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,9 +17,9 @@ 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.81/sni-v0.0.81-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: |
@@ -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.81/sni-v0.0.81-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: |
@@ -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.81/sni-v0.0.81-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: |

15
.gitignore vendored
View File

@@ -28,7 +28,6 @@ README.html
.vs/ .vs/
EnemizerCLI/ EnemizerCLI/
/Players/ /Players/
/SNI/
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/
@@ -117,9 +116,6 @@ target/
profile_default/ profile_default/
ipython_config.py ipython_config.py
# vim editor
*.swp
# SageMath parsed files # SageMath parsed files
*.sage.py *.sage.py
@@ -156,17 +152,10 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# minecraft server stuff #minecraft server stuff
jdk*/ jdk*/
minecraft*/ minecraft*/
minecraft_versions.json minecraft_versions.json
# pyenv #pyenv
.python-version .python-version
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

View File

@@ -126,6 +126,7 @@ class MultiWorld():
set_player_attr('beemizer_total_chance', 0) set_player_attr('beemizer_total_chance', 0)
set_player_attr('beemizer_trap_chance', 0) set_player_attr('beemizer_trap_chance', 0)
set_player_attr('escape_assist', []) set_player_attr('escape_assist', [])
set_player_attr('open_pyramid', False)
set_player_attr('treasure_hunt_icon', 'Triforce Piece') set_player_attr('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0) set_player_attr('treasure_hunt_count', 0)
set_player_attr('clock_mode', False) set_player_attr('clock_mode', False)
@@ -166,7 +167,7 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {} self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game] world_type = AutoWorld.AutoWorldRegister.world_types[game]
for option_key, option in world_type.option_definitions.items(): for option_key, option in world_type.options.items():
getattr(self, option_key)[new_id] = option(option.default) getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.common_options.items(): for option_key, option in Options.common_options.items():
getattr(self, option_key)[new_id] = option(option.default) getattr(self, option_key)[new_id] = option(option.default)
@@ -204,7 +205,7 @@ class MultiWorld():
for player in self.player_ids: for player in self.player_ids:
self.custom_data[player] = {} self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
for option_key in world_type.option_definitions: for option_key in world_type.options:
setattr(self, option_key, getattr(args, option_key, {})) setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player) self.worlds[player] = world_type(self, player)
@@ -384,17 +385,25 @@ class MultiWorld():
return self.worlds[player].create_item(item_name) return self.worlds[player].create_item(item_name)
def push_precollected(self, item: Item): def push_precollected(self, item: Item):
item.world = self
self.precollected_items[item.player].append(item) self.precollected_items[item.player].append(item)
self.state.collect(item, True) self.state.collect(item, True)
def push_item(self, location: Location, item: Item, collect: bool = True): def push_item(self, location: Location, item: Item, collect: bool = True):
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}." if not isinstance(location, Location):
location.item = item raise RuntimeError(
item.location = location 'Cannot assign item %s to invalid location %s (player %d).' % (item, location, item.player))
if collect:
self.state.collect(item, location.event, location)
logging.debug('Placed %s at %s', item, location) if location.can_fill(self.state, item, False):
location.item = item
item.location = location
item.world = self # try to not have this here anymore
if collect:
self.state.collect(item, location.event, location)
logging.debug('Placed %s at %s', item, location)
else:
raise RuntimeError('Cannot assign item %s to location %s.' % (item, location))
def get_entrances(self) -> List[Entrance]: def get_entrances(self) -> List[Entrance]:
if self._cached_entrances is None: if self._cached_entrances is None:
@@ -1064,25 +1073,26 @@ class LocationProgressType(IntEnum):
class Location: class Location:
game: str = "Generic" # If given as integer, then this is the shop's inventory index
player: int shop_slot: Optional[int] = None
name: str shop_slot_disabled: bool = False
address: Optional[int]
parent_region: Optional[Region]
event: bool = False event: bool = False
locked: bool = False locked: bool = False
game: str = "Generic"
show_in_spoiler: bool = True show_in_spoiler: bool = True
crystal: bool = False
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 = 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
parent_region: Optional[Region]
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None): def __init__(self, player: int, name: str = '', address: int = None, parent=None):
self.player = player self.name: str = name
self.name = name self.address: Optional[int] = address
self.address = address
self.parent_region = parent self.parent_region = parent
self.player: int = player
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) or (self.item_rule(item) and (not check_access or self.can_reach(state))) return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
@@ -1099,6 +1109,7 @@ class Location:
self.item = item self.item = item
item.location = self item.location = self
self.event = item.advancement self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True self.locked = True
def __repr__(self): def __repr__(self):
@@ -1143,28 +1154,39 @@ class ItemClassification(IntFlag):
class Item: class Item:
game: str = "Generic" location: Optional[Location] = None
__slots__ = ("name", "classification", "code", "player", "location") world: Optional[MultiWorld] = None
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
name: str name: str
game: str = "Generic"
type: str = None
classification: ItemClassification classification: ItemClassification
code: Optional[int]
"""an item with code None is called an Event, and does not get written to multidata""" # need to find a decent place for these to live and to allow other games to register texts if they want.
player: int pedestal_credit_text: str = "and the Unknown Item"
location: Optional[Location] sickkid_credit_text: Optional[str] = None
magicshop_credit_text: Optional[str] = None
zora_credit_text: Optional[str] = None
fluteboy_credit_text: Optional[str] = None
# hopefully temporary attributes to satisfy legacy LttP code, proper implementation in subclass ALttPItem
smallkey: bool = False
bigkey: bool = False
map: bool = False
compass: bool = False
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int): def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
self.name = name self.name = name
self.classification = classification self.classification = classification
self.player = player self.player = player
self.code = code self.code = code
self.location = None
@property @property
def hint_text(self) -> str: def hint_text(self):
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " ")) return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
@property @property
def pedestal_hint_text(self) -> str: def pedestal_hint_text(self):
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " ")) return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
@property @property
@@ -1190,7 +1212,7 @@ class Item:
def __eq__(self, other): def __eq__(self, other):
return self.name == other.name and self.player == other.player return self.name == other.name and self.player == other.player
def __lt__(self, other: Item) -> bool: def __lt__(self, other: Item):
if other.player != self.player: if other.player != self.player:
return other.player < self.player return other.player < self.player
return self.name < other.name return self.name < other.name
@@ -1198,13 +1220,11 @@ class Item:
def __hash__(self): def __hash__(self):
return hash((self.name, self.player)) return hash((self.name, self.player))
def __repr__(self) -> str: def __repr__(self):
return self.__str__() return self.__str__()
def __str__(self) -> str: def __str__(self):
if self.location and self.location.parent_region and self.location.parent_region.world: return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
return self.location.parent_region.world.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
class Spoiler(): class Spoiler():
@@ -1388,7 +1408,7 @@ class Spoiler():
outfile.write('Game: %s\n' % self.world.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.world.worlds[player].option_definitions options = self.world.worlds[player].options
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)
@@ -1411,6 +1431,8 @@ class Spoiler():
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player]) outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
if self.world.shuffle[player] != "vanilla": if self.world.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed) outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
outfile.write('Pyramid hole pre-opened: %s\n' % (
'Yes' if self.world.open_pyramid[player] else 'No'))
outfile.write('Shop inventory shuffle: %s\n' % outfile.write('Shop inventory shuffle: %s\n' %
bool_to_text("i" in self.world.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' %

View File

@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
import asyncio import asyncio
import shutil
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -34,24 +32,6 @@ class ChecksFinderContext(CommonContext):
self.send_index: int = 0 self.send_index: int = 0
self.syncing = False self.syncing = False
self.awaiting_bridge = False self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%/ChecksFinder")
else:
# not windows. game is an exe so let's see if wine might be around to run it
if "WINEPREFIX" in os.environ:
wineprefix = os.environ["WINEPREFIX"]
elif shutil.which("wine") or shutil.which("wine-stable"):
wineprefix = os.path.expanduser("~/.wine") # default root of wine system data, deep in which is app data
else:
msg = "ChecksFinderClient couldn't detect system type. Unable to infer required game_communication_path"
logger.error("Error: " + msg)
Utils.messagebox("Error", msg, error=True)
sys.exit(1)
self.game_communication_path = os.path.join(
wineprefix,
"drive_c",
os.path.expandvars("users/$USER/Local Settings/Application Data/ChecksFinder"))
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:
@@ -61,7 +41,8 @@ class ChecksFinderContext(CommonContext):
async def connection_closed(self): async def connection_closed(self):
await super(ChecksFinderContext, self).connection_closed() await super(ChecksFinderContext, self).connection_closed()
for root, dirs, files in os.walk(self.game_communication_path): path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files: for file in files:
if file.find("obtain") <= -1: if file.find("obtain") <= -1:
os.remove(root + "/" + file) os.remove(root + "/" + file)
@@ -75,25 +56,26 @@ class ChecksFinderContext(CommonContext):
async def shutdown(self): async def shutdown(self):
await super(ChecksFinderContext, self).shutdown() await super(ChecksFinderContext, self).shutdown()
for root, dirs, files in os.walk(self.game_communication_path): path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files: for file in files:
if file.find("obtain") <= -1: if file.find("obtain") <= -1:
os.remove(root+"/"+file) os.remove(root+"/"+file)
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}: if cmd in {"Connected"}:
if not os.path.exists(self.game_communication_path): if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
os.makedirs(self.game_communication_path) os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.close() f.close()
if cmd in {"ReceivedItems"}: if cmd in {"ReceivedItems"}:
start_index = args["index"] start_index = args["index"]
if start_index != len(self.items_received): if start_index != len(self.items_received):
for item in args['items']: for item in args['items']:
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item" filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.write(str(NetworkItem(*item).item)) f.write(str(NetworkItem(*item).item))
f.close() f.close()
@@ -101,7 +83,7 @@ class ChecksFinderContext(CommonContext):
if "checked_locations" in args: if "checked_locations" in args:
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.close() f.close()
def run_gui(self): def run_gui(self):
@@ -127,9 +109,10 @@ async def game_watcher(ctx: ChecksFinderContext):
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg) await ctx.send_msgs(sync_msg)
ctx.syncing = False ctx.syncing = False
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
sending = [] sending = []
victory = False victory = False
for root, dirs, files in os.walk(ctx.game_communication_path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if file.find("send") > -1: if file.find("send") > -1:
st = file.split("send", -1)[1] st = file.split("send", -1)[1]

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,8 +17,7 @@ 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
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@@ -112,6 +110,10 @@ class ClientCommandProcessor(CommandProcessor):
self.output("Unreadied.") self.output("Unreadied.")
asyncio.create_task(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 _cmd_show_all_hints(self):
"""Allows the player to see all hints, not just the ones that apply to them."""
asyncio.create_task(self.ctx.update_show_all_hints("ShowAllHints" not in self.ctx.tags))
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:
@@ -154,9 +156,8 @@ class CommonContext:
# 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]
missing_locations: typing.Set[int] # server state missing_locations: typing.Set[int]
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
@@ -187,9 +188,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()
@@ -206,10 +206,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")
@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."""
@@ -353,8 +349,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
@@ -412,6 +406,15 @@ class CommonContext:
} }
}]) }])
async def update_show_all_hints(self, show_all_hints: bool):
old_tags = self.tags.copy()
if show_all_hints:
self.tags.add("ShowAllHints")
else:
self.tags -= {"ShowAllHints"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
async def update_death_link(self, death_link: bool): async def update_death_link(self, death_link: bool):
old_tags = self.tags.copy() old_tags = self.tags.copy()
if death_link: if death_link:
@@ -503,8 +506,7 @@ async def server_loop(ctx: CommonContext, address=None):
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)
if ctx.ui is not None: ctx.ui.update_address_bar(server_url.netloc)
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket) ctx.server = Endpoint(socket)
logger.info('Connected') logger.info('Connected')
ctx.server_address = address ctx.server_address = address
@@ -573,21 +575,18 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
f" for each location checked. Use !hint for more information.") f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost']) ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points']) ctx.check_points = int(args['location_check_points'])
players = args.get("players", [])
if "players" in args: # TODO remove when servers sending this are outdated if len(players) < 1:
players = args.get("players", []) logger.info('No player connected')
if len(players) < 1: else:
logger.info('No player connected') players.sort()
else: current_team = -1
players.sort() logger.info('Connected Players:')
current_team = -1 for network_player in players:
logger.info('Connected Players:') if network_player.team != current_team:
for network_player in players: logger.info(f' Team #{network_player.team + 1}')
if network_player.team != current_team: current_team = network_player.team
logger.info(f' Team #{network_player.team + 1}') logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update datapackage # update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"]) await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
@@ -642,7 +641,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
elif cmd == 'ReceivedItems': elif cmd == 'ReceivedItems':
start_index = args["index"] start_index = args["index"]
@@ -738,7 +736,7 @@ if __name__ == '__main__':
class TextContext(CommonContext): class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"} tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2 game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received items_handling = 0 # don't receive any NetworkItems
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:

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,7 +6,7 @@ from typing import List
import Utils import Utils
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser get_base_parser
SYSTEM_MESSAGE_ID = 0 SYSTEM_MESSAGE_ID = 0
@@ -65,7 +64,7 @@ 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':
@@ -74,28 +73,32 @@ class FF1Context(CommonContext):
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):

View File

@@ -20,7 +20,8 @@ import Utils
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client") Utils.init_logging("FactorioClient", exception_logger="Client")
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, console_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
@@ -399,7 +400,6 @@ if __name__ == '__main__':
"Refer to Factorio --help for those.") "Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args() args, rest = parser.parse_known_args()
colorama.init() colorama.init()
@@ -410,9 +410,6 @@ if __name__ == '__main__':
factorio_server_logger = logging.getLogger("FactorioServer") factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options() options = Utils.get_options()
executable = options["factorio_options"]["executable"] executable = options["factorio_options"]["executable"]
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings:
server_settings = os.path.abspath(server_settings)
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.")
@@ -424,10 +421,7 @@ if __name__ == '__main__':
else: else:
raise FileNotFoundError(f"Path {executable} is not an executable file.") raise FileNotFoundError(f"Path {executable} is not an executable file.")
if server_settings and os.path.isfile(server_settings): server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
else:
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

16
Fill.py
View File

@@ -42,16 +42,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
has_beaten_game = world.has_beaten_game(maximum_exploration_state) has_beaten_game = world.has_beaten_game(maximum_exploration_state)
while items_to_place: for item_to_place in items_to_place:
# if we have run out of locations to fill,break out of this loop
if not locations:
unplaced_items += items_to_place
break
item_to_place = items_to_place.pop(0)
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if world.accessibility[item_to_place.player] == 'minimal': if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state, perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \ item_to_place.player) \
@@ -62,7 +54,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
for i, location in enumerate(locations): for i, location in enumerate(locations):
if (not single_player_placement or location.player == item_to_place.player) \ if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check): and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
# popping by index is faster than removing by content, # poping by index is faster than removing by content,
spot_to_fill = locations.pop(i) spot_to_fill = locations.pop(i)
# skipping a scan for the element # skipping a scan for the element
break break
@@ -220,8 +212,8 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
world.push_item(defaultlocations.pop(i), item_to_place, False) world.push_item(defaultlocations.pop(i), item_to_place, False)
break break
else: else:
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. " logging.warning(
f"Too many non-local items for too few remaining locations.") f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
world.random.shuffle(defaultlocations) world.random.shuffle(defaultlocations)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import argparse import argparse
import logging import logging
import random import random
@@ -7,9 +5,8 @@ import urllib.request
import urllib.parse import urllib.parse
from typing import Set, Dict, Tuple, Callable, Any, Union from typing import Set, Dict, Tuple, Callable, Any, Union
import os import os
from collections import Counter, ChainMap from collections import Counter
import string import string
import enum
import ModuleUpdate import ModuleUpdate
@@ -28,43 +25,7 @@ from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import copy import copy
categories = set(AutoWorldRegister.world_types)
class PlandoSettings(enum.IntFlag):
items = 0b0001
connections = 0b0010
texts = 0b0100
bosses = 0b1000
@classmethod
def from_option_string(cls, option_string: str) -> PlandoSettings:
result = cls(0)
for part in option_string.split(","):
part = part.strip().lower()
if part:
result = cls._handle_part(part, result)
return result
@classmethod
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
result = cls(0)
for part in option_set:
result = cls._handle_part(part, result)
return result
@classmethod
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
try:
part = cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
f"Known options: {', '.join(flag.name for flag in cls)}") from e
else:
return base | part
def __str__(self) -> str:
if self.value:
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
return "Off"
def mystery_argparse(): def mystery_argparse():
@@ -84,6 +45,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"])
@@ -98,7 +64,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando) args.plando: Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args, options return args, options
@@ -128,14 +94,12 @@ def main(args=None, callback=ERmain):
if args.meta_file_path and os.path.exists(args.meta_file_path): if args.meta_file_path and os.path.exists(args.meta_file_path):
try: try:
meta_weights = read_weights_yamls(args.meta_file_path)[-1] weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
except Exception as e: except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path][-1]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file del(meta_weights["meta_description"])
del(meta_weights["meta_description"])
except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
if args.samesettings: if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta") raise Exception("Cannot mix --samesettings with --meta")
else: else:
@@ -161,9 +125,9 @@ def main(args=None, callback=ERmain):
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: "
f"{args.plando}") f"{', '.join(args.plando)}")
if not weights_cache: if not weights_cache:
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. " raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
@@ -178,29 +142,31 @@ 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()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
if meta_weights: if meta_weights:
for category_name, category_dict in meta_weights.items(): for category_name, category_dict in meta_weights.items():
for key in category_dict: for key in category_dict:
option = roll_meta_option(key, category_name, category_dict) option = get_choice(key, category_dict)
if option is not None: if option is not None:
for path in weights_cache: for player, path in player_path_cache.items():
for yaml in weights_cache[path]: for yaml in weights_cache[path]:
if category_name is None: if category_name is None:
for category in yaml: yaml[key] = option
if category in AutoWorldRegister.world_types and key in Options.common_options:
yaml[category][key] = option
elif category_name not in yaml: elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.") logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else: else:
yaml[category_name][key] = option yaml[category_name][key] = option
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter = Counter()
erargs.player_settings = {} erargs.player_settings = {}
@@ -382,28 +348,6 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
return weights return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return options[option_key]
if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict: def roll_linked_options(weights: dict) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]: for option_set in weights["linked_options"]:
@@ -459,7 +403,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str: def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
if boss_shuffle in boss_shuffle_options: if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle] return boss_shuffle_options[boss_shuffle]
elif PlandoSettings.bosses in plando_options: elif "bosses" in plando_options:
options = boss_shuffle.lower().split(";") options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla remainder_shuffle = "none" # vanilla
bosses = [] bosses = []
@@ -508,7 +452,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
setattr(ret, option_key, option(option.default)) setattr(ret, option_key, option(option.default))
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses): def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",))):
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
@@ -521,11 +465,17 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
if tuplize_version(version) > version_tuple: if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, " raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}") f"however generator is of version {__version__}")
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", "")) required_plando_options = requirements.get("plando", "")
if required_plando_options not in plando_options: if required_plando_options:
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
required_plando_options -= plando_options
if required_plando_options: if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, " if len(required_plando_options) == 1:
f"which is not enabled.") raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
f"which is not enabled.")
else:
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
f"which are not enabled.")
ret = argparse.Namespace() ret = argparse.Namespace()
for option_key in Options.per_game_common_options: for option_key in Options.per_game_common_options:
@@ -548,18 +498,18 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
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.options.items():
handle_option(ret, game_weights, option_key, option) 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) handle_option(ret, game_weights, option_key, option)
if PlandoSettings.items in plando_options: if "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":
# bad hardcoded behavior to make this work for now # bad hardcoded behavior to make this work for now
ret.plando_connections = [] ret.plando_connections = []
if PlandoSettings.connections in plando_options: if "connections" in plando_options:
options = game_weights.get("plando_connections", []) options = game_weights.get("plando_connections", [])
for placement in options: for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)): if roll_percentage(get_choice("percentage", placement, 100)):
@@ -605,6 +555,9 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.goal = goals[goal] ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available') extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
@@ -676,7 +629,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}") raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {} ret.plando_texts = {}
if PlandoSettings.texts in plando_options: if "texts" in plando_options:
tt = TextTable() tt = TextTable()
tt.removeUnwantedText() tt.removeUnwantedText()
options = weights.get("plando_texts", []) options = weights.get("plando_texts", [])
@@ -688,7 +641,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.plando_texts[at] = str(get_choice_legacy("text", placement)) ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = [] ret.plando_connections = []
if PlandoSettings.connections in plando_options: if "connections" in plando_options:
options = weights.get("plando_connections", []) options = weights.get("plando_connections", [])
for placement in options: for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)): if roll_percentage(get_choice_legacy("percentage", placement, 100)):

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')), file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio # Factorio
Component('Factorio Client', 'FactorioClient'), Component('Factorio Client', 'FactorioClient'),

View File

@@ -83,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'])
@@ -289,7 +289,7 @@ def run_sprite_update():
else: else:
top.withdraw() top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set()) task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.is_set(): while not done.isSet():
task.do_events() task.do_events()
logging.info("Done updating sprites") logging.info("Done updating sprites")
@@ -300,7 +300,6 @@ def update_sprites(task, on_finish=None):
sprite_dir = user_path("data", "sprites", "alttpr") sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True) os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
def finished(): def finished():
task.close_window() task.close_window()
if on_finish: if on_finish:
@@ -752,7 +751,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):
@@ -834,13 +832,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)
@@ -905,13 +896,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())

10
Main.py
View File

@@ -47,6 +47,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.item_functionality = args.item_functionality.copy() world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy() world.timer = args.timer.copy()
world.goal = args.goal.copy() world.goal = args.goal.copy()
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy() world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy() world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy() world.enemy_damage = args.enemy_damage.copy()
@@ -70,6 +71,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.
@@ -216,6 +218,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Running Item Plando") logger.info("Running Item Plando")
for item in world.itempool:
item.world = world
distribute_planned(world) distribute_planned(world)
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
@@ -359,8 +364,7 @@ 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) == int: if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \ "location.address should then also be None"
f" {location}"
locations_data[location.player][location.address] = \ locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags location.item.code, location.item.player, location.item.flags
if location.name in world.start_location_hints[location.player]: if location.name in world.start_location_hints[location.player]:
@@ -422,7 +426,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip") zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}") logger.info(f'Creating final archive at {zipfilename}.')
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf: compresslevel=9) as zf:
for file in os.scandir(temp_dir): for file in os.scandir(temp_dir):

View File

@@ -30,13 +30,17 @@ except ImportError:
OperationalError = ConnectionError OperationalError = ConnectionError
import NetUtils import NetUtils
from worlds.AutoWorld import AutoWorldRegister
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version from Utils import get_item_name_from_id, get_location_name_from_id, \
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
@@ -122,11 +126,6 @@ class Context:
stored_data: typing.Dict[str, object] stored_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (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]]
forced_auto_forfeits: typing.Dict[str, bool]
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",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
@@ -191,43 +190,8 @@ class Context:
self.stored_data = {} self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet) self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
# init empty to satisfy linter, I suppose
self.gamespackage = {}
self.item_name_groups = {}
self.all_item_and_group_names = {}
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
self.non_hintable_names = {}
self._load_game_data()
self._init_game_data()
# Datapackage retrieval
def _load_game_data(self):
import worlds
self.gamespackage = worlds.network_data_package["games"]
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit
self.non_hintable_names[world_name] = world.hint_blacklist
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
self.all_item_and_group_names[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.Dict[str, int]:
return self.gamespackage[game]["item_name_to_id"]
def location_names_for_game(self, game: str) -> typing.Dict[str, int]:
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:
if not endpoint.socket or not endpoint.socket.open: if not endpoint.socket or not endpoint.socket.open:
return False return False
@@ -292,27 +256,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}]))
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else:
asyncio.create_task(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]))
asyncio.create_task(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else:
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading # loading
@@ -587,13 +544,12 @@ class Context:
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \ finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.' f' has completed their goal.'
self.notify_all(finished_msg) self.notify_all(finished_msg)
if "auto" in self.collect_mode:
collect_player(self, client.team, client.slot)
if "auto" in self.forfeit_mode: if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot) forfeit_player(self, client.team, client.slot)
elif self.forced_auto_forfeits[self.games[client.slot]]: elif proxy_worlds[self.games[client.slot]].forced_auto_forfeit:
forfeit_player(self, client.team, client.slot) forfeit_player(self, client.team, client.slot)
self.save() # save goal completion flag if "auto" in self.collect_mode:
collect_player(self, client.team, client.slot)
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):
@@ -603,12 +559,19 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
if not hints: if not hints:
return return
concerns = collections.defaultdict(list) concerns = collections.defaultdict(list)
all_hints = collections.defaultdict(list)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True): for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message()) data = (hint, hint.as_network_message())
for player in ctx.slot_set(hint.receiving_player): for player in ctx.slot_set(hint.receiving_player):
concerns[player].append(data) concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]: if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data) concerns[hint.finding_player].append(data)
for slot, clients in ctx.clients[team].items():
if not clients or slot == hint.finding_player or slot in ctx.slot_set(hint.receiving_player) or \
(ctx.games[slot] != "Archipelago" and all(["ShowAllHints" not in client.tags for client in clients])):
continue
for client in [client for client in clients if "ShowAllHints" in client.tags or ctx.games[slot] == "Archipelago"]:
all_hints[client].append(data)
# remember hints in all cases # remember hints in all cases
if not hint.found: if not hint.found:
# since hints are bidirectional, finding player and receiving player, # since hints are bidirectional, finding player and receiving player,
@@ -628,6 +591,10 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
for client in clients: for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints)) asyncio.create_task(ctx.send_msgs(client, client_hints))
for client, hint_data in all_hints.items():
client_hints = [datum[1] for datum in sorted(hint_data)]
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int): def update_aliases(ctx: Context, team: int):
cmd = ctx.dumper([{"cmd": "RoomUpdate", cmd = ctx.dumper([{"cmd": "RoomUpdate",
@@ -686,10 +653,9 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx), 'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost, 'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points, 'location_check_points': ctx.location_check_points,
'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values()) 'datapackage_version': network_data_package["version"],
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
'datapackage_versions': {game: game_data["version"] for game, game_data 'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items()}, in network_data_package["games"].items()},
'seed_name': ctx.seed_name, 'seed_name': ctx.seed_name,
'time': time.time(), 'time': time.time(),
}]) }])
@@ -730,37 +696,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}
@@ -782,16 +731,16 @@ def get_players_string(ctx: Context):
return f'{len(auth_clients)} players of {total} connected ' + text[:-1] return f'{len(auth_clients)} players of {total} connected ' + text[:-1]
def get_status_string(ctx: Context, team: int, tag: str): def get_status_string(ctx: Context, team: int):
text = f"Player Status on team {team}:" text = "Player Status on your team:"
for slot in ctx.locations: for slot in ctx.locations:
connected = len(ctx.clients[team][slot]) connected = len(ctx.clients[team][slot])
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags]) death_link = len([client for client in ctx.clients[team][slot] if "DeathLink" in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})" completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else "" death_text = f" {death_link} of which are death link" if connected else ""
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "." goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \ text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{tag_text}{goal_text} {completion_text}" f"{death_text}{goal_text} {completion_text}"
return text return text
@@ -884,8 +833,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
send_items_to(ctx, team, target_player, new_item) send_items_to(ctx, team, target_player, new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % ( logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id], team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], ctx.location_names[location])) ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player) info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text]) ctx.broadcast_team(team, [info_text])
@@ -900,14 +849,13 @@ 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_name: str) -> typing.List[NetUtils.Hint]: def collect_hints(ctx: Context, team: int, slot: int, item: 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 = proxy_worlds[ctx.games[slot]].item_name_to_id[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:
@@ -920,7 +868,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]: def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
return collect_hint_location_id(ctx, team, slot, seeked_location) return collect_hint_location_id(ctx, team, slot, seeked_location)
@@ -937,8 +885,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[hint.item]} is " \ f"{lookup_any_item_id_to_name[hint.item]} is " \
f"at {ctx.location_names[hint.location]} " \ f"at {get_location_name_from_id(hint.location)} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World" f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance: if hint.entrance:
@@ -1176,11 +1124,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(get_players_string(self.ctx)) self.output(get_players_string(self.ctx))
return True return True
def _cmd_status(self, tag:str="") -> bool: def _cmd_status(self) -> bool:
"""Get status information about your team. """Get status information about your team."""
Optionally mention a Tag name and get information on who has that Tag. self.output(get_status_string(self.ctx, self.client.team))
For example: DeathLink or EnergyLink."""
self.output(get_status_string(self.ctx, self.client.team, tag))
return True return True
def _cmd_release(self) -> bool: def _cmd_release(self) -> bool:
@@ -1196,8 +1142,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
forfeit_player(self.ctx, self.client.team, self.client.slot) forfeit_player(self.ctx, self.client.team, self.client.slot)
return True return True
elif "disabled" in self.ctx.forfeit_mode: elif "disabled" in self.ctx.forfeit_mode:
self.output("Sorry, client item releasing has been disabled on this server. " self.output(
"You can ask the server admin for a /release") "Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release")
return False return False
else: # is auto or goal else: # is auto or goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
@@ -1233,7 +1179,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id] self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1246,7 +1192,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id] self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1262,7 +1208,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations] texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks") texts.append(f"Found {len(locations)} missing location checks")
self.ctx.notify_client_multiple(self.client, texts) self.ctx.notify_client_multiple(self.client, texts)
else: else:
@@ -1275,7 +1221,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations] texts = [f'Checked: {get_location_name_from_id(location)}' for location in locations]
texts.append(f"Found {len(locations)} done location checks") texts.append(f"Found {len(locations)} done location checks")
self.ctx.notify_client_multiple(self.client, texts) self.ctx.notify_client_multiple(self.client, texts)
else: else:
@@ -1304,13 +1250,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_getitem(self, item_name: str) -> bool: def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item, if it is enabled on this server""" """Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat: if self.ctx.item_cheat:
names = self.ctx.item_names_for_game(self.ctx.games[self.client.slot]) world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text( item_name, usable, response = get_intended_text(item_name,
item_name, world.item_names)
names
)
if usable: if usable:
new_item = NetworkItem(names[item_name], -1, self.client.slot) new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item) get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item)
get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item) get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item)
self.ctx.notify_all( self.ctx.notify_all(
@@ -1336,22 +1280,20 @@ class ClientMessageProcessor(CommonCommandProcessor):
f"You have {points_available} points.") f"You have {points_available} points.")
return True return True
else: else:
game = self.ctx.games[self.client.slot] world = proxy_worlds[self.ctx.games[self.client.slot]]
names = self.ctx.location_names_for_game(game) \ names = world.location_names if for_location else world.all_item_and_group_names
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, hint_name, usable, response = get_intended_text(input_text,
names) names)
if usable: if usable:
if hint_name in self.ctx.non_hintable_names[game]: if hint_name in world.hint_blacklist:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = [] hints = []
elif not for_location and hint_name in self.ctx.item_name_groups[game]: # item group name elif not for_location and hint_name in world.item_name_groups: # item group name
hints = [] hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]: for item in world.item_name_groups[hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID if item in world.item_name_to_id: # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name elif not for_location and hint_name in world.item_names: # item name
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)
@@ -1375,8 +1317,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
can_pay = 1000 can_pay = 1000
self.ctx.random.shuffle(not_found_hints) 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 hints = found_hints
while can_pay > 0: while can_pay > 0:
@@ -1413,12 +1353,12 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False return False
@mark_raw @mark_raw
def _cmd_hint(self, item_name: str = "") -> bool: def _cmd_hint(self, item: str = "") -> bool:
"""Use !hint {item_name}, """Use !hint {item_name},
for example !hint Lamp to get a spoiler peek for that item. for example !hint Lamp to get a spoiler peek for that item.
If hint costs are on, this will only give you one new result, If hint costs are on, this will only give you one new result,
you can rerun the command to get more in that case.""" you can rerun the command to get more in that case."""
return self.get_hints(item_name) return self.get_hints(item)
@mark_raw @mark_raw
def _cmd_hint_location(self, location: str = "") -> bool: def _cmd_hint_location(self, location: str = "") -> bool:
@@ -1544,23 +1484,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == "GetDataPackage": elif cmd == "GetDataPackage":
exclusions = args.get("exclusions", []) exclusions = args.get("exclusions", [])
if "games" in args: if "games" in args:
games = {name: game_data for name, game_data in ctx.gamespackage.items() games = {name: game_data for name, game_data in network_data_package["games"].items()
if name in set(args.get("games", []))} if name in set(args.get("games", []))}
await ctx.send_msgs(client, [{"cmd": "DataPackage", await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": games}}]) "data": {"games": games}}])
# TODO: remove exclusions behaviour around 0.5.0 # TODO: remove exclusions behaviour around 0.5.0
elif exclusions: elif exclusions:
exclusions = set(exclusions) exclusions = set(exclusions)
games = {name: game_data for name, game_data in ctx.gamespackage.items() games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions} if name not in exclusions}
package = network_data_package.copy()
package = {"games": games} package["games"] = games
await ctx.send_msgs(client, [{"cmd": "DataPackage", await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}]) "data": package}])
else: else:
await ctx.send_msgs(client, [{"cmd": "DataPackage", await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": ctx.gamespackage}}]) "data": network_data_package}])
elif client.auth: elif client.auth:
if cmd == "ConnectUpdate": if cmd == "ConnectUpdate":
@@ -1616,7 +1556,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
create_as_hint: int = int(args.get("create_as_hint", 0)) create_as_hint: int = int(args.get("create_as_hint", 0))
hints = [] hints = []
for location in args["locations"]: for location in args["locations"]:
if type(location) is not int: if type(location) is not int or location not in lookup_any_location_id_to_name:
await ctx.send_msgs(client, await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts', [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
"original_cmd": cmd}]) "original_cmd": cmd}])
@@ -1728,14 +1668,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(get_players_string(self.ctx)) self.output(get_players_string(self.ctx))
return True return True
def _cmd_status(self, tag: str = "") -> bool:
"""Get status information about teams.
Optionally mention a Tag name and get information on who has that Tag.
For example: DeathLink or EnergyLink."""
for team in self.ctx.clients:
self.output(get_status_string(self.ctx, team, tag))
return True
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
asyncio.create_task(self.ctx.server.ws_server._close()) asyncio.create_task(self.ctx.server.ws_server._close())
@@ -1830,18 +1762,18 @@ 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) item = " ".join(item_name)
names = self.ctx.item_names_for_game(self.ctx.games[slot]) world = proxy_worlds[self.ctx.games[slot]]
item_name, usable, response = get_intended_text(item_name, names) item, usable, response = get_intended_text(item, world.item_names)
if usable: if usable:
amount: int = int(amount) amount: int = int(amount)
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))] new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items) send_items_to(self.ctx, team, slot, *new_items)
send_new_items(self.ctx) send_new_items(self.ctx)
self.ctx.notify_all( self.ctx.notify_all(
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') + 'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}') f'"{item}" to {self.ctx.get_aliased_name(team, slot)}')
return True return True
else: else:
self.output(response) self.output(response)
@@ -1854,22 +1786,22 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Sends an item to the specified player""" """Sends an item to the specified player"""
return self._cmd_send_multiple(1, player_name, *item_name) return self._cmd_send_multiple(1, player_name, *item_name)
def _cmd_hint(self, player_name: str, *item_name: str) -> bool: def _cmd_hint(self, player_name: str, *item: str) -> bool:
"""Send out a hint for a player's item to their team""" """Send out a hint for a player's item to their team"""
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) item = " ".join(item)
game = self.ctx.games[slot] world = proxy_worlds[self.ctx.games[slot]]
item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game]) item, usable, response = get_intended_text(item, world.all_item_and_group_names)
if usable: if usable:
if item_name in self.ctx.item_name_groups[game]: if item in world.item_name_groups:
hints = [] hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item_name]: for item in world.item_name_groups[item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID if item in world.item_name_to_id: # 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))
else: # item name else: # item name
hints = collect_hints(self.ctx, team, slot, item_name) hints = collect_hints(self.ctx, team, slot, item)
if hints: if hints:
notify_hints(self.ctx, team, hints) notify_hints(self.ctx, team, hints)
@@ -1885,16 +1817,16 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response) self.output(response)
return False return False
def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool: def _cmd_hint_location(self, player_name: str, *location: str) -> bool:
"""Send out a hint for a player's location to their team""" """Send out a hint for a player's location to their team"""
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]
location_name = " ".join(location_name) item = " ".join(location)
location_name, usable, response = get_intended_text(location_name, world = proxy_worlds[self.ctx.games[slot]]
self.ctx.location_names_for_game(self.ctx.games[slot])) item, usable, response = get_intended_text(item, world.location_names)
if usable: if usable:
hints = collect_hint_location_name(self.ctx, team, slot, location_name) hints = collect_hint_location_name(self.ctx, team, slot, item)
if hints: if hints:
notify_hints(self.ctx, team, hints) notify_hints(self.ctx, team, hints)
else: else:

View File

@@ -270,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47} 'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
def color_code(*args): def color_code(*args):

View File

@@ -48,7 +48,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"] oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
script_version: int = 2 script_version: int = 1
def get_item_value(ap_id): def get_item_value(ap_id):
return ap_id - 66000 return ap_id - 66000
@@ -186,7 +186,7 @@ async def n64_sync_task(ctx: OoTContext):
data = await asyncio.wait_for(reader.readline(), timeout=10) data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode()) data_decoded = json.loads(data.decode())
reported_version = data_decoded.get('scriptVersion', 0) reported_version = data_decoded.get('scriptVersion', 0)
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
asyncio.create_task(parse_payload(data_decoded, ctx, False)) asyncio.create_task(parse_payload(data_decoded, ctx, False))

View File

@@ -298,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):

View File

@@ -17,7 +17,7 @@ ModuleUpdate.update()
import Utils import Utils
current_patch_version = 5 current_patch_version = 4
class AutoPatchRegister(type): class AutoPatchRegister(type):
@@ -128,7 +128,6 @@ class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
manifest = super(APDeltaPatch, self).get_manifest() manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
return manifest return manifest
@classmethod @classmethod
@@ -167,15 +166,13 @@ GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid" GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore" GAME_SOE = "Secret of Evermore"
GAME_SMZ3 = "SMZ3" GAME_SMZ3 = "SMZ3"
GAME_DKC3 = "Donkey Kong Country 3" supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
preferred_endings = { preferred_endings = {
GAME_ALTTP: "apbp", GAME_ALTTP: "apbp",
GAME_SM: "apm3", GAME_SM: "apm3",
GAME_SOE: "apsoe", GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz", GAME_SMZ3: "apsmz"
GAME_DKC3: "apdkc3"
} }
@@ -190,8 +187,6 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAM
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH HASH = ALTTPHASH + SMHASH
elif game == GAME_DKC3:
from worlds.dkc3.Rom import USHASH as HASH
else: else:
raise RuntimeError(f"Selected game {game} for base rom not found.") raise RuntimeError(f"Selected game {game} for base rom not found.")
@@ -221,10 +216,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
meta, meta,
game) game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ( target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP ".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
else ".apsmz" if game == GAME_SMZ3
else ".apdkc3" if game == GAME_DKC3
else ".apm3")
write_lzma(bytes, target) write_lzma(bytes, target)
return target return target
@@ -253,8 +245,6 @@ def get_base_rom_data(game: str):
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb"))) get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3: elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes from worlds.smz3.Rom import get_base_rom_bytes
elif game == GAME_DKC3:
from worlds.dkc3.Rom import get_base_rom_bytes
else: else:
raise RuntimeError("Selected game for base rom not found.") raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes() return get_base_rom_bytes()
@@ -399,13 +389,6 @@ if __name__ == "__main__":
if 'server' in data: if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server']) Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {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"): elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}") print(f"Updating host in patch files contained in {rom}")
@@ -413,9 +396,7 @@ if __name__ == "__main__":
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str): def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo) data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp") or \ if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
zfinfo.filename.endswith(".apm3") or \
zfinfo.filename.endswith(".apdkc3"):
data = update_patch_data(data, server) data = update_patch_data(data, server)
with ziplock: with ziplock:
zfw.writestr(zfinfo, data) zfw.writestr(zfinfo, data)

View File

@@ -26,8 +26,6 @@ Currently, the following games are supported:
* The Witness * The Witness
* Sonic Adventure 2: Battle * Sonic Adventure 2: Battle
* Starcraft 2: Wings of Liberty * Starcraft 2: Wings of Liberty
* Donkey Kong Country 3
* Dark Souls 3
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
@@ -51,7 +49,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
## Running Archipelago ## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only. For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md). If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
## Related Repositories ## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present. This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
@@ -61,10 +59,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 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.

View File

@@ -15,6 +15,9 @@ import typing
from json import loads, dumps from json import loads, dumps
import ModuleUpdate
ModuleUpdate.update()
from Utils import init_logging, messagebox from Utils import init_logging, messagebox
if __name__ == "__main__": if __name__ == "__main__":
@@ -30,7 +33,7 @@ from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
import Utils import Utils
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3 from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
snes_logger = logging.getLogger("SNES") snes_logger = logging.getLogger("SNES")
@@ -59,7 +62,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
def _cmd_snes(self, snes_options: str = "") -> bool: def _cmd_snes(self, snes_options: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, """Connect to a snes. Optionally include network address of a snes to connect to,
otherwise show available devices; and a SNES device number if more than one SNES is detected. otherwise show available devices; and a SNES device number if more than one SNES is detected.
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """ Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
snes_address = self.ctx.snes_address snes_address = self.ctx.snes_address
snes_device_number = -1 snes_device_number = -1
@@ -146,8 +149,8 @@ class Context(CommonContext):
def event_invalid_slot(self): def event_invalid_slot(self):
if self.snes_socket is not None and not self.snes_socket.closed: if self.snes_socket is not None and not self.snes_socket.closed:
asyncio.create_task(self.snes_socket.close()) asyncio.create_task(self.snes_socket.close())
raise Exception("Invalid ROM detected, " raise Exception('Invalid ROM detected, '
"please verify that you have loaded the correct rom and reconnect your snes (/snes)") 'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
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:
@@ -155,7 +158,7 @@ class Context(CommonContext):
if self.rom is None: if self.rom is None:
self.awaiting_rom = True self.awaiting_rom = True
snes_logger.info( snes_logger.info(
"No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)") 'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
return return
self.awaiting_rom = False self.awaiting_rom = False
self.auth = self.rom self.auth = self.rom
@@ -185,10 +188,7 @@ class Context(CommonContext):
async def shutdown(self): async def shutdown(self):
await super(Context, self).shutdown() await super(Context, self).shutdown()
if self.snes_connect_task: if self.snes_connect_task:
try: await self.snes_connect_task
await asyncio.wait_for(self.snes_connect_task, 1)
except asyncio.TimeoutError:
self.snes_connect_task.cancel()
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}: if cmd in {"Connected", "RoomUpdate"}:
@@ -251,15 +251,12 @@ async def deathlink_kill_player(ctx: Context):
if not gamemode or gamemode[0] in SM_DEATH_MODES or ( if not gamemode or gamemode[0] in SM_DEATH_MODES or (
ctx.death_link_allow_survive and health is not None and health > 0): ctx.death_link_allow_survive and health is not None and health > 0):
ctx.death_state = DeathState.dead ctx.death_state = DeathState.dead
elif ctx.game == GAME_DKC3:
from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player
await dkc3_deathlink_kill_player(ctx)
ctx.last_death_link = time.time() ctx.last_death_link = time.time()
SNES_RECONNECT_DELAY = 5 SNES_RECONNECT_DELAY = 5
# FXPAK Pro protocol memory mapping used by SNI # LttP
ROM_START = 0x000000 ROM_START = 0x000000
WRAM_START = 0xF50000 WRAM_START = 0xF50000
WRAM_SIZE = 0x20000 WRAM_SIZE = 0x20000
@@ -290,24 +287,21 @@ SHOP_LEN = (len(Shops.shop_table) * 3) + 5
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
# SM # SM
SM_ROMNAME_START = ROM_START + 0x007FC0 SM_ROMNAME_START = 0x007FC0
SM_INGAME_MODES = {0x07, 0x09, 0x0b} SM_INGAME_MODES = {0x07, 0x09, 0x0b}
SM_ENDGAME_MODES = {0x26, 0x27} SM_ENDGAME_MODES = {0x26, 0x27}
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
SM_RECV_QUEUE_START = SRAM_START + 0x2000 SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
SM_SEND_QUEUE_START = SRAM_START + 0x2700
SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680
SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
# SMZ3 # SMZ3
SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 SMZ3_ROMNAME_START = 0x00FFC0
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b} SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
SMZ3_ENDGAME_MODES = {0x26, 0x27} SMZ3_ENDGAME_MODES = {0x26, 0x27}
@@ -601,7 +595,7 @@ class SNESState(enum.IntEnum):
SNES_ATTACHED = 3 SNES_ATTACHED = 3
def launch_sni(): def launch_sni(ctx: Context):
sni_path = Utils.get_options()["lttp_options"]["sni"] sni_path = Utils.get_options()["lttp_options"]["sni"]
if not os.path.isdir(sni_path): if not os.path.isdir(sni_path):
@@ -639,9 +633,11 @@ async def _snes_connect(ctx: Context, address: str):
address = f"ws://{address}" if "://" not in address else address address = f"ws://{address}" if "://" not in address else address
snes_logger.info("Connecting to SNI at %s ..." % address) snes_logger.info("Connecting to SNI at %s ..." % address)
seen_problems = set() seen_problems = set()
while 1: succesful = False
while not succesful:
try: try:
snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None) snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None)
succesful = True
except Exception as e: except Exception as e:
problem = "%s" % e problem = "%s" % e
# only tell the user about new problems, otherwise silently lay in wait for a working connection # only tell the user about new problems, otherwise silently lay in wait for a working connection
@@ -651,7 +647,7 @@ async def _snes_connect(ctx: Context, address: str):
if len(seen_problems) == 1: if len(seen_problems) == 1:
# this is the first problem. Let's try launching SNI if it isn't already running # this is the first problem. Let's try launching SNI if it isn't already running
launch_sni() launch_sni(ctx)
await asyncio.sleep(1) await asyncio.sleep(1)
else: else:
@@ -1038,54 +1034,47 @@ async def game_watcher(ctx: Context):
if not ctx.rom: if not ctx.rom:
ctx.finished_game = False ctx.finished_game = False
ctx.death_link_allow_survive = False ctx.death_link_allow_survive = False
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
from worlds.dkc3.Client import dkc3_rom_init if game_name is None:
init_handled = await dkc3_rom_init(ctx) continue
if not init_handled: elif game_name[:2] == b"SM":
game_name = await snes_read(ctx, SM_ROMNAME_START, 5) ctx.game = GAME_SM
if game_name is None: # versions lower than 0.3.0 dont have item handling flag nor remote item support
continue romVersion = int(game_name[2:5].decode('UTF-8'))
elif game_name[:2] == b"SM": if romVersion < 30:
ctx.game = GAME_SM ctx.items_handling = 0b001 # full local
# versions lower than 0.3.0 dont have item handling flag nor remote item support
romVersion = int(game_name[2:5].decode('UTF-8'))
if romVersion < 30:
ctx.items_handling = 0b001 # full local
else:
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
else: else:
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3) item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
if game_name == b"ZSM": ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
ctx.game = GAME_SMZ3 else:
ctx.items_handling = 0b101 # local items and remote start inventory game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
else: if game_name == b"ZSM":
ctx.game = GAME_ALTTP ctx.game = GAME_SMZ3
ctx.items_handling = 0b001 # full local ctx.items_handling = 0b101 # local items and remote start inventory
else:
ctx.game = GAME_ALTTP
ctx.items_handling = 0b001 # full local
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE) rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
if rom is None or rom == bytes([0] * ROMNAME_SIZE): if rom is None or rom == bytes([0] * ROMNAME_SIZE):
continue continue
ctx.rom = rom ctx.rom = rom
if ctx.game != GAME_SMZ3: if ctx.game != GAME_SMZ3:
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1) SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link: if death_link:
ctx.allow_collect = bool(death_link[0] & 0b100) ctx.allow_collect = bool(death_link[0] & 0b100)
ctx.death_link_allow_survive = bool(death_link[0] & 0b10) ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1)) await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom: if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set() ctx.locations_checked = set()
ctx.locations_scouted = set() ctx.locations_scouted = set()
ctx.locations_info = {} ctx.locations_info = {}
ctx.prev_rom = ctx.rom ctx.prev_rom = ctx.rom
if ctx.awaiting_rom: if ctx.awaiting_rom:
await ctx.server_auth(False) await ctx.server_auth(False)
elif ctx.server is None:
snes_logger.warning("ROM detected but no active multiworld server connection. " +
"Connect using command: /connect server:port")
if ctx.auth and ctx.auth != ctx.rom: if ctx.auth and ctx.auth != ctx.rom:
snes_logger.warning("ROM change detected, please reconnect to the multiworld server") snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -1162,9 +1151,6 @@ async def game_watcher(ctx: Context):
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
await track_locations(ctx, roomid, roomdata) await track_locations(ctx, roomid, roomdata)
elif ctx.game == GAME_SM: elif ctx.game == GAME_SM:
if ctx.server is None or ctx.slot is None:
# not successfully connected to a multiworld server, cannot process the game sending items
continue
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
currently_dead = gamemode[0] in SM_DEATH_MODES currently_dead = gamemode[0] in SM_DEATH_MODES
@@ -1175,25 +1161,25 @@ async def game_watcher(ctx: Context):
ctx.finished_game = True ctx.finished_game = True
continue continue
data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4)
if data is None: if data is None:
continue continue
recv_index = data[0] | (data[1] << 8) recv_index = data[0] | (data[1] << 8)
recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT recv_item = data[2] | (data[3] << 8)
while (recv_index < recv_item): while (recv_index < recv_item):
itemAdress = recv_index * 8 itemAdress = recv_index * 8
message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8) message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
# worldId = message[0] | (message[1] << 8) # unused # worldId = message[0] | (message[1] << 8) # unused
# itemId = message[2] | (message[3] << 8) # unused # itemId = message[2] | (message[3] << 8) # unused
itemIndex = (message[4] | (message[5] << 8)) >> 3 itemIndex = (message[4] | (message[5] << 8)) >> 3
recv_index += 1 recv_index += 1
snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.sm import locations_start_id from worlds.sm.Locations import locations_start_id
location_id = locations_start_id + itemIndex location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id) ctx.locations_checked.add(location_id)
@@ -1202,14 +1188,15 @@ async def game_watcher(ctx: Context):
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
if data is None: if data is None:
continue continue
itemOutPtr = data[0] | (data[1] << 8) # recv_itemOutPtr = data[0] | (data[1] << 8) # unused
itemOutPtr = data[2] | (data[3] << 8)
from worlds.sm import items_start_id from worlds.sm.Items import items_start_id
from worlds.sm import locations_start_id from worlds.sm.Locations import locations_start_id
if itemOutPtr < len(ctx.items_received): if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr] item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id itemId = item.item - items_start_id
@@ -1219,10 +1206,10 @@ async def game_watcher(ctx: Context):
locationId = 0x00 #backward compat locationId = 0x00 #backward compat
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes( snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF])) [playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
itemOutPtr += 1 itemOutPtr += 1
snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % ( logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.item_names[item.item], 'red', 'bold'),
@@ -1230,9 +1217,6 @@ async def game_watcher(ctx: Context):
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx) await snes_flush_writes(ctx)
elif ctx.game == GAME_SMZ3: elif ctx.game == GAME_SMZ3:
if ctx.server is None or ctx.slot is None:
# not successfully connected to a multiworld server, cannot process the game sending items
continue
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
if (currentGame is not None): if (currentGame is not None):
if (currentGame[0] != 0): if (currentGame[0] != 0):
@@ -1268,8 +1252,7 @@ async def game_watcher(ctx: Context):
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.smz3.TotalSMZ3.Location import locations_start_id from worlds.smz3.TotalSMZ3.Location import locations_start_id
from worlds.smz3 import convertLocSMZ3IDToAPID location_id = locations_start_id + itemIndex
location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex)
ctx.locations_checked.add(location_id) ctx.locations_checked.add(location_id)
location = ctx.location_names[location_id] location = ctx.location_names[location_id]
@@ -1296,9 +1279,6 @@ async def game_watcher(ctx: Context):
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx) await snes_flush_writes(ctx)
elif ctx.game == GAME_DKC3:
from worlds.dkc3.Client import dkc3_game_watcher
await dkc3_game_watcher(ctx)
async def run_game(romfile): async def run_game(romfile):
@@ -1316,7 +1296,7 @@ async def main():
parser = get_base_parser() parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?", parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file') help='Path to a Archipelago Binary Patch file')
parser.add_argument('--snes', default='localhost:23074', help='Address of the SNI server.') parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical']) parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
args = parser.parse_args() args = parser.parse_args()

View File

@@ -1,32 +1,25 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import copy
import ctypes
import logging
import multiprocessing import multiprocessing
import logging
import asyncio
import os.path import os.path
import re
import sys
import typing
import queue
from pathlib import Path
import nest_asyncio import nest_asyncio
import sc2 import sc2
from sc2.bot_ai import BotAI
from sc2.data import Race
from sc2.main import run_game from sc2.main import run_game
from sc2.data import Race
from sc2.bot_ai import BotAI
from sc2.player import Bot from sc2.player import Bot
import NetUtils
from MultiServer import mark_raw
from Utils import init_logging, is_windows
from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Regions import MissionInfo from worlds.sc2wol.Regions import MissionInfo
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Items import lookup_id_to_name, item_table
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol import SC2WoLWorld
from Utils import init_logging
if __name__ == "__main__": if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client") init_logging("SC2Client", exception_logger="Client")
@@ -36,49 +29,20 @@ sc2_logger = logging.getLogger("Starcraft2")
import colorama import colorama
from NetUtils import ClientStatus, RawJSONtoTextParser from NetUtils import *
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply() nest_asyncio.apply()
max_bonus: int = 8
victory_modulo: int = 100
class StarcraftClientProcessor(ClientCommandProcessor): class StarcraftClientProcessor(ClientCommandProcessor):
ctx: SC2Context ctx: SC2Context
def _cmd_difficulty(self, difficulty: str = "") -> bool:
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
options = difficulty.split()
num_options = len(options)
difficulty_choice = options[0].lower()
if num_options > 0:
if difficulty_choice == "casual":
self.ctx.difficulty_override = 0
elif difficulty_choice == "normal":
self.ctx.difficulty_override = 1
elif difficulty_choice == "hard":
self.ctx.difficulty_override = 2
elif difficulty_choice == "brutal":
self.ctx.difficulty_override = 3
else:
self.output("Unable to parse difficulty '" + options[0] + "'")
return False
self.output("Difficulty set to " + options[0])
return True
else:
self.output("Difficulty needs to be specified in the command.")
return False
def _cmd_disable_mission_check(self) -> bool: def _cmd_disable_mission_check(self) -> bool:
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
the next mission in a chain the other player is doing.""" the next mission in a chain the other player is doing."""
self.ctx.missions_unlocked = True self.ctx.missions_unlocked = True
sc2_logger.info("Mission check has been disabled") sc2_logger.info("Mission check has been disabled")
return True
def _cmd_play(self, mission_id: str = "") -> bool: def _cmd_play(self, mission_id: str = "") -> bool:
"""Start a Starcraft 2 mission""" """Start a Starcraft 2 mission"""
@@ -94,33 +58,21 @@ class StarcraftClientProcessor(ClientCommandProcessor):
else: else:
sc2_logger.info( sc2_logger.info(
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.") "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
return False
return True return True
def _cmd_available(self) -> bool: def _cmd_available(self) -> bool:
"""Get what missions are currently available to play""" """Get what missions are currently available to play"""
request_available_missions(self.ctx) request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
return True return True
def _cmd_unfinished(self) -> bool: def _cmd_unfinished(self) -> bool:
"""Get what missions are currently available to play and have not had all locations checked""" """Get what missions are currently available to play and have not had all locations checked"""
request_unfinished_missions(self.ctx) request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
return True return True
@mark_raw
def _cmd_set_path(self, path: str = '') -> bool:
"""Manually set the SC2 install directory (if the automatic detection fails)."""
if path:
os.environ["SC2PATH"] = path
check_mod_install()
return True
else:
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
return False
class SC2Context(CommonContext): class SC2Context(CommonContext):
command_processor = StarcraftClientProcessor command_processor = StarcraftClientProcessor
@@ -128,19 +80,17 @@ class SC2Context(CommonContext):
items_handling = 0b111 items_handling = 0b111
difficulty = -1 difficulty = -1
all_in_choice = 0 all_in_choice = 0
mission_req_table: typing.Dict[str, MissionInfo] = {} mission_req_table = None
announcements = queue.Queue() items_rec_to_announce = []
rec_announce_pos = 0
items_sent_to_announce = []
sent_announce_pos = 0
announcements = []
announcement_pos = 0
sc2_run_task: typing.Optional[asyncio.Task] = None sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked: bool = False # allow launching missions ignoring requirements missions_unlocked = False
current_tooltip = None current_tooltip = None
last_loc_list = None last_loc_list = None
difficulty_override = -1
mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
last_bot: typing.Optional[ArchipelagoBot] = None
def __init__(self, *args, **kwargs):
super(SC2Context, self).__init__(*args, **kwargs)
self.raw_text_parser = RawJSONtoTextParser(self)
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:
@@ -153,35 +103,25 @@ class SC2Context(CommonContext):
self.difficulty = args["slot_data"]["game_difficulty"] self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"] self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"] slot_req_table = args["slot_data"]["mission_req"]
self.mission_req_table = { self.mission_req_table = {}
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table # Compatibility for 0.3.2 server data.
} if "category" not in next(iter(slot_req_table)):
for i, mission_data in enumerate(slot_req_table.values()):
mission_data["category"] = wol_default_categories[i]
for mission in slot_req_table:
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
self.build_location_to_mission_mapping() if cmd in {"PrintJSON"}:
if "receiving" in args:
# Look for and set SC2PATH. if self.slot_concerns_self(args["receiving"]):
# check_game_install_path() returns True if and only if it finds + sets SC2PATH. self.announcements.append(args["data"])
if "SC2PATH" not in os.environ and check_game_install_path(): return
check_mod_install() if "item" in args:
if self.slot_concerns_self(args["item"].player):
def on_print_json(self, args: dict): self.announcements.append(args["data"])
# goes to this world
if "receiving" in args and self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif "item" in args and self.slot_concerns_self(args["item"].player):
relevant = True
# not related
else:
relevant = False
if relevant:
self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"])))
super(SC2Context, self).on_print_json(args)
def run_gui(self): def run_gui(self):
from kvui import GameManager, HoverBehavior, ServerToolTip from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
from kivy.app import App from kivy.app import App
from kivy.clock import Clock from kivy.clock import Clock
from kivy.uix.tabbedpanel import TabbedPanelItem from kivy.uix.tabbedpanel import TabbedPanelItem
@@ -199,7 +139,6 @@ class SC2Context(CommonContext):
class MissionButton(HoverableButton): class MissionButton(HoverableButton):
tooltip_text = StringProperty("Test") tooltip_text = StringProperty("Test")
ctx: SC2Context
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs) super(HoverableButton, self).__init__(*args, **kwargs)
@@ -220,7 +159,10 @@ class SC2Context(CommonContext):
self.ctx.current_tooltip = self.layout self.ctx.current_tooltip = self.layout
def on_leave(self): def on_leave(self):
self.ctx.ui.clear_tooltip() if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
@property @property
def ctx(self) -> CommonContext: def ctx(self) -> CommonContext:
@@ -242,20 +184,13 @@ class SC2Context(CommonContext):
mission_panel = None mission_panel = None
last_checked_locations = {} last_checked_locations = {}
mission_id_to_button = {} mission_id_to_button = {}
launching: typing.Union[bool, int] = False # if int -> mission ID launching = False
refresh_from_launching = True refresh_from_launching = True
first_check = True first_check = True
ctx: SC2Context
def __init__(self, ctx): def __init__(self, ctx):
super().__init__(ctx) super().__init__(ctx)
def clear_tooltip(self):
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
def build(self): def build(self):
container = super().build() container = super().build()
@@ -270,7 +205,7 @@ class SC2Context(CommonContext):
def build_mission_table(self, dt): def build_mission_table(self, dt):
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
not self.refresh_from_launching)) or self.first_check: not self.refresh_from_launching)) or self.first_check:
self.refresh_from_launching = True self.refresh_from_launching = True
self.mission_panel.clear_widgets() self.mission_panel.clear_widgets()
@@ -281,7 +216,12 @@ class SC2Context(CommonContext):
self.mission_id_to_button = {} self.mission_id_to_button = {}
categories = {} categories = {}
available_missions, unfinished_missions = calc_unfinished_missions(self.ctx) available_missions = []
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
self.ctx.mission_req_table,
self.ctx, available_missions=available_missions,
unfinished_locations=unfinished_locations)
# separate missions into categories # separate missions into categories
for mission in self.ctx.mission_req_table: for mission in self.ctx.mission_req_table:
@@ -292,8 +232,7 @@ class SC2Context(CommonContext):
for category in categories: for category in categories:
category_panel = MissionCategory() category_panel = MissionCategory()
category_panel.add_widget( category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
Label(text=category, size_hint_y=None, height=50, outline_width=1))
# Map is completed # Map is completed
for mission in categories[category]: for mission in categories[category]:
@@ -305,9 +244,7 @@ class SC2Context(CommonContext):
text = f"[color=6495ED]{text}[/color]" text = f"[color=6495ED]{text}[/color]"
tooltip = f"Uncollected locations:\n" tooltip = f"Uncollected locations:\n"
tooltip += "\n".join([self.ctx.location_names[loc] for loc in tooltip += "\n".join(location for location in unfinished_locations[mission])
self.ctx.locations_for_mission(mission)
if loc in self.ctx.missing_locations])
elif mission in available_missions: elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]" text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met # Map requirements not met
@@ -315,7 +252,7 @@ class SC2Context(CommonContext):
text = f"[color=a9a9a9]{text}[/color]" text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: " tooltip = f"Requires: "
if len(self.ctx.mission_req_table[mission].required_world) > 0: if len(self.ctx.mission_req_table[mission].required_world) > 0:
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
req_mission in req_mission in
self.ctx.mission_req_table[mission].required_world) self.ctx.mission_req_table[mission].required_world)
@@ -337,16 +274,13 @@ class SC2Context(CommonContext):
self.refresh_from_launching = False self.refresh_from_launching = False
self.mission_panel.clear_widgets() self.mission_panel.clear_widgets()
self.mission_panel.add_widget(Label(text="Launching Mission: " + self.mission_panel.add_widget(Label(text="Launching Mission"))
lookup_id_to_mission[self.launching]))
if self.ctx.ui:
self.ctx.ui.clear_tooltip()
def mission_callback(self, button): def mission_callback(self, button):
if not self.launching: if not self.launching:
mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button) self.ctx.play_mission(list(self.mission_id_to_button.keys())
self.ctx.play_mission(mission_id) [list(self.mission_id_to_button.values()).index(button)])
self.launching = mission_id self.launching = True
Clock.schedule_once(self.finish_launching, 10) Clock.schedule_once(self.finish_launching, 10)
def finish_launching(self, dt): def finish_launching(self, dt):
@@ -359,14 +293,12 @@ class SC2Context(CommonContext):
async def shutdown(self): async def shutdown(self):
await super(SC2Context, self).shutdown() await super(SC2Context, self).shutdown()
if self.last_bot:
self.last_bot.want_close = True
if self.sc2_run_task: if self.sc2_run_task:
self.sc2_run_task.cancel() self.sc2_run_task.cancel()
def play_mission(self, mission_id: int): def play_mission(self, mission_id):
if self.missions_unlocked or \ if self.missions_unlocked or \
is_mission_available(self, mission_id): is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
if self.sc2_run_task: if self.sc2_run_task:
if not self.sc2_run_task.done(): if not self.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!") sc2_logger.warning("Starcraft 2 Client is still running!")
@@ -375,29 +307,12 @@ class SC2Context(CommonContext):
sc2_logger.warning("Launching Mission without Archipelago authentication, " sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.") "checks will not be registered to server.")
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id), self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
name="Starcraft 2 Launch") name="Starcraft 2 Launch")
else: else:
sc2_logger.info( sc2_logger.info(
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. " f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
f"Use /unfinished or /available to see what is available.") f"Use /unfinished or /available to see what is available.")
def build_location_to_mission_mapping(self):
mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
mission_info.id: set() for mission_info in self.mission_req_table.values()
}
for loc in self.server_locations:
mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo)
mission_id_to_location_ids[mission_id].add(objective)
self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in
mission_id_to_location_ids.items()}
def locations_for_mission(self, mission: str):
mission_id: int = self.mission_req_table[mission].id
objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id]
for objective in objectives:
yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective
async def main(): async def main():
multiprocessing.freeze_support() multiprocessing.freeze_support()
@@ -437,27 +352,47 @@ wol_default_categories = [
] ]
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]: def calculate_items(items):
network_item: NetUtils.NetworkItem unit_unlocks = 0
accumulators: typing.List[int] = [0 for _ in type_flaggroups] armory1_unlocks = 0
armory2_unlocks = 0
upgrade_unlocks = 0
building_unlocks = 0
merc_unlocks = 0
lab_unlocks = 0
protoss_unlock = 0
minerals = 0
vespene = 0
supply = 0
for network_item in items: for item in items:
name: str = lookup_id_to_name[network_item.item] data = lookup_id_to_name[item.item]
item_data: ItemData = item_table[name]
# exists exactly once if item_table[data].type == "Unit":
if item_data.quantity == 1: unit_unlocks += (1 << item_table[data].number)
accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number elif item_table[data].type == "Upgrade":
upgrade_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 1":
armory1_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 2":
armory2_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Building":
building_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Mercenary":
merc_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Laboratory":
lab_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Protoss":
protoss_unlock += (1 << item_table[data].number)
elif item_table[data].type == "Minerals":
minerals += item_table[data].number
elif item_table[data].type == "Vespene":
vespene += item_table[data].number
elif item_table[data].type == "Supply":
supply += item_table[data].number
# exists multiple times return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
elif item_data.type == "Upgrade": lab_unlocks, protoss_unlock, minerals, vespene, supply]
accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number
# sum
else:
accumulators[type_flaggroups[item_data.type]] += item_data.number
return accumulators
def calc_difficulty(difficulty): def calc_difficulty(difficulty):
@@ -473,48 +408,46 @@ def calc_difficulty(difficulty):
return 'X' return 'X'
async def starcraft_launch(ctx: SC2Context, mission_id: int): async def starcraft_launch(ctx: SC2Context, mission_id):
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
ctx.announcements_pos = len(ctx.announcements)
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
with DllDirectory(None): run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), name="Archipelago", fullscreen=True)], realtime=True)
name="Archipelago", fullscreen=True)], realtime=True)
class ArchipelagoBot(sc2.bot_ai.BotAI): class ArchipelagoBot(sc2.bot_ai.BotAI):
game_running: bool = False game_running = False
mission_completed: bool = False mission_completed = False
boni: typing.List[bool] first_bonus = False
setup_done: bool second_bonus = False
ctx: SC2Context third_bonus = False
mission_id: int fourth_bonus = False
want_close: bool = False fifth_bonus = False
sixth_bonus = False
seventh_bonus = False
eight_bonus = False
ctx: SC2Context = None
mission_id = 0
can_read_game = False can_read_game = False
last_received_update: int = 0 last_received_update = 0
def __init__(self, ctx: SC2Context, mission_id): def __init__(self, ctx: SC2Context, mission_id):
self.setup_done = False
self.ctx = ctx self.ctx = ctx
self.ctx.last_bot = self
self.mission_id = mission_id self.mission_id = mission_id
self.boni = [False for _ in range(max_bonus)]
super(ArchipelagoBot, self).__init__() super(ArchipelagoBot, self).__init__()
async def on_step(self, iteration: int): async def on_step(self, iteration: int):
if self.want_close:
self.want_close = False
await self._client.leave()
return
game_state = 0 game_state = 0
if not self.setup_done: if iteration == 0:
self.setup_done = True
start_items = calculate_items(self.ctx.items_received) start_items = calculate_items(self.ctx.items_received)
if self.ctx.difficulty_override >= 0: difficulty = calc_difficulty(self.ctx.difficulty)
difficulty = calc_difficulty(self.ctx.difficulty_override)
else:
difficulty = calc_difficulty(self.ctx.difficulty)
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format( await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
difficulty, difficulty,
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
@@ -523,10 +456,36 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
self.last_received_update = len(self.ctx.items_received) self.last_received_update = len(self.ctx.items_received)
else: else:
if not self.ctx.announcements.empty(): if self.ctx.announcement_pos < len(self.ctx.announcements):
message = self.ctx.announcements.get(timeout=1) index = 0
message = ""
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
index += 1
index = 0
start_rem_pos = -1
# Remove unneeded [Color] tags
while index < len(message):
if message[index] == '[':
start_rem_pos = index
index += 1
elif message[index] == ']' and start_rem_pos > -1:
temp_msg = ""
if start_rem_pos > 0:
temp_msg = message[:start_rem_pos]
if index < len(message) - 1:
temp_msg += message[index + 1:]
message = temp_msg
index += start_rem_pos - index
start_rem_pos = -1
else:
index += 1
await self.chat_send("SendMessage " + message) await self.chat_send("SendMessage " + message)
self.ctx.announcements.task_done() self.ctx.announcement_pos += 1
# Archipelago reads the health # Archipelago reads the health
for unit in self.all_own_units(): for unit in self.all_own_units():
@@ -554,97 +513,169 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if game_state & (1 << 1) and not self.mission_completed: if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != 29: if self.mission_id != 29:
print("Mission Completed") print("Mission Completed")
await self.ctx.send_msgs( await self.ctx.send_msgs([
[{"cmd": 'LocationChecks', {"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}])
self.mission_completed = True self.mission_completed = True
else: else:
print("Game Complete") print("Game Complete")
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}]) await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
self.mission_completed = True self.mission_completed = True
for x, completed in enumerate(self.boni): if game_state & (1 << 2) and not self.first_bonus:
if not completed and game_state & (1 << (x + 2)): print("1st Bonus Collected")
await self.ctx.send_msgs( await self.ctx.send_msgs(
[{"cmd": 'LocationChecks', [{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}]) "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
self.boni[x] = True self.first_bonus = True
if not self.second_bonus and game_state & (1 << 3):
print("2nd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
self.second_bonus = True
if not self.third_bonus and game_state & (1 << 4):
print("3rd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
self.third_bonus = True
if not self.fourth_bonus and game_state & (1 << 5):
print("4th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
self.fourth_bonus = True
if not self.fifth_bonus and game_state & (1 << 6):
print("5th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
self.fifth_bonus = True
if not self.sixth_bonus and game_state & (1 << 7):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
self.sixth_bonus = True
if not self.seventh_bonus and game_state & (1 << 8):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
self.seventh_bonus = True
if not self.eight_bonus and game_state & (1 << 9):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
self.eight_bonus = True
else: else:
await self.chat_send("LostConnection - Lost connection to game.") await self.chat_send("LostConnection - Lost connection to game.")
def request_unfinished_missions(ctx: SC2Context): def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
if ctx.mission_req_table: objectives_complete = 0
if missions_info[mission].extra_locations > 0:
for i in range(missions_info[mission].extra_locations):
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
objectives_complete += 1
else:
unfinished_locations[mission].append(ctx.location_names[
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
return objectives_complete
else:
return -1
def request_unfinished_missions(locations_done, location_table, ui, ctx):
if location_table:
message = "Unfinished Missions: " message = "Unfinished Missions: "
unlocks = initialize_blank_mission_dict(ctx.mission_req_table) unlocks = initialize_blank_mission_dict(location_table)
unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table) unfinished_locations = initialize_blank_mission_dict(location_table)
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks) unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
unfinished_locations=unfinished_locations)
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " + message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
mark_up_objectives( mark_up_objectives(
f"[{len(unfinished_missions[mission])}/" f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
ctx, unfinished_locations, mission) ctx, unfinished_locations, mission)
for mission in unfinished_missions) for mission in unfinished_missions)
if ctx.ui: if ui:
ctx.ui.log_panels['All'].on_message_markup(message) ui.log_panels['All'].on_message_markup(message)
ctx.ui.log_panels['Starcraft2'].on_message_markup(message) ui.log_panels['Starcraft2'].on_message_markup(message)
else: else:
sc2_logger.info(message) sc2_logger.info(message)
else: else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.") sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_unfinished_missions(ctx: SC2Context, unlocks=None): def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
available_missions=[]):
unfinished_missions = [] unfinished_missions = []
locations_completed = [] locations_completed = []
if not unlocks: if not unlocks:
unlocks = initialize_blank_mission_dict(ctx.mission_req_table) unlocks = initialize_blank_mission_dict(locations)
available_missions = calc_available_missions(ctx, unlocks) if not unfinished_locations:
unfinished_locations = initialize_blank_mission_dict(locations)
if len(available_missions) > 0:
available_missions = []
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
for name in available_missions: for name in available_missions:
objectives = set(ctx.locations_for_mission(name)) if not locations[name].extra_locations == -1:
if objectives: objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
objectives_completed = ctx.checked_locations & objectives
if len(objectives_completed) < len(objectives): if objectives_completed < locations[name].extra_locations:
unfinished_missions.append(name) unfinished_missions.append(name)
locations_completed.append(objectives_completed) locations_completed.append(objectives_completed)
else: # infer that this is the final mission as it has no objectives else:
unfinished_missions.append(name) unfinished_missions.append(name)
locations_completed.append(-1) locations_completed.append(-1)
return available_missions, dict(zip(unfinished_missions, locations_completed)) return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
def is_mission_available(ctx: SC2Context, mission_id_to_check): def is_mission_available(mission_id_to_check, locations_done, locations):
unfinished_missions = calc_available_missions(ctx) unfinished_missions = calc_available_missions(locations_done, locations)
return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions) return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
def mark_up_mission_name(ctx: SC2Context, mission, unlock_table): def mark_up_mission_name(mission, location_table, ui, unlock_table):
"""Checks if the mission is required for game completion and adds '*' to the name to mark that.""" """Checks if the mission is required for game completion and adds '*' to the name to mark that."""
if ctx.mission_req_table[mission].completion_critical: if location_table[mission].completion_critical:
if ctx.ui: if ui:
message = "[color=AF99EF]" + mission + "[/color]" message = "[color=AF99EF]" + mission + "[/color]"
else: else:
message = "*" + mission + "*" message = "*" + mission + "*"
else: else:
message = mission message = mission
if ctx.ui: if ui:
unlocks = unlock_table[mission] unlocks = unlock_table[mission]
if len(unlocks) > 0: if len(unlocks) > 0:
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: " pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks) pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
pre_message += f"]" pre_message += f"]"
message = pre_message + message + "[/ref]" message = pre_message + message + "[/ref]"
@@ -657,7 +688,7 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
if ctx.ui: if ctx.ui:
locations = unfinished_locations[mission] locations = unfinished_locations[mission]
pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|" pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
pre_message += "<br>".join(location for location in locations) pre_message += "<br>".join(location for location in locations)
pre_message += f"]" pre_message += f"]"
formatted_message = pre_message + message + "[/ref]" formatted_message = pre_message + message + "[/ref]"
@@ -665,91 +696,90 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
return formatted_message return formatted_message
def request_available_missions(ctx: SC2Context): def request_available_missions(locations_done, location_table, ui):
if ctx.mission_req_table: if location_table:
message = "Available Missions: " message = "Available Missions: "
# Initialize mission unlock table # Initialize mission unlock table
unlocks = initialize_blank_mission_dict(ctx.mission_req_table) unlocks = initialize_blank_mission_dict(location_table)
missions = calc_available_missions(ctx, unlocks) missions = calc_available_missions(locations_done, location_table, unlocks)
message += \ message += \
", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}" ", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
f"[{ctx.mission_req_table[mission].id}]"
for mission in missions) for mission in missions)
if ctx.ui: if ui:
ctx.ui.log_panels['All'].on_message_markup(message) ui.log_panels['All'].on_message_markup(message)
ctx.ui.log_panels['Starcraft2'].on_message_markup(message) ui.log_panels['Starcraft2'].on_message_markup(message)
else: else:
sc2_logger.info(message) sc2_logger.info(message)
else: else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.") sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_available_missions(ctx: SC2Context, unlocks=None): def calc_available_missions(locations_done, locations, unlocks=None):
available_missions = [] available_missions = []
missions_complete = 0 missions_complete = 0
# Get number of missions completed # Get number of missions completed
for loc in ctx.checked_locations: for loc in locations_done:
if loc % victory_modulo == 0: if loc % 100 == 0:
missions_complete += 1 missions_complete += 1
for name in ctx.mission_req_table: for name in locations:
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
if unlocks: if unlocks:
for unlock in ctx.mission_req_table[name].required_world: for unlock in locations[name].required_world:
unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name) unlocks[list(locations)[unlock-1]].append(name)
if mission_reqs_completed(ctx, name, missions_complete): if mission_reqs_completed(name, missions_complete, locations_done, locations):
available_missions.append(name) available_missions.append(name)
return available_missions return available_missions
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete): def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
"""Returns a bool signifying if the mission has all requirements complete and can be done """Returns a bool signifying if the mission has all requirements complete and can be done
Arguments: Keyword arguments:
ctx -- instance of SC2Context
locations_to_check -- the mission string name to check locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed missions_complete -- an int of how many missions have been completed
""" locations_done -- a list of the location ids that have been complete
if len(ctx.mission_req_table[mission_name].required_world) >= 1: locations -- a dict of MissionInfo for mission requirements for this world"""
if len(locations[location_to_check].required_world) >= 1:
# A check for when the requirements are being or'd # A check for when the requirements are being or'd
or_success = False or_success = False
# Loop through required missions # Loop through required missions
for req_mission in ctx.mission_req_table[mission_name].required_world: for req_mission in locations[location_to_check].required_world:
req_success = True req_success = True
# Check if required mission has been completed # Check if required mission has been completed
if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id * if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations: if not locations[location_to_check].or_requirements:
if not ctx.mission_req_table[mission_name].or_requirements:
return False return False
else: else:
req_success = False req_success = False
# Recursively check required mission to see if it's requirements are met, in case !collect has been done # Recursively check required mission to see if it's requirements are met, in case !collect has been done
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
if not ctx.mission_req_table[mission_name].or_requirements: locations):
if not locations[location_to_check].or_requirements:
return False return False
else: else:
req_success = False req_success = False
# If requirement check succeeded mark or as satisfied # If requirement check succeeded mark or as satisfied
if ctx.mission_req_table[mission_name].or_requirements and req_success: if locations[location_to_check].or_requirements and req_success:
or_success = True or_success = True
if ctx.mission_req_table[mission_name].or_requirements: if locations[location_to_check].or_requirements:
# Return false if or requirements not met # Return false if or requirements not met
if not or_success: if not or_success:
return False return False
# Check number of missions # Check number of missions
if missions_complete >= ctx.mission_req_table[mission_name].number: if missions_complete >= locations[location_to_check].number:
return True return True
else: else:
return False return False
@@ -766,101 +796,6 @@ def initialize_blank_mission_dict(location_table):
return unlocks return unlocks
def check_game_install_path() -> bool:
# First thing: go to the default location for ExecuteInfo.
# An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
if is_windows:
# The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
# https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
import ctypes.wintypes
CSIDL_PERSONAL = 5 # My Documents
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
documentspath = buf.value
einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
else:
einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF]))
# Check if the file exists.
if os.path.isfile(einfo):
# Open the file and read it, picking out the latest executable's path.
with open(einfo) as f:
content = f.read()
if content:
base = re.search(r" = (.*)Versions", content).group(1)
if os.path.exists(base):
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
# Finally, check the path for an actual executable.
# If we find one, great. Set up the SC2PATH.
if os.path.isfile(executable):
sc2_logger.info(f"Found an SC2 install at {base}!")
sc2_logger.debug(f"Latest executable at {executable}.")
os.environ["SC2PATH"] = base
sc2_logger.debug(f"SC2PATH set to {base}.")
return True
else:
sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
else:
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
else:
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
return False
def check_mod_install() -> bool:
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
try:
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
return True
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
except KeyError:
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
return False
class DllDirectory:
# Credit to Black Sliver for this code.
# More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
_old: typing.Optional[str] = None
_new: typing.Optional[str] = None
def __init__(self, new: typing.Optional[str]):
self._new = new
def __enter__(self):
old = self.get()
if self.set(self._new):
self._old = old
def __exit__(self, *args):
if self._old is not None:
self.set(self._old)
@staticmethod
def get() -> typing.Optional[str]:
if sys.platform == "win32":
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
buf = ctypes.create_unicode_buffer(n)
ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
return buf.value
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
return None
@staticmethod
def set(s: typing.Optional[str]) -> bool:
if sys.platform == "win32":
return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
return False
if __name__ == '__main__': if __name__ == '__main__':
colorama.init() colorama.init()
asyncio.run(main()) asyncio.run(main())

195
Utils.py
View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import shutil
import typing import typing
import builtins import builtins
import os import os
@@ -11,18 +12,12 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
from yaml import load, load_all, dump, SafeLoader import decimal
try:
from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError:
from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import tkinter from tkinter import Tk
import pathlib else:
Tk = typing.Any
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -35,13 +30,21 @@ class Version(typing.NamedTuple):
build: int build: int
__version__ = "0.3.5" __version__ = "0.3.3"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == "darwin" is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys") is_windows = sys.platform in ("win32", "cygwin", "msys")
import jellyfish
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
def int16_as_bytes(value: int) -> typing.List[int]: def int16_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFF value = value & 0xFFFF
@@ -122,18 +125,17 @@ def home_path(*path: str) -> str:
def user_path(*path: str) -> str: def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"): if hasattr(user_path, 'cached_path'):
pass pass
elif os.access(local_path(), os.W_OK): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
# populate home from local - TODO: upgrade feature # populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
import shutil for dn in ('Players', 'data/sprites'):
for dn in ("Players", "data/sprites"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ("manifest.json", "host.yaml"): for fn in ('manifest.json', 'host.yaml'):
shutil.copy2(local_path(fn), user_path(fn)) shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
@@ -148,12 +150,11 @@ def output_path(*path: str):
return path return path
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: def open_file(filename):
if is_windows: if sys.platform == 'win32':
os.startfile(filename) os.startfile(filename)
else: else:
from shutil import which open_command = 'open' if sys.platform == 'darwin' else 'xdg-open'
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
subprocess.call([open_command, filename]) subprocess.call([open_command, filename])
@@ -172,9 +173,7 @@ class UniqueKeyLoader(SafeLoader):
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader) parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader) parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader) unsafe_parse_yaml = functools.partial(load, Loader=Loader)
del load, load_all # should not be used. don't leak their names
def get_cert_none_ssl_context(): def get_cert_none_ssl_context():
@@ -192,12 +191,11 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip() ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
except Exception as e: except Exception as e:
# noinspection PyBroadException
try: try:
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip() ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
except Exception: except:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out pass # we could be offline, in a local game, so no point in erroring out
return ip return ip
@@ -210,7 +208,7 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip() ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available pass # we could be offline, in a local game, or ipv6 may not be available
@@ -279,12 +277,7 @@ def get_default_options() -> dict:
}, },
"oot_options": { "oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64", "rom_file": "The Legend of Zelda - Ocarina of Time.z64",
}, }
"dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
"sni": "SNI",
"rom_start": True,
},
} }
return options return options
@@ -311,19 +304,33 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
@cache_argsless @cache_argsless
def get_options() -> dict: def get_options() -> dict:
filenames = ("options.yaml", "host.yaml") if not hasattr(get_options, "options"):
locations = [] filenames = ("options.yaml", "host.yaml")
if os.path.join(os.getcwd()) != local_path(): locations = []
locations += filenames # use files from cwd only if it's not the local_path if os.path.join(os.getcwd()) != local_path():
locations += [user_path(filename) for filename in filenames] locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]
for location in locations: for location in locations:
if os.path.exists(location): if os.path.exists(location):
with open(location) as f: with open(location) as f:
options = parse_yaml(f.read()) options = parse_yaml(f.read())
return update_options(get_default_options(), options, location, list())
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") get_options.options = update_options(get_default_options(), options, location, list())
break
else:
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
return get_options.options
def get_item_name_from_id(code: int) -> str:
from worlds import lookup_any_item_id_to_name
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
def get_location_name_from_id(code: int) -> str:
from worlds import lookup_any_location_id_to_name
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
def persistent_store(category: str, key: typing.Any, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -332,10 +339,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any):
category = storage.setdefault(category, {}) category = storage.setdefault(category, {})
category[key] = value category[key] = value
with open(path, "wt") as f: with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper)) f.write(dump(storage))
def persistent_load() -> typing.Dict[str, dict]: def persistent_load() -> typing.Dict[dict]:
storage = getattr(persistent_load, "storage", None) storage = getattr(persistent_load, "storage", None)
if storage: if storage:
return storage return storage
@@ -353,8 +360,8 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage return storage
def get_adjuster_settings(game_name: str): def get_adjuster_settings(gameName: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
return adjuster_settings return adjuster_settings
@@ -370,10 +377,10 @@ def get_unique_identifier():
return uuid return uuid
safe_builtins = frozenset(( safe_builtins = {
'set', 'set',
'frozenset', 'frozenset',
)) }
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
@@ -401,7 +408,8 @@ class RestrictedUnpickler(pickle.Unpickler):
if issubclass(obj, self.options_module.Option): if issubclass(obj, self.options_module.Option):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s): def restricted_loads(s):
@@ -410,9 +418,6 @@ def restricted_loads(s):
class KeyedDefaultDict(collections.defaultdict): class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
def __missing__(self, key): def __missing__(self, key):
self[key] = value = self.default_factory(key) self[key] = value = self.default_factory(key)
return value return value
@@ -422,10 +427,6 @@ def get_text_between(text: str, start: str, end: str) -> str:
return text[text.index(start) + len(start): text.rindex(end)] return text[text.index(start) + len(start): text.rindex(end)]
def get_text_after(text: str, start: str) -> str:
return text[text.index(start) + len(start):]
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
@@ -473,13 +474,9 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
def stream_input(stream, queue): def stream_input(stream, queue):
def queuer(): def queuer():
while 1: while 1:
try: text = stream.readline().strip()
text = stream.readline().strip() if text:
except UnicodeDecodeError as e: queue.put_nowait(text)
logging.exception(e)
else:
if text:
queue.put_nowait(text)
from threading import Thread from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True) thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -487,11 +484,11 @@ def stream_input(stream, queue):
return thread return thread
def tkinter_center_window(window: "tkinter.Tk") -> None: def tkinter_center_window(window: Tk):
window.update() window.update()
x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2) xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2) yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
window.geometry(f"+{x}+{y}") window.geometry("+{}+{}".format(xPos, yPos))
class VersionException(Exception): class VersionException(Exception):
@@ -508,27 +505,24 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
# noinspection PyPep8Naming # noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str: def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix""" """Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
import decimal
n = 0 n = 0
value = decimal.Decimal(value) value = decimal.Decimal(value)
limit = power - decimal.Decimal("0.005") while value >= power:
while value >= limit:
value /= power value /= power
n += 1 n += 1
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]: -> typing.List[typing.Tuple[str, int]]:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
limit: int = limit if limit else len(wordlist) limit: int = limit if limit else len(wordlist)
return list( return list(
map( map(
@@ -546,19 +540,18 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]: -> typing.Optional[str]:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which kdialog = shutil.which('kdialog')
kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
zenity = which("zenity") zenity = shutil.which('zenity')
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
return run(zenity, f"--title={title}", "--file-selection", *z_filters) return run(zenity, f'--title={title}', '--file-selection', *z_filters)
# fall back to tk # fall back to tk
try: try:
@@ -576,10 +569,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
def is_kivy_running(): def is_kivy_running():
if "kivy" in sys.modules: if 'kivy' in sys.modules:
from kivy.app import App from kivy.app import App
return App.get_running_app() is not None return App.get_running_app() is not None
return False return False
@@ -589,15 +582,14 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
return return
if is_linux and "tkinter" not in sys.modules: if is_linux and not 'tkinter' in sys.modules:
# prefer native dialog # prefer native dialog
from shutil import which kdialog = shutil.which('kdialog')
kdialog = which("kdialog")
if kdialog: if kdialog:
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
zenity = which("zenity") zenity = shutil.which('zenity')
if zenity: if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
# fall back to tk # fall back to tk
try: try:
@@ -612,14 +604,3 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.withdraw() root.withdraw()
showerror(title, text) if error else showinfo(title, text) showerror(title, text) if error else showinfo(title, text)
root.update() root.update()
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: str) -> str:
parts = element.split(maxsplit=1)
if parts[0].lower() in ignore:
return parts[1].lower()
else:
return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))

View File

@@ -12,9 +12,9 @@ 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 app as raw_app
from waitress import serve from waitress import serve
from WebHostLib.models import db from WebHostLib.models import db
@@ -22,13 +22,14 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app(): def get_app():
register()
app = raw_app app = raw_app
if os.path.exists(configpath): if os.path.exists(configpath):
import yaml import yaml
@@ -42,39 +43,19 @@ def get_app():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json import json
import shutil import shutil
import zipfile
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {} worlds = {}
data = [] data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game) source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
os.makedirs(target_path, exist_ok=True) target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
files = os.listdir(source_path)
if world.zip_path: for file in files:
zipfile_path = world.zip_path os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
with zipfile.ZipFile(zipfile_path) as zf:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game # build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []} game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials: for tutorial in world.web.tutorials:
@@ -104,7 +85,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] + 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,46 +0,0 @@
# WebHost
## Contribution Guidelines
**Thank you for your interest in contributing to the Archipelago website!**
Much of the content on the website is generated automatically, but there are some things
that need a personal touch. For those things, we rely on contributions from both the core
team and the community. The current primary maintainer of the website is Farrak Kilhn.
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
### Small Changes
Little changes like adding a button or a couple new select elements are perfectly fine.
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
you build a new page which needs two side by side tables, and you need to write a CSS file
specific to your page, that is perfectly reasonable.
### Content Additions
Once you develop a new feature or add new content the website, make a pull request. It will
be reviewed by the community and there will probably be some discussion around it. Depending
on the size of the feature, and if new styles are required, there may be an additional step
before the PR is accepted wherein Farrak works with the designer to implement styles.
### Restrictions on Style Changes
A professional designer is paid to develop the styles and assets for the Archipelago website.
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
change site styles are rejected. Please note this applies to code which changes the overall
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
behind these restrictions is to maintain a curated feel for the design of the site. If
any PR affects the overall feel of the site but includes additive changes, there will
likely be a conversation about how to implement those changes without compromising the
curated site style. It is therefore worth noting there are a couple files which, if
changed in your pull request, will cause it to draw additional scrutiny.
These closely guarded files are:
- `globalStyles.css`
- `islandFooter.css`
- `landing.css`
- `markdown.css`
- `tooltip.css`
### Site Themes
There are several themes available for game pages. It is possible to request a new theme in
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
are not free, and take some time to create. Farrak works closely with the designer to implement
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
good chance it will become a reality.

View File

@@ -3,13 +3,13 @@ import uuid
import base64 import base64
import socket import socket
import jinja2.exceptions
from pony.flask import Pony from pony.flask import Pony
from flask import Flask from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from werkzeug.routing import BaseConverter from worlds.AutoWorld import AutoWorldRegister
from Utils import title_sorted
from .models import * from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
@@ -53,6 +53,8 @@ app.config["PATCH_TARGET"] = "archipelago.gg"
cache = Cache(app) cache = Cache(app)
Compress(app) Compress(app)
from werkzeug.routing import BaseConverter
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
@@ -66,18 +68,170 @@ class B64UUIDConverter(BaseConverter):
# short UUID # short UUID
app.url_map.converters["suuid"] = B64UUIDConverter app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted
def register(): def get_world_theme(game_name: str):
"""Import submodules, triggering their registering on flask routing. if game_name in AutoWorldRegister.world_types:
Note: initializes worlds subsystem.""" return AutoWorldRegister.world_types[game_name].web.theme
# has automatic patch integration return 'grass'
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
app.register_blueprint(api.api_endpoints) @app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
with db_session:
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@cache.cached()
def get_datapackge():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@@ -32,14 +32,14 @@ def room_info(room: UUID):
@api_endpoints.route('/datapackage') @api_endpoints.route('/datapackage')
@cache.cached() @cache.cached()
def get_datapackage(): def get_datapackge():
from worlds import network_data_package from worlds import network_data_package
return network_data_package return network_data_package
@api_endpoints.route('/datapackage_version') @api_endpoints.route('/datapackage_version')
@cache.cached() @cache.cached()
def get_datapackage_versions(): def get_datapackge_versions():
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()} version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"] version_package["version"] = network_data_package["version"]

View File

@@ -154,10 +154,8 @@ def autogen(config: dict):
while 1: while 1:
time.sleep(0.1) time.sleep(0.1)
with db_session: with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select( to_start = select(
generation for generation in Generation generation for generation in Generation if generation.state == STATE_QUEUED)
if generation.state == STATE_QUEUED).for_update()
for generation in to_start: for generation in to_start:
launch_generator(generator_pool, generation) launch_generator(generator_pool, generation)
except AlreadyRunningException: except AlreadyRunningException:
@@ -184,7 +182,7 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}") logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process, process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig, get_static_server_data()), args=(self.room_id, self.ponyconfig),
name="MultiHost") name="MultiHost")
process.start() process.start()
# bind after start to prevent thread sync issues with guardian. # bind after start to prevent thread sync issues with guardian.
@@ -238,5 +236,5 @@ def run_guardian():
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
from .customserver import run_server_process, get_static_server_data from .customserver import run_server_process
from .generate import gen_game from .generate import gen_game

View File

@@ -12,7 +12,7 @@ def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip")) return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoSettings from Generate import roll_settings
from Utils import parse_yamls from Utils import parse_yamls
@@ -65,7 +65,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
def roll_options(options: Dict[str, Union[dict, str]], def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \ plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]: Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoSettings.from_set(set(plando_options)) plando_options = set(plando_options)
results = {} results = {}
rolled_results = {} rolled_results = {}
for filename, text in options.items(): for filename, text in options.items():

View File

@@ -9,13 +9,12 @@ import time
import random import random
import pickle import pickle
import logging import logging
import datetime
import Utils import Utils
from .models import db_session, Room, select, commit, Command, db from .models import *
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
class CustomClientMessageProcessor(ClientMessageProcessor): class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -40,7 +39,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
import MultiServer import MultiServer
MultiServer.client_message_processor = CustomClientMessageProcessor MultiServer.client_message_processor = CustomClientMessageProcessor
del MultiServer del (MultiServer)
class DBCommandProcessor(ServerCommandProcessor): class DBCommandProcessor(ServerCommandProcessor):
@@ -49,20 +48,12 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context): class WebHostContext(Context):
def __init__(self, static_server_data: dict): def __init__(self):
# 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
self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2) super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
del self.static_server_data
self.main_loop = asyncio.get_running_loop() self.main_loop = asyncio.get_running_loop()
self.video = {} self.video = {}
self.tags = ["AP", "WebHost"] self.tags = ["AP", "WebHost"]
def _load_game_data(self):
for key, value in self.static_server_data.items():
setattr(self, key, value)
def listen_to_db_commands(self): def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self) cmdprocessor = DBCommandProcessor(self)
@@ -103,7 +94,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:
@@ -116,32 +107,14 @@ def get_random_port():
return random.randint(49152, 65535) return random.randint(49152, 65535)
@cache_argsless def run_server_process(room_id, ponyconfig: dict):
def get_static_server_data() -> dict:
import worlds
data = {
"forced_auto_forfeits": {},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
# establish DB connection for multidata and multisave # establish DB connection for multidata and multisave
db.bind(**ponyconfig) db.bind(**ponyconfig)
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
async def main(): async def main():
Utils.init_logging(str(room_id), write_mode="a") Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data) ctx = WebHostContext()
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()

View File

@@ -32,21 +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, attachment_filename=fname)
else: else:
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}") patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = BytesIO(patch_data) patch_data = BytesIO(patch_data)
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"{preferred_endings[patch.game]}" f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, download_name=fname) return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/dl_spoiler/<suuid:seed_id>") @app.route("/dl_spoiler/<suuid:seed_id>")
@@ -69,7 +66,7 @@ def download_slot_file(room_id, player_id: int):
from worlds.minecraft import mc_update_output from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port) data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname) return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
elif slot_data.game == "Factorio": elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
@@ -81,11 +78,9 @@ def download_slot_file(room_id, player_id: int):
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 == "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":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
else: else:
return "Game download not supported." return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname) return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
@app.route("/templates") @app.route("/templates")

View File

@@ -4,7 +4,7 @@ import random
import json import json
import zipfile import zipfile
from collections import Counter from collections import Counter
from typing import Dict, Optional, Any from typing import Dict, Optional as TypeOptional
from Utils import __version__ 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
@@ -12,10 +12,10 @@ from flask import request, flash, redirect, url_for, session, render_template
from worlds.alttp.EntranceRandomizer import parse_arguments 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
from Generate import handle_name, PlandoSettings from Generate import handle_name
import pickle import pickle
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID from .models import *
from WebHostLib import app from WebHostLib import app
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
@@ -30,15 +30,16 @@ def get_meta(options_source: dict) -> dict:
} }
plando_options -= {""} plando_options -= {""}
server_options = { meta = {
"hint_cost": int(options_source.get("hint_cost", 10)), "hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"), "forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("remaining_mode", "disabled"), "remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"), "collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))), "item_cheat": bool(int(options_source.get("item_cheat", 1))),
"server_password": options_source.get("server_password", None), "server_password": options_source.get("server_password", None),
"plando_options": list(plando_options)
} }
return {"server_options": server_options, "plando_options": list(plando_options)} return meta
@app.route('/generate', methods=['GET', 'POST']) @app.route('/generate', methods=['GET', 'POST'])
@@ -59,13 +60,13 @@ def generate(race=False):
results, gen_options = roll_options(options, meta["plando_options"]) results, gen_options = roll_options(options, meta["plando_options"])
if race: if race:
meta["server_options"]["item_cheat"] = False meta["item_cheat"] = False
meta["server_options"]["remaining_mode"] = "disabled" meta["remaining_mode"] = "disabled"
if any(type(result) == str for result in results.values()): if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results) return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]: elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. " flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
f"If you have a larger group, please generate it yourself and upload it.") f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]: elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation( gen = Generation(
@@ -91,35 +92,35 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
if not meta: if not meta:
meta: Dict[str, Any] = {} meta: Dict[str, object] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False)
meta.setdefault("hint_cost", 10)
race = meta.get("race", False)
del (meta["race"])
plando_options = meta.get("plando", {"bosses", "items", "connections", "texts"})
del (meta["plando_options"])
try: try:
target = tempfile.TemporaryDirectory() target = tempfile.TemporaryDirectory()
playercount = len(gen_options) playercount = len(gen_options)
seed = get_seed() seed = get_seed()
random.seed(seed)
if race: if race:
random.seed() # use time-based random source random.seed() # reset to time-based random source
else:
random.seed(seed)
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
erargs = parse_arguments(['--multi', str(playercount)]) erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
erargs.spoiler = 0 if race else 2 erargs.spoiler = 0 if race else 2
erargs.race = race erargs.race = race
erargs.outputname = seedname erargs.outputname = seedname
erargs.outputpath = target.name erargs.outputpath = target.name
erargs.teams = 1 erargs.teams = 1
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options", erargs.plando_options = ", ".join(plando_options)
{"bosses", "items", "connections", "texts"}))
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
@@ -135,7 +136,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(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(erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta)
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
except BaseException as e: except BaseException as e:
@@ -147,6 +148,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
meta = json.loads(gen.meta) meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": " + str(e)) meta["error"] = (e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise

View File

@@ -1,173 +0,0 @@
import datetime
import os
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@cache.cached()
def get_datapackage():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)

View File

@@ -27,7 +27,7 @@ class Room(db.Entity):
seed = Required('Seed', index=True) seed = Required('Seed', index=True)
multisave = Optional(buffer, lazy=True) multisave = Optional(buffer, lazy=True)
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: 6 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True) tracker = Optional(UUID, index=True)
last_port = Optional(int, default=lambda: 0) last_port = Optional(int, default=lambda: 0)

View File

@@ -1,6 +1,6 @@
import logging import logging
import os import os
from Utils import __version__, local_path from Utils import __version__
from jinja2 import Template from jinja2 import Template
import yaml import yaml
import json import json
@@ -9,13 +9,14 @@ import typing
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import Options 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)
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {} data = {}
@@ -48,11 +49,6 @@ def create():
return list(default_value) return list(default_value)
return default_value return default_value
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
weighted_settings = { weighted_settings = {
"baseOptions": { "baseOptions": {
"description": "Generated by https://archipelago.gg/", "description": "Generated by https://archipelago.gg/",
@@ -64,17 +60,13 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
all_options = {**Options.per_game_common_options, **world.option_definitions} all_options = {**world.options, **Options.per_game_common_options}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f: res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
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, default_converter=default_converter, 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)
@@ -96,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": []
} }
@@ -118,18 +110,18 @@ def create():
if option.default == "random": if option.default == "random":
this_option["defaultValue"] = "random" this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range): elif hasattr(option, "range_start") and hasattr(option, "range_end"):
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,
"max": option.range_end, "max": option.range_end,
} }
if issubclass(option, Options.SpecialRange): if hasattr(option, "special_range_names"):
game_options[option_name]["type"] = 'special_range' game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {} game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items(): for key, val in option.special_range_names.items():
@@ -139,22 +131,22 @@ 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 hasattr(option, "valid_keys"):
if option.valid_keys: if option.valid_keys:
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.2
pony>=0.7.16 pony>=0.7.16
waitress>=2.1.2 waitress>=2.1.1
Flask-Caching>=2.0.1 flask-caching>=1.11.1
Flask-Compress>=1.12 Flask-Compress>=1.12
Flask-Limiter>=2.6.2 Flask-Limiter>=2.4.6
bokeh>=2.4.3 bokeh>=2.4.3

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);

View File

@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
games.forEach((game) => { games.forEach((game) => {
const gameTitle = document.createElement('h2'); const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle; gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle); tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => { game.tutorials.forEach((tutorial) => {
@@ -66,15 +65,6 @@ window.addEventListener('load', () => {
showError(); showError();
console.error(error); console.error(error);
} }
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
}; };
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true); ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send(); ajax.send();

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

@@ -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,104 +1,54 @@
from collections import Counter, defaultdict from collections import Counter, defaultdict
from colorsys import hsv_to_rgb from itertools import cycle
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from math import tau from math import tau
import typing
from bokeh.embed import components from bokeh.embed import components
from bokeh.models import HoverTool from bokeh.palettes import Dark2_8 as palette
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
from . import app, cache from . import app, cache
from .models import Room from .models import Room
PLOT_WIDTH = 600
def get_db_data():
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[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=30000)
room: Room room: Room
for room in select(room for room in Room if room.creation_time >= cutoff): for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots: for slot in room.seed.slots:
if slot.game in known_games: total_games[slot.game] += 1
total_games[slot.game] += 1 games_played[room.creation_time.date()][slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played return total_games, games_played
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
for x in range(0, 361, 360 // colors_needed):
# a bit of noise on value to add some luminosity difference
colors.append(RGB(*(val * 255 for val in hsv_to_rgb(x / 360, 0.8, 0.8 + (x / 1800)))))
# splice colors for maximum hue contrast.
colors = colors[::2] + colors[1::2]
return colors
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
occurences.append(all_games_data[day][game])
data = {
"days": [datetime.combine(day, datetime.min.time()) for day in days],
"played": occurences
}
plot = figure(
title=f"{game} Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500,
toolbar_location=None, tools="",
# setting legend to False seems broken in bokeh currently?
# legend=False
)
hover = HoverTool(tooltips=[("Date:", "@days{%F}"), ("Played:", "@played")], formatters={"@days": "datetime"})
plot.add_tools(hover)
plot.vbar(x="days", top="played", legend_label=game, color=color, source=ColumnDataSource(data=data), width=1)
return plot
@app.route('/stats') @app.route('/stats')
@cache.memoize(timeout=60 * 60) # regen once per hour should be plenty @cache.memoize(timeout=60*60) # regen once per hour should be plenty
def stats(): def stats():
from worlds import network_data_package
known_games = set(network_data_package["games"])
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date", plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500) y_axis_label="Games Played", sizing_mode="scale_both", width=500, height=500)
total_games, games_played = get_db_data(known_games) total_games, games_played = get_db_data()
days = sorted(games_played) days = sorted(games_played)
color_palette = get_color_palette(len(total_games)) cyc_palette = cycle(palette)
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games): for game in sorted(total_games):
occurences = [] occurences = []
for day in days: for day in days:
occurences.append(games_played[day][game]) occurences.append(games_played[day][game])
plot.line([datetime.combine(day, datetime.min.time()) for day in days], plot.line([datetime.combine(day, datetime.min.time()) for day in days],
occurences, legend_label=game, line_width=2, color=game_to_color[game]) occurences, legend_label=game, line_width=2, color=next(cyc_palette))
total = sum(total_games.values()) total = sum(total_games.values())
pie = figure(plot_height=350, 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=500, height=500)
pie.axis.visible = False pie.axis.visible = False
pie.xgrid.visible = False
pie.ygrid.visible = False
data = { data = {
"games": [], "games": [],
@@ -115,15 +65,12 @@ def stats():
current_angle += angle current_angle += angle
data["end_angles"].append(current_angle) data["end_angles"].append(current_angle)
data["colors"] = [game_to_color[game] for game in data["games"]] data["colors"] = [element[1] for element in sorted((game, color) for game, color in
zip(data["games"], cycle(palette)))]
pie.wedge(x=0, y=0, radius=0.5, pie.wedge(x=0.5, y=0.5, radius=0.5,
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 total_games script, charts = components((plot, pie))
if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts))
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(), return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
chart_data=script, charts=charts) chart_data=script, charts=charts)

View File

@@ -41,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">
@@ -62,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">
@@ -83,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">
@@ -103,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">
@@ -128,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">
@@ -146,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">
@@ -158,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

@@ -2,7 +2,6 @@
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% block head %} {% block head %}
<title>Multiworld {{ room.id|suuid }}</title> <title>Multiworld {{ room.id|suuid }}</title>
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %} {% endblock %}
@@ -17,9 +16,9 @@
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled. This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br /> <br />
{% endif %} {% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue
Should you wish to continue later, later,
anyone can simply refresh this page and the server will resume.<br> you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %} {% if 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 }}.">

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

@@ -40,12 +40,9 @@
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %} {% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<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 APSM64EX File...</a> Download APSM64EX File...</a>
{% elif patch.game | supports_apdeltapatch %} {% elif patch.game in ["A Link to the Past", "Secret of Evermore", "Super Metroid", "SMZ3"] %}
<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" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% else %} {% else %}
No file to download for this game. No file to download for this game.
{% endif %} {% endif %}

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 %}
@@ -10,21 +10,15 @@
{% include 'header/oceanHeader.html' %} {% include 'header/oceanHeader.html' %}
<div id="games" class="markdown"> <div id="games" class="markdown">
<h1>Currently Supported Games</h1> <h1>Currently Supported Games</h1>
{% for game_name in worlds | title_sorted %} {% for game_name, world in worlds.items() | sort(attribute=0) %}
{% set world = worlds[game_name] %}
<h2>{{ game_name }}</h2> <h2>{{ game_name }}</h2>
<p> <p>
{{ world.__doc__ | default("No description provided.", true) }}<br /> {{ world.__doc__ | default("No description provided.", true) }}<br />
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a> <a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
{% if world.web.tutorials %}
<span class="link-spacer">|</span> <span class="link-spacer">|</span>
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
{% endif %}
{% if world.web.settings_page is string %} {% if world.web.settings_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.settings_page }}">Settings Page</a> <a href="{{ world.web.settings_page }}">Settings Page</a>
{% elif world.web.settings_page %} {% elif world.web.settings_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a> <a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
{% endif %} {% endif %}
{% if world.web.bug_report_page %} {% if world.web.bug_report_page %}

View File

@@ -11,7 +11,7 @@ from worlds.alttp import Items
from WebHostLib import app, cache, Room 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 MultiServer import Context from MultiServer import get_item_name_from_id, Context
from NetUtils import SlotType from NetUtils import SlotType
alttp_icons = { alttp_icons = {
@@ -987,10 +987,10 @@ def getTracker(tracker: UUID):
if game_state == 30: if game_state == 30:
inventory[team][player][106] = 1 # Triforce inventory[team][player][106] = 1 # Triforce
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)} player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)} player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
for loc_data in locations.values(): for loc_data in locations.values():
for values in loc_data.values(): for values in loc_data.values():
item_id, item_player, flags = values item_id, item_player, flags = values
if item_id in ids_big_key: if item_id in ids_big_key:
@@ -1021,7 +1021,7 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []): for (team, player), data in multisave.get("video", []):
video[(team, player)] = data video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas, multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,

View File

@@ -80,11 +80,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name, slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Ocarina of Time")) player_id=int(slot_id[1:]), game="Ocarina of Time"))
elif file.filename.endswith(".json"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Dark Souls III"))
elif file.filename.endswith(".txt"): elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig") spoiler = zfile.open(file, "r").read().decode("utf-8-sig")

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

@@ -2,8 +2,8 @@ local socket = require("socket")
local json = require('json') local json = require('json')
local math = require('math') local math = require('math')
local last_modified_date = '2022-07-24' -- Should be the last modified date local last_modified_date = '2022-05-25' -- Should be the last modified date
local script_version = 2 local script_version = 1
-------------------------------------------------- --------------------------------------------------
-- Heavily modified form of RiptideSage's tracker -- Heavily modified form of RiptideSage's tracker
@@ -1723,11 +1723,6 @@ function get_death_state()
end end
function kill_link() function kill_link()
-- market entrance: 27/28/29
-- outside ToT: 35/36/37.
-- if killed on these scenes the game crashes, so we wait until not on this screen.
local scene = global_context:rawget('cur_scene'):rawget()
if scene == 27 or scene == 28 or scene == 29 or scene == 35 or scene == 36 or scene == 37 then return end
mainmemory.write_u16_be(0x11A600, 0) mainmemory.write_u16_be(0x11A600, 0)
end end
@@ -1829,15 +1824,13 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then if (frame % 60 == 0) then
server:settimeout(2) server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept() local client, timeout = server:accept()
if timeout == nil then if timeout == nil then
print('Initial Connection Made') print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE curstate = STATE_INITIAL_CONNECTION_MADE
ootSocket = client ootSocket = client
ootSocket:settimeout(0) ootSocket:settimeout(0)
else
print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua')
return
end end
end end
end end

View File

@@ -1,25 +0,0 @@
# apworld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
See [world api.md](world api.md) for details.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
file into the worlds folder.
## File Format
apworld files are zip archives with the case-sensitive file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
## Metadata
No metadata is specified yet.
## Extra Data
The zip can contain arbitrary files in addition what was specified above.

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).

BIN
docs/network diagram.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

View File

@@ -8,15 +8,6 @@ flowchart LR
CC[CommonClient.py] CC[CommonClient.py]
AS <-- WebSockets --> CC AS <-- WebSockets --> CC
subgraph "Starcraft 2"
SC2[Starcraft 2 Game Client]
SC2C[Starcraft2Client.py]
SC2AI[apsc2 Python Package]
SC2C <--> SC2AI <-- WebSockets --> SC2
end
CC <-- Integrated --> SC2C
%% ChecksFinder %% ChecksFinder
subgraph ChecksFinder subgraph ChecksFinder
CFC[ChecksFinderClient] CFC[ChecksFinderClient]
@@ -69,12 +60,6 @@ flowchart LR
end end
SNI <-- Various, depending on SNES device --> SMZ SNI <-- Various, depending on SNES device --> SMZ
%% Donkey Kong Country 3
subgraph Donkey Kong Country 3
DK3[SNES]
end
SNI <-- Various, depending on SNES device --> DK3
%% Native Clients or Games %% Native Clients or Games
%% Games or clients which compile to native or which the client is integrated in the game. %% Games or clients which compile to native or which the client is integrated in the game.
subgraph "Native" subgraph "Native"
@@ -87,16 +72,12 @@ flowchart LR
V6[VVVVVV] V6[VVVVVV]
MT[Meritous] MT[Meritous]
TW[The Witness] TW[The Witness]
SA2B[Sonic Adventure 2: Battle]
DS3[Dark Souls 3]
APCLIENTPP <--> SOE APCLIENTPP <--> SOE
APCLIENTPP <--> MT APCLIENTPP <--> MT
APCLIENTPP <-- The Witness Randomizer --> TW APCLIENTPP <-- The Witness Randomizer --> TW
APCLIENTPP <--> DS3
APCPP <--> SM64 APCPP <--> SM64
APCPP <--> V6 APCPP <--> V6
APCPP <--> SA2B
end end
SOE <--> SNI <-- Various, depending on SNES device --> SOESNES SOE <--> SNI <-- Various, depending on SNES device --> SOESNES
AS <-- WebSockets --> APCLIENTPP AS <-- WebSockets --> APCLIENTPP

1
docs/network diagram.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -13,18 +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) | almost-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) | |
## 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.
@@ -161,8 +152,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
All arguments for this packet are optional, only changes are sent. 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 |
| ---- | ---- | ----- | | ---- | ---- | ----- |
@@ -174,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.
@@ -212,23 +191,8 @@ Sent to clients after a client requested this message be sent to them, more info
### InvalidPacket ### InvalidPacket
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for. Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. |
##### PacketProblemType
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
| Type | Notes |
| ---- | ----- |
| cmd | `cmd` argument of the faulty packet that could not be parsed correctly. |
| arguments | Arguments of the faulty packet which were not correct. |
### Retrieved ### Retrieved
Sent to clients as a response the a [Get](#Get) package. Sent to clients as a response the a [Get](#Get) package
#### Arguments #### Arguments
| Name | Type | Notes | | Name | Type | Notes |
| ---- | ---- | ----- | | ---- | ---- | ----- |
@@ -522,7 +486,7 @@ Color options:
* green_bg * green_bg
* yellow_bg * yellow_bg
* blue_bg * blue_bg
* magenta_bg * purple_bg
* cyan_bg * cyan_bg
* white_bg * white_bg

View File

@@ -1,63 +0,0 @@
# Running From Source
If you just want to play and there is a compiled version available on the
[Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases),
use that version. These steps are for developers or platforms without compiled releases available.
## General
What you'll need:
* Python 3.8.7 or newer
* pip (Depending on platform may come included)
* A C compiler
* possibly optional, read OS-specific sections
Then run any of the starting point scripts, like Generate.py, and the included ModuleUpdater should prompt to install or update the
required modules and after pressing enter proceed to install everything automatically.
After this, you should be able to run the programs.
## Windows
Recommended steps
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
* Download and install full Visual Studio from
[Visual Studio Downloads](https://visualstudio.microsoft.com/downloads/)
or an older "Build Tools for Visual Studio" from
[Visual Studio Older Downloads](https://visualstudio.microsoft.com/vs/older-downloads/).
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details
* This step is optional. Pre-compiled modules are pinned on
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
## macOS
Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md).
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
You can get the latest Enemizer release at [Enemizer Github releases](https://github.com/Ijwu/Enemizer/releases).
It should be dropped as "EnemizerCLI" into the root folder of the project. Alternatively, you can point the Enemizer
setting in host.yaml at your Enemizer executable.
## Optional: SNI
SNI is required to use SNIClient. If not integrated into the project, it has to be started manually.
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
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

@@ -1,49 +0,0 @@
# Style Guide
## Generic
* This guide can be ignored for data files that are not to be viewed in an editor.
* 120 character per line for all source files.
* Avoid white space errors like trailing spaces.
## Python Code
* We mostly follow [PEP8](https://peps.python.org/pep-0008/). Read below to see the differences.
* 120 characters per line. PyCharm does this automatically, other editors can be configured for it.
* Strings in core code will be `"strings"`. In other words: double quote your strings.
* 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,
use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotation where possible.
## Markdown
* We almost follow [Google's styleguide](https://google.github.io/styleguide/docguide/style.html).
Read below for differences.
* For existing documents, try to follow its style or ask to completely reformat it.
* 120 characters per line.
* One space between bullet/number and text.
* No lazy numbering.
## HTML
* Indent with 2 spaces for new code.
* kebab-case for ids and classes.
## CSS
* Indent with 2 spaces for new code.
* `{` on the same line as the selector.
* No space between selector and `{`.
## JS
* Indent with 2 spaces.
* Indent `case` inside `switch ` with 2 spaces.
* Use single quotes.
* Semicolons are required after every statement.

View File

@@ -86,7 +86,7 @@ inside a World object.
Players provide customized settings for their World in the form of yamls. Players provide customized settings for their World in the form of yamls.
Those are accessible through `self.world.<option_name>[self.player]`. A dict Those are accessible through `self.world.<option_name>[self.player]`. A dict
of valid options has to be provided in `self.option_definitions`. Options are automatically of valid options has to be provided in `self.options`. Options are automatically
added to the `World` object for easy access. added to the `World` object for easy access.
### World Options ### World Options
@@ -236,7 +236,7 @@ class MyGameLocation(Location):
game: str = "My Game" game: str = "My Game"
# override constructor to automatically mark event locations as such # override constructor to automatically mark event locations as such
def __init__(self, player: int, name = "", code = None, parent = None): def __init__(self, player: int, name = '', code = None, parent = None):
super(MyGameLocation, self).__init__(player, name, code, parent) super(MyGameLocation, self).__init__(player, name, code, parent)
self.event = code is None self.event = code is None
``` ```
@@ -252,7 +252,7 @@ to describe it and a `display_name` property for display on the website and in
spoiler logs. spoiler logs.
The actual name as used in the yaml is defined in a `dict[str, Option]`, that is The actual name as used in the yaml is defined in a `dict[str, Option]`, that is
assigned to the world under `self.option_definitions`. assigned to the world under `self.options`.
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
For more see `Options.py` in AP's base directory. For more see `Options.py` in AP's base directory.
@@ -328,7 +328,7 @@ from .Options import mygame_options # import the options dict
class MyGameWorld(World): class MyGameWorld(World):
#... #...
option_definitions = mygame_options # assign the options dict to the world options = mygame_options # assign the options dict to the world
#... #...
``` ```
@@ -365,7 +365,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation
class MyGameWorld(World): class MyGameWorld(World):
"""Insert description of the world/game here.""" """Insert description of the world/game here."""
game: str = "My Game" # name of the game/world game: str = "My Game" # name of the game/world
option_definitions = mygame_options # options the player can set options = mygame_options # options the player can set
topology_present: bool = True # show path to required location checks in spoiler topology_present: bool = True # show path to required location checks in spoiler
remote_items: bool = False # True if all items come from the server remote_items: bool = False # True if all items come from the server
remote_start_inventory: bool = False # True if start inventory comes from the server remote_start_inventory: bool = False # True if start inventory comes from the server
@@ -487,14 +487,14 @@ def create_items(self) -> None:
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.world.itempool.append(self.create_item("nothing")) self.world.itempool.append(self.create_item('nothing'))
else: else:
self.world.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.world.itempool += [self.create_item("nothing") for _ in range(junk)] self.world.itempool += [self.create_item('nothing') for _ in range(junk)]
``` ```
#### create_regions #### create_regions
@@ -628,7 +628,7 @@ class MyGameLogic(LogicMixin):
def _mygame_has_key(self, world: MultiWorld, player: int): def _mygame_has_key(self, world: MultiWorld, player: int):
# Arguments above are free to choose # Arguments above are free to choose
# it may make sense to use World as argument instead of MultiWorld # it may make sense to use World as argument instead of MultiWorld
return self.has("key", player) # or whatever return self.has('key', player) # or whatever
``` ```
```python ```python
# __init__.py # __init__.py

View File

@@ -101,9 +101,7 @@ sm_options:
# Alternatively, a path to a program to open the .sfc file with # Alternatively, a path to a program to open the .sfc file with
rom_start: true 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.
# server_settings: "factorio\\data\\server-settings.json"
minecraft_options: minecraft_options:
forge_directory: "Minecraft Forge server" forge_directory: "Minecraft Forge server"
max_heap_size: "2G" max_heap_size: "2G"
@@ -129,12 +127,3 @@ smz3_options:
# 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
rom_start: true rom_start: true
dkc3_options:
# 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

@@ -54,7 +54,6 @@ Name: "custom"; Description: "Custom installation"; Flags: iscustom
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
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/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
@@ -63,7 +62,6 @@ 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/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
@@ -78,7 +76,6 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
[Files] [Files]
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: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: "{#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
@@ -132,7 +129,6 @@ Type: dirifempty; Name: "{app}"
[InstallDelete] [InstallDelete]
Type: files; Name: "{app}\ArchipelagoLttPClient.exe" Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
[Registry] [Registry]
@@ -146,11 +142,6 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; 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: ".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
@@ -214,9 +205,6 @@ var LttPROMFilePage: TInputFileWizardPage;
var smrom: string; var smrom: string;
var SMRomFilePage: TInputFileWizardPage; var SMRomFilePage: TInputFileWizardPage;
var dkc3rom: string;
var DKC3RomFilePage: TInputFileWizardPage;
var soerom: string; var soerom: string;
var SoERomFilePage: TInputFileWizardPage; var SoERomFilePage: TInputFileWizardPage;
@@ -306,8 +294,6 @@ begin
Result := not (LttPROMFilePage.Values[0] = '') Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '') Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
Result := not (DKC3ROMFilePage.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
@@ -348,22 +334,6 @@ begin
Result := ''; Result := '';
end; end;
function GetDKC3ROMPath(Param: string): string;
begin
if Length(dkc3rom) > 0 then
Result := dkc3rom
else if Assigned(DKC3RomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(DKC3ROMFilePage.Values[0]), '120abf304f0c40fe059f6a192ed4f947')
if R <> 0 then
MsgBox('Donkey Kong Country 3 ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := DKC3ROMFilePage.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
@@ -408,10 +378,6 @@ begin
if Length(smrom) = 0 then if Length(smrom) = 0 then
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc'); SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
dkc3rom := CheckRom('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc', '120abf304f0c40fe059f6a192ed4f947');
if Length(dkc3rom) = 0 then
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).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');
@@ -425,8 +391,6 @@ begin
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp')); Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
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
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
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

View File

@@ -175,15 +175,12 @@ A Link to the Past:
retro_caves: retro_caves:
on: 0 # Zelda-1 like mode. There are randomly placed take-any caves that contain one Sword and choices of Heart Container/Blue Potion. on: 0 # Zelda-1 like mode. There are randomly placed take-any caves that contain one Sword and choices of Heart Container/Blue Potion.
off: 50 off: 50
hints: # On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints. hints: # Vendors: King Zora and Bottle Merchant say what they're selling.
# On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints.
'on': 50 'on': 50
vendors: 0
'off': 0 'off': 0
full: 0 full: 0
scams: # If on, these Merchants will no longer tell you what they're selling.
'off': 50
'king_zora': 0
'bottle_merchant': 0
'all': 0
swordless: swordless:
on: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change on: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
off: 1 off: 1
@@ -276,7 +273,6 @@ A Link to the Past:
p: 0 # Randomize the prices of the items in shop inventories p: 0 # Randomize the prices of the items in shop inventories
u: 0 # Shuffle capacity upgrades into the item pool (and allow them to traverse the multiworld) u: 0 # Shuffle capacity upgrades into the item pool (and allow them to traverse the multiworld)
w: 0 # Consider witch's hut like any other shop and shuffle/randomize it too w: 0 # Consider witch's hut like any other shop and shuffle/randomize it too
P: 0 # Prices of the items in shop inventories cost hearts, arrow, or bombs instead of rupees
ip: 0 # Shuffle inventories and randomize prices ip: 0 # Shuffle inventories and randomize prices
fpu: 0 # Generate new inventories, randomize prices and shuffle capacity upgrades into item pool fpu: 0 # Generate new inventories, randomize prices and shuffle capacity upgrades into item pool
uip: 0 # Shuffle inventories, randomize prices and shuffle capacity upgrades into the item pool uip: 0 # Shuffle inventories, randomize prices and shuffle capacity upgrades into the item pool

View File

@@ -1,8 +1,8 @@
colorama>=0.4.5 colorama>=0.4.4
websockets>=10.3 websockets>=10.3
PyYAML>=6.0 PyYAML>=6.0
jellyfish>=0.9.0 jellyfish>=0.9.0
jinja2>=3.1.2 jinja2>=3.1.2
schema>=0.7.5 schema>=0.7.4
kivy>=2.1.0 kivy>=2.1.0
bsdiff4>=1.2.2 bsdiff4>=1.2.2

View File

@@ -16,7 +16,7 @@ class TestDungeon(unittest.TestCase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -49,7 +49,8 @@ class PlayerDefinition(object):
region_name = "player" + str(self.id) + region_tag region_name = "player" + str(self.id) + region_tag
region = Region("player" + str(self.id) + region_tag, RegionType.Generic, region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
"Region Hint", self.id, self.world) "Region Hint", self.id, self.world)
self.locations += generate_locations(size, self.id, None, region, region_tag) self.locations += generate_locations(size,
self.id, None, region, region_tag)
entrance = Entrance(self.id, region_name + "_entrance", parent) entrance = Entrance(self.id, region_name + "_entrance", parent)
parent.exits.append(entrance) parent.exits.append(entrance)

View File

@@ -52,13 +52,3 @@ class TestIDs(unittest.TestCase):
else: else:
for location_id in world_type.location_id_to_name: for location_id in world_type.location_id_to_name:
self.assertGreater(location_id, 0) self.assertGreater(location_id, 0)
def testDuplicateItemIDs(self):
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id))
def testDuplicateLocationIDs(self):
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))

View File

@@ -1,6 +1,5 @@
import unittest import unittest
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import setup_default_world
class TestBase(unittest.TestCase): class TestBase(unittest.TestCase):
@@ -30,17 +29,3 @@ class TestBase(unittest.TestCase):
with self.subTest(group_name, group_name=group_name): with self.subTest(group_name, group_name=group_name):
for item in items: for item in items:
self.assertIn(item, world_type.item_name_to_id) self.assertIn(item, world_type.item_name_to_id)
def testItemCountGreaterEqualLocations(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name in {"Final Fantasy"}:
continue
with self.subTest("Game", game=game_name):
world = setup_default_world(world_type)
location_count = sum(0 if location.event or location.item else 1 for location in world.get_locations())
self.assertGreaterEqual(
len(world.itempool),
location_count,
f"{game_name} Item count MUST meet or exceede the number of locations",
)

View File

@@ -12,7 +12,7 @@ def setup_default_world(world_type) -> MultiWorld:
world.player_name = {1: "Tester"} world.player_name = {1: "Tester"}
world.set_seed() world.set_seed()
args = Namespace() args = Namespace()
for name, option in world_type.option_definitions.items(): for name, option in world_type.options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
world.set_options(args) world.set_options(args)
world.set_default_common_options() world.set_default_common_options()

View File

@@ -16,7 +16,7 @@ class TestInverted(TestBase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -17,7 +17,7 @@ class TestInvertedBombRules(unittest.TestCase):
self.world = MultiWorld(1) self.world = MultiWorld(1)
self.world.mode[1] = "inverted" self.world.mode[1] = "inverted"
args = Namespace args = Namespace
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -17,7 +17,7 @@ class TestInvertedMinor(TestBase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -18,7 +18,7 @@ class TestInvertedOWG(TestBase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -17,7 +17,7 @@ class TestMinor(TestBase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -18,7 +18,7 @@ class TestVanillaOWG(TestBase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -1,44 +0,0 @@
# Tests for SI prefix in Utils.py
import unittest
from decimal import Decimal
from Utils import format_SI_prefix
class TestGenerateMain(unittest.TestCase):
"""This tests SI prefix formatting in Utils.py"""
def assertEqual(self, first, second, msg=None):
# we strip spaces everywhere because that is an undefined implementation detail
super().assertEqual(first.replace(" ", ""), second.replace(" ", ""), msg)
def test_rounding(self):
# we don't care if float(999.995) would fail due to error in precision
self.assertEqual(format_SI_prefix(999.999), "1.00k")
self.assertEqual(format_SI_prefix(1000.001), "1.00k")
self.assertEqual(format_SI_prefix(Decimal("999.995")), "1.00k")
self.assertEqual(format_SI_prefix(Decimal("1000.004")), "1.00k")
def test_letters(self):
self.assertEqual(format_SI_prefix(0e0), "0.00")
self.assertEqual(format_SI_prefix(1e3), "1.00k")
self.assertEqual(format_SI_prefix(2e6), "2.00M")
self.assertEqual(format_SI_prefix(3e9), "3.00G")
self.assertEqual(format_SI_prefix(4e12), "4.00T")
self.assertEqual(format_SI_prefix(5e15), "5.00P")
self.assertEqual(format_SI_prefix(6e18), "6.00E")
self.assertEqual(format_SI_prefix(7e21), "7.00Z")
self.assertEqual(format_SI_prefix(8e24), "8.00Y")
def test_multiple_letters(self):
self.assertEqual(format_SI_prefix(9e27), "9.00kY")
def test_custom_power(self):
self.assertEqual(format_SI_prefix(1023.99, 1024), "1023.99")
self.assertEqual(format_SI_prefix(1034.24, 1024), "1.01k")
def test_custom_labels(self):
labels = ("E", "da", "h", "k")
self.assertEqual(format_SI_prefix(1, 10, labels), "1.00E")
self.assertEqual(format_SI_prefix(10, 10, labels), "1.00da")
self.assertEqual(format_SI_prefix(100, 10, labels), "1.00h")
self.assertEqual(format_SI_prefix(1000, 10, labels), "1.00k")

View File

View File

@@ -16,7 +16,7 @@ class TestVanilla(TestBase):
def setUp(self): def setUp(self):
self.world = MultiWorld(1) self.world = MultiWorld(1)
args = Namespace() args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
setattr(args, name, {1: option.from_any(option.default)}) setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args) self.world.set_options(args)
self.world.set_default_common_options() self.world.set_default_common_options()

View File

@@ -1,23 +0,0 @@
"""Tests for successful generation of WebHost cached files. Can catch some other deeper errors."""
import os
import unittest
import WebHost
class TestFileGeneration(unittest.TestCase):
def setUp(self) -> None:
self.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
# should not create the folder *here*
self.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib")
def testOptions(self):
WebHost.create_options_files()
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "configs")))
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "configs")))
def testTutorial(self):
WebHost.create_ordered_tutorials_file()
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json")))
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json")))

View File

@@ -2,14 +2,10 @@ from __future__ import annotations
import logging import logging
import sys import sys
import pathlib from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, TYPE_CHECKING
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
from Options import Option from Options import Option
from BaseClasses import CollectionState
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial
class AutoWorldRegister(type): class AutoWorldRegister(type):
@@ -27,8 +23,7 @@ class AutoWorldRegister(type):
# build rest # build rest
dct["item_names"] = frozenset(dct["item_name_to_id"]) dct["item_names"] = frozenset(dct["item_name_to_id"])
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set dct["item_name_groups"] = dct.get("item_name_groups", {})
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"] dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"]) dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {}))) dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
@@ -46,18 +41,14 @@ class AutoWorldRegister(type):
# construct class # construct class
new_class = super().__new__(mcs, name, bases, dct) new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct: if "game" in dct:
if dct["game"] in AutoWorldRegister.world_types:
raise RuntimeError(f"""Game {dct["game"]} already registered.""")
AutoWorldRegister.world_types[dct["game"]] = new_class AutoWorldRegister.world_types[dct["game"]] = new_class
new_class.__file__ = sys.modules[new_class.__module__].__file__ new_class.__file__ = sys.modules[new_class.__module__].__file__
if ".apworld" in new_class.__file__:
new_class.zip_path = pathlib.Path(new_class.__file__).parents[1]
return new_class return new_class
class AutoLogicRegister(type): class AutoLogicRegister(type):
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister: def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister:
new_class = super().__new__(mcs, name, bases, dct) new_class = super().__new__(cls, name, bases, dct)
function: Callable[..., Any] function: Callable[..., Any]
for item_name, function in dct.items(): for item_name, function in dct.items():
if item_name == "copy_mixin": if item_name == "copy_mixin":
@@ -71,12 +62,12 @@ class AutoLogicRegister(type):
return new_class return new_class
def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any) -> Any: def call_single(world: MultiWorld, method_name: str, player: int, *args: Any) -> Any:
method = getattr(world.worlds[player], method_name) method = getattr(world.worlds[player], method_name)
return method(*args) return method(*args)
def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None: def call_all(world: MultiWorld, method_name: str, *args: Any) -> None:
world_types: Set[AutoWorldRegister] = set() world_types: Set[AutoWorldRegister] = set()
for player in world.player_ids: for player in world.player_ids:
world_types.add(world.worlds[player].__class__) world_types.add(world.worlds[player].__class__)
@@ -88,7 +79,7 @@ def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None:
stage_callable(world, *args) stage_callable(world, *args)
def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None: def call_stage(world: MultiWorld, method_name: str, *args: Any) -> None:
world_types = {world.worlds[player].__class__ for player in world.player_ids} world_types = {world.worlds[player].__class__ for player in world.player_ids}
for world_type in world_types: for world_type in world_types:
stage_callable = getattr(world_type, f"stage_{method_name}", None) stage_callable = getattr(world_type, f"stage_{method_name}", None)
@@ -98,29 +89,29 @@ def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None:
class WebWorld: class WebWorld:
"""Webhost integration""" """Webhost integration"""
# display a settings page. Can be a link to an out-of-ap settings tool too.
settings_page: Union[bool, str] = True settings_page: Union[bool, str] = True
"""display a settings page. Can be a link to a specific page or external tool."""
# docs folder will be scanned for game info pages using this list in the format '{language}_{game_name}.md'
game_info_languages: List[str] = ['en'] game_info_languages: List[str] = ['en']
"""docs folder will be scanned for game info pages using this list in the format '{language}_{game_name}.md'"""
tutorials: List["Tutorial"] # docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial
"""docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide.""" # class is to be used for one guide.
tutorials: List[Tutorial]
# Choose a theme for your /game/* pages
# Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone
theme = "grass" theme = "grass"
"""Choose a theme for you /game/* pages.
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""
# display a link to a bug report page, most likely a link to a GitHub issue page.
bug_report_page: Optional[str] bug_report_page: Optional[str]
"""display a link to a bug report page, most likely a link to a GitHub issue page."""
class World(metaclass=AutoWorldRegister): class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
A Game should have its own subclass of World in which it defines the required data structures.""" A Game should have its own subclass of World in which it defines the required data structures."""
option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping options: Dict[str, Option[Any]] = {} # link your Options mapping
game: str # name the game game: str # name the game
topology_present: bool = False # indicate if world type has any meaningful layout/pathing topology_present: bool = False # indicate if world type has any meaningful layout/pathing
@@ -168,11 +159,8 @@ class World(metaclass=AutoWorldRegister):
# Hide World Type from various views. Does not remove functionality. # Hide World Type from various views. Does not remove functionality.
hidden: bool = False hidden: bool = False
# see WebWorld for options
web: WebWorld = WebWorld()
# autoset on creation: # autoset on creation:
world: "MultiWorld" world: MultiWorld
player: int player: int
# automatically generated # automatically generated
@@ -182,10 +170,9 @@ class World(metaclass=AutoWorldRegister):
item_names: Set[str] # set of all potential item names item_names: Set[str] # set of all potential item names
location_names: Set[str] # set of all potential location names location_names: Set[str] # set of all potential location names
zip_path: Optional[pathlib.Path] = None # If loaded from a .apworld, this is the Path to it. web: WebWorld = WebWorld()
__file__: str # path it was loaded from
def __init__(self, world: "MultiWorld", player: int): def __init__(self, world: MultiWorld, player: int):
self.world = world self.world = world
self.player = player self.player = player
@@ -220,12 +207,12 @@ class World(metaclass=AutoWorldRegister):
@classmethod @classmethod
def fill_hook(cls, def fill_hook(cls,
progitempool: List["Item"], progitempool: List[Item],
nonexcludeditempool: List["Item"], nonexcludeditempool: List[Item],
localrestitempool: Dict[int, List["Item"]], localrestitempool: Dict[int, List[Item]],
nonlocalrestitempool: Dict[int, List["Item"]], nonlocalrestitempool: Dict[int, List[Item]],
restitempool: List["Item"], restitempool: List[Item],
fill_locations: List["Location"]) -> None: fill_locations: List[Location]) -> None:
"""Special method that gets called as part of distribute_items_restrictive (main fill). """Special method that gets called as part of distribute_items_restrictive (main fill).
This gets called once per present world type.""" This gets called once per present world type."""
pass pass
@@ -263,7 +250,7 @@ class World(metaclass=AutoWorldRegister):
# end of ordered Main.py calls # end of ordered Main.py calls
def create_item(self, name: str) -> "Item": def create_item(self, name: str) -> Item:
"""Create an item for this world type and player. """Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer""" Warning: this may be called with self.world = None, for example by MultiServer"""
raise NotImplementedError raise NotImplementedError
@@ -274,7 +261,7 @@ class World(metaclass=AutoWorldRegister):
return self.world.random.choice(tuple(self.item_name_to_id.keys())) return self.world.random.choice(tuple(self.item_name_to_id.keys()))
# decent place to implement progressive items, in most cases can stay as-is # decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]: def collect_item(self, state: CollectionState, item: Item, remove: bool = False) -> Optional[str]:
"""Collect an item name into state. For speed reasons items that aren't logically useful get skipped. """Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
Collect None to skip item. Collect None to skip item.
:param state: CollectionState to collect into :param state: CollectionState to collect into
@@ -285,18 +272,18 @@ class World(metaclass=AutoWorldRegister):
return None return None
# called to create all_state, return Items that are created during pre_fill # called to create all_state, return Items that are created during pre_fill
def get_pre_fill_items(self) -> List["Item"]: def get_pre_fill_items(self) -> List[Item]:
return [] return []
# following methods should not need to be overridden. # following methods should not need to be overridden.
def collect(self, state: "CollectionState", item: "Item") -> bool: def collect(self, state: CollectionState, item: Item) -> bool:
name = self.collect_item(state, item) name = self.collect_item(state, item)
if name: if name:
state.prog_items[name, self.player] += 1 state.prog_items[name, self.player] += 1
return True return True
return False return False
def remove(self, state: "CollectionState", item: "Item") -> bool: def remove(self, state: CollectionState, item: Item) -> bool:
name = self.collect_item(state, item, True) name = self.collect_item(state, item, True)
if name: if name:
state.prog_items[name, self.player] -= 1 state.prog_items[name, self.player] -= 1
@@ -305,7 +292,7 @@ class World(metaclass=AutoWorldRegister):
return True return True
return False return False
def create_filler(self) -> "Item": def create_filler(self) -> Item:
return self.create_item(self.get_filler_item_name()) return self.create_item(self.get_filler_item_name())

View File

@@ -1,56 +1,29 @@
import importlib import importlib
import zipimport
import os import os
import typing
folder = os.path.dirname(__file__) __all__ = {"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name",
__all__ = { "network_data_package",
"lookup_any_item_id_to_name", "AutoWorldRegister"}
"lookup_any_location_id_to_name",
"network_data_package",
"AutoWorldRegister",
"world_sources",
"folder",
}
if typing.TYPE_CHECKING:
from .AutoWorld import World
class WorldSource(typing.NamedTuple):
path: str # typically relative path from this module
is_zip: bool = False
# find potential world containers, currently folders and zip-importable .apworld's
world_sources: typing.List[WorldSource] = []
file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly
for file in os.scandir(folder):
if not file.name.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders
if file.is_dir():
world_sources.append(WorldSource(file.name))
elif file.is_file() and file.name.endswith(".apworld"):
world_sources.append(WorldSource(file.name, is_zip=True))
# import all submodules to trigger AutoWorldRegister # import all submodules to trigger AutoWorldRegister
world_sources.sort() world_folders = []
for world_source in world_sources: for file in os.scandir(os.path.dirname(__file__)):
if world_source.is_zip: if file.is_dir():
importer = zipimport.zipimporter(os.path.join(folder, world_source.path)) world_folders.append(file.name)
importer.load_module(world_source.path.split(".", 1)[0]) world_folders.sort()
else: for world in world_folders:
importlib.import_module(f".{world_source.path}", "worlds") if not world.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders
importlib.import_module(f".{world}", "worlds")
from .AutoWorld import AutoWorldRegister
lookup_any_item_id_to_name = {} lookup_any_item_id_to_name = {}
lookup_any_location_id_to_name = {} lookup_any_location_id_to_name = {}
games = {} games = {}
from .AutoWorld import AutoWorldRegister
for world_name, world in AutoWorldRegister.world_types.items(): for world_name, world in AutoWorldRegister.world_types.items():
games[world_name] = { games[world_name] = {
"item_name_to_id": world.item_name_to_id, "item_name_to_id" : world.item_name_to_id,
"location_name_to_id": world.location_name_to_id, "location_name_to_id": world.location_name_to_id,
"version": world.data_version, "version": world.data_version,
# seems clients don't actually want this. Keeping it here in case someone changes their mind. # seems clients don't actually want this. Keeping it here in case someone changes their mind.
@@ -68,6 +41,5 @@ network_data_package = {
if any(not world.data_version for world in AutoWorldRegister.world_types.values()): if any(not world.data_version for world in AutoWorldRegister.world_types.values()):
network_data_package["version"] = 0 network_data_package["version"] = 0
import logging import logging
logging.warning(f"Datapackage is in custom mode. Custom Worlds: " logging.warning(f"Datapackage is in custom mode. Custom Worlds: "
f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}") f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}")

View File

@@ -15,6 +15,7 @@ def create_dungeons(world, player):
dungeon_items, player) dungeon_items, player)
for item in dungeon.all_items: for item in dungeon.all_items:
item.dungeon = dungeon item.dungeon = dungeon
item.world = world
dungeon.boss = BossFactory(default_boss, player) if default_boss else None dungeon.boss = BossFactory(default_boss, player) if default_boss else None
for region in dungeon.regions: for region in dungeon.regions:
world.get_region(region, player).dungeon = dungeon world.get_region(region, player).dungeon = dungeon

View File

@@ -212,7 +212,9 @@ def parse_arguments(argv, no_defaults=False):
Alternatively, can be a ALttP Rom patched with a Link Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted. sprite that will be extracted.
''') ''')
parser.add_argument('--gui', help='Launch the GUI', action='store_true')
parser.add_argument('--enemizercli', default=defval('EnemizerCLI/EnemizerCLI.Core'))
parser.add_argument('--shufflebosses', default=defval('none'), choices=['none', 'basic', 'normal', 'chaos', parser.add_argument('--shufflebosses', default=defval('none'), choices=['none', 'basic', 'normal', 'chaos',
"singularity"]) "singularity"])

View File

@@ -51,11 +51,6 @@ class ItemData(typing.NamedTuple):
flute_boy_credit: typing.Optional[str] flute_boy_credit: typing.Optional[str]
hint_text: typing.Optional[str] hint_text: typing.Optional[str]
def as_init_dict(self) -> typing.Dict[str, typing.Any]:
return {key: getattr(self, key) for key in
('classification', 'type', 'item_code', 'pedestal_hint', 'hint_text')}
# Format: Name: (Advancement, Type, ItemCode, Pedestal Hint Text, Pedestal Credit Text, Sick Kid Credit Text, Zora Credit Text, Witch Credit Text, Flute Boy Credit Text, Hint Text) # Format: Name: (Advancement, Type, ItemCode, Pedestal Hint Text, Pedestal Credit Text, Sick Kid Credit Text, Zora Credit Text, Witch Credit Text, Flute Boy Credit Text, Hint Text)
item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'), item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'),
'Progressive Bow': ItemData(IC.progression, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'), 'Progressive Bow': ItemData(IC.progression, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
@@ -223,7 +218,7 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\
'Open Floodgate': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None), 'Open Floodgate': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
} }
item_init_table = {name: data.as_init_dict() for name, data in item_table.items()} as_dict_item_table = {name: data._asdict() for name, data in item_table.items()}
progression_mapping = { progression_mapping = {
"Golden Sword": ("Progressive Sword", 4), "Golden Sword": ("Progressive Sword", 4),

View File

@@ -1,6 +1,5 @@
import typing import typing
from BaseClasses import MultiWorld
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink
@@ -28,35 +27,6 @@ class Goal(Choice):
option_hand_in = 2 option_hand_in = 2
class OpenPyramid(Choice):
"""Determines whether the hole at the top of pyramid is open.
Goal will open the pyramid if the goal requires you to kill Ganon, without needing to kill Agahnim 2.
Auto is the same as goal except if Ganon's dropdown is in another location, the hole will be closed."""
display_name = "Open Pyramid Hole"
option_closed = 0
option_open = 1
option_goal = 2
option_auto = 3
default = option_goal
alias_true = option_open
alias_false = option_closed
alias_yes = option_open
alias_no = option_closed
def to_bool(self, world: MultiWorld, player: int) -> bool:
if self.value == self.option_goal:
return world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'}
elif self.value == self.option_auto:
return world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'} \
and (world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed'} or not
world.shuffle_ganon)
elif self.value == self.option_open:
return True
else:
return False
class DungeonItem(Choice): class DungeonItem(Choice):
value: int value: int
option_original_dungeon = 0 option_original_dungeon = 0
@@ -215,11 +185,9 @@ class Scams(Choice):
option_all = 3 option_all = 3
alias_false = 0 alias_false = 0
@property
def gives_king_zora_hint(self): def gives_king_zora_hint(self):
return self.value in {0, 2} return self.value in {0, 2}
@property
def gives_bottle_merchant_hint(self): def gives_bottle_merchant_hint(self):
return self.value in {0, 1} return self.value in {0, 1}
@@ -282,8 +250,8 @@ class ShieldPalette(Palette):
display_name = "Shield Palette" display_name = "Shield Palette"
# class LinkPalette(Palette): class LinkPalette(Palette):
# display_name = "Link Palette" display_name = "Link Palette"
class HeartBeep(Choice): class HeartBeep(Choice):
@@ -363,7 +331,6 @@ class AllowCollect(Toggle):
alttp_options: typing.Dict[str, type(Option)] = { alttp_options: typing.Dict[str, type(Option)] = {
"crystals_needed_for_gt": CrystalsTower, "crystals_needed_for_gt": CrystalsTower,
"crystals_needed_for_ganon": CrystalsGanon, "crystals_needed_for_ganon": CrystalsGanon,
"open_pyramid": OpenPyramid,
"bigkey_shuffle": bigkey_shuffle, "bigkey_shuffle": bigkey_shuffle,
"smallkey_shuffle": smallkey_shuffle, "smallkey_shuffle": smallkey_shuffle,
"compass_shuffle": compass_shuffle, "compass_shuffle": compass_shuffle,
@@ -387,7 +354,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
"hud_palettes": HUDPalette, "hud_palettes": HUDPalette,
"sword_palettes": SwordPalette, "sword_palettes": SwordPalette,
"shield_palettes": ShieldPalette, "shield_palettes": ShieldPalette,
# "link_palettes": LinkPalette, "link_palettes": LinkPalette,
"heartbeep": HeartBeep, "heartbeep": HeartBeep,
"heartcolor": HeartColor, "heartcolor": HeartColor,
"quickswap": QuickSwap, "quickswap": QuickSwap,

View File

@@ -34,7 +34,7 @@ from worlds.alttp.Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts
DeathMountain_texts, \ DeathMountain_texts, \
LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, \ LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, \
SickKid_texts, FluteBoy_texts, Zora_texts, MagicShop_texts, Sahasrahla_names SickKid_texts, FluteBoy_texts, Zora_texts, MagicShop_texts, Sahasrahla_names
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, parse_yaml from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen
from worlds.alttp.Items import ItemFactory, item_table, item_name_groups, progression_items from worlds.alttp.Items import ItemFactory, item_table, item_name_groups, progression_items
from worlds.alttp.EntranceShuffle import door_addresses from worlds.alttp.EntranceShuffle import door_addresses
from worlds.alttp.Options import smallkey_shuffle from worlds.alttp.Options import smallkey_shuffle
@@ -551,22 +551,18 @@ class Sprite():
Sprite.base_data = Sprite.sprite + Sprite.palette + Sprite.glove_palette Sprite.base_data = Sprite.sprite + Sprite.palette + Sprite.glove_palette
def from_ap_sprite(self, filedata): def from_ap_sprite(self, filedata):
# noinspection PyBroadException filedata = filedata.decode("utf-8-sig")
try: import yaml
obj = parse_yaml(filedata.decode("utf-8-sig")) obj = yaml.safe_load(filedata)
if obj["min_format_version"] > 1: if obj["min_format_version"] > 1:
raise Exception("Sprite file requires an updated reader.") raise Exception("Sprite file requires an updated reader.")
self.author_name = obj["author"] self.author_name = obj["author"]
self.name = obj["name"] self.name = obj["name"]
if obj["data"]: # skip patching for vanilla content if obj["data"]: # skip patching for vanilla content
data = bsdiff4.patch(Sprite.base_data, obj["data"]) data = bsdiff4.patch(Sprite.base_data, obj["data"])
self.sprite = data[:self.sprite_size] self.sprite = data[:self.sprite_size]
self.palette = data[self.sprite_size:self.palette_size] self.palette = data[self.sprite_size:self.palette_size]
self.glove_palette = data[self.sprite_size + self.palette_size:] self.glove_palette = data[self.sprite_size + self.palette_size:]
except Exception:
logger = logging.getLogger("apsprite")
logger.exception("Error parsing apsprite file")
self.valid = False
@property @property
def author_game_display(self) -> str: def author_game_display(self) -> str:
@@ -663,7 +659,7 @@ class Sprite():
@staticmethod @staticmethod
def parse_zspr(filedata, expected_kind): def parse_zspr(filedata, expected_kind):
logger = logging.getLogger("ZSPR") logger = logging.getLogger('ZSPR')
headerstr = "<4xBHHIHIHH6x" headerstr = "<4xBHHIHIHH6x"
headersize = struct.calcsize(headerstr) headersize = struct.calcsize(headerstr)
if len(filedata) < headersize: if len(filedata) < headersize:
@@ -671,7 +667,7 @@ class Sprite():
version, csum, icsum, sprite_offset, sprite_size, palette_offset, palette_size, kind = struct.unpack_from( version, csum, icsum, sprite_offset, sprite_size, palette_offset, palette_size, kind = struct.unpack_from(
headerstr, filedata) headerstr, filedata)
if version not in [1]: if version not in [1]:
logger.error("Error parsing ZSPR file: Version %g not supported", version) logger.error('Error parsing ZSPR file: Version %g not supported', version)
return None return None
if kind != expected_kind: if kind != expected_kind:
return None return None
@@ -680,42 +676,36 @@ class Sprite():
stream.seek(headersize) stream.seek(headersize)
def read_utf16le(stream): def read_utf16le(stream):
"""Decodes a null-terminated UTF-16_LE string of unknown size from a stream""" "Decodes a null-terminated UTF-16_LE string of unknown size from a stream"
raw = bytearray() raw = bytearray()
while True: while True:
char = stream.read(2) char = stream.read(2)
if char in [b"", b"\x00\x00"]: if char in [b'', b'\x00\x00']:
break break
raw += char raw += char
return raw.decode("utf-16_le") return raw.decode('utf-16_le')
# noinspection PyBroadException sprite_name = read_utf16le(stream)
try: author_name = read_utf16le(stream)
sprite_name = read_utf16le(stream) author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
author_name = read_utf16le(stream)
author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
# Ignoring the Author Rom name for the time being. # Ignoring the Author Rom name for the time being.
real_csum = sum(filedata) % 0x10000 real_csum = sum(filedata) % 0x10000
if real_csum != csum or real_csum ^ 0xFFFF != icsum: if real_csum != csum or real_csum ^ 0xFFFF != icsum:
logger.warning("ZSPR file has incorrect checksum. It may be corrupted.") logger.warning('ZSPR file has incorrect checksum. It may be corrupted.')
sprite = filedata[sprite_offset:sprite_offset + sprite_size] sprite = filedata[sprite_offset:sprite_offset + sprite_size]
palette = filedata[palette_offset:palette_offset + palette_size] palette = filedata[palette_offset:palette_offset + palette_size]
if len(sprite) != sprite_size or len(palette) != palette_size: if len(sprite) != sprite_size or len(palette) != palette_size:
logger.error("Error parsing ZSPR file: Unexpected end of file") logger.error('Error parsing ZSPR file: Unexpected end of file')
return None
return sprite, palette, sprite_name, author_name, author_credits_name
except Exception:
logger.exception("Error parsing ZSPR file")
return None return None
return (sprite, palette, sprite_name, author_name, author_credits_name)
def decode_palette(self): def decode_palette(self):
"""Returns the palettes as an array of arrays of 15 colors""" "Returns the palettes as an array of arrays of 15 colors"
def array_chunk(arr, size): def array_chunk(arr, size):
return list(zip(*[iter(arr)] * size)) return list(zip(*[iter(arr)] * size))
@@ -1257,7 +1247,7 @@ def patch_rom(world, rom, player, enemized):
rom.write_bytes(0x50563, [0x3F, 0x14]) # disable below ganon chest rom.write_bytes(0x50563, [0x3F, 0x14]) # disable below ganon chest
rom.write_byte(0x50599, 0x00) # disable below ganon chest rom.write_byte(0x50599, 0x00) # disable below ganon chest
rom.write_bytes(0xE9A5, [0x7E, 0x00, 0x24]) # disable below ganon chest rom.write_bytes(0xE9A5, [0x7E, 0x00, 0x24]) # disable below ganon chest
rom.write_byte(0x18008B, 0x01 if world.open_pyramid[player].to_bool(world, player) else 0x00) # pre-open Pyramid Hole rom.write_byte(0x18008B, 0x01 if world.open_pyramid[player] else 0x00) # pre-open Pyramid Hole
rom.write_byte(0x18008C, 0x01 if world.crystals_needed_for_gt[ rom.write_byte(0x18008C, 0x01 if world.crystals_needed_for_gt[
player] == 0 else 0x00) # GT pre-opened if crystal requirement is 0 player] == 0 else 0x00) # GT pre-opened if crystal requirement is 0
rom.write_byte(0xF5D73, 0xF0) # bees are catchable rom.write_byte(0xF5D73, 0xF0) # bees are catchable
@@ -2101,9 +2091,7 @@ def write_string_to_rom(rom, target, string):
def write_strings(rom, world, player): def write_strings(rom, world, player):
from . import ALTTPWorld
local_random = world.slot_seeds[player] local_random = world.slot_seeds[player]
w: ALTTPWorld = world.worlds[player]
tt = TextTable() tt = TextTable()
tt.removeUnwantedText() tt.removeUnwantedText()
@@ -2432,8 +2420,7 @@ def write_strings(rom, world, player):
pedestal_text = 'Some Hot Air' if pedestalitem is None else hint_text(pedestalitem, pedestal_text = 'Some Hot Air' if pedestalitem is None else hint_text(pedestalitem,
True) if pedestalitem.pedestal_hint_text is not None else 'Unknown Item' True) if pedestalitem.pedestal_hint_text is not None else 'Unknown Item'
tt['mastersword_pedestal_translated'] = pedestal_text tt['mastersword_pedestal_translated'] = pedestal_text
pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else \ pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else pedestalitem.pedestal_credit_text if pedestalitem.pedestal_credit_text is not None else 'and the Unknown Item'
w.pedestal_credit_texts.get(pedestalitem.code, 'and the Unknown Item')
etheritem = world.get_location('Ether Tablet', player).item etheritem = world.get_location('Ether Tablet', player).item
ether_text = 'Some Hot Air' if etheritem is None else hint_text(etheritem, ether_text = 'Some Hot Air' if etheritem is None else hint_text(etheritem,
@@ -2461,24 +2448,20 @@ def write_strings(rom, world, player):
credits = Credits() credits = Credits()
sickkiditem = world.get_location('Sick Kid', player).item sickkiditem = world.get_location('Sick Kid', player).item
sickkiditem_text = local_random.choice(SickKid_texts) \ sickkiditem_text = local_random.choice(
if sickkiditem is None or sickkiditem.code not in w.sickkid_credit_texts \ SickKid_texts) if sickkiditem is None or sickkiditem.sickkid_credit_text is None else sickkiditem.sickkid_credit_text
else w.sickkid_credit_texts[sickkiditem.code]
zoraitem = world.get_location('King Zora', player).item zoraitem = world.get_location('King Zora', player).item
zoraitem_text = local_random.choice(Zora_texts) \ zoraitem_text = local_random.choice(
if zoraitem is None or zoraitem.code not in w.zora_credit_texts \ Zora_texts) if zoraitem is None or zoraitem.zora_credit_text is None else zoraitem.zora_credit_text
else w.zora_credit_texts[zoraitem.code]
magicshopitem = world.get_location('Potion Shop', player).item magicshopitem = world.get_location('Potion Shop', player).item
magicshopitem_text = local_random.choice(MagicShop_texts) \ magicshopitem_text = local_random.choice(
if magicshopitem is None or magicshopitem.code not in w.magicshop_credit_texts \ MagicShop_texts) if magicshopitem is None or magicshopitem.magicshop_credit_text is None else magicshopitem.magicshop_credit_text
else w.magicshop_credit_texts[magicshopitem.code]
fluteboyitem = world.get_location('Flute Spot', player).item fluteboyitem = world.get_location('Flute Spot', player).item
fluteboyitem_text = local_random.choice(FluteBoy_texts) \ fluteboyitem_text = local_random.choice(
if fluteboyitem is None or fluteboyitem.code not in w.fluteboy_credit_texts \ FluteBoy_texts) if fluteboyitem is None or fluteboyitem.fluteboy_credit_text is None else fluteboyitem.fluteboy_credit_text
else w.fluteboy_credit_texts[fluteboyitem.code]
credits.update_credits_line('castle', 0, local_random.choice(KingsReturn_texts)) credits.update_credits_line('castle', 0, local_random.choice(KingsReturn_texts))
credits.update_credits_line('sanctuary', 0, local_random.choice(Sanctuary_texts)) credits.update_credits_line('sanctuary', 0, local_random.choice(Sanctuary_texts))

View File

@@ -935,6 +935,7 @@ def set_trock_key_rules(world, player):
else: else:
# A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works # A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works
item = ItemFactory('Small Key (Turtle Rock)', player) item = ItemFactory('Small Key (Turtle Rock)', player)
item.world = world
location = world.get_location('Turtle Rock - Big Key Chest', player) location = world.get_location('Turtle Rock - Big Key Chest', player)
location.place_locked_item(item) location.place_locked_item(item)
location.event = True location.event = True

View File

@@ -207,10 +207,10 @@ def ShopSlotFill(world):
shops_per_sphere.append(current_shops_slots) shops_per_sphere.append(current_shops_slots)
candidates_per_sphere.append(current_candidates) candidates_per_sphere.append(current_candidates)
for location in sphere: for location in sphere:
if isinstance(location, ALttPLocation) and location.shop_slot is not None: if location.shop_slot is not None:
if not location.shop_slot_disabled: if not location.shop_slot_disabled:
current_shops_slots.append(location) current_shops_slots.append(location)
elif not location.locked and location.item.name not in blacklist_words: elif not location.locked and not location.item.name in blacklist_words:
current_candidates.append(location) current_candidates.append(location)
if cumu_weights: if cumu_weights:
x = cumu_weights[-1] x = cumu_weights[-1]
@@ -335,6 +335,7 @@ def create_shops(world, player: int):
else: else:
loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player) loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player)
loc.shop_slot_disabled = True loc.shop_slot_disabled = True
loc.item.world = world
shop.region.locations.append(loc) shop.region.locations.append(loc)
world.clear_location_cache() world.clear_location_cache()
@@ -459,11 +460,10 @@ def shuffle_shops(world, items, player: int):
f"Not all upgrades put into Player{player}' item pool. Putting remaining items in Capacity Upgrade shop instead.") f"Not all upgrades put into Player{player}' item pool. Putting remaining items in Capacity Upgrade shop instead.")
bombupgrades = sum(1 for item in new_items if 'Bomb Upgrade' in item) bombupgrades = sum(1 for item in new_items if 'Bomb Upgrade' in item)
arrowupgrades = sum(1 for item in new_items if 'Arrow Upgrade' in item) arrowupgrades = sum(1 for item in new_items if 'Arrow Upgrade' in item)
slots = iter(range(2))
if bombupgrades: if bombupgrades:
capacityshop.add_inventory(next(slots), 'Bomb Upgrade (+5)', 100, bombupgrades) capacityshop.add_inventory(1, 'Bomb Upgrade (+5)', 100, bombupgrades)
if arrowupgrades: if arrowupgrades:
capacityshop.add_inventory(next(slots), 'Arrow Upgrade (+5)', 100, arrowupgrades) capacityshop.add_inventory(1, 'Arrow Upgrade (+5)', 100, arrowupgrades)
else: else:
for item in new_items: for item in new_items:
world.push_precollected(ItemFactory(item, player)) world.push_precollected(ItemFactory(item, player))

View File

@@ -6,33 +6,31 @@ from BaseClasses import Location, Item, ItemClassification
class ALttPLocation(Location): class ALttPLocation(Location):
game: str = "A Link to the Past" game: str = "A Link to the Past"
crystal: bool
player_address: Optional[int]
_hint_text: Optional[str]
shop_slot: Optional[int] = None
"""If given as integer, shop_slot is the shop's inventory index."""
shop_slot_disabled: bool = False
def __init__(self, player: int, name: str, address: Optional[int] = None, crystal: bool = False, def __init__(self, player: int, name: str = '', address=None, crystal: bool = False,
hint_text: Optional[str] = None, parent=None, player_address: Optional[int] = None): hint_text: Optional[str] = None, parent=None,
player_address=None):
super(ALttPLocation, self).__init__(player, name, address, parent) super(ALttPLocation, self).__init__(player, name, address, parent)
self.crystal = crystal self.crystal = crystal
self.player_address = player_address self.player_address = player_address
self._hint_text = hint_text self._hint_text: str = hint_text
class ALttPItem(Item): class ALttPItem(Item):
game: str = "A Link to the Past" game: str = "A Link to the Past"
type: Optional[str]
_pedestal_hint_text: Optional[str]
_hint_text: Optional[str]
dungeon = None dungeon = None
def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, pedestal_hint=None,
pedestal_hint=None, hint_text=None): pedestal_credit=None, sick_kid_credit=None, zora_credit=None, witch_credit=None,
flute_boy_credit=None, hint_text=None):
super(ALttPItem, self).__init__(name, classification, item_code, player) super(ALttPItem, self).__init__(name, classification, item_code, player)
self.type = type self.type = type
self._pedestal_hint_text = pedestal_hint self._pedestal_hint_text = pedestal_hint
self.pedestal_credit_text = pedestal_credit
self.sickkid_credit_text = sick_kid_credit
self.zora_credit_text = zora_credit
self.magicshop_credit_text = witch_credit
self.fluteboy_credit_text = flute_boy_credit
self._hint_text = hint_text self._hint_text = hint_text
@property @property

View File

@@ -1,24 +1,26 @@
import random
import logging import logging
import os import os
import random
import threading import threading
import typing import typing
import Utils
from BaseClasses import Item, CollectionState, Tutorial from BaseClasses import Item, CollectionState, Tutorial
from .Dungeons import create_dungeons
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
from .ItemPool import generate_itempool, difficulties
from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem
from .Options import alttp_options, smallkey_shuffle
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions
from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \
get_hash_string, get_base_rom_path, LttPDeltaPatch
from .Rules import set_rules
from .Shops import create_shops, ShopSlotFill
from .SubClasses import ALttPItem from .SubClasses import ALttPItem
from ..AutoWorld import World, WebWorld, LogicMixin from ..AutoWorld import World, WebWorld, LogicMixin
from .Options import alttp_options, smallkey_shuffle
from .Items import as_dict_item_table, item_name_groups, item_table, GetBeemizerItem
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions
from .Rules import set_rules
from .ItemPool import generate_itempool, difficulties
from .Shops import create_shops, ShopSlotFill
from .Dungeons import create_dungeons
from .Rom import LocalRom, patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, get_hash_string, \
get_base_rom_path, LttPDeltaPatch
import Patch
from itertools import chain
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
lttp_logger = logging.getLogger("A Link to the Past") lttp_logger = logging.getLogger("A Link to the Past")
@@ -108,7 +110,7 @@ class ALTTPWorld(World):
Ganon! Ganon!
""" """
game: str = "A Link to the Past" game: str = "A Link to the Past"
option_definitions = alttp_options options = alttp_options
topology_present = True topology_present = True
item_name_groups = item_name_groups item_name_groups = item_name_groups
hint_blacklist = {"Triforce"} hint_blacklist = {"Triforce"}
@@ -122,25 +124,10 @@ class ALTTPWorld(World):
required_client_version = (0, 3, 2) required_client_version = (0, 3, 2)
web = ALTTPWeb() web = ALTTPWeb()
pedestal_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit}
sickkid_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.sick_kid_credit for data in item_table.values() if data.sick_kid_credit}
zora_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.zora_credit for data in item_table.values() if data.zora_credit}
magicshop_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.witch_credit for data in item_table.values() if data.witch_credit}
fluteboy_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.flute_boy_credit for data in item_table.values() if data.flute_boy_credit}
set_rules = set_rules set_rules = set_rules
create_items = generate_itempool create_items = generate_itempool
enemizer_path: str = Utils.get_options()["generator"]["enemizer_path"] \
if os.path.isabs(Utils.get_options()["generator"]["enemizer_path"]) \
else Utils.local_path(Utils.get_options()["generator"]["enemizer_path"])
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.dungeon_local_item_names = set() self.dungeon_local_item_names = set()
self.dungeon_specific_item_names = set() self.dungeon_specific_item_names = set()
@@ -155,9 +142,6 @@ class ALTTPWorld(World):
raise FileNotFoundError(rom_file) raise FileNotFoundError(rom_file)
def generate_early(self): def generate_early(self):
if self.use_enemizer():
check_enemizer(self.enemizer_path)
player = self.player player = self.player
world = self.world world = self.world
@@ -192,6 +176,17 @@ class ALTTPWorld(World):
def create_regions(self): def create_regions(self):
player = self.player player = self.player
world = self.world world = self.world
if world.open_pyramid[player] == 'goal':
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
'localganontriforcehunt', 'ganonpedestal'}
elif world.open_pyramid[player] == 'auto':
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
'localganontriforcehunt', 'ganonpedestal'} and \
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'} or not world.shuffle_ganon)
else:
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(
world.open_pyramid[player], 'auto')
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player],
world.triforce_pieces_required[player]) world.triforce_pieces_required[player])
@@ -346,26 +341,21 @@ class ALTTPWorld(World):
def stage_post_fill(cls, world): def stage_post_fill(cls, world):
ShopSlotFill(world) ShopSlotFill(world)
def use_enemizer(self):
world = self.world
player = self.player
return (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.pot_shuffle[player] or world.bush_shuffle[player]
or world.killable_thieves[player])
def generate_output(self, output_directory: str): def generate_output(self, output_directory: str):
world = self.world world = self.world
player = self.player player = self.player
try: try:
use_enemizer = self.use_enemizer() use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.pot_shuffle[player] or world.bush_shuffle[player]
or world.killable_thieves[player])
rom = LocalRom(get_base_rom_path()) rom = LocalRom(get_base_rom_path())
patch_rom(world, rom, player, use_enemizer) patch_rom(world, rom, player, use_enemizer)
if use_enemizer: if use_enemizer:
patch_enemizer(world, player, rom, self.enemizer_path, output_directory) patch_enemizer(world, player, rom, world.enemizer, output_directory)
if world.is_race: if world.is_race:
patch_race_rom(rom, world, player) patch_race_rom(rom, world, player)
@@ -378,7 +368,7 @@ class ALTTPWorld(World):
'hud': world.hud_palettes[player], 'hud': world.hud_palettes[player],
'sword': world.sword_palettes[player], 'sword': world.sword_palettes[player],
'shield': world.shield_palettes[player], 'shield': world.shield_palettes[player],
# 'link': world.link_palettes[player] 'link': world.link_palettes[player]
} }
palettes_options = {key: option.current_key for key, option in palettes_options.items()} palettes_options = {key: option.current_key for key, option in palettes_options.items()}
@@ -421,7 +411,7 @@ class ALTTPWorld(World):
multidata["connect_names"][new_name] = multidata["connect_names"][self.world.player_name[self.player]] multidata["connect_names"][new_name] = multidata["connect_names"][self.world.player_name[self.player]]
def create_item(self, name: str) -> Item: def create_item(self, name: str) -> Item:
return ALttPItem(name, self.player, **item_init_table[name]) return ALttPItem(name, self.player, **as_dict_item_table[name])
@classmethod @classmethod
def stage_fill_hook(cls, world, progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, def stage_fill_hook(cls, world, progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool,

View File

@@ -144,8 +144,7 @@ Sólo hay que segiur estos pasos una vez.
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON. 2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el 3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el
default) el Puerto de comandos de red. default) el Puerto de comandos de red.
![Captura de pantalla del ajuste Comandos de red](/static/assets/tutorial/retroarch-network-commands-en.png)
![Captura de pantalla del ajuste Comandos de red](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES / 4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
SFC (bsnes-mercury Performance)". SFC (bsnes-mercury Performance)".

View File

@@ -299,5 +299,4 @@ item_table = (
'A Shrubbery', 'A Shrubbery',
'Roomba with a Knife', 'Roomba with a Knife',
'Wet Cat', 'Wet Cat',
'The missing moderator, Frostwares',
) )

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