diff --git a/BaseClasses.py b/BaseClasses.py index ada18f1e1d..88857f8032 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -728,7 +728,7 @@ class CollectionState(): return True return False - def has_from_list_exclusive(self, items: Iterable[str], player: int, count: int) -> bool: + def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool: """Returns True if the state contains at least `count` items matching any of the item names from a list. Ignores duplicates of the same item.""" found: int = 0 @@ -743,7 +743,7 @@ class CollectionState(): """Returns the cumulative count of items from a list present in state.""" return sum(self.prog_items[player][item_name] for item_name in items) - def count_from_list_exclusive(self, items: Iterable[str], player: int) -> int: + def count_from_list_unique(self, items: Iterable[str], player: int) -> int: """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item.""" return sum(self.prog_items[player][item_name] > 0 for item_name in items) @@ -758,7 +758,7 @@ class CollectionState(): return True return False - def has_group_exclusive(self, item_name_group: str, player: int, count: int = 1) -> bool: + def has_group_unique(self, item_name_group: str, player: int, count: int = 1) -> bool: """Returns True if the state contains at least `count` items present in a specified item group. Ignores duplicates of the same item. """ @@ -778,7 +778,7 @@ class CollectionState(): for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group] ) - def count_group_exclusive(self, item_name_group: str, player: int) -> int: + def count_group_unique(self, item_name_group: str, player: int) -> int: """Returns the cumulative count of items from an item group present in state. Ignores duplicates of the same item.""" player_prog_items = self.prog_items[player] diff --git a/Generate.py b/Generate.py index 30b992317d..fab34c893a 100644 --- a/Generate.py +++ b/Generate.py @@ -432,7 +432,6 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, player_option = option.from_any(game_weights[option_key]) else: player_option = option.from_any(get_choice(option_key, game_weights)) - del game_weights[option_key] else: player_option = option.from_any(option.default) # call the from_any here to support default "random" setattr(ret, option_key, player_option) @@ -446,9 +445,9 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b if "linked_options" in weights: weights = roll_linked_options(weights) - valid_trigger_names = set() + valid_keys = set() if "triggers" in weights: - weights = roll_triggers(weights, weights["triggers"], valid_trigger_names) + weights = roll_triggers(weights, weights["triggers"], valid_keys) requirements = weights.get("requires", {}) if requirements: @@ -490,7 +489,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}") if "triggers" in game_weights: - weights = roll_triggers(weights, game_weights["triggers"], valid_trigger_names) + weights = roll_triggers(weights, game_weights["triggers"], valid_keys) game_weights = weights[ret.game] ret.name = get_choice('name', weights) @@ -499,8 +498,9 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b for option_key, option in world_type.options_dataclass.type_hints.items(): handle_option(ret, game_weights, option_key, option, plando_options) + valid_keys.add(option_key) for option_key in game_weights: - if option_key in {"triggers", *valid_trigger_names}: + if option_key in {"triggers", *valid_keys}: continue logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.") if PlandoOptions.items in plando_options: diff --git a/Main.py b/Main.py index 8b15a57a69..de6b467f93 100644 --- a/Main.py +++ b/Main.py @@ -372,6 +372,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} + # get spheres -> filter address==None -> skip empty + spheres: List[Dict[int, Set[int]]] = [] + for sphere in multiworld.get_spheres(): + current_sphere: Dict[int, Set[int]] = collections.defaultdict(set) + for sphere_location in sphere: + if type(sphere_location.address) is int: + current_sphere[sphere_location.player].add(sphere_location.address) + + if current_sphere: + spheres.append(dict(current_sphere)) + multidata = { "slot_data": slot_data, "slot_info": slot_info, @@ -386,6 +397,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No "tags": ["AP"], "minimum_versions": minimum_versions, "seed_name": multiworld.seed_name, + "spheres": spheres, "datapackage": data_package, } AutoWorld.call_all(multiworld, "modify_multidata", multidata) diff --git a/MultiServer.py b/MultiServer.py index e95e44dd7d..4fb03732d8 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -175,8 +175,11 @@ class Context: 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]] + spheres: typing.List[typing.Dict[int, typing.Set[int]]] + """ each sphere is { player: { location_id, ... } } """ logger: logging.Logger + def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, @@ -238,6 +241,7 @@ class Context: self.stored_data = {} self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet) self.read_data = {} + self.spheres = [] # init empty to satisfy linter, I suppose self.gamespackage = {} @@ -466,6 +470,9 @@ class Context: 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] + # sorted access spheres + self.spheres = decoded_obj.get("spheres", []) + # saving def save(self, now=False) -> bool: @@ -624,6 +631,16 @@ class Context: self.recheck_hints(team, slot) return self.hints[team, slot] + def get_sphere(self, player: int, location_id: int) -> int: + """Get sphere of a location, -1 if spheres are not available.""" + if self.spheres: + for i, sphere in enumerate(self.spheres): + if location_id in sphere.get(player, set()): + return i + raise KeyError(f"No Sphere found for location ID {location_id} belonging to player {player}. " + f"Location or player may not exist.") + return -1 + def get_players_package(self): return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()] @@ -1549,6 +1566,9 @@ class ClientMessageProcessor(CommonCommandProcessor): self.ctx.random.shuffle(not_found_hints) # By popular vote, make hints prefer non-local placements not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player)) + # By another popular vote, prefer early sphere + not_found_hints.sort(key=lambda hint: self.ctx.get_sphere(hint.finding_player, hint.location), + reverse=True) hints = found_hints + old_hints while can_pay > 0: @@ -1558,10 +1578,10 @@ class ClientMessageProcessor(CommonCommandProcessor): hints.append(hint) can_pay -= 1 self.ctx.hints_used[self.client.team, self.client.slot] += 1 - points_available = get_client_points(self.ctx, self.client) self.ctx.notify_hints(self.client.team, hints) if not_found_hints: + points_available = get_client_points(self.ctx, self.client) if hints and cost and int((points_available // cost) == 0): self.output( f"There may be more hintables, however, you cannot afford to pay for any more. " diff --git a/Options.py b/Options.py index 39fd567656..7f480cbaae 100644 --- a/Options.py +++ b/Options.py @@ -1130,9 +1130,41 @@ class OptionGroup(typing.NamedTuple): """Name of the group to categorize these options in for display on the WebHost and in generated YAMLS.""" options: typing.List[typing.Type[Option[typing.Any]]] """Options to be in the defined group.""" + start_collapsed: bool = False + """Whether the group will start collapsed on the WebHost options pages.""" -def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): +item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints, + StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks] +""" +Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group. +If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to +it. +""" + + +def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[ + str, typing.Dict[str, typing.Type[Option[typing.Any]]]]: + """Generates and returns a dictionary for the option groups of a specified world.""" + option_groups = {option: option_group.name + for option_group in world.web.option_groups + for option in option_group.options} + # add a default option group for uncategorized options to get thrown into + ordered_groups = ["Game Options"] + [ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups] + grouped_options = {group: {} for group in ordered_groups} + for option_name, option in world.options_dataclass.type_hints.items(): + if visibility_level & option.visibility: + grouped_options[option_groups.get(option, "Game Options")][option_name] = option + + # if the world doesn't have any ungrouped options, this group will be empty so just remove it + if not grouped_options["Game Options"]: + del grouped_options["Game Options"] + + return grouped_options + + +def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: import os import yaml @@ -1170,17 +1202,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - - option_groups = {option: option_group.name - for option_group in world.web.option_groups - for option in option_group.options} - ordered_groups = ["Game Options"] - [ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups] - grouped_options = {group: {} for group in ordered_groups} - for option_name, option in world.options_dataclass.type_hints.items(): - if option.visibility >= Visibility.template: - grouped_options[option_groups.get(option, "Game Options")][option_name] = option - + grouped_options = get_option_groups(world) with open(local_path("data", "options.yaml")) as f: file_data = f.read() res = Template(file_data).render( diff --git a/README.md b/README.md index 4633c99c66..cebd4f7e75 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases) -Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself. +Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, +presently, Archipelago is also the randomizer itself. Currently, the following games are supported: + * The Legend of Zelda: A Link to the Past * Factorio * Minecraft @@ -77,36 +79,57 @@ windows binaries. ## History -Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are: +Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. +The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are: * [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31) * [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer) * [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer) * [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer) -* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions. +* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) + and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the + vast majority of Enemizer contributions. -We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly. +We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the +path. Just because one person's name may be in a repository title does not mean that only one person made that project +happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor +them fairly. ### Path to the Archipelago -Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project. + +Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a +long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to +_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as +"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository +(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project. ## Running Archipelago -For most people, all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer, or AppImage for Linux-based systems. -If you are a developer or are running on a platform with no compiled releases available, please see our doc on [running Archipelago from source](docs/running%20from%20source.md). +For most people, all you need to do is head over to +the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate +installer, or AppImage for Linux-based systems. + +If you are a developer or are running on a platform with no compiled releases available, please see our doc on +[running Archipelago from source](docs/running%20from%20source.md). ## Related Repositories -This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present. + +This project makes use of multiple other projects. We wouldn't be here without these other repositories and the +contributions of their developers, past and present. * [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer) * [Enemizer](https://github.com/Ijwu/Enemizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) ## Contributing -For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md) + +To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our +[Contributing guidelines](/docs/contributing.md). ## FAQ -For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/) + +For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/). ## Code of Conduct -Please refer to our [code of conduct.](/docs/code_of_conduct.md) + +Please refer to our [code of conduct](/docs/code_of_conduct.md). diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 16769b7a76..3a86cb551d 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -106,9 +106,9 @@ class WebHostContext(Context): static_gamespackage = self.gamespackage # this is shared across all rooms static_item_name_groups = self.item_name_groups static_location_name_groups = self.location_name_groups - self.gamespackage = {"Archipelago": static_gamespackage["Archipelago"]} # this may be modified by _load - self.item_name_groups = {} - self.location_name_groups = {} + self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load + self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})} + self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})} for game in list(multidata.get("datapackage", {})): game_data = multidata["datapackage"][game] diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index a78560cb0b..a12dc0f4ae 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -6,7 +6,7 @@ import random import tempfile import zipfile from collections import Counter -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, Set from flask import flash, redirect, render_template, request, session, url_for from pony.orm import commit, db_session @@ -16,6 +16,7 @@ from Generate import PlandoOptions, handle_name from Main import main as ERmain from Utils import __version__ from WebHostLib import app +from settings import ServerOptions, GeneratorOptions from worlds.alttp.EntranceRandomizer import parse_arguments from .check import get_yaml_data, roll_options from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID @@ -23,25 +24,22 @@ from .upload import upload_zip_to_db def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: - plando_options = { - options_source.get("plando_bosses", ""), - options_source.get("plando_items", ""), - options_source.get("plando_connections", ""), - options_source.get("plando_texts", "") - } - plando_options -= {""} + plando_options: Set[str] = set() + for substr in ("bosses", "items", "connections", "texts"): + if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): + plando_options.add(substr) server_options = { - "hint_cost": int(options_source.get("hint_cost", 10)), - "release_mode": options_source.get("release_mode", "goal"), - "remaining_mode": options_source.get("remaining_mode", "disabled"), - "collect_mode": options_source.get("collect_mode", "disabled"), - "item_cheat": bool(int(options_source.get("item_cheat", 1))), + "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), + "release_mode": options_source.get("release_mode", ServerOptions.release_mode), + "remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode), + "collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode), + "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "server_password": options_source.get("server_password", None), } generator_options = { - "spoiler": int(options_source.get("spoiler", 0)), - "race": race + "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), + "race": race, } if race: diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 72e6c19a04..b112c8e361 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -11,6 +11,7 @@ import Options from Utils import local_path from worlds.AutoWorld import AutoWorldRegister from . import app, cache +from .generate import get_meta def create() -> None: @@ -27,26 +28,21 @@ def get_world_theme(game_name: str) -> str: def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]: - visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui world = AutoWorldRegister.world_types[world_name] if world.hidden or world.web.options_page is False: return redirect("games") + visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui - option_groups = {option: option_group.name - for option_group in world.web.option_groups - for option in option_group.options} - ordered_groups = ["Game Options", *[group.name for group in world.web.option_groups]] - grouped_options = {group: {} for group in ordered_groups} - for option_name, option in world.options_dataclass.type_hints.items(): - # Exclude settings from options pages if their visibility is disabled - if visibility_flag in option.visibility: - grouped_options[option_groups.get(option, "Game Options")][option_name] = option + start_collapsed = {"Game Options": False} + for group in world.web.option_groups: + start_collapsed[group.name] = group.start_collapsed return render_template( template, world_name=world_name, world=world, - option_groups=grouped_options, + option_groups=Options.get_option_groups(world, visibility_level=visibility_flag), + start_collapsed=start_collapsed, issubclass=issubclass, Options=Options, theme=get_world_theme(world_name), @@ -55,7 +51,7 @@ def render_options_page(template: str, world_name: str, is_complex: bool = False def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]: from .generate import start_generation - return start_generation(options, {"plando_options": ["items", "connections", "texts", "bosses"]}) + return start_generation(options, get_meta({})) def send_yaml(player_name: str, formatted_options: dict) -> Response: diff --git a/WebHostLib/templates/playerOptions/playerOptions.html b/WebHostLib/templates/playerOptions/playerOptions.html index 5657610914..2506cf9619 100644 --- a/WebHostLib/templates/playerOptions/playerOptions.html +++ b/WebHostLib/templates/playerOptions/playerOptions.html @@ -69,7 +69,7 @@