diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c51f155049..d4e1efd466 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,10 +2,20 @@
name: Build
-on: workflow_dispatch
+on:
+ push:
+ paths:
+ - '.github/workflows/build.yml'
+ - 'setup.py'
+ - 'requirements.txt'
+ pull_request:
+ paths:
+ - '.github/workflows/build.yml'
+ - 'setup.py'
+ - 'requirements.txt'
+ workflow_dispatch:
env:
- SNI_VERSION: v0.0.88
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -15,15 +25,13 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Install python
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v4
with:
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
- 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
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
@@ -39,7 +47,7 @@ jobs:
Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
@@ -49,14 +57,14 @@ jobs:
runs-on: ubuntu-18.04
steps:
# - copy code below to release.yml -
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -69,10 +77,6 @@ jobs:
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
- 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
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
@@ -93,13 +97,13 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Store AppImage
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index b331c25506..6aeb477a22 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -14,9 +14,17 @@ name: "CodeQL"
on:
push:
branches: [ main ]
+ paths:
+ - '**.py'
+ - '**.js'
+ - '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
+ paths:
+ - '**.py'
+ - '**.js'
+ - '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '44 8 * * 1'
@@ -35,11 +43,11 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v1
+ uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -50,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v1
+ uses: github/codeql-action/autobuild@v2
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -64,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
+ uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 28adb50026..7ecda45eda 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -3,7 +3,13 @@
name: lint
-on: [push, pull_request]
+on:
+ push:
+ paths:
+ - '**.py'
+ pull_request:
+ paths:
+ - '**.py'
jobs:
build:
@@ -11,9 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set up Python 3.9
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e9559f7856..fa3dd32100 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,7 +8,6 @@ on:
- '*.*.*'
env:
- SNI_VERSION: v0.0.88
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -36,14 +35,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -56,10 +55,6 @@ jobs:
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
- 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
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index c86d637243..93be745a8c 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -3,7 +3,25 @@
name: unittests
-on: [push, pull_request]
+on:
+ push:
+ paths:
+ - '**'
+ - '!docs/**'
+ - '!setup.py'
+ - '!*.iss'
+ - '!.gitignore'
+ - '!.github/workflows/**'
+ - '.github/workflows/unittests.yml'
+ pull_request:
+ paths:
+ - '**'
+ - '!docs/**'
+ - '!setup.py'
+ - '!*.iss'
+ - '!.gitignore'
+ - '!.github/workflows/**'
+ - '.github/workflows/unittests.yml'
jobs:
build:
@@ -23,11 +41,13 @@ jobs:
os: windows-latest
- python: {version: '3.10'} # current
os: windows-latest
+ - python: {version: '3.10'} # current
+ os: macos-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python.version }}
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
diff --git a/.gitignore b/.gitignore
index e269202db9..4a9f3402a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
*.apm3
*.apmc
*.apz5
+*.aptloz
*.pyc
*.pyd
*.sfc
@@ -138,6 +139,7 @@ ENV/
env.bak/
venv.bak/
.code-workspace
+shell.nix
# Spyder project settings
.spyderproject
@@ -167,6 +169,7 @@ cython_debug/
jdk*/
minecraft*/
minecraft_versions.json
+!worlds/minecraft/
# pyenv
.python-version
diff --git a/BaseClasses.py b/BaseClasses.py
index e30dbd3296..d3e7c8db95 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -2,14 +2,13 @@ from __future__ import annotations
import copy
import functools
-import json
import logging
import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
-from collections import OrderedDict, Counter, deque
-from enum import unique, IntEnum, IntFlag
+from collections import OrderedDict, Counter, deque, ChainMap
+from enum import IntEnum, IntFlag
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
import NetUtils
@@ -73,6 +72,11 @@ class MultiWorld():
completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
+ priority_locations: Dict[int, Options.PriorityLocations]
+ start_inventory: Dict[int, Options.StartInventory]
+ start_hints: Dict[int, Options.StartHints]
+ start_location_hints: Dict[int, Options.StartLocationHints]
+ item_links: Dict[int, Options.ItemLinks]
game: Dict[int, str]
@@ -761,169 +765,9 @@ class CollectionState():
found += self.prog_items[item_name, player]
return found
- def can_buy_unlimited(self, item: str, player: int) -> bool:
- return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
- shop in self.multiworld.shops)
-
- def can_buy(self, item: str, player: int) -> bool:
- return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
- shop in self.multiworld.shops)
-
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
- def has_triforce_pieces(self, count: int, player: int) -> bool:
- return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count
-
- def has_crystals(self, count: int, player: int) -> bool:
- found: int = 0
- for crystalnumber in range(1, 8):
- found += self.prog_items[f"Crystal {crystalnumber}", player]
- if found >= count:
- return True
- return False
-
- def can_lift_rocks(self, player: int):
- return self.has('Power Glove', player) or self.has('Titans Mitts', player)
-
- def bottle_count(self, player: int) -> int:
- return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
- self.count_group("Bottles", player))
-
- def has_hearts(self, player: int, count: int) -> int:
- # Warning: This only considers items that are marked as advancement items
- return self.heart_count(player) >= count
-
- def heart_count(self, player: int) -> int:
- # Warning: This only considers items that are marked as advancement items
- diff = self.multiworld.difficulty_requirements[player]
- return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
- + self.item_count('Sanctuary Heart Container', player) \
- + min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
- + 3 # starting hearts
-
- def can_lift_heavy_rocks(self, player: int) -> bool:
- return self.has('Titans Mitts', player)
-
- def can_extend_magic(self, player: int, smallmagic: int = 16,
- fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
- basemagic = 8
- if self.has('Magic Upgrade (1/4)', player):
- basemagic = 32
- elif self.has('Magic Upgrade (1/2)', player):
- basemagic = 16
- if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
- if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
- basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
- elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
- basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
- else:
- basemagic = basemagic + basemagic * self.bottle_count(player)
- return basemagic >= smallmagic
-
- def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
- return (self.has_melee_weapon(player)
- or self.has('Cane of Somaria', player)
- or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
- or self.can_shoot_arrows(player)
- or self.has('Fire Rod', player)
- or (self.has('Bombs (10)', player) and enemies < 6))
-
- def can_shoot_arrows(self, player: int) -> bool:
- if self.multiworld.retro_bow[player]:
- return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
- return self.has('Bow', player) or self.has('Silver Bow', player)
-
- def can_get_good_bee(self, player: int) -> bool:
- cave = self.multiworld.get_region('Good Bee Cave', player)
- return (
- self.has_group("Bottles", player) and
- self.has('Bug Catching Net', player) and
- (self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
- cave.can_reach(self) and
- self.is_not_bunny(cave, player)
- )
-
- def can_retrieve_tablet(self, player: int) -> bool:
- return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
- (self.multiworld.swordless[player] and
- self.has("Hammer", player)))
-
- def has_sword(self, player: int) -> bool:
- return self.has('Fighter Sword', player) \
- or self.has('Master Sword', player) \
- or self.has('Tempered Sword', player) \
- or self.has('Golden Sword', player)
-
- def has_beam_sword(self, player: int) -> bool:
- return self.has('Master Sword', player) or self.has('Tempered Sword', player) or self.has('Golden Sword',
- player)
-
- def has_melee_weapon(self, player: int) -> bool:
- return self.has_sword(player) or self.has('Hammer', player)
-
- def has_fire_source(self, player: int) -> bool:
- return self.has('Fire Rod', player) or self.has('Lamp', player)
-
- def can_melt_things(self, player: int) -> bool:
- return self.has('Fire Rod', player) or \
- (self.has('Bombos', player) and
- (self.multiworld.swordless[player] or
- self.has_sword(player)))
-
- def can_avoid_lasers(self, player: int) -> bool:
- return self.has('Mirror Shield', player) or self.has('Cane of Byrna', player) or self.has('Cape', player)
-
- def is_not_bunny(self, region: Region, player: int) -> bool:
- if self.has('Moon Pearl', player):
- return True
-
- return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
-
- def can_reach_light_world(self, player: int) -> bool:
- if True in [i.is_light_world for i in self.reachable_regions[player]]:
- return True
- return False
-
- def can_reach_dark_world(self, player: int) -> bool:
- if True in [i.is_dark_world for i in self.reachable_regions[player]]:
- return True
- return False
-
- def has_misery_mire_medallion(self, player: int) -> bool:
- return self.has(self.multiworld.required_medallions[player][0], player)
-
- def has_turtle_rock_medallion(self, player: int) -> bool:
- return self.has(self.multiworld.required_medallions[player][1], player)
-
- def can_boots_clip_lw(self, player: int) -> bool:
- if self.multiworld.mode[player] == 'inverted':
- return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
- return self.has('Pegasus Boots', player)
-
- def can_boots_clip_dw(self, player: int) -> bool:
- if self.multiworld.mode[player] != 'inverted':
- return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
- return self.has('Pegasus Boots', player)
-
- def can_get_glitched_speed_lw(self, player: int) -> bool:
- rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
- if self.multiworld.mode[player] == 'inverted':
- rules.append(self.has('Moon Pearl', player))
- return all(rules)
-
- def can_superbunny_mirror_with_sword(self, player: int) -> bool:
- return self.has('Magic Mirror', player) and self.has_sword(player)
-
- def can_get_glitched_speed_dw(self, player: int) -> bool:
- rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
- if self.multiworld.mode[player] != 'inverted':
- rules.append(self.has('Moon Pearl', player))
- return all(rules)
-
- def can_bomb_clip(self, region: Region, player: int) -> bool:
- return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
-
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -959,12 +803,6 @@ class Region:
exits: List[Entrance]
locations: List[Location]
dungeon: Optional[Dungeon] = None
- shop: Optional = None
-
- # LttP specific. TODO: move to a LttPRegion
- # will be set after making connections.
- is_light_world: bool = False
- is_dark_world: bool = False
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
@@ -1129,7 +967,7 @@ class Location:
self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
- return (self.always_allow(state, item)
+ return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
@@ -1261,13 +1099,9 @@ class Spoiler():
self.multiworld = world
self.hashes = {}
self.entrances = OrderedDict()
- self.medallions = {}
self.playthrough = {}
self.unreachables = set()
- self.locations = {}
self.paths = {}
- self.shops = []
- self.bosses = OrderedDict()
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.multiworld.players == 1:
@@ -1277,126 +1111,6 @@ class Spoiler():
self.entrances[(entrance, direction, player)] = OrderedDict(
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
- def parse_data(self):
- from worlds.alttp.SubClasses import LTTPRegionType
- self.medallions = OrderedDict()
- for player in self.multiworld.get_game_players("A Link to the Past"):
- self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
- self.multiworld.required_medallions[player][0]
- self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
- self.multiworld.required_medallions[player][1]
-
- self.locations = OrderedDict()
- listed_locations = set()
- lw_locations = []
- dw_locations = []
- cave_locations = []
- for loc in self.multiworld.get_locations():
- if loc.game == "A Link to the Past":
- if loc not in listed_locations and loc.parent_region and \
- loc.parent_region.type == LTTPRegionType.LightWorld and loc.show_in_spoiler:
- lw_locations.append(loc)
- elif loc not in listed_locations and loc.parent_region and \
- loc.parent_region.type == LTTPRegionType.DarkWorld and loc.show_in_spoiler:
- dw_locations.append(loc)
- elif loc not in listed_locations and loc.parent_region and \
- loc.parent_region.type == LTTPRegionType.Cave and loc.show_in_spoiler:
- cave_locations.append(loc)
-
- self.locations['Light World'] = OrderedDict(
- [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
- lw_locations])
- listed_locations.update(lw_locations)
-
- self.locations['Dark World'] = OrderedDict(
- [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
- dw_locations])
- listed_locations.update(dw_locations)
-
- self.locations['Caves'] = OrderedDict(
- [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
- cave_locations])
- listed_locations.update(cave_locations)
-
- for dungeon in self.multiworld.dungeons.values():
- dungeon_locations = [loc for loc in self.multiworld.get_locations() if
- loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
- self.locations[str(dungeon)] = OrderedDict(
- [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
- dungeon_locations])
- listed_locations.update(dungeon_locations)
-
- other_locations = [loc for loc in self.multiworld.get_locations() if
- loc not in listed_locations and loc.show_in_spoiler]
- if other_locations:
- self.locations['Other Locations'] = OrderedDict(
- [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
- other_locations])
- listed_locations.update(other_locations)
-
- self.shops = []
- from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
- for shop in self.multiworld.shops:
- if not shop.custom:
- continue
- shopdata = {
- 'location': str(shop.region),
- 'type': 'Take Any' if shop.type == ShopType.TakeAny else 'Shop'
- }
- for index, item in enumerate(shop.inventory):
- if item is None:
- continue
- my_price = item['price'] // price_rate_display.get(item['price_type'], 1)
- shopdata['item_{}'.format(
- index)] = f"{item['item']} — {my_price} {price_type_display_name[item['price_type']]}"
-
- if item['player'] > 0:
- shopdata['item_{}'.format(index)] = shopdata['item_{}'.format(index)].replace('—',
- '(Player {}) — '.format(
- item['player']))
-
- if item['max'] == 0:
- continue
- shopdata['item_{}'.format(index)] += " x {}".format(item['max'])
-
- if item['replacement'] is None:
- continue
- shopdata['item_{}'.format(
- index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
- self.shops.append(shopdata)
-
- for player in self.multiworld.get_game_players("A Link to the Past"):
- self.bosses[str(player)] = OrderedDict()
- self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name
- self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name
- self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name
- self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
- self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
- player).boss.name
- self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name
- self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name
- self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name
- self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name
- self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
- self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
- if self.multiworld.mode[player] != 'inverted':
- self.bosses[str(player)]["Ganons Tower Basement"] = \
- self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
- self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
- 'middle'].name
- self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
- 'top'].name
- else:
- self.bosses[str(player)]["Ganons Tower Basement"] = \
- self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
- self.bosses[str(player)]["Ganons Tower Middle"] = \
- self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
- self.bosses[str(player)]["Ganons Tower Top"] = \
- self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
-
- self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
- self.bosses[str(player)]["Ganon"] = "Ganon"
-
def create_playthrough(self, create_paths: bool = True):
"""Destructive to the world while it is run, damage gets repaired afterwards."""
from itertools import chain
@@ -1548,35 +1262,12 @@ class Spoiler():
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
- def to_json(self):
- self.parse_data()
- out = OrderedDict()
- out['Entrances'] = list(self.entrances.values())
- out.update(self.locations)
- out['Special'] = self.medallions
- if self.hashes:
- out['Hashes'] = self.hashes
- if self.shops:
- out['Shops'] = self.shops
- out['playthrough'] = self.playthrough
- out['paths'] = self.paths
- out['Bosses'] = self.bosses
-
- return json.dumps(out)
-
def to_file(self, filename: str):
- self.parse_data()
-
- def bool_to_text(variable: Union[bool, str]) -> str:
- if type(variable) == str:
- return variable
- return 'Yes' if variable else 'No'
-
def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key)
try:
- outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
+ outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
except:
raise Exception
@@ -1593,46 +1284,13 @@ class Spoiler():
if self.multiworld.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
- for f_option, option in Options.per_game_common_options.items():
+
+ options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
+ for f_option, option in options.items():
write_option(f_option, option)
- options = self.multiworld.worlds[player].option_definitions
- if options:
- for f_option, option in options.items():
- write_option(f_option, option)
+
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
- if player in self.multiworld.get_game_players("A Link to the Past"):
- outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
-
- outfile.write('Logic: %s\n' % self.multiworld.logic[player])
- outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
- outfile.write('Mode: %s\n' % self.multiworld.mode[player])
- outfile.write('Goal: %s\n' % self.multiworld.goal[player])
- if "triforce" in self.multiworld.goal[player]: # triforce hunt
- outfile.write("Pieces available for Triforce: %s\n" %
- self.multiworld.triforce_pieces_available[player])
- outfile.write("Pieces required for Triforce: %s\n" %
- self.multiworld.triforce_pieces_required[player])
- outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
- outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
- outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
- if self.multiworld.shuffle[player] != "vanilla":
- outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
- outfile.write('Shop inventory shuffle: %s\n' %
- bool_to_text("i" in self.multiworld.shop_shuffle[player]))
- outfile.write('Shop price shuffle: %s\n' %
- bool_to_text("p" in self.multiworld.shop_shuffle[player]))
- outfile.write('Shop upgrade shuffle: %s\n' %
- bool_to_text("u" in self.multiworld.shop_shuffle[player]))
- outfile.write('New Shop inventory: %s\n' %
- bool_to_text("g" in self.multiworld.shop_shuffle[player] or
- "f" in self.multiworld.shop_shuffle[player]))
- outfile.write('Custom Potion Shop: %s\n' %
- bool_to_text("w" in self.multiworld.shop_shuffle[player]))
- outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
- outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
- outfile.write('Prize shuffle %s\n' %
- self.multiworld.shuffle_prizes[player])
if self.entrances:
outfile.write('\n\nEntrances:\n\n')
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
@@ -1641,30 +1299,14 @@ class Spoiler():
'<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()]))
- if self.medallions:
- outfile.write('\n\nMedallions:\n')
- for dungeon, medallion in self.medallions.items():
- outfile.write(f'\n{dungeon}: {medallion}')
-
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
+ locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
+ for location in self.multiworld.get_locations() if location.show_in_spoiler]
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
- ['%s: %s' % (location, item) for grouping in self.locations.values() for (location, item) in
- grouping.items()]))
+ ['%s: %s' % (location, item) for location, item in locations]))
- if self.shops:
- outfile.write('\n\nShops:\n\n')
- outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(
- item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
- item)) for shop in self.shops))
-
- for player in self.multiworld.get_game_players("A Link to the Past"):
- if self.multiworld.boss_shuffle[player] != 'none':
- bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
- outfile.write(
- f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n')
- outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
[' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [
diff --git a/CommonClient.py b/CommonClient.py
index 92f8d76a66..02dd55da98 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -63,7 +63,7 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
- logger.info(f'{len(self.ctx.items_received)} received items:')
+ self.output(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
diff --git a/Fill.py b/Fill.py
index ac3ae8fc6d..92b57af58b 100644
--- a/Fill.py
+++ b/Fill.py
@@ -840,8 +840,7 @@ def distribute_planned(world: MultiWorld) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
- candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
- worlds))
+ candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0
diff --git a/Generate.py b/Generate.py
index dadabd7ac6..afb34f11c6 100644
--- a/Generate.py
+++ b/Generate.py
@@ -107,7 +107,7 @@ def main(args=None, callback=ERmain):
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
- if file.is_file() and not file.name.startswith(".") and \
+ if file.is_file() and not fname.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
diff --git a/Launcher.py b/Launcher.py
index 7d5b2f7316..c4d9b6fea0 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -132,7 +132,8 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
- file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
+ file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
+ '.apsmw', '.apl2ac')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
@@ -147,10 +148,14 @@ components: Iterable[Component] = (
Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
+ # TLoZ
+ Component('Zelda 1 Client', 'Zelda1Client'),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
+ # Wargroove
+ Component('Wargroove Client', 'WargrooveClient'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
diff --git a/Main.py b/Main.py
index 9bb2cfef9e..5175c29f4f 100644
--- a/Main.py
+++ b/Main.py
@@ -38,7 +38,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world = MultiWorld(args.multi)
logger = logging.getLogger()
- world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
+ world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
world.plando_options = args.plando_options
world.shuffle = args.shuffle.copy()
@@ -53,7 +53,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
- world.timer = args.timer.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
@@ -79,7 +78,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
- logger.info("Found World Types:")
+ logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
@@ -362,6 +361,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if game_world.data_version == 0 and game_world.game not in datapackage:
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
+ datapackage[game_world.game]["location_name_groups"] = game_world.location_name_groups
multidata = {
"slot_data": slot_data,
diff --git a/MultiServer.py b/MultiServer.py
index f69ce6bc64..40b6582dca 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -163,7 +163,9 @@ class Context:
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
+ location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
+ all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
@@ -233,7 +235,9 @@ class Context:
# init empty to satisfy linter, I suppose
self.gamespackage = {}
self.item_name_groups = {}
+ self.location_name_groups = {}
self.all_item_and_group_names = {}
+ self.all_location_and_group_names = {}
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
@@ -245,6 +249,8 @@ class Context:
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
+ self.location_name_groups = {world_name: world.location_name_groups for world_name, world in
+ worlds.AutoWorldRegister.world_types.items()}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
@@ -256,6 +262,8 @@ class Context:
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])
+ self.all_location_and_group_names[game_name] = \
+ set(game_package["location_name_to_id"]) | set(self.location_name_groups[game_name])
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -431,10 +439,14 @@ class Context:
logging.info(f"Loading custom datapackage for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
+ self.location_name_groups[game_name] = data["location_name_groups"]
del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups
+ del data["location_name_groups"]
self._init_game_data()
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
+ for game_name, data in self.location_name_groups.items():
+ self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
# saving
@@ -1408,7 +1420,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if game not in self.ctx.all_item_and_group_names:
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
return False
- names = self.ctx.location_names_for_game(game) \
+ names = self.ctx.all_location_and_group_names[game] \
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, names)
@@ -1424,6 +1436,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
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
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
+ elif hint_name in self.ctx.location_name_groups[game]: # location group name
+ hints = []
+ for loc_name in self.ctx.location_name_groups[game][hint_name]:
+ if loc_name in self.ctx.location_names_for_game(game):
+ hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
diff --git a/Options.py b/Options.py
index c01168965b..b7013d84fc 100644
--- a/Options.py
+++ b/Options.py
@@ -1,5 +1,6 @@
from __future__ import annotations
import abc
+import logging
from copy import deepcopy
import math
import numbers
@@ -9,6 +10,10 @@ import random
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
+if typing.TYPE_CHECKING:
+ from BaseClasses import PlandoOptions
+ from worlds.AutoWorld import World
+
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
@@ -95,11 +100,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
- name_lookup: typing.Dict[int, str]
+ name_lookup: typing.Dict[T, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
- return f"{self.__class__.__name__}({self.get_current_option_name()})"
+ return f"{self.__class__.__name__}({self.current_option_name})"
def __hash__(self) -> int:
return hash(self.value)
@@ -109,7 +114,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
- """For display purposes."""
+ """Deprecated. use current_option_name instead. TODO remove around 0.4"""
+ logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
+ f" use current_option_name instead. Worlds should use {self}.current_key"))
+ return self.current_option_name
+
+ @property
+ def current_option_name(self) -> str:
+ """For display purposes. Worlds should be using current_key."""
return self.get_option_name(self.value)
@classmethod
@@ -131,17 +143,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
...
if typing.TYPE_CHECKING:
- from Generate import PlandoOptions
- from worlds.AutoWorld import World
-
- def verify(self, world: World, player_name: str, plando_options: PlandoOptions) -> None:
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
-class FreeText(Option):
+class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
@@ -162,7 +171,7 @@ class FreeText(Option):
return cls.from_text(str(data))
@classmethod
- def get_option_name(cls, value: T) -> str:
+ def get_option_name(cls, value: str) -> str:
return value
@@ -424,6 +433,7 @@ class Choice(NumericOption):
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
+ value: typing.Union[str, int]
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
@@ -434,8 +444,7 @@ class TextChoice(Choice):
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
- else:
- return self.name_lookup[self.value]
+ return super().current_key
@classmethod
def from_text(cls, text: str) -> TextChoice:
@@ -450,7 +459,7 @@ class TextChoice(Choice):
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
- return cls.name_lookup[value]
+ return super().get_option_name(value)
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
@@ -573,12 +582,11 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
- def verify(self, world, player_name: str, plando_options) -> None:
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if isinstance(self.value, int):
return
- from Generate import PlandoOptions
+ from BaseClasses import PlandoOptions
if not(PlandoOptions.bosses & plando_options):
- import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
@@ -716,7 +724,7 @@ class VerifyKeys:
value: typing.Any
@classmethod
- def verify_keys(cls, data):
+ def verify_keys(cls, data: typing.List[str]):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
@@ -725,12 +733,17 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
- def verify(self, world, player_name: str, plando_options) -> None:
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
new_value |= world.item_name_groups.get(item_name, {item_name})
self.value = new_value
+ elif self.convert_name_groups and self.verify_location_name:
+ new_value = type(self.value)()
+ for loc_name in self.value:
+ new_value |= world.location_name_groups.get(loc_name, {loc_name})
+ self.value = new_value
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
@@ -830,7 +843,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return item in self.value
-local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
+class ItemSet(OptionSet):
+ verify_item_name = True
+ convert_name_groups = True
class Accessibility(Choice):
@@ -872,11 +887,6 @@ common_options = {
}
-class ItemSet(OptionSet):
- verify_item_name = True
- convert_name_groups = True
-
-
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
@@ -898,22 +908,23 @@ class StartHints(ItemSet):
display_name = "Start Hints"
-class StartLocationHints(OptionSet):
+class LocationSet(OptionSet):
+ verify_location_name = True
+
+
+class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
- verify_location_name = True
-class ExcludeLocations(OptionSet):
+class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item"""
display_name = "Excluded Locations"
- verify_location_name = True
-class PriorityLocations(OptionSet):
+class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
- verify_location_name = True
class DeathLink(Toggle):
@@ -954,7 +965,7 @@ class ItemLinks(OptionList):
pool |= {item_name}
return pool
- def verify(self, world, player_name: str, plando_options) -> None:
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
link: dict
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
diff --git a/PokemonClient.py b/PokemonClient.py
index eb1f124391..e78e76fa00 100644
--- a/PokemonClient.py
+++ b/PokemonClient.py
@@ -17,7 +17,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
-location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
+location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
@@ -40,7 +40,7 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
-SCRIPT_VERSION = 1
+SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
@@ -70,6 +70,8 @@ class GBContext(CommonContext):
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
+ self.sent_release = False
+ self.sent_collect = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -124,7 +126,8 @@ def get_payload(ctx: GBContext):
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
- "deathlink": ctx.deathlink_pending
+ "deathlink": ctx.deathlink_pending,
+ "options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
@@ -134,10 +137,13 @@ def get_payload(ctx: GBContext):
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
- "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
+ "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
+ "Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
- if len(flags['Rod']) > 1:
- return
+ if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
+ flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
+ else:
+ flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
@@ -207,6 +213,16 @@ async def gb_sync_task(ctx: GBContext):
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
+ if 'options' in data_decoded:
+ msgs = []
+ if data_decoded['options'] & 4 and not ctx.sent_release:
+ ctx.sent_release = True
+ msgs.append({"cmd": "Say", "text": "!release"})
+ if data_decoded['options'] & 8 and not ctx.sent_collect:
+ ctx.sent_collect = True
+ msgs.append({"cmd": "Say", "text": "!collect"})
+ if msgs:
+ await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
diff --git a/README.md b/README.md
index 42493b5904..b99182f496 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,11 @@ Currently, the following games are supported:
* Overcooked! 2
* Zillion
* Lufia II Ancient Cave
+* Blasphemous
+* Wargroove
+* Stardew Valley
+* The Legend of Zelda
+* The Messenger
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
index 3b05f5aa87..cf16405766 100644
--- a/Starcraft2Client.py
+++ b/Starcraft2Client.py
@@ -52,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""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:
+ difficulty_choice = options[0].lower()
if difficulty_choice == "casual":
self.ctx.difficulty_override = 0
elif difficulty_choice == "normal":
@@ -71,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
return True
else:
- self.output("Difficulty needs to be specified in the command.")
+ if self.ctx.difficulty == -1:
+ self.output("Please connect to a seed before checking difficulty.")
+ else:
+ self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
+ self.output("To change the difficulty, add the name of the difficulty after the command.")
return False
def _cmd_disable_mission_check(self) -> bool:
diff --git a/Utils.py b/Utils.py
index 010cc3e5d3..059168c857 100644
--- a/Utils.py
+++ b/Utils.py
@@ -195,11 +195,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, timeout=10).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()
+ ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
@@ -213,7 +213,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, timeout=10).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
@@ -310,6 +310,14 @@ def get_default_options() -> OptionsType:
"lufia2ac_options": {
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
},
+ "tloz_options": {
+ "rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
+ "rom_start": True,
+ "display_msgs": True,
+ },
+ "wargroove_options": {
+ "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
+ }
}
return options
diff --git a/WargrooveClient.py b/WargrooveClient.py
new file mode 100644
index 0000000000..16bfeb15ab
--- /dev/null
+++ b/WargrooveClient.py
@@ -0,0 +1,445 @@
+from __future__ import annotations
+
+import atexit
+import os
+import sys
+import asyncio
+import random
+import shutil
+from typing import Tuple, List, Iterable, Dict
+
+from worlds.wargroove import WargrooveWorld
+from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
+
+import ModuleUpdate
+ModuleUpdate.update()
+
+import Utils
+import json
+import logging
+
+if __name__ == "__main__":
+ Utils.init_logging("WargrooveClient", exception_logger="Client")
+
+from NetUtils import NetworkItem, ClientStatus
+from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
+ CommonContext, server_loop
+
+wg_logger = logging.getLogger("WG")
+
+
+class WargrooveClientCommandProcessor(ClientCommandProcessor):
+ def _cmd_resync(self):
+ """Manually trigger a resync."""
+ self.output(f"Syncing items.")
+ self.ctx.syncing = True
+
+ def _cmd_commander(self, *commander_name: Iterable[str]):
+ """Set the current commander to the given commander."""
+ if commander_name:
+ self.ctx.set_commander(' '.join(commander_name))
+ else:
+ if self.ctx.can_choose_commander:
+ commanders = self.ctx.get_commanders()
+ wg_logger.info('Unlocked commanders: ' +
+ ', '.join((commander.name for commander, unlocked in commanders if unlocked)))
+ wg_logger.info('Locked commanders: ' +
+ ', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
+ else:
+ wg_logger.error('Cannot set commanders in this game mode.')
+
+
+class WargrooveContext(CommonContext):
+ command_processor: int = WargrooveClientCommandProcessor
+ game = "Wargroove"
+ items_handling = 0b111 # full remote
+ current_commander: CommanderData = faction_table["Starter"][0]
+ can_choose_commander: bool = False
+ commander_defense_boost_multiplier: int = 0
+ income_boost_multiplier: int = 0
+ starting_groove_multiplier: float
+ faction_item_ids = {
+ 'Starter': 0,
+ 'Cherrystone': 52025,
+ 'Felheim': 52026,
+ 'Floran': 52027,
+ 'Heavensong': 52028,
+ 'Requiem': 52029,
+ 'Outlaw': 52030
+ }
+ buff_item_ids = {
+ 'Income Boost': 52023,
+ 'Commander Defense Boost': 52024,
+ }
+
+ def __init__(self, server_address, password):
+ super(WargrooveContext, self).__init__(server_address, password)
+ 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 "appdata" in os.environ:
+ options = Utils.get_options()
+ root_directory = os.path.join(options["wargroove_options"]["root_directory"])
+ data_directory = os.path.join("lib", "worlds", "wargroove", "data")
+ dev_data_directory = os.path.join("worlds", "wargroove", "data")
+ appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
+ if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
+ print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
+ "Unable to infer required game_communication_path")
+ self.game_communication_path = os.path.join(root_directory, "AP")
+ if not os.path.exists(self.game_communication_path):
+ os.makedirs(self.game_communication_path)
+ self.remove_communication_files()
+ atexit.register(self.remove_communication_files)
+ if not os.path.isdir(appdata_wargroove):
+ print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
+ "Boot Wargroove and then close it to attempt to fix this error")
+ if not os.path.isdir(data_directory):
+ data_directory = dev_data_directory
+ if not os.path.isdir(data_directory):
+ print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
+ shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
+ else:
+ print_error_and_close("WargrooveClient couldn't detect system type. "
+ "Unable to infer required game_communication_path")
+
+ async def server_auth(self, password_requested: bool = False):
+ if password_requested and not self.password:
+ await super(WargrooveContext, self).server_auth(password_requested)
+ await self.get_username()
+ await self.send_connect()
+
+ async def connection_closed(self):
+ await super(WargrooveContext, self).connection_closed()
+ self.remove_communication_files()
+
+ @property
+ def endpoints(self):
+ if self.server:
+ return [self.server]
+ else:
+ return []
+
+ async def shutdown(self):
+ await super(WargrooveContext, self).shutdown()
+ self.remove_communication_files()
+
+ def remove_communication_files(self):
+ for root, dirs, files in os.walk(self.game_communication_path):
+ for file in files:
+ os.remove(root + "/" + file)
+
+ def on_package(self, cmd: str, args: dict):
+ if cmd in {"Connected"}:
+ filename = f"AP_settings.json"
+ with open(os.path.join(self.game_communication_path, filename), 'w') as f:
+ slot_data = args["slot_data"]
+ json.dump(args["slot_data"], f)
+ self.can_choose_commander = slot_data["can_choose_commander"]
+ print('can choose commander:', self.can_choose_commander)
+ self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
+ self.income_boost_multiplier = slot_data["income_boost"]
+ self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
+ f.close()
+ for ss in self.checked_locations:
+ filename = f"send{ss}"
+ with open(os.path.join(self.game_communication_path, filename), 'w') as f:
+ f.close()
+ self.update_commander_data()
+ self.ui.update_tracker()
+
+ random.seed(self.seed_name + str(self.slot))
+ # Our indexes start at 1 and we have 24 levels
+ for i in range(1, 25):
+ filename = f"seed{i}"
+ with open(os.path.join(self.game_communication_path, filename), 'w') as f:
+ f.write(str(random.randint(0, 4294967295)))
+ f.close()
+
+ if cmd in {"RoomInfo"}:
+ self.seed_name = args["seed_name"]
+
+ if cmd in {"ReceivedItems"}:
+ received_ids = [item.item for item in self.items_received]
+ for network_item in self.items_received:
+ filename = f"AP_{str(network_item.item)}.item"
+ path = os.path.join(self.game_communication_path, filename)
+
+ # Newly-obtained items
+ if not os.path.isfile(path):
+ open(path, 'w').close()
+ # Announcing commander unlocks
+ item_name = self.item_names[network_item.item]
+ if item_name in faction_table.keys():
+ for commander in faction_table[item_name]:
+ logger.info(f"{commander.name} has been unlocked!")
+
+ with open(path, 'w') as f:
+ item_count = received_ids.count(network_item.item)
+ if self.buff_item_ids["Income Boost"] == network_item.item:
+ f.write(f"{item_count * self.income_boost_multiplier}")
+ elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
+ f.write(f"{item_count * self.commander_defense_boost_multiplier}")
+ else:
+ f.write(f"{item_count}")
+ f.close()
+
+ print_filename = f"AP_{str(network_item.item)}.item.print"
+ print_path = os.path.join(self.game_communication_path, print_filename)
+ if not os.path.isfile(print_path):
+ open(print_path, 'w').close()
+ with open(print_path, 'w') as f:
+ f.write("Received " +
+ self.item_names[network_item.item] +
+ " from " +
+ self.player_names[network_item.player])
+ f.close()
+ self.update_commander_data()
+ self.ui.update_tracker()
+
+ if cmd in {"RoomUpdate"}:
+ 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:
+ f.close()
+
+ def run_gui(self):
+ """Import kivy UI system and start running it as self.ui_task."""
+ from kvui import GameManager, HoverBehavior, ServerToolTip
+ from kivy.uix.tabbedpanel import TabbedPanelItem
+ from kivy.lang import Builder
+ from kivy.uix.button import Button
+ from kivy.uix.togglebutton import ToggleButton
+ from kivy.uix.boxlayout import BoxLayout
+ from kivy.uix.gridlayout import GridLayout
+ from kivy.uix.image import AsyncImage, Image
+ from kivy.uix.stacklayout import StackLayout
+ from kivy.uix.label import Label
+ from kivy.properties import ColorProperty
+ from kivy.uix.image import Image
+ import pkgutil
+
+ class TrackerLayout(BoxLayout):
+ pass
+
+ class CommanderSelect(BoxLayout):
+ pass
+
+ class CommanderButton(ToggleButton):
+ pass
+
+ class FactionBox(BoxLayout):
+ pass
+
+ class CommanderGroup(BoxLayout):
+ pass
+
+ class ItemTracker(BoxLayout):
+ pass
+
+ class ItemLabel(Label):
+ pass
+
+ class WargrooveManager(GameManager):
+ logging_pairs = [
+ ("Client", "Archipelago"),
+ ("WG", "WG Console"),
+ ]
+ base_title = "Archipelago Wargroove Client"
+ ctx: WargrooveContext
+ unit_tracker: ItemTracker
+ trigger_tracker: BoxLayout
+ boost_tracker: BoxLayout
+ commander_buttons: Dict[int, List[CommanderButton]]
+ tracker_items = {
+ "Swordsman": ItemData(None, "Unit", False),
+ "Dog": ItemData(None, "Unit", False),
+ **item_table
+ }
+
+ def build(self):
+ container = super().build()
+ panel = TabbedPanelItem(text="Wargroove")
+ panel.content = self.build_tracker()
+ self.tabs.add_widget(panel)
+ return container
+
+ def build_tracker(self) -> TrackerLayout:
+ try:
+ tracker = TrackerLayout(orientation="horizontal")
+ commander_select = CommanderSelect(orientation="vertical")
+ self.commander_buttons = {}
+
+ for faction, commanders in faction_table.items():
+ faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
+ commander_group = CommanderGroup()
+ commander_buttons = []
+ for commander in commanders:
+ commander_button = CommanderButton(text=commander.name, group="commanders")
+ if faction == "Starter":
+ commander_button.disabled = False
+ commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
+ commander_buttons.append(commander_button)
+ commander_group.add_widget(commander_button)
+ self.commander_buttons[faction] = commander_buttons
+ faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
+ faction_box.add_widget(commander_group)
+ commander_select.add_widget(faction_box)
+ item_tracker = ItemTracker(padding=[0,20])
+ self.unit_tracker = BoxLayout(orientation="vertical")
+ other_tracker = BoxLayout(orientation="vertical")
+ self.trigger_tracker = BoxLayout(orientation="vertical")
+ self.boost_tracker = BoxLayout(orientation="vertical")
+ other_tracker.add_widget(self.trigger_tracker)
+ other_tracker.add_widget(self.boost_tracker)
+ item_tracker.add_widget(self.unit_tracker)
+ item_tracker.add_widget(other_tracker)
+ tracker.add_widget(commander_select)
+ tracker.add_widget(item_tracker)
+ self.update_tracker()
+ return tracker
+ except Exception as e:
+ print(e)
+
+ def update_tracker(self):
+ received_ids = [item.item for item in self.ctx.items_received]
+ for faction, item_id in self.ctx.faction_item_ids.items():
+ for commander_button in self.commander_buttons[faction]:
+ commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
+ self.unit_tracker.clear_widgets()
+ self.trigger_tracker.clear_widgets()
+ for name, item in self.tracker_items.items():
+ if item.type in ("Unit", "Trigger"):
+ status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
+ label = ItemLabel(text=name, color=status_color)
+ if item.type == "Unit":
+ self.unit_tracker.add_widget(label)
+ else:
+ self.trigger_tracker.add_widget(label)
+ self.boost_tracker.clear_widgets()
+ extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
+ extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
+ income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
+ defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
+ self.boost_tracker.add_widget(income_boost)
+ self.boost_tracker.add_widget(defense_boost)
+
+ self.ui = WargrooveManager(self)
+ data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
+ Builder.load_string(data)
+ self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
+
+ def update_commander_data(self):
+ if self.can_choose_commander:
+ faction_items = 0
+ faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
+ for network_item in self.items_received:
+ if self.item_names[network_item.item] in faction_item_names:
+ faction_items += 1
+ starting_groove = (faction_items - 1) * self.starting_groove_multiplier
+ # Must be an integer larger than 0
+ starting_groove = int(max(starting_groove, 0))
+ data = {
+ "commander": self.current_commander.internal_name,
+ "starting_groove": starting_groove
+ }
+ else:
+ data = {
+ "commander": "seed",
+ "starting_groove": 0
+ }
+ filename = 'commander.json'
+ with open(os.path.join(self.game_communication_path, filename), 'w') as f:
+ json.dump(data, f)
+ if self.ui:
+ self.ui.update_tracker()
+
+ def set_commander(self, commander_name: str) -> bool:
+ """Sets the current commander to the given one, if possible"""
+ if not self.can_choose_commander:
+ wg_logger.error("Cannot set commanders in this game mode.")
+ return
+ match_name = commander_name.lower()
+ for commander, unlocked in self.get_commanders():
+ if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
+ if unlocked:
+ self.current_commander = commander
+ self.syncing = True
+ wg_logger.info(f"Commander set to {commander.name}.")
+ self.update_commander_data()
+ return True
+ else:
+ wg_logger.error(f"Commander {commander.name} has not been unlocked.")
+ return False
+ else:
+ wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
+
+ def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
+ """Gets a list of commanders with their unlocked status"""
+ commanders = []
+ received_ids = [item.item for item in self.items_received]
+ for faction in faction_table.keys():
+ unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
+ commanders += [(commander, unlocked) for commander in faction_table[faction]]
+ return commanders
+
+
+async def game_watcher(ctx: WargrooveContext):
+ from worlds.wargroove.Locations import location_table
+ while not ctx.exit_event.is_set():
+ if ctx.syncing == True:
+ sync_msg = [{'cmd': 'Sync'}]
+ if ctx.locations_checked:
+ sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
+ await ctx.send_msgs(sync_msg)
+ ctx.syncing = False
+ sending = []
+ victory = False
+ for root, dirs, files in os.walk(ctx.game_communication_path):
+ for file in files:
+ if file.find("send") > -1:
+ st = file.split("send", -1)[1]
+ sending = sending+[(int(st))]
+ if file.find("victory") > -1:
+ victory = True
+ ctx.locations_checked = sending
+ message = [{"cmd": 'LocationChecks', "locations": sending}]
+ await ctx.send_msgs(message)
+ if not ctx.finished_game and victory:
+ await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
+ ctx.finished_game = True
+ await asyncio.sleep(0.1)
+
+
+def print_error_and_close(msg):
+ logger.error("Error: " + msg)
+ Utils.messagebox("Error", msg, error=True)
+ sys.exit(1)
+
+if __name__ == '__main__':
+ async def main(args):
+ ctx = WargrooveContext(args.connect, args.password)
+ ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
+ if gui_enabled:
+ ctx.run_gui()
+ ctx.run_cli()
+ progression_watcher = asyncio.create_task(
+ game_watcher(ctx), name="WargrooveProgressionWatcher")
+
+ await ctx.exit_event.wait()
+ ctx.server_address = None
+
+ await progression_watcher
+
+ await ctx.shutdown()
+
+ import colorama
+
+ parser = get_base_parser(description="Wargroove Client, for text interfacing.")
+
+ args, rest = parser.parse_known_args()
+ colorama.init()
+ asyncio.run(main(args))
+ colorama.deinit()
diff --git a/WebHost.py b/WebHost.py
index d098f6e7fb..40d366a02f 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -33,6 +33,11 @@ def get_app():
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
+ if not app.config["HOST_ADDRESS"]:
+ logging.info("Getting public IP, as HOST_ADDRESS is empty.")
+ app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
+ logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
+
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
return app
diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py
index e8e5b59d89..8bd3609c1d 100644
--- a/WebHostLib/__init__.py
+++ b/WebHostLib/__init__.py
@@ -51,7 +51,7 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
-app.config["PATCH_TARGET"] = "archipelago.gg"
+app.config["HOST_ADDRESS"] = ""
cache = Cache(app)
Compress(app)
diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py
index 4cf7243302..484755b3c3 100644
--- a/WebHostLib/autolauncher.py
+++ b/WebHostLib/autolauncher.py
@@ -179,6 +179,7 @@ class MultiworldInstance():
self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
+ self.host = config["HOST_ADDRESS"]
def start(self):
if self.process and self.process.is_alive():
@@ -187,7 +188,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(),
- self.cert, self.key),
+ self.cert, self.key, self.host),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.
diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py
index 9c21fca4f9..584ca9feca 100644
--- a/WebHostLib/customserver.py
+++ b/WebHostLib/customserver.py
@@ -131,6 +131,8 @@ def get_static_server_data() -> dict:
"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()},
+ "location_name_groups": {world_name: world.location_name_groups for world_name, world in
+ worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
@@ -140,7 +142,8 @@ def get_static_server_data() -> dict:
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
- cert_file: typing.Optional[str], cert_key_file: typing.Optional[str]):
+ cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
+ host: str):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
@@ -165,17 +168,18 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
- logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
- logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
port = socketname[1]
if port:
+ logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
+ else:
+ logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py
index d9600d2d16..02ea7320ef 100644
--- a/WebHostLib/downloads.py
+++ b/WebHostLib/downloads.py
@@ -26,7 +26,7 @@ def download_patch(room_id, patch_id):
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
- manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
+ manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
@@ -64,7 +64,7 @@ def download_slot_file(room_id, player_id: int):
if slot_data.game == "Minecraft":
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)
+ data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt
index 2397bf91b4..d5c1719863 100644
--- a/WebHostLib/requirements.txt
+++ b/WebHostLib/requirements.txt
@@ -1,7 +1,7 @@
-flask>=2.2.2
+flask>=2.2.3
pony>=0.7.16
waitress>=2.1.2
-Flask-Caching>=2.0.1
+Flask-Caching>=2.0.2
Flask-Compress>=1.13
-Flask-Limiter>=2.8.1
-bokeh>=3.0.2
+Flask-Limiter>=3.3.0
+bokeh>=3.1.0
diff --git a/WebHostLib/static/assets/baseHeader.js b/WebHostLib/static/assets/baseHeader.js
new file mode 100644
index 0000000000..b8ee82dd63
--- /dev/null
+++ b/WebHostLib/static/assets/baseHeader.js
@@ -0,0 +1,18 @@
+window.addEventListener('load', () => {
+ const menuButton = document.getElementById('base-header-mobile-menu-button');
+ const mobileMenu = document.getElementById('base-header-mobile-menu');
+
+ menuButton.addEventListener('click', (evt) => {
+ evt.preventDefault();
+
+ if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
+ return mobileMenu.style.display = 'flex';
+ }
+
+ mobileMenu.style.display = 'none';
+ });
+
+ window.addEventListener('resize', () => {
+ mobileMenu.style.display = 'none';
+ });
+});
diff --git a/WebHostLib/static/assets/checksfinderTracker.js b/WebHostLib/static/assets/checksfinderTracker.js
new file mode 100644
index 0000000000..61cf1e1559
--- /dev/null
+++ b/WebHostLib/static/assets/checksfinderTracker.js
@@ -0,0 +1,49 @@
+window.addEventListener('load', () => {
+ // Reload tracker every 60 seconds
+ const url = window.location;
+ setInterval(() => {
+ const ajax = new XMLHttpRequest();
+ ajax.onreadystatechange = () => {
+ if (ajax.readyState !== 4) { return; }
+
+ // Create a fake DOM using the returned HTML
+ const domParser = new DOMParser();
+ const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
+
+ // Update item tracker
+ document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
+ // Update only counters in the location-table
+ let counters = document.getElementsByClassName('counter');
+ const fakeCounters = fakeDOM.getElementsByClassName('counter');
+ for (let i = 0; i < counters.length; i++) {
+ counters[i].innerHTML = fakeCounters[i].innerHTML;
+ }
+ };
+ ajax.open('GET', url);
+ ajax.send();
+}, 60000)
+
+ // Collapsible advancement sections
+ const categories = document.getElementsByClassName("location-category");
+ for (let i = 0; i < categories.length; i++) {
+ let hide_id = categories[i].id.split('-')[0];
+ if (hide_id == 'Total') {
+ continue;
+ }
+ categories[i].addEventListener('click', function() {
+ // Toggle the advancement list
+ document.getElementById(hide_id).classList.toggle("hide");
+ // Change text of the header
+ const tab_header = document.getElementById(hide_id+'-header').children[0];
+ const orig_text = tab_header.innerHTML;
+ let new_text;
+ if (orig_text.includes("▼")) {
+ new_text = orig_text.replace("▼", "▲");
+ }
+ else {
+ new_text = orig_text.replace("▲", "▼");
+ }
+ tab_header.innerHTML = new_text;
+ });
+ }
+});
diff --git a/WebHostLib/static/assets/lttpMultiTracker.js b/WebHostLib/static/assets/lttpMultiTracker.js
new file mode 100644
index 0000000000..e90331028d
--- /dev/null
+++ b/WebHostLib/static/assets/lttpMultiTracker.js
@@ -0,0 +1,6 @@
+window.addEventListener('load', () => {
+ $(".table-wrapper").scrollsync({
+ y_sync: true,
+ x_sync: true
+ });
+});
diff --git a/WebHostLib/static/assets/tracker.js b/WebHostLib/static/assets/trackerCommon.js
similarity index 97%
rename from WebHostLib/static/assets/tracker.js
rename to WebHostLib/static/assets/trackerCommon.js
index 23e7f979a5..c08590cbf7 100644
--- a/WebHostLib/static/assets/tracker.js
+++ b/WebHostLib/static/assets/trackerCommon.js
@@ -1,5 +1,7 @@
const adjustTableHeight = () => {
const tablesContainer = document.getElementById('tables-container');
+ if (!tablesContainer)
+ return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
@@ -108,7 +110,7 @@ window.addEventListener('load', () => {
const update = () => {
const target = $("
");
console.log("Updating Tracker...");
- target.load("/tracker/" + tracker, function (response, status) {
+ target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
@@ -135,10 +137,5 @@ window.addEventListener('load', () => {
tables.draw();
});
- $(".table-wrapper").scrollsync({
- y_sync: true,
- x_sync: true
- });
-
adjustTableHeight();
});
diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.png b/WebHostLib/static/static/button-images/hamburger-menu-icon.png
new file mode 100644
index 0000000000..f1c9631635
Binary files /dev/null and b/WebHostLib/static/static/button-images/hamburger-menu-icon.png differ
diff --git a/WebHostLib/static/styles/checksfinderTracker.css b/WebHostLib/static/styles/checksfinderTracker.css
new file mode 100644
index 0000000000..e0cde61241
--- /dev/null
+++ b/WebHostLib/static/styles/checksfinderTracker.css
@@ -0,0 +1,30 @@
+#player-tracker-wrapper{
+ margin: 0;
+}
+
+#inventory-table{
+ padding: 8px 10px 2px 6px;
+ background-color: #42b149;
+ border-radius: 4px;
+ border: 2px solid black;
+}
+
+#inventory-table tr.column-headers td {
+ font-size: 1rem;
+ padding: 0 5rem 0 0;
+}
+
+#inventory-table td{
+ padding: 0 0.5rem 0.5rem;
+ font-family: LexendDeca-Light, monospace;
+ font-size: 2.5rem;
+ color: #ffffff;
+}
+
+#inventory-table td img{
+ vertical-align: middle;
+}
+
+.hide {
+ display: none;
+}
diff --git a/WebHostLib/static/styles/themes/base.css b/WebHostLib/static/styles/themes/base.css
index fca65a51c1..d38a8e610c 100644
--- a/WebHostLib/static/styles/themes/base.css
+++ b/WebHostLib/static/styles/themes/base.css
@@ -42,7 +42,7 @@ html{
margin-top: 4px;
}
-#base-header a{
+#base-header a, #base-header-mobile-menu a{
color: #2f6b83;
text-decoration: none;
cursor: pointer;
@@ -51,3 +51,64 @@ html{
font-family: LondrinaSolid-Light, sans-serif;
text-transform: uppercase;
}
+
+#base-header-right-mobile{
+ display: none;
+ margin-top: 2rem;
+ margin-right: 1rem;
+}
+
+#base-header-mobile-menu{
+ display: none;
+ flex-direction: column;
+ background-color: #ffffff;
+ text-align: center;
+ overflow-y: auto;
+ z-index: 10000;
+ width: 100vw;
+ border-bottom-left-radius: 20px;
+ border-bottom-right-radius: 20px;
+
+ position: absolute;
+ top: 7rem;
+ right: 0;
+ padding-top: 1rem;
+}
+
+#base-header-mobile-menu a{
+ padding: 4rem 2rem;
+ font-size: 5rem;
+ line-height: 5rem;
+ color: #699ca8;
+ border-top: 1px solid #d3d3d3;
+}
+
+#base-header-right-mobile img{
+ height: 3rem;
+}
+
+@media all and (max-width: 1580px){
+ html{
+ padding-top: 260px;
+ scroll-padding-top: 230px;
+ }
+
+ #base-header{
+ height: 200px;
+ background-size: auto 200px;
+ }
+
+ #base-header #site-title img{
+ height: calc(38px * 2);
+ margin-top: 30px;
+ margin-left: 20px;
+ }
+
+ #base-header-right{
+ display: none;
+ }
+
+ #base-header-right-mobile{
+ display: unset;
+ }
+}
diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css
index e203d9e97d..0e00553c72 100644
--- a/WebHostLib/static/styles/tracker.css
+++ b/WebHostLib/static/styles/tracker.css
@@ -119,6 +119,33 @@ img.alttp-sprite {
background-color: #d3c97d;
}
+#tracker-navigation {
+ display: inline-flex;
+ background-color: #b0a77d;
+ margin: 0.5rem;
+ border-radius: 4px;
+}
+
+.tracker-navigation-button {
+ display: block;
+ margin: 4px;
+ padding-left: 12px;
+ padding-right: 12px;
+ border-radius: 4px;
+ text-align: center;
+ font-size: 14px;
+ color: #000;
+ font-weight: lighter;
+}
+
+.tracker-navigation-button:hover {
+ background-color: #e2eabb !important;
+}
+
+.tracker-navigation-button.selected {
+ background-color: rgb(220, 226, 189);
+}
+
@media all and (max-width: 1700px) {
table.dataTable thead th.upper-row{
position: -webkit-sticky;
diff --git a/WebHostLib/templates/checksfinderTracker.html b/WebHostLib/templates/checksfinderTracker.html
new file mode 100644
index 0000000000..5df77f5e74
--- /dev/null
+++ b/WebHostLib/templates/checksfinderTracker.html
@@ -0,0 +1,35 @@
+
+
+
+ {{ player_name }}'s Tracker
+
+
+
+
+
+
+
+
diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html
index 508c084e7f..1c2fcd44c0 100644
--- a/WebHostLib/templates/genericTracker.html
+++ b/WebHostLib/templates/genericTracker.html
@@ -4,7 +4,7 @@
{{ player_name }}'s Tracker
-
+
{% endblock %}
{% block body %}
diff --git a/WebHostLib/templates/header/baseHeader.html b/WebHostLib/templates/header/baseHeader.html
index a76835b5d9..80e8d6220d 100644
--- a/WebHostLib/templates/header/baseHeader.html
+++ b/WebHostLib/templates/header/baseHeader.html
@@ -1,5 +1,6 @@
{% block head %}
+
{% endblock %}
{% block header %}
@@ -16,5 +17,17 @@
f.a.q.
discord
+
+
{% endblock %}
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html
index 1c8f3de255..6f02dc0944 100644
--- a/WebHostLib/templates/hostRoom.html
+++ b/WebHostLib/templates/hostRoom.html
@@ -14,7 +14,7 @@
{% endif %}
{% if room.tracker %}
- This room has a Multiworld Tracker enabled.
+ This room has a Multiworld Tracker enabled.
{% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
@@ -25,8 +25,8 @@
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using
- '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
+ data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
+ '/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
in the client .
{% endif %}
diff --git a/WebHostLib/templates/tracker.html b/WebHostLib/templates/lttpMultiTracker.html
similarity index 97%
rename from WebHostLib/templates/tracker.html
rename to WebHostLib/templates/lttpMultiTracker.html
index 96148e3454..276e1de3ce 100644
--- a/WebHostLib/templates/tracker.html
+++ b/WebHostLib/templates/lttpMultiTracker.html
@@ -1,14 +1,16 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
- Multiworld Tracker
+ ALttP Multiworld Tracker
-
+
+
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
+ {% include 'multiTrackerNavigation.html' %}