mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-12 02:23:47 -07:00
Compare commits
5 Commits
0.3.5
...
show_all_h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87152f17f5 | ||
|
|
c1b099d44e | ||
|
|
d20ade7ff8 | ||
|
|
df90ff4ddb | ||
|
|
a798e8aea2 |
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -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
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -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?
|
||||
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
@@ -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?
|
||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -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.
|
||||
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -4,11 +4,6 @@ name: Build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
SNI_VERSION: v0.0.84
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
@@ -22,9 +17,9 @@ jobs:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
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
|
||||
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
|
||||
- name: Build
|
||||
run: |
|
||||
@@ -48,7 +43,6 @@ jobs:
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
@@ -62,18 +56,18 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
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
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
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
|
||||
rm sni-*.tar.xz
|
||||
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
|
||||
- name: Build
|
||||
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")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -18,8 +18,8 @@ jobs:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -7,11 +7,6 @@ on:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
env:
|
||||
SNI_VERSION: v0.0.84
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -49,23 +44,22 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
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
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
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
|
||||
rm sni-*.tar.xz
|
||||
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
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
4
.github/workflows/unittests.yml
vendored
4
.github/workflows/unittests.yml
vendored
@@ -32,8 +32,8 @@ jobs:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -28,7 +28,6 @@ README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
@@ -117,9 +116,6 @@ target/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# vim editor
|
||||
*.swp
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
@@ -156,17 +152,10 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# minecraft server stuff
|
||||
#minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
|
||||
# pyenv
|
||||
#pyenv
|
||||
.python-version
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
[Dd]esktop.ini
|
||||
|
||||
@@ -126,6 +126,7 @@ class MultiWorld():
|
||||
set_player_attr('beemizer_total_chance', 0)
|
||||
set_player_attr('beemizer_trap_chance', 0)
|
||||
set_player_attr('escape_assist', [])
|
||||
set_player_attr('open_pyramid', False)
|
||||
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
|
||||
set_player_attr('treasure_hunt_count', 0)
|
||||
set_player_attr('clock_mode', False)
|
||||
@@ -166,7 +167,7 @@ class MultiWorld():
|
||||
self.player_types[new_id] = NetUtils.SlotType.group
|
||||
self._region_cache[new_id] = {}
|
||||
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)
|
||||
for option_key, option in Options.common_options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
@@ -204,7 +205,7 @@ class MultiWorld():
|
||||
for player in self.player_ids:
|
||||
self.custom_data[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, {}))
|
||||
|
||||
self.worlds[player] = world_type(self, player)
|
||||
@@ -384,17 +385,25 @@ class MultiWorld():
|
||||
return self.worlds[player].create_item(item_name)
|
||||
|
||||
def push_precollected(self, item: Item):
|
||||
item.world = self
|
||||
self.precollected_items[item.player].append(item)
|
||||
self.state.collect(item, 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}."
|
||||
location.item = item
|
||||
item.location = location
|
||||
if collect:
|
||||
self.state.collect(item, location.event, location)
|
||||
if not isinstance(location, Location):
|
||||
raise RuntimeError(
|
||||
'Cannot assign item %s to invalid location %s (player %d).' % (item, location, item.player))
|
||||
|
||||
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]:
|
||||
if self._cached_entrances is None:
|
||||
@@ -1064,25 +1073,26 @@ class LocationProgressType(IntEnum):
|
||||
|
||||
|
||||
class Location:
|
||||
game: str = "Generic"
|
||||
player: int
|
||||
name: str
|
||||
address: Optional[int]
|
||||
parent_region: Optional[Region]
|
||||
# If given as integer, then this is the shop's inventory index
|
||||
shop_slot: Optional[int] = None
|
||||
shop_slot_disabled: bool = False
|
||||
event: bool = False
|
||||
locked: bool = False
|
||||
game: str = "Generic"
|
||||
show_in_spoiler: bool = True
|
||||
crystal: bool = False
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow = staticmethod(lambda item, state: False)
|
||||
access_rule = staticmethod(lambda state: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
parent_region: Optional[Region]
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||
self.player = player
|
||||
self.name = name
|
||||
self.address = address
|
||||
def __init__(self, player: int, name: str = '', address: int = None, parent=None):
|
||||
self.name: str = name
|
||||
self.address: Optional[int] = address
|
||||
self.parent_region = parent
|
||||
self.player: int = player
|
||||
|
||||
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)))
|
||||
@@ -1099,6 +1109,7 @@ class Location:
|
||||
self.item = item
|
||||
item.location = self
|
||||
self.event = item.advancement
|
||||
self.item.world = self.parent_region.world
|
||||
self.locked = True
|
||||
|
||||
def __repr__(self):
|
||||
@@ -1143,28 +1154,39 @@ class ItemClassification(IntFlag):
|
||||
|
||||
|
||||
class Item:
|
||||
game: str = "Generic"
|
||||
__slots__ = ("name", "classification", "code", "player", "location")
|
||||
location: Optional[Location] = None
|
||||
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
|
||||
game: str = "Generic"
|
||||
type: str = None
|
||||
classification: ItemClassification
|
||||
code: Optional[int]
|
||||
"""an item with code None is called an Event, and does not get written to multidata"""
|
||||
player: int
|
||||
location: Optional[Location]
|
||||
|
||||
# need to find a decent place for these to live and to allow other games to register texts if they want.
|
||||
pedestal_credit_text: str = "and the Unknown Item"
|
||||
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):
|
||||
self.name = name
|
||||
self.classification = classification
|
||||
self.player = player
|
||||
self.code = code
|
||||
self.location = None
|
||||
|
||||
@property
|
||||
def hint_text(self) -> str:
|
||||
def hint_text(self):
|
||||
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||
|
||||
@property
|
||||
def pedestal_hint_text(self) -> str:
|
||||
def pedestal_hint_text(self):
|
||||
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||
|
||||
@property
|
||||
@@ -1190,7 +1212,7 @@ class Item:
|
||||
def __eq__(self, other):
|
||||
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:
|
||||
return other.player < self.player
|
||||
return self.name < other.name
|
||||
@@ -1198,13 +1220,11 @@ class Item:
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.location and self.location.parent_region and self.location.parent_region.world:
|
||||
return self.location.parent_region.world.get_name_string_for_object(self)
|
||||
return f"{self.name} (Player {self.player})"
|
||||
def __str__(self):
|
||||
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Spoiler():
|
||||
@@ -1388,7 +1408,7 @@ class Spoiler():
|
||||
outfile.write('Game: %s\n' % self.world.game[player])
|
||||
for f_option, option in Options.per_game_common_options.items():
|
||||
write_option(f_option, option)
|
||||
options = self.world.worlds[player].option_definitions
|
||||
options = self.world.worlds[player].options
|
||||
if options:
|
||||
for f_option, option in options.items():
|
||||
write_option(f_option, option)
|
||||
@@ -1411,6 +1431,8 @@ class Spoiler():
|
||||
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
||||
if self.world.shuffle[player] != "vanilla":
|
||||
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' %
|
||||
bool_to_text("i" in self.world.shop_shuffle[player]))
|
||||
outfile.write('Shop price shuffle: %s\n' %
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -34,24 +32,6 @@ class ChecksFinderContext(CommonContext):
|
||||
self.send_index: int = 0
|
||||
self.syncing = 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):
|
||||
if password_requested and not self.password:
|
||||
@@ -61,7 +41,8 @@ class ChecksFinderContext(CommonContext):
|
||||
|
||||
async def connection_closed(self):
|
||||
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:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root + "/" + file)
|
||||
@@ -75,25 +56,26 @@ class ChecksFinderContext(CommonContext):
|
||||
|
||||
async def shutdown(self):
|
||||
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:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
|
||||
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
|
||||
for ss in self.checked_locations:
|
||||
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()
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index != len(self.items_received):
|
||||
for item in args['items']:
|
||||
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.close()
|
||||
|
||||
@@ -101,7 +83,7 @@ class ChecksFinderContext(CommonContext):
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
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()
|
||||
|
||||
def run_gui(self):
|
||||
@@ -127,9 +109,10 @@ async def game_watcher(ctx: ChecksFinderContext):
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
sending = []
|
||||
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:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
|
||||
@@ -5,7 +5,6 @@ import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import functools
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -18,8 +17,7 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
@@ -112,6 +110,10 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("Unreadied.")
|
||||
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):
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
@@ -154,9 +156,8 @@ class CommonContext:
|
||||
# locations
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
missing_locations: typing.Set[int] # server state
|
||||
missing_locations: typing.Set[int]
|
||||
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]
|
||||
|
||||
# internals
|
||||
@@ -187,9 +188,8 @@ class CommonContext:
|
||||
self.locations_checked = set() # local state
|
||||
self.locations_scouted = set()
|
||||
self.items_received = []
|
||||
self.missing_locations = set() # server state
|
||||
self.missing_locations = set()
|
||||
self.checked_locations = set() # server state
|
||||
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
|
||||
self.locations_info = {}
|
||||
|
||||
self.input_queue = asyncio.Queue()
|
||||
@@ -206,10 +206,6 @@ class CommonContext:
|
||||
# execution
|
||||
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
|
||||
def total_locations(self) -> typing.Optional[int]:
|
||||
"""Will return None until connected."""
|
||||
@@ -353,8 +349,6 @@ class CommonContext:
|
||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
needed_updates: typing.Set[str] = set()
|
||||
for game in relevant_games:
|
||||
if game not in remote_datepackage_versions:
|
||||
continue
|
||||
remote_version: int = remote_datepackage_versions[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):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
@@ -503,8 +506,7 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
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)
|
||||
logger.info('Connected')
|
||||
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.")
|
||||
ctx.hint_cost = int(args['hint_cost'])
|
||||
ctx.check_points = int(args['location_check_points'])
|
||||
|
||||
if "players" in args: # TODO remove when servers sending this are outdated
|
||||
players = args.get("players", [])
|
||||
if len(players) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
players.sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in players:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
|
||||
players = args.get("players", [])
|
||||
if len(players) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
players.sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in players:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
# update datapackage
|
||||
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.
|
||||
ctx.missing_locations = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
@@ -738,7 +736,7 @@ if __name__ == '__main__':
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
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):
|
||||
if password_requested and not self.password:
|
||||
|
||||
53
FF1Client.py
53
FF1Client.py
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
@@ -7,7 +6,7 @@ from typing import List
|
||||
|
||||
|
||||
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
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
@@ -65,7 +64,7 @@ class FF1Context(CommonContext):
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
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):
|
||||
if cmd == 'Connected':
|
||||
@@ -74,28 +73,32 @@ class FF1Context(CommonContext):
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||
else:
|
||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
# goes to this world
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
# found in this world
|
||||
elif self.slot_concerns_self(item.player):
|
||||
relevant = True
|
||||
# not related
|
||||
else:
|
||||
relevant = False
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
item = args['item']
|
||||
receiving_player_id = args['receiving']
|
||||
receiving_player_name = self.player_names[receiving_player_id]
|
||||
sending_player_id = item.player
|
||||
sending_player_name = self.player_names[item.player]
|
||||
if print_type == 'Hint':
|
||||
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
|
||||
self._set_message(msg, item.item)
|
||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
||||
if sending_player_id == self.slot:
|
||||
if receiving_player_id == self.slot:
|
||||
msg = f"You found your own {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
|
||||
else:
|
||||
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)
|
||||
|
||||
def run_gui(self):
|
||||
|
||||
@@ -20,7 +20,8 @@ import Utils
|
||||
if __name__ == "__main__":
|
||||
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 NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
@@ -399,7 +400,6 @@ if __name__ == '__main__':
|
||||
"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-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
@@ -410,9 +410,6 @@ if __name__ == '__main__':
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
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)):
|
||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||
@@ -424,10 +421,7 @@ if __name__ == '__main__':
|
||||
else:
|
||||
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, "--server-settings", server_settings, *rest)
|
||||
else:
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
16
Fill.py
16
Fill.py
@@ -42,16 +42,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
|
||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
while items_to_place:
|
||||
# if we have run out of locations to fill,break out of this loop
|
||||
if not locations:
|
||||
unplaced_items += items_to_place
|
||||
break
|
||||
item_to_place = items_to_place.pop(0)
|
||||
|
||||
for item_to_place in items_to_place:
|
||||
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':
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) \
|
||||
@@ -62,7 +54,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
for i, location in enumerate(locations):
|
||||
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):
|
||||
# popping by index is faster than removing by content,
|
||||
# poping by index is faster than removing by content,
|
||||
spot_to_fill = locations.pop(i)
|
||||
# skipping a scan for the element
|
||||
break
|
||||
@@ -220,8 +212,8 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
||||
break
|
||||
else:
|
||||
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
|
||||
f"Too many non-local items for too few remaining locations.")
|
||||
logging.warning(
|
||||
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
|
||||
|
||||
world.random.shuffle(defaultlocations)
|
||||
|
||||
|
||||
139
Generate.py
139
Generate.py
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
@@ -7,9 +5,8 @@ import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||
import os
|
||||
from collections import Counter, ChainMap
|
||||
from collections import Counter
|
||||
import string
|
||||
import enum
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -28,43 +25,7 @@ from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
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"
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
|
||||
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('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
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),
|
||||
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"])
|
||||
@@ -98,7 +64,7 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_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.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||
args.plando: Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
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):
|
||||
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:
|
||||
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)}")
|
||||
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||
del(meta_weights["meta_description"])
|
||||
except Exception as e:
|
||||
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
||||
del(meta_weights["meta_description"])
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
else:
|
||||
@@ -161,9 +125,9 @@ def main(args=None, callback=ERmain):
|
||||
player_files[player_id] = filename
|
||||
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: "
|
||||
f"{args.plando}")
|
||||
f"{', '.join(args.plando)}")
|
||||
|
||||
if not weights_cache:
|
||||
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)
|
||||
|
||||
erargs.lttp_rom = args.lttp_rom
|
||||
erargs.sm_rom = args.sm_rom
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
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:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
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:
|
||||
for path in weights_cache:
|
||||
for player, path in player_path_cache.items():
|
||||
for yaml in weights_cache[path]:
|
||||
if category_name is None:
|
||||
for category in yaml:
|
||||
if category in AutoWorldRegister.world_types and key in Options.common_options:
|
||||
yaml[category][key] = option
|
||||
yaml[key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
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()
|
||||
erargs.player_settings = {}
|
||||
|
||||
@@ -382,28 +348,6 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
|
||||
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:
|
||||
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"]:
|
||||
@@ -459,7 +403,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif PlandoSettings.bosses in plando_options:
|
||||
elif "bosses" in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
@@ -508,7 +452,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
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:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
@@ -521,11 +465,17 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
required_plando_options = requirements.get("plando", "")
|
||||
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:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
if len(required_plando_options) == 1:
|
||||
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()
|
||||
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)))
|
||||
|
||||
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)
|
||||
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
|
||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||
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", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
if "connections" in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
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]
|
||||
|
||||
# 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')
|
||||
|
||||
@@ -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'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if PlandoSettings.texts in plando_options:
|
||||
if "texts" in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
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_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
if "connections" in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
|
||||
22
Launcher.py
22
Launcher.py
@@ -10,21 +10,16 @@ Scroll down to components= to add components to the launcher as well as setup.py
|
||||
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from enum import Enum, auto
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
import sys
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
||||
import subprocess
|
||||
import itertools
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
|
||||
is_windows, is_macos, is_linux
|
||||
from shutil import which
|
||||
import shlex
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
@@ -70,7 +65,6 @@ def browse_files():
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
# noinspection PyArgumentList
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
FUNC = auto() # not a real component
|
||||
@@ -132,7 +126,7 @@ components: Iterable[Component] = (
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
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'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
|
||||
@@ -83,9 +83,9 @@ def main():
|
||||
parser.add_argument('--ow_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
# parser.add_argument('--link_palettes', default='default',
|
||||
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
# 'sick'])
|
||||
parser.add_argument('--link_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
parser.add_argument('--shield_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
@@ -289,7 +289,7 @@ def run_sprite_update():
|
||||
else:
|
||||
top.withdraw()
|
||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.is_set():
|
||||
while not done.isSet():
|
||||
task.do_events()
|
||||
logging.info("Done updating sprites")
|
||||
|
||||
@@ -300,7 +300,6 @@ def update_sprites(task, on_finish=None):
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
|
||||
def finished():
|
||||
task.close_window()
|
||||
if on_finish:
|
||||
@@ -752,7 +751,6 @@ class SpriteSelector():
|
||||
self.window['pady'] = 5
|
||||
self.spritesPerRow = 32
|
||||
self.all_sprites = []
|
||||
self.invalid_sprites = []
|
||||
self.sprite_pool = spritePool
|
||||
|
||||
def open_custom_sprite_dir(_evt):
|
||||
@@ -834,13 +832,6 @@ class SpriteSelector():
|
||||
self.window.focus()
|
||||
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):
|
||||
self.callback(("remove", spritename))
|
||||
self.spritePoolButtons.buttons.remove(button)
|
||||
@@ -905,13 +896,7 @@ class SpriteSelector():
|
||||
sprites = []
|
||||
|
||||
for file in os.listdir(path):
|
||||
if file == '.gitignore':
|
||||
continue
|
||||
sprite = Sprite(os.path.join(path, file))
|
||||
if sprite.valid:
|
||||
sprites.append((file, sprite))
|
||||
else:
|
||||
self.invalid_sprites.append(file)
|
||||
sprites.append((file, Sprite(os.path.join(path, file))))
|
||||
|
||||
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
||||
|
||||
|
||||
10
Main.py
10
Main.py
@@ -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.timer = args.timer.copy()
|
||||
world.goal = args.goal.copy()
|
||||
world.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_health = args.enemy_health.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.game = args.game.copy()
|
||||
world.player_name = args.name.copy()
|
||||
world.enemizer = args.enemizercli
|
||||
world.sprite = args.sprite.copy()
|
||||
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")
|
||||
|
||||
for item in world.itempool:
|
||||
item.world = world
|
||||
|
||||
distribute_planned(world)
|
||||
|
||||
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():
|
||||
if type(location.address) == int:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None. Location: " \
|
||||
f" {location}"
|
||||
"location.address should then also be None"
|
||||
locations_data[location.player][location.address] = \
|
||||
location.item.code, location.item.player, location.item.flags
|
||||
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))
|
||||
|
||||
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,
|
||||
compresslevel=9) as zf:
|
||||
for file in os.scandir(temp_dir):
|
||||
|
||||
250
MultiServer.py
250
MultiServer.py
@@ -30,13 +30,17 @@ except ImportError:
|
||||
OperationalError = ConnectionError
|
||||
|
||||
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
|
||||
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, \
|
||||
SlotType
|
||||
|
||||
min_client_version = Version(0, 1, 6)
|
||||
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
|
||||
colorama.init()
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
@@ -122,11 +126,6 @@ class Context:
|
||||
stored_data: typing.Dict[str, object]
|
||||
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,
|
||||
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,
|
||||
@@ -191,43 +190,8 @@ class Context:
|
||||
self.stored_data = {}
|
||||
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
|
||||
|
||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||
if not endpoint.socket or not endpoint.socket.open:
|
||||
return False
|
||||
@@ -292,27 +256,20 @@ class Context:
|
||||
|
||||
# text
|
||||
|
||||
def notify_all(self, text: str):
|
||||
def notify_all(self, 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):
|
||||
if not client.auth:
|
||||
return
|
||||
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": "PrintJSON", "data": [{ "text": text }]}]))
|
||||
else:
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||
if not client.auth:
|
||||
return
|
||||
if client.version >= print_command_compatability_threshold:
|
||||
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]))
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||
|
||||
# loading
|
||||
|
||||
@@ -587,13 +544,12 @@ class Context:
|
||||
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
|
||||
f' has completed their goal.'
|
||||
self.notify_all(finished_msg)
|
||||
if "auto" in self.collect_mode:
|
||||
collect_player(self, client.team, client.slot)
|
||||
if "auto" in self.forfeit_mode:
|
||||
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)
|
||||
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):
|
||||
@@ -603,12 +559,19 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
|
||||
if not hints:
|
||||
return
|
||||
concerns = collections.defaultdict(list)
|
||||
all_hints = collections.defaultdict(list)
|
||||
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
|
||||
data = (hint, hint.as_network_message())
|
||||
for player in ctx.slot_set(hint.receiving_player):
|
||||
concerns[player].append(data)
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
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
|
||||
if not hint.found:
|
||||
# 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:
|
||||
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):
|
||||
cmd = ctx.dumper([{"cmd": "RoomUpdate",
|
||||
@@ -686,10 +653,9 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
'permissions': get_permissions(ctx),
|
||||
'hint_cost': ctx.hint_cost,
|
||||
'location_check_points': ctx.location_check_points,
|
||||
'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values())
|
||||
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
|
||||
'datapackage_version': network_data_package["version"],
|
||||
'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,
|
||||
'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)
|
||||
|
||||
|
||||
async def countdown(ctx: Context, timer: int):
|
||||
broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s")
|
||||
async def countdown(ctx: Context, timer):
|
||||
ctx.notify_all(f'[Server]: Starting countdown of {timer}s')
|
||||
if ctx.countdown_timer:
|
||||
ctx.countdown_timer = timer # timer is already running, set it to a different time
|
||||
else:
|
||||
ctx.countdown_timer = timer
|
||||
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
|
||||
await asyncio.sleep(1)
|
||||
broadcast_countdown(ctx, 0, f"[Server]: GO")
|
||||
ctx.notify_all(f'[Server]: GO')
|
||||
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):
|
||||
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]
|
||||
|
||||
|
||||
def get_status_string(ctx: Context, team: int, tag: str):
|
||||
text = f"Player Status on team {team}:"
|
||||
def get_status_string(ctx: Context, team: int):
|
||||
text = "Player Status on your team:"
|
||||
for slot in ctx.locations:
|
||||
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])})"
|
||||
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 "."
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
||||
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
||||
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
|
||||
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
|
||||
info_text = json_format_send_event(new_item, target_player)
|
||||
ctx.broadcast_team(team, [info_text])
|
||||
|
||||
@@ -900,14 +849,13 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
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 = []
|
||||
slots: typing.Set[int] = {slot}
|
||||
for group_id, group in ctx.groups.items():
|
||||
if slot in group:
|
||||
slots.add(group_id)
|
||||
|
||||
seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name]
|
||||
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
|
||||
for finding_player, check_data in ctx.locations.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:
|
||||
@@ -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]:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||
f"{ctx.item_names[hint.item]} is " \
|
||||
f"at {ctx.location_names[hint.location]} " \
|
||||
f"{lookup_any_item_id_to_name[hint.item]} is " \
|
||||
f"at {get_location_name_from_id(hint.location)} " \
|
||||
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
||||
|
||||
if hint.entrance:
|
||||
@@ -1176,11 +1124,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.output(get_players_string(self.ctx))
|
||||
return True
|
||||
|
||||
def _cmd_status(self, tag:str="") -> bool:
|
||||
"""Get status information about your team.
|
||||
Optionally mention a Tag name and get information on who has that Tag.
|
||||
For example: DeathLink or EnergyLink."""
|
||||
self.output(get_status_string(self.ctx, self.client.team, tag))
|
||||
def _cmd_status(self) -> bool:
|
||||
"""Get status information about your team."""
|
||||
self.output(get_status_string(self.ctx, self.client.team))
|
||||
return True
|
||||
|
||||
def _cmd_release(self) -> bool:
|
||||
@@ -1196,8 +1142,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||
return True
|
||||
elif "disabled" in self.ctx.forfeit_mode:
|
||||
self.output("Sorry, client item releasing has been disabled on this server. "
|
||||
"You can ask the server admin for a /release")
|
||||
self.output(
|
||||
"Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release")
|
||||
return False
|
||||
else: # is auto or 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":
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
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))
|
||||
else:
|
||||
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:
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
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))
|
||||
else:
|
||||
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)
|
||||
|
||||
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")
|
||||
self.ctx.notify_client_multiple(self.client, texts)
|
||||
else:
|
||||
@@ -1275,7 +1221,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
||||
|
||||
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")
|
||||
self.ctx.notify_client_multiple(self.client, texts)
|
||||
else:
|
||||
@@ -1304,13 +1250,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def _cmd_getitem(self, item_name: str) -> bool:
|
||||
"""Cheat in an item, if it is enabled on this server"""
|
||||
if self.ctx.item_cheat:
|
||||
names = self.ctx.item_names_for_game(self.ctx.games[self.client.slot])
|
||||
item_name, usable, response = get_intended_text(
|
||||
item_name,
|
||||
names
|
||||
)
|
||||
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
||||
item_name, usable, response = get_intended_text(item_name,
|
||||
world.item_names)
|
||||
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, True).append(new_item)
|
||||
self.ctx.notify_all(
|
||||
@@ -1336,22 +1280,20 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
f"You have {points_available} points.")
|
||||
return True
|
||||
else:
|
||||
game = self.ctx.games[self.client.slot]
|
||||
names = self.ctx.location_names_for_game(game) \
|
||||
if for_location else \
|
||||
self.ctx.all_item_and_group_names[game]
|
||||
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
||||
names = world.location_names if for_location else world.all_item_and_group_names
|
||||
hint_name, usable, response = get_intended_text(input_text,
|
||||
names)
|
||||
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.")
|
||||
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 = []
|
||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||
for item in world.item_name_groups[hint_name]:
|
||||
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))
|
||||
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)
|
||||
else: # location 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
|
||||
|
||||
self.ctx.random.shuffle(not_found_hints)
|
||||
# By popular vote, make hints prefer non-local placements
|
||||
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
||||
|
||||
hints = found_hints
|
||||
while can_pay > 0:
|
||||
@@ -1413,12 +1353,12 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
|
||||
@mark_raw
|
||||
def _cmd_hint(self, item_name: str = "") -> bool:
|
||||
def _cmd_hint(self, item: str = "") -> bool:
|
||||
"""Use !hint {item_name},
|
||||
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,
|
||||
you can rerun the command to get more in that case."""
|
||||
return self.get_hints(item_name)
|
||||
return self.get_hints(item)
|
||||
|
||||
@mark_raw
|
||||
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":
|
||||
exclusions = args.get("exclusions", [])
|
||||
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", []))}
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": {"games": games}}])
|
||||
# TODO: remove exclusions behaviour around 0.5.0
|
||||
elif 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}
|
||||
|
||||
package = {"games": games}
|
||||
package = network_data_package.copy()
|
||||
package["games"] = games
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": package}])
|
||||
|
||||
else:
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": {"games": ctx.gamespackage}}])
|
||||
"data": network_data_package}])
|
||||
|
||||
elif client.auth:
|
||||
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))
|
||||
hints = []
|
||||
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,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
||||
"original_cmd": cmd}])
|
||||
@@ -1728,14 +1668,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(get_players_string(self.ctx))
|
||||
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:
|
||||
"""Shutdown the server"""
|
||||
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())
|
||||
if usable:
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
item_name = " ".join(item_name)
|
||||
names = self.ctx.item_names_for_game(self.ctx.games[slot])
|
||||
item_name, usable, response = get_intended_text(item_name, names)
|
||||
item = " ".join(item_name)
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.item_names)
|
||||
if usable:
|
||||
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_new_items(self.ctx)
|
||||
self.ctx.notify_all(
|
||||
'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
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1854,22 +1786,22 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
"""Sends an item to the specified player"""
|
||||
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"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
if usable:
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
item_name = " ".join(item_name)
|
||||
game = self.ctx.games[slot]
|
||||
item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game])
|
||||
item = " ".join(item)
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.all_item_and_group_names)
|
||||
if usable:
|
||||
if item_name in self.ctx.item_name_groups[game]:
|
||||
if item in world.item_name_groups:
|
||||
hints = []
|
||||
for item_name_from_group in self.ctx.item_name_groups[game][item_name]:
|
||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||
for item in world.item_name_groups[item]:
|
||||
if item in world.item_name_to_id: # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||
else: # item name
|
||||
hints = collect_hints(self.ctx, team, slot, item_name)
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
@@ -1885,16 +1817,16 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(response)
|
||||
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"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
if usable:
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
location_name = " ".join(location_name)
|
||||
location_name, usable, response = get_intended_text(location_name,
|
||||
self.ctx.location_names_for_game(self.ctx.games[slot]))
|
||||
item = " ".join(location)
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.location_names)
|
||||
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:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
else:
|
||||
|
||||
@@ -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,
|
||||
'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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
script_version: int = 2
|
||||
script_version: int = 1
|
||||
|
||||
def get_item_value(ap_id):
|
||||
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_decoded = json.loads(data.decode())
|
||||
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:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
|
||||
@@ -298,7 +298,7 @@ class Toggle(NumericOption):
|
||||
if type(data) == str:
|
||||
return cls.from_text(data)
|
||||
else:
|
||||
return cls(int(data))
|
||||
return cls(data)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
|
||||
29
Patch.py
29
Patch.py
@@ -17,7 +17,7 @@ ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
current_patch_version = 5
|
||||
current_patch_version = 4
|
||||
|
||||
|
||||
class AutoPatchRegister(type):
|
||||
@@ -128,7 +128,6 @@ class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||
manifest = super(APDeltaPatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
manifest["patch_file_ending"] = self.patch_file_ending
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
@@ -167,15 +166,13 @@ GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
GAME_SMZ3 = "SMZ3"
|
||||
GAME_DKC3 = "Donkey Kong Country 3"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe",
|
||||
GAME_SMZ3: "apsmz",
|
||||
GAME_DKC3: "apdkc3"
|
||||
GAME_SMZ3: "apsmz"
|
||||
}
|
||||
|
||||
|
||||
@@ -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.sm.Rom import SMJUHASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import USHASH as HASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
@@ -221,10 +216,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP
|
||||
else ".apsmz" if game == GAME_SMZ3
|
||||
else ".apdkc3" if game == GAME_DKC3
|
||||
else ".apm3")
|
||||
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
|
||||
write_lzma(bytes, 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")))
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.smz3.Rom import get_base_rom_bytes
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
@@ -399,13 +389,6 @@ if __name__ == "__main__":
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apdkc3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
@@ -413,9 +396,7 @@ if __name__ == "__main__":
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp") or \
|
||||
zfinfo.filename.endswith(".apm3") or \
|
||||
zfinfo.filename.endswith(".apdkc3"):
|
||||
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
|
||||
26
README.md
26
README.md
@@ -26,8 +26,6 @@ Currently, the following games are supported:
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* 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/).
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
## 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
|
||||
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
|
||||
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.
|
||||
|
||||
158
SNIClient.py
158
SNIClient.py
@@ -15,6 +15,9 @@ import typing
|
||||
|
||||
from json import loads, dumps
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import init_logging, messagebox
|
||||
|
||||
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
|
||||
import Utils
|
||||
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")
|
||||
|
||||
@@ -59,7 +62,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_snes(self, snes_options: str = "") -> bool:
|
||||
"""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.
|
||||
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
|
||||
Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
|
||||
|
||||
snes_address = self.ctx.snes_address
|
||||
snes_device_number = -1
|
||||
@@ -146,8 +149,8 @@ class Context(CommonContext):
|
||||
def event_invalid_slot(self):
|
||||
if self.snes_socket is not None and not self.snes_socket.closed:
|
||||
asyncio.create_task(self.snes_socket.close())
|
||||
raise Exception("Invalid ROM detected, "
|
||||
"please verify that you have loaded the correct rom and reconnect your snes (/snes)")
|
||||
raise Exception('Invalid ROM detected, '
|
||||
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -155,7 +158,7 @@ class Context(CommonContext):
|
||||
if self.rom is None:
|
||||
self.awaiting_rom = True
|
||||
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
|
||||
self.awaiting_rom = False
|
||||
self.auth = self.rom
|
||||
@@ -185,10 +188,7 @@ class Context(CommonContext):
|
||||
async def shutdown(self):
|
||||
await super(Context, self).shutdown()
|
||||
if self.snes_connect_task:
|
||||
try:
|
||||
await asyncio.wait_for(self.snes_connect_task, 1)
|
||||
except asyncio.TimeoutError:
|
||||
self.snes_connect_task.cancel()
|
||||
await self.snes_connect_task
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
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 (
|
||||
ctx.death_link_allow_survive and health is not None and health > 0):
|
||||
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()
|
||||
|
||||
|
||||
SNES_RECONNECT_DELAY = 5
|
||||
|
||||
# FXPAK Pro protocol memory mapping used by SNI
|
||||
# LttP
|
||||
ROM_START = 0x000000
|
||||
WRAM_START = 0xF50000
|
||||
WRAM_SIZE = 0x20000
|
||||
@@ -290,24 +287,21 @@ SHOP_LEN = (len(Shops.shop_table) * 3) + 5
|
||||
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
|
||||
|
||||
# SM
|
||||
SM_ROMNAME_START = ROM_START + 0x007FC0
|
||||
SM_ROMNAME_START = 0x007FC0
|
||||
|
||||
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SM_ENDGAME_MODES = {0x26, 0x27}
|
||||
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_QUEUE_START = SRAM_START + 0x2000
|
||||
SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602
|
||||
SM_SEND_QUEUE_START = SRAM_START + 0x2700
|
||||
SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680
|
||||
SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682
|
||||
SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
|
||||
SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
|
||||
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
|
||||
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
|
||||
|
||||
# SMZ3
|
||||
SMZ3_ROMNAME_START = ROM_START + 0x00FFC0
|
||||
SMZ3_ROMNAME_START = 0x00FFC0
|
||||
|
||||
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SMZ3_ENDGAME_MODES = {0x26, 0x27}
|
||||
@@ -601,7 +595,7 @@ class SNESState(enum.IntEnum):
|
||||
SNES_ATTACHED = 3
|
||||
|
||||
|
||||
def launch_sni():
|
||||
def launch_sni(ctx: Context):
|
||||
sni_path = Utils.get_options()["lttp_options"]["sni"]
|
||||
|
||||
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
|
||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems = set()
|
||||
while 1:
|
||||
succesful = False
|
||||
while not succesful:
|
||||
try:
|
||||
snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None)
|
||||
succesful = True
|
||||
except Exception as e:
|
||||
problem = "%s" % e
|
||||
# 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:
|
||||
# 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)
|
||||
else:
|
||||
@@ -1038,54 +1034,47 @@ async def game_watcher(ctx: Context):
|
||||
if not ctx.rom:
|
||||
ctx.finished_game = False
|
||||
ctx.death_link_allow_survive = False
|
||||
|
||||
from worlds.dkc3.Client import dkc3_rom_init
|
||||
init_handled = await dkc3_rom_init(ctx)
|
||||
if not init_handled:
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name[:2] == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
# 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]
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name[:2] == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
# 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:
|
||||
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
||||
if game_name == b"ZSM":
|
||||
ctx.game = GAME_SMZ3
|
||||
ctx.items_handling = 0b101 # local items and remote start inventory
|
||||
else:
|
||||
ctx.game = GAME_ALTTP
|
||||
ctx.items_handling = 0b001 # full local
|
||||
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:
|
||||
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
||||
if game_name == b"ZSM":
|
||||
ctx.game = GAME_SMZ3
|
||||
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)
|
||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||
continue
|
||||
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):
|
||||
continue
|
||||
|
||||
ctx.rom = rom
|
||||
if ctx.game != GAME_SMZ3:
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.allow_collect = bool(death_link[0] & 0b100)
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.locations_info = {}
|
||||
ctx.prev_rom = ctx.rom
|
||||
ctx.rom = rom
|
||||
if ctx.game != GAME_SMZ3:
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.allow_collect = bool(death_link[0] & 0b100)
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.locations_info = {}
|
||||
ctx.prev_rom = ctx.rom
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
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:
|
||||
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 track_locations(ctx, roomid, roomdata)
|
||||
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)
|
||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||
currently_dead = gamemode[0] in SM_DEATH_MODES
|
||||
@@ -1175,25 +1161,25 @@ async def game_watcher(ctx: Context):
|
||||
ctx.finished_game = True
|
||||
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:
|
||||
continue
|
||||
|
||||
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):
|
||||
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
|
||||
# itemId = message[2] | (message[3] << 8) # unused
|
||||
itemIndex = (message[4] | (message[5] << 8)) >> 3
|
||||
|
||||
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]))
|
||||
|
||||
from worlds.sm import locations_start_id
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
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)})')
|
||||
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:
|
||||
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 import locations_start_id
|
||||
from worlds.sm.Items import items_start_id
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
@@ -1219,10 +1206,10 @@ async def game_watcher(ctx: Context):
|
||||
locationId = 0x00 #backward compat
|
||||
|
||||
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]))
|
||||
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]))
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
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)))
|
||||
await snes_flush_writes(ctx)
|
||||
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)
|
||||
if (currentGame is not None):
|
||||
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]))
|
||||
|
||||
from worlds.smz3.TotalSMZ3.Location import locations_start_id
|
||||
from worlds.smz3 import convertLocSMZ3IDToAPID
|
||||
location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex)
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(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'),
|
||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||
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):
|
||||
@@ -1316,7 +1296,7 @@ async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
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'])
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import ctypes
|
||||
import logging
|
||||
import multiprocessing
|
||||
import logging
|
||||
import asyncio
|
||||
import os.path
|
||||
import re
|
||||
import sys
|
||||
import typing
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.data import Race
|
||||
|
||||
from sc2.main import run_game
|
||||
from sc2.data import Race
|
||||
from sc2.bot_ai import BotAI
|
||||
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.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__":
|
||||
init_logging("SC2Client", exception_logger="Client")
|
||||
@@ -36,49 +29,20 @@ sc2_logger = logging.getLogger("Starcraft2")
|
||||
|
||||
import colorama
|
||||
|
||||
from NetUtils import ClientStatus, RawJSONtoTextParser
|
||||
from NetUtils import *
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
nest_asyncio.apply()
|
||||
max_bonus: int = 8
|
||||
victory_modulo: int = 100
|
||||
|
||||
|
||||
class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
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:
|
||||
"""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."""
|
||||
self.ctx.missions_unlocked = True
|
||||
sc2_logger.info("Mission check has been disabled")
|
||||
return True
|
||||
|
||||
def _cmd_play(self, mission_id: str = "") -> bool:
|
||||
"""Start a Starcraft 2 mission"""
|
||||
@@ -94,33 +58,21 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
else:
|
||||
sc2_logger.info(
|
||||
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _cmd_available(self) -> bool:
|
||||
"""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
|
||||
|
||||
def _cmd_unfinished(self) -> bool:
|
||||
"""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
|
||||
|
||||
@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):
|
||||
command_processor = StarcraftClientProcessor
|
||||
@@ -128,19 +80,17 @@ class SC2Context(CommonContext):
|
||||
items_handling = 0b111
|
||||
difficulty = -1
|
||||
all_in_choice = 0
|
||||
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
||||
announcements = queue.Queue()
|
||||
mission_req_table = None
|
||||
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
|
||||
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
||||
missions_unlocked = False
|
||||
current_tooltip = 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):
|
||||
if password_requested and not self.password:
|
||||
@@ -153,35 +103,25 @@ class SC2Context(CommonContext):
|
||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||
slot_req_table = args["slot_data"]["mission_req"]
|
||||
self.mission_req_table = {
|
||||
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
|
||||
}
|
||||
self.mission_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()
|
||||
|
||||
# Look for and set SC2PATH.
|
||||
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||
check_mod_install()
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
# 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)
|
||||
if cmd in {"PrintJSON"}:
|
||||
if "receiving" in args:
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
self.announcements.append(args["data"])
|
||||
return
|
||||
if "item" in args:
|
||||
if self.slot_concerns_self(args["item"].player):
|
||||
self.announcements.append(args["data"])
|
||||
|
||||
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.clock import Clock
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
@@ -199,7 +139,6 @@ class SC2Context(CommonContext):
|
||||
|
||||
class MissionButton(HoverableButton):
|
||||
tooltip_text = StringProperty("Test")
|
||||
ctx: SC2Context
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HoverableButton, self).__init__(*args, **kwargs)
|
||||
@@ -220,7 +159,10 @@ class SC2Context(CommonContext):
|
||||
self.ctx.current_tooltip = self.layout
|
||||
|
||||
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
|
||||
def ctx(self) -> CommonContext:
|
||||
@@ -242,20 +184,13 @@ class SC2Context(CommonContext):
|
||||
mission_panel = None
|
||||
last_checked_locations = {}
|
||||
mission_id_to_button = {}
|
||||
launching: typing.Union[bool, int] = False # if int -> mission ID
|
||||
launching = False
|
||||
refresh_from_launching = True
|
||||
first_check = True
|
||||
ctx: SC2Context
|
||||
|
||||
def __init__(self, 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):
|
||||
container = super().build()
|
||||
|
||||
@@ -270,7 +205,7 @@ class SC2Context(CommonContext):
|
||||
|
||||
def build_mission_table(self, dt):
|
||||
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.mission_panel.clear_widgets()
|
||||
@@ -281,7 +216,12 @@ class SC2Context(CommonContext):
|
||||
|
||||
self.mission_id_to_button = {}
|
||||
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
|
||||
for mission in self.ctx.mission_req_table:
|
||||
@@ -292,8 +232,7 @@ class SC2Context(CommonContext):
|
||||
|
||||
for category in categories:
|
||||
category_panel = MissionCategory()
|
||||
category_panel.add_widget(
|
||||
Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||
|
||||
# Map is completed
|
||||
for mission in categories[category]:
|
||||
@@ -305,9 +244,7 @@ class SC2Context(CommonContext):
|
||||
text = f"[color=6495ED]{text}[/color]"
|
||||
|
||||
tooltip = f"Uncollected locations:\n"
|
||||
tooltip += "\n".join([self.ctx.location_names[loc] for loc in
|
||||
self.ctx.locations_for_mission(mission)
|
||||
if loc in self.ctx.missing_locations])
|
||||
tooltip += "\n".join(location for location in unfinished_locations[mission])
|
||||
elif mission in available_missions:
|
||||
text = f"[color=FFFFFF]{text}[/color]"
|
||||
# Map requirements not met
|
||||
@@ -315,7 +252,7 @@ class SC2Context(CommonContext):
|
||||
text = f"[color=a9a9a9]{text}[/color]"
|
||||
tooltip = f"Requires: "
|
||||
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
|
||||
self.ctx.mission_req_table[mission].required_world)
|
||||
|
||||
@@ -337,16 +274,13 @@ class SC2Context(CommonContext):
|
||||
self.refresh_from_launching = False
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
self.mission_panel.add_widget(Label(text="Launching Mission: " +
|
||||
lookup_id_to_mission[self.launching]))
|
||||
if self.ctx.ui:
|
||||
self.ctx.ui.clear_tooltip()
|
||||
self.mission_panel.add_widget(Label(text="Launching Mission"))
|
||||
|
||||
def mission_callback(self, button):
|
||||
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(mission_id)
|
||||
self.launching = mission_id
|
||||
self.ctx.play_mission(list(self.mission_id_to_button.keys())
|
||||
[list(self.mission_id_to_button.values()).index(button)])
|
||||
self.launching = True
|
||||
Clock.schedule_once(self.finish_launching, 10)
|
||||
|
||||
def finish_launching(self, dt):
|
||||
@@ -359,14 +293,12 @@ class SC2Context(CommonContext):
|
||||
|
||||
async def shutdown(self):
|
||||
await super(SC2Context, self).shutdown()
|
||||
if self.last_bot:
|
||||
self.last_bot.want_close = True
|
||||
if self.sc2_run_task:
|
||||
self.sc2_run_task.cancel()
|
||||
|
||||
def play_mission(self, mission_id: int):
|
||||
def play_mission(self, mission_id):
|
||||
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 not self.sc2_run_task.done():
|
||||
sc2_logger.warning("Starcraft 2 Client is still running!")
|
||||
@@ -375,29 +307,12 @@ class SC2Context(CommonContext):
|
||||
sc2_logger.warning("Launching Mission without Archipelago authentication, "
|
||||
"checks will not be registered to server.")
|
||||
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
|
||||
name="Starcraft 2 Launch")
|
||||
name="Starcraft 2 Launch")
|
||||
else:
|
||||
sc2_logger.info(
|
||||
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
|
||||
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():
|
||||
multiprocessing.freeze_support()
|
||||
@@ -437,27 +352,47 @@ wol_default_categories = [
|
||||
]
|
||||
|
||||
|
||||
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
|
||||
network_item: NetUtils.NetworkItem
|
||||
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
||||
def calculate_items(items):
|
||||
unit_unlocks = 0
|
||||
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:
|
||||
name: str = lookup_id_to_name[network_item.item]
|
||||
item_data: ItemData = item_table[name]
|
||||
for item in items:
|
||||
data = lookup_id_to_name[item.item]
|
||||
|
||||
# exists exactly once
|
||||
if item_data.quantity == 1:
|
||||
accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number
|
||||
if item_table[data].type == "Unit":
|
||||
unit_unlocks += (1 << item_table[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
|
||||
elif item_data.type == "Upgrade":
|
||||
accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number
|
||||
|
||||
# sum
|
||||
else:
|
||||
accumulators[type_flaggroups[item_data.type]] += item_data.number
|
||||
|
||||
return accumulators
|
||||
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
|
||||
lab_unlocks, protoss_unlock, minerals, vespene, supply]
|
||||
|
||||
|
||||
def calc_difficulty(difficulty):
|
||||
@@ -473,48 +408,46 @@ def calc_difficulty(difficulty):
|
||||
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.")
|
||||
|
||||
with DllDirectory(None):
|
||||
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
||||
name="Archipelago", fullscreen=True)], realtime=True)
|
||||
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
||||
name="Archipelago", fullscreen=True)], realtime=True)
|
||||
|
||||
|
||||
class ArchipelagoBot(sc2.bot_ai.BotAI):
|
||||
game_running: bool = False
|
||||
mission_completed: bool = False
|
||||
boni: typing.List[bool]
|
||||
setup_done: bool
|
||||
ctx: SC2Context
|
||||
mission_id: int
|
||||
want_close: bool = False
|
||||
game_running = False
|
||||
mission_completed = False
|
||||
first_bonus = False
|
||||
second_bonus = False
|
||||
third_bonus = False
|
||||
fourth_bonus = False
|
||||
fifth_bonus = False
|
||||
sixth_bonus = False
|
||||
seventh_bonus = False
|
||||
eight_bonus = False
|
||||
ctx: SC2Context = None
|
||||
mission_id = 0
|
||||
|
||||
can_read_game = False
|
||||
|
||||
last_received_update: int = 0
|
||||
last_received_update = 0
|
||||
|
||||
def __init__(self, ctx: SC2Context, mission_id):
|
||||
self.setup_done = False
|
||||
self.ctx = ctx
|
||||
self.ctx.last_bot = self
|
||||
self.mission_id = mission_id
|
||||
self.boni = [False for _ in range(max_bonus)]
|
||||
|
||||
super(ArchipelagoBot, self).__init__()
|
||||
|
||||
async def on_step(self, iteration: int):
|
||||
if self.want_close:
|
||||
self.want_close = False
|
||||
await self._client.leave()
|
||||
return
|
||||
game_state = 0
|
||||
if not self.setup_done:
|
||||
self.setup_done = True
|
||||
if iteration == 0:
|
||||
start_items = calculate_items(self.ctx.items_received)
|
||||
if self.ctx.difficulty_override >= 0:
|
||||
difficulty = calc_difficulty(self.ctx.difficulty_override)
|
||||
else:
|
||||
difficulty = calc_difficulty(self.ctx.difficulty)
|
||||
difficulty = calc_difficulty(self.ctx.difficulty)
|
||||
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
||||
difficulty,
|
||||
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)
|
||||
|
||||
else:
|
||||
if not self.ctx.announcements.empty():
|
||||
message = self.ctx.announcements.get(timeout=1)
|
||||
if self.ctx.announcement_pos < len(self.ctx.announcements):
|
||||
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)
|
||||
self.ctx.announcements.task_done()
|
||||
self.ctx.announcement_pos += 1
|
||||
|
||||
# Archipelago reads the health
|
||||
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 self.mission_id != 29:
|
||||
print("Mission Completed")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}])
|
||||
await self.ctx.send_msgs([
|
||||
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
|
||||
self.mission_completed = True
|
||||
else:
|
||||
print("Game Complete")
|
||||
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
|
||||
self.mission_completed = True
|
||||
|
||||
for x, completed in enumerate(self.boni):
|
||||
if not completed and game_state & (1 << (x + 2)):
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}])
|
||||
self.boni[x] = True
|
||||
if game_state & (1 << 2) and not self.first_bonus:
|
||||
print("1st Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
|
||||
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:
|
||||
await self.chat_send("LostConnection - Lost connection to game.")
|
||||
|
||||
|
||||
def request_unfinished_missions(ctx: SC2Context):
|
||||
if ctx.mission_req_table:
|
||||
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
|
||||
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: "
|
||||
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
||||
unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table)
|
||||
unlocks = initialize_blank_mission_dict(location_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(
|
||||
f"[{len(unfinished_missions[mission])}/"
|
||||
f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
|
||||
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
|
||||
ctx, unfinished_locations, mission)
|
||||
for mission in unfinished_missions)
|
||||
|
||||
if ctx.ui:
|
||||
ctx.ui.log_panels['All'].on_message_markup(message)
|
||||
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
if ui:
|
||||
ui.log_panels['All'].on_message_markup(message)
|
||||
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
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 = []
|
||||
locations_completed = []
|
||||
|
||||
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:
|
||||
objectives = set(ctx.locations_for_mission(name))
|
||||
if objectives:
|
||||
objectives_completed = ctx.checked_locations & objectives
|
||||
if len(objectives_completed) < len(objectives):
|
||||
if not locations[name].extra_locations == -1:
|
||||
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
|
||||
|
||||
if objectives_completed < locations[name].extra_locations:
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(objectives_completed)
|
||||
|
||||
else: # infer that this is the final mission as it has no objectives
|
||||
else:
|
||||
unfinished_missions.append(name)
|
||||
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):
|
||||
unfinished_missions = calc_available_missions(ctx)
|
||||
def is_mission_available(mission_id_to_check, locations_done, locations):
|
||||
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."""
|
||||
|
||||
if ctx.mission_req_table[mission].completion_critical:
|
||||
if ctx.ui:
|
||||
if location_table[mission].completion_critical:
|
||||
if ui:
|
||||
message = "[color=AF99EF]" + mission + "[/color]"
|
||||
else:
|
||||
message = "*" + mission + "*"
|
||||
else:
|
||||
message = mission
|
||||
|
||||
if ctx.ui:
|
||||
if ui:
|
||||
unlocks = unlock_table[mission]
|
||||
|
||||
if len(unlocks) > 0:
|
||||
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: "
|
||||
pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks)
|
||||
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
|
||||
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
|
||||
pre_message += f"]"
|
||||
message = pre_message + message + "[/ref]"
|
||||
|
||||
@@ -657,7 +688,7 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
||||
if ctx.ui:
|
||||
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 += f"]"
|
||||
formatted_message = pre_message + message + "[/ref]"
|
||||
@@ -665,91 +696,90 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
||||
return formatted_message
|
||||
|
||||
|
||||
def request_available_missions(ctx: SC2Context):
|
||||
if ctx.mission_req_table:
|
||||
def request_available_missions(locations_done, location_table, ui):
|
||||
if location_table:
|
||||
message = "Available Missions: "
|
||||
|
||||
# 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 += \
|
||||
", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
|
||||
f"[{ctx.mission_req_table[mission].id}]"
|
||||
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
|
||||
for mission in missions)
|
||||
|
||||
if ctx.ui:
|
||||
ctx.ui.log_panels['All'].on_message_markup(message)
|
||||
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
if ui:
|
||||
ui.log_panels['All'].on_message_markup(message)
|
||||
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
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 = []
|
||||
missions_complete = 0
|
||||
|
||||
# Get number of missions completed
|
||||
for loc in ctx.checked_locations:
|
||||
if loc % victory_modulo == 0:
|
||||
for loc in locations_done:
|
||||
if loc % 100 == 0:
|
||||
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
|
||||
if unlocks:
|
||||
for unlock in ctx.mission_req_table[name].required_world:
|
||||
unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
|
||||
for unlock in locations[name].required_world:
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
Arguments:
|
||||
ctx -- instance of SC2Context
|
||||
Keyword arguments:
|
||||
locations_to_check -- the mission string name to check
|
||||
missions_complete -- an int of how many missions have been completed
|
||||
"""
|
||||
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
||||
locations_done -- a list of the location ids that have been complete
|
||||
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
|
||||
or_success = False
|
||||
|
||||
# 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
|
||||
|
||||
# Check if required mission has been completed
|
||||
if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
|
||||
victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
|
||||
if not ctx.mission_req_table[mission_name].or_requirements:
|
||||
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
|
||||
if not locations[location_to_check].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# 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 ctx.mission_req_table[mission_name].or_requirements:
|
||||
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
|
||||
locations):
|
||||
if not locations[location_to_check].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# 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
|
||||
|
||||
if ctx.mission_req_table[mission_name].or_requirements:
|
||||
if locations[location_to_check].or_requirements:
|
||||
# Return false if or requirements not met
|
||||
if not or_success:
|
||||
return False
|
||||
|
||||
# Check number of missions
|
||||
if missions_complete >= ctx.mission_req_table[mission_name].number:
|
||||
if missions_complete >= locations[location_to_check].number:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -766,101 +796,6 @@ def initialize_blank_mission_dict(location_table):
|
||||
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__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
|
||||
195
Utils.py
195
Utils.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
@@ -11,18 +12,12 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
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
|
||||
import decimal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import tkinter
|
||||
import pathlib
|
||||
from tkinter import Tk
|
||||
else:
|
||||
Tk = typing.Any
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> Version:
|
||||
@@ -35,13 +30,21 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.3.5"
|
||||
__version__ = "0.3.3"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_macos = sys.platform == "darwin"
|
||||
is_linux = sys.platform.startswith('linux')
|
||||
is_macos = sys.platform == 'darwin'
|
||||
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]:
|
||||
value = value & 0xFFFF
|
||||
@@ -122,18 +125,17 @@ def home_path(*path: str) -> str:
|
||||
|
||||
def user_path(*path: str) -> str:
|
||||
"""Returns either local_path or home_path based on write permissions."""
|
||||
if hasattr(user_path, "cached_path"):
|
||||
if hasattr(user_path, 'cached_path'):
|
||||
pass
|
||||
elif os.access(local_path(), os.W_OK):
|
||||
user_path.cached_path = local_path()
|
||||
else:
|
||||
user_path.cached_path = home_path()
|
||||
# populate home from local - TODO: upgrade feature
|
||||
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
|
||||
import shutil
|
||||
for dn in ("Players", "data/sprites"):
|
||||
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
|
||||
for dn in ('Players', 'data/sprites'):
|
||||
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))
|
||||
|
||||
return os.path.join(user_path.cached_path, *path)
|
||||
@@ -148,12 +150,11 @@ def output_path(*path: str):
|
||||
return path
|
||||
|
||||
|
||||
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
||||
if is_windows:
|
||||
def open_file(filename):
|
||||
if sys.platform == 'win32':
|
||||
os.startfile(filename)
|
||||
else:
|
||||
from shutil import which
|
||||
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||
open_command = 'open' if sys.platform == 'darwin' else 'xdg-open'
|
||||
subprocess.call([open_command, filename])
|
||||
|
||||
|
||||
@@ -172,9 +173,7 @@ class UniqueKeyLoader(SafeLoader):
|
||||
|
||||
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
|
||||
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader)
|
||||
|
||||
del load, load_all # should not be used. don't leak their names
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
|
||||
|
||||
def get_cert_none_ssl_context():
|
||||
@@ -192,12 +191,11 @@ def get_public_ipv4() -> str:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
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:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
|
||||
except Exception:
|
||||
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
|
||||
except:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, so no point in erroring out
|
||||
return ip
|
||||
@@ -210,7 +208,7 @@ def get_public_ipv6() -> str:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
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:
|
||||
logging.exception(e)
|
||||
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": {
|
||||
"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
|
||||
@@ -311,19 +304,33 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
if not hasattr(get_options, "options"):
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
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:
|
||||
if os.path.exists(location):
|
||||
with open(location) as f:
|
||||
options = parse_yaml(f.read())
|
||||
return update_options(get_default_options(), options, location, list())
|
||||
for location in locations:
|
||||
if os.path.exists(location):
|
||||
with open(location) as f:
|
||||
options = parse_yaml(f.read())
|
||||
|
||||
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):
|
||||
@@ -332,10 +339,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||
category = storage.setdefault(category, {})
|
||||
category[key] = value
|
||||
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)
|
||||
if storage:
|
||||
return storage
|
||||
@@ -353,8 +360,8 @@ def persistent_load() -> typing.Dict[str, dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(game_name: str):
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||
def get_adjuster_settings(gameName: str):
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
|
||||
return adjuster_settings
|
||||
|
||||
|
||||
@@ -370,10 +377,10 @@ def get_unique_identifier():
|
||||
return uuid
|
||||
|
||||
|
||||
safe_builtins = frozenset((
|
||||
safe_builtins = {
|
||||
'set',
|
||||
'frozenset',
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
@@ -401,7 +408,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if issubclass(obj, self.options_module.Option):
|
||||
return obj
|
||||
# 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):
|
||||
@@ -410,9 +418,6 @@ def restricted_loads(s):
|
||||
|
||||
|
||||
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):
|
||||
self[key] = value = self.default_factory(key)
|
||||
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)]
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -473,13 +474,9 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
def stream_input(stream, queue):
|
||||
def queuer():
|
||||
while 1:
|
||||
try:
|
||||
text = stream.readline().strip()
|
||||
except UnicodeDecodeError as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
text = stream.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
||||
@@ -487,11 +484,11 @@ def stream_input(stream, queue):
|
||||
return thread
|
||||
|
||||
|
||||
def tkinter_center_window(window: "tkinter.Tk") -> None:
|
||||
def tkinter_center_window(window: Tk):
|
||||
window.update()
|
||||
x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
||||
y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
||||
window.geometry(f"+{x}+{y}")
|
||||
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
||||
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
||||
window.geometry("+{}+{}".format(xPos, yPos))
|
||||
|
||||
|
||||
class VersionException(Exception):
|
||||
@@ -508,27 +505,24 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
||||
|
||||
|
||||
# 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"""
|
||||
import decimal
|
||||
n = 0
|
||||
value = decimal.Decimal(value)
|
||||
limit = power - decimal.Decimal("0.005")
|
||||
while value >= limit:
|
||||
while value >= power:
|
||||
value /= power
|
||||
n += 1
|
||||
|
||||
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) \
|
||||
-> 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)
|
||||
return list(
|
||||
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]]]) \
|
||||
-> typing.Optional[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:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
kdialog = shutil.which('kdialog')
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
|
||||
zenity = which("zenity")
|
||||
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
|
||||
zenity = shutil.which('zenity')
|
||||
if zenity:
|
||||
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
|
||||
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 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():
|
||||
if "kivy" in sys.modules:
|
||||
if 'kivy' in sys.modules:
|
||||
from kivy.app import App
|
||||
return App.get_running_app() is not None
|
||||
return False
|
||||
@@ -589,15 +582,14 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
MessageBox(title, text, error).open()
|
||||
return
|
||||
|
||||
if is_linux and "tkinter" not in sys.modules:
|
||||
if is_linux and not 'tkinter' in sys.modules:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
kdialog = shutil.which('kdialog')
|
||||
if kdialog:
|
||||
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||
zenity = which("zenity")
|
||||
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
|
||||
zenity = shutil.which('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
|
||||
try:
|
||||
@@ -612,14 +604,3 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
root.withdraw()
|
||||
showerror(title, text) if error else showinfo(title, text)
|
||||
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))
|
||||
|
||||
41
WebHost.py
41
WebHost.py
@@ -12,9 +12,9 @@ ModuleUpdate.update()
|
||||
# in case app gets imported by something like gunicorn
|
||||
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 WebHostLib.models import db
|
||||
@@ -22,13 +22,14 @@ from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app():
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath):
|
||||
import yaml
|
||||
@@ -42,39 +43,19 @@ def get_app():
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
zfile: zipfile.ZipInfo
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
zipfile_path = world.zip_path
|
||||
|
||||
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))
|
||||
|
||||
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
|
||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
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))
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, '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:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
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)
|
||||
return sorted_data
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -3,13 +3,13 @@ import uuid
|
||||
import base64
|
||||
import socket
|
||||
|
||||
import jinja2.exceptions
|
||||
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_compress import Compress
|
||||
from werkzeug.routing import BaseConverter
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
from Utils import title_sorted
|
||||
from .models import *
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
@@ -53,6 +53,8 @@ app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
cache = Cache(app)
|
||||
Compress(app)
|
||||
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
@@ -66,18 +68,170 @@ class B64UUIDConverter(BaseConverter):
|
||||
# short UUID
|
||||
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["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
# has automatic patch integration
|
||||
import Patch
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||
def get_world_theme(game_name: str):
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
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)
|
||||
|
||||
@@ -32,14 +32,14 @@ def room_info(room: UUID):
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
def get_datapackge():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackage_versions():
|
||||
def get_datapackge_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
|
||||
@@ -154,10 +154,8 @@ def autogen(config: dict):
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
except AlreadyRunningException:
|
||||
@@ -184,7 +182,7 @@ class MultiworldInstance():
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
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")
|
||||
process.start()
|
||||
# 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 .customserver import run_server_process, get_static_server_data
|
||||
from .customserver import run_server_process
|
||||
from .generate import gen_game
|
||||
|
||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Generate import roll_settings, PlandoSettings
|
||||
from Generate import roll_settings
|
||||
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]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||
plando_options = set(plando_options)
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
|
||||
@@ -9,13 +9,12 @@ import time
|
||||
import random
|
||||
import pickle
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
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 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):
|
||||
@@ -40,7 +39,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
import MultiServer
|
||||
|
||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||
del MultiServer
|
||||
del (MultiServer)
|
||||
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
@@ -49,20 +48,12 @@ class DBCommandProcessor(ServerCommandProcessor):
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
def __init__(self, static_server_data: dict):
|
||||
# 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
|
||||
def __init__(self):
|
||||
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.video = {}
|
||||
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):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
@@ -103,7 +94,7 @@ class WebHostContext(Context):
|
||||
room.multisave = pickle.dumps(self.get_save())
|
||||
# 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
|
||||
room.last_activity = datetime.datetime.utcnow()
|
||||
room.last_activity = datetime.utcnow()
|
||||
return True
|
||||
|
||||
def get_save(self) -> dict:
|
||||
@@ -116,32 +107,14 @@ def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
|
||||
@cache_argsless
|
||||
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):
|
||||
def run_server_process(room_id, ponyconfig: dict):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx = WebHostContext()
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
|
||||
@@ -32,21 +32,18 @@ def download_patch(room_id, patch_id):
|
||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||
else:
|
||||
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)}" \
|
||||
f"{patch_file_ending}"
|
||||
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
|
||||
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:
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, download_name=fname)
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
|
||||
|
||||
@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
|
||||
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)
|
||||
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":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
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"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
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:
|
||||
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")
|
||||
|
||||
@@ -4,7 +4,7 @@ import random
|
||||
import json
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, Optional as TypeOptional
|
||||
from Utils import __version__
|
||||
|
||||
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 Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoSettings
|
||||
from Generate import handle_name
|
||||
import pickle
|
||||
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||
from .models import *
|
||||
from WebHostLib import app
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .upload import upload_zip_to_db
|
||||
@@ -30,15 +30,16 @@ def get_meta(options_source: dict) -> dict:
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
server_options = {
|
||||
meta = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"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'])
|
||||
@@ -59,13 +60,13 @@ def generate(race=False):
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
|
||||
if race:
|
||||
meta["server_options"]["item_cheat"] = False
|
||||
meta["server_options"]["remaining_mode"] = "disabled"
|
||||
meta["item_cheat"] = False
|
||||
meta["remaining_mode"] = "disabled"
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
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.")
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
gen = Generation(
|
||||
@@ -91,35 +92,35 @@ def generate(race=False):
|
||||
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:
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("race", False)
|
||||
meta: Dict[str, object] = {}
|
||||
|
||||
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:
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
seed = get_seed()
|
||||
random.seed(seed)
|
||||
|
||||
if race:
|
||||
random.seed() # use time-based random source
|
||||
else:
|
||||
random.seed(seed)
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
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.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.plando_options = ", ".join(plando_options)
|
||||
|
||||
name_counter = Counter()
|
||||
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)
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
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)
|
||||
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["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
|
||||
commit()
|
||||
raise
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -27,7 +27,7 @@ class Room(db.Entity):
|
||||
seed = Required('Seed', index=True)
|
||||
multisave = Optional(buffer, lazy=True)
|
||||
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)
|
||||
last_port = Optional(int, default=lambda: 0)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from Utils import __version__, local_path
|
||||
from Utils import __version__
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
@@ -9,13 +9,14 @@ import typing
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import Options
|
||||
|
||||
target_folder = os.path.join("WebHostLib", "static", "generated")
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
|
||||
|
||||
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]):
|
||||
data = {}
|
||||
@@ -48,11 +49,6 @@ def create():
|
||||
return list(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 = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
@@ -64,17 +60,13 @@ def create():
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
all_options = {**world.options, **Options.per_game_common_options}
|
||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
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:
|
||||
f.write(res)
|
||||
|
||||
@@ -96,7 +88,7 @@ def create():
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"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,
|
||||
"options": []
|
||||
}
|
||||
@@ -118,18 +110,18 @@ def create():
|
||||
if option.default == "random":
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif issubclass(option, Options.Range):
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"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(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"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]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
@@ -139,22 +131,22 @@ def create():
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"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):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"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:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
flask>=2.2.2
|
||||
flask>=2.1.2
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.1
|
||||
waitress>=2.1.1
|
||||
flask-caching>=1.11.1
|
||||
Flask-Compress>=1.12
|
||||
Flask-Limiter>=2.6.2
|
||||
bokeh>=2.4.3
|
||||
Flask-Limiter>=2.4.6
|
||||
bokeh>=2.4.3
|
||||
@@ -102,15 +102,9 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.textContent = `${settings[setting].displayName}: `;
|
||||
label.setAttribute('for', setting);
|
||||
|
||||
const questionSpan = document.createElement('span');
|
||||
questionSpan.classList.add('interactive');
|
||||
questionSpan.setAttribute('data-tooltip', settings[setting].description);
|
||||
questionSpan.innerText = '(?)';
|
||||
|
||||
label.appendChild(questionSpan);
|
||||
label.setAttribute('data-tooltip', settings[setting].description);
|
||||
label.innerText = `${settings[setting].displayName}:`;
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
|
||||
games.forEach((game) => {
|
||||
const gameTitle = document.createElement('h2');
|
||||
gameTitle.innerText = game.gameTitle;
|
||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||
tutorialDiv.appendChild(gameTitle);
|
||||
|
||||
game.tutorials.forEach((tutorial) => {
|
||||
@@ -66,15 +65,6 @@ window.addEventListener('load', () => {
|
||||
showError();
|
||||
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.send();
|
||||
|
||||
@@ -56,3 +56,7 @@
|
||||
#file-input{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,3 @@ h5, h6{
|
||||
margin-bottom: 20px;
|
||||
background-color: #ffff00;
|
||||
}
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
}
|
||||
@@ -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 */
|
||||
[data-tooltip], .tooltip {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.tooltip:after, [data-tooltip]:after {
|
||||
width: 260px;
|
||||
z-index: 10000;
|
||||
padding: 8px;
|
||||
width: 160px;
|
||||
border-radius: 4px;
|
||||
background-color: #000;
|
||||
background-color: hsla(0, 0%, 20%, 0.9);
|
||||
color: #fff;
|
||||
content: attr(data-tooltip);
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -1,104 +1,54 @@
|
||||
from collections import Counter, defaultdict
|
||||
from colorsys import hsv_to_rgb
|
||||
from itertools import cycle
|
||||
from datetime import datetime, timedelta, date
|
||||
from math import tau
|
||||
import typing
|
||||
|
||||
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.resources import INLINE
|
||||
from bokeh.colors import RGB
|
||||
from flask import render_template
|
||||
from pony.orm import select
|
||||
|
||||
from . import app, cache
|
||||
from .models import Room
|
||||
|
||||
PLOT_WIDTH = 600
|
||||
|
||||
|
||||
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
|
||||
def get_db_data():
|
||||
games_played = defaultdict(Counter)
|
||||
total_games = Counter()
|
||||
cutoff = date.today()-timedelta(days=30)
|
||||
cutoff = date.today()-timedelta(days=30000)
|
||||
room: Room
|
||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||
for slot in room.seed.slots:
|
||||
if slot.game in known_games:
|
||||
total_games[slot.game] += 1
|
||||
games_played[room.creation_time.date()][slot.game] += 1
|
||||
total_games[slot.game] += 1
|
||||
games_played[room.creation_time.date()][slot.game] += 1
|
||||
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')
|
||||
@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():
|
||||
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",
|
||||
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)
|
||||
|
||||
color_palette = get_color_palette(len(total_games))
|
||||
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
||||
cyc_palette = cycle(palette)
|
||||
|
||||
for game in sorted(total_games):
|
||||
occurences = []
|
||||
for day in days:
|
||||
occurences.append(games_played[day][game])
|
||||
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())
|
||||
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")],
|
||||
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.xgrid.visible = False
|
||||
pie.ygrid.visible = False
|
||||
|
||||
data = {
|
||||
"games": [],
|
||||
@@ -115,15 +65,12 @@ def stats():
|
||||
current_angle += angle
|
||||
data["end_angles"].append(current_angle)
|
||||
|
||||
data["colors"] = [game_to_color[game] for game in data["games"]]
|
||||
|
||||
pie.wedge(x=0, y=0, radius=0.5,
|
||||
data["colors"] = [element[1] for element in sorted((game, color) for game, color in
|
||||
zip(data["games"], cycle(palette)))]
|
||||
pie.wedge(x=0.5, y=0.5, radius=0.5,
|
||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||
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
|
||||
if total_games[game] > 1]
|
||||
|
||||
script, charts = components((plot, pie, *per_game_charts))
|
||||
script, charts = components((plot, pie))
|
||||
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
|
||||
chart_data=script, charts=charts)
|
||||
|
||||
@@ -41,11 +41,12 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="forfeit_mode">Forfeit Permission:
|
||||
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<select name="forfeit_mode" id="forfeit_mode">
|
||||
@@ -62,11 +63,12 @@
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<label for="collect_mode">Collect Permission:
|
||||
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<select name="collect_mode" id="collect_mode">
|
||||
@@ -83,11 +85,12 @@
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<label for="remaining_mode">Remaining Permission:
|
||||
<span class="interactive" data-tooltip="Remaining lists all items still in your world by name only.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
<label for="remaining_mode">Remaining Permission:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="Remaining lists all items still in your world by name only."
|
||||
>(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="remaining_mode" id="remaining_mode">
|
||||
@@ -103,11 +106,11 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="item_cheat">Item Cheat:
|
||||
<span class="interactive" data-tooltip="Allows players to use the !getitem command.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
<label for="item_cheat">Item Cheat:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="Allows players to use the !getitem command.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="item_cheat" id="item_cheat">
|
||||
@@ -128,11 +131,12 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="hint_cost"> Hint Cost:
|
||||
<span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<select name="hint_cost" id="hint_cost">
|
||||
@@ -146,11 +150,11 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="server_password">Server Password:
|
||||
<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>
|
||||
</label>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<input id="server_password" name="server_password">
|
||||
@@ -158,22 +162,23 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Plando Options:
|
||||
<span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">
|
||||
(?)
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
{% import "macros.html" as macros %}
|
||||
{% block head %}
|
||||
<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") }}"/>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,9 +16,9 @@
|
||||
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue
|
||||
later,
|
||||
you can simply refresh this page and the server will be started again.<br>
|
||||
{% if room.last_port %}
|
||||
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 }}.">
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
-
|
||||
<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/issues">Bug Report</a>
|
||||
|
||||
@@ -40,12 +40,9 @@
|
||||
{% 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>
|
||||
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>
|
||||
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 %}
|
||||
No file to download for this game.
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% 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/supportedGames.css") }}" />
|
||||
{% endblock %}
|
||||
@@ -10,21 +10,15 @@
|
||||
{% include 'header/oceanHeader.html' %}
|
||||
<div id="games" class="markdown">
|
||||
<h1>Currently Supported Games</h1>
|
||||
{% for game_name in worlds | title_sorted %}
|
||||
{% set world = worlds[game_name] %}
|
||||
{% for game_name, world in worlds.items() | sort(attribute=0) %}
|
||||
<h2>{{ game_name }}</h2>
|
||||
<p>
|
||||
{{ world.__doc__ | default("No description provided.", true) }}<br />
|
||||
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
|
||||
{% if world.web.tutorials %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
|
||||
{% endif %}
|
||||
{% if world.web.settings_page is string %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ world.web.settings_page }}">Settings Page</a>
|
||||
{% elif world.web.settings_page %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
|
||||
{% endif %}
|
||||
{% if world.web.bug_report_page %}
|
||||
|
||||
@@ -11,7 +11,7 @@ from worlds.alttp import Items
|
||||
from WebHostLib import app, cache, Room
|
||||
from Utils import restricted_loads
|
||||
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
|
||||
|
||||
alttp_icons = {
|
||||
@@ -987,10 +987,10 @@ def getTracker(tracker: UUID):
|
||||
if game_state == 30:
|
||||
inventory[team][player][106] = 1 # Triforce
|
||||
|
||||
player_big_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)}
|
||||
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) if playernumber not in groups}
|
||||
for loc_data in locations.values():
|
||||
for values in loc_data.values():
|
||||
for values in loc_data.values():
|
||||
item_id, item_player, flags = values
|
||||
|
||||
if item_id in ids_big_key:
|
||||
@@ -1021,7 +1021,7 @@ def getTracker(tracker: UUID):
|
||||
for (team, player), data in multisave.get("video", []):
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
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"):
|
||||
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
|
||||
|
||||
|
||||
@@ -97,11 +97,6 @@ local extensionConsumableLookup = {
|
||||
[443] = 0x3F
|
||||
}
|
||||
|
||||
local noOverworldItemsLookup = {
|
||||
[499] = 0x2B,
|
||||
[500] = 0x12,
|
||||
}
|
||||
|
||||
local itemMessages = {}
|
||||
local consumableStacks = nil
|
||||
local prevstate = ""
|
||||
@@ -346,7 +341,7 @@ function processBlock(block)
|
||||
-- This is a key item
|
||||
memoryLocation = memoryLocation - 0x0E0
|
||||
wU8(memoryLocation, 0x01)
|
||||
elseif v >= 0x1E0 and v <= 0x1F2 then
|
||||
elseif v >= 0x1E0 then
|
||||
-- This is a movement item
|
||||
-- Minus Offset (0x100) - movement offset (0xE0)
|
||||
memoryLocation = memoryLocation - 0x1E0
|
||||
@@ -356,10 +351,7 @@ function processBlock(block)
|
||||
else
|
||||
wU8(memoryLocation, 0x01)
|
||||
end
|
||||
elseif v >= 0x1F3 and v <= 0x1F4 then
|
||||
-- NoOverworld special items
|
||||
memoryLocation = noOverworldItemsLookup[v]
|
||||
wU8(memoryLocation, 0x01)
|
||||
|
||||
elseif v >= 0x16C and v <= 0x1AF then
|
||||
-- This is a gold item
|
||||
amountToAdd = goldLookup[v]
|
||||
|
||||
@@ -2,8 +2,8 @@ local socket = require("socket")
|
||||
local json = require('json')
|
||||
local math = require('math')
|
||||
|
||||
local last_modified_date = '2022-07-24' -- Should be the last modified date
|
||||
local script_version = 2
|
||||
local last_modified_date = '2022-05-25' -- Should be the last modified date
|
||||
local script_version = 1
|
||||
|
||||
--------------------------------------------------
|
||||
-- Heavily modified form of RiptideSage's tracker
|
||||
@@ -1723,11 +1723,6 @@ function get_death_state()
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -1829,15 +1824,13 @@ function main()
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
if (frame % 60 == 0) then
|
||||
server:settimeout(2)
|
||||
print("Attempting to connect")
|
||||
local client, timeout = server:accept()
|
||||
if timeout == nil then
|
||||
print('Initial Connection Made')
|
||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||
ootSocket = client
|
||||
ootSocket:settimeout(0)
|
||||
else
|
||||
print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua')
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
BIN
docs/network diagram.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
@@ -8,15 +8,6 @@ flowchart LR
|
||||
CC[CommonClient.py]
|
||||
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
|
||||
subgraph ChecksFinder
|
||||
CFC[ChecksFinderClient]
|
||||
@@ -69,12 +60,6 @@ flowchart LR
|
||||
end
|
||||
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
|
||||
%% Games or clients which compile to native or which the client is integrated in the game.
|
||||
subgraph "Native"
|
||||
@@ -87,16 +72,12 @@ flowchart LR
|
||||
V6[VVVVVV]
|
||||
MT[Meritous]
|
||||
TW[The Witness]
|
||||
SA2B[Sonic Adventure 2: Battle]
|
||||
DS3[Dark Souls 3]
|
||||
|
||||
APCLIENTPP <--> SOE
|
||||
APCLIENTPP <--> MT
|
||||
APCLIENTPP <-- The Witness Randomizer --> TW
|
||||
APCLIENTPP <--> DS3
|
||||
APCPP <--> SM64
|
||||
APCPP <--> V6
|
||||
APCPP <--> SA2B
|
||||
end
|
||||
SOE <--> SNI <-- Various, depending on SNES device --> SOESNES
|
||||
AS <-- WebSockets --> APCLIENTPP
|
||||
1
docs/network diagram.svg
Normal file
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 |
@@ -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.
|
||||
|
||||
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 |
|
||||
|-------------------------------|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
|
||||
| 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) | |
|
||||
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)
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
|
||||
### Print
|
||||
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)*
|
||||
Sent to clients purely to display a message to the player.
|
||||
#### Arguments
|
||||
| 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 |
|
||||
| ---- | ---- | ----- |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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
|
||||
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
|
||||
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
|
||||
Sent to clients as a response the a [Get](#Get) package.
|
||||
Sent to clients as a response the a [Get](#Get) package
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
@@ -522,7 +486,7 @@ Color options:
|
||||
* green_bg
|
||||
* yellow_bg
|
||||
* blue_bg
|
||||
* magenta_bg
|
||||
* purple_bg
|
||||
* cyan_bg
|
||||
* white_bg
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -86,7 +86,7 @@ inside a World object.
|
||||
|
||||
Players provide customized settings for their World in the form of yamls.
|
||||
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.
|
||||
|
||||
### World Options
|
||||
@@ -236,7 +236,7 @@ class MyGameLocation(Location):
|
||||
game: str = "My Game"
|
||||
|
||||
# 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)
|
||||
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.
|
||||
|
||||
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`.
|
||||
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):
|
||||
#...
|
||||
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):
|
||||
"""Insert description of the world/game here."""
|
||||
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
|
||||
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
|
||||
@@ -487,14 +487,14 @@ def create_items(self) -> None:
|
||||
for item in map(self.create_item, mygame_items):
|
||||
if item in exclude:
|
||||
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:
|
||||
self.world.itempool.append(item)
|
||||
|
||||
# itempool and number of locations should match up.
|
||||
# If this is not the case we want to fill the itempool with junk.
|
||||
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
|
||||
@@ -628,7 +628,7 @@ class MyGameLogic(LogicMixin):
|
||||
def _mygame_has_key(self, world: MultiWorld, player: int):
|
||||
# Arguments above are free to choose
|
||||
# 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
|
||||
# __init__.py
|
||||
|
||||
13
host.yaml
13
host.yaml
@@ -101,9 +101,7 @@ sm_options:
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
rom_start: true
|
||||
factorio_options:
|
||||
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"
|
||||
executable: "factorio\\bin\\x64\\factorio"
|
||||
minecraft_options:
|
||||
forge_directory: "Minecraft Forge server"
|
||||
max_heap_size: "2G"
|
||||
@@ -129,12 +127,3 @@ smz3_options:
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
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
|
||||
|
||||
@@ -54,7 +54,6 @@ Name: "custom"; Description: "Custom installation"; Flags: iscustom
|
||||
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
|
||||
Name: "generator"; Description: "Generator"; Types: full hosting
|
||||
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/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
|
||||
@@ -63,7 +62,6 @@ Name: "client"; Description: "Clients"; 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/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/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||
@@ -78,7 +76,6 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
|
||||
[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: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: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
|
||||
@@ -132,7 +129,6 @@ Type: dirifempty; Name: "{app}"
|
||||
|
||||
[InstallDelete]
|
||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||
|
||||
[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\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: "{#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
|
||||
@@ -214,9 +205,6 @@ var LttPROMFilePage: TInputFileWizardPage;
|
||||
var smrom: string;
|
||||
var SMRomFilePage: TInputFileWizardPage;
|
||||
|
||||
var dkc3rom: string;
|
||||
var DKC3RomFilePage: TInputFileWizardPage;
|
||||
|
||||
var soerom: string;
|
||||
var SoERomFilePage: TInputFileWizardPage;
|
||||
|
||||
@@ -306,8 +294,6 @@ begin
|
||||
Result := not (LttPROMFilePage.Values[0] = '')
|
||||
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
|
||||
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
|
||||
Result := not (SoEROMFilePage.Values[0] = '')
|
||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||
@@ -348,22 +334,6 @@ begin
|
||||
Result := '';
|
||||
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;
|
||||
begin
|
||||
if Length(soerom) > 0 then
|
||||
@@ -408,10 +378,6 @@ begin
|
||||
if Length(smrom) = 0 then
|
||||
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');
|
||||
if Length(soerom) = 0 then
|
||||
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
||||
@@ -425,8 +391,6 @@ begin
|
||||
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
|
||||
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
|
||||
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
|
||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||
|
||||
@@ -175,15 +175,12 @@ A Link to the Past:
|
||||
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.
|
||||
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
|
||||
vendors: 0
|
||||
'off': 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:
|
||||
on: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
|
||||
off: 1
|
||||
@@ -276,7 +273,6 @@ A Link to the Past:
|
||||
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)
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
colorama>=0.4.5
|
||||
colorama>=0.4.4
|
||||
websockets>=10.3
|
||||
PyYAML>=6.0
|
||||
jellyfish>=0.9.0
|
||||
jinja2>=3.1.2
|
||||
schema>=0.7.5
|
||||
schema>=0.7.4
|
||||
kivy>=2.1.0
|
||||
bsdiff4>=1.2.2
|
||||
@@ -16,7 +16,7 @@ class TestDungeon(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -49,7 +49,8 @@ class PlayerDefinition(object):
|
||||
region_name = "player" + str(self.id) + region_tag
|
||||
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
||||
"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)
|
||||
parent.exits.append(entrance)
|
||||
|
||||
@@ -52,13 +52,3 @@ class TestIDs(unittest.TestCase):
|
||||
else:
|
||||
for location_id in world_type.location_id_to_name:
|
||||
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))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import unittest
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_default_world
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
@@ -30,17 +29,3 @@ class TestBase(unittest.TestCase):
|
||||
with self.subTest(group_name, group_name=group_name):
|
||||
for item in items:
|
||||
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",
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ def setup_default_world(world_type) -> MultiWorld:
|
||||
world.player_name = {1: "Tester"}
|
||||
world.set_seed()
|
||||
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)})
|
||||
world.set_options(args)
|
||||
world.set_default_common_options()
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestInverted(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestInvertedBombRules(unittest.TestCase):
|
||||
self.world = MultiWorld(1)
|
||||
self.world.mode[1] = "inverted"
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestInvertedMinor(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -18,7 +18,7 @@ class TestInvertedOWG(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestMinor(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -18,7 +18,7 @@ class TestVanillaOWG(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -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")
|
||||
@@ -16,7 +16,7 @@ class TestVanilla(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
|
||||
@@ -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")))
|
||||
@@ -2,14 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import pathlib
|
||||
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, TYPE_CHECKING
|
||||
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple
|
||||
|
||||
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
|
||||
from Options import Option
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, Item, Location, Tutorial
|
||||
|
||||
|
||||
class AutoWorldRegister(type):
|
||||
@@ -27,8 +23,7 @@ class AutoWorldRegister(type):
|
||||
|
||||
# build rest
|
||||
dct["item_names"] = frozenset(dct["item_name_to_id"])
|
||||
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("item_name_groups", {}).items()}
|
||||
dct["item_name_groups"] = dct.get("item_name_groups", {})
|
||||
dct["item_name_groups"]["Everything"] = dct["item_names"]
|
||||
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", {})))
|
||||
@@ -46,18 +41,14 @@ class AutoWorldRegister(type):
|
||||
# construct class
|
||||
new_class = super().__new__(mcs, name, bases, 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
|
||||
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
|
||||
|
||||
|
||||
class AutoLogicRegister(type):
|
||||
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister:
|
||||
new_class = super().__new__(mcs, name, bases, dct)
|
||||
def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister:
|
||||
new_class = super().__new__(cls, name, bases, dct)
|
||||
function: Callable[..., Any]
|
||||
for item_name, function in dct.items():
|
||||
if item_name == "copy_mixin":
|
||||
@@ -71,12 +62,12 @@ class AutoLogicRegister(type):
|
||||
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)
|
||||
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()
|
||||
for player in world.player_ids:
|
||||
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)
|
||||
|
||||
|
||||
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}
|
||||
for world_type in world_types:
|
||||
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:
|
||||
"""Webhost integration"""
|
||||
|
||||
# display a settings page. Can be a link to an out-of-ap settings tool too.
|
||||
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']
|
||||
"""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. Each Tutorial class is to be used for one guide."""
|
||||
# docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial
|
||||
# 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"
|
||||
"""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]
|
||||
"""display a link to a bug report page, most likely a link to a GitHub issue page."""
|
||||
|
||||
|
||||
class World(metaclass=AutoWorldRegister):
|
||||
"""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."""
|
||||
|
||||
option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping
|
||||
options: Dict[str, Option[Any]] = {} # link your Options mapping
|
||||
game: str # name the game
|
||||
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.
|
||||
hidden: bool = False
|
||||
|
||||
# see WebWorld for options
|
||||
web: WebWorld = WebWorld()
|
||||
|
||||
# autoset on creation:
|
||||
world: "MultiWorld"
|
||||
world: MultiWorld
|
||||
player: int
|
||||
|
||||
# automatically generated
|
||||
@@ -182,10 +170,9 @@ class World(metaclass=AutoWorldRegister):
|
||||
item_names: Set[str] # set of all potential item 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.
|
||||
__file__: str # path it was loaded from
|
||||
web: WebWorld = WebWorld()
|
||||
|
||||
def __init__(self, world: "MultiWorld", player: int):
|
||||
def __init__(self, world: MultiWorld, player: int):
|
||||
self.world = world
|
||||
self.player = player
|
||||
|
||||
@@ -220,12 +207,12 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
@classmethod
|
||||
def fill_hook(cls,
|
||||
progitempool: List["Item"],
|
||||
nonexcludeditempool: List["Item"],
|
||||
localrestitempool: Dict[int, List["Item"]],
|
||||
nonlocalrestitempool: Dict[int, List["Item"]],
|
||||
restitempool: List["Item"],
|
||||
fill_locations: List["Location"]) -> None:
|
||||
progitempool: List[Item],
|
||||
nonexcludeditempool: List[Item],
|
||||
localrestitempool: Dict[int, List[Item]],
|
||||
nonlocalrestitempool: Dict[int, List[Item]],
|
||||
restitempool: List[Item],
|
||||
fill_locations: List[Location]) -> None:
|
||||
"""Special method that gets called as part of distribute_items_restrictive (main fill).
|
||||
This gets called once per present world type."""
|
||||
pass
|
||||
@@ -263,7 +250,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
# 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.
|
||||
Warning: this may be called with self.world = None, for example by MultiServer"""
|
||||
raise NotImplementedError
|
||||
@@ -274,7 +261,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
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
|
||||
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 None to skip item.
|
||||
:param state: CollectionState to collect into
|
||||
@@ -285,18 +272,18 @@ class World(metaclass=AutoWorldRegister):
|
||||
return None
|
||||
|
||||
# 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 []
|
||||
|
||||
# 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)
|
||||
if name:
|
||||
state.prog_items[name, self.player] += 1
|
||||
return True
|
||||
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)
|
||||
if name:
|
||||
state.prog_items[name, self.player] -= 1
|
||||
@@ -305,7 +292,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
return True
|
||||
return False
|
||||
|
||||
def create_filler(self) -> "Item":
|
||||
def create_filler(self) -> Item:
|
||||
return self.create_item(self.get_filler_item_name())
|
||||
|
||||
|
||||
|
||||
@@ -1,56 +1,29 @@
|
||||
import importlib
|
||||
import zipimport
|
||||
import os
|
||||
import typing
|
||||
|
||||
folder = os.path.dirname(__file__)
|
||||
|
||||
__all__ = {
|
||||
"lookup_any_item_id_to_name",
|
||||
"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))
|
||||
__all__ = {"lookup_any_item_id_to_name",
|
||||
"lookup_any_location_id_to_name",
|
||||
"network_data_package",
|
||||
"AutoWorldRegister"}
|
||||
|
||||
# import all submodules to trigger AutoWorldRegister
|
||||
world_sources.sort()
|
||||
for world_source in world_sources:
|
||||
if world_source.is_zip:
|
||||
importer = zipimport.zipimporter(os.path.join(folder, world_source.path))
|
||||
importer.load_module(world_source.path.split(".", 1)[0])
|
||||
else:
|
||||
importlib.import_module(f".{world_source.path}", "worlds")
|
||||
world_folders = []
|
||||
for file in os.scandir(os.path.dirname(__file__)):
|
||||
if file.is_dir():
|
||||
world_folders.append(file.name)
|
||||
world_folders.sort()
|
||||
for world in world_folders:
|
||||
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_location_id_to_name = {}
|
||||
games = {}
|
||||
|
||||
from .AutoWorld import AutoWorldRegister
|
||||
|
||||
for world_name, world in AutoWorldRegister.world_types.items():
|
||||
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,
|
||||
"version": world.data_version,
|
||||
# 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()):
|
||||
network_data_package["version"] = 0
|
||||
import logging
|
||||
|
||||
logging.warning(f"Datapackage is in custom mode. Custom Worlds: "
|
||||
f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}")
|
||||
|
||||
@@ -15,6 +15,7 @@ def create_dungeons(world, player):
|
||||
dungeon_items, player)
|
||||
for item in dungeon.all_items:
|
||||
item.dungeon = dungeon
|
||||
item.world = world
|
||||
dungeon.boss = BossFactory(default_boss, player) if default_boss else None
|
||||
for region in dungeon.regions:
|
||||
world.get_region(region, player).dungeon = dungeon
|
||||
|
||||
@@ -212,7 +212,9 @@ def parse_arguments(argv, no_defaults=False):
|
||||
Alternatively, can be a ALttP Rom patched with a Link
|
||||
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',
|
||||
"singularity"])
|
||||
|
||||
|
||||
@@ -51,11 +51,6 @@ class ItemData(typing.NamedTuple):
|
||||
flute_boy_credit: 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)
|
||||
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'),
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
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 = {
|
||||
"Golden Sword": ("Progressive Sword", 4),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink
|
||||
|
||||
|
||||
@@ -28,35 +27,6 @@ class Goal(Choice):
|
||||
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):
|
||||
value: int
|
||||
option_original_dungeon = 0
|
||||
@@ -215,11 +185,9 @@ class Scams(Choice):
|
||||
option_all = 3
|
||||
alias_false = 0
|
||||
|
||||
@property
|
||||
def gives_king_zora_hint(self):
|
||||
return self.value in {0, 2}
|
||||
|
||||
@property
|
||||
def gives_bottle_merchant_hint(self):
|
||||
return self.value in {0, 1}
|
||||
|
||||
@@ -282,8 +250,8 @@ class ShieldPalette(Palette):
|
||||
display_name = "Shield Palette"
|
||||
|
||||
|
||||
# class LinkPalette(Palette):
|
||||
# display_name = "Link Palette"
|
||||
class LinkPalette(Palette):
|
||||
display_name = "Link Palette"
|
||||
|
||||
|
||||
class HeartBeep(Choice):
|
||||
@@ -363,7 +331,6 @@ class AllowCollect(Toggle):
|
||||
alttp_options: typing.Dict[str, type(Option)] = {
|
||||
"crystals_needed_for_gt": CrystalsTower,
|
||||
"crystals_needed_for_ganon": CrystalsGanon,
|
||||
"open_pyramid": OpenPyramid,
|
||||
"bigkey_shuffle": bigkey_shuffle,
|
||||
"smallkey_shuffle": smallkey_shuffle,
|
||||
"compass_shuffle": compass_shuffle,
|
||||
@@ -387,7 +354,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
|
||||
"hud_palettes": HUDPalette,
|
||||
"sword_palettes": SwordPalette,
|
||||
"shield_palettes": ShieldPalette,
|
||||
# "link_palettes": LinkPalette,
|
||||
"link_palettes": LinkPalette,
|
||||
"heartbeep": HeartBeep,
|
||||
"heartcolor": HeartColor,
|
||||
"quickswap": QuickSwap,
|
||||
|
||||
@@ -34,7 +34,7 @@ from worlds.alttp.Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts
|
||||
DeathMountain_texts, \
|
||||
LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, \
|
||||
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.EntranceShuffle import door_addresses
|
||||
from worlds.alttp.Options import smallkey_shuffle
|
||||
@@ -551,22 +551,18 @@ class Sprite():
|
||||
Sprite.base_data = Sprite.sprite + Sprite.palette + Sprite.glove_palette
|
||||
|
||||
def from_ap_sprite(self, filedata):
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
obj = parse_yaml(filedata.decode("utf-8-sig"))
|
||||
if obj["min_format_version"] > 1:
|
||||
raise Exception("Sprite file requires an updated reader.")
|
||||
self.author_name = obj["author"]
|
||||
self.name = obj["name"]
|
||||
if obj["data"]: # skip patching for vanilla content
|
||||
data = bsdiff4.patch(Sprite.base_data, obj["data"])
|
||||
self.sprite = data[:self.sprite_size]
|
||||
self.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
|
||||
filedata = filedata.decode("utf-8-sig")
|
||||
import yaml
|
||||
obj = yaml.safe_load(filedata)
|
||||
if obj["min_format_version"] > 1:
|
||||
raise Exception("Sprite file requires an updated reader.")
|
||||
self.author_name = obj["author"]
|
||||
self.name = obj["name"]
|
||||
if obj["data"]: # skip patching for vanilla content
|
||||
data = bsdiff4.patch(Sprite.base_data, obj["data"])
|
||||
self.sprite = data[:self.sprite_size]
|
||||
self.palette = data[self.sprite_size:self.palette_size]
|
||||
self.glove_palette = data[self.sprite_size + self.palette_size:]
|
||||
|
||||
@property
|
||||
def author_game_display(self) -> str:
|
||||
@@ -663,7 +659,7 @@ class Sprite():
|
||||
|
||||
@staticmethod
|
||||
def parse_zspr(filedata, expected_kind):
|
||||
logger = logging.getLogger("ZSPR")
|
||||
logger = logging.getLogger('ZSPR')
|
||||
headerstr = "<4xBHHIHIHH6x"
|
||||
headersize = struct.calcsize(headerstr)
|
||||
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(
|
||||
headerstr, filedata)
|
||||
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
|
||||
if kind != expected_kind:
|
||||
return None
|
||||
@@ -680,42 +676,36 @@ class Sprite():
|
||||
stream.seek(headersize)
|
||||
|
||||
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()
|
||||
while True:
|
||||
char = stream.read(2)
|
||||
if char in [b"", b"\x00\x00"]:
|
||||
if char in [b'', b'\x00\x00']:
|
||||
break
|
||||
raw += char
|
||||
return raw.decode("utf-16_le")
|
||||
return raw.decode('utf-16_le')
|
||||
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
sprite_name = read_utf16le(stream)
|
||||
author_name = read_utf16le(stream)
|
||||
author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
|
||||
sprite_name = read_utf16le(stream)
|
||||
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
|
||||
if real_csum != csum or real_csum ^ 0xFFFF != icsum:
|
||||
logger.warning("ZSPR file has incorrect checksum. It may be corrupted.")
|
||||
real_csum = sum(filedata) % 0x10000
|
||||
if real_csum != csum or real_csum ^ 0xFFFF != icsum:
|
||||
logger.warning('ZSPR file has incorrect checksum. It may be corrupted.')
|
||||
|
||||
sprite = filedata[sprite_offset:sprite_offset + sprite_size]
|
||||
palette = filedata[palette_offset:palette_offset + palette_size]
|
||||
sprite = filedata[sprite_offset:sprite_offset + sprite_size]
|
||||
palette = filedata[palette_offset:palette_offset + palette_size]
|
||||
|
||||
if len(sprite) != sprite_size or len(palette) != palette_size:
|
||||
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")
|
||||
if len(sprite) != sprite_size or len(palette) != palette_size:
|
||||
logger.error('Error parsing ZSPR file: Unexpected end of file')
|
||||
return None
|
||||
|
||||
return (sprite, palette, sprite_name, author_name, author_credits_name)
|
||||
|
||||
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):
|
||||
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_byte(0x50599, 0x00) # 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[
|
||||
player] == 0 else 0x00) # GT pre-opened if crystal requirement is 0
|
||||
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):
|
||||
from . import ALTTPWorld
|
||||
local_random = world.slot_seeds[player]
|
||||
w: ALTTPWorld = world.worlds[player]
|
||||
|
||||
tt = TextTable()
|
||||
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,
|
||||
True) if pedestalitem.pedestal_hint_text is not None else 'Unknown Item'
|
||||
tt['mastersword_pedestal_translated'] = pedestal_text
|
||||
pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else \
|
||||
w.pedestal_credit_texts.get(pedestalitem.code, 'and the Unknown Item')
|
||||
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'
|
||||
|
||||
etheritem = world.get_location('Ether Tablet', player).item
|
||||
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()
|
||||
|
||||
sickkiditem = world.get_location('Sick Kid', player).item
|
||||
sickkiditem_text = local_random.choice(SickKid_texts) \
|
||||
if sickkiditem is None or sickkiditem.code not in w.sickkid_credit_texts \
|
||||
else w.sickkid_credit_texts[sickkiditem.code]
|
||||
sickkiditem_text = local_random.choice(
|
||||
SickKid_texts) if sickkiditem is None or sickkiditem.sickkid_credit_text is None else sickkiditem.sickkid_credit_text
|
||||
|
||||
zoraitem = world.get_location('King Zora', player).item
|
||||
zoraitem_text = local_random.choice(Zora_texts) \
|
||||
if zoraitem is None or zoraitem.code not in w.zora_credit_texts \
|
||||
else w.zora_credit_texts[zoraitem.code]
|
||||
zoraitem_text = local_random.choice(
|
||||
Zora_texts) if zoraitem is None or zoraitem.zora_credit_text is None else zoraitem.zora_credit_text
|
||||
|
||||
magicshopitem = world.get_location('Potion Shop', player).item
|
||||
magicshopitem_text = local_random.choice(MagicShop_texts) \
|
||||
if magicshopitem is None or magicshopitem.code not in w.magicshop_credit_texts \
|
||||
else w.magicshop_credit_texts[magicshopitem.code]
|
||||
magicshopitem_text = local_random.choice(
|
||||
MagicShop_texts) if magicshopitem is None or magicshopitem.magicshop_credit_text is None else magicshopitem.magicshop_credit_text
|
||||
|
||||
fluteboyitem = world.get_location('Flute Spot', player).item
|
||||
fluteboyitem_text = local_random.choice(FluteBoy_texts) \
|
||||
if fluteboyitem is None or fluteboyitem.code not in w.fluteboy_credit_texts \
|
||||
else w.fluteboy_credit_texts[fluteboyitem.code]
|
||||
fluteboyitem_text = local_random.choice(
|
||||
FluteBoy_texts) if fluteboyitem is None or fluteboyitem.fluteboy_credit_text is None else fluteboyitem.fluteboy_credit_text
|
||||
|
||||
credits.update_credits_line('castle', 0, local_random.choice(KingsReturn_texts))
|
||||
credits.update_credits_line('sanctuary', 0, local_random.choice(Sanctuary_texts))
|
||||
|
||||
@@ -935,6 +935,7 @@ def set_trock_key_rules(world, player):
|
||||
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
|
||||
item = ItemFactory('Small Key (Turtle Rock)', player)
|
||||
item.world = world
|
||||
location = world.get_location('Turtle Rock - Big Key Chest', player)
|
||||
location.place_locked_item(item)
|
||||
location.event = True
|
||||
|
||||
@@ -207,10 +207,10 @@ def ShopSlotFill(world):
|
||||
shops_per_sphere.append(current_shops_slots)
|
||||
candidates_per_sphere.append(current_candidates)
|
||||
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:
|
||||
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)
|
||||
if cumu_weights:
|
||||
x = cumu_weights[-1]
|
||||
@@ -335,6 +335,7 @@ def create_shops(world, player: int):
|
||||
else:
|
||||
loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player)
|
||||
loc.shop_slot_disabled = True
|
||||
loc.item.world = world
|
||||
shop.region.locations.append(loc)
|
||||
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.")
|
||||
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)
|
||||
slots = iter(range(2))
|
||||
if bombupgrades:
|
||||
capacityshop.add_inventory(next(slots), 'Bomb Upgrade (+5)', 100, bombupgrades)
|
||||
capacityshop.add_inventory(1, 'Bomb Upgrade (+5)', 100, bombupgrades)
|
||||
if arrowupgrades:
|
||||
capacityshop.add_inventory(next(slots), 'Arrow Upgrade (+5)', 100, arrowupgrades)
|
||||
capacityshop.add_inventory(1, 'Arrow Upgrade (+5)', 100, arrowupgrades)
|
||||
else:
|
||||
for item in new_items:
|
||||
world.push_precollected(ItemFactory(item, player))
|
||||
|
||||
@@ -6,33 +6,31 @@ from BaseClasses import Location, Item, ItemClassification
|
||||
|
||||
class ALttPLocation(Location):
|
||||
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,
|
||||
hint_text: Optional[str] = None, parent=None, player_address: Optional[int] = None):
|
||||
def __init__(self, player: int, name: str = '', address=None, crystal: bool = False,
|
||||
hint_text: Optional[str] = None, parent=None,
|
||||
player_address=None):
|
||||
super(ALttPLocation, self).__init__(player, name, address, parent)
|
||||
self.crystal = crystal
|
||||
self.player_address = player_address
|
||||
self._hint_text = hint_text
|
||||
self._hint_text: str = hint_text
|
||||
|
||||
|
||||
class ALttPItem(Item):
|
||||
game: str = "A Link to the Past"
|
||||
type: Optional[str]
|
||||
_pedestal_hint_text: Optional[str]
|
||||
_hint_text: Optional[str]
|
||||
dungeon = None
|
||||
|
||||
def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None,
|
||||
pedestal_hint=None, hint_text=None):
|
||||
def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, pedestal_hint=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)
|
||||
self.type = type
|
||||
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
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import random
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import typing
|
||||
|
||||
import Utils
|
||||
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 ..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")
|
||||
|
||||
@@ -108,7 +110,7 @@ class ALTTPWorld(World):
|
||||
Ganon!
|
||||
"""
|
||||
game: str = "A Link to the Past"
|
||||
option_definitions = alttp_options
|
||||
options = alttp_options
|
||||
topology_present = True
|
||||
item_name_groups = item_name_groups
|
||||
hint_blacklist = {"Triforce"}
|
||||
@@ -122,25 +124,10 @@ class ALTTPWorld(World):
|
||||
required_client_version = (0, 3, 2)
|
||||
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
|
||||
|
||||
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):
|
||||
self.dungeon_local_item_names = set()
|
||||
self.dungeon_specific_item_names = set()
|
||||
@@ -155,9 +142,6 @@ class ALTTPWorld(World):
|
||||
raise FileNotFoundError(rom_file)
|
||||
|
||||
def generate_early(self):
|
||||
if self.use_enemizer():
|
||||
check_enemizer(self.enemizer_path)
|
||||
|
||||
player = self.player
|
||||
world = self.world
|
||||
|
||||
@@ -192,6 +176,17 @@ class ALTTPWorld(World):
|
||||
def create_regions(self):
|
||||
player = self.player
|
||||
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_required[player])
|
||||
@@ -346,26 +341,21 @@ class ALTTPWorld(World):
|
||||
def stage_post_fill(cls, 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):
|
||||
world = self.world
|
||||
player = self.player
|
||||
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())
|
||||
|
||||
patch_rom(world, rom, player, 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:
|
||||
patch_race_rom(rom, world, player)
|
||||
@@ -378,7 +368,7 @@ class ALTTPWorld(World):
|
||||
'hud': world.hud_palettes[player],
|
||||
'sword': world.sword_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()}
|
||||
|
||||
@@ -421,7 +411,7 @@ class ALTTPWorld(World):
|
||||
multidata["connect_names"][new_name] = multidata["connect_names"][self.world.player_name[self.player]]
|
||||
|
||||
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
|
||||
def stage_fill_hook(cls, world, progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool,
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||

|
||||

|
||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||
SFC (bsnes-mercury Performance)".
|
||||
|
||||
|
||||
@@ -299,5 +299,4 @@ item_table = (
|
||||
'A Shrubbery',
|
||||
'Roomba with a Knife',
|
||||
'Wet Cat',
|
||||
'The missing moderator, Frostwares',
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user