Merge branch 'main' into setup_flip_apworld_list

This commit is contained in:
black-sliver
2023-06-25 01:59:46 +02:00
committed by GitHub
17 changed files with 186 additions and 85 deletions

View File

@@ -23,6 +23,7 @@ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
import ssl
if typing.TYPE_CHECKING:
import kvui
@@ -33,6 +34,12 @@ logger = logging.getLogger("Client")
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@Utils.cache_argsless
def get_ssl_context():
import certifi
return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
@@ -589,7 +596,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
@@ -604,6 +612,7 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
except websockets.InvalidMessage:
# probably encrypted
if address.startswith("ws://"):
# try wss
await server_loop(ctx, "ws" + address[1:])
else:
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"

View File

@@ -447,6 +447,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types:
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.")
if ret.game not in weights:
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
@@ -461,32 +466,29 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in Options.common_options.items():
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.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options)
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 option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
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 option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.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 PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
if PlandoOptions.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 PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
return ret

View File

@@ -92,7 +92,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
except Exception as e:
results[filename] = f"Failed to generate mystery in {filename}: {e}"
results[filename] = f"Failed to generate options in {filename}: {e}"
else:
results[filename] = True
return results, rolled_results

View File

@@ -169,13 +169,11 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
port = 0

View File

@@ -148,7 +148,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
@@ -185,7 +185,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
@@ -269,7 +269,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, [specialRange, specialRangeSelect])
event, specialRange, specialRangeSelect)
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
@@ -294,23 +294,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table;
};
const toggleRandomize = (event, inputElements) => {
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameSetting(randomButton);
};
const updateBaseSetting = (event) => {
@@ -364,6 +366,7 @@ const generateGame = (raceMode = false) => {
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;

View File

@@ -1199,6 +1199,7 @@ const generateGame = (raceMode = false) => {
weights: { player: JSON.stringify(settings) },
presetData: { player: JSON.stringify(settings) },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;

View File

@@ -341,3 +341,4 @@ The various methods and attributes are documented in `/worlds/AutoWorld.py[World
[world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md),
though it is also recommended to look at existing implementations to see how all this works first-hand.
Once you get all that, all that remains to do is test the game and publish your work.
Make sure to check out [world maintainer.md](./world%20maintainer.md) before publishing.

View File

@@ -10,3 +10,5 @@ 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).
If you want to merge a new game, please make sure to read the responsibilities as
[world maintainer](/docs/world%20maintainer.md).

View File

@@ -69,6 +69,19 @@ It should be dropped as "SNI" into the root folder of the project. Alternatively
host.yaml at your SNI folder.
## Optional: Git
[Git](https://git-scm.com) is required to install some of the packages that Archipelago depends on.
It may be possible to run Archipelago from source without it, at your own risk.
It is also generally recommended to have Git installed and understand how to use it, especially if you're thinking about contributing.
You can download the latest release of Git at [The downloads page on the Git website](https://git-scm.com/downloads).
Beyond that, there are also graphical interfaces for Git that make it more accessible.
For repositories on Github (such as this one), [Github Desktop](https://desktop.github.com) is one such option.
PyCharm has a built-in version control integration that supports Git.
## Running tests
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.

60
docs/world maintainer.md Normal file
View File

@@ -0,0 +1,60 @@
# World Maintainer
A world maintainer is a person responsible for a world or part of a world in Archipelago.
If a world author does not want to take on the responsibilities of a world maintainer, they can release their world as
an unofficial [APWorld](/docs/apworld%20specification.md) or maintain their own fork instead.
## Responsibilities
Unless these are shared between multiple people, we expect the following from each world maintainer
* Be on our Discord to get updates on problems with and suggestions for the world.
* Decide if a feature (pull request) should be merged.
* Review contents of such pull requests or organize peer reviews or post that you did not review the content.
* Fix or point out issues when core changes break your code.
* Use the watch function on GitHub, the #github-updates channel on Discord or check manually from time to time for new
pull requests. Core maintainers may also ping you if a pull request concerns your world.
* Test (or have tested) the world on the main branch from time to time, especially during RC (release candidate) phases
of development.
* Let us know of long unavailabilities.
## Becoming a World Maintainer
### Adding a World
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
nominate someone else (i.e. there are multiple devs).
### Getting Voted
When a world is unmaintained, the [core maintainers](https://github.com/orgs/ArchipelagoMW/people)
can vote for a new maintainer if there is a candidate.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 1 week, but can end early if the majority is reached earlier.
Voting shall be conducted on Discord in #archipelago-dev.
## Dropping out
### Resigning
A world maintainer can resign. If no new maintainer steps up and gets voted, the world becomes unmaintained.
### Getting Voted out
A world maintainer can be voted out by the [core maintainers](https://github.com/orgs/ArchipelagoMW/people),
for example when they become unreachable.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
made their case or was pinged and has been unreachable for more than 2 weeks already.
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds
As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
moved from `worlds/` to `worlds_disabled/`.

View File

@@ -7,3 +7,4 @@ schema>=0.7.5
kivy>=2.2.0
bsdiff4>=1.2.3
platformdirs>=3.5.1
certifi>=2023.5.7

View File

@@ -32,12 +32,16 @@ class DungeonItemData(ItemData):
s = self.ladxr_id[:-1]
return DungeonItemType.__dict__[s]
class TradeItemData(ItemData):
vanilla_location = None
def __new__(cls, item_name, ladxr_id, classification, vanilla_location):
self = super(ItemData, cls).__new__(cls, (item_name, ladxr_id, classification))
self.vanilla_location = vanilla_location
return self
class LinksAwakeningItem(Item):
game: str = Common.LINKS_AWAKENING
@@ -49,6 +53,7 @@ class LinksAwakeningItem(Item):
super().__init__(item_data.item_name, classification, Common.BASE_ID + item_data.item_id, player)
self.item_data = item_data
# TODO: use _NAMES instead?
class ItemName:
POWER_BRACELET = "Progressive Power Bracelet"

View File

@@ -1,12 +1,11 @@
from BaseClasses import Region, Entrance, Location
from worlds.AutoWorld import LogicMixin
from BaseClasses import Region, Entrance, Location, CollectionState
from .LADXR.checkMetadata import checkMetadataTable
from .Common import *
from worlds.generic.Rules import add_item_rule
from .Items import ladxr_item_to_la_item_name, ItemName, LinksAwakeningItem
from .LADXR.locations.tradeSequence import TradeRequirements, TradeSequenceItem
from .Items import ladxr_item_to_la_item_name
prefilled_events = ["ANGLER_KEYHOLE", "RAFT", "MEDICINE2", "CASTLE_BUTTON"]
@@ -80,27 +79,19 @@ class LinksAwakeningLocation(Location):
add_item_rule(self, filter_item)
def has_free_weapon(state: "CollectionState", player: int) -> bool:
def has_free_weapon(state: CollectionState, player: int) -> bool:
return state.has("Progressive Sword", player) or state.has("Magic Rod", player) or state.has("Boomerang", player) or state.has("Hookshot", player)
# If the player has access to farm enough rupees to afford a game, we assume that they can keep beating the game
def can_farm_rupees(state: "CollectionState", player: int) -> bool:
def can_farm_rupees(state: CollectionState, player: int) -> bool:
return has_free_weapon(state, player) and (state.has("Can Play Trendy Game", player=player) or state.has("RAFT", player=player))
class LinksAwakeningLogic(LogicMixin):
rupees = {
ItemName.RUPEES_20: 0,
ItemName.RUPEES_50: 0,
ItemName.RUPEES_100: 100,
ItemName.RUPEES_200: 200,
ItemName.RUPEES_500: 500,
}
def get_credits(self, player: int):
if can_farm_rupees(self, player):
return 999999999
return sum(self.count(item_name, player) * amount for item_name, amount in self.rupees.items())
def get_credits(state: CollectionState, player: int):
if can_farm_rupees(state, player):
return 999999999
return state.prog_items["RUPEES", player]
class LinksAwakeningRegion(Region):
@@ -137,7 +128,7 @@ class GameStateAdapater:
def get(self, item, default):
if item == "RUPEES":
return self.state.get_credits(self.player)
return get_credits(self.state, self.player)
elif item.endswith("_USED"):
return 0
else:

View File

@@ -1,6 +1,5 @@
import binascii
import bsdiff4
import itertools
import os
import pkgutil
import tempfile
@@ -12,7 +11,7 @@ from worlds.AutoWorld import WebWorld, World
from .Common import *
from .Items import (DungeonItemData, DungeonItemType, LinksAwakeningItem, TradeItemData,
ladxr_item_to_la_item_name, links_awakening_items,
links_awakening_items_by_name)
links_awakening_items_by_name, ItemName)
from .LADXR import generator
from .LADXR.itempool import ItemPool as LADXRItemPool
from .LADXR.logic import Logic as LAXDRLogic
@@ -29,6 +28,7 @@ from .Rom import LADXDeltaPatch
DEVELOPER_MODE = False
class LinksAwakeningWebWorld(WebWorld):
tutorials = [Tutorial(
"Multiworld Setup Guide",
@@ -45,7 +45,7 @@ class LinksAwakeningWorld(World):
After a previous adventure, Link is stranded on Koholint Island, full of mystery and familiar faces.
Gather the 8 Instruments of the Sirens to wake the Wind Fish, so that Link can go home!
"""
game: str = LINKS_AWAKENING # name of the game/world
game = LINKS_AWAKENING # name of the game/world
web = LinksAwakeningWebWorld()
option_definitions = links_awakening_options # options the player can set
@@ -82,6 +82,14 @@ class LinksAwakeningWorld(World):
player_options = None
rupees = {
ItemName.RUPEES_20: 0,
ItemName.RUPEES_50: 0,
ItemName.RUPEES_100: 100,
ItemName.RUPEES_200: 200,
ItemName.RUPEES_500: 500,
}
def convert_ap_options_to_ladxr_logic(self):
self.player_options = {
option: getattr(self.multiworld, option)[self.player] for option in self.option_definitions
@@ -95,7 +103,6 @@ class LinksAwakeningWorld(World):
self.ladxr_logic = LAXDRLogic(configuration_options=self.laxdr_options, world_setup=world_setup)
self.ladxr_itempool = LADXRItemPool(self.ladxr_logic, self.laxdr_options, self.multiworld.random).toDict()
def create_regions(self) -> None:
# Initialize
self.convert_ap_options_to_ladxr_logic()
@@ -401,9 +408,6 @@ class LinksAwakeningWorld(World):
return "TRADING_ITEM_LETTER"
def generate_output(self, output_directory: str):
# copy items back to locations
for r in self.multiworld.get_regions(self.player):
@@ -464,9 +468,8 @@ class LinksAwakeningWorld(World):
bsdiff4.file_patch_inplace(out_path, title_patch.name)
os.unlink(title_patch.name)
patch = LADXDeltaPatch(os.path.splitext(out_path)[0]+LADXDeltaPatch.patch_file_ending, player=self.player,
player_name=self.multiworld.player_name[self.player], patched_path=out_path)
player_name=self.multiworld.player_name[self.player], patched_path=out_path)
patch.write()
if not DEVELOPER_MODE:
os.unlink(out_path)
@@ -475,4 +478,20 @@ class LinksAwakeningWorld(World):
return bytearray(self.multiworld.random.getrandbits(8) for _ in range(10)) + self.player.to_bytes(2, 'big')
def modify_multidata(self, multidata: dict):
multidata["connect_names"][binascii.hexlify(self.multi_key).decode()] = multidata["connect_names"][self.multiworld.player_name[self.player]]
multidata["connect_names"][binascii.hexlify(self.multi_key).decode()] = multidata["connect_names"][self.multiworld.player_name[self.player]]
def collect(self, state, item: Item) -> bool:
change = super().collect(state, item)
if change:
rupees = self.rupees.get(item.name, 0)
state.prog_items["RUPEES", item.player] += rupees
return change
def remove(self, state, item: Item) -> bool:
change = super().remove(state, item)
if change:
rupees = self.rupees.get(item.name, 0)
state.prog_items["RUPEES", item.player] -= rupees
return change

View File

@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
from ..generic.Rules import add_rule
from worlds.generic.Rules import add_rule
from .Locations import food_locations, shop_locations
from .ItemPool import dangerous_weapon_locations
from .Options import StartingPosition

View File

@@ -1,8 +1,7 @@
import logging
import os
import threading
import pkgutil
from typing import NamedTuple, Union, Dict, Any
from pkgutil import get_data
from typing import Dict, Any
import bsdiff4
@@ -168,9 +167,8 @@ class TLoZWorld(World):
# Remove map/compass check so they're always on
# Removing a bit from the boss roars flags, so we can have more dungeon items. This allows us to
# go past 0x1F items for dungeon items.
base_patch_location = os.path.dirname(__file__) + "/z1_base_patch.bsdiff4"
with open(base_patch_location, "rb") as base_patch:
rom_data = bsdiff4.patch(rom.read(), base_patch.read())
base_patch = get_data(__name__, os.path.join(os.path.dirname(__file__), "z1_base_patch.bsdiff4"))
rom_data = bsdiff4.patch(rom.read(), base_patch)
rom_data = bytearray(rom_data)
# Set every item to the new nothing value, but keep room flags. Type 2 boss roars should
# become type 1 boss roars, so we at least keep the sound of roaring where it should be.
@@ -275,8 +273,10 @@ class TLoZWorld(World):
def modify_multidata(self, multidata: dict):
import base64
self.rom_name_available_event.wait()
new_name = base64.b64encode(bytes(self.rom_name)).decode()
multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]]
rom_name = getattr(self, "rom_name", None)
if rom_name:
new_name = base64.b64encode(bytes(self.rom_name)).decode()
multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]]
def get_filler_item_name(self) -> str:
if self.filler_items is None:
@@ -320,4 +320,4 @@ class TLoZItem(Item):
class TLoZLocation(Location):
game = 'The Legend of Zelda'
game = 'The Legend of Zelda'

View File

@@ -463,10 +463,6 @@ class WitnessPlayerLocations:
self.PANEL_TYPES_TO_SHUFFLE = {"General", "Laser"}
self.CHECK_LOCATIONS = StaticWitnessLocations.GENERAL_LOCATIONS.copy()
if get_option_value(world, player, "puzzle_randomization") == 1:
self.CHECK_LOCATIONS.remove("Keep Pressure Plates 4")
self.CHECK_LOCATIONS.add("Keep Pressure Plates 2")
doors = get_option_value(world, player, "shuffle_doors") >= 2
earlyutm = is_option_enabled(world, player, "early_secret_area")
victory = get_option_value(world, player, "victory_condition")