diff --git a/BaseClasses.py b/BaseClasses.py index 423ffbd5..e818f629 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -27,6 +27,7 @@ class MultiWorld(): plando_connections: List worlds: Dict[int, Any] is_race: bool = False + precollected_items: Dict[int, List[Item]] class AttributeProxy(): def __init__(self, rule): @@ -46,7 +47,7 @@ class MultiWorld(): self.itempool = [] self.seed = None self.seed_name: str = "Unavailable" - self.precollected_items = [] + self.precollected_items = {player: [] for player in self.player_ids} self.state = CollectionState(self) self._cached_entrances = None self._cached_locations = None @@ -266,7 +267,7 @@ class MultiWorld(): def push_precollected(self, item: Item): item.world = self - self.precollected_items.append(item) + self.precollected_items[item.player].append(item) self.state.collect(item, True) def push_item(self, location: Location, item: Item, collect: bool = True): @@ -473,8 +474,9 @@ class CollectionState(object): self.path = {} self.locations_checked = set() self.stale = {player: True for player in range(1, parent.players + 1)} - for item in parent.precollected_items: - self.collect(item, True) + for items in parent.precollected_items.values(): + for item in items: + self.collect(item, True) def update_reachable_regions(self, player: int): from worlds.alttp.EntranceShuffle import indirect_connections diff --git a/Main.py b/Main.py index 8e26a1e5..f8a74e79 100644 --- a/Main.py +++ b/Main.py @@ -1,4 +1,4 @@ -from itertools import zip_longest +from itertools import zip_longest, chain import logging import os import time @@ -7,7 +7,7 @@ import concurrent.futures import pickle import tempfile import zipfile -from typing import Dict, Tuple +from typing import Dict, Tuple, Optional from BaseClasses import MultiWorld, CollectionState, Region, RegionType from worlds.alttp.Items import item_name_groups @@ -19,7 +19,16 @@ from worlds.generic.Rules import locality_rules, exclusion_rules from worlds import AutoWorld -def main(args, seed=None): +ordered_areas = ( + 'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace', + 'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', + 'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total" +) + + +def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): + if not baked_server_options: + baked_server_options = get_options()["server_options"] if args.outputpath: os.makedirs(args.outputpath, exist_ok=True) output_path.cached_path = args.outputpath @@ -30,7 +39,7 @@ def main(args, seed=None): world = MultiWorld(args.multi) logger = logging.getLogger() - world.set_seed(secure=args.race, name=str(args.outputname if args.outputname else world.seed)) + world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed)) world.shuffle = args.shuffle.copy() world.logic = args.logic.copy() @@ -159,16 +168,15 @@ def main(args, seed=None): output = tempfile.TemporaryDirectory() with output as temp_dir: - with concurrent.futures.ThreadPoolExecutor() as pool: + with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool: check_accessibility_task = pool.submit(world.fulfills_accessibility) - output_file_futures = [] - + output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] for player in world.player_ids: # skip starting a thread for methods that say "pass". if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__: - output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) - output_file_futures.append(pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)) + output_file_futures.append( + pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) def get_entrance_to_region(region: Region): for entrance in region.entrances: @@ -189,9 +197,7 @@ def main(args, seed=None): if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name: er_hint_data[region.player][location.address] = main_entrance.name - ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace', - 'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', - 'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total") + checks_in_area = {player: {area: list() for area in ordered_areas} for player in range(1, world.players + 1)} @@ -220,8 +226,9 @@ def main(args, seed=None): for index, take_any in enumerate(takeanyregions): for region in [world.get_region(take_any, player) for player in world.get_game_players("A Link to the Past") if world.retro[player]]: - item = world.create_item(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], - region.player) + item = world.create_item( + region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], + region.player) player = region.player location_id = SHOP_ID_START + total_shop_slots + index @@ -246,18 +253,16 @@ def main(args, seed=None): for slot in world.player_ids: client_versions[slot] = world.worlds[slot].get_required_client_version() games[slot] = world.game[slot] - precollected_items = {player: [] for player in range(1, world.players + 1)} - for item in world.precollected_items: - precollected_items[item.player].append(item.code) + precollected_items = {player: [item.code for item in world_precollected] + for player, world_precollected in world.precollected_items.items()} precollected_hints = {player: set() for player in range(1, world.players + 1)} # for now special case Factorio tech_tree_information sending_visible_players = set() - for player in world.get_game_players("Factorio"): - if world.tech_tree_information[player].value == 2: - sending_visible_players.add(player) for slot in world.player_ids: slot_data[slot] = world.worlds[slot].fill_slot_data() + if world.worlds[slot].sending_visible: + sending_visible_players.add(slot) def precollect_hint(location): hint = NetUtils.Hint(location.item.player, location.player, location.address, @@ -271,7 +276,7 @@ def main(args, seed=None): # item code None should be event, location.address should then also be None assert location.item.code is not None locations_data[location.player][location.address] = location.item.code, location.item.player - if location.player in sending_visible_players and location.item.player != location.player: + if location.player in sending_visible_players: precollect_hint(location) elif location.name in world.start_location_hints[location.player]: precollect_hint(location) @@ -289,7 +294,7 @@ def main(args, seed=None): world.worlds[player].remote_start_inventory}, "locations": locations_data, "checks_in_area": checks_in_area, - "server_options": get_options()["server_options"], + "server_options": baked_server_options, "er_hint_data": er_hint_data, "precollected_items": precollected_items, "precollected_hints": precollected_hints, @@ -398,9 +403,9 @@ def create_playthrough(world): # second phase, sphere 0 removed_precollected = [] - for item in (i for i in world.precollected_items if i.advancement): + for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement): logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) - world.precollected_items.remove(item) + world.precollected_items[item.player].remove(item) world.state.remove(item) if not world.can_beat_game(): world.push_precollected(item) @@ -464,7 +469,9 @@ def create_playthrough(world): get_path(state, world.get_region('Inverted Big Bomb Shop', player)) # we can finally output our playthrough - world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])} + world.spoiler.playthrough = {"0": sorted([str(item) for item in + chain.from_iterable(world.precollected_items.values()) + if item.advancement])} for i, sphere in enumerate(collection_spheres): world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)} diff --git a/MultiServer.py b/MultiServer.py index 28a510b9..1520d528 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -26,6 +26,7 @@ from prompt_toolkit.patch_stdout import patch_stdout from fuzzywuzzy import process as fuzzy_process from worlds.AutoWorld import AutoWorldRegister + proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()} from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name import Utils @@ -293,7 +294,7 @@ class Context: if not self.save_filename: import os name, ext = os.path.splitext(self.data_filename) - self.save_filename = name + '.apsave' if ext.lower() in ('.archipelago','.zip') \ + self.save_filename = name + '.apsave' if ext.lower() in ('.archipelago', '.zip') \ else self.data_filename + '_' + 'apsave' try: with open(self.save_filename, 'rb') as f: @@ -472,10 +473,7 @@ async def on_client_connected(ctx: Context, client: Client): # TODO ~0.2.0 remove forfeit_mode and remaining_mode in favor of permissions 'forfeit_mode': ctx.forfeit_mode, 'remaining_mode': ctx.remaining_mode, - 'permissions': { - "forfeit": Permission.from_text(ctx.forfeit_mode), - "remaining": Permission.from_text(ctx.remaining_mode), - }, + 'permissions': get_permissions(ctx), 'hint_cost': ctx.hint_cost, 'location_check_points': ctx.location_check_points, 'datapackage_version': network_data_package["version"], @@ -485,6 +483,13 @@ async def on_client_connected(ctx: Context, client: Client): }]) +def get_permissions(ctx) -> typing.Dict[str, Permission]: + return { + "forfeit": Permission.from_text(ctx.forfeit_mode), + "remaining": Permission.from_text(ctx.remaining_mode), + } + + async def on_client_disconnected(ctx: Context, client: Client): if client.auth: await on_client_left(ctx, client) @@ -972,14 +977,9 @@ class ClientMessageProcessor(CommonCommandProcessor): self.output("Cheating is disabled.") return False - @mark_raw - def _cmd_hint(self, item_or_location: str = "") -> bool: - """Use !hint {item_name/location_name}, - for example !hint Lamp or !hint Link's House to get a spoiler peek for that location or item. - If hint costs are on, this will only give you one new result, - you can rerun the command to get more in that case.""" + def get_hints(self, input_text: str, explicit_location: bool = False) -> bool: points_available = get_client_points(self.ctx, self.client) - if not item_or_location: + if not input_text: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot] = hints @@ -989,16 +989,16 @@ class ClientMessageProcessor(CommonCommandProcessor): return True else: world = proxy_worlds[self.ctx.games[self.client.slot]] - item_name, usable, response = get_intended_text(item_or_location, world.all_names) + item_name, usable, response = get_intended_text(input_text, world.all_names if not explicit_location else world.location_names) if usable: if item_name in world.hint_blacklist: self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.") hints = [] - elif item_name in world.item_name_groups: + elif item_name in world.item_name_groups and not explicit_location: hints = [] for item in world.item_name_groups[item_name]: hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item)) - elif item_name in world.item_names: # item name + elif item_name in world.item_names and not explicit_location: # item name hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name) else: # location name hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name) @@ -1031,19 +1031,25 @@ 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) if not hint.found: self.ctx.hints[self.client.team, hint.finding_player].add(hint) self.ctx.hints[self.client.team, hint.receiving_player].add(hint) if not_found_hints: - if hints: + 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. " + f" You have {points_available} and need at least " + f"{self.ctx.get_hint_cost(self.client.slot)}.") + elif hints: self.output( "There may be more hintables, you can rerun the command to find more.") else: self.output(f"You can't afford the hint. " f"You have {points_available} points and need at least " - f"{self.ctx.get_hint_cost(self.client.slot)}") + f"{self.ctx.get_hint_cost(self.client.slot)}.") notify_hints(self.ctx, self.client.team, hints) self.ctx.save() return True @@ -1055,6 +1061,22 @@ class ClientMessageProcessor(CommonCommandProcessor): self.output(response) return False + @mark_raw + def _cmd_hint(self, item_or_location: str = "") -> bool: + """Use !hint {item_name/location_name}, + for example !hint Lamp or !hint Link's House to get a spoiler peek for that location or item. + If hint costs are on, this will only give you one new result, + you can rerun the command to get more in that case.""" + return self.get_hints(item_or_location) + + @mark_raw + def _cmd_hint_location(self, location: str = "") -> bool: + """Use !hint_location {location_name}, + for example !hint atomic-bomb to get a spoiler peek for that location. + (In the case of factorio, or any other game where item names and location names are identical, + this command must be used explicitly.)""" + return self.get_hints(location, True) + def get_checked_checks(ctx: Context, client: Client) -> typing.List[int]: return [location_id for @@ -1181,7 +1203,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): locs = [] for location in args["locations"]: if type(location) is not int or location not in lookup_any_location_id_to_name: - await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}]) + await ctx.send_msgs(client, + [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}]) return target_item, target_player = ctx.locations[client.slot][location] locs.append(NetworkItem(target_item, location, target_player)) @@ -1407,6 +1430,8 @@ class ServerCommandProcessor(CommonCommandProcessor): return input_text setattr(self.ctx, option_name, attrtype(option)) self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}") + if option_name in {"forfeit_mode", "remaining_mode"}: + self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}]) return True else: known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items()) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 2c749594..6463540e 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -82,6 +82,12 @@ def page_not_found(err): return render_template('404.html'), 404 +# Start Playing Page +@app.route('/start-playing') +def start_playing(): + return render_template(f"startPlaying.html") + + # Player settings pages @app.route('/games//player-settings') def player_settings(game): @@ -180,6 +186,9 @@ def favicon(): return send_from_directory(os.path.join(app.root_path, 'static/static'), 'favicon.ico', mimetype='image/vnd.microsoft.icon') +@app.route('/discord') +def discord(): + return redirect("https://discord.gg/archipelago") from WebHostLib.customserver import run_server_process from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index b2f60812..0c1c2b6d 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -89,7 +89,7 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") pool.apply_async(gen_game, (options,), - {"race": meta["race"], + {"meta": meta, "sid": generation.id, "owner": generation.owner}, handle_generation_success, handle_generation_failure) diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 60251ca3..ac16d7c9 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -4,6 +4,7 @@ import random import json import zipfile from collections import Counter +from typing import Dict, Optional as TypeOptional from flask import request, flash, redirect, url_for, session, render_template @@ -33,6 +34,14 @@ def generate(race=False): flash(options) else: results, gen_options = roll_options(options) + # get form data -> server settings + hint_cost = int(request.form.get("hint_cost", 10)) + forfeit_mode = request.form.get("forfeit_mode", "goal") + meta = {"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode} + if race: + meta["item_cheat"] = False + meta["remaining"] = False + if any(type(result) == str for result in results.values()): return render_template("checkResult.html", results=results) elif len(gen_options) > app.config["MAX_ROLL"]: @@ -42,7 +51,8 @@ def generate(race=False): gen = Generation( options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), # convert to json compatible - meta=json.dumps({"race": race}), state=STATE_QUEUED, + meta=json.dumps(meta), + state=STATE_QUEUED, owner=session["_id"]) commit() @@ -50,18 +60,24 @@ def generate(race=False): else: try: seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, - race=race, owner=session["_id"].int) + meta=meta, owner=session["_id"].int) except BaseException as e: from .autolauncher import handle_generation_failure handle_generation_failure(e) - return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": "+ str(e))) + return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e))) return redirect(url_for("viewSeed", seed=seed_id)) return render_template("generate.html", race=race) -def gen_game(gen_options, race=False, owner=None, sid=None): +def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None): + if not meta: + meta: Dict[str, object] = {} + + meta.setdefault("hint_cost", 10) + race = meta.get("race", False) + del (meta["race"]) try: target = tempfile.TemporaryDirectory() playercount = len(gen_options) @@ -95,7 +111,7 @@ def gen_game(gen_options, race=False, owner=None, sid=None): erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0] erargs.name[player] = handle_name(erargs.name[player], player, name_counter) - ERmain(erargs, seed) + ERmain(erargs, seed, baked_server_options=meta) return upload_to_db(target.name, sid, owner, race) except BaseException as e: @@ -105,7 +121,7 @@ def gen_game(gen_options, race=False, owner=None, sid=None): if gen is not None: gen.state = STATE_ERROR meta = json.loads(gen.meta) - meta["error"] = (e.__class__.__name__ + ": "+ str(e)) + meta["error"] = (e.__class__.__name__ + ": " + str(e)) gen.meta = json.dumps(meta) commit() diff --git a/WebHostLib/options.py b/WebHostLib/options.py index f3c50ae3..9c23242f 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -32,7 +32,10 @@ def create(): dictify_range=dictify_range, default_converter=default_converter, ) - with open(os.path.join(target_folder, game_name + ".yaml"), "w") as f: + if not os.path.isdir(os.path.join(target_folder, 'configs')): + os.mkdir(os.path.join(target_folder, 'configs')) + + with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f: f.write(res) # Generate JSON files for player-settings pages @@ -78,5 +81,8 @@ def create(): player_settings["gameOptions"] = game_options - with open(os.path.join(target_folder, game_name + ".json"), "w") as f: + if not os.path.isdir(os.path.join(target_folder, 'player-settings')): + os.mkdir(os.path.join(target_folder, 'player-settings')) + + with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f: f.write(json.dumps(player_settings, indent=2, separators=(',', ': '))) diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js index bd1f2a27..e039e8c0 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-settings.js @@ -61,7 +61,7 @@ const fetchSettingData = () => new Promise((resolve, reject) => { try{ resolve(JSON.parse(ajax.responseText)); } catch(error){ reject(error); } }; - ajax.open('GET', `${window.location.origin}/static/generated/${gameName}.json`, true); + ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true); ajax.send(); }); diff --git a/WebHostLib/static/styles/generate.css b/WebHostLib/static/styles/generate.css index d7406682..e0b3f5ec 100644 --- a/WebHostLib/static/styles/generate.css +++ b/WebHostLib/static/styles/generate.css @@ -25,6 +25,21 @@ margin-bottom: 1rem; } -#generate-game-form{ +#generate-game-form-wrapper table td{ + text-align: left; + padding-right: 0.5rem; +} + +#generate-form-button-row{ + display: flex; + flex-direction: row; + justify-content: center; +} + +#file-input{ display: none; } + +.interactive{ + color: #ffef00; +} diff --git a/WebHostLib/static/styles/startPlaying.css b/WebHostLib/static/styles/startPlaying.css new file mode 100644 index 00000000..f04c8af8 --- /dev/null +++ b/WebHostLib/static/styles/startPlaying.css @@ -0,0 +1,12 @@ +#start-playing-wrapper{ + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; +} + +#start-playing{ + width: 700px; + min-height: 240px; + text-align: center; +} diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index 13720313..a1a18115 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -11,11 +11,11 @@ {% include 'header/oceanHeader.html' %}
-

Upload Config{% if race %} (Race Mode){% endif %}

+

Generate Game{% if race %} (Race Mode){% endif %}

- This page allows you to generate a game by uploading a yaml file or a zip file containing yaml files. - If you do not have a config (yaml) file yet, you may create one on the - Player Settings page. + This page allows you to generate a game by uploading a config file or a zip file containing config + files. If you do not have a config (.yaml) file yet, you may create one on the game's settings page, + which you can find via the supported games list.

{% if race -%} @@ -23,21 +23,54 @@ roms will be encrypted, and single-player games will have no multidata files. {%- else -%} If you would like to generate a race game, - click here. Race games are generated without - a spoiler log, the ROMs are encrypted, and single-player games will not include a multidata file. + click here.
+ Race games are generated without a spoiler log, the ROMs are encrypted, and single-player games + will not include a multidata file. {%- endif -%}

-

- After generation is complete, you will have the option to download a patch file. - This patch file can be opened with the - client, which can be - used to to create a rom file. In-browser patching is planned for the future. -

- + + + + + + + + + + + + +
+ +
+ + (?) + + + +
+
+ +
- +
diff --git a/WebHostLib/templates/header/baseHeader.html b/WebHostLib/templates/header/baseHeader.html index dfe42e51..eb8a86a0 100644 --- a/WebHostLib/templates/header/baseHeader.html +++ b/WebHostLib/templates/header/baseHeader.html @@ -13,7 +13,7 @@
supported games setup guides - start game + start playing f.a.q. discord
diff --git a/WebHostLib/templates/hostGame.html b/WebHostLib/templates/hostGame.html index 5ae0323b..0b4c9484 100644 --- a/WebHostLib/templates/hostGame.html +++ b/WebHostLib/templates/hostGame.html @@ -15,17 +15,17 @@

Host Game

This page allows you to host a game which was not generated by the website. For example, if you have - generated a doors game on your own computer, you may upload the zip file created by the generator to - host the game here. This will also provide the tracker, and the ability for your players to download + generated a game on your own computer, you may upload the zip file created by the generator to + host the game here. This will also provide a tracker, and the ability for your players to download their patch files.

- In addition to a zip file created by the generator, you may upload a multidata file here as well. + In addition to the zip file created by the generator, you may upload a multidata file here as well.

- +
diff --git a/WebHostLib/templates/landing.html b/WebHostLib/templates/landing.html index ec187dad..56d7a1c0 100644 --- a/WebHostLib/templates/landing.html +++ b/WebHostLib/templates/landing.html @@ -13,7 +13,7 @@

multiworld multi-game randomizer