diff --git a/BaseClasses.py b/BaseClasses.py index 683cc11c2c..6464aa2b9c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -328,11 +328,6 @@ class MultiWorld(): """ the base name (without file extension) for each player's output file for a seed """ return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}" - def initialize_regions(self, regions=None): - for region in regions if regions else self.regions: - region.multiworld = self - self._region_cache[region.player][region.name] = region - @functools.cached_property def world_name_lookup(self): return {self.player_name[player_id]: player_id for player_id in self.player_ids} diff --git a/CommonClient.py b/CommonClient.py index 154b61b1d5..a5e9b4553a 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -882,7 +882,7 @@ def get_base_parser(description: typing.Optional[str] = None): def run_as_textclient(): class TextContext(CommonContext): # Text Mode to use !hint and such with games that have no text entry - tags = {"AP", "TextOnly"} + tags = CommonContext.tags | {"TextOnly"} game = "" # empty matches any game since 0.3.2 items_handling = 0b111 # receive all items for /received want_slot_data = False # Can't use game specific slot_data diff --git a/Fill.py b/Fill.py index 600d18ef2a..9d5dc0b457 100644 --- a/Fill.py +++ b/Fill.py @@ -847,7 +847,7 @@ def distribute_planned(world: MultiWorld) -> None: for target_player in worlds: locations += non_early_locations[target_player] - block['locations'] = locations + block['locations'] = list(dict.fromkeys(locations)) if not block['count']: block['count'] = (min(len(block['items']), len(block['locations'])) if @@ -897,19 +897,22 @@ def distribute_planned(world: MultiWorld) -> None: for item_name in items: item = world.worlds[player].create_item(item_name) for location in reversed(candidates): - if not location.item: - if location.item_rule(item): - if location.can_fill(world.state, item, False): - successful_pairs.append((item, location)) - candidates.remove(location) - count = count + 1 - break + if (location.address is None) == (item.code is None): # either both None or both not None + if not location.item: + if location.item_rule(item): + if location.can_fill(world.state, item, False): + successful_pairs.append((item, location)) + candidates.remove(location) + count = count + 1 + break + else: + err.append(f"Can't place item at {location} due to fill condition not met.") else: - err.append(f"Can't place item at {location} due to fill condition not met.") + err.append(f"{item_name} not allowed at {location}.") else: - err.append(f"{item_name} not allowed at {location}.") + err.append(f"Cannot place {item_name} into already filled location {location}.") else: - err.append(f"Cannot place {item_name} into already filled location {location}.") + err.append(f"Mismatch between {item_name} and {location}, only one is an event.") if count == maxcount: break if count < placement['count']['min']: diff --git a/Generate.py b/Generate.py index 08fe2b9083..34a0084e8d 100644 --- a/Generate.py +++ b/Generate.py @@ -169,7 +169,7 @@ def main(args=None, callback=ERmain): for player in range(1, args.multi + 1): player_path_cache[player] = player_files.get(player, args.weights_file_path) name_counter = Counter() - erargs.player_settings = {} + erargs.player_options = {} player = 1 while player <= args.multi: diff --git a/Main.py b/Main.py index dfc4ed1793..0995d2091f 100644 --- a/Main.py +++ b/Main.py @@ -301,15 +301,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No output = tempfile.TemporaryDirectory() with output as temp_dir: - with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool: + output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__ + is not world.worlds[player].generate_output.__code__] + with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool: check_accessibility_task = pool.submit(world.fulfills_accessibility) output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] - for player in world.player_ids: + for player in output_players: # 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_single, world, "generate_output", player, temp_dir)) # collect ER hint info er_hint_data: Dict[int, Dict[int, str]] = {} diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 209f2da672..c33e894e8b 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -67,14 +67,23 @@ def update(yes=False, force=False): install_pkg_resources(yes=yes) import pkg_resources + prev = "" # if a line ends in \ we store here and merge later for req_file in requirements_files: path = os.path.join(os.path.dirname(sys.argv[0]), req_file) if not os.path.exists(path): path = os.path.join(os.path.dirname(__file__), req_file) with open(path) as requirementsfile: for line in requirementsfile: - if not line or line[0] == "#": - continue # ignore comments + if not line or line.lstrip(" \t")[0] == "#": + if not prev: + continue # ignore comments + line = "" + elif line.rstrip("\r\n").endswith("\\"): + prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line + continue + line = prev + line + line = line.split("--hash=")[0] # remove hashes from requirement for version checking + prev = "" if line.startswith(("https://", "git+https://")): # extract name and version for url rest = line.split('/')[-1] diff --git a/Options.py b/Options.py index 59e7b30aca..d8024444ee 100644 --- a/Options.py +++ b/Options.py @@ -959,7 +959,10 @@ class CommonOptions(metaclass=OptionsMetaProperty): else: raise ValueError(f"{casing} is invalid casing for as_dict. " "Valid names are 'snake', 'camel', 'pascal', 'kebab'.") - option_results[display_name] = getattr(self, option_name).value + value = getattr(self, option_name).value + if isinstance(value, set): + value = sorted(value) + option_results[display_name] = value else: raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") return option_results diff --git a/WebHostLib/check.py b/WebHostLib/check.py index c5dfd9f556..4db2ec2ce3 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,5 +1,6 @@ import zipfile -from typing import * +import base64 +from typing import Union, Dict, Set, Tuple from flask import request, flash, redirect, url_for, render_template from markupsafe import Markup @@ -30,7 +31,15 @@ def check(): flash(options) else: results, _ = roll_options(options) - return render_template("checkResult.html", results=results) + if len(options) > 1: + # offer combined file back + combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}" + for file_name, file_content in options.items()) + combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode() + else: + combined_yaml = "" + return render_template("checkResult.html", + results=results, combined_yaml=combined_yaml) return render_template("check.html") @@ -41,31 +50,32 @@ def mysterycheck(): def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} - for file in files: + for uploaded_file in files: # if user does not select file, browser also # submit an empty part without filename - if file.filename == '': + if uploaded_file.filename == '': return 'No selected file' - elif file.filename in options: - return f'Conflicting files named {file.filename} submitted' - elif file and allowed_file(file.filename): - if file.filename.endswith(".zip"): + elif uploaded_file.filename in options: + return f'Conflicting files named {uploaded_file.filename} submitted' + elif uploaded_file and allowed_file(uploaded_file.filename): + if uploaded_file.filename.endswith(".zip"): - with zipfile.ZipFile(file, 'r') as zfile: + with zipfile.ZipFile(uploaded_file, 'r') as zfile: infolist = zfile.infolist() if any(file.filename.endswith(".archipelago") for file in infolist): return Markup("Error: Your .zip file contains an .archipelago file. " - 'Did you mean to host a game?') + 'Did you mean to host a game?') for file in infolist: if file.filename.endswith(banned_zip_contents): - return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ - "Your file was deleted." + return ("Uploaded data contained a rom file, " + "which is likely to contain copyrighted material. " + "Your file was deleted.") elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): options[file.filename] = zfile.open(file, "r").read() else: - options[file.filename] = file.read() + options[uploaded_file.filename] = uploaded_file.read() if not options: return "Did not find a .yaml file to process." return options diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index e3111ed5b5..ee04e56fd7 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -37,17 +37,29 @@ def start_playing(): return render_template(f"startPlaying.html") -@app.route('/weighted-settings') -@cache.cached() +# TODO for back compat. remove around 0.4.5 +@app.route("/weighted-settings") def weighted_settings(): - return render_template(f"weighted-settings.html") + return redirect("weighted-options", 301) -# Player settings pages -@app.route('/games//player-settings') +@app.route("/weighted-options") @cache.cached() -def player_settings(game): - return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) +def weighted_options(): + return render_template("weighted-options.html") + + +# TODO for back compat. remove around 0.4.5 +@app.route("/games//player-settings") +def player_settings(game: str): + return redirect(url_for("player_options", game=game), 301) + + +# Player options pages +@app.route("/games//player-options") +@cache.cached() +def player_options(game: str): + return render_template("player-options.html", game=game, theme=get_world_theme(game)) # Game Info Pages @@ -181,6 +193,6 @@ def get_sitemap(): available_games: List[Dict[str, Union[str, bool]]] = [] for game, world in AutoWorldRegister.world_types.items(): if not world.hidden: - has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page + has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page available_games.append({ 'title': game, 'has_settings': has_settings }) return render_template("siteMap.html", games=available_games) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 18a28045ee..785785cde0 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -25,7 +25,7 @@ def create(): return "Please document me!" return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip() - weighted_settings = { + weighted_options = { "baseOptions": { "description": "Generated by https://archipelago.gg/", "name": "Player", @@ -38,8 +38,8 @@ def create(): all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints - # Generate JSON files for player-settings pages - player_settings = { + # Generate JSON files for player-options pages + player_options = { "baseOptions": { "description": f"Generated by https://archipelago.gg/ for {game_name}", "game": game_name, @@ -117,17 +117,17 @@ def create(): } else: - logging.debug(f"{option} not exported to Web Settings.") + logging.debug(f"{option} not exported to Web options.") - player_settings["gameOptions"] = game_options + player_options["gameOptions"] = game_options - os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True) + os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True) - with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f: - json.dump(player_settings, f, indent=2, separators=(',', ': ')) + with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f: + json.dump(player_options, f, indent=2, separators=(',', ': ')) - if not world.hidden and world.web.settings_page is True: - # Add the random option to Choice, TextChoice, and Toggle settings + if not world.hidden and world.web.options_page is True: + # Add the random option to Choice, TextChoice, and Toggle options for option in game_options.values(): if option["type"] == "select": option["options"].append({"name": "Random", "value": "random"}) @@ -135,11 +135,11 @@ def create(): if not option["defaultValue"]: option["defaultValue"] = "random" - weighted_settings["baseOptions"]["game"][game_name] = 0 - weighted_settings["games"][game_name] = {} - weighted_settings["games"][game_name]["gameSettings"] = game_options - weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names) - weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names) + weighted_options["baseOptions"]["game"][game_name] = 0 + weighted_options["games"][game_name] = {} + weighted_options["games"][game_name]["gameSettings"] = game_options + weighted_options["games"][game_name]["gameItems"] = tuple(world.item_names) + weighted_options["games"][game_name]["gameLocations"] = tuple(world.location_names) - with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f: - json.dump(weighted_settings, f, indent=2, separators=(',', ': ')) + with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f: + json.dump(weighted_options, f, indent=2, separators=(',', ': ')) diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-options.js similarity index 63% rename from WebHostLib/static/assets/player-settings.js rename to WebHostLib/static/assets/player-options.js index 4ebec1adbf..727e0f63b9 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-options.js @@ -1,41 +1,41 @@ let gameName = null; window.addEventListener('load', () => { - gameName = document.getElementById('player-settings').getAttribute('data-game'); + gameName = document.getElementById('player-options').getAttribute('data-game'); // Update game name on page document.getElementById('game-name').innerText = gameName; - fetchSettingData().then((results) => { - let settingHash = localStorage.getItem(`${gameName}-hash`); - if (!settingHash) { + fetchOptionData().then((results) => { + let optionHash = localStorage.getItem(`${gameName}-hash`); + if (!optionHash) { // If no hash data has been set before, set it now - settingHash = md5(JSON.stringify(results)); - localStorage.setItem(`${gameName}-hash`, settingHash); + optionHash = md5(JSON.stringify(results)); + localStorage.setItem(`${gameName}-hash`, optionHash); localStorage.removeItem(gameName); } - if (settingHash !== md5(JSON.stringify(results))) { - showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " + + if (optionHash !== md5(JSON.stringify(results))) { + showUserMessage("Your options are out of date! Click here to update them! Be aware this will reset " + "them all to default."); - document.getElementById('user-message').addEventListener('click', resetSettings); + document.getElementById('user-message').addEventListener('click', resetOptions); } // Page setup - createDefaultSettings(results); + createDefaultOptions(results); buildUI(results); adjustHeaderWidth(); // Event listeners - document.getElementById('export-settings').addEventListener('click', () => exportSettings()); + document.getElementById('export-options').addEventListener('click', () => exportOptions()); document.getElementById('generate-race').addEventListener('click', () => generateGame(true)); document.getElementById('generate-game').addEventListener('click', () => generateGame()); // Name input field - const playerSettings = JSON.parse(localStorage.getItem(gameName)); + const playerOptions = JSON.parse(localStorage.getItem(gameName)); const nameInput = document.getElementById('player-name'); - nameInput.addEventListener('keyup', (event) => updateBaseSetting(event)); - nameInput.value = playerSettings.name; + nameInput.addEventListener('keyup', (event) => updateBaseOption(event)); + nameInput.value = playerOptions.name; }).catch((e) => { console.error(e); const url = new URL(window.location.href); @@ -43,13 +43,13 @@ window.addEventListener('load', () => { }) }); -const resetSettings = () => { +const resetOptions = () => { localStorage.removeItem(gameName); localStorage.removeItem(`${gameName}-hash`) window.location.reload(); }; -const fetchSettingData = () => new Promise((resolve, reject) => { +const fetchOptionData = () => new Promise((resolve, reject) => { const ajax = new XMLHttpRequest(); ajax.onreadystatechange = () => { if (ajax.readyState !== 4) { return; } @@ -60,54 +60,54 @@ const fetchSettingData = () => new Promise((resolve, reject) => { try{ resolve(JSON.parse(ajax.responseText)); } catch(error){ reject(error); } }; - ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true); + ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true); ajax.send(); }); -const createDefaultSettings = (settingData) => { +const createDefaultOptions = (optionData) => { if (!localStorage.getItem(gameName)) { - const newSettings = { + const newOptions = { [gameName]: {}, }; - for (let baseOption of Object.keys(settingData.baseOptions)){ - newSettings[baseOption] = settingData.baseOptions[baseOption]; + for (let baseOption of Object.keys(optionData.baseOptions)){ + newOptions[baseOption] = optionData.baseOptions[baseOption]; } - for (let gameOption of Object.keys(settingData.gameOptions)){ - newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue; + for (let gameOption of Object.keys(optionData.gameOptions)){ + newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue; } - localStorage.setItem(gameName, JSON.stringify(newSettings)); + localStorage.setItem(gameName, JSON.stringify(newOptions)); } }; -const buildUI = (settingData) => { +const buildUI = (optionData) => { // Game Options const leftGameOpts = {}; const rightGameOpts = {}; - Object.keys(settingData.gameOptions).forEach((key, index) => { - if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; } - else { rightGameOpts[key] = settingData.gameOptions[key]; } + Object.keys(optionData.gameOptions).forEach((key, index) => { + if (index < Object.keys(optionData.gameOptions).length / 2) { leftGameOpts[key] = optionData.gameOptions[key]; } + else { rightGameOpts[key] = optionData.gameOptions[key]; } }); document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts)); document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts)); }; -const buildOptionsTable = (settings, romOpts = false) => { - const currentSettings = JSON.parse(localStorage.getItem(gameName)); +const buildOptionsTable = (options, romOpts = false) => { + const currentOptions = JSON.parse(localStorage.getItem(gameName)); const table = document.createElement('table'); const tbody = document.createElement('tbody'); - Object.keys(settings).forEach((setting) => { + Object.keys(options).forEach((option) => { const tr = document.createElement('tr'); // td Left const tdl = document.createElement('td'); const label = document.createElement('label'); - label.textContent = `${settings[setting].displayName}: `; - label.setAttribute('for', setting); + label.textContent = `${options[option].displayName}: `; + label.setAttribute('for', option); const questionSpan = document.createElement('span'); questionSpan.classList.add('interactive'); - questionSpan.setAttribute('data-tooltip', settings[setting].description); + questionSpan.setAttribute('data-tooltip', options[option].description); questionSpan.innerText = '(?)'; label.appendChild(questionSpan); @@ -120,36 +120,36 @@ const buildOptionsTable = (settings, romOpts = false) => { const randomButton = document.createElement('button'); - switch(settings[setting].type){ + switch(options[option].type){ case 'select': element = document.createElement('div'); element.classList.add('select-container'); let select = document.createElement('select'); - select.setAttribute('id', setting); - select.setAttribute('data-key', setting); + select.setAttribute('id', option); + select.setAttribute('data-key', option); if (romOpts) { select.setAttribute('data-romOpt', '1'); } - settings[setting].options.forEach((opt) => { + options[option].options.forEach((opt) => { const option = document.createElement('option'); option.setAttribute('value', opt.value); option.innerText = opt.name; - if ((isNaN(currentSettings[gameName][setting]) && - (parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) || - (opt.value === currentSettings[gameName][setting])) + if ((isNaN(currentOptions[gameName][option]) && + (parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) || + (opt.value === currentOptions[gameName][option])) { option.selected = true; } select.appendChild(option); }); - select.addEventListener('change', (event) => updateGameSetting(event.target)); + select.addEventListener('change', (event) => updateGameOption(event.target)); element.appendChild(select); // Randomize button randomButton.innerText = '🎲'; randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', setting); + randomButton.setAttribute('data-key', option); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); randomButton.addEventListener('click', (event) => toggleRandomize(event, select)); - if (currentSettings[gameName][setting] === 'random') { + if (currentOptions[gameName][option] === 'random') { randomButton.classList.add('active'); select.disabled = true; } @@ -163,30 +163,30 @@ const buildOptionsTable = (settings, romOpts = false) => { let range = document.createElement('input'); range.setAttribute('type', 'range'); - range.setAttribute('data-key', setting); - range.setAttribute('min', settings[setting].min); - range.setAttribute('max', settings[setting].max); - range.value = currentSettings[gameName][setting]; + range.setAttribute('data-key', option); + range.setAttribute('min', options[option].min); + range.setAttribute('max', options[option].max); + range.value = currentOptions[gameName][option]; range.addEventListener('change', (event) => { - document.getElementById(`${setting}-value`).innerText = event.target.value; - updateGameSetting(event.target); + document.getElementById(`${option}-value`).innerText = event.target.value; + updateGameOption(event.target); }); element.appendChild(range); let rangeVal = document.createElement('span'); rangeVal.classList.add('range-value'); - rangeVal.setAttribute('id', `${setting}-value`); - rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? - currentSettings[gameName][setting] : settings[setting].defaultValue; + rangeVal.setAttribute('id', `${option}-value`); + rangeVal.innerText = currentOptions[gameName][option] !== 'random' ? + currentOptions[gameName][option] : options[option].defaultValue; element.appendChild(rangeVal); // Randomize button randomButton.innerText = '🎲'; randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', setting); + randomButton.setAttribute('data-key', option); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); randomButton.addEventListener('click', (event) => toggleRandomize(event, range)); - if (currentSettings[gameName][setting] === 'random') { + if (currentOptions[gameName][option] === 'random') { randomButton.classList.add('active'); range.disabled = true; } @@ -200,11 +200,11 @@ const buildOptionsTable = (settings, romOpts = false) => { // Build the select element let specialRangeSelect = document.createElement('select'); - specialRangeSelect.setAttribute('data-key', setting); - Object.keys(settings[setting].value_names).forEach((presetName) => { + specialRangeSelect.setAttribute('data-key', option); + Object.keys(options[option].value_names).forEach((presetName) => { let presetOption = document.createElement('option'); presetOption.innerText = presetName; - presetOption.value = settings[setting].value_names[presetName]; + presetOption.value = options[option].value_names[presetName]; const words = presetOption.innerText.split("_"); for (let i = 0; i < words.length; i++) { words[i] = words[i][0].toUpperCase() + words[i].substring(1); @@ -217,8 +217,8 @@ const buildOptionsTable = (settings, romOpts = false) => { customOption.value = 'custom'; customOption.selected = true; specialRangeSelect.appendChild(customOption); - if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) { - specialRangeSelect.value = Number(currentSettings[gameName][setting]); + if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) { + specialRangeSelect.value = Number(currentOptions[gameName][option]); } // Build range element @@ -226,17 +226,17 @@ const buildOptionsTable = (settings, romOpts = false) => { specialRangeWrapper.classList.add('special-range-wrapper'); let specialRange = document.createElement('input'); specialRange.setAttribute('type', 'range'); - specialRange.setAttribute('data-key', setting); - specialRange.setAttribute('min', settings[setting].min); - specialRange.setAttribute('max', settings[setting].max); - specialRange.value = currentSettings[gameName][setting]; + specialRange.setAttribute('data-key', option); + specialRange.setAttribute('min', options[option].min); + specialRange.setAttribute('max', options[option].max); + specialRange.value = currentOptions[gameName][option]; // Build rage value element let specialRangeVal = document.createElement('span'); specialRangeVal.classList.add('range-value'); - specialRangeVal.setAttribute('id', `${setting}-value`); - specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? - currentSettings[gameName][setting] : settings[setting].defaultValue; + specialRangeVal.setAttribute('id', `${option}-value`); + specialRangeVal.innerText = currentOptions[gameName][option] !== 'random' ? + currentOptions[gameName][option] : options[option].defaultValue; // Configure select event listener specialRangeSelect.addEventListener('change', (event) => { @@ -244,18 +244,18 @@ const buildOptionsTable = (settings, romOpts = false) => { // Update range slider specialRange.value = event.target.value; - document.getElementById(`${setting}-value`).innerText = event.target.value; - updateGameSetting(event.target); + document.getElementById(`${option}-value`).innerText = event.target.value; + updateGameOption(event.target); }); // Configure range event handler specialRange.addEventListener('change', (event) => { // Update select element specialRangeSelect.value = - (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ? + (Object.values(options[option].value_names).includes(parseInt(event.target.value))) ? parseInt(event.target.value) : 'custom'; - document.getElementById(`${setting}-value`).innerText = event.target.value; - updateGameSetting(event.target); + document.getElementById(`${option}-value`).innerText = event.target.value; + updateGameOption(event.target); }); element.appendChild(specialRangeSelect); @@ -266,12 +266,12 @@ const buildOptionsTable = (settings, romOpts = false) => { // Randomize button randomButton.innerText = '🎲'; randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', setting); + randomButton.setAttribute('data-key', option); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); randomButton.addEventListener('click', (event) => toggleRandomize( event, specialRange, specialRangeSelect) ); - if (currentSettings[gameName][setting] === 'random') { + if (currentOptions[gameName][option] === 'random') { randomButton.classList.add('active'); specialRange.disabled = true; specialRangeSelect.disabled = true; @@ -281,7 +281,7 @@ const buildOptionsTable = (settings, romOpts = false) => { break; default: - console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`); + console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`); return; } @@ -311,35 +311,35 @@ const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { optionalSelectElement.disabled = true; } } - updateGameSetting(active ? inputElement : randomButton); + updateGameOption(active ? inputElement : randomButton); }; -const updateBaseSetting = (event) => { +const updateBaseOption = (event) => { const options = JSON.parse(localStorage.getItem(gameName)); options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value); localStorage.setItem(gameName, JSON.stringify(options)); }; -const updateGameSetting = (settingElement) => { +const updateGameOption = (optionElement) => { const options = JSON.parse(localStorage.getItem(gameName)); - if (settingElement.classList.contains('randomize-button')) { + if (optionElement.classList.contains('randomize-button')) { // If the event passed in is the randomize button, then we know what we must do. - options[gameName][settingElement.getAttribute('data-key')] = 'random'; + options[gameName][optionElement.getAttribute('data-key')] = 'random'; } else { - options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ? - settingElement.value : parseInt(settingElement.value, 10); + options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ? + optionElement.value : parseInt(optionElement.value, 10); } localStorage.setItem(gameName, JSON.stringify(options)); }; -const exportSettings = () => { - const settings = JSON.parse(localStorage.getItem(gameName)); - if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) { +const exportOptions = () => { + const options = JSON.parse(localStorage.getItem(gameName)); + if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) { return showUserMessage('You must enter a player name!'); } - const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); + const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); download(`${document.getElementById('player-name').value}.yaml`, yamlText); }; @@ -355,14 +355,14 @@ const download = (filename, text) => { }; const generateGame = (raceMode = false) => { - const settings = JSON.parse(localStorage.getItem(gameName)); - if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) { + const options = JSON.parse(localStorage.getItem(gameName)); + if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) { return showUserMessage('You must enter a player name!'); } axios.post('/api/generate', { - weights: { player: settings }, - presetData: { player: settings }, + weights: { player: options }, + presetData: { player: options }, playerCount: 1, spoiler: 3, race: raceMode ? '1' : '0', diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js index 1acf0e0cc5..56eb15b5e5 100644 --- a/WebHostLib/static/assets/supportedGames.js +++ b/WebHostLib/static/assets/supportedGames.js @@ -1,51 +1,32 @@ window.addEventListener('load', () => { - const gameHeaders = document.getElementsByClassName('collapse-toggle'); - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - header.addEventListener('click', () => { - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); - if (gameInfo.classList.contains('collapsed')) { - gameArrow.innerText = '▼'; - gameInfo.classList.remove('collapsed'); - } else { - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); - } - }); - }); + // Add toggle listener to all elements with .collapse-toggle + const toggleButtons = document.querySelectorAll('.collapse-toggle'); + toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse)); // Handle game filter input const gameSearch = document.getElementById('game-search'); gameSearch.value = ''; - gameSearch.addEventListener('input', (evt) => { if (!evt.target.value.trim()) { // If input is empty, display all collapsed games - return Array.from(gameHeaders).forEach((header) => { + return toggleButtons.forEach((header) => { header.style.display = null; - const gameName = header.getAttribute('data-game'); - document.getElementById(`${gameName}-arrow`).innerText = '▶'; - document.getElementById(gameName).classList.add('collapsed'); + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); }); } // Loop over all the games - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); - + toggleButtons.forEach((header) => { // If the game name includes the search string, display the game. If not, hide it - if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) { + if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) { header.style.display = null; - gameArrow.innerText = '▼'; - gameInfo.classList.remove('collapsed'); + header.firstElementChild.innerText = '▼'; + header.nextElementSibling.classList.remove('collapsed'); } else { - console.log(header); header.style.display = 'none'; - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); } }); }); @@ -54,30 +35,30 @@ window.addEventListener('load', () => { document.getElementById('collapse-all').addEventListener('click', collapseAll); }); -const expandAll = () => { - const gameHeaders = document.getElementsByClassName('collapse-toggle'); - // Loop over all the games - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); +const toggleCollapse = (evt) => { + const gameArrow = evt.target.firstElementChild; + const gameInfo = evt.target.nextElementSibling; + if (gameInfo.classList.contains('collapsed')) { + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + } else { + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + } +}; - if (header.style.display === 'none') { return; } - gameArrow.innerText = '▼'; - gameInfo.classList.remove('collapsed'); - }); +const expandAll = () => { + document.querySelectorAll('.collapse-toggle').forEach((header) => { + if (header.style.display === 'none') { return; } + header.firstElementChild.innerText = '▼'; + header.nextElementSibling.classList.remove('collapsed'); + }); }; const collapseAll = () => { - const gameHeaders = document.getElementsByClassName('collapse-toggle'); - // Loop over all the games - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); - - if (header.style.display === 'none') { return; } - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); - }); + document.querySelectorAll('.collapse-toggle').forEach((header) => { + if (header.style.display === 'none') { return; } + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); + }); }; diff --git a/WebHostLib/static/assets/weighted-options.js b/WebHostLib/static/assets/weighted-options.js new file mode 100644 index 0000000000..bdd121eff5 --- /dev/null +++ b/WebHostLib/static/assets/weighted-options.js @@ -0,0 +1,1284 @@ +window.addEventListener('load', () => { + fetchSettingData().then((data) => { + let settingHash = localStorage.getItem('weighted-settings-hash'); + if (!settingHash) { + // If no hash data has been set before, set it now + settingHash = md5(JSON.stringify(data)); + localStorage.setItem('weighted-settings-hash', settingHash); + localStorage.removeItem('weighted-settings'); + } + + if (settingHash !== md5(JSON.stringify(data))) { + const userMessage = document.getElementById('user-message'); + userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " + + "them all to default."; + userMessage.classList.add('visible'); + userMessage.addEventListener('click', resetSettings); + } + + // Page setup + const settings = new WeightedSettings(data); + settings.buildUI(); + settings.updateVisibleGames(); + adjustHeaderWidth(); + + // Event listeners + document.getElementById('export-options').addEventListener('click', () => settings.export()); + document.getElementById('generate-race').addEventListener('click', () => settings.generateGame(true)); + document.getElementById('generate-game').addEventListener('click', () => settings.generateGame()); + + // Name input field + const nameInput = document.getElementById('player-name'); + nameInput.setAttribute('data-type', 'data'); + nameInput.setAttribute('data-setting', 'name'); + nameInput.addEventListener('keyup', (evt) => settings.updateBaseSetting(evt)); + nameInput.value = settings.current.name; + }); +}); + +const resetSettings = () => { + localStorage.removeItem('weighted-settings'); + localStorage.removeItem('weighted-settings-hash') + window.location.reload(); +}; + +const fetchSettingData = () => new Promise((resolve, reject) => { + fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => { + try{ response.json().then((jsonObj) => resolve(jsonObj)); } + catch(error){ reject(error); } + }); +}); + +/// The weighted settings across all games. +class WeightedSettings { + // The data from the server describing the types of settings available for + // each game, as a JSON-safe blob. + data; + + // The settings chosen by the user as they'd appear in the YAML file, stored + // to and retrieved from local storage. + current; + + // A record mapping game names to the associated GameSettings. + games; + + constructor(data) { + this.data = data; + this.current = JSON.parse(localStorage.getItem('weighted-settings')); + this.games = Object.keys(this.data.games).map((game) => new GameSettings(this, game)); + if (this.current) { return; } + + this.current = {}; + + // Transfer base options directly + for (let baseOption of Object.keys(this.data.baseOptions)){ + this.current[baseOption] = this.data.baseOptions[baseOption]; + } + + // Set options per game + for (let game of Object.keys(this.data.games)) { + // Initialize game object + this.current[game] = {}; + + // Transfer game settings + for (let gameSetting of Object.keys(this.data.games[game].gameSettings)){ + this.current[game][gameSetting] = {}; + + const setting = this.data.games[game].gameSettings[gameSetting]; + switch(setting.type){ + case 'select': + setting.options.forEach((option) => { + this.current[game][gameSetting][option.value] = + (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0; + }); + break; + case 'range': + case 'special_range': + this.current[game][gameSetting]['random'] = 0; + this.current[game][gameSetting]['random-low'] = 0; + this.current[game][gameSetting]['random-high'] = 0; + if (setting.hasOwnProperty('defaultValue')) { + this.current[game][gameSetting][setting.defaultValue] = 25; + } else { + this.current[game][gameSetting][setting.min] = 25; + } + break; + + case 'items-list': + case 'locations-list': + case 'custom-list': + this.current[game][gameSetting] = setting.defaultValue; + break; + + default: + console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`); + } + } + + this.current[game].start_inventory = {}; + this.current[game].exclude_locations = []; + this.current[game].priority_locations = []; + this.current[game].local_items = []; + this.current[game].non_local_items = []; + this.current[game].start_hints = []; + this.current[game].start_location_hints = []; + } + + this.save(); + } + + // Saves the current settings to local storage. + save() { + localStorage.setItem('weighted-settings', JSON.stringify(this.current)); + } + + buildUI() { + // Build the game-choice div + this.#buildGameChoice(); + + const gamesWrapper = document.getElementById('games-wrapper'); + this.games.forEach((game) => { + gamesWrapper.appendChild(game.buildUI()); + }); + } + + #buildGameChoice() { + const gameChoiceDiv = document.getElementById('game-choice'); + const h2 = document.createElement('h2'); + h2.innerText = 'Game Select'; + gameChoiceDiv.appendChild(h2); + + const gameSelectDescription = document.createElement('p'); + gameSelectDescription.classList.add('setting-description'); + gameSelectDescription.innerText = 'Choose which games you might be required to play.'; + gameChoiceDiv.appendChild(gameSelectDescription); + + const hintText = document.createElement('p'); + hintText.classList.add('hint-text'); + hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' + + 'to that section.' + gameChoiceDiv.appendChild(hintText); + + // Build the game choice table + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + + Object.keys(this.data.games).forEach((game) => { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + const span = document.createElement('span'); + span.innerText = game; + span.setAttribute('id', `${game}-game-option`) + tdLeft.appendChild(span); + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.setAttribute('data-type', 'weight'); + range.setAttribute('data-setting', 'game'); + range.setAttribute('data-option', game); + range.value = this.current.game[game]; + range.addEventListener('change', (evt) => { + this.updateBaseSetting(evt); + this.updateVisibleGames(); // Show or hide games based on the new settings + }); + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `game-${game}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + gameChoiceDiv.appendChild(table); + } + + // Verifies that `this.settings` meets all the requirements for world + // generation, normalizes it for serialization, and returns the result. + #validateSettings() { + const settings = structuredClone(this.current); + const userMessage = document.getElementById('user-message'); + let errorMessage = null; + + // User must choose a name for their file + if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') { + userMessage.innerText = 'You forgot to set your player name at the top of the page!'; + userMessage.classList.add('visible'); + userMessage.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + return; + } + + // Clean up the settings output + Object.keys(settings.game).forEach((game) => { + // Remove any disabled games + if (settings.game[game] === 0) { + delete settings.game[game]; + delete settings[game]; + return; + } + + Object.keys(settings[game]).forEach((setting) => { + // Remove any disabled options + Object.keys(settings[game][setting]).forEach((option) => { + if (settings[game][setting][option] === 0) { + delete settings[game][setting][option]; + } + }); + + if ( + Object.keys(settings[game][setting]).length === 0 && + !Array.isArray(settings[game][setting]) && + setting !== 'start_inventory' + ) { + errorMessage = `${game} // ${setting} has no values above zero!`; + } + + // Remove weights from options with only one possibility + if ( + Object.keys(settings[game][setting]).length === 1 && + !Array.isArray(settings[game][setting]) && + setting !== 'start_inventory' + ) { + settings[game][setting] = Object.keys(settings[game][setting])[0]; + } + + // Remove empty arrays + else if ( + ['exclude_locations', 'priority_locations', 'local_items', + 'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) && + settings[game][setting].length === 0 + ) { + delete settings[game][setting]; + } + + // Remove empty start inventory + else if ( + setting === 'start_inventory' && + Object.keys(settings[game]['start_inventory']).length === 0 + ) { + delete settings[game]['start_inventory']; + } + }); + }); + + if (Object.keys(settings.game).length === 0) { + errorMessage = 'You have not chosen a game to play!'; + } + + // Remove weights if there is only one game + else if (Object.keys(settings.game).length === 1) { + settings.game = Object.keys(settings.game)[0]; + } + + // If an error occurred, alert the user and do not export the file + if (errorMessage) { + userMessage.innerText = errorMessage; + userMessage.classList.add('visible'); + userMessage.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + return; + } + + // If no error occurred, hide the user message if it is visible + userMessage.classList.remove('visible'); + return settings; + } + + updateVisibleGames() { + Object.entries(this.current.game).forEach(([game, weight]) => { + const gameDiv = document.getElementById(`${game}-div`); + const gameOption = document.getElementById(`${game}-game-option`); + if (parseInt(weight, 10) > 0) { + gameDiv.classList.remove('invisible'); + gameOption.classList.add('jump-link'); + gameOption.addEventListener('click', () => { + const gameDiv = document.getElementById(`${game}-div`); + if (gameDiv.classList.contains('invisible')) { return; } + gameDiv.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }); + } else { + gameDiv.classList.add('invisible'); + gameOption.classList.remove('jump-link'); + } + }); + } + + updateBaseSetting(event) { + const setting = event.target.getAttribute('data-setting'); + const option = event.target.getAttribute('data-option'); + const type = event.target.getAttribute('data-type'); + + switch(type){ + case 'weight': + this.current[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); + document.getElementById(`${setting}-${option}`).innerText = event.target.value; + break; + case 'data': + this.current[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); + break; + } + + this.save(); + } + + export() { + const settings = this.#validateSettings(); + if (!settings) { return; } + + const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); + download(`${document.getElementById('player-name').value}.yaml`, yamlText); + } + + generateGame(raceMode = false) { + const settings = this.#validateSettings(); + if (!settings) { return; } + + axios.post('/api/generate', { + 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; + }).catch((error) => { + const userMessage = document.getElementById('user-message'); + userMessage.innerText = 'Something went wrong and your game could not be generated.'; + if (error.response.data.text) { + userMessage.innerText += ' ' + error.response.data.text; + } + userMessage.classList.add('visible'); + userMessage.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + console.error(error); + }); + } +} + +// Settings for an individual game. +class GameSettings { + // The WeightedSettings that contains this game's settings. Used to save + // settings after editing. + #allSettings; + + // The name of this game. + name; + + // The data from the server describing the types of settings available for + // this game, as a JSON-safe blob. + get data() { + return this.#allSettings.data.games[this.name]; + } + + // The settings chosen by the user as they'd appear in the YAML file, stored + // to and retrieved from local storage. + get current() { + return this.#allSettings.current[this.name]; + } + + constructor(allSettings, name) { + this.#allSettings = allSettings; + this.name = name; + } + + // Builds and returns the settings UI for this game. + buildUI() { + // Create game div, invisible by default + const gameDiv = document.createElement('div'); + gameDiv.setAttribute('id', `${this.name}-div`); + gameDiv.classList.add('game-div'); + gameDiv.classList.add('invisible'); + + const gameHeader = document.createElement('h2'); + gameHeader.innerText = this.name; + gameDiv.appendChild(gameHeader); + + const collapseButton = document.createElement('a'); + collapseButton.innerText = '(Collapse)'; + gameDiv.appendChild(collapseButton); + + const expandButton = document.createElement('a'); + expandButton.innerText = '(Expand)'; + expandButton.classList.add('invisible'); + gameDiv.appendChild(expandButton); + + // Sort items and locations alphabetically. + this.data.gameItems.sort(); + this.data.gameLocations.sort(); + + const weightedSettingsDiv = this.#buildWeightedSettingsDiv(); + gameDiv.appendChild(weightedSettingsDiv); + + const itemPoolDiv = this.#buildItemsDiv(); + gameDiv.appendChild(itemPoolDiv); + + const hintsDiv = this.#buildHintsDiv(); + gameDiv.appendChild(hintsDiv); + + const locationsDiv = this.#buildLocationsDiv(); + gameDiv.appendChild(locationsDiv); + + collapseButton.addEventListener('click', () => { + collapseButton.classList.add('invisible'); + weightedSettingsDiv.classList.add('invisible'); + itemPoolDiv.classList.add('invisible'); + hintsDiv.classList.add('invisible'); + locationsDiv.classList.add('invisible'); + expandButton.classList.remove('invisible'); + }); + + expandButton.addEventListener('click', () => { + collapseButton.classList.remove('invisible'); + weightedSettingsDiv.classList.remove('invisible'); + itemPoolDiv.classList.remove('invisible'); + hintsDiv.classList.remove('invisible'); + locationsDiv.classList.remove('invisible'); + expandButton.classList.add('invisible'); + }); + + return gameDiv; + } + + #buildWeightedSettingsDiv() { + const settingsWrapper = document.createElement('div'); + settingsWrapper.classList.add('settings-wrapper'); + + Object.keys(this.data.gameSettings).forEach((settingName) => { + const setting = this.data.gameSettings[settingName]; + const settingWrapper = document.createElement('div'); + settingWrapper.classList.add('setting-wrapper'); + + const settingNameHeader = document.createElement('h4'); + settingNameHeader.innerText = setting.displayName; + settingWrapper.appendChild(settingNameHeader); + + const settingDescription = document.createElement('p'); + settingDescription.classList.add('setting-description'); + settingDescription.innerText = setting.description.replace(/(\n)/g, ' '); + settingWrapper.appendChild(settingDescription); + + switch(setting.type){ + case 'select': + const optionTable = document.createElement('table'); + const tbody = document.createElement('tbody'); + + // Add a weight range for each option + setting.options.forEach((option) => { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option.name; + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option.value); + range.setAttribute('data-type', setting.type); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][option.value]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option.value}`); + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + tbody.appendChild(tr); + }); + + optionTable.appendChild(tbody); + settingWrapper.appendChild(optionTable); + break; + + case 'range': + case 'special_range': + const rangeTable = document.createElement('table'); + const rangeTbody = document.createElement('tbody'); + + if (((setting.max - setting.min) + 1) < 11) { + for (let i=setting.min; i <= setting.max; ++i) { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = i; + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${i}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', i); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][i] || 0; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${i}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + rangeTbody.appendChild(tr); + } + } else { + const hintText = document.createElement('p'); + hintText.classList.add('hint-text'); + hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' + + `below, then press the "Add" button to add a weight for it.
Minimum value: ${setting.min}
` + + `Maximum value: ${setting.max}`; + + if (setting.hasOwnProperty('value_names')) { + hintText.innerHTML += '

Certain values have special meaning:'; + Object.keys(setting.value_names).forEach((specialName) => { + hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; + }); + } + + settingWrapper.appendChild(hintText); + + const addOptionDiv = document.createElement('div'); + addOptionDiv.classList.add('add-option-div'); + const optionInput = document.createElement('input'); + optionInput.setAttribute('id', `${this.name}-${settingName}-option`); + optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`); + addOptionDiv.appendChild(optionInput); + const addOptionButton = document.createElement('button'); + addOptionButton.innerText = 'Add'; + addOptionDiv.appendChild(addOptionButton); + settingWrapper.appendChild(addOptionDiv); + optionInput.addEventListener('keydown', (evt) => { + if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } + }); + + addOptionButton.addEventListener('click', () => { + const optionInput = document.getElementById(`${this.name}-${settingName}-option`); + let option = optionInput.value; + if (!option || !option.trim()) { return; } + option = parseInt(option, 10); + if ((option < setting.min) || (option > setting.max)) { return; } + optionInput.value = ''; + if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][parseInt(option, 10)]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + const tdDelete = document.createElement('td'); + tdDelete.classList.add('td-delete'); + const deleteButton = document.createElement('span'); + deleteButton.classList.add('range-option-delete'); + deleteButton.innerText = '❌'; + deleteButton.addEventListener('click', () => { + range.value = 0; + range.dispatchEvent(new Event('change')); + rangeTbody.removeChild(tr); + }); + tdDelete.appendChild(deleteButton); + tr.appendChild(tdDelete); + + rangeTbody.appendChild(tr); + + // Save new option to settings + range.dispatchEvent(new Event('change')); + }); + + Object.keys(this.current[settingName]).forEach((option) => { + // These options are statically generated below, and should always appear even if they are deleted + // from localStorage + if (['random-low', 'random', 'random-high'].includes(option)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][parseInt(option, 10)]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + const tdDelete = document.createElement('td'); + tdDelete.classList.add('td-delete'); + const deleteButton = document.createElement('span'); + deleteButton.classList.add('range-option-delete'); + deleteButton.innerText = '❌'; + deleteButton.addEventListener('click', () => { + range.value = 0; + const changeEvent = new Event('change'); + changeEvent.action = 'rangeDelete'; + range.dispatchEvent(changeEvent); + rangeTbody.removeChild(tr); + }); + tdDelete.appendChild(deleteButton); + tr.appendChild(tdDelete); + + rangeTbody.appendChild(tr); + }); + } + + ['random', 'random-low', 'random-high'].forEach((option) => { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + switch(option){ + case 'random': + tdLeft.innerText = 'Random'; + break; + case 'random-low': + tdLeft.innerText = "Random (Low)"; + break; + case 'random-high': + tdLeft.innerText = "Random (High)"; + break; + } + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][option]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + rangeTbody.appendChild(tr); + }); + + rangeTable.appendChild(rangeTbody); + settingWrapper.appendChild(rangeTable); + break; + + case 'items-list': + const itemsList = document.createElement('div'); + itemsList.classList.add('simple-list'); + + Object.values(this.data.gameItems).forEach((item) => { + const itemRow = document.createElement('div'); + itemRow.classList.add('list-row'); + + const itemLabel = document.createElement('label'); + itemLabel.setAttribute('for', `${this.name}-${settingName}-${item}`) + + const itemCheckbox = document.createElement('input'); + itemCheckbox.setAttribute('id', `${this.name}-${settingName}-${item}`); + itemCheckbox.setAttribute('type', 'checkbox'); + itemCheckbox.setAttribute('data-game', this.name); + itemCheckbox.setAttribute('data-setting', settingName); + itemCheckbox.setAttribute('data-option', item.toString()); + itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + if (this.current[settingName].includes(item)) { + itemCheckbox.setAttribute('checked', '1'); + } + + const itemName = document.createElement('span'); + itemName.innerText = item.toString(); + + itemLabel.appendChild(itemCheckbox); + itemLabel.appendChild(itemName); + + itemRow.appendChild(itemLabel); + itemsList.appendChild((itemRow)); + }); + + settingWrapper.appendChild(itemsList); + break; + + case 'locations-list': + const locationsList = document.createElement('div'); + locationsList.classList.add('simple-list'); + + Object.values(this.data.gameLocations).forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-${settingName}-${location}`) + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('id', `${this.name}-${settingName}-${location}`); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', settingName); + locationCheckbox.setAttribute('data-option', location.toString()); + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + if (this.current[settingName].includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + + const locationName = document.createElement('span'); + locationName.innerText = location.toString(); + + locationLabel.appendChild(locationCheckbox); + locationLabel.appendChild(locationName); + + locationRow.appendChild(locationLabel); + locationsList.appendChild((locationRow)); + }); + + settingWrapper.appendChild(locationsList); + break; + + case 'custom-list': + const customList = document.createElement('div'); + customList.classList.add('simple-list'); + + Object.values(this.data.gameSettings[settingName].options).forEach((listItem) => { + const customListRow = document.createElement('div'); + customListRow.classList.add('list-row'); + + const customItemLabel = document.createElement('label'); + customItemLabel.setAttribute('for', `${this.name}-${settingName}-${listItem}`) + + const customItemCheckbox = document.createElement('input'); + customItemCheckbox.setAttribute('id', `${this.name}-${settingName}-${listItem}`); + customItemCheckbox.setAttribute('type', 'checkbox'); + customItemCheckbox.setAttribute('data-game', this.name); + customItemCheckbox.setAttribute('data-setting', settingName); + customItemCheckbox.setAttribute('data-option', listItem.toString()); + customItemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + if (this.current[settingName].includes(listItem)) { + customItemCheckbox.setAttribute('checked', '1'); + } + + const customItemName = document.createElement('span'); + customItemName.innerText = listItem.toString(); + + customItemLabel.appendChild(customItemCheckbox); + customItemLabel.appendChild(customItemName); + + customListRow.appendChild(customItemLabel); + customList.appendChild((customListRow)); + }); + + settingWrapper.appendChild(customList); + break; + + default: + console.error(`Unknown setting type for ${this.name} setting ${settingName}: ${setting.type}`); + return; + } + + settingsWrapper.appendChild(settingWrapper); + }); + + return settingsWrapper; + } + + #buildItemsDiv() { + const itemsDiv = document.createElement('div'); + itemsDiv.classList.add('items-div'); + + const itemsDivHeader = document.createElement('h3'); + itemsDivHeader.innerText = 'Item Pool'; + itemsDiv.appendChild(itemsDivHeader); + + const itemsDescription = document.createElement('p'); + itemsDescription.classList.add('setting-description'); + itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' + + 'your seed or someone else\'s.'; + itemsDiv.appendChild(itemsDescription); + + const itemsHint = document.createElement('p'); + itemsHint.classList.add('hint-text'); + itemsHint.innerText = 'Drag and drop items from one box to another.'; + itemsDiv.appendChild(itemsHint); + + const itemsWrapper = document.createElement('div'); + itemsWrapper.classList.add('items-wrapper'); + + const itemDragoverHandler = (evt) => evt.preventDefault(); + const itemDropHandler = (evt) => this.#itemDropHandler(evt); + + // Create container divs for each category + const availableItemsWrapper = document.createElement('div'); + availableItemsWrapper.classList.add('item-set-wrapper'); + availableItemsWrapper.innerText = 'Available Items'; + const availableItems = document.createElement('div'); + availableItems.classList.add('item-container'); + availableItems.setAttribute('id', `${this.name}-available_items`); + availableItems.addEventListener('dragover', itemDragoverHandler); + availableItems.addEventListener('drop', itemDropHandler); + + const startInventoryWrapper = document.createElement('div'); + startInventoryWrapper.classList.add('item-set-wrapper'); + startInventoryWrapper.innerText = 'Start Inventory'; + const startInventory = document.createElement('div'); + startInventory.classList.add('item-container'); + startInventory.setAttribute('id', `${this.name}-start_inventory`); + startInventory.setAttribute('data-setting', 'start_inventory'); + startInventory.addEventListener('dragover', itemDragoverHandler); + startInventory.addEventListener('drop', itemDropHandler); + + const localItemsWrapper = document.createElement('div'); + localItemsWrapper.classList.add('item-set-wrapper'); + localItemsWrapper.innerText = 'Local Items'; + const localItems = document.createElement('div'); + localItems.classList.add('item-container'); + localItems.setAttribute('id', `${this.name}-local_items`); + localItems.setAttribute('data-setting', 'local_items') + localItems.addEventListener('dragover', itemDragoverHandler); + localItems.addEventListener('drop', itemDropHandler); + + const nonLocalItemsWrapper = document.createElement('div'); + nonLocalItemsWrapper.classList.add('item-set-wrapper'); + nonLocalItemsWrapper.innerText = 'Non-Local Items'; + const nonLocalItems = document.createElement('div'); + nonLocalItems.classList.add('item-container'); + nonLocalItems.setAttribute('id', `${this.name}-non_local_items`); + nonLocalItems.setAttribute('data-setting', 'non_local_items'); + nonLocalItems.addEventListener('dragover', itemDragoverHandler); + nonLocalItems.addEventListener('drop', itemDropHandler); + + // Populate the divs + this.data.gameItems.forEach((item) => { + if (Object.keys(this.current.start_inventory).includes(item)){ + const itemDiv = this.#buildItemQtyDiv(item); + itemDiv.setAttribute('data-setting', 'start_inventory'); + startInventory.appendChild(itemDiv); + } else if (this.current.local_items.includes(item)) { + const itemDiv = this.#buildItemDiv(item); + itemDiv.setAttribute('data-setting', 'local_items'); + localItems.appendChild(itemDiv); + } else if (this.current.non_local_items.includes(item)) { + const itemDiv = this.#buildItemDiv(item); + itemDiv.setAttribute('data-setting', 'non_local_items'); + nonLocalItems.appendChild(itemDiv); + } else { + const itemDiv = this.#buildItemDiv(item); + availableItems.appendChild(itemDiv); + } + }); + + availableItemsWrapper.appendChild(availableItems); + startInventoryWrapper.appendChild(startInventory); + localItemsWrapper.appendChild(localItems); + nonLocalItemsWrapper.appendChild(nonLocalItems); + itemsWrapper.appendChild(availableItemsWrapper); + itemsWrapper.appendChild(startInventoryWrapper); + itemsWrapper.appendChild(localItemsWrapper); + itemsWrapper.appendChild(nonLocalItemsWrapper); + itemsDiv.appendChild(itemsWrapper); + return itemsDiv; + } + + #buildItemDiv(item) { + const itemDiv = document.createElement('div'); + itemDiv.classList.add('item-div'); + itemDiv.setAttribute('id', `${this.name}-${item}`); + itemDiv.setAttribute('data-game', this.name); + itemDiv.setAttribute('data-item', item); + itemDiv.setAttribute('draggable', 'true'); + itemDiv.innerText = item; + itemDiv.addEventListener('dragstart', (evt) => { + evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id')); + }); + return itemDiv; + } + + #buildItemQtyDiv(item) { + const itemQtyDiv = document.createElement('div'); + itemQtyDiv.classList.add('item-qty-div'); + itemQtyDiv.setAttribute('id', `${this.name}-${item}`); + itemQtyDiv.setAttribute('data-game', this.name); + itemQtyDiv.setAttribute('data-item', item); + itemQtyDiv.setAttribute('draggable', 'true'); + itemQtyDiv.innerText = item; + + const inputWrapper = document.createElement('div'); + inputWrapper.classList.add('item-qty-input-wrapper') + + const itemQty = document.createElement('input'); + itemQty.setAttribute('value', this.current.start_inventory.hasOwnProperty(item) ? + this.current.start_inventory[item] : '1'); + itemQty.setAttribute('data-game', this.name); + itemQty.setAttribute('data-setting', 'start_inventory'); + itemQty.setAttribute('data-option', item); + itemQty.setAttribute('maxlength', '3'); + itemQty.addEventListener('keyup', (evt) => { + evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value); + this.#updateItemSetting(evt); + }); + inputWrapper.appendChild(itemQty); + itemQtyDiv.appendChild(inputWrapper); + + itemQtyDiv.addEventListener('dragstart', (evt) => { + evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id')); + }); + return itemQtyDiv; + } + + #itemDropHandler(evt) { + evt.preventDefault(); + const sourceId = evt.dataTransfer.getData('text/plain'); + const sourceDiv = document.getElementById(sourceId); + + const item = sourceDiv.getAttribute('data-item'); + + const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null; + const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null; + + const itemDiv = newSetting === 'start_inventory' ? this.#buildItemQtyDiv(item) : this.#buildItemDiv(item); + + if (oldSetting) { + if (oldSetting === 'start_inventory') { + if (this.current[oldSetting].hasOwnProperty(item)) { + delete this.current[oldSetting][item]; + } + } else { + if (this.current[oldSetting].includes(item)) { + this.current[oldSetting].splice(this.current[oldSetting].indexOf(item), 1); + } + } + } + + if (newSetting) { + itemDiv.setAttribute('data-setting', newSetting); + document.getElementById(`${this.name}-${newSetting}`).appendChild(itemDiv); + if (newSetting === 'start_inventory') { + this.current[newSetting][item] = 1; + } else { + if (!this.current[newSetting].includes(item)){ + this.current[newSetting].push(item); + } + } + } else { + // No setting was assigned, this item has been removed from the settings + document.getElementById(`${this.name}-available_items`).appendChild(itemDiv); + } + + // Remove the source drag object + sourceDiv.parentElement.removeChild(sourceDiv); + + // Save the updated settings + this.save(); + } + + #buildHintsDiv() { + const hintsDiv = document.createElement('div'); + hintsDiv.classList.add('hints-div'); + const hintsHeader = document.createElement('h3'); + hintsHeader.innerText = 'Item & Location Hints'; + hintsDiv.appendChild(hintsHeader); + const hintsDescription = document.createElement('p'); + hintsDescription.classList.add('setting-description'); + hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' + + ' items are, or what those locations contain.'; + hintsDiv.appendChild(hintsDescription); + + const itemHintsContainer = document.createElement('div'); + itemHintsContainer.classList.add('hints-container'); + + // Item Hints + const itemHintsWrapper = document.createElement('div'); + itemHintsWrapper.classList.add('hints-wrapper'); + itemHintsWrapper.innerText = 'Starting Item Hints'; + + const itemHintsDiv = document.createElement('div'); + itemHintsDiv.classList.add('simple-list'); + this.data.gameItems.forEach((item) => { + const itemRow = document.createElement('div'); + itemRow.classList.add('list-row'); + + const itemLabel = document.createElement('label'); + itemLabel.setAttribute('for', `${this.name}-start_hints-${item}`); + + const itemCheckbox = document.createElement('input'); + itemCheckbox.setAttribute('type', 'checkbox'); + itemCheckbox.setAttribute('id', `${this.name}-start_hints-${item}`); + itemCheckbox.setAttribute('data-game', this.name); + itemCheckbox.setAttribute('data-setting', 'start_hints'); + itemCheckbox.setAttribute('data-option', item); + if (this.current.start_hints.includes(item)) { + itemCheckbox.setAttribute('checked', 'true'); + } + itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + itemLabel.appendChild(itemCheckbox); + + const itemName = document.createElement('span'); + itemName.innerText = item; + itemLabel.appendChild(itemName); + + itemRow.appendChild(itemLabel); + itemHintsDiv.appendChild(itemRow); + }); + + itemHintsWrapper.appendChild(itemHintsDiv); + itemHintsContainer.appendChild(itemHintsWrapper); + + // Starting Location Hints + const locationHintsWrapper = document.createElement('div'); + locationHintsWrapper.classList.add('hints-wrapper'); + locationHintsWrapper.innerText = 'Starting Location Hints'; + + const locationHintsDiv = document.createElement('div'); + locationHintsDiv.classList.add('simple-list'); + this.data.gameLocations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-start_location_hints-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${this.name}-start_location_hints-${location}`); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', 'start_location_hints'); + locationCheckbox.setAttribute('data-option', location); + if (this.current.start_location_hints.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + locationLabel.appendChild(locationCheckbox); + + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); + + locationRow.appendChild(locationLabel); + locationHintsDiv.appendChild(locationRow); + }); + + locationHintsWrapper.appendChild(locationHintsDiv); + itemHintsContainer.appendChild(locationHintsWrapper); + + hintsDiv.appendChild(itemHintsContainer); + return hintsDiv; + } + + #buildLocationsDiv() { + const locationsDiv = document.createElement('div'); + locationsDiv.classList.add('locations-div'); + const locationsHeader = document.createElement('h3'); + locationsHeader.innerText = 'Priority & Exclusion Locations'; + locationsDiv.appendChild(locationsHeader); + const locationsDescription = document.createElement('p'); + locationsDescription.classList.add('setting-description'); + locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' + + 'excluded locations will not contain progression or useful items.'; + locationsDiv.appendChild(locationsDescription); + + const locationsContainer = document.createElement('div'); + locationsContainer.classList.add('locations-container'); + + // Priority Locations + const priorityLocationsWrapper = document.createElement('div'); + priorityLocationsWrapper.classList.add('locations-wrapper'); + priorityLocationsWrapper.innerText = 'Priority Locations'; + + const priorityLocationsDiv = document.createElement('div'); + priorityLocationsDiv.classList.add('simple-list'); + this.data.gameLocations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-priority_locations-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${this.name}-priority_locations-${location}`); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', 'priority_locations'); + locationCheckbox.setAttribute('data-option', location); + if (this.current.priority_locations.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + locationLabel.appendChild(locationCheckbox); + + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); + + locationRow.appendChild(locationLabel); + priorityLocationsDiv.appendChild(locationRow); + }); + + priorityLocationsWrapper.appendChild(priorityLocationsDiv); + locationsContainer.appendChild(priorityLocationsWrapper); + + // Exclude Locations + const excludeLocationsWrapper = document.createElement('div'); + excludeLocationsWrapper.classList.add('locations-wrapper'); + excludeLocationsWrapper.innerText = 'Exclude Locations'; + + const excludeLocationsDiv = document.createElement('div'); + excludeLocationsDiv.classList.add('simple-list'); + this.data.gameLocations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-exclude_locations-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${this.name}-exclude_locations-${location}`); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', 'exclude_locations'); + locationCheckbox.setAttribute('data-option', location); + if (this.current.exclude_locations.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + locationLabel.appendChild(locationCheckbox); + + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); + + locationRow.appendChild(locationLabel); + excludeLocationsDiv.appendChild(locationRow); + }); + + excludeLocationsWrapper.appendChild(excludeLocationsDiv); + locationsContainer.appendChild(excludeLocationsWrapper); + + locationsDiv.appendChild(locationsContainer); + return locationsDiv; + } + + #updateRangeSetting(evt) { + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); + document.getElementById(`${this.name}-${setting}-${option}`).innerText = evt.target.value; + if (evt.action && evt.action === 'rangeDelete') { + delete this.current[setting][option]; + } else { + this.current[setting][option] = parseInt(evt.target.value, 10); + } + this.save(); + } + + #updateListSetting(evt) { + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); + + if (evt.target.checked) { + // If the option is to be enabled and it is already enabled, do nothing + if (this.current[setting].includes(option)) { return; } + + this.current[setting].push(option); + } else { + // If the option is to be disabled and it is already disabled, do nothing + if (!this.current[setting].includes(option)) { return; } + + this.current[setting].splice(this.current[setting].indexOf(option), 1); + } + this.save(); + } + + #updateItemSetting(evt) { + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); + if (setting === 'start_inventory') { + this.current[setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0; + } else { + this.current[setting][option] = isNaN(evt.target.value) ? + evt.target.value : parseInt(evt.target.value, 10); + } + this.save(); + } + + // Saves the current settings to local storage. + save() { + this.#allSettings.save(); + } +} + +/** Create an anchor and trigger a download of a text file. */ +const download = (filename, text) => { + const downloadLink = document.createElement('a'); + downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text)) + downloadLink.setAttribute('download', filename); + downloadLink.style.display = 'none'; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +}; diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js deleted file mode 100644 index fb7d3a349b..0000000000 --- a/WebHostLib/static/assets/weighted-settings.js +++ /dev/null @@ -1,1252 +0,0 @@ -window.addEventListener('load', () => { - fetchSettingData().then((results) => { - let settingHash = localStorage.getItem('weighted-settings-hash'); - if (!settingHash) { - // If no hash data has been set before, set it now - settingHash = md5(JSON.stringify(results)); - localStorage.setItem('weighted-settings-hash', settingHash); - localStorage.removeItem('weighted-settings'); - } - - if (settingHash !== md5(JSON.stringify(results))) { - const userMessage = document.getElementById('user-message'); - userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " + - "them all to default."; - userMessage.classList.add('visible'); - userMessage.addEventListener('click', resetSettings); - } - - // Page setup - createDefaultSettings(results); - buildUI(results); - updateVisibleGames(); - adjustHeaderWidth(); - - // Event listeners - document.getElementById('export-settings').addEventListener('click', () => exportSettings()); - document.getElementById('generate-race').addEventListener('click', () => generateGame(true)); - document.getElementById('generate-game').addEventListener('click', () => generateGame()); - - // Name input field - const weightedSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const nameInput = document.getElementById('player-name'); - nameInput.setAttribute('data-type', 'data'); - nameInput.setAttribute('data-setting', 'name'); - nameInput.addEventListener('keyup', updateBaseSetting); - nameInput.value = weightedSettings.name; - }); -}); - -const resetSettings = () => { - localStorage.removeItem('weighted-settings'); - localStorage.removeItem('weighted-settings-hash') - window.location.reload(); -}; - -const fetchSettingData = () => new Promise((resolve, reject) => { - fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => { - try{ response.json().then((jsonObj) => resolve(jsonObj)); } - catch(error){ reject(error); } - }); -}); - -const createDefaultSettings = (settingData) => { - if (!localStorage.getItem('weighted-settings')) { - const newSettings = {}; - - // Transfer base options directly - for (let baseOption of Object.keys(settingData.baseOptions)){ - newSettings[baseOption] = settingData.baseOptions[baseOption]; - } - - // Set options per game - for (let game of Object.keys(settingData.games)) { - // Initialize game object - newSettings[game] = {}; - - // Transfer game settings - for (let gameSetting of Object.keys(settingData.games[game].gameSettings)){ - newSettings[game][gameSetting] = {}; - - const setting = settingData.games[game].gameSettings[gameSetting]; - switch(setting.type){ - case 'select': - setting.options.forEach((option) => { - newSettings[game][gameSetting][option.value] = - (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0; - }); - break; - case 'range': - case 'special_range': - newSettings[game][gameSetting]['random'] = 0; - newSettings[game][gameSetting]['random-low'] = 0; - newSettings[game][gameSetting]['random-high'] = 0; - if (setting.hasOwnProperty('defaultValue')) { - newSettings[game][gameSetting][setting.defaultValue] = 25; - } else { - newSettings[game][gameSetting][setting.min] = 25; - } - break; - - case 'items-list': - case 'locations-list': - case 'custom-list': - newSettings[game][gameSetting] = setting.defaultValue; - break; - - default: - console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`); - } - } - - newSettings[game].start_inventory = {}; - newSettings[game].exclude_locations = []; - newSettings[game].priority_locations = []; - newSettings[game].local_items = []; - newSettings[game].non_local_items = []; - newSettings[game].start_hints = []; - newSettings[game].start_location_hints = []; - } - - localStorage.setItem('weighted-settings', JSON.stringify(newSettings)); - } -}; - -const buildUI = (settingData) => { - // Build the game-choice div - buildGameChoice(settingData.games); - - const gamesWrapper = document.getElementById('games-wrapper'); - Object.keys(settingData.games).forEach((game) => { - // Create game div, invisible by default - const gameDiv = document.createElement('div'); - gameDiv.setAttribute('id', `${game}-div`); - gameDiv.classList.add('game-div'); - gameDiv.classList.add('invisible'); - - const gameHeader = document.createElement('h2'); - gameHeader.innerText = game; - gameDiv.appendChild(gameHeader); - - const collapseButton = document.createElement('a'); - collapseButton.innerText = '(Collapse)'; - gameDiv.appendChild(collapseButton); - - const expandButton = document.createElement('a'); - expandButton.innerText = '(Expand)'; - expandButton.classList.add('invisible'); - gameDiv.appendChild(expandButton); - - settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0))); - settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0))); - - const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings, - settingData.games[game].gameItems, settingData.games[game].gameLocations); - gameDiv.appendChild(weightedSettingsDiv); - - const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems); - gameDiv.appendChild(itemPoolDiv); - - const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations); - gameDiv.appendChild(hintsDiv); - - const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations); - gameDiv.appendChild(locationsDiv); - - gamesWrapper.appendChild(gameDiv); - - collapseButton.addEventListener('click', () => { - collapseButton.classList.add('invisible'); - weightedSettingsDiv.classList.add('invisible'); - itemPoolDiv.classList.add('invisible'); - hintsDiv.classList.add('invisible'); - locationsDiv.classList.add('invisible'); - expandButton.classList.remove('invisible'); - }); - - expandButton.addEventListener('click', () => { - collapseButton.classList.remove('invisible'); - weightedSettingsDiv.classList.remove('invisible'); - itemPoolDiv.classList.remove('invisible'); - hintsDiv.classList.remove('invisible'); - locationsDiv.classList.remove('invisible'); - expandButton.classList.add('invisible'); - }); - }); -}; - -const buildGameChoice = (games) => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - const gameChoiceDiv = document.getElementById('game-choice'); - const h2 = document.createElement('h2'); - h2.innerText = 'Game Select'; - gameChoiceDiv.appendChild(h2); - - const gameSelectDescription = document.createElement('p'); - gameSelectDescription.classList.add('setting-description'); - gameSelectDescription.innerText = 'Choose which games you might be required to play.'; - gameChoiceDiv.appendChild(gameSelectDescription); - - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' + - 'to that section.' - gameChoiceDiv.appendChild(hintText); - - // Build the game choice table - const table = document.createElement('table'); - const tbody = document.createElement('tbody'); - - Object.keys(games).forEach((game) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - const span = document.createElement('span'); - span.innerText = game; - span.setAttribute('id', `${game}-game-option`) - tdLeft.appendChild(span); - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.setAttribute('data-type', 'weight'); - range.setAttribute('data-setting', 'game'); - range.setAttribute('data-option', game); - range.value = settings.game[game]; - range.addEventListener('change', (evt) => { - updateBaseSetting(evt); - updateVisibleGames(); // Show or hide games based on the new settings - }); - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `game-${game}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - tbody.appendChild(tr); - }); - - table.appendChild(tbody); - gameChoiceDiv.appendChild(table); -}; - -const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const settingsWrapper = document.createElement('div'); - settingsWrapper.classList.add('settings-wrapper'); - - Object.keys(settings).forEach((settingName) => { - const setting = settings[settingName]; - const settingWrapper = document.createElement('div'); - settingWrapper.classList.add('setting-wrapper'); - - const settingNameHeader = document.createElement('h4'); - settingNameHeader.innerText = setting.displayName; - settingWrapper.appendChild(settingNameHeader); - - const settingDescription = document.createElement('p'); - settingDescription.classList.add('setting-description'); - settingDescription.innerText = setting.description.replace(/(\n)/g, ' '); - settingWrapper.appendChild(settingDescription); - - switch(setting.type){ - case 'select': - const optionTable = document.createElement('table'); - const tbody = document.createElement('tbody'); - - // Add a weight range for each option - setting.options.forEach((option) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option.name; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option.value); - range.setAttribute('data-type', setting.type); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][option.value]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option.value}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - tbody.appendChild(tr); - }); - - optionTable.appendChild(tbody); - settingWrapper.appendChild(optionTable); - break; - - case 'range': - case 'special_range': - const rangeTable = document.createElement('table'); - const rangeTbody = document.createElement('tbody'); - - if (((setting.max - setting.min) + 1) < 11) { - for (let i=setting.min; i <= setting.max; ++i) { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = i; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${i}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', i); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][i] || 0; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${i}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - rangeTbody.appendChild(tr); - } - } else { - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' + - `below, then press the "Add" button to add a weight for it.
Minimum value: ${setting.min}
` + - `Maximum value: ${setting.max}`; - - if (setting.hasOwnProperty('value_names')) { - hintText.innerHTML += '

Certain values have special meaning:'; - Object.keys(setting.value_names).forEach((specialName) => { - hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; - }); - } - - settingWrapper.appendChild(hintText); - - const addOptionDiv = document.createElement('div'); - addOptionDiv.classList.add('add-option-div'); - const optionInput = document.createElement('input'); - optionInput.setAttribute('id', `${game}-${settingName}-option`); - optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`); - addOptionDiv.appendChild(optionInput); - const addOptionButton = document.createElement('button'); - addOptionButton.innerText = 'Add'; - addOptionDiv.appendChild(addOptionButton); - settingWrapper.appendChild(addOptionDiv); - optionInput.addEventListener('keydown', (evt) => { - if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } - }); - - addOptionButton.addEventListener('click', () => { - const optionInput = document.getElementById(`${game}-${settingName}-option`); - let option = optionInput.value; - if (!option || !option.trim()) { return; } - option = parseInt(option, 10); - if ((option < setting.min) || (option > setting.max)) { return; } - optionInput.value = ''; - if (document.getElementById(`${game}-${settingName}-${option}-range`)) { return; } - - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${option}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = '❌'; - deleteButton.addEventListener('click', () => { - range.value = 0; - range.dispatchEvent(new Event('change')); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); - - // Save new option to settings - range.dispatchEvent(new Event('change')); - }); - - Object.keys(currentSettings[game][settingName]).forEach((option) => { - // These options are statically generated below, and should always appear even if they are deleted - // from localStorage - if (['random-low', 'random', 'random-high'].includes(option)) { return; } - - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${option}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = '❌'; - deleteButton.addEventListener('click', () => { - range.value = 0; - const changeEvent = new Event('change'); - changeEvent.action = 'rangeDelete'; - range.dispatchEvent(changeEvent); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); - }); - } - - ['random', 'random-low', 'random-high'].forEach((option) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - switch(option){ - case 'random': - tdLeft.innerText = 'Random'; - break; - case 'random-low': - tdLeft.innerText = "Random (Low)"; - break; - case 'random-high': - tdLeft.innerText = "Random (High)"; - break; - } - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${option}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][option]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - rangeTbody.appendChild(tr); - }); - - rangeTable.appendChild(rangeTbody); - settingWrapper.appendChild(rangeTable); - break; - - case 'items-list': - const itemsList = document.createElement('div'); - itemsList.classList.add('simple-list'); - - Object.values(gameItems).forEach((item) => { - const itemRow = document.createElement('div'); - itemRow.classList.add('list-row'); - - const itemLabel = document.createElement('label'); - itemLabel.setAttribute('for', `${game}-${settingName}-${item}`) - - const itemCheckbox = document.createElement('input'); - itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`); - itemCheckbox.setAttribute('type', 'checkbox'); - itemCheckbox.setAttribute('data-game', game); - itemCheckbox.setAttribute('data-setting', settingName); - itemCheckbox.setAttribute('data-option', item.toString()); - itemCheckbox.addEventListener('change', updateListSetting); - if (currentSettings[game][settingName].includes(item)) { - itemCheckbox.setAttribute('checked', '1'); - } - - const itemName = document.createElement('span'); - itemName.innerText = item.toString(); - - itemLabel.appendChild(itemCheckbox); - itemLabel.appendChild(itemName); - - itemRow.appendChild(itemLabel); - itemsList.appendChild((itemRow)); - }); - - settingWrapper.appendChild(itemsList); - break; - - case 'locations-list': - const locationsList = document.createElement('div'); - locationsList.classList.add('simple-list'); - - Object.values(gameLocations).forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-${settingName}-${location}`) - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', settingName); - locationCheckbox.setAttribute('data-option', location.toString()); - locationCheckbox.addEventListener('change', updateListSetting); - if (currentSettings[game][settingName].includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - - const locationName = document.createElement('span'); - locationName.innerText = location.toString(); - - locationLabel.appendChild(locationCheckbox); - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - locationsList.appendChild((locationRow)); - }); - - settingWrapper.appendChild(locationsList); - break; - - case 'custom-list': - const customList = document.createElement('div'); - customList.classList.add('simple-list'); - - Object.values(settings[settingName].options).forEach((listItem) => { - const customListRow = document.createElement('div'); - customListRow.classList.add('list-row'); - - const customItemLabel = document.createElement('label'); - customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`) - - const customItemCheckbox = document.createElement('input'); - customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`); - customItemCheckbox.setAttribute('type', 'checkbox'); - customItemCheckbox.setAttribute('data-game', game); - customItemCheckbox.setAttribute('data-setting', settingName); - customItemCheckbox.setAttribute('data-option', listItem.toString()); - customItemCheckbox.addEventListener('change', updateListSetting); - if (currentSettings[game][settingName].includes(listItem)) { - customItemCheckbox.setAttribute('checked', '1'); - } - - const customItemName = document.createElement('span'); - customItemName.innerText = listItem.toString(); - - customItemLabel.appendChild(customItemCheckbox); - customItemLabel.appendChild(customItemName); - - customListRow.appendChild(customItemLabel); - customList.appendChild((customListRow)); - }); - - settingWrapper.appendChild(customList); - break; - - default: - console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`); - return; - } - - settingsWrapper.appendChild(settingWrapper); - }); - - return settingsWrapper; -}; - -const buildItemsDiv = (game, items) => { - // Sort alphabetical, in pace - items.sort(); - - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const itemsDiv = document.createElement('div'); - itemsDiv.classList.add('items-div'); - - const itemsDivHeader = document.createElement('h3'); - itemsDivHeader.innerText = 'Item Pool'; - itemsDiv.appendChild(itemsDivHeader); - - const itemsDescription = document.createElement('p'); - itemsDescription.classList.add('setting-description'); - itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' + - 'your seed or someone else\'s.'; - itemsDiv.appendChild(itemsDescription); - - const itemsHint = document.createElement('p'); - itemsHint.classList.add('hint-text'); - itemsHint.innerText = 'Drag and drop items from one box to another.'; - itemsDiv.appendChild(itemsHint); - - const itemsWrapper = document.createElement('div'); - itemsWrapper.classList.add('items-wrapper'); - - // Create container divs for each category - const availableItemsWrapper = document.createElement('div'); - availableItemsWrapper.classList.add('item-set-wrapper'); - availableItemsWrapper.innerText = 'Available Items'; - const availableItems = document.createElement('div'); - availableItems.classList.add('item-container'); - availableItems.setAttribute('id', `${game}-available_items`); - availableItems.addEventListener('dragover', itemDragoverHandler); - availableItems.addEventListener('drop', itemDropHandler); - - const startInventoryWrapper = document.createElement('div'); - startInventoryWrapper.classList.add('item-set-wrapper'); - startInventoryWrapper.innerText = 'Start Inventory'; - const startInventory = document.createElement('div'); - startInventory.classList.add('item-container'); - startInventory.setAttribute('id', `${game}-start_inventory`); - startInventory.setAttribute('data-setting', 'start_inventory'); - startInventory.addEventListener('dragover', itemDragoverHandler); - startInventory.addEventListener('drop', itemDropHandler); - - const localItemsWrapper = document.createElement('div'); - localItemsWrapper.classList.add('item-set-wrapper'); - localItemsWrapper.innerText = 'Local Items'; - const localItems = document.createElement('div'); - localItems.classList.add('item-container'); - localItems.setAttribute('id', `${game}-local_items`); - localItems.setAttribute('data-setting', 'local_items') - localItems.addEventListener('dragover', itemDragoverHandler); - localItems.addEventListener('drop', itemDropHandler); - - const nonLocalItemsWrapper = document.createElement('div'); - nonLocalItemsWrapper.classList.add('item-set-wrapper'); - nonLocalItemsWrapper.innerText = 'Non-Local Items'; - const nonLocalItems = document.createElement('div'); - nonLocalItems.classList.add('item-container'); - nonLocalItems.setAttribute('id', `${game}-non_local_items`); - nonLocalItems.setAttribute('data-setting', 'non_local_items'); - nonLocalItems.addEventListener('dragover', itemDragoverHandler); - nonLocalItems.addEventListener('drop', itemDropHandler); - - // Populate the divs - items.forEach((item) => { - if (Object.keys(currentSettings[game].start_inventory).includes(item)){ - const itemDiv = buildItemQtyDiv(game, item); - itemDiv.setAttribute('data-setting', 'start_inventory'); - startInventory.appendChild(itemDiv); - } else if (currentSettings[game].local_items.includes(item)) { - const itemDiv = buildItemDiv(game, item); - itemDiv.setAttribute('data-setting', 'local_items'); - localItems.appendChild(itemDiv); - } else if (currentSettings[game].non_local_items.includes(item)) { - const itemDiv = buildItemDiv(game, item); - itemDiv.setAttribute('data-setting', 'non_local_items'); - nonLocalItems.appendChild(itemDiv); - } else { - const itemDiv = buildItemDiv(game, item); - availableItems.appendChild(itemDiv); - } - }); - - availableItemsWrapper.appendChild(availableItems); - startInventoryWrapper.appendChild(startInventory); - localItemsWrapper.appendChild(localItems); - nonLocalItemsWrapper.appendChild(nonLocalItems); - itemsWrapper.appendChild(availableItemsWrapper); - itemsWrapper.appendChild(startInventoryWrapper); - itemsWrapper.appendChild(localItemsWrapper); - itemsWrapper.appendChild(nonLocalItemsWrapper); - itemsDiv.appendChild(itemsWrapper); - return itemsDiv; -}; - -const buildItemDiv = (game, item) => { - const itemDiv = document.createElement('div'); - itemDiv.classList.add('item-div'); - itemDiv.setAttribute('id', `${game}-${item}`); - itemDiv.setAttribute('data-game', game); - itemDiv.setAttribute('data-item', item); - itemDiv.setAttribute('draggable', 'true'); - itemDiv.innerText = item; - itemDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id')); - }); - return itemDiv; -}; - -const buildItemQtyDiv = (game, item) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const itemQtyDiv = document.createElement('div'); - itemQtyDiv.classList.add('item-qty-div'); - itemQtyDiv.setAttribute('id', `${game}-${item}`); - itemQtyDiv.setAttribute('data-game', game); - itemQtyDiv.setAttribute('data-item', item); - itemQtyDiv.setAttribute('draggable', 'true'); - itemQtyDiv.innerText = item; - - const inputWrapper = document.createElement('div'); - inputWrapper.classList.add('item-qty-input-wrapper') - - const itemQty = document.createElement('input'); - itemQty.setAttribute('value', currentSettings[game].start_inventory.hasOwnProperty(item) ? - currentSettings[game].start_inventory[item] : '1'); - itemQty.setAttribute('data-game', game); - itemQty.setAttribute('data-setting', 'start_inventory'); - itemQty.setAttribute('data-option', item); - itemQty.setAttribute('maxlength', '3'); - itemQty.addEventListener('keyup', (evt) => { - evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value); - updateItemSetting(evt); - }); - inputWrapper.appendChild(itemQty); - itemQtyDiv.appendChild(inputWrapper); - - itemQtyDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id')); - }); - return itemQtyDiv; -}; - -const itemDragoverHandler = (evt) => { - evt.preventDefault(); -}; - -const itemDropHandler = (evt) => { - evt.preventDefault(); - const sourceId = evt.dataTransfer.getData('text/plain'); - const sourceDiv = document.getElementById(sourceId); - - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const game = sourceDiv.getAttribute('data-game'); - const item = sourceDiv.getAttribute('data-item'); - - const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null; - const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null; - - const itemDiv = newSetting === 'start_inventory' ? buildItemQtyDiv(game, item) : buildItemDiv(game, item); - - if (oldSetting) { - if (oldSetting === 'start_inventory') { - if (currentSettings[game][oldSetting].hasOwnProperty(item)) { - delete currentSettings[game][oldSetting][item]; - } - } else { - if (currentSettings[game][oldSetting].includes(item)) { - currentSettings[game][oldSetting].splice(currentSettings[game][oldSetting].indexOf(item), 1); - } - } - } - - if (newSetting) { - itemDiv.setAttribute('data-setting', newSetting); - document.getElementById(`${game}-${newSetting}`).appendChild(itemDiv); - if (newSetting === 'start_inventory') { - currentSettings[game][newSetting][item] = 1; - } else { - if (!currentSettings[game][newSetting].includes(item)){ - currentSettings[game][newSetting].push(item); - } - } - } else { - // No setting was assigned, this item has been removed from the settings - document.getElementById(`${game}-available_items`).appendChild(itemDiv); - } - - // Remove the source drag object - sourceDiv.parentElement.removeChild(sourceDiv); - - // Save the updated settings - localStorage.setItem('weighted-settings', JSON.stringify(currentSettings)); -}; - -const buildHintsDiv = (game, items, locations) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - - // Sort alphabetical, in place - items.sort(); - locations.sort(); - - const hintsDiv = document.createElement('div'); - hintsDiv.classList.add('hints-div'); - const hintsHeader = document.createElement('h3'); - hintsHeader.innerText = 'Item & Location Hints'; - hintsDiv.appendChild(hintsHeader); - const hintsDescription = document.createElement('p'); - hintsDescription.classList.add('setting-description'); - hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' + - ' items are, or what those locations contain.'; - hintsDiv.appendChild(hintsDescription); - - const itemHintsContainer = document.createElement('div'); - itemHintsContainer.classList.add('hints-container'); - - // Item Hints - const itemHintsWrapper = document.createElement('div'); - itemHintsWrapper.classList.add('hints-wrapper'); - itemHintsWrapper.innerText = 'Starting Item Hints'; - - const itemHintsDiv = document.createElement('div'); - itemHintsDiv.classList.add('simple-list'); - items.forEach((item) => { - const itemRow = document.createElement('div'); - itemRow.classList.add('list-row'); - - const itemLabel = document.createElement('label'); - itemLabel.setAttribute('for', `${game}-start_hints-${item}`); - - const itemCheckbox = document.createElement('input'); - itemCheckbox.setAttribute('type', 'checkbox'); - itemCheckbox.setAttribute('id', `${game}-start_hints-${item}`); - itemCheckbox.setAttribute('data-game', game); - itemCheckbox.setAttribute('data-setting', 'start_hints'); - itemCheckbox.setAttribute('data-option', item); - if (currentSettings[game].start_hints.includes(item)) { - itemCheckbox.setAttribute('checked', 'true'); - } - itemCheckbox.addEventListener('change', updateListSetting); - itemLabel.appendChild(itemCheckbox); - - const itemName = document.createElement('span'); - itemName.innerText = item; - itemLabel.appendChild(itemName); - - itemRow.appendChild(itemLabel); - itemHintsDiv.appendChild(itemRow); - }); - - itemHintsWrapper.appendChild(itemHintsDiv); - itemHintsContainer.appendChild(itemHintsWrapper); - - // Starting Location Hints - const locationHintsWrapper = document.createElement('div'); - locationHintsWrapper.classList.add('hints-wrapper'); - locationHintsWrapper.innerText = 'Starting Location Hints'; - - const locationHintsDiv = document.createElement('div'); - locationHintsDiv.classList.add('simple-list'); - locations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${game}-start_location_hints-${location}`); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', 'start_location_hints'); - locationCheckbox.setAttribute('data-option', location); - if (currentSettings[game].start_location_hints.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', updateListSetting); - locationLabel.appendChild(locationCheckbox); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - locationHintsDiv.appendChild(locationRow); - }); - - locationHintsWrapper.appendChild(locationHintsDiv); - itemHintsContainer.appendChild(locationHintsWrapper); - - hintsDiv.appendChild(itemHintsContainer); - return hintsDiv; -}; - -const buildLocationsDiv = (game, locations) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - locations.sort(); // Sort alphabetical, in-place - - const locationsDiv = document.createElement('div'); - locationsDiv.classList.add('locations-div'); - const locationsHeader = document.createElement('h3'); - locationsHeader.innerText = 'Priority & Exclusion Locations'; - locationsDiv.appendChild(locationsHeader); - const locationsDescription = document.createElement('p'); - locationsDescription.classList.add('setting-description'); - locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' + - 'excluded locations will not contain progression or useful items.'; - locationsDiv.appendChild(locationsDescription); - - const locationsContainer = document.createElement('div'); - locationsContainer.classList.add('locations-container'); - - // Priority Locations - const priorityLocationsWrapper = document.createElement('div'); - priorityLocationsWrapper.classList.add('locations-wrapper'); - priorityLocationsWrapper.innerText = 'Priority Locations'; - - const priorityLocationsDiv = document.createElement('div'); - priorityLocationsDiv.classList.add('simple-list'); - locations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-priority_locations-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', 'priority_locations'); - locationCheckbox.setAttribute('data-option', location); - if (currentSettings[game].priority_locations.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', updateListSetting); - locationLabel.appendChild(locationCheckbox); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - priorityLocationsDiv.appendChild(locationRow); - }); - - priorityLocationsWrapper.appendChild(priorityLocationsDiv); - locationsContainer.appendChild(priorityLocationsWrapper); - - // Exclude Locations - const excludeLocationsWrapper = document.createElement('div'); - excludeLocationsWrapper.classList.add('locations-wrapper'); - excludeLocationsWrapper.innerText = 'Exclude Locations'; - - const excludeLocationsDiv = document.createElement('div'); - excludeLocationsDiv.classList.add('simple-list'); - locations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${game}-exclude_locations-${location}`); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', 'exclude_locations'); - locationCheckbox.setAttribute('data-option', location); - if (currentSettings[game].exclude_locations.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', updateListSetting); - locationLabel.appendChild(locationCheckbox); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - excludeLocationsDiv.appendChild(locationRow); - }); - - excludeLocationsWrapper.appendChild(excludeLocationsDiv); - locationsContainer.appendChild(excludeLocationsWrapper); - - locationsDiv.appendChild(locationsContainer); - return locationsDiv; -}; - -const updateVisibleGames = () => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - Object.keys(settings.game).forEach((game) => { - const gameDiv = document.getElementById(`${game}-div`); - const gameOption = document.getElementById(`${game}-game-option`); - if (parseInt(settings.game[game], 10) > 0) { - gameDiv.classList.remove('invisible'); - gameOption.classList.add('jump-link'); - gameOption.addEventListener('click', () => { - const gameDiv = document.getElementById(`${game}-div`); - if (gameDiv.classList.contains('invisible')) { return; } - gameDiv.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - }); - } else { - gameDiv.classList.add('invisible'); - gameOption.classList.remove('jump-link'); - - } - }); -}; - -const updateBaseSetting = (event) => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - const setting = event.target.getAttribute('data-setting'); - const option = event.target.getAttribute('data-option'); - const type = event.target.getAttribute('data-type'); - - switch(type){ - case 'weight': - settings[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); - document.getElementById(`${setting}-${option}`).innerText = event.target.value; - break; - case 'data': - settings[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); - break; - } - - localStorage.setItem('weighted-settings', JSON.stringify(settings)); -}; - -const updateRangeSetting = (evt) => { - const options = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; - if (evt.action && evt.action === 'rangeDelete') { - delete options[game][setting][option]; - } else { - options[game][setting][option] = parseInt(evt.target.value, 10); - } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; - -const updateListSetting = (evt) => { - const options = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - - if (evt.target.checked) { - // If the option is to be enabled and it is already enabled, do nothing - if (options[game][setting].includes(option)) { return; } - - options[game][setting].push(option); - } else { - // If the option is to be disabled and it is already disabled, do nothing - if (!options[game][setting].includes(option)) { return; } - - options[game][setting].splice(options[game][setting].indexOf(option), 1); - } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; - -const updateItemSetting = (evt) => { - const options = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - if (setting === 'start_inventory') { - options[game][setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0; - } else { - options[game][setting][option] = isNaN(evt.target.value) ? - evt.target.value : parseInt(evt.target.value, 10); - } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; - -const validateSettings = () => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - const userMessage = document.getElementById('user-message'); - let errorMessage = null; - - // User must choose a name for their file - if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') { - userMessage.innerText = 'You forgot to set your player name at the top of the page!'; - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return; - } - - // Clean up the settings output - Object.keys(settings.game).forEach((game) => { - // Remove any disabled games - if (settings.game[game] === 0) { - delete settings.game[game]; - delete settings[game]; - return; - } - - Object.keys(settings[game]).forEach((setting) => { - // Remove any disabled options - Object.keys(settings[game][setting]).forEach((option) => { - if (settings[game][setting][option] === 0) { - delete settings[game][setting][option]; - } - }); - - if ( - Object.keys(settings[game][setting]).length === 0 && - !Array.isArray(settings[game][setting]) && - setting !== 'start_inventory' - ) { - errorMessage = `${game} // ${setting} has no values above zero!`; - } - - // Remove weights from options with only one possibility - if ( - Object.keys(settings[game][setting]).length === 1 && - !Array.isArray(settings[game][setting]) && - setting !== 'start_inventory' - ) { - settings[game][setting] = Object.keys(settings[game][setting])[0]; - } - - // Remove empty arrays - else if ( - ['exclude_locations', 'priority_locations', 'local_items', - 'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) && - settings[game][setting].length === 0 - ) { - delete settings[game][setting]; - } - - // Remove empty start inventory - else if ( - setting === 'start_inventory' && - Object.keys(settings[game]['start_inventory']).length === 0 - ) { - delete settings[game]['start_inventory']; - } - }); - }); - - if (Object.keys(settings.game).length === 0) { - errorMessage = 'You have not chosen a game to play!'; - } - - // Remove weights if there is only one game - else if (Object.keys(settings.game).length === 1) { - settings.game = Object.keys(settings.game)[0]; - } - - // If an error occurred, alert the user and do not export the file - if (errorMessage) { - userMessage.innerText = errorMessage; - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return; - } - - // If no error occurred, hide the user message if it is visible - userMessage.classList.remove('visible'); - return settings; -}; - -const exportSettings = () => { - const settings = validateSettings(); - if (!settings) { return; } - - const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); - download(`${document.getElementById('player-name').value}.yaml`, yamlText); -}; - -/** Create an anchor and trigger a download of a text file. */ -const download = (filename, text) => { - const downloadLink = document.createElement('a'); - downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text)) - downloadLink.setAttribute('download', filename); - downloadLink.style.display = 'none'; - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); -}; - -const generateGame = (raceMode = false) => { - const settings = validateSettings(); - if (!settings) { return; } - - axios.post('/api/generate', { - 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; - }).catch((error) => { - const userMessage = document.getElementById('user-message'); - userMessage.innerText = 'Something went wrong and your game could not be generated.'; - if (error.response.data.text) { - userMessage.innerText += ' ' + error.response.data.text; - } - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - console.error(error); - }); -}; diff --git a/WebHostLib/static/styles/player-settings.css b/WebHostLib/static/styles/player-options.css similarity index 67% rename from WebHostLib/static/styles/player-settings.css rename to WebHostLib/static/styles/player-options.css index e6e0c29292..2f5481d285 100644 --- a/WebHostLib/static/styles/player-settings.css +++ b/WebHostLib/static/styles/player-options.css @@ -4,7 +4,7 @@ html{ background-size: 650px 650px; } -#player-settings{ +#player-options{ box-sizing: border-box; max-width: 1024px; margin-left: auto; @@ -15,14 +15,14 @@ html{ color: #eeffeb; } -#player-settings #player-settings-button-row{ +#player-options #player-options-button-row{ display: flex; flex-direction: row; justify-content: space-between; margin-top: 15px; } -#player-settings code{ +#player-options code{ background-color: #d9cd8e; border-radius: 4px; padding-left: 0.25rem; @@ -30,7 +30,7 @@ html{ color: #000000; } -#player-settings #user-message{ +#player-options #user-message{ display: none; width: calc(100% - 8px); background-color: #ffe86b; @@ -40,12 +40,12 @@ html{ text-align: center; } -#player-settings #user-message.visible{ +#player-options #user-message.visible{ display: block; cursor: pointer; } -#player-settings h1{ +#player-options h1{ font-size: 2.5rem; font-weight: normal; width: 100%; @@ -53,7 +53,7 @@ html{ text-shadow: 1px 1px 4px #000000; } -#player-settings h2{ +#player-options h2{ font-size: 40px; font-weight: normal; width: 100%; @@ -62,22 +62,22 @@ html{ text-shadow: 1px 1px 2px #000000; } -#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{ +#player-options h3, #player-options h4, #player-options h5, #player-options h6{ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); } -#player-settings input:not([type]){ +#player-options input:not([type]){ border: 1px solid #000000; padding: 3px; border-radius: 3px; min-width: 150px; } -#player-settings input:not([type]):focus{ +#player-options input:not([type]):focus{ border: 1px solid #ffffff; } -#player-settings select{ +#player-options select{ border: 1px solid #000000; padding: 3px; border-radius: 3px; @@ -85,72 +85,72 @@ html{ background-color: #ffffff; } -#player-settings #game-options, #player-settings #rom-options{ +#player-options #game-options, #player-options #rom-options{ display: flex; flex-direction: row; } -#player-settings .left, #player-settings .right{ +#player-options .left, #player-options .right{ flex-grow: 1; } -#player-settings .left{ +#player-options .left{ margin-right: 10px; } -#player-settings .right{ +#player-options .right{ margin-left: 10px; } -#player-settings table{ +#player-options table{ margin-bottom: 30px; width: 100%; } -#player-settings table .select-container{ +#player-options table .select-container{ display: flex; flex-direction: row; } -#player-settings table .select-container select{ +#player-options table .select-container select{ min-width: 200px; flex-grow: 1; } -#player-settings table select:disabled{ +#player-options table select:disabled{ background-color: lightgray; } -#player-settings table .range-container{ +#player-options table .range-container{ display: flex; flex-direction: row; } -#player-settings table .range-container input[type=range]{ +#player-options table .range-container input[type=range]{ flex-grow: 1; } -#player-settings table .range-value{ +#player-options table .range-value{ min-width: 20px; margin-left: 0.25rem; } -#player-settings table .special-range-container{ +#player-options table .special-range-container{ display: flex; flex-direction: column; } -#player-settings table .special-range-wrapper{ +#player-options table .special-range-wrapper{ display: flex; flex-direction: row; margin-top: 0.25rem; } -#player-settings table .special-range-wrapper input[type=range]{ +#player-options table .special-range-wrapper input[type=range]{ flex-grow: 1; } -#player-settings table .randomize-button { +#player-options table .randomize-button { max-height: 24px; line-height: 16px; padding: 2px 8px; @@ -160,23 +160,23 @@ html{ border-radius: 3px; } -#player-settings table .randomize-button.active { +#player-options table .randomize-button.active { background-color: #ffef00; /* Same as .interactive in globalStyles.css */ } -#player-settings table .randomize-button[data-tooltip]::after { +#player-options table .randomize-button[data-tooltip]::after { left: unset; right: 0; } -#player-settings table label{ +#player-options table label{ display: block; min-width: 200px; margin-right: 4px; cursor: default; } -#player-settings th, #player-settings td{ +#player-options th, #player-options td{ border: none; padding: 3px; font-size: 17px; @@ -184,17 +184,17 @@ html{ } @media all and (max-width: 1024px) { - #player-settings { + #player-options { border-radius: 0; } - #player-settings #game-options{ + #player-options #game-options{ justify-content: flex-start; flex-wrap: wrap; } - #player-settings .left, - #player-settings .right { + #player-options .left, + #player-options .right { margin: 0; } diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css index 1e9a98c17a..7396daa954 100644 --- a/WebHostLib/static/styles/supportedGames.css +++ b/WebHostLib/static/styles/supportedGames.css @@ -18,10 +18,16 @@ margin-bottom: 2px; } +#games .collapse-toggle{ + cursor: pointer; +} + #games h2 .collapse-arrow{ font-size: 20px; + display: inline-block; /* make vertical-align work */ + padding-bottom: 9px; vertical-align: middle; - cursor: pointer; + padding-right: 8px; } #games p.collapsed{ @@ -42,12 +48,12 @@ margin-bottom: 7px; } -#games #page-controls{ +#games .page-controls{ display: flex; flex-direction: row; margin-top: 0.25rem; } -#games #page-controls button{ +#games .page-controls button{ margin-left: 0.5rem; } diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-options.css similarity index 100% rename from WebHostLib/static/styles/weighted-settings.css rename to WebHostLib/static/styles/weighted-options.css diff --git a/WebHostLib/templates/checkResult.html b/WebHostLib/templates/checkResult.html index c245d7381a..75ae7479f5 100644 --- a/WebHostLib/templates/checkResult.html +++ b/WebHostLib/templates/checkResult.html @@ -28,6 +28,10 @@ {% endfor %} + {% if combined_yaml %} +

Combined File Download

+

Download

+ {% endif %} {% endblock %} diff --git a/WebHostLib/templates/player-settings.html b/WebHostLib/templates/player-options.html similarity index 75% rename from WebHostLib/templates/player-settings.html rename to WebHostLib/templates/player-options.html index 50b9e3cbb1..701b4e5861 100644 --- a/WebHostLib/templates/player-settings.html +++ b/WebHostLib/templates/player-options.html @@ -1,26 +1,26 @@ {% extends 'pageWrapper.html' %} {% block head %} - {{ game }} Settings + {{ game }} Options - + - + {% endblock %} {% block body %} {% include 'header/'+theme+'Header.html' %} -
+
-

Player Settings

+

Player Options

Choose the options you would like to play with! You may generate a single-player game from this page, - or download a settings file you can use to participate in a MultiWorld.

+ or download an options file you can use to participate in a MultiWorld.

- A more advanced settings configuration for all games can be found on the - Weighted Settings page. + A more advanced options configuration for all games can be found on the + Weighted options page.
A list of all games you have generated can be found on the User Content Page.
@@ -39,8 +39,8 @@

-
- +
+
diff --git a/WebHostLib/templates/siteMap.html b/WebHostLib/templates/siteMap.html index 562dd3b71b..231ec83e24 100644 --- a/WebHostLib/templates/siteMap.html +++ b/WebHostLib/templates/siteMap.html @@ -24,7 +24,7 @@
  • Supported Games Page
  • Tutorials Page
  • User Content
  • -
  • Weighted Settings Page
  • +
  • Weighted Options Page
  • Game Statistics
  • Glossary
  • @@ -46,11 +46,11 @@ {% endfor %} -

    Game Settings Pages

    +

    Game Options Pages

    diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html index 63b70216d7..3252b16ad4 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -5,15 +5,35 @@ + {% endblock %} {% block body %} {% include 'header/oceanHeader.html' %}

    Currently Supported Games

    -
    +

    -
    +
    @@ -22,21 +42,21 @@ {% for game_name in worlds | title_sorted %} {% set world = worlds[game_name] %}

    -  {{ game_name }} + {{ game_name }}

    -
    -

    Weighted Settings

    -

    Weighted Settings allows you to choose how likely a particular option is to be used in game generation. +

    Weighted Options

    +

    Weighted options allow you to choose how likely a particular option is to be used in game generation. The higher an option is weighted, the more likely the option will be chosen. Think of them like entries in a raffle.

    Choose the games and options you would like to play with! You may generate a single-player game from - this page, or download a settings file you can use to participate in a MultiWorld.

    + this page, or download an options file you can use to participate in a MultiWorld.

    A list of all games you have generated can be found on the User Content page.

    @@ -40,7 +40,7 @@
    - +
    diff --git a/docs/adding games.md b/docs/adding games.md index 24d9e499cd..e9f7860fc6 100644 --- a/docs/adding games.md +++ b/docs/adding games.md @@ -1,214 +1,206 @@ +# How do I add a game to Archipelago? - -# How do I add a game to Archipelago? This guide is going to try and be a broad summary of how you can do just that. -There are two key steps to incorporating a game into Archipelago: -- Game Modification +There are two key steps to incorporating a game into Archipelago: + +- Game Modification - Archipelago Server Integration Refer to the following documents as well: -- [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) for network communication between client and server. -- [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md) for documentation on server side code and creating a world package. +- [network protocol.md](/docs/network%20protocol.md) for network communication between client and server. +- [world api.md](/docs/world%20api.md) for documentation on server side code and creating a world package. -# Game Modification -One half of the work required to integrate a game into Archipelago is the development of the game client. This is +# Game Modification + +One half of the work required to integrate a game into Archipelago is the development of the game client. This is typically done through a modding API or other modification process, described further down. As an example, modifications to a game typically include (more on this later): + - Hooking into when a 'location check' is completed. - Networking with the Archipelago server. - Optionally, UI or HUD updates to show status of the multiworld session or Archipelago server connection. In order to determine how to modify a game, refer to the following sections. - -## Engine Identification -This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is critical. The first step is to look at a game's files. Let's go over what some game files might look like. It’s important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice. + +## Engine Identification + +This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is +critical. The first step is to look at a game's files. Let's go over what some game files might look like. It’s +important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice. Examples are provided below. - + ### Creepy Castle -![Creepy Castle Root Directory in Window's Explorer](./img/creepy-castle-directory.png) - + +![Creepy Castle Root Directory in Windows Explorer](/docs/img/creepy-castle-directory.png) + This is the delightful title Creepy Castle, which is a fantastic game that I highly recommend. It’s also your worst-case -scenario as a modder. All that’s present here is an executable file and some meta-information that Steam uses. You have -basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty nasty -disassembly and reverse engineering work, which is outside the scope of this tutorial. Let’s look at some other examples -of game releases. +scenario as a modder. All that’s present here is an executable file and some meta-information that Steam uses. You have +basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty +nasty disassembly and reverse engineering work, which is outside the scope of this tutorial. Let’s look at some other +examples of game releases. ### Heavy Bullets -![Heavy Bullets Root Directory in Window's Explorer](./img/heavy-bullets-directory.png) - -Here’s the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files. -“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually -with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing -information, credits, and general info about the game. You usually won’t find anything too helpful here, but it never -hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important. -“steam_api.dll” is a file you can safely ignore, it’s just some code used to interface with Steam. -The directory “HEAVY_BULLETS_Data”, however, has some good news. - -![Heavy Bullets Data Directory in Window's Explorer](./img/heavy-bullets-data-directory.png) - -Jackpot! It might not be obvious what you’re looking at here, but I can instantly tell from this folder’s contents that -what we have is a game made in the Unity Engine. If you look in the sub-folders, you’ll seem some .dll files which affirm -our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered, extension-less -level files and the sharedassets files. We’ll tell you a bit about why seeing a Unity game is such good news later, -but for now, this is what one looks like. Also keep your eyes out for an executable with a name like UnityCrashHandler, -that’s another dead giveaway. + +![Heavy Bullets Root Directory in Window's Explorer](/docs/img/heavy-bullets-directory.png) + +Here’s the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files. +“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually +with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing +information, credits, and general info about the game. You usually won’t find anything too helpful here, but it never +hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important. +“steam_api.dll” is a file you can safely ignore, it’s just some code used to interface with Steam. +The directory “HEAVY_BULLETS_Data”, however, has some good news. + +![Heavy Bullets Data Directory in Window's Explorer](/docs/img/heavy-bullets-data-directory.png) + +Jackpot! It might not be obvious what you’re looking at here, but I can instantly tell from this folder’s contents that +what we have is a game made in the Unity Engine. If you look in the sub-folders, you’ll seem some .dll files which +affirm our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered, +extension-less level files and the sharedassets files. If you've identified the game as a Unity game, some useful tools +and information to help you on your journey can be found at this +[Unity Game Hacking guide.](https://github.com/imadr/Unity-game-hacking) ### Stardew Valley -![Stardew Valley Root Directory in Window's Explorer](./img/stardew-valley-directory.png) - -This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways. -Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good news. -More on that later. + +![Stardew Valley Root Directory in Window's Explorer](/docs/img/stardew-valley-directory.png) + +This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways. +Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good +news. Many games made in C# can be modified using the same tools found in our Unity game hacking toolset; namely BepInEx +and MonoMod. ### Gato Roboto -![Gato Roboto Root Directory in Window's Explorer](./img/gato-roboto-directory.png) - -Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for. -The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker. - -This isn't all you'll ever see looking at game files, but it's a good place to start. -As a general rule, the more files a game has out in plain sight, the more you'll be able to change. -This especially applies in the case of code or script files - always keep a lookout for anything you can use to your -advantage! - + +![Gato Roboto Root Directory in Window's Explorer](/docs/img/gato-roboto-directory.png) + +Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for. +The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker. For +modifying GameMaker games the [Undertale Mod Tool](https://github.com/krzys-h/UndertaleModTool) is incredibly helpful. + +This isn't all you'll ever see looking at game files, but it's a good place to start. +As a general rule, the more files a game has out in plain sight, the more you'll be able to change. +This especially applies in the case of code or script files - always keep a lookout for anything you can use to your +advantage! + ## Open or Leaked Source Games -As a side note, many games have either been made open source, or have had source files leaked at some point. -This can be a boon to any would-be modder, for obvious reasons. -Always be sure to check - a quick internet search for "(Game) Source Code" might not give results often, but when it -does you're going to have a much better time. - + +As a side note, many games have either been made open source, or have had source files leaked at some point. +This can be a boon to any would-be modder, for obvious reasons. Always be sure to check - a quick internet search for +"(Game) Source Code" might not give results often, but when it does, you're going to have a much better time. + Be sure never to distribute source code for games that you decompile or find if you do not have express permission to do so, or to redistribute any materials obtained through similar methods, as this is illegal and unethical. - -## Modifying Release Versions of Games -However, for now we'll assume you haven't been so lucky, and have to work with only what’s sitting in your install directory. -Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools, -but these are often not geared to the kind of work you'll be doing and may not help much. -As a general rule, any modding tool that lets you write actual code is something worth using. - +## Modifying Release Versions of Games + +However, for now we'll assume you haven't been so lucky, and have to work with only what’s sitting in your install +directory. Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools, +but these are often not geared to the kind of work you'll be doing and may not help much. + +As a general rule, any modding tool that lets you write actual code is something worth using. + ### Research -The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification, -it's possible other motivated parties have concocted useful tools for your game already. -Always be sure to search the Internet for the efforts of other modders. - -### Analysis Tools -Depending on the game’s underlying engine, there may be some tools you can use either in lieu of or in addition to existing game tools. - -#### [dnSpy](https://github.com/dnSpy/dnSpy/releases) -The first tool in your toolbox is dnSpy. -dnSpy is useful for opening and modifying code files, like .exe and .dll files, that were made in C#. -This won't work for executable files made by other means, and obfuscated code (code which was deliberately made -difficult to reverse engineer) will thwart it, but 9 times out of 10 this is exactly what you need. -You'll want to avoid opening common library files in dnSpy, as these are unlikely to contain the data you're looking to -modify. -For Unity games, the file you’ll want to open will be the file (Data Folder)/Managed/Assembly-CSharp.dll, as pictured below: - -![Heavy Bullets Managed Directory in Window's Explorer](./img/heavy-bullets-managed-directory.png) - -This file will contain the data of the actual game. -For other C# games, the file you want is usually just the executable itself. - -With dnSpy, you can view the game’s C# code, but the tool isn’t perfect. -Although the names of classes, methods, variables, and more will be preserved, code structures may not remain entirely intact. This is because compilers will often subtly rewrite code to be more optimal, so that it works the same as the original code but uses fewer resources. Compiled C# files also lose comments and other documentation. - -#### [UndertaleModTool](https://github.com/krzys-h/UndertaleModTool/releases) -This is currently the best tool for modifying games made in GameMaker, and supports games made in both GMS 1 and 2. -It allows you to modify code in GML, if the game wasn't made with the wrong compiler (usually something you don't have -to worry about). +The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification, +it's possible other motivated parties have concocted useful tools for your game already. +Always be sure to search the Internet for the efforts of other modders. -You'll want to open the data.win file, as this is where all the goods are kept. -Like dnSpy, you won’t be able to see comments. -In addition, you will be able to see and modify many hidden fields on items that GameMaker itself will often hide from -creators. +### Other helpful tools -Fonts in particular are notoriously complex, and to add new sprites you may need to modify existing sprite sheets. - -#### [CheatEngine](https://cheatengine.org/) -CheatEngine is a tool with a very long and storied history. -Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as -malware (because this behavior is most commonly found in malware and rarely used by other programs). -If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level, -including binary data formats, addressing, and assembly language programming. +Depending on the game’s underlying engine, there may be some tools you can use either in lieu of or in addition to +existing game tools. -The tool itself is highly complex and even I have not yet charted its expanses. +#### [CheatEngine](https://cheatengine.org/) + +CheatEngine is a tool with a very long and storied history. +Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as +malware (because this behavior is most commonly found in malware and rarely used by other programs). +If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level, +including binary data formats, addressing, and assembly language programming. + +The tool itself is highly complex and even I have not yet charted its expanses. However, it can also be a very powerful tool in the right hands, allowing you to query and modify gamestate without ever -modifying the actual game itself. -In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do -anything with it. - -### What Modifications You Should Make to the Game -We talked about this briefly in [Game Modification](#game-modification) section. -The next step is to know what you need to make the game do now that you can modify it. Here are your key goals: -- Modify the game so that checks are shuffled -- Know when the player has completed a check, and react accordingly -- Listen for messages from the Archipelago server -- Modify the game to display messages from the Archipelago server -- Add interface for connecting to the Archipelago server with passwords and sessions -- Add commands for manually rewarding, re-syncing, releasing, and other actions - -To elaborate, you need to be able to inform the server whenever you check locations, print out messages that you receive -from the server in-game so players can read them, award items when the server tells you to, sync and re-sync when necessary, -avoid double-awarding items while still maintaining game file integrity, and allow players to manually enter commands in -case the client or server make mistakes. +modifying the actual game itself. +In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do +anything with it. + +### What Modifications You Should Make to the Game + +We talked about this briefly in [Game Modification](#game-modification) section. +The next step is to know what you need to make the game do now that you can modify it. Here are your key goals: + +- Know when the player has checked a location, and react accordingly +- Be able to receive items from the server on the fly +- Keep an index for items received in order to resync from disconnections +- Add interface for connecting to the Archipelago server with passwords and sessions +- Add commands for manually rewarding, re-syncing, releasing, and other actions + +Refer to the [Network Protocol documentation](/docs/network%20protocol.md) for how to communicate with Archipelago's +servers. + +## But my Game is a console game. Can I still add it? + +That depends – what console? + +### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc -Refer to the [Network Protocol documentation](./network%20protocol.md) for how to communicate with Archipelago's servers. - -## But my Game is a console game. Can I still add it? -That depends – what console? - -### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc Most games for recent generations of console platforms are inaccessible to the typical modder. It is generally advised that you do not attempt to work with these games as they are difficult to modify and are protected by their copyright -holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console games. - -### My Game isn’t that old, it’s for the Wii/PS2/360/etc -This is very complex, but doable. -If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it. +holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console +games. + +### My Game isn’t that old, it’s for the Wii/PS2/360/etc + +This is very complex, but doable. +If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it. There exist many disassembly and debugging tools, but more recent content may have lackluster support. - -### My Game is a classic for the SNES/Sega Genesis/etc -That’s a lot more feasible. -There are many good tools available for understanding and modifying games on these older consoles, and the emulation -community will have figured out the bulk of the console’s secrets. -Look for debugging tools, but be ready to learn assembly. -Old consoles usually have their own unique dialects of ASM you’ll need to get used to. + +### My Game is a classic for the SNES/Sega Genesis/etc + +That’s a lot more feasible. +There are many good tools available for understanding and modifying games on these older consoles, and the emulation +community will have figured out the bulk of the console’s secrets. +Look for debugging tools, but be ready to learn assembly. +Old consoles usually have their own unique dialects of ASM you’ll need to get used to. Also make sure there’s a good way to interface with a running emulator, since that’s the only way you can connect these older consoles to the Internet. -There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a computer, -but these will require the same sort of interface software to be written in order to work properly - from your perspective -the two won't really look any different. - -### My Game is an exclusive for the Super Baby Magic Dream Boy. It’s this console from the Soviet Union that- -Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no. +There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a +computer, but these will require the same sort of interface software to be written in order to work properly; from your +perspective the two won't really look any different. + +### My Game is an exclusive for the Super Baby Magic Dream Boy. It’s this console from the Soviet Union that- + +Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no. Obscurity is your enemy – there will likely be little to no emulator or modding information, and you’d essentially be -working from scratch. - +working from scratch. + ## How to Distribute Game Modifications + **NEVER EVER distribute anyone else's copyrighted work UNLESS THEY EXPLICITLY GIVE YOU PERMISSION TO DO SO!!!** This is a good way to get any project you're working on sued out from under you. The right way to distribute modified versions of a game's binaries, assuming that the licensing terms do not allow you -to copy them wholesale, is as patches. +to copy them wholesale, is as patches. There are many patch formats, which I'll cover in brief. The common theme is that you can’t distribute anything that wasn't made by you. Patches are files that describe how your modified file differs from the original one, thus avoiding the issue of distributing someone else’s original work. -Users who have a copy of the game just need to apply the patch, and those who don’t are unable to play. +Users who have a copy of the game just need to apply the patch, and those who don’t are unable to play. ### Patches #### IPS + IPS patches are a simple list of chunks to replace in the original to generate the output. It is not possible to encode moving of a chunk, so they may inadvertently contain copyrighted material and should be avoided unless you know it's fine. #### UPS, BPS, VCDIFF (xdelta), bsdiff + Other patch formats generate the difference between two streams (delta patches) with varying complexity. This way it is possible to insert bytes or move chunks without including any original data. Bsdiff is highly optimized and includes compression, so this format is used by APBP. @@ -217,6 +209,7 @@ Only a bsdiff module is integrated into AP. If the final patch requires or is ba bsdiff or APBP before adding it to the AP source code as "basepatch.bsdiff4" or "basepatch.apbp". #### APBP Archipelago Binary Patch + Starting with version 4 of the APBP format, this is a ZIP file containing metadata in `archipelago.json` and additional files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the bsdiff between the original and the randomized ROM. @@ -224,121 +217,53 @@ bsdiff between the original and the randomized ROM. To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`. ### Mod files + Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere. Mod files come in many forms, but the rules about not distributing other people's content remain the same. They can either be generic and modify the game using a seed or `slot_data` from the AP websocket, or they can be -generated per seed. +generated per seed. If at all possible, it's generally best practice to collect your world information from `slot_data` +so that the users don't have to move files around in order to play. If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy integration into the Webhost by inheriting from `worlds.Files.APContainer`. - ## Archipelago Integration -Integrating a randomizer into Archipelago involves a few steps. -There are several things that may need to be done, but the most important is to create an implementation of the -`World` class specific to your game. This implementation should exist as a Python module within the `worlds` folder -in the Archipelago file structure. -This encompasses most of the data for your game – the items available, what checks you have, the logic for reaching those -checks, what options to offer for the player’s yaml file, and the code to initialize all this data. +In order for your game to communicate with the Archipelago server and generate the necessary randomized information, +you must create a world package in the main Archipelago repo. This section will cover the requisites and expectations +and show the basics of a world. More in depth documentation on the available API can be read in +the [world api doc.](/docs/world%20api.md) +For setting up your working environment with Archipelago refer +to [running from source](/docs/running%20from%20source.md) and the [style guide](/docs/style.md). -Here’s an example of what your world module can look like: - -![Example world module directory open in Window's Explorer](./img/archipelago-world-directory-example.png) +### Requirements -The minimum requirements for a new archipelago world are the package itself (the world folder containing a file named `__init__.py`), -which must define a `World` class object for the game with a game name, create an equal number of items and locations with rules, -a win condition, and at least one `Region` object. - -Let's give a quick breakdown of what the contents for these files look like. -This is just one example of an Archipelago world - the way things are done below is not an immutable property of Archipelago. - -### Items.py -This file is used to define the items which exist in a given game. - -![Example Items.py file open in Notepad++](./img/example-items-py-file.png) - -Some important things to note here. The center of our Items.py file is the item_table, which individually lists every -item in the game and associates them with an ItemData. +A world implementation requires a few key things from its implementation -This file is rather skeletal - most of the actual data has been stripped out for simplicity. -Each ItemData gives a numeric ID to associate with the item and a boolean telling us whether the item might allow the -player to do more than they would have been able to before. - -Next there's the item_frequencies. This simply tells Archipelago how many times each item appears in the pool. -Items that appear exactly once need not be listed - Archipelago will interpret absence from this dictionary as meaning -that the item appears once. - -Lastly, note the `lookup_id_to_name` dictionary, which is typically imported and used in your Archipelago `World` -implementation. This is how Archipelago is told about the items in your world. - -### Locations.py -This file lists all locations in the game. - -![Example Locations.py file open in Notepad++](./img/example-locations-py-file.png) - -First is the achievement_table. It lists each location, the region that it can be found in (more on regions later), -and a numeric ID to associate with each location. - -The exclusion table is a series of dictionaries which are used to exclude certain checks from the pool of progression -locations based on user settings, and the events table associates certain specific checks with specific items. - -`lookup_id_to_name` is also present for locations, though this is a separate dictionary, to be clear. - -### Options.py -This file details options to be searched for in a player's YAML settings file. - -![Example Options.py file open in Notepad++](./img/example-options-py-file.png) - -There are several types of option Archipelago has support for. -In our case, we have three separate choices a player can toggle, either On or Off. -You can also have players choose between a number of predefined values, or have them provide a numeric value within a -specified range. - -### Regions.py -This file contains data which defines the world's topology. -In other words, it details how different regions of the game connect to each other. - -![Example Regions.py file open in Notepad++](./img/example-regions-py-file.png) - -`terraria_regions` contains a list of tuples. -The first element of the tuple is the name of the region, and the second is a list of connections that lead out of the region. - -`mandatory_connections` describe where the connection leads. - -Above this data is a function called `link_terraria_structures` which uses our defined regions and connections to create -something more usable for Archipelago, but this has been left out for clarity. - -### Rules.py -This is the file that details rules for what players can and cannot logically be required to do, based on items and settings. - -![Example Rules.py file open in Notepad++](./img/example-rules-py-file.png) - -This is the most complicated part of the job, and is one part of Archipelago that is likely to see some changes in the future. -The first class, called `TerrariaLogic`, is an extension of the `LogicMixin` class. -This is where you would want to define methods for evaluating certain conditions, which would then return a boolean to -indicate whether conditions have been met. Your rule definitions should start with some sort of identifier to delineate it -from other games, as all rules are mixed together due to `LogicMixin`. In our case, `_terraria_rule` would be a better name. - -The method below, `set_rules()`, is where you would assign these functions as "rules", using lambdas to associate these -functions or combinations of them (or any other code that evaluates to a boolean, in my case just the placeholder `True`) -to certain tasks, like checking locations or using entrances. - -### \_\_init\_\_.py -This is the file that actually extends the `World` class, and is where you expose functionality and data to Archipelago. - -![Example \_\_init\_\_.py file open in Notepad++](./img/example-init-py-file.png) - -This is the most important file for the implementation, and technically the only one you need, but it's best to keep this -file as short as possible and use other script files to do most of the heavy lifting. -If you've done things well, this will just be where you assign everything you set up in the other files to their associated -fields in the class being extended. - -This is also a good place to put game-specific quirky behavior that needs to be managed, as it tends to make things a bit -cluttered if you put these things elsewhere. - -The various methods and attributes are documented in `/worlds/AutoWorld.py[World]` and -[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. +- A folder within `worlds` that contains an `__init__.py` + - This is what defines it as a Python package and how it's able to be imported + into Archipelago's generation system. During generation time only code that is + defined within this file will be run. It's suggested to split up your information + into more files to improve readability, but all of that information can be + imported at its base level within your world. +- A `World` subclass where you create your world and define all of its rules + and the following requirements: + - Your items and locations need a `item_name_to_id` and `location_name_to_id`, + respectively, mapping. + - An `option_definitions` mapping of your game options with the format + `{name: Class}`, where `name` uses Python snake_case. + - You must define your world's `create_item` method, because this may be called + by the generator in certain circumstances + - When creating your world you submit items and regions to the Multiworld. + - These are lists of said objects which you can access at + `self.multiworld.itempool` and `self.multiworld.regions`. Best practice for + adding to these lists is with either `append` or `extend`, where `append` is a + single object and `extend` is a list. + - Do not use `=` as this will delete other worlds' items and regions. + - Regions are containers for holding your world's Locations. + - Locations are where players will "check" for items and must exist within + a region. It's also important for your world's submitted items to be the same as + its submitted locations count. + - You must always have a "Menu" Region from which the generation algorithm + uses to enter the game and access locations. +- Make sure to check out [world maintainer.md](/docs/world%20maintainer.md) before publishing. \ No newline at end of file diff --git a/docs/img/archipelago-world-directory-example.png b/docs/img/archipelago-world-directory-example.png deleted file mode 100644 index ba720f3319..0000000000 Binary files a/docs/img/archipelago-world-directory-example.png and /dev/null differ diff --git a/docs/img/example-init-py-file.png b/docs/img/example-init-py-file.png deleted file mode 100644 index 6dd5c3c938..0000000000 Binary files a/docs/img/example-init-py-file.png and /dev/null differ diff --git a/docs/img/example-items-py-file.png b/docs/img/example-items-py-file.png deleted file mode 100644 index e114a78d21..0000000000 Binary files a/docs/img/example-items-py-file.png and /dev/null differ diff --git a/docs/img/example-locations-py-file.png b/docs/img/example-locations-py-file.png deleted file mode 100644 index 53c4bc1e29..0000000000 Binary files a/docs/img/example-locations-py-file.png and /dev/null differ diff --git a/docs/img/example-options-py-file.png b/docs/img/example-options-py-file.png deleted file mode 100644 index 5811f54000..0000000000 Binary files a/docs/img/example-options-py-file.png and /dev/null differ diff --git a/docs/img/example-regions-py-file.png b/docs/img/example-regions-py-file.png deleted file mode 100644 index a9d05c53fc..0000000000 Binary files a/docs/img/example-regions-py-file.png and /dev/null differ diff --git a/docs/img/example-rules-py-file.png b/docs/img/example-rules-py-file.png deleted file mode 100644 index b76e78b406..0000000000 Binary files a/docs/img/example-rules-py-file.png and /dev/null differ diff --git a/docs/img/heavy-bullets-managed-directory.png b/docs/img/heavy-bullets-managed-directory.png deleted file mode 100644 index 73017f6dc9..0000000000 Binary files a/docs/img/heavy-bullets-managed-directory.png and /dev/null differ diff --git a/docs/triage role expectations.md b/docs/triage role expectations.md new file mode 100644 index 0000000000..5b4cab2275 --- /dev/null +++ b/docs/triage role expectations.md @@ -0,0 +1,100 @@ +# Triage Role Expectations + +Users with Triage-level access are selected contributors who can and wish to proactively label/triage issues and pull +requests without being granted write access to the Archipelago repository. + +Triage users are not necessarily official members of the Archipelago organization, for the list of core maintainers, +please reference [ArchipelagoMW Members](https://github.com/orgs/ArchipelagoMW/people) page. + +## Access Permissions + +Triage users have the following permissions: + +* Apply/dismiss labels on all issues and pull requests. +* Close, reopen, and assign all issues and pull requests. +* Mark issues and pull requests as duplicate. +* Request pull request reviews from repository members. +* Hide comments in issues or pull requests from public view. + * Hidden comments are not deleted and can be reversed by another triage user or repository member with write access. +* And all other standard permissions granted to regular GitHub users. + +For more details on permissions granted by the Triage role, see +[GitHub's Role Documentation](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/repository-roles-for-an-organization). + +## Expectations + +Users with triage-level permissions have no expectation to review code, but, if desired, to review pull requests/issues +and apply the relevant labels and ping/request reviews from any relevant [code owners](./CODEOWNERS) for review. Triage +users are also expected not to close others' issues or pull requests without strong reason to do so (with exception of +`meta: invalid` or `meta: duplicate` scenarios, which are listed below). When in doubt, defer to a core maintainer. + +Triage users are not "moderators" for others' issues or pull requests. However, they may voice their opinions/feedback +on issues or pull requests, just the same as any other GitHub user contributing to Archipelago. + +## Labeling + +As of the time of writing this document, there are 15 distinct labels that can be applied to issues and pull requests. + +### Affects + +These labels notate if certain issues or pull requests affect critical aspects of Archipelago that may require specific +review. More than one of these labels can be used on a issue or pull request, if relevant. + +* `affects: core` is to be applied to issues/PRs that may affect core Archipelago functionality and should be reviewed +with additional scrutiny. + * Core is defined as any files not contained in the `WebHostLib` directory or individual world implementations + directories inside the `worlds` directory, not including `worlds/generic`. +* `affects: webhost` is to be applied to issues/PRs that may affect the core WebHost portion of Archipelago. In +general, this is anything being modified inside the `WebHostLib` directory or `WebHost.py` file. +* `affects: release/blocker` is to be applied for any issues/PRs that may either negatively impact (issues) or propose +to resolve critical issues (pull requests) that affect the current or next official release of Archipelago and should be +given top priority for review. + +### Is + +These labels notate what kinds of changes are being made or proposed in issues or pull requests. More than one of these +labels can be used on a issue or pull request, if relevant, but at least one of these labels should be applied to every +pull request and issue. + +* `is: bug/fix` is to be applied to issues/PRs that report or resolve an issue in core, web, or individual world +implementations. +* `is: documentation` is to be applied to issues/PRs that relate to adding, updating, or removing documentation in +core, web, or individual world implementations without modifying actual code. +* `is: enhancement` is to be applied to issues/PRs that relate to adding, modifying, or removing functionality in +core, web, or individual world implementations. +* `is: refactor/cleanup` is to be applied to issues/PRs that relate to reorganizing existing code to improve +readability or performance without adding, modifying, or removing functionality or fixing known regressions. +* `is: maintenance` is to be applied to issues/PRs that don't modify logic, refactor existing code, change features. +This is typically reserved for pull requests that need to update dependencies or increment version numbers without +resolving existing issues. +* `is: new game` is to be applied to any pull requests that introduce a new game for the first time to the `worlds` +directory. + * Issues should not be opened and classified with `is: new game`, and instead should be directed to the + #future-game-design channel in Archipelago for opening suggestions. If they are opened, they should be labeled + with `meta: invalid` and closed. + * Pull requests for new games should only have this label, as enhancement, documentation, bug/fix, refactor, and + possibly maintenance is implied. + +### Meta + +These labels allow additional quick meta information for contributors or reviewers for issues and pull requests. They +have specific situations where they should be applied. + +* `meta: duplicate` is to be applied to any issues/PRs that are duplicate of another issue/PR that was already opened. + * These should be immediately closed after leaving a comment, directing to the original issue or pull request. +* `meta: invalid` is to be applied to any issues/PRs that do not relate to Archipelago or are inappropriate for +discussion on GitHub. + * These should be immediately closed afterwards. +* `meta: help wanted` is to be applied to any issues/PRs that require additional attention for whatever reason. + * These should include a comment describing what kind of help is requested when the label is added. + * Some common reasons include, but are not limited to: Breaking API changes that require developer input/testing or + pull requests with large line changes that need additional reviewers to be reviewed effectively. + * This label may require some programming experience and familiarity with Archipelago source to determine if + requesting additional attention for help is warranted. +* `meta: good first issue` is to be applied to any issues that may be a good starting ground for new contributors to try +and tackle. + * This label may require some programming experience and familiarity with Archipelago source to determine if an + issue is a "good first issue". +* `meta: wontfix` is to be applied for any issues/PRs that are opened that will not be actioned because it's out of +scope or determined to not be an issue. + * This should be reserved for use by a world's code owner(s) on their relevant world or by core maintainers. diff --git a/docs/world api.md b/docs/world api.md index 6fb5b3ac9c..b128e2b146 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -759,8 +759,9 @@ multiworld for each test written using it. Within subsequent modules, classes sh TestBase, and can then define options to test in the class body, and run tests in each test method. Example `__init__.py` + ```python -from test.TestBase import WorldTestBase +from test.test_base import WorldTestBase class MyGameTestBase(WorldTestBase): diff --git a/inno_setup.iss b/inno_setup.iss index 3c1bdc4571..b6f40f7701 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -46,151 +46,33 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Types] Name: "full"; Description: "Full installation" -Name: "hosting"; Description: "Installation for hosting purposes" -Name: "playing"; Description: "Installation for playing purposes" +Name: "minimal"; Description: "Minimal installation" Name: "custom"; Description: "Custom installation"; Flags: iscustom [Components] -Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed -Name: "generator"; Description: "Generator"; Types: full hosting -Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning -Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 -Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning -Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning -Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting -Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting -Name: "generator/mmbn3"; Description: "MegaMan Battle Network 3"; Types: full hosting; ExtraDiskSpaceRequired: 8388608; Flags: disablenouninstallwarning -Name: "generator/ladx"; Description: "Link's Awakening DX ROM Setup"; Types: full hosting -Name: "generator/tloz"; Description: "The Legend of Zelda ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 135168; Flags: disablenouninstallwarning -Name: "server"; Description: "Server"; Types: full hosting -Name: "client"; Description: "Clients"; Types: full playing -Name: "client/sni"; Description: "SNI Client"; Types: full playing -Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -Name: "client/bizhawk"; Description: "BizHawk Client"; Types: full playing -Name: "client/factorio"; Description: "Factorio"; Types: full playing -Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing -Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 -Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing -Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing -Name: "client/pkmn"; Description: "Pokemon Client" -Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576 -Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576 -Name: "client/mmbn3"; Description: "MegaMan Battle Network 3 Client"; Types: full playing; -Name: "client/ladx"; Description: "Link's Awakening Client"; Types: full playing; ExtraDiskSpaceRequired: 1048576 -Name: "client/cf"; Description: "ChecksFinder"; Types: full playing -Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing -Name: "client/wargroove"; Description: "Wargroove"; Types: full playing -Name: "client/zl"; Description: "Zillion"; Types: full playing -Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing -Name: "client/advn"; Description: "Adventure"; Types: full playing -Name: "client/ut"; Description: "Undertale"; Types: full playing -Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing +Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed +Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full; [Dirs] NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; [Files] -Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp -Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm -Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3 -Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw -Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe -Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac -Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot -Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl -Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r -Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b -Source: "{code:GetBN3ROMPath}"; DestDir: "{app}"; DestName: "Mega Man Battle Network 3 - Blue Version (USA).gba"; Flags: external; Components: client/mmbn3 -Source: "{code:GetLADXROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"; Flags: external; Components: client/ladx or generator/ladx -Source: "{code:GetTLoZROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The (U) (PRG0) [!].nes"; Flags: external; Components: client/tloz or generator/tloz -Source: "{code:GetAdvnROMPath}"; DestDir: "{app}"; DestName: "ADVNTURE.BIN"; Flags: external; Components: client/advn -Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni -Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp - -Source: "{#source_path}\ArchipelagoLauncher.exe"; DestDir: "{app}"; Flags: ignoreversion; -Source: "{#source_path}\ArchipelagoLauncher(DEBUG).exe"; DestDir: "{app}"; Flags: ignoreversion; -Source: "{#source_path}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator -Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server -Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio -Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text -Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni -Source: "{#source_path}\ArchipelagoBizHawkClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/bizhawk -Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx -Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp -Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft -Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot -Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot -Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl -Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1 -Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn -Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf -Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2 -Source: "{#source_path}\ArchipelagoMMBN3Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/mmbn3 -Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/tloz -Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove -Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2 -Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn -Source: "{#source_path}\ArchipelagoUndertaleClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ut +Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; +Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall [Icons] Name: "{group}\{#MyAppName} Folder"; Filename: "{app}"; Name: "{group}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe" -Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server -Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text -Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni -Name: "{group}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Components: client/bizhawk -Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio -Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft -Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot -Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl -Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1 -Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn -Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf -Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2 -Name: "{group}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Components: client/mmbn3 -Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Components: client/tloz -Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2 -Name: "{group}\{#MyAppName} Link's Awakening Client"; Filename: "{app}\ArchipelagoLinksAwakeningClient.exe"; Components: client/ladx -Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn -Name: "{group}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Components: client/wargroove -Name: "{group}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Components: client/ut Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"; Tasks: desktopicon -Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Tasks: desktopicon; Components: server -Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni -Name: "{commondesktop}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Tasks: desktopicon; Components: client/bizhawk -Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio -Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft -Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot -Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl -Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1 -Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn -Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf -Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2 -Name: "{commondesktop}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Tasks: desktopicon; Components: client/mmbn3 -Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Tasks: desktopicon; Components: client/tloz -Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove -Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2 -Name: "{commondesktop}\{#MyAppName} Link's Awakening Client"; Filename: "{app}\ArchipelagoLinksAwakeningClient.exe"; Tasks: desktopicon; Components: client/ladx -Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn -Name: "{commondesktop}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Tasks: desktopicon; Components: client/ut [Run] Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..." -Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp -Filename: "{app}\ArchipelagoMinecraftClient.exe"; Parameters: "--install"; StatusMsg: "Installing Forge Server..."; Components: client/minecraft +Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent @@ -206,101 +88,97 @@ Type: filesandordirs; Name: "{app}\EnemizerCLI*" [Registry] -Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl -Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl -Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl -Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl +Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft -Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft -Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft -Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/minecraft +Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/oot -Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/oot -Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot -Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot +Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn +Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn +Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/mmbn3 -Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/mmbn3 -Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: ""; Components: client/mmbn3 -Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/mmbn3 +Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/ladx -Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/ladx -Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; Components: client/ladx -Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/ladx +Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/tloz -Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/tloz -Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; Components: client/tloz -Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/tloz +Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/advn -Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/advn -Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; Components: client/advn -Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/advn +Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server -Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server -Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server -Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server +Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text -Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text -Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text -Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text +Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; +Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; +Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; +Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; [Code] -const - SHCONTCH_NOPROGRESSBOX = 4; - SHCONTCH_RESPONDYESTOALL = 16; - // See: https://stackoverflow.com/a/51614652/2287576 function IsVCRedist64BitNeeded(): boolean; var @@ -320,594 +198,3 @@ begin Result := True; end; end; - -var R : longint; - -var lttprom: string; -var LttPROMFilePage: TInputFileWizardPage; - -var smrom: string; -var SMRomFilePage: TInputFileWizardPage; - -var dkc3rom: string; -var DKC3RomFilePage: TInputFileWizardPage; - -var smwrom: string; -var SMWRomFilePage: TInputFileWizardPage; - -var soerom: string; -var SoERomFilePage: TInputFileWizardPage; - -var l2acrom: string; -var L2ACROMFilePage: TInputFileWizardPage; - -var ootrom: string; -var OoTROMFilePage: TInputFileWizardPage; - -var zlrom: string; -var ZlROMFilePage: TInputFileWizardPage; - -var redrom: string; -var RedROMFilePage: TInputFileWizardPage; - -var bluerom: string; -var BlueROMFilePage: TInputFileWizardPage; - -var bn3rom: string; -var BN3ROMFilePage: TInputFileWizardPage; - -var ladxrom: string; -var LADXROMFilePage: TInputFileWizardPage; - -var tlozrom: string; -var TLoZROMFilePage: TInputFileWizardPage; - -var advnrom: string; -var AdvnROMFilePage: TInputFileWizardPage; - -function GetSNESMD5OfFile(const rom: string): string; -var data: AnsiString; -begin - if LoadStringFromFile(rom, data) then - begin - if Length(data) mod 1024 = 512 then - begin - data := copy(data, 513, Length(data)-512); - end; - Result := GetMD5OfString(data); - end; -end; - -function GetSMSMD5OfFile(const rom: string): string; -var data: AnsiString; -begin - if LoadStringFromFile(rom, data) then - begin - Result := GetMD5OfString(data); - end; -end; - -function CheckRom(name: string; hash: string): string; -var rom: string; -begin - log('Handling ' + name) - rom := FileSearch(name, WizardDirValue()); - if Length(rom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash))); - if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then - begin - log('existing ROM verified'); - Result := rom; - exit; - end; - log('existing ROM failed verification'); - end; -end; - -function CheckSMSRom(name: string; hash: string): string; -var rom: string; -begin - log('Handling ' + name) - rom := FileSearch(name, WizardDirValue()); - if Length(rom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash))); - if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then - begin - log('existing ROM verified'); - Result := rom; - exit; - end; - log('existing ROM failed verification'); - end; -end; - -function CheckNESRom(name: string; hash: string): string; -var rom: string; -begin - log('Handling ' + name) - rom := FileSearch(name, WizardDirValue()); - if Length(rom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash))); - if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then - begin - log('existing ROM verified'); - Result := rom; - exit; - end; - log('existing ROM failed verification'); - end; -end; - -function AddRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'SNES ROM files|*.sfc;*.smc|All files|*.*', - '.sfc'); -end; - - -function AddGBRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'GB ROM files|*.gb;*.gbc|All files|*.*', - '.gb'); -end; - -function AddGBARomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - Result.Add( - 'Location of ROM file:', - 'GBA ROM files|*.gba|All files|*.*', - '.gba'); -end; - -function AddSMSRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - Result.Add( - 'Location of ROM file:', - 'SMS ROM files|*.sms|All files|*.*', - '.sms'); -end; - -function AddNESRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'NES ROM files|*.nes|All files|*.*', - '.nes'); -end; - -procedure AddOoTRomPage(); -begin - ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue()); - if Length(ootrom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6'))); // normal - log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b'))); // byteswapped - log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f'))); // decompressed - if (CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6') = 0) or (CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b') = 0) or (CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f') = 0) then - begin - log('existing ROM verified'); - exit; - end; - log('existing ROM failed verification'); - end; - ootrom := '' - OoTROMFilePage := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your OoT 1.0 ROM located?', - 'Select the file, then click Next.'); - - OoTROMFilePage.Add( - 'Location of ROM file:', - 'N64 ROM files (*.z64, *.n64)|*.z64;*.n64|All files|*.*', - '.z64'); -end; - -function AddA26Page(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'A2600 ROM files|*.BIN;*.a26|All files|*.*', - '.BIN'); -end; - -function NextButtonClick(CurPageID: Integer): Boolean; -begin - if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then - Result := not (LttPROMFilePage.Values[0] = '') - else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then - Result := not (SMROMFilePage.Values[0] = '') - else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then - Result := not (DKC3ROMFilePage.Values[0] = '') - else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then - Result := not (SMWROMFilePage.Values[0] = '') - else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then - Result := not (SoEROMFilePage.Values[0] = '') - else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then - Result := not (L2ACROMFilePage.Values[0] = '') - else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then - Result := not (OoTROMFilePage.Values[0] = '') - else if (assigned(BN3ROMFilePage)) and (CurPageID = BN3ROMFilePage.ID) then - Result := not (BN3ROMFilePage.Values[0] = '') - else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then - Result := not (ZlROMFilePage.Values[0] = '') - else if (assigned(RedROMFilePage)) and (CurPageID = RedROMFilePage.ID) then - Result := not (RedROMFilePage.Values[0] = '') - else if (assigned(BlueROMFilePage)) and (CurPageID = BlueROMFilePage.ID) then - Result := not (BlueROMFilePage.Values[0] = '') - else if (assigned(LADXROMFilePage)) and (CurPageID = LADXROMFilePage.ID) then - Result := not (LADXROMFilePage.Values[0] = '') - else if (assigned(TLoZROMFilePage)) and (CurPageID = TLoZROMFilePage.ID) then - Result := not (TLoZROMFilePage.Values[0] = '') - else if (assigned(AdvnROMFilePage)) and (CurPageID = AdvnROMFilePage.ID) then - Result := not (AdvnROMFilePage.Values[0] = '') - else - Result := True; -end; - -function GetROMPath(Param: string): string; -begin - if Length(lttprom) > 0 then - Result := lttprom - else if Assigned(LttPRomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') - if R <> 0 then - MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := LttPROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetSMROMPath(Param: string): string; -begin - if Length(smrom) > 0 then - Result := smrom - else if Assigned(SMRomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') - if R <> 0 then - MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := SMROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetDKC3ROMPath(Param: string): string; -begin - if Length(dkc3rom) > 0 then - Result := dkc3rom - else if Assigned(DKC3RomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(DKC3ROMFilePage.Values[0]), '120abf304f0c40fe059f6a192ed4f947') - if R <> 0 then - MsgBox('Donkey Kong Country 3 ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := DKC3ROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetSMWROMPath(Param: string): string; -begin - if Length(smwrom) > 0 then - Result := smwrom - else if Assigned(SMWRomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804') - if R <> 0 then - MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := SMWROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetSoEROMPath(Param: string): string; -begin - if Length(soerom) > 0 then - Result := soerom - else if Assigned(SoERomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') - if R <> 0 then - MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := SoEROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetOoTROMPath(Param: string): string; -begin - if Length(ootrom) > 0 then - Result := ootrom - else if Assigned(OoTROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f'); - if R <> 0 then - MsgBox('OoT ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := OoTROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetL2ACROMPath(Param: string): string; -begin - if Length(l2acrom) > 0 then - Result := l2acrom - else if Assigned(L2ACROMFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d') - if R <> 0 then - MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := L2ACROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetZlROMPath(Param: string): string; -begin - if Length(zlrom) > 0 then - Result := zlrom - else if Assigned(ZlROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270'); - if R <> 0 then - MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := ZlROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetRedROMPath(Param: string): string; -begin - if Length(redrom) > 0 then - Result := redrom - else if Assigned(RedROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc') - if R <> 0 then - MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := RedROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetBlueROMPath(Param: string): string; -begin - if Length(bluerom) > 0 then - Result := bluerom - else if Assigned(BlueROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b') - if R <> 0 then - MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := BlueROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetTLoZROMPath(Param: string): string; -begin - if Length(tlozrom) > 0 then - Result := tlozrom - else if Assigned(TLoZROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(TLoZROMFilePage.Values[0]), '337bd6f1a1163df31bf2633665589ab0'); - if R <> 0 then - MsgBox('The Legend of Zelda ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := TLoZROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetLADXROMPath(Param: string): string; -begin - if Length(ladxrom) > 0 then - Result := ladxrom - else if Assigned(LADXROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(LADXROMFilePage.Values[0]), '07c211479386825042efb4ad31bb525f') - if R <> 0 then - MsgBox('Link''s Awakening DX ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := LADXROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetAdvnROMPath(Param: string): string; -begin - if Length(advnrom) > 0 then - Result := advnrom - else if Assigned(AdvnROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(AdvnROMFilePage.Values[0]), '157bddb7192754a45372be196797f284'); - if R <> 0 then - MsgBox('Adventure ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := AdvnROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetBN3ROMPath(Param: string): string; -begin - if Length(bn3rom) > 0 then - Result := bn3rom - else if Assigned(BN3ROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(BN3ROMFilePage.Values[0]), '6fe31df0144759b34ad666badaacc442') - if R <> 0 then - MsgBox('MegaMan Battle Network 3 Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := BN3ROMFilePage.Values[0] - end - else - Result := ''; - end; - -procedure InitializeWizard(); -begin - AddOoTRomPage(); - - lttprom := CheckRom('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', '03a63945398191337e896e5771f77173'); - if Length(lttprom) = 0 then - LttPROMFilePage:= AddRomPage('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc'); - - smrom := CheckRom('Super Metroid (JU).sfc', '21f3e98df4780ee1c667b84e57d88675'); - if Length(smrom) = 0 then - SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc'); - - dkc3rom := CheckRom('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc', '120abf304f0c40fe059f6a192ed4f947'); - if Length(dkc3rom) = 0 then - DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc'); - - smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804'); - if Length(smwrom) = 0 then - SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc'); - - soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a'); - if Length(soerom) = 0 then - SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc'); - - zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270'); - if Length(zlrom) = 0 then - ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms'); - - redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc'); - if Length(redrom) = 0 then - RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb'); - - bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b'); - if Length(bluerom) = 0 then - BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb'); - - bn3rom := CheckRom('Mega Man Battle Network 3 - Blue Version (USA).gba','6fe31df0144759b34ad666badaacc442'); - if Length(bn3rom) = 0 then - BN3ROMFilePage:= AddGBARomPage('Mega Man Battle Network 3 - Blue Version (USA).gba'); - - ladxrom := CheckRom('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc','07c211479386825042efb4ad31bb525f'); - if Length(ladxrom) = 0 then - LADXROMFilePage:= AddGBRomPage('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc'); - - l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d'); - if Length(l2acrom) = 0 then - L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc'); - - tlozrom := CheckNESROM('Legend of Zelda, The (U) (PRG0) [!].nes', '337bd6f1a1163df31bf2633665589ab0'); - if Length(tlozrom) = 0 then - TLoZROMFilePage:= AddNESRomPage('Legend of Zelda, The (U) (PRG0) [!].nes'); - - advnrom := CheckSMSRom('ADVNTURE.BIN', '157bddb7192754a45372be196797f284'); - if Length(advnrom) = 0 then - AdvnROMFilePage:= AddA26Page('ADVNTURE.BIN'); -end; - - -function ShouldSkipPage(PageID: Integer): Boolean; -begin - Result := False; - if (assigned(LttPROMFilePage)) and (PageID = LttPROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp')); - if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm')); - if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3')); - if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw')); - if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac')); - if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/soe')); - if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot')); - if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl')); - if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red')); - if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue')); - if (assigned(BN3ROMFilePage)) and (PageID = BN3ROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/mmbn3') or WizardIsComponentSelected('client/mmbn3')); - if (assigned(LADXROMFilePage)) and (PageID = LADXROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/ladx') or WizardIsComponentSelected('client/ladx')); - if (assigned(TLoZROMFilePage)) and (PageID = TLoZROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/tloz') or WizardIsComponentSelected('client/tloz')); - if (assigned(AdvnROMFilePage)) and (PageID = AdvnROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/advn')); -end; diff --git a/pytest.ini b/pytest.ini index 5599a3c90f..33e0bab8a9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] -python_files = Test*.py +python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported python_classes = Test -python_functions = test \ No newline at end of file +python_functions = test diff --git a/settings.py b/settings.py index a7dcbbf8dd..acae86095c 100644 --- a/settings.py +++ b/settings.py @@ -694,6 +694,25 @@ does nothing if not found snes_rom_start: Union[SnesRomStart, bool] = True +class BizHawkClientOptions(Group): + class EmuHawkPath(UserFilePath): + """ + The location of the EmuHawk you want to auto launch patched ROMs with + """ + is_exe = True + description = "EmuHawk Executable" + + class RomStart(str): + """ + Set this to true to autostart a patched ROM in BizHawk with the connector script, + to false to never open the patched rom automatically, + or to a path to an external program to open the ROM file with that instead. + """ + + emuhawk_path: EmuHawkPath = EmuHawkPath(None) + rom_start: Union[RomStart, bool] = True + + # Top-level group with lazy loading of worlds class Settings(Group): @@ -701,6 +720,7 @@ class Settings(Group): server_options: ServerOptions = ServerOptions() generator: GeneratorOptions = GeneratorOptions() sni_options: SNIOptions = SNIOptions() + bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions() _filename: Optional[str] = None diff --git a/setup.py b/setup.py index 6d4d947dbd..cea60dab83 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,6 @@ non_apworlds: set = { "Clique", "DLCQuest", "Final Fantasy", - "Hylics 2", "Kingdom Hearts 2", "Lufia II Ancient Cave", "Meritous", diff --git a/test/TestBase.py b/test/TestBase.py index e6fbafd95a..bfd92346d3 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -1,311 +1,3 @@ -import typing -import unittest -from argparse import Namespace - -from test.general import gen_steps -from worlds import AutoWorld -from worlds.AutoWorld import call_all - -from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item -from worlds.alttp.Items import ItemFactory - - -class TestBase(unittest.TestCase): - multiworld: MultiWorld - _state_cache = {} - - def get_state(self, items): - if (self.multiworld, tuple(items)) in self._state_cache: - return self._state_cache[self.multiworld, tuple(items)] - state = CollectionState(self.multiworld) - for item in items: - item.classification = ItemClassification.progression - state.collect(item, event=True) - state.sweep_for_events() - state.update_reachable_regions(1) - self._state_cache[self.multiworld, tuple(items)] = state - return state - - def get_path(self, state, region): - def flist_to_iter(node): - while node: - value, node = node - yield value - - from itertools import zip_longest - reversed_path_as_flist = state.path.get(region, (region, None)) - string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) - # Now we combine the flat string list into (region, exit) pairs - pathsiter = iter(string_path_flat) - pathpairs = zip_longest(pathsiter, pathsiter) - return list(pathpairs) - - def run_location_tests(self, access_pool): - for i, (location, access, *item_pool) in enumerate(access_pool): - items = item_pool[0] - all_except = item_pool[1] if len(item_pool) > 1 else None - state = self._get_items(item_pool, all_except) - path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region) - with self.subTest(msg="Reach Location", location=location, access=access, items=items, - all_except=all_except, path=path, entry=i): - - self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, - f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}") - - # check for partial solution - if not all_except and access: # we are not supposed to be able to reach location with partial inventory - for missing_item in item_pool[0]: - with self.subTest(msg="Location reachable without required item", location=location, - items=item_pool[0], missing_item=missing_item, entry=i): - state = self._get_items_partial(item_pool, missing_item) - - self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False, - f"failed {self.multiworld.get_location(location, 1)}: succeeded with " - f"{missing_item} removed from: {item_pool}") - - def run_entrance_tests(self, access_pool): - for i, (entrance, access, *item_pool) in enumerate(access_pool): - items = item_pool[0] - all_except = item_pool[1] if len(item_pool) > 1 else None - state = self._get_items(item_pool, all_except) - path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region) - with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items, - all_except=all_except, path=path, entry=i): - - self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access) - - # check for partial solution - if not all_except and access: # we are not supposed to be able to reach location with partial inventory - for missing_item in item_pool[0]: - with self.subTest(msg="Entrance reachable without required item", entrance=entrance, - items=item_pool[0], missing_item=missing_item, entry=i): - state = self._get_items_partial(item_pool, missing_item) - self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False, - f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}") - - def _get_items(self, item_pool, all_except): - if all_except and len(all_except) > 0: - items = self.multiworld.itempool[:] - items = [item for item in items if - item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)] - items.extend(ItemFactory(item_pool[0], 1)) - else: - items = ItemFactory(item_pool[0], 1) - return self.get_state(items) - - def _get_items_partial(self, item_pool, missing_item): - new_items = item_pool[0].copy() - new_items.remove(missing_item) - items = ItemFactory(new_items, 1) - return self.get_state(items) - - -class WorldTestBase(unittest.TestCase): - options: typing.Dict[str, typing.Any] = {} - multiworld: MultiWorld - - game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore" - auto_construct: typing.ClassVar[bool] = True - """ automatically set up a world for each test in this class """ - - def setUp(self) -> None: - if self.auto_construct: - self.world_setup() - - def world_setup(self, seed: typing.Optional[int] = None) -> None: - if type(self) is WorldTestBase or \ - (hasattr(WorldTestBase, self._testMethodName) - and not self.run_default_tests and - getattr(self, self._testMethodName).__code__ is - getattr(WorldTestBase, self._testMethodName, None).__code__): - return # setUp gets called for tests defined in the base class. We skip world_setup here. - if not hasattr(self, "game"): - raise NotImplementedError("didn't define game name") - self.multiworld = MultiWorld(1) - self.multiworld.game[1] = self.game - self.multiworld.player_name = {1: "Tester"} - self.multiworld.set_seed(seed) - self.multiworld.state = CollectionState(self.multiworld) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items(): - setattr(args, name, { - 1: option.from_any(self.options.get(name, getattr(option, "default"))) - }) - self.multiworld.set_options(args) - for step in gen_steps: - call_all(self.multiworld, step) - - # methods that can be called within tests - def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]], - state: typing.Optional[CollectionState] = None) -> None: - """Collects all pre-placed items and items in the multiworld itempool except those provided""" - if isinstance(item_names, str): - item_names = (item_names,) - if not state: - state = self.multiworld.state - for item in self.multiworld.get_items(): - if item.name not in item_names: - state.collect(item) - - def get_item_by_name(self, item_name: str) -> Item: - """Returns the first item found in placed items, or in the itempool with the matching name""" - for item in self.multiworld.get_items(): - if item.name == item_name: - return item - raise ValueError("No such item") - - def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: - """Returns actual items from the itempool that match the provided name(s)""" - if isinstance(item_names, str): - item_names = (item_names,) - return [item for item in self.multiworld.itempool if item.name in item_names] - - def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: - """ collect all of the items in the item pool that have the given names """ - items = self.get_items_by_name(item_names) - self.collect(items) - return items - - def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: - """Collects the provided item(s) into state""" - if isinstance(items, Item): - items = (items,) - for item in items: - self.multiworld.state.collect(item) - - def remove_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: - """Remove all of the items in the item pool with the given names from state""" - items = self.get_items_by_name(item_names) - self.remove(items) - return items - - def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: - """Removes the provided item(s) from state""" - if isinstance(items, Item): - items = (items,) - for item in items: - if item.location and item.location.event and item.location in self.multiworld.state.events: - self.multiworld.state.events.remove(item.location) - self.multiworld.state.remove(item) - - def can_reach_location(self, location: str) -> bool: - """Determines if the current state can reach the provided location name""" - return self.multiworld.state.can_reach(location, "Location", 1) - - def can_reach_entrance(self, entrance: str) -> bool: - """Determines if the current state can reach the provided entrance name""" - return self.multiworld.state.can_reach(entrance, "Entrance", 1) - - def can_reach_region(self, region: str) -> bool: - """Determines if the current state can reach the provided region name""" - return self.multiworld.state.can_reach(region, "Region", 1) - - def count(self, item_name: str) -> int: - """Returns the amount of an item currently in state""" - return self.multiworld.state.count(item_name, 1) - - def assertAccessDependency(self, - locations: typing.List[str], - possible_items: typing.Iterable[typing.Iterable[str]], - only_check_listed: bool = False) -> None: - """Asserts that the provided locations can't be reached without the listed items but can be reached with any - one of the provided combinations""" - all_items = [item_name for item_names in possible_items for item_name in item_names] - - state = CollectionState(self.multiworld) - self.collect_all_but(all_items, state) - if only_check_listed: - for location in locations: - self.assertFalse(state.can_reach(location, "Location", 1), f"{location} is reachable without {all_items}") - else: - for location in self.multiworld.get_locations(): - loc_reachable = state.can_reach(location, "Location", 1) - self.assertEqual(loc_reachable, location.name not in locations, - f"{location.name} is reachable without {all_items}" if loc_reachable - else f"{location.name} is not reachable without {all_items}") - for item_names in possible_items: - items = self.get_items_by_name(item_names) - for item in items: - state.collect(item) - for location in locations: - self.assertTrue(state.can_reach(location, "Location", 1), - f"{location} not reachable with {item_names}") - for item in items: - state.remove(item) - - def assertBeatable(self, beatable: bool): - """Asserts that the game can be beaten with the current state""" - self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable) - - # following tests are automatically run - @property - def run_default_tests(self) -> bool: - """Not possible or identical to the base test that's always being run already""" - return (self.options - or self.setUp.__code__ is not WorldTestBase.setUp.__code__ - or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__) - - @property - def constructed(self) -> bool: - """A multiworld has been constructed by this point""" - return hasattr(self, "game") and hasattr(self, "multiworld") - - def testAllStateCanReachEverything(self): - """Ensure all state can reach everything and complete the game with the defined options""" - if not (self.run_default_tests and self.constructed): - return - with self.subTest("Game", game=self.game): - excluded = self.multiworld.exclude_locations[1].value - state = self.multiworld.get_all_state(False) - for location in self.multiworld.get_locations(): - if location.name not in excluded: - with self.subTest("Location should be reached", location=location): - reachable = location.can_reach(state) - self.assertTrue(reachable, f"{location.name} unreachable") - with self.subTest("Beatable"): - self.multiworld.state = state - self.assertBeatable(True) - - def testEmptyStateCanReachSomething(self): - """Ensure empty state can reach at least one location with the defined options""" - if not (self.run_default_tests and self.constructed): - return - with self.subTest("Game", game=self.game): - state = CollectionState(self.multiworld) - locations = self.multiworld.get_reachable_locations(state, 1) - self.assertGreater(len(locations), 0, - "Need to be able to reach at least one location to get started.") - - def testFill(self): - """Generates a multiworld and validates placements with the defined options""" - # don't run this test if accessibility is set manually - if not (self.run_default_tests and self.constructed): - return - from Fill import distribute_items_restrictive - - # basically a shortened reimplementation of this method from core, in order to force the check is done - def fulfills_accessibility(): - locations = self.multiworld.get_locations(1).copy() - state = CollectionState(self.multiworld) - while locations: - sphere: typing.List[Location] = [] - for n in range(len(locations) - 1, -1, -1): - if locations[n].can_reach(state): - sphere.append(locations.pop(n)) - self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal", - f"Unreachable locations: {locations}") - if not sphere: - break - for location in sphere: - if location.item: - state.collect(location.item, True, location) - - return self.multiworld.has_beaten_game(state, 1) - - with self.subTest("Game", game=self.game): - distribute_items_restrictive(self.multiworld) - call_all(self.multiworld, "post_fill") - self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.") - placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code] - self.assertLessEqual(len(self.multiworld.itempool), len(placed_items), - "Unplaced Items remaining in itempool") +from .bases import TestBase, WorldTestBase +from warnings import warn +warn("TestBase was renamed to bases", DeprecationWarning) diff --git a/test/bases.py b/test/bases.py new file mode 100644 index 0000000000..5fe4df2014 --- /dev/null +++ b/test/bases.py @@ -0,0 +1,309 @@ +import typing +import unittest +from argparse import Namespace + +from test.general import gen_steps +from worlds import AutoWorld +from worlds.AutoWorld import call_all + +from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item +from worlds.alttp.Items import ItemFactory + + +class TestBase(unittest.TestCase): + multiworld: MultiWorld + _state_cache = {} + + def get_state(self, items): + if (self.multiworld, tuple(items)) in self._state_cache: + return self._state_cache[self.multiworld, tuple(items)] + state = CollectionState(self.multiworld) + for item in items: + item.classification = ItemClassification.progression + state.collect(item, event=True) + state.sweep_for_events() + state.update_reachable_regions(1) + self._state_cache[self.multiworld, tuple(items)] = state + return state + + def get_path(self, state, region): + def flist_to_iter(node): + while node: + value, node = node + yield value + + from itertools import zip_longest + reversed_path_as_flist = state.path.get(region, (region, None)) + string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) + # Now we combine the flat string list into (region, exit) pairs + pathsiter = iter(string_path_flat) + pathpairs = zip_longest(pathsiter, pathsiter) + return list(pathpairs) + + def run_location_tests(self, access_pool): + for i, (location, access, *item_pool) in enumerate(access_pool): + items = item_pool[0] + all_except = item_pool[1] if len(item_pool) > 1 else None + state = self._get_items(item_pool, all_except) + path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region) + with self.subTest(msg="Reach Location", location=location, access=access, items=items, + all_except=all_except, path=path, entry=i): + + self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, + f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}") + + # check for partial solution + if not all_except and access: # we are not supposed to be able to reach location with partial inventory + for missing_item in item_pool[0]: + with self.subTest(msg="Location reachable without required item", location=location, + items=item_pool[0], missing_item=missing_item, entry=i): + state = self._get_items_partial(item_pool, missing_item) + + self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False, + f"failed {self.multiworld.get_location(location, 1)}: succeeded with " + f"{missing_item} removed from: {item_pool}") + + def run_entrance_tests(self, access_pool): + for i, (entrance, access, *item_pool) in enumerate(access_pool): + items = item_pool[0] + all_except = item_pool[1] if len(item_pool) > 1 else None + state = self._get_items(item_pool, all_except) + path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region) + with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items, + all_except=all_except, path=path, entry=i): + + self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access) + + # check for partial solution + if not all_except and access: # we are not supposed to be able to reach location with partial inventory + for missing_item in item_pool[0]: + with self.subTest(msg="Entrance reachable without required item", entrance=entrance, + items=item_pool[0], missing_item=missing_item, entry=i): + state = self._get_items_partial(item_pool, missing_item) + self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False, + f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}") + + def _get_items(self, item_pool, all_except): + if all_except and len(all_except) > 0: + items = self.multiworld.itempool[:] + items = [item for item in items if + item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)] + items.extend(ItemFactory(item_pool[0], 1)) + else: + items = ItemFactory(item_pool[0], 1) + return self.get_state(items) + + def _get_items_partial(self, item_pool, missing_item): + new_items = item_pool[0].copy() + new_items.remove(missing_item) + items = ItemFactory(new_items, 1) + return self.get_state(items) + + +class WorldTestBase(unittest.TestCase): + options: typing.Dict[str, typing.Any] = {} + multiworld: MultiWorld + + game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore" + auto_construct: typing.ClassVar[bool] = True + """ automatically set up a world for each test in this class """ + + def setUp(self) -> None: + if self.auto_construct: + self.world_setup() + + def world_setup(self, seed: typing.Optional[int] = None) -> None: + if type(self) is WorldTestBase or \ + (hasattr(WorldTestBase, self._testMethodName) + and not self.run_default_tests and + getattr(self, self._testMethodName).__code__ is + getattr(WorldTestBase, self._testMethodName, None).__code__): + return # setUp gets called for tests defined in the base class. We skip world_setup here. + if not hasattr(self, "game"): + raise NotImplementedError("didn't define game name") + self.multiworld = MultiWorld(1) + self.multiworld.game[1] = self.game + self.multiworld.player_name = {1: "Tester"} + self.multiworld.set_seed(seed) + self.multiworld.state = CollectionState(self.multiworld) + args = Namespace() + for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items(): + setattr(args, name, { + 1: option.from_any(self.options.get(name, getattr(option, "default"))) + }) + self.multiworld.set_options(args) + for step in gen_steps: + call_all(self.multiworld, step) + + # methods that can be called within tests + def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]], + state: typing.Optional[CollectionState] = None) -> None: + """Collects all pre-placed items and items in the multiworld itempool except those provided""" + if isinstance(item_names, str): + item_names = (item_names,) + if not state: + state = self.multiworld.state + for item in self.multiworld.get_items(): + if item.name not in item_names: + state.collect(item) + + def get_item_by_name(self, item_name: str) -> Item: + """Returns the first item found in placed items, or in the itempool with the matching name""" + for item in self.multiworld.get_items(): + if item.name == item_name: + return item + raise ValueError("No such item") + + def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """Returns actual items from the itempool that match the provided name(s)""" + if isinstance(item_names, str): + item_names = (item_names,) + return [item for item in self.multiworld.itempool if item.name in item_names] + + def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """ collect all of the items in the item pool that have the given names """ + items = self.get_items_by_name(item_names) + self.collect(items) + return items + + def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: + """Collects the provided item(s) into state""" + if isinstance(items, Item): + items = (items,) + for item in items: + self.multiworld.state.collect(item) + + def remove_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """Remove all of the items in the item pool with the given names from state""" + items = self.get_items_by_name(item_names) + self.remove(items) + return items + + def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: + """Removes the provided item(s) from state""" + if isinstance(items, Item): + items = (items,) + for item in items: + if item.location and item.location.event and item.location in self.multiworld.state.events: + self.multiworld.state.events.remove(item.location) + self.multiworld.state.remove(item) + + def can_reach_location(self, location: str) -> bool: + """Determines if the current state can reach the provided location name""" + return self.multiworld.state.can_reach(location, "Location", 1) + + def can_reach_entrance(self, entrance: str) -> bool: + """Determines if the current state can reach the provided entrance name""" + return self.multiworld.state.can_reach(entrance, "Entrance", 1) + + def can_reach_region(self, region: str) -> bool: + """Determines if the current state can reach the provided region name""" + return self.multiworld.state.can_reach(region, "Region", 1) + + def count(self, item_name: str) -> int: + """Returns the amount of an item currently in state""" + return self.multiworld.state.count(item_name, 1) + + def assertAccessDependency(self, + locations: typing.List[str], + possible_items: typing.Iterable[typing.Iterable[str]], + only_check_listed: bool = False) -> None: + """Asserts that the provided locations can't be reached without the listed items but can be reached with any + one of the provided combinations""" + all_items = [item_name for item_names in possible_items for item_name in item_names] + + state = CollectionState(self.multiworld) + self.collect_all_but(all_items, state) + if only_check_listed: + for location in locations: + self.assertFalse(state.can_reach(location, "Location", 1), f"{location} is reachable without {all_items}") + else: + for location in self.multiworld.get_locations(): + loc_reachable = state.can_reach(location, "Location", 1) + self.assertEqual(loc_reachable, location.name not in locations, + f"{location.name} is reachable without {all_items}" if loc_reachable + else f"{location.name} is not reachable without {all_items}") + for item_names in possible_items: + items = self.get_items_by_name(item_names) + for item in items: + state.collect(item) + for location in locations: + self.assertTrue(state.can_reach(location, "Location", 1), + f"{location} not reachable with {item_names}") + for item in items: + state.remove(item) + + def assertBeatable(self, beatable: bool): + """Asserts that the game can be beaten with the current state""" + self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable) + + # following tests are automatically run + @property + def run_default_tests(self) -> bool: + """Not possible or identical to the base test that's always being run already""" + return (self.options + or self.setUp.__code__ is not WorldTestBase.setUp.__code__ + or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__) + + @property + def constructed(self) -> bool: + """A multiworld has been constructed by this point""" + return hasattr(self, "game") and hasattr(self, "multiworld") + + def test_all_state_can_reach_everything(self): + """Ensure all state can reach everything and complete the game with the defined options""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + excluded = self.multiworld.exclude_locations[1].value + state = self.multiworld.get_all_state(False) + for location in self.multiworld.get_locations(): + if location.name not in excluded: + with self.subTest("Location should be reached", location=location): + reachable = location.can_reach(state) + self.assertTrue(reachable, f"{location.name} unreachable") + with self.subTest("Beatable"): + self.multiworld.state = state + self.assertBeatable(True) + + def test_empty_state_can_reach_something(self): + """Ensure empty state can reach at least one location with the defined options""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + state = CollectionState(self.multiworld) + locations = self.multiworld.get_reachable_locations(state, 1) + self.assertGreater(len(locations), 0, + "Need to be able to reach at least one location to get started.") + + def test_fill(self): + """Generates a multiworld and validates placements with the defined options""" + if not (self.run_default_tests and self.constructed): + return + from Fill import distribute_items_restrictive + + # basically a shortened reimplementation of this method from core, in order to force the check is done + def fulfills_accessibility() -> bool: + locations = self.multiworld.get_locations(1).copy() + state = CollectionState(self.multiworld) + while locations: + sphere: typing.List[Location] = [] + for n in range(len(locations) - 1, -1, -1): + if locations[n].can_reach(state): + sphere.append(locations.pop(n)) + self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal", + f"Unreachable locations: {locations}") + if not sphere: + break + for location in sphere: + if location.item: + state.collect(location.item, True, location) + return self.multiworld.has_beaten_game(state, 1) + + with self.subTest("Game", game=self.game, seed=self.multiworld.seed): + distribute_items_restrictive(self.multiworld) + call_all(self.multiworld, "post_fill") + self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.") + placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code] + self.assertLessEqual(len(self.multiworld.itempool), len(placed_items), + "Unplaced Items remaining in itempool") diff --git a/test/general/__init__.py b/test/general/__init__.py index d7ecc95749..5e0f22f4ec 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -8,6 +8,13 @@ gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "g def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_steps) -> MultiWorld: + """ + Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps. + + :param world_type: Type of the world to generate a multiworld for + :param steps: The gen steps that should be called on the generated multiworld before returning. Default calls + steps through pre_fill + """ multiworld = MultiWorld(1) multiworld.game[1] = world_type.game multiworld.player_name = {1: "Tester"} diff --git a/test/general/TestFill.py b/test/general/test_fill.py similarity index 92% rename from test/general/TestFill.py rename to test/general/test_fill.py index 5bb56408a5..feaaa4a79a 100644 --- a/test/general/TestFill.py +++ b/test/general/test_fill.py @@ -72,7 +72,7 @@ class PlayerDefinition(object): return region -def fillRegion(world: MultiWorld, region: Region, items: List[Item]) -> List[Item]: +def fill_region(world: MultiWorld, region: Region, items: List[Item]) -> List[Item]: items = items.copy() while len(items) > 0: location = region.locations.pop(0) @@ -86,7 +86,7 @@ def fillRegion(world: MultiWorld, region: Region, items: List[Item]) -> List[Ite return items -def regionContains(region: Region, item: Item) -> bool: +def region_contains(region: Region, item: Item) -> bool: for location in region.locations: if location.item == item: return True @@ -133,6 +133,7 @@ def names(objs: list) -> Iterable[str]: class TestFillRestrictive(unittest.TestCase): def test_basic_fill(self): + """Tests `fill_restrictive` fills and removes the locations and items from their respective lists""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -150,6 +151,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual([], player1.prog_items) def test_ordered_fill(self): + """Tests `fill_restrictive` fulfills set rules""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) items = player1.prog_items @@ -166,6 +168,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(locations[1].item, items[1]) def test_partial_fill(self): + """Tests that `fill_restrictive` returns unfilled locations""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 3, 2) @@ -191,6 +194,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(player1.locations[0], loc2) def test_minimal_fill(self): + """Test that fill for minimal player can have unreachable items""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -246,6 +250,7 @@ class TestFillRestrictive(unittest.TestCase): f'{item} is unreachable in {item.location}') def test_reversed_fill(self): + """Test a different set of rules can be satisfied""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -264,6 +269,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(loc1.item, item0) def test_multi_step_fill(self): + """Test that fill is able to satisfy multiple spheres""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 4, 4) @@ -288,6 +294,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(locations[3].item, items[3]) def test_impossible_fill(self): + """Test that fill raises an error when it can't place any items""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) items = player1.prog_items @@ -304,6 +311,7 @@ class TestFillRestrictive(unittest.TestCase): player1.locations.copy(), player1.prog_items.copy()) def test_circular_fill(self): + """Test that fill raises an error when it can't place all items""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 3, 3) @@ -324,6 +332,7 @@ class TestFillRestrictive(unittest.TestCase): player1.locations.copy(), player1.prog_items.copy()) def test_competing_fill(self): + """Test that fill raises an error when it can't place items in a way to satisfy the conditions""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -340,6 +349,7 @@ class TestFillRestrictive(unittest.TestCase): player1.locations.copy(), player1.prog_items.copy()) def test_multiplayer_fill(self): + """Test that items can be placed across worlds""" multi_world = generate_multi_world(2) player1 = generate_player_data(multi_world, 1, 2, 2) player2 = generate_player_data(multi_world, 2, 2, 2) @@ -360,6 +370,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(player2.locations[1].item, player2.prog_items[0]) def test_multiplayer_rules_fill(self): + """Test that fill across worlds satisfies the rules""" multi_world = generate_multi_world(2) player1 = generate_player_data(multi_world, 1, 2, 2) player2 = generate_player_data(multi_world, 2, 2, 2) @@ -383,6 +394,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(player2.locations[1].item, player1.prog_items[1]) def test_restrictive_progress(self): + """Test that various spheres with different requirements can be filled""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, prog_item_count=25) items = player1.prog_items.copy() @@ -405,6 +417,7 @@ class TestFillRestrictive(unittest.TestCase): locations, player1.prog_items) def test_swap_to_earlier_location_with_item_rule(self): + """Test that item swap happens and works as intended""" # test for PR#1109 multi_world = generate_multi_world(1) player1 = generate_player_data(multi_world, 1, 4, 4) @@ -430,6 +443,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1") def test_double_sweep(self): + """Test that sweep doesn't duplicate Event items when sweeping""" # test for PR1114 multi_world = generate_multi_world(1) player1 = generate_player_data(multi_world, 1, 1, 1) @@ -445,6 +459,7 @@ class TestFillRestrictive(unittest.TestCase): self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times") def test_correct_item_instance_removed_from_pool(self): + """Test that a placed item gets removed from the submitted pool""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -461,6 +476,7 @@ class TestFillRestrictive(unittest.TestCase): class TestDistributeItemsRestrictive(unittest.TestCase): def test_basic_distribute(self): + """Test that distribute_items_restrictive is deterministic""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -480,6 +496,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertFalse(locations[3].event) def test_excluded_distribute(self): + """Test that distribute_items_restrictive doesn't put advancement items on excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -494,6 +511,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertFalse(locations[2].item.advancement) def test_non_excluded_item_distribute(self): + """Test that useful items aren't placed on excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -508,6 +526,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertEqual(locations[1].item, basic_items[0]) def test_too_many_excluded_distribute(self): + """Test that fill fails if it can't place all progression items due to too many excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -520,6 +539,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertRaises(FillError, distribute_items_restrictive, multi_world) def test_non_excluded_item_must_distribute(self): + """Test that fill fails if it can't place useful items due to too many excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -534,6 +554,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertRaises(FillError, distribute_items_restrictive, multi_world) def test_priority_distribute(self): + """Test that priority locations receive advancement items""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -548,6 +569,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertTrue(locations[3].item.advancement) def test_excess_priority_distribute(self): + """Test that if there's more priority locations than advancement items, they can still fill""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -562,6 +584,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertFalse(locations[3].item.advancement) def test_multiple_world_priority_distribute(self): + """Test that priority fill can be satisfied for multiple worlds""" multi_world = generate_multi_world(3) player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -591,7 +614,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertTrue(player3.locations[3].item.advancement) def test_can_remove_locations_in_fill_hook(self): - + """Test that distribute_items_restrictive calls the fill hook and allows for item and location removal""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -611,6 +634,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertIsNone(removed_location[0].item) def test_seed_robust_to_item_order(self): + """Test deterministic fill""" mw1 = generate_multi_world() gen1 = generate_player_data( mw1, 1, 4, prog_item_count=2, basic_item_count=2) @@ -628,6 +652,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertEqual(gen1.locations[3].item, gen2.locations[3].item) def test_seed_robust_to_location_order(self): + """Test deterministic fill even if locations in a region are reordered""" mw1 = generate_multi_world() gen1 = generate_player_data( mw1, 1, 4, prog_item_count=2, basic_item_count=2) @@ -646,6 +671,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertEqual(gen1.locations[3].item, gen2.locations[3].item) def test_can_reserve_advancement_items_for_general_fill(self): + """Test that priority locations fill still satisfies item rules""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, location_count=5, prog_item_count=5) @@ -655,14 +681,14 @@ class TestDistributeItemsRestrictive(unittest.TestCase): location = player1.locations[0] location.progress_type = LocationProgressType.PRIORITY - location.item_rule = lambda item: item != items[ - 0] and item != items[1] and item != items[2] and item != items[3] + location.item_rule = lambda item: item not in items[:4] distribute_items_restrictive(multi_world) self.assertEqual(location.item, items[4]) def test_non_excluded_local_items(self): + """Test that local items get placed locally in a multiworld""" multi_world = generate_multi_world(2) player1 = generate_player_data( multi_world, 1, location_count=5, basic_item_count=5) @@ -683,6 +709,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertFalse(item.location.event, False) def test_early_items(self) -> None: + """Test that the early items API successfully places items early""" mw = generate_multi_world(2) player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5) player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5) @@ -762,21 +789,22 @@ class TestBalanceMultiworldProgression(unittest.TestCase): # Sphere 1 region = player1.generate_region(player1.menu, 20) - items = fillRegion(multi_world, region, [ + items = fill_region(multi_world, region, [ player1.prog_items[0]] + items) # Sphere 2 region = player1.generate_region( player1.regions[1], 20, lambda state: state.has(player1.prog_items[0].name, player1.id)) - items = fillRegion( + items = fill_region( multi_world, region, [player1.prog_items[1], player2.prog_items[0]] + items) # Sphere 3 region = player2.generate_region( player2.menu, 20, lambda state: state.has(player2.prog_items[0].name, player2.id)) - fillRegion(multi_world, region, [player2.prog_items[1]] + items) + fill_region(multi_world, region, [player2.prog_items[1]] + items) def test_balances_progression(self) -> None: + """Tests that progression balancing moves progression items earlier""" self.multi_world.progression_balancing[self.player1.id].value = 50 self.multi_world.progression_balancing[self.player2.id].value = 50 @@ -789,6 +817,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): self.player1.regions[1], self.player2.prog_items[0]) def test_balances_progression_light(self) -> None: + """Test that progression balancing still moves items earlier on minimum value""" self.multi_world.progression_balancing[self.player1.id].value = 1 self.multi_world.progression_balancing[self.player2.id].value = 1 @@ -802,6 +831,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): self.player1.regions[1], self.player2.prog_items[0]) def test_balances_progression_heavy(self) -> None: + """Test that progression balancing moves items earlier on maximum value""" self.multi_world.progression_balancing[self.player1.id].value = 99 self.multi_world.progression_balancing[self.player2.id].value = 99 @@ -815,6 +845,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): self.player1.regions[1], self.player2.prog_items[0]) def test_skips_balancing_progression(self) -> None: + """Test that progression balancing is skipped when players have it disabled""" self.multi_world.progression_balancing[self.player1.id].value = 0 self.multi_world.progression_balancing[self.player2.id].value = 0 @@ -827,6 +858,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): self.player1.regions[2], self.player2.prog_items[0]) def test_ignores_priority_locations(self) -> None: + """Test that progression items on priority locations don't get moved by balancing""" self.multi_world.progression_balancing[self.player1.id].value = 50 self.multi_world.progression_balancing[self.player2.id].value = 50 diff --git a/test/general/TestHelpers.py b/test/general/test_helpers.py similarity index 90% rename from test/general/TestHelpers.py rename to test/general/test_helpers.py index 17fdce653c..83b56b3438 100644 --- a/test/general/TestHelpers.py +++ b/test/general/test_helpers.py @@ -1,8 +1,7 @@ -from argparse import Namespace -from typing import Dict, Optional, Callable - -from BaseClasses import MultiWorld, CollectionState, Region import unittest +from typing import Callable, Dict, Optional + +from BaseClasses import CollectionState, MultiWorld, Region class TestHelpers(unittest.TestCase): @@ -15,7 +14,8 @@ class TestHelpers(unittest.TestCase): self.multiworld.player_name = {1: "Tester"} self.multiworld.set_seed() - def testRegionHelpers(self) -> None: + def test_region_helpers(self) -> None: + """Tests `Region.add_locations()` and `Region.add_exits()` have correct behavior""" regions: Dict[str, str] = { "TestRegion1": "I'm an apple", "TestRegion2": "I'm a banana", @@ -79,4 +79,5 @@ class TestHelpers(unittest.TestCase): current_region.add_exits(reg_exit_set[region]) exit_names = {_exit.name for _exit in current_region.exits} for reg_exit in reg_exit_set[region]: - self.assertTrue(f"{region} -> {reg_exit}" in exit_names, f"{region} -> {reg_exit} not in {exit_names}") + self.assertTrue(f"{region} -> {reg_exit}" in exit_names, + f"{region} -> {reg_exit} not in {exit_names}") diff --git a/test/general/TestHostYAML.py b/test/general/test_host_yaml.py similarity index 87% rename from test/general/TestHostYAML.py rename to test/general/test_host_yaml.py index f5fd406cac..9408f95b16 100644 --- a/test/general/TestHostYAML.py +++ b/test/general/test_host_yaml.py @@ -15,6 +15,7 @@ class TestIDs(unittest.TestCase): cls.yaml_options = Utils.parse_yaml(f.read()) def test_utils_in_yaml(self) -> None: + """Tests that the auto generated host.yaml has default settings in it""" for option_key, option_set in Utils.get_default_options().items(): with self.subTest(option_key): self.assertIn(option_key, self.yaml_options) @@ -22,6 +23,7 @@ class TestIDs(unittest.TestCase): self.assertIn(sub_option_key, self.yaml_options[option_key]) def test_yaml_in_utils(self) -> None: + """Tests that the auto generated host.yaml shows up in reference calls""" utils_options = Utils.get_default_options() for option_key, option_set in self.yaml_options.items(): with self.subTest(option_key): diff --git a/test/general/TestIDs.py b/test/general/test_ids.py similarity index 82% rename from test/general/TestIDs.py rename to test/general/test_ids.py index db1c9461b9..4edfb8d994 100644 --- a/test/general/TestIDs.py +++ b/test/general/test_ids.py @@ -3,35 +3,37 @@ from worlds.AutoWorld import AutoWorldRegister class TestIDs(unittest.TestCase): - def testUniqueItems(self): + def test_unique_items(self): + """Tests that every game has a unique ID per item in the datapackage""" known_item_ids = set() for gamename, world_type in AutoWorldRegister.world_types.items(): current = len(known_item_ids) known_item_ids |= set(world_type.item_id_to_name) self.assertEqual(len(known_item_ids) - len(world_type.item_id_to_name), current) - def testUniqueLocations(self): + def test_unique_locations(self): + """Tests that every game has a unique ID per location in the datapackage""" known_location_ids = set() for gamename, world_type in AutoWorldRegister.world_types.items(): current = len(known_location_ids) known_location_ids |= set(world_type.location_id_to_name) self.assertEqual(len(known_location_ids) - len(world_type.location_id_to_name), current) - def testRangeItems(self): + def test_range_items(self): """There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): for item_id in world_type.item_id_to_name: self.assertLess(item_id, 2**53) - def testRangeLocations(self): + def test_range_locations(self): """There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): for location_id in world_type.location_id_to_name: self.assertLess(location_id, 2**53) - def testReservedItems(self): + def test_reserved_items(self): """negative item IDs are reserved to the special "Archipelago" world.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): @@ -42,7 +44,7 @@ class TestIDs(unittest.TestCase): for item_id in world_type.item_id_to_name: self.assertGreater(item_id, 0) - def testReservedLocations(self): + def test_reserved_locations(self): """negative location IDs are reserved to the special "Archipelago" world.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): @@ -53,12 +55,14 @@ class TestIDs(unittest.TestCase): for location_id in world_type.location_id_to_name: self.assertGreater(location_id, 0) - def testDuplicateItemIDs(self): + def test_duplicate_item_ids(self): + """Test that a game doesn't have item id overlap within its own datapackage""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id)) - def testDuplicateLocationIDs(self): + def test_duplicate_location_ids(self): + """Test that a game doesn't have location id overlap within its own datapackage""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id)) diff --git a/test/general/TestImplemented.py b/test/general/test_implemented.py similarity index 58% rename from test/general/TestImplemented.py rename to test/general/test_implemented.py index 22c546eff1..b60bcee467 100644 --- a/test/general/TestImplemented.py +++ b/test/general/test_implemented.py @@ -1,11 +1,13 @@ import unittest -from worlds.AutoWorld import AutoWorldRegister +from Fill import distribute_items_restrictive +from NetUtils import encode +from worlds.AutoWorld import AutoWorldRegister, call_all from . import setup_solo_multiworld class TestImplemented(unittest.TestCase): - def testCompletionCondition(self): + def test_completion_condition(self): """Ensure a completion condition is set that has requirements.""" for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden and game_name not in {"Sudoku"}: @@ -13,7 +15,7 @@ class TestImplemented(unittest.TestCase): multiworld = setup_solo_multiworld(world_type) self.assertFalse(multiworld.completion_condition[1](multiworld.state)) - def testEntranceParents(self): + def test_entrance_parents(self): """Tests that the parents of created Entrances match the exiting Region.""" for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: @@ -23,7 +25,7 @@ class TestImplemented(unittest.TestCase): for exit in region.exits: self.assertEqual(exit.parent_region, region) - def testStageMethods(self): + def test_stage_methods(self): """Tests that worlds don't try to implement certain steps that are only ever called as stage.""" for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: @@ -31,3 +33,17 @@ class TestImplemented(unittest.TestCase): for method in ("assert_generate",): self.assertFalse(hasattr(world_type, method), f"{method} must be implemented as a @classmethod named stage_{method}.") + + def test_slot_data(self): + """Tests that if a world creates slot data, it's json serializable.""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + # has an await for generate_output which isn't being called + if game_name in {"Ocarina of Time", "Zillion"}: + continue + with self.subTest(game_name): + multiworld = setup_solo_multiworld(world_type) + distribute_items_restrictive(multiworld) + call_all(multiworld, "post_fill") + for key, data in multiworld.worlds[1].fill_slot_data().items(): + self.assertIsInstance(key, str, "keys in slot data must be a string") + self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.") diff --git a/test/general/TestItems.py b/test/general/test_items.py similarity index 88% rename from test/general/TestItems.py rename to test/general/test_items.py index 95eb8d28d9..464d246e1f 100644 --- a/test/general/TestItems.py +++ b/test/general/test_items.py @@ -4,7 +4,8 @@ from . import setup_solo_multiworld class TestBase(unittest.TestCase): - def testCreateItem(self): + def test_create_item(self): + """Test that a world can successfully create all items in its datapackage""" for game_name, world_type in AutoWorldRegister.world_types.items(): proxy_world = world_type(None, 0) # this is identical to MultiServer.py creating worlds for item_name in world_type.item_name_to_id: @@ -12,7 +13,7 @@ class TestBase(unittest.TestCase): item = proxy_world.create_item(item_name) self.assertEqual(item.name, item_name) - def testItemNameGroupHasValidItem(self): + def test_item_name_group_has_valid_item(self): """Test that all item name groups contain valid items. """ # This cannot test for Event names that you may have declared for logic, only sendable Items. # In such a case, you can add your entries to this Exclusion dict. Game Name -> Group Names @@ -33,7 +34,7 @@ class TestBase(unittest.TestCase): for item in items: self.assertIn(item, world_type.item_name_to_id) - def testItemNameGroupConflict(self): + def test_item_name_group_conflict(self): """Test that all item name groups aren't also item names.""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game_name, game_name=game_name): @@ -41,7 +42,8 @@ class TestBase(unittest.TestCase): with self.subTest(group_name, group_name=group_name): self.assertNotIn(group_name, world_type.item_name_to_id) - def testItemCountGreaterEqualLocations(self): + def test_item_count_greater_equal_locations(self): + """Test that by the pre_fill step under default settings, each game submits items >= locations""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): multiworld = setup_solo_multiworld(world_type) diff --git a/test/general/TestLocations.py b/test/general/test_locations.py similarity index 96% rename from test/general/TestLocations.py rename to test/general/test_locations.py index e77e7a6332..2e609a756f 100644 --- a/test/general/TestLocations.py +++ b/test/general/test_locations.py @@ -5,7 +5,7 @@ from . import setup_solo_multiworld class TestBase(unittest.TestCase): - def testCreateDuplicateLocations(self): + def test_create_duplicate_locations(self): """Tests that no two Locations share a name or ID.""" for game_name, world_type in AutoWorldRegister.world_types.items(): multiworld = setup_solo_multiworld(world_type) @@ -20,7 +20,7 @@ class TestBase(unittest.TestCase): self.assertLessEqual(locations.most_common(1)[0][1], 1, f"{world_type.game} has duplicate of location ID {locations.most_common(1)}") - def testLocationsInDatapackage(self): + def test_locations_in_datapackage(self): """Tests that created locations not filled before fill starts exist in the datapackage.""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game_name=game_name): @@ -30,7 +30,7 @@ class TestBase(unittest.TestCase): self.assertIn(location.name, world_type.location_name_to_id) self.assertEqual(location.address, world_type.location_name_to_id[location.name]) - def testLocationCreationSteps(self): + def test_location_creation_steps(self): """Tests that Regions and Locations aren't created after `create_items`.""" gen_steps = ("generate_early", "create_regions", "create_items") for game_name, world_type in AutoWorldRegister.world_types.items(): @@ -60,7 +60,7 @@ class TestBase(unittest.TestCase): self.assertGreaterEqual(location_count, len(multiworld.get_locations()), f"{game_name} modified locations count during pre_fill") - def testLocationGroup(self): + def test_location_group(self): """Test that all location name groups contain valid locations and don't share names.""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game_name, game_name=game_name): diff --git a/test/general/TestNames.py b/test/general/test_names.py similarity index 92% rename from test/general/TestNames.py rename to test/general/test_names.py index 6dae53240d..7be76eed4b 100644 --- a/test/general/TestNames.py +++ b/test/general/test_names.py @@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister class TestNames(unittest.TestCase): - def testItemNamesFormat(self): + def test_item_names_format(self): """Item names must not be all numeric in order to differentiate between ID and name in !hint""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): @@ -11,7 +11,7 @@ class TestNames(unittest.TestCase): self.assertFalse(item_name.isnumeric(), f"Item name \"{item_name}\" is invalid. It must not be numeric.") - def testLocationNameFormat(self): + def test_location_name_format(self): """Location names must not be all numeric in order to differentiate between ID and name in !hint_location""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): diff --git a/test/general/TestOptions.py b/test/general/test_options.py similarity index 78% rename from test/general/TestOptions.py rename to test/general/test_options.py index 4a3bd0b02a..e1136f93c9 100644 --- a/test/general/TestOptions.py +++ b/test/general/test_options.py @@ -3,7 +3,8 @@ from worlds.AutoWorld import AutoWorldRegister class TestOptions(unittest.TestCase): - def testOptionsHaveDocString(self): + def test_options_have_doc_string(self): + """Test that submitted options have their own specified docstring""" for gamename, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: for option_key, option in world_type.options_dataclass.type_hints.items(): diff --git a/test/general/TestReachability.py b/test/general/test_reachability.py similarity index 91% rename from test/general/TestReachability.py rename to test/general/test_reachability.py index dd786b8352..828912ee35 100644 --- a/test/general/TestReachability.py +++ b/test/general/test_reachability.py @@ -31,7 +31,8 @@ class TestBase(unittest.TestCase): } } - def testDefaultAllStateCanReachEverything(self): + def test_default_all_state_can_reach_everything(self): + """Ensure all state can reach everything and complete the game with the defined options""" for game_name, world_type in AutoWorldRegister.world_types.items(): unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set()) with self.subTest("Game", game=game_name): @@ -54,7 +55,8 @@ class TestBase(unittest.TestCase): with self.subTest("Completion Condition"): self.assertTrue(world.can_beat_game(state)) - def testDefaultEmptyStateCanReachSomething(self): + def test_default_empty_state_can_reach_something(self): + """Ensure empty state can reach at least one location with the defined options""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): world = setup_solo_multiworld(world_type) diff --git a/test/netutils/TestLocationStore.py b/test/netutils/test_location_store.py similarity index 100% rename from test/netutils/TestLocationStore.py rename to test/netutils/test_location_store.py diff --git a/test/programs/data/OnePlayer/test.yaml b/test/programs/data/one_player/test.yaml similarity index 100% rename from test/programs/data/OnePlayer/test.yaml rename to test/programs/data/one_player/test.yaml diff --git a/test/programs/TestGenerate.py b/test/programs/test_generate.py similarity index 98% rename from test/programs/TestGenerate.py rename to test/programs/test_generate.py index 73e1d3b834..887a417ec9 100644 --- a/test/programs/TestGenerate.py +++ b/test/programs/test_generate.py @@ -16,7 +16,7 @@ class TestGenerateMain(unittest.TestCase): generate_dir = Path(Generate.__file__).parent run_dir = generate_dir / "test" # reproducible cwd that's neither __file__ nor Generate.__file__ - abs_input_dir = Path(__file__).parent / 'data' / 'OnePlayer' + abs_input_dir = Path(__file__).parent / 'data' / 'one_player' rel_input_dir = abs_input_dir.relative_to(run_dir) # directly supplied relative paths are relative to cwd yaml_input_dir = abs_input_dir.relative_to(generate_dir) # yaml paths are relative to user_path diff --git a/test/programs/TestMultiServer.py b/test/programs/test_multi_server.py similarity index 100% rename from test/programs/TestMultiServer.py rename to test/programs/test_multi_server.py diff --git a/test/utils/TestSIPrefix.py b/test/utils/test_si_prefix.py similarity index 100% rename from test/utils/TestSIPrefix.py rename to test/utils/test_si_prefix.py diff --git a/test/webhost/TestAPIGenerate.py b/test/webhost/test_api_generate.py similarity index 93% rename from test/webhost/TestAPIGenerate.py rename to test/webhost/test_api_generate.py index 8ea78f27f9..b8bdcb38c7 100644 --- a/test/webhost/TestAPIGenerate.py +++ b/test/webhost/test_api_generate.py @@ -19,11 +19,11 @@ class TestDocs(unittest.TestCase): cls.client = app.test_client() - def testCorrectErrorEmptyRequest(self): + def test_correct_error_empty_request(self): response = self.client.post("/api/generate") self.assertIn("No options found. Expected file attachment or json weights.", response.text) - def testGenerationQueued(self): + def test_generation_queued(self): options = { "Tester1": { diff --git a/test/webhost/TestDocs.py b/test/webhost/test_docs.py similarity index 96% rename from test/webhost/TestDocs.py rename to test/webhost/test_docs.py index f6ede1543e..68aba05f9d 100644 --- a/test/webhost/TestDocs.py +++ b/test/webhost/test_docs.py @@ -11,7 +11,7 @@ class TestDocs(unittest.TestCase): def setUpClass(cls) -> None: cls.tutorials_data = WebHost.create_ordered_tutorials_file() - def testHasTutorial(self): + def test_has_tutorial(self): games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data) for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: @@ -27,7 +27,7 @@ class TestDocs(unittest.TestCase): self.fail(f"{game_name} has no setup tutorial. " f"Games with Tutorial: {games_with_tutorial}") - def testHasGameInfo(self): + def test_has_game_info(self): for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game_name) diff --git a/test/webhost/TestFileGeneration.py b/test/webhost/test_file_generation.py similarity index 96% rename from test/webhost/TestFileGeneration.py rename to test/webhost/test_file_generation.py index f01b70e14f..059f6b49a1 100644 --- a/test/webhost/TestFileGeneration.py +++ b/test/webhost/test_file_generation.py @@ -13,7 +13,7 @@ class TestFileGeneration(unittest.TestCase): # should not create the folder *here* cls.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib") - def testOptions(self): + def test_options(self): from WebHostLib.options import create as create_options_files create_options_files() target = os.path.join(self.correct_path, "static", "generated", "configs") @@ -30,7 +30,7 @@ class TestFileGeneration(unittest.TestCase): for value in roll_options({file.name: f.read()})[0].values(): self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.") - def testTutorial(self): + def test_tutorial(self): WebHost.create_ordered_tutorials_file() self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json"))) self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json"))) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 9a8b6a56ef..d4fe0f49a2 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -149,7 +149,7 @@ def call_stage(multiworld: "MultiWorld", method_name: str, *args: Any) -> None: class WebWorld: """Webhost integration""" - settings_page: Union[bool, str] = True + options_page: Union[bool, str] = True """display a settings page. Can be a link to a specific page or external tool.""" game_info_languages: List[str] = ['en'] diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 2d445a77b8..c3ae2b0495 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -89,9 +89,6 @@ components: List[Component] = [ Component('SNI Client', 'SNIClient', file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw', '.apl2ac')), - # BizHawk - Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, - file_identifier=SuffixIdentifier()), Component('Links Awakening DX Client', 'LinksAwakeningClient', file_identifier=SuffixIdentifier('.apladx')), Component('LttP Adjuster', 'LttPAdjuster'), diff --git a/worlds/_bizhawk/__init__.py b/worlds/_bizhawk/__init__.py index cdf227ec7b..3403990832 100644 --- a/worlds/_bizhawk/__init__.py +++ b/worlds/_bizhawk/__init__.py @@ -13,7 +13,6 @@ import typing BIZHAWK_SOCKET_PORT = 43055 -EXPECTED_SCRIPT_VERSION = 1 class ConnectionStatus(enum.IntEnum): @@ -22,15 +21,6 @@ class ConnectionStatus(enum.IntEnum): CONNECTED = 3 -class BizHawkContext: - streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]] - connection_status: ConnectionStatus - - def __init__(self) -> None: - self.streams = None - self.connection_status = ConnectionStatus.NOT_CONNECTED - - class NotConnectedError(Exception): """Raised when something tries to make a request to the connector script before a connection has been established""" pass @@ -51,6 +41,50 @@ class SyncError(Exception): pass +class BizHawkContext: + streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]] + connection_status: ConnectionStatus + _lock: asyncio.Lock + + def __init__(self) -> None: + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + self._lock = asyncio.Lock() + + async def _send_message(self, message: str): + async with self._lock: + if self.streams is None: + raise NotConnectedError("You tried to send a request before a connection to BizHawk was made") + + try: + reader, writer = self.streams + writer.write(message.encode("utf-8") + b"\n") + await asyncio.wait_for(writer.drain(), timeout=5) + + res = await asyncio.wait_for(reader.readline(), timeout=5) + + if res == b"": + writer.close() + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection closed") + + if self.connection_status == ConnectionStatus.TENTATIVE: + self.connection_status = ConnectionStatus.CONNECTED + + return res.decode("utf-8") + except asyncio.TimeoutError as exc: + writer.close() + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection timed out") from exc + except ConnectionResetError as exc: + writer.close() + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection reset") from exc + + async def connect(ctx: BizHawkContext) -> bool: """Attempts to establish a connection with the connector script. Returns True if successful.""" try: @@ -72,74 +106,14 @@ def disconnect(ctx: BizHawkContext) -> None: async def get_script_version(ctx: BizHawkContext) -> int: - if ctx.streams is None: - raise NotConnectedError("You tried to send a request before a connection to BizHawk was made") - - try: - reader, writer = ctx.streams - writer.write("VERSION".encode("ascii") + b"\n") - await asyncio.wait_for(writer.drain(), timeout=5) - - version = await asyncio.wait_for(reader.readline(), timeout=5) - - if version == b"": - writer.close() - ctx.streams = None - ctx.connection_status = ConnectionStatus.NOT_CONNECTED - raise RequestFailedError("Connection closed") - - return int(version.decode("ascii")) - except asyncio.TimeoutError as exc: - writer.close() - ctx.streams = None - ctx.connection_status = ConnectionStatus.NOT_CONNECTED - raise RequestFailedError("Connection timed out") from exc - except ConnectionResetError as exc: - writer.close() - ctx.streams = None - ctx.connection_status = ConnectionStatus.NOT_CONNECTED - raise RequestFailedError("Connection reset") from exc + return int(await ctx._send_message("VERSION")) async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]: """Sends a list of requests to the BizHawk connector and returns their responses. It's likely you want to use the wrapper functions instead of this.""" - if ctx.streams is None: - raise NotConnectedError("You tried to send a request before a connection to BizHawk was made") - - try: - reader, writer = ctx.streams - writer.write(json.dumps(req_list).encode("utf-8") + b"\n") - await asyncio.wait_for(writer.drain(), timeout=5) - - res = await asyncio.wait_for(reader.readline(), timeout=5) - - if res == b"": - writer.close() - ctx.streams = None - ctx.connection_status = ConnectionStatus.NOT_CONNECTED - raise RequestFailedError("Connection closed") - - if ctx.connection_status == ConnectionStatus.TENTATIVE: - ctx.connection_status = ConnectionStatus.CONNECTED - - ret = json.loads(res.decode("utf-8")) - for response in ret: - if response["type"] == "ERROR": - raise ConnectorError(response["err"]) - - return ret - except asyncio.TimeoutError as exc: - writer.close() - ctx.streams = None - ctx.connection_status = ConnectionStatus.NOT_CONNECTED - raise RequestFailedError("Connection timed out") from exc - except ConnectionResetError as exc: - writer.close() - ctx.streams = None - ctx.connection_status = ConnectionStatus.NOT_CONNECTED - raise RequestFailedError("Connection reset") from exc + return json.loads(await ctx._send_message(json.dumps(req_list))) async def ping(ctx: BizHawkContext) -> None: diff --git a/worlds/_bizhawk/client.py b/worlds/_bizhawk/client.py index b614c083ba..32a6e3704e 100644 --- a/worlds/_bizhawk/client.py +++ b/worlds/_bizhawk/client.py @@ -16,12 +16,22 @@ else: BizHawkClientContext = object +def launch_client(*args) -> None: + from .context import launch + launch_subprocess(launch, name="BizHawkClient") + +component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, + file_identifier=SuffixIdentifier()) +components.append(component) + + class AutoBizHawkClientRegister(abc.ABCMeta): game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {} def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister: new_class = super().__new__(cls, name, bases, namespace) + # Register handler if "system" in namespace: systems = (namespace["system"],) if type(namespace["system"]) is str else tuple(sorted(namespace["system"])) if systems not in AutoBizHawkClientRegister.game_handlers: @@ -30,6 +40,19 @@ class AutoBizHawkClientRegister(abc.ABCMeta): if "game" in namespace: AutoBizHawkClientRegister.game_handlers[systems][namespace["game"]] = new_class() + # Update launcher component's suffixes + if "patch_suffix" in namespace: + if namespace["patch_suffix"] is not None: + existing_identifier: SuffixIdentifier = component.file_identifier + new_suffixes = [*existing_identifier.suffixes] + + if type(namespace["patch_suffix"]) is str: + new_suffixes.append(namespace["patch_suffix"]) + else: + new_suffixes.extend(namespace["patch_suffix"]) + + component.file_identifier = SuffixIdentifier(*new_suffixes) + return new_class @staticmethod @@ -45,11 +68,14 @@ class AutoBizHawkClientRegister(abc.ABCMeta): class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister): system: ClassVar[Union[str, Tuple[str, ...]]] - """The system that the game this client is for runs on""" + """The system(s) that the game this client is for runs on""" game: ClassVar[str] """The game this client is for""" + patch_suffix: ClassVar[Optional[Union[str, Tuple[str, ...]]]] + """The file extension(s) this client is meant to open and patch (e.g. ".apz3")""" + @abc.abstractmethod async def validate_rom(self, ctx: BizHawkClientContext) -> bool: """Should return whether the currently loaded ROM should be handled by this client. You might read the game name @@ -75,13 +101,3 @@ class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister): def on_package(self, ctx: BizHawkClientContext, cmd: str, args: dict) -> None: """For handling packages from the server. Called from `BizHawkClientContext.on_package`.""" pass - - -def launch_client(*args) -> None: - from .context import launch - launch_subprocess(launch, name="BizHawkClient") - - -if not any(component.script_name == "BizHawkClient" for component in components): - components.append(Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, - file_identifier=SuffixIdentifier())) diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index 6e53b370af..5d865f3321 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -5,6 +5,7 @@ checking or launching the client, otherwise it will probably cause circular impo import asyncio +import subprocess import traceback from typing import Any, Dict, Optional @@ -12,8 +13,8 @@ from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, import Patch import Utils -from . import BizHawkContext, ConnectionStatus, RequestFailedError, connect, disconnect, get_hash, get_script_version, \ - get_system, ping +from . import BizHawkContext, ConnectionStatus, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \ + get_script_version, get_system, ping from .client import BizHawkClient, AutoBizHawkClientRegister @@ -132,6 +133,8 @@ async def _game_watcher(ctx: BizHawkClientContext): except RequestFailedError as exc: logger.info(f"Lost connection to BizHawk: {exc.args[0]}") continue + except NotConnectedError: + continue # Get slot name and send `Connect` if ctx.server is not None and ctx.username is None: @@ -146,8 +149,24 @@ async def _game_watcher(ctx: BizHawkClientContext): async def _run_game(rom: str): - import webbrowser - webbrowser.open(rom) + import os + auto_start = Utils.get_settings().bizhawkclient_options.rom_start + + if auto_start is True: + emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path + subprocess.Popen([emuhawk_path, "--lua=data/lua/connector_bizhawk_generic.lua", os.path.realpath(rom)], + cwd=Utils.local_path("."), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + elif isinstance(auto_start, str): + import shlex + + subprocess.Popen([*shlex.split(auto_start), os.path.realpath(rom)], + cwd=Utils.local_path("."), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) async def _patch_and_run_game(patch_file: str): diff --git a/worlds/alttp/InvertedRegions.py b/worlds/alttp/InvertedRegions.py index ffa23881d3..f89eebec33 100644 --- a/worlds/alttp/InvertedRegions.py +++ b/worlds/alttp/InvertedRegions.py @@ -477,8 +477,6 @@ def create_inverted_regions(world, player): create_lw_region(world, player, 'Death Mountain Bunny Descent Area') ] - world.initialize_regions() - def mark_dark_world_regions(world, player): # cross world caves may have some sections marked as both in_light_world, and in_dark_work. diff --git a/worlds/alttp/ItemPool.py b/worlds/alttp/ItemPool.py index f8fdd55ef6..806a420f41 100644 --- a/worlds/alttp/ItemPool.py +++ b/worlds/alttp/ItemPool.py @@ -535,8 +535,6 @@ def set_up_take_anys(world, player): take_any.shop.add_inventory(0, 'Blue Potion', 0, 0) take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0, create_location=True) - world.initialize_regions() - def get_pool_core(world, player: int): shuffle = world.shuffle[player] diff --git a/worlds/alttp/Items.py b/worlds/alttp/Items.py index 40634de8da..18f96b2ddb 100644 --- a/worlds/alttp/Items.py +++ b/worlds/alttp/Items.py @@ -102,7 +102,7 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\ 'Red Pendant': ItemData(IC.progression, 'Crystal', (0x01, 0x32, 0x60, 0x00, 0x69, 0x03), None, None, None, None, None, None, "the red pendant"), 'Triforce': ItemData(IC.progression, None, 0x6A, '\n YOU WIN!', 'and the triforce', 'victorious kid', 'victory for sale', 'fungus for the win', 'greedy boy wins game again', 'the Triforce'), 'Power Star': ItemData(IC.progression, None, 0x6B, 'a small victory', 'and the power star', 'star-struck kid', 'star for sale', 'see stars with shroom', 'mario powers up again', 'a Power Star'), - 'Triforce Piece': ItemData(IC.progression, None, 0x6C, 'a small victory', 'and the thirdforce', 'triangular kid', 'triangle for sale', 'fungus for triangle', 'wise boy has triangle again', 'a Triforce Piece'), + 'Triforce Piece': ItemData(IC.progression_skip_balancing, None, 0x6C, 'a small victory', 'and the thirdforce', 'triangular kid', 'triangle for sale', 'fungus for triangle', 'wise boy has triangle again', 'a Triforce Piece'), 'Crystal 1': ItemData(IC.progression, 'Crystal', (0x02, 0x34, 0x64, 0x40, 0x7F, 0x06), None, None, None, None, None, None, "a blue crystal"), 'Crystal 2': ItemData(IC.progression, 'Crystal', (0x10, 0x34, 0x64, 0x40, 0x79, 0x06), None, None, None, None, None, None, "a blue crystal"), 'Crystal 3': ItemData(IC.progression, 'Crystal', (0x40, 0x34, 0x64, 0x40, 0x6C, 0x06), None, None, None, None, None, None, "a blue crystal"), diff --git a/worlds/alttp/Regions.py b/worlds/alttp/Regions.py index 8311bc3269..0cc8a3d6a7 100644 --- a/worlds/alttp/Regions.py +++ b/worlds/alttp/Regions.py @@ -382,8 +382,6 @@ def create_regions(world, player): create_dw_region(world, player, 'Dark Death Mountain Bunny Descent Area') ] - world.initialize_regions() - def create_lw_region(world: MultiWorld, player: int, name: str, locations=None, exits=None): return _create_region(world, player, name, LTTPRegionType.LightWorld, 'Light World', locations, exits) diff --git a/worlds/alttp/docs/multiworld_de.md b/worlds/alttp/docs/multiworld_de.md index 38009fb58e..8ccd1a87a6 100644 --- a/worlds/alttp/docs/multiworld_de.md +++ b/worlds/alttp/docs/multiworld_de.md @@ -67,7 +67,7 @@ Wenn du eine Option nicht gewählt haben möchtest, setze ihren Wert einfach auf ### Überprüfung deiner YAML-Datei -Wenn man sichergehen will, ob die YAML-Datei funktioniert, kann man dies bei der [YAML Validator](/mysterycheck) Seite +Wenn man sichergehen will, ob die YAML-Datei funktioniert, kann man dies bei der [YAML Validator](/check) Seite tun. ## ein Einzelspielerspiel erstellen diff --git a/worlds/alttp/docs/multiworld_es.md b/worlds/alttp/docs/multiworld_es.md index 8576318bb9..37aeda2a63 100644 --- a/worlds/alttp/docs/multiworld_es.md +++ b/worlds/alttp/docs/multiworld_es.md @@ -82,7 +82,7 @@ debe tener al menos un valor mayor que cero, si no la generación fallará. ### Verificando tu archivo YAML Si quieres validar que tu fichero YAML para asegurarte que funciona correctamente, puedes hacerlo en la pagina -[YAML Validator](/mysterycheck). +[YAML Validator](/check). ## Generar una partida para un jugador diff --git a/worlds/alttp/docs/multiworld_fr.md b/worlds/alttp/docs/multiworld_fr.md index 329ca65375..078a270f08 100644 --- a/worlds/alttp/docs/multiworld_fr.md +++ b/worlds/alttp/docs/multiworld_fr.md @@ -83,7 +83,7 @@ chaque paramètre il faut au moins une option qui soit paramétrée sur un nombr ### Vérifier son fichier YAML Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous pouvez le vérifier sur la page du -[Validateur de YAML](/mysterycheck). +[Validateur de YAML](/check). ## Générer une partie pour un joueur diff --git a/worlds/bk_sudoku/__init__.py b/worlds/bk_sudoku/__init__.py index f914baf066..36d863bb44 100644 --- a/worlds/bk_sudoku/__init__.py +++ b/worlds/bk_sudoku/__init__.py @@ -5,7 +5,7 @@ from ..AutoWorld import WebWorld, World class Bk_SudokuWebWorld(WebWorld): - settings_page = "games/Sudoku/info/en" + options_page = "games/Sudoku/info/en" theme = 'partyTime' tutorials = [ Tutorial( diff --git a/worlds/bumpstik/Regions.py b/worlds/bumpstik/Regions.py index 247d6d61a3..6cddde882a 100644 --- a/worlds/bumpstik/Regions.py +++ b/worlds/bumpstik/Regions.py @@ -23,13 +23,13 @@ def create_regions(world: MultiWorld, player: int): entrance_map = { "Level 1": lambda state: - state.has("Booster Bumper", player, 2) and state.has("Treasure Bumper", player, 9), + state.has("Booster Bumper", player, 1) and state.has("Treasure Bumper", player, 8), "Level 2": lambda state: - state.has("Booster Bumper", player, 3) and state.has("Treasure Bumper", player, 17), + state.has("Booster Bumper", player, 2) and state.has("Treasure Bumper", player, 16), "Level 3": lambda state: - state.has("Booster Bumper", player, 4) and state.has("Treasure Bumper", player, 25), + state.has("Booster Bumper", player, 3) and state.has("Treasure Bumper", player, 24), "Level 4": lambda state: - state.has("Booster Bumper", player, 5) and state.has("Treasure Bumper", player, 33) + state.has("Booster Bumper", player, 5) and state.has("Treasure Bumper", player, 32) } for x, region_name in enumerate(region_map): diff --git a/worlds/bumpstik/__init__.py b/worlds/bumpstik/__init__.py index 9eeb3325e3..c4e65d07b6 100644 --- a/worlds/bumpstik/__init__.py +++ b/worlds/bumpstik/__init__.py @@ -108,7 +108,7 @@ class BumpStikWorld(World): item_pool += self._create_item_in_quantities( name, frequencies[i]) - item_delta = len(location_table) - len(item_pool) - 1 + item_delta = len(location_table) - len(item_pool) if item_delta > 0: item_pool += self._create_item_in_quantities( "Score Bonus", item_delta) @@ -116,13 +116,16 @@ class BumpStikWorld(World): self.multiworld.itempool += item_pool def set_rules(self): - forbid_item(self.multiworld.get_location("Bonus Booster 5", self.player), - "Booster Bumper", self.player) - - def generate_basic(self): - self.multiworld.get_location("Level 5 - Cleared all Hazards", self.player).place_locked_item( - self.create_item(self.get_filler_item_name())) - + for x in range(1, 32): + self.multiworld.get_location(f"Treasure Bumper {x + 1}", self.player).access_rule = \ + lambda state, x = x: state.has("Treasure Bumper", self.player, x) + for x in range(1, 5): + self.multiworld.get_location(f"Bonus Booster {x + 1}", self.player).access_rule = \ + lambda state, x = x: state.has("Booster Bumper", self.player, x) + self.multiworld.get_location("Level 5 - Cleared all Hazards", self.player).access_rule = \ + lambda state: state.has("Hazard Bumper", self.player, 25) + self.multiworld.completion_condition[self.player] = \ lambda state: state.has("Booster Bumper", self.player, 5) and \ state.has("Treasure Bumper", self.player, 32) + diff --git a/worlds/bumpstik/test/TestLogic.py b/worlds/bumpstik/test/TestLogic.py new file mode 100644 index 0000000000..e374b7b1e9 --- /dev/null +++ b/worlds/bumpstik/test/TestLogic.py @@ -0,0 +1,39 @@ +from . import BumpStikTestBase + + +class TestRuleLogic(BumpStikTestBase): + def testLogic(self): + for x in range(1, 33): + if x == 32: + self.assertFalse(self.can_reach_location("Level 5 - Cleared all Hazards")) + + self.collect(self.get_item_by_name("Treasure Bumper")) + if x % 8 == 0: + bb_count = round(x / 8) + + if bb_count < 4: + self.assertFalse(self.can_reach_location(f"Treasure Bumper {x + 1}")) + elif bb_count == 4: + bb_count += 1 + + for y in range(self.count("Booster Bumper"), bb_count): + self.assertTrue(self.can_reach_location(f"Bonus Booster {y + 1}"), + f"BB {y + 1} check not reachable with {self.count('Booster Bumper')} BBs") + if y < 4: + self.assertFalse(self.can_reach_location(f"Bonus Booster {y + 2}"), + f"BB {y + 2} check reachable with {self.count('Treasure Bumper')} TBs") + self.collect(self.get_item_by_name("Booster Bumper")) + + if x < 31: + self.assertFalse(self.can_reach_location(f"Treasure Bumper {x + 2}")) + elif x == 31: + self.assertFalse(self.can_reach_location("Level 5 - 50,000+ Total Points")) + + if x < 32: + self.assertTrue(self.can_reach_location(f"Treasure Bumper {x + 1}"), + f"TB {x + 1} check not reachable with {self.count('Treasure Bumper')} TBs") + elif x == 32: + self.assertTrue(self.can_reach_location("Level 5 - 50,000+ Total Points")) + self.assertFalse(self.can_reach_location("Level 5 - Cleared all Hazards")) + self.collect(self.get_items_by_name("Hazard Bumper")) + self.assertTrue(self.can_reach_location("Level 5 - Cleared all Hazards")) diff --git a/worlds/bumpstik/test/__init__.py b/worlds/bumpstik/test/__init__.py new file mode 100644 index 0000000000..1199d7b8e5 --- /dev/null +++ b/worlds/bumpstik/test/__init__.py @@ -0,0 +1,5 @@ +from test.TestBase import WorldTestBase + + +class BumpStikTestBase(WorldTestBase): + game = "Bumper Stickers" diff --git a/worlds/dkc3/docs/setup_en.md b/worlds/dkc3/docs/setup_en.md index bb10756300..9c4197286e 100644 --- a/worlds/dkc3/docs/setup_en.md +++ b/worlds/dkc3/docs/setup_en.md @@ -50,7 +50,7 @@ them. Player settings page: [Donkey Kong Country 3 Player Settings Page](/games/ ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -validator page: [YAML Validation page](/mysterycheck) +validator page: [YAML Validation page](/check) ## Generating a Single-Player Game diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 61d1be54cb..e7008f7b12 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -11,6 +11,8 @@ from . import Options, data class DLCQuestItem(Item): game: str = "DLCQuest" + coins: int = 0 + coin_suffix: str = "" offset = 120_000 diff --git a/worlds/dlcquest/Regions.py b/worlds/dlcquest/Regions.py index dfb5f6c021..6dad9fc10c 100644 --- a/worlds/dlcquest/Regions.py +++ b/worlds/dlcquest/Regions.py @@ -1,4 +1,5 @@ import math +from typing import List from BaseClasses import Entrance, MultiWorld, Region from . import Options @@ -9,318 +10,181 @@ DLCQuestRegion = ["Movement Pack", "Behind Tree", "Psychological Warfare", "Doub "Double Jump Behind the Tree", "The Forest", "Final Room"] -def add_coin_freemium(region: Region, Coin: int, player: int): - number_coin = f"{Coin} coins freemium" - location_coin = f"{region.name} coins freemium" +def add_coin_lfod(region: Region, coin: int, player: int): + add_coin(region, coin, player, " coins freemium") + + +def add_coin_dlcquest(region: Region, coin: int, player: int): + add_coin(region, coin, player, " coins") + + +def add_coin(region: Region, coin: int, player: int, suffix: str): + number_coin = f"{coin}{suffix}" + location_coin = f"{region.name}{suffix}" location = DLCQuestLocation(player, location_coin, None, region) region.locations.append(location) - location.place_locked_item(create_event(player, number_coin)) + event = create_event(player, number_coin) + event.coins = coin + event.coin_suffix = suffix + location.place_locked_item(event) -def add_coin_dlcquest(region: Region, Coin: int, player: int): - number_coin = f"{Coin} coins" - location_coin = f"{region.name} coins" - location = DLCQuestLocation(player, location_coin, None, region) - region.locations.append(location) - location.place_locked_item(create_event(player, number_coin)) +def create_regions(multiworld: MultiWorld, player: int, world_options: Options.DLCQuestOptions): + region_menu = Region("Menu", player, multiworld) + has_campaign_basic = world_options.campaign == Options.Campaign.option_basic or world_options.campaign == Options.Campaign.option_both + has_campaign_lfod = world_options.campaign == Options.Campaign.option_live_freemium_or_die or world_options.campaign == Options.Campaign.option_both + has_coinsanity = world_options.coinsanity == Options.CoinSanity.option_coin + coin_bundle_size = world_options.coinbundlequantity.value + has_item_shuffle = world_options.item_shuffle == Options.ItemShuffle.option_shuffled + + multiworld.regions.append(region_menu) + + create_regions_basic_campaign(has_campaign_basic, region_menu, has_item_shuffle, has_coinsanity, coin_bundle_size, player, multiworld) + + create_regions_lfod_campaign(coin_bundle_size, has_campaign_lfod, has_coinsanity, has_item_shuffle, multiworld, player, region_menu) -def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQuestOptions): - Regmenu = Region("Menu", player, world) - if (World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign - == Options.Campaign.option_both): - Regmenu.exits += [Entrance(player, "DLC Quest Basic", Regmenu)] - if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or World_Options.campaign - == Options.Campaign.option_both): - Regmenu.exits += [Entrance(player, "Live Freemium or Die", Regmenu)] - world.regions.append(Regmenu) +def create_regions_basic_campaign(has_campaign_basic: bool, region_menu: Region, has_item_shuffle: bool, has_coinsanity: bool, + coin_bundle_size: int, player: int, world: MultiWorld): + if not has_campaign_basic: + return - if (World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign - == Options.Campaign.option_both): + region_menu.exits += [Entrance(player, "DLC Quest Basic", region_menu)] + locations_move_right = ["Movement Pack", "Animation Pack", "Audio Pack", "Pause Menu Pack"] + region_move_right = create_region_and_locations_basic("Move Right", locations_move_right, ["Moving"], player, world, 4) + create_coinsanity_locations_dlc_quest(has_coinsanity, coin_bundle_size, player, region_move_right) + locations_movement_pack = ["Time is Money Pack", "Psychological Warfare Pack", "Armor for your Horse Pack", "Shepherd Sheep"] + locations_movement_pack += conditional_location(has_item_shuffle, "Sword") + create_region_and_locations_basic("Movement Pack", locations_movement_pack, ["Tree", "Cloud"], player, world, 46) + locations_behind_tree = ["Double Jump Pack", "Map Pack", "Between Trees Sheep", "Hole in the Wall Sheep"] + conditional_location(has_item_shuffle, "Gun") + create_region_and_locations_basic("Behind Tree", locations_behind_tree, ["Behind Tree Double Jump", "Forest Entrance"], player, world, 60) + create_region_and_locations_basic("Psychological Warfare", ["West Cave Sheep"], ["Cloud Double Jump"], player, world, 100) + locations_double_jump_left = ["Pet Pack", "Top Hat Pack", "North West Alcove Sheep"] + create_region_and_locations_basic("Double Jump Total Left", locations_double_jump_left, ["Cave Tree", "Cave Roof"], player, world, 50) + create_region_and_locations_basic("Double Jump Total Left Cave", ["Top Hat Sheep"], [], player, world, 9) + create_region_and_locations_basic("Double Jump Total Left Roof", ["North West Ceiling Sheep"], [], player, world, 10) + locations_double_jump_left_ceiling = ["Sexy Outfits Pack", "Double Jump Alcove Sheep", "Sexy Outfits Sheep"] + create_region_and_locations_basic("Double Jump Behind Tree", locations_double_jump_left_ceiling, ["True Double Jump"], player, world, 89) + create_region_and_locations_basic("True Double Jump Behind Tree", ["Double Jump Floating Sheep", "Cutscene Sheep"], [], player, world, 7) + create_region_and_locations_basic("The Forest", ["Gun Pack", "Night Map Pack"], ["Behind Ogre", "Forest Double Jump"], player, world, 171) + create_region_and_locations_basic("The Forest with double Jump", ["The Zombie Pack", "Forest Low Sheep"], ["Forest True Double Jump"], player, world, 76) + create_region_and_locations_basic("The Forest with double Jump Part 2", ["Forest High Sheep"], [], player, world, 203) + region_final_boss_room = create_region_and_locations_basic("The Final Boss Room", ["Finish the Fight Pack"], [], player, world) - Regmoveright = Region("Move Right", player, world, "Start of the basic game") - Locmoveright_name = ["Movement Pack", "Animation Pack", "Audio Pack", "Pause Menu Pack"] - Regmoveright.exits = [Entrance(player, "Moving", Regmoveright)] - Regmoveright.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regmoveright) for - loc_name in Locmoveright_name] - add_coin_dlcquest(Regmoveright, 4, player) - if World_Options.coinsanity == Options.CoinSanity.option_coin: - coin_bundle_needed = math.floor(825 / World_Options.coinbundlequantity) - for i in range(coin_bundle_needed): - item_coin = f"DLC Quest: {World_Options.coinbundlequantity * (i + 1)} Coin" - Regmoveright.locations += [ - DLCQuestLocation(player, item_coin, location_table[item_coin], Regmoveright)] - if 825 % World_Options.coinbundlequantity != 0: - Regmoveright.locations += [ - DLCQuestLocation(player, "DLC Quest: 825 Coin", location_table["DLC Quest: 825 Coin"], - Regmoveright)] - world.regions.append(Regmoveright) + create_victory_event(region_final_boss_room, "Winning Basic", "Victory Basic", player) - Regmovpack = Region("Movement Pack", player, world) - Locmovpack_name = ["Time is Money Pack", "Psychological Warfare Pack", "Armor for your Horse Pack", - "Shepherd Sheep"] - if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: - Locmovpack_name += ["Sword"] - Regmovpack.exits = [Entrance(player, "Tree", Regmovpack), Entrance(player, "Cloud", Regmovpack)] - Regmovpack.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regmovpack) for loc_name - in Locmovpack_name] - add_coin_dlcquest(Regmovpack, 46, player) - world.regions.append(Regmovpack) + connect_entrances_basic(player, world) - Regbtree = Region("Behind Tree", player, world) - Locbtree_name = ["Double Jump Pack", "Map Pack", "Between Trees Sheep", "Hole in the Wall Sheep"] - if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: - Locbtree_name += ["Gun"] - Regbtree.exits = [Entrance(player, "Behind Tree Double Jump", Regbtree), - Entrance(player, "Forest Entrance", Regbtree)] - Regbtree.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regbtree) for loc_name in - Locbtree_name] - add_coin_dlcquest(Regbtree, 60, player) - world.regions.append(Regbtree) - Regpsywarfare = Region("Psychological Warfare", player, world) - Locpsywarfare_name = ["West Cave Sheep"] - Regpsywarfare.exits = [Entrance(player, "Cloud Double Jump", Regpsywarfare)] - Regpsywarfare.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regpsywarfare) for - loc_name in Locpsywarfare_name] - add_coin_dlcquest(Regpsywarfare, 100, player) - world.regions.append(Regpsywarfare) +def create_regions_lfod_campaign(coin_bundle_size, has_campaign_lfod, has_coinsanity, has_item_shuffle, multiworld, player, region_menu): + if not has_campaign_lfod: + return - Regdoubleleft = Region("Double Jump Total Left", player, world) - Locdoubleleft_name = ["Pet Pack", "Top Hat Pack", "North West Alcove Sheep"] - Regdoubleleft.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubleleft) for - loc_name in - Locdoubleleft_name] - Regdoubleleft.exits = [Entrance(player, "Cave Tree", Regdoubleleft), - Entrance(player, "Cave Roof", Regdoubleleft)] - add_coin_dlcquest(Regdoubleleft, 50, player) - world.regions.append(Regdoubleleft) + region_menu.exits += [Entrance(player, "Live Freemium or Die", region_menu)] + locations_lfod_start = ["Particles Pack", "Day One Patch Pack", "Checkpoint Pack", "Incredibly Important Pack", + "Nice Try", "Story is Important", "I Get That Reference!"] + conditional_location(has_item_shuffle, "Wooden Sword") + region_lfod_start = create_region_and_locations_lfod("Freemium Start", locations_lfod_start, ["Vines"], player, multiworld, 50) + create_coinsanity_locations_lfod(has_coinsanity, coin_bundle_size, player, region_lfod_start) + locations_behind_vines = ["Wall Jump Pack", "Health Bar Pack", "Parallax Pack"] + conditional_location(has_item_shuffle, "Pickaxe") + create_region_and_locations_lfod("Behind the Vines", locations_behind_vines, ["Wall Jump Entrance"], player, multiworld, 95) + locations_wall_jump = ["Harmless Plants Pack", "Death of Comedy Pack", "Canadian Dialog Pack", "DLC NPC Pack"] + create_region_and_locations_lfod("Wall Jump", locations_wall_jump, ["Harmless Plants", "Pickaxe Hard Cave"], player, multiworld, 150) + create_region_and_locations_lfod("Fake Ending", ["Cut Content Pack", "Name Change Pack"], ["Name Change Entrance", "Cut Content Entrance"], player, + multiworld) + create_region_and_locations_lfod("Hard Cave", [], ["Hard Cave Wall Jump"], player, multiworld, 20) + create_region_and_locations_lfod("Hard Cave Wall Jump", ["Increased HP Pack"], [], player, multiworld, 130) + create_region_and_locations_lfod("Cut Content", conditional_location(has_item_shuffle, "Humble Indie Bindle"), [], player, multiworld, 200) + create_region_and_locations_lfod("Name Change", conditional_location(has_item_shuffle, "Box of Various Supplies"), ["Behind Rocks"], player, multiworld) + create_region_and_locations_lfod("Top Right", ["Season Pass", "High Definition Next Gen Pack"], ["Blizzard"], player, multiworld, 90) + create_region_and_locations_lfod("Season", ["Remove Ads Pack", "Not Exactly Noble"], ["Boss Door"], player, multiworld, 154) + region_final_boss = create_region_and_locations_lfod("Final Boss", ["Big Sword Pack", "Really Big Sword Pack", "Unfathomable Sword Pack"], [], player, multiworld) - Regdoubleleftcave = Region("Double Jump Total Left Cave", player, world) - Locdoubleleftcave_name = ["Top Hat Sheep"] - Regdoubleleftcave.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubleleftcave) - for loc_name in Locdoubleleftcave_name] - add_coin_dlcquest(Regdoubleleftcave, 9, player) - world.regions.append(Regdoubleleftcave) + create_victory_event(region_final_boss, "Winning Freemium", "Victory Freemium", player) - Regdoubleleftroof = Region("Double Jump Total Left Roof", player, world) - Locdoubleleftroof_name = ["North West Ceiling Sheep"] - Regdoubleleftroof.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubleleftroof) - for loc_name in Locdoubleleftroof_name] - add_coin_dlcquest(Regdoubleleftroof, 10, player) - world.regions.append(Regdoubleleftroof) + connect_entrances_lfod(multiworld, player) - Regdoubletree = Region("Double Jump Behind Tree", player, world) - Locdoubletree_name = ["Sexy Outfits Pack", "Double Jump Alcove Sheep", "Sexy Outfits Sheep"] - Regdoubletree.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubletree) for - loc_name in - Locdoubletree_name] - Regdoubletree.exits = [Entrance(player, "True Double Jump", Regdoubletree)] - add_coin_dlcquest(Regdoubletree, 89, player) - world.regions.append(Regdoubletree) - Regtruedoublejump = Region("True Double Jump Behind Tree", player, world) - Loctruedoublejump_name = ["Double Jump Floating Sheep", "Cutscene Sheep"] - Regtruedoublejump.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regtruedoublejump) - for loc_name in Loctruedoublejump_name] - add_coin_dlcquest(Regtruedoublejump, 7, player) - world.regions.append(Regtruedoublejump) +def conditional_location(condition: bool, location: str) -> List[str]: + return conditional_locations(condition, [location]) - Regforest = Region("The Forest", player, world) - Locforest_name = ["Gun Pack", "Night Map Pack"] - Regforest.exits = [Entrance(player, "Behind Ogre", Regforest), - Entrance(player, "Forest Double Jump", Regforest)] - Regforest.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regforest) for loc_name in - Locforest_name] - add_coin_dlcquest(Regforest, 171, player) - world.regions.append(Regforest) - Regforestdoublejump = Region("The Forest whit double Jump", player, world) - Locforestdoublejump_name = ["The Zombie Pack", "Forest Low Sheep"] - Regforestdoublejump.exits = [Entrance(player, "Forest True Double Jump", Regforestdoublejump)] - Regforestdoublejump.locations += [ - DLCQuestLocation(player, loc_name, location_table[loc_name], Regforestdoublejump) for loc_name in - Locforestdoublejump_name] - add_coin_dlcquest(Regforestdoublejump, 76, player) - world.regions.append(Regforestdoublejump) +def conditional_locations(condition: bool, locations: List[str]) -> List[str]: + return locations if condition else [] - Regforesttruedoublejump = Region("The Forest whit double Jump Part 2", player, world) - Locforesttruedoublejump_name = ["Forest High Sheep"] - Regforesttruedoublejump.locations += [ - DLCQuestLocation(player, loc_name, location_table[loc_name], Regforesttruedoublejump) - for loc_name in Locforesttruedoublejump_name] - add_coin_dlcquest(Regforesttruedoublejump, 203, player) - world.regions.append(Regforesttruedoublejump) - Regfinalroom = Region("The Final Boss Room", player, world) - Locfinalroom_name = ["Finish the Fight Pack"] - Regfinalroom.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfinalroom) for - loc_name in - Locfinalroom_name] - world.regions.append(Regfinalroom) +def create_region_and_locations_basic(region_name: str, locations: List[str], exits: List[str], player: int, multiworld: MultiWorld, + number_coins: int = 0) -> Region: + return create_region_and_locations(region_name, locations, exits, player, multiworld, number_coins, 0) - loc_win = DLCQuestLocation(player, "Winning Basic", None, world.get_region("The Final Boss Room", player)) - world.get_region("The Final Boss Room", player).locations.append(loc_win) - loc_win.place_locked_item(create_event(player, "Victory Basic")) - world.get_entrance("DLC Quest Basic", player).connect(world.get_region("Move Right", player)) +def create_region_and_locations_lfod(region_name: str, locations: List[str], exits: List[str], player: int, multiworld: MultiWorld, + number_coins: int = 0) -> Region: + return create_region_and_locations(region_name, locations, exits, player, multiworld, 0, number_coins) - world.get_entrance("Moving", player).connect(world.get_region("Movement Pack", player)) - world.get_entrance("Tree", player).connect(world.get_region("Behind Tree", player)) +def create_region_and_locations(region_name: str, locations: List[str], exits: List[str], player: int, multiworld: MultiWorld, + number_coins_basic: int, number_coins_lfod: int) -> Region: + region = Region(region_name, player, multiworld) + region.exits = [Entrance(player, exit_name, region) for exit_name in exits] + region.locations += [DLCQuestLocation(player, name, location_table[name], region) for name in locations] + if number_coins_basic > 0: + add_coin_dlcquest(region, number_coins_basic, player) + if number_coins_lfod > 0: + add_coin_lfod(region, number_coins_lfod, player) + multiworld.regions.append(region) + return region - world.get_entrance("Cloud", player).connect(world.get_region("Psychological Warfare", player)) - world.get_entrance("Cloud Double Jump", player).connect(world.get_region("Double Jump Total Left", player)) +def create_victory_event(region_victory: Region, event_name: str, item_name: str, player: int): + location_victory = DLCQuestLocation(player, event_name, None, region_victory) + region_victory.locations.append(location_victory) + location_victory.place_locked_item(create_event(player, item_name)) - world.get_entrance("Cave Tree", player).connect(world.get_region("Double Jump Total Left Cave", player)) - world.get_entrance("Cave Roof", player).connect(world.get_region("Double Jump Total Left Roof", player)) +def connect_entrances_basic(player, world): + world.get_entrance("DLC Quest Basic", player).connect(world.get_region("Move Right", player)) + world.get_entrance("Moving", player).connect(world.get_region("Movement Pack", player)) + world.get_entrance("Tree", player).connect(world.get_region("Behind Tree", player)) + world.get_entrance("Cloud", player).connect(world.get_region("Psychological Warfare", player)) + world.get_entrance("Cloud Double Jump", player).connect(world.get_region("Double Jump Total Left", player)) + world.get_entrance("Cave Tree", player).connect(world.get_region("Double Jump Total Left Cave", player)) + world.get_entrance("Cave Roof", player).connect(world.get_region("Double Jump Total Left Roof", player)) + world.get_entrance("Forest Entrance", player).connect(world.get_region("The Forest", player)) + world.get_entrance("Behind Tree Double Jump", player).connect(world.get_region("Double Jump Behind Tree", player)) + world.get_entrance("Behind Ogre", player).connect(world.get_region("The Final Boss Room", player)) + world.get_entrance("Forest Double Jump", player).connect(world.get_region("The Forest with double Jump", player)) + world.get_entrance("Forest True Double Jump", player).connect(world.get_region("The Forest with double Jump Part 2", player)) + world.get_entrance("True Double Jump", player).connect(world.get_region("True Double Jump Behind Tree", player)) - world.get_entrance("Forest Entrance", player).connect(world.get_region("The Forest", player)) - world.get_entrance("Behind Tree Double Jump", player).connect( - world.get_region("Double Jump Behind Tree", player)) +def connect_entrances_lfod(multiworld, player): + multiworld.get_entrance("Live Freemium or Die", player).connect(multiworld.get_region("Freemium Start", player)) + multiworld.get_entrance("Vines", player).connect(multiworld.get_region("Behind the Vines", player)) + multiworld.get_entrance("Wall Jump Entrance", player).connect(multiworld.get_region("Wall Jump", player)) + multiworld.get_entrance("Harmless Plants", player).connect(multiworld.get_region("Fake Ending", player)) + multiworld.get_entrance("Pickaxe Hard Cave", player).connect(multiworld.get_region("Hard Cave", player)) + multiworld.get_entrance("Hard Cave Wall Jump", player).connect(multiworld.get_region("Hard Cave Wall Jump", player)) + multiworld.get_entrance("Name Change Entrance", player).connect(multiworld.get_region("Name Change", player)) + multiworld.get_entrance("Cut Content Entrance", player).connect(multiworld.get_region("Cut Content", player)) + multiworld.get_entrance("Behind Rocks", player).connect(multiworld.get_region("Top Right", player)) + multiworld.get_entrance("Blizzard", player).connect(multiworld.get_region("Season", player)) + multiworld.get_entrance("Boss Door", player).connect(multiworld.get_region("Final Boss", player)) - world.get_entrance("Behind Ogre", player).connect(world.get_region("The Final Boss Room", player)) - world.get_entrance("Forest Double Jump", player).connect( - world.get_region("The Forest whit double Jump", player)) +def create_coinsanity_locations_dlc_quest(has_coinsanity: bool, coin_bundle_size: int, player: int, region_move_right: Region): + create_coinsanity_locations(has_coinsanity, coin_bundle_size, player, region_move_right, 825, "DLC Quest") - world.get_entrance("Forest True Double Jump", player).connect( - world.get_region("The Forest whit double Jump Part 2", player)) - world.get_entrance("True Double Jump", player).connect(world.get_region("True Double Jump Behind Tree", player)) +def create_coinsanity_locations_lfod(has_coinsanity: bool, coin_bundle_size: int, player: int, region_lfod_start: Region): + create_coinsanity_locations(has_coinsanity, coin_bundle_size, player, region_lfod_start, 889, "Live Freemium or Die") - if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or World_Options.campaign - == Options.Campaign.option_both): - Regfreemiumstart = Region("Freemium Start", player, world) - Locfreemiumstart_name = ["Particles Pack", "Day One Patch Pack", "Checkpoint Pack", "Incredibly Important Pack", - "Nice Try", "Story is Important", "I Get That Reference!"] - if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: - Locfreemiumstart_name += ["Wooden Sword"] - Regfreemiumstart.exits = [Entrance(player, "Vines", Regfreemiumstart)] - Regfreemiumstart.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfreemiumstart) - for loc_name in - Locfreemiumstart_name] - add_coin_freemium(Regfreemiumstart, 50, player) - if World_Options.coinsanity == Options.CoinSanity.option_coin: - coin_bundle_needed = math.floor(889 / World_Options.coinbundlequantity) - for i in range(coin_bundle_needed): - item_coin_freemium = f"Live Freemium or Die: {World_Options.coinbundlequantity * (i + 1)} Coin" - Regfreemiumstart.locations += [ - DLCQuestLocation(player, item_coin_freemium, location_table[item_coin_freemium], - Regfreemiumstart)] - if 889 % World_Options.coinbundlequantity != 0: - Regfreemiumstart.locations += [ - DLCQuestLocation(player, "Live Freemium or Die: 889 Coin", - location_table["Live Freemium or Die: 889 Coin"], - Regfreemiumstart)] - world.regions.append(Regfreemiumstart) +def create_coinsanity_locations(has_coinsanity: bool, coin_bundle_size: int, player: int, region: Region, last_coin_number: int, campaign_prefix: str): + if not has_coinsanity: + return - Regbehindvine = Region("Behind the Vines", player, world) - Locbehindvine_name = ["Wall Jump Pack", "Health Bar Pack", "Parallax Pack"] - if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: - Locbehindvine_name += ["Pickaxe"] - Regbehindvine.exits = [Entrance(player, "Wall Jump Entrance", Regbehindvine)] - Regbehindvine.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regbehindvine) for - loc_name in Locbehindvine_name] - add_coin_freemium(Regbehindvine, 95, player) - world.regions.append(Regbehindvine) - - Regwalljump = Region("Wall Jump", player, world) - Locwalljump_name = ["Harmless Plants Pack", "Death of Comedy Pack", "Canadian Dialog Pack", "DLC NPC Pack"] - Regwalljump.exits = [Entrance(player, "Harmless Plants", Regwalljump), - Entrance(player, "Pickaxe Hard Cave", Regwalljump)] - Regwalljump.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regwalljump) for - loc_name in Locwalljump_name] - add_coin_freemium(Regwalljump, 150, player) - world.regions.append(Regwalljump) - - Regfakeending = Region("Fake Ending", player, world) - Locfakeending_name = ["Cut Content Pack", "Name Change Pack"] - Regfakeending.exits = [Entrance(player, "Name Change Entrance", Regfakeending), - Entrance(player, "Cut Content Entrance", Regfakeending)] - Regfakeending.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfakeending) for - loc_name in Locfakeending_name] - world.regions.append(Regfakeending) - - Reghardcave = Region("Hard Cave", player, world) - add_coin_freemium(Reghardcave, 20, player) - Reghardcave.exits = [Entrance(player, "Hard Cave Wall Jump", Reghardcave)] - world.regions.append(Reghardcave) - - Reghardcavewalljump = Region("Hard Cave Wall Jump", player, world) - Lochardcavewalljump_name = ["Increased HP Pack"] - Reghardcavewalljump.locations += [ - DLCQuestLocation(player, loc_name, location_table[loc_name], Reghardcavewalljump) for - loc_name in Lochardcavewalljump_name] - add_coin_freemium(Reghardcavewalljump, 130, player) - world.regions.append(Reghardcavewalljump) - - Regcutcontent = Region("Cut Content", player, world) - Loccutcontent_name = [] - if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: - Loccutcontent_name += ["Humble Indie Bindle"] - Regcutcontent.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regcutcontent) for - loc_name in Loccutcontent_name] - add_coin_freemium(Regcutcontent, 200, player) - world.regions.append(Regcutcontent) - - Regnamechange = Region("Name Change", player, world) - Locnamechange_name = [] - if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: - Locnamechange_name += ["Box of Various Supplies"] - Regnamechange.exits = [Entrance(player, "Behind Rocks", Regnamechange)] - Regnamechange.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regnamechange) for - loc_name in Locnamechange_name] - world.regions.append(Regnamechange) - - Regtopright = Region("Top Right", player, world) - Loctopright_name = ["Season Pass", "High Definition Next Gen Pack"] - Regtopright.exits = [Entrance(player, "Blizzard", Regtopright)] - Regtopright.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regtopright) for - loc_name in Loctopright_name] - add_coin_freemium(Regtopright, 90, player) - world.regions.append(Regtopright) - - Regseason = Region("Season", player, world) - Locseason_name = ["Remove Ads Pack", "Not Exactly Noble"] - Regseason.exits = [Entrance(player, "Boss Door", Regseason)] - Regseason.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regseason) for - loc_name in Locseason_name] - add_coin_freemium(Regseason, 154, player) - world.regions.append(Regseason) - - Regfinalboss = Region("Final Boss", player, world) - Locfinalboss_name = ["Big Sword Pack", "Really Big Sword Pack", "Unfathomable Sword Pack"] - Regfinalboss.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfinalboss) for - loc_name in Locfinalboss_name] - world.regions.append(Regfinalboss) - - loc_wining = DLCQuestLocation(player, "Winning Freemium", None, world.get_region("Final Boss", player)) - world.get_region("Final Boss", player).locations.append(loc_wining) - loc_wining.place_locked_item(create_event(player, "Victory Freemium")) - - world.get_entrance("Live Freemium or Die", player).connect(world.get_region("Freemium Start", player)) - - world.get_entrance("Vines", player).connect(world.get_region("Behind the Vines", player)) - - world.get_entrance("Wall Jump Entrance", player).connect(world.get_region("Wall Jump", player)) - - world.get_entrance("Harmless Plants", player).connect(world.get_region("Fake Ending", player)) - - world.get_entrance("Pickaxe Hard Cave", player).connect(world.get_region("Hard Cave", player)) - - world.get_entrance("Hard Cave Wall Jump", player).connect(world.get_region("Hard Cave Wall Jump", player)) - - world.get_entrance("Name Change Entrance", player).connect(world.get_region("Name Change", player)) - - world.get_entrance("Cut Content Entrance", player).connect(world.get_region("Cut Content", player)) - - world.get_entrance("Behind Rocks", player).connect(world.get_region("Top Right", player)) - - world.get_entrance("Blizzard", player).connect(world.get_region("Season", player)) - - world.get_entrance("Boss Door", player).connect(world.get_region("Final Boss", player)) + coin_bundle_needed = math.ceil(last_coin_number / coin_bundle_size) + for i in range(1, coin_bundle_needed + 1): + number_coins = min(last_coin_number, coin_bundle_size * i) + item_coin = f"{campaign_prefix}: {number_coins} Coin" + region.locations += [DLCQuestLocation(player, item_coin, location_table[item_coin], region)] diff --git a/worlds/dlcquest/Rules.py b/worlds/dlcquest/Rules.py index c5fdfe8282..a11e5c504e 100644 --- a/worlds/dlcquest/Rules.py +++ b/worlds/dlcquest/Rules.py @@ -7,41 +7,25 @@ from . import Options from .Items import DLCQuestItem -def create_event(player, event: str): +def create_event(player, event: str) -> DLCQuestItem: return DLCQuestItem(event, ItemClassification.progression, None, player) +def has_enough_coin(player: int, coin: int): + return lambda state: state.prog_items[" coins", player] >= coin + + +def has_enough_coin_freemium(player: int, coin: int): + return lambda state: state.prog_items[" coins freemium", player] >= coin + + def set_rules(world, player, World_Options: Options.DLCQuestOptions): - def has_enough_coin(player: int, coin: int): - def has_coin(state, player: int, coins: int): - coin_possessed = 0 - for i in [4, 7, 9, 10, 46, 50, 60, 76, 89, 100, 171, 203]: - name_coin = f"{i} coins" - if state.has(name_coin, player): - coin_possessed += i - - return coin_possessed >= coins - - return lambda state: has_coin(state, player, coin) - - def has_enough_coin_freemium(player: int, coin: int): - def has_coin(state, player: int, coins: int): - coin_possessed = 0 - for i in [20, 50, 90, 95, 130, 150, 154, 200]: - name_coin = f"{i} coins freemium" - if state.has(name_coin, player): - coin_possessed += i - - return coin_possessed >= coins - - return lambda state: has_coin(state, player, coin) - - set_basic_rules(World_Options, has_enough_coin, player, world) - set_lfod_rules(World_Options, has_enough_coin_freemium, player, world) + set_basic_rules(World_Options, player, world) + set_lfod_rules(World_Options, player, world) set_completion_condition(World_Options, player, world) -def set_basic_rules(World_Options, has_enough_coin, player, world): +def set_basic_rules(World_Options, player, world): if World_Options.campaign == Options.Campaign.option_live_freemium_or_die: return set_basic_entrance_rules(player, world) @@ -49,8 +33,8 @@ def set_basic_rules(World_Options, has_enough_coin, player, world): set_basic_shuffled_items_rules(World_Options, player, world) set_double_jump_glitchless_rules(World_Options, player, world) set_easy_double_jump_glitch_rules(World_Options, player, world) - self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, player, world) - set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player, world) + self_basic_coinsanity_funded_purchase_rules(World_Options, player, world) + set_basic_self_funded_purchase_rules(World_Options, player, world) self_basic_win_condition(World_Options, player, world) @@ -131,7 +115,7 @@ def set_easy_double_jump_glitch_rules(World_Options, player, world): lambda state: state.has("Double Jump Pack", player)) -def self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, player, world): +def self_basic_coinsanity_funded_purchase_rules(World_Options, player, world): if World_Options.coinsanity != Options.CoinSanity.option_coin: return number_of_bundle = math.floor(825 / World_Options.coinbundlequantity) @@ -194,7 +178,7 @@ def self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, math.ceil(5 / World_Options.coinbundlequantity))) -def set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player, world): +def set_basic_self_funded_purchase_rules(World_Options, player, world): if World_Options.coinsanity != Options.CoinSanity.option_none: return set_rule(world.get_location("Movement Pack", player), @@ -241,14 +225,14 @@ def self_basic_win_condition(World_Options, player, world): player)) -def set_lfod_rules(World_Options, has_enough_coin_freemium, player, world): +def set_lfod_rules(World_Options, player, world): if World_Options.campaign == Options.Campaign.option_basic: return set_lfod_entrance_rules(player, world) set_boss_door_requirements_rules(player, world) set_lfod_self_obtained_items_rules(World_Options, player, world) set_lfod_shuffled_items_rules(World_Options, player, world) - self_lfod_coinsanity_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world) + self_lfod_coinsanity_funded_purchase_rules(World_Options, player, world) set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world) @@ -327,7 +311,7 @@ def set_lfod_shuffled_items_rules(World_Options, player, world): lambda state: state.can_reach("Cut Content", 'region', player)) -def self_lfod_coinsanity_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world): +def self_lfod_coinsanity_funded_purchase_rules(World_Options, player, world): if World_Options.coinsanity != Options.CoinSanity.option_coin: return number_of_bundle = math.floor(889 / World_Options.coinbundlequantity) diff --git a/worlds/dlcquest/__init__.py b/worlds/dlcquest/__init__.py index 392eac7796..54d27f7b65 100644 --- a/worlds/dlcquest/__init__.py +++ b/worlds/dlcquest/__init__.py @@ -1,6 +1,6 @@ from typing import Union -from BaseClasses import Tutorial +from BaseClasses import Tutorial, CollectionState from worlds.AutoWorld import WebWorld, World from . import Options from .Items import DLCQuestItem, ItemData, create_items, item_table @@ -71,7 +71,6 @@ class DLCqworld(World): if self.options.coinsanity == Options.CoinSanity.option_coin and self.options.coinbundlequantity >= 5: self.multiworld.push_precollected(self.create_item("Movement Pack")) - def create_item(self, item: Union[str, ItemData]) -> DLCQuestItem: if isinstance(item, str): item = item_table[item] @@ -87,3 +86,19 @@ class DLCqworld(World): "seed": self.random.randrange(99999999) }) return options_dict + + def collect(self, state: CollectionState, item: DLCQuestItem) -> bool: + change = super().collect(state, item) + if change: + suffix = item.coin_suffix + if suffix: + state.prog_items[suffix, self.player] += item.coins + return change + + def remove(self, state: CollectionState, item: DLCQuestItem) -> bool: + change = super().remove(state, item) + if change: + suffix = item.coin_suffix + if suffix: + state.prog_items[suffix, self.player] -= item.coins + return change diff --git a/worlds/dlcquest/test/TestItemShuffle.py b/worlds/dlcquest/test/TestItemShuffle.py new file mode 100644 index 0000000000..bfe999246a --- /dev/null +++ b/worlds/dlcquest/test/TestItemShuffle.py @@ -0,0 +1,130 @@ +from . import DLCQuestTestBase +from .. import Options + +sword = "Sword" +gun = "Gun" +wooden_sword = "Wooden Sword" +pickaxe = "Pickaxe" +humble_bindle = "Humble Indie Bindle" +box_supplies = "Box of Various Supplies" +items = [sword, gun, wooden_sword, pickaxe, humble_bindle, box_supplies] + +important_pack = "Incredibly Important Pack" + + +class TestItemShuffle(DLCQuestTestBase): + options = {Options.ItemShuffle.internal_name: Options.ItemShuffle.option_shuffled, + Options.Campaign.internal_name: Options.Campaign.option_both} + + def test_items_in_pool(self): + item_names = {item.name for item in self.multiworld.get_items()} + for item in items: + with self.subTest(f"{item}"): + self.assertIn(item, item_names) + + def test_item_locations_in_pool(self): + location_names = {location.name for location in self.multiworld.get_locations()} + for item_location in items: + with self.subTest(f"{item_location}"): + self.assertIn(item_location, location_names) + + def test_sword_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(sword)) + movement_pack = self.multiworld.create_item("Movement Pack", self.player) + self.collect(movement_pack) + self.assertFalse(self.can_reach_location(sword)) + time_pack = self.multiworld.create_item("Time is Money Pack", self.player) + self.collect(time_pack) + self.assertTrue(self.can_reach_location(sword)) + + def test_gun_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(gun)) + movement_pack = self.multiworld.create_item("Movement Pack", self.player) + self.collect(movement_pack) + self.assertFalse(self.can_reach_location(gun)) + sword_item = self.multiworld.create_item(sword, self.player) + self.collect(sword_item) + self.assertFalse(self.can_reach_location(gun)) + gun_pack = self.multiworld.create_item("Gun Pack", self.player) + self.collect(gun_pack) + self.assertTrue(self.can_reach_location(gun)) + + def test_wooden_sword_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(wooden_sword)) + important_pack_item = self.multiworld.create_item(important_pack, self.player) + self.collect(important_pack_item) + self.assertTrue(self.can_reach_location(wooden_sword)) + + def test_bindle_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(humble_bindle)) + wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player) + self.collect(wooden_sword_item) + self.assertFalse(self.can_reach_location(humble_bindle)) + plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player) + self.collect(plants_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + wall_jump_pack = self.multiworld.create_item("Wall Jump Pack", self.player) + self.collect(wall_jump_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + name_change_pack = self.multiworld.create_item("Name Change Pack", self.player) + self.collect(name_change_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + cut_content_pack = self.multiworld.create_item("Cut Content Pack", self.player) + self.collect(cut_content_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + box_supplies_item = self.multiworld.create_item(box_supplies, self.player) + self.collect(box_supplies_item) + self.assertTrue(self.can_reach_location(humble_bindle)) + + def test_box_supplies_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(box_supplies)) + wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player) + self.collect(wooden_sword_item) + self.assertFalse(self.can_reach_location(box_supplies)) + plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player) + self.collect(plants_pack) + self.assertFalse(self.can_reach_location(box_supplies)) + wall_jump_pack = self.multiworld.create_item("Wall Jump Pack", self.player) + self.collect(wall_jump_pack) + self.assertFalse(self.can_reach_location(box_supplies)) + name_change_pack = self.multiworld.create_item("Name Change Pack", self.player) + self.collect(name_change_pack) + self.assertFalse(self.can_reach_location(box_supplies)) + cut_content_pack = self.multiworld.create_item("Cut Content Pack", self.player) + self.collect(cut_content_pack) + self.assertTrue(self.can_reach_location(box_supplies)) + + def test_pickaxe_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(pickaxe)) + wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player) + self.collect(wooden_sword_item) + self.assertFalse(self.can_reach_location(pickaxe)) + plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player) + self.collect(plants_pack) + self.assertFalse(self.can_reach_location(pickaxe)) + wall_jump_pack = self.multiworld.create_item("Wall Jump Pack", self.player) + self.collect(wall_jump_pack) + self.assertFalse(self.can_reach_location(pickaxe)) + name_change_pack = self.multiworld.create_item("Name Change Pack", self.player) + self.collect(name_change_pack) + self.assertFalse(self.can_reach_location(pickaxe)) + bindle_item = self.multiworld.create_item("Humble Indie Bindle", self.player) + self.collect(bindle_item) + self.assertTrue(self.can_reach_location(pickaxe)) + + +class TestNoItemShuffle(DLCQuestTestBase): + options = {Options.ItemShuffle.internal_name: Options.ItemShuffle.option_disabled, + Options.Campaign.internal_name: Options.Campaign.option_both} + + def test_items_not_in_pool(self): + item_names = {item.name for item in self.multiworld.get_items()} + for item in items: + with self.subTest(f"{item}"): + self.assertNotIn(item, item_names) + + def test_item_locations_not_in_pool(self): + location_names = {location.name for location in self.multiworld.get_locations()} + for item_location in items: + with self.subTest(f"{item_location}"): + self.assertNotIn(item_location, location_names) \ No newline at end of file diff --git a/worlds/dlcquest/test/TestOptionsLong.py b/worlds/dlcquest/test/TestOptionsLong.py new file mode 100644 index 0000000000..d0a5c0ed7d --- /dev/null +++ b/worlds/dlcquest/test/TestOptionsLong.py @@ -0,0 +1,87 @@ +from typing import Dict + +from BaseClasses import MultiWorld +from Options import SpecialRange +from .option_names import options_to_include +from .checks.world_checks import assert_can_win, assert_same_number_items_locations +from . import DLCQuestTestBase, setup_dlc_quest_solo_multiworld +from ... import AutoWorldRegister + + +def basic_checks(tester: DLCQuestTestBase, multiworld: MultiWorld): + assert_can_win(tester, multiworld) + assert_same_number_items_locations(tester, multiworld) + + +def get_option_choices(option) -> Dict[str, int]: + if issubclass(option, SpecialRange): + return option.special_range_names + elif option.options: + return option.options + return {} + + +class TestGenerateDynamicOptions(DLCQuestTestBase): + def test_given_option_pair_when_generate_then_basic_checks(self): + num_options = len(options_to_include) + for option1_index in range(0, num_options): + for option2_index in range(option1_index + 1, num_options): + option1 = options_to_include[option1_index] + option2 = options_to_include[option2_index] + option1_choices = get_option_choices(option1) + option2_choices = get_option_choices(option2) + for key1 in option1_choices: + for key2 in option2_choices: + with self.subTest(f"{option1.internal_name}: {key1}, {option2.internal_name}: {key2}"): + choices = {option1.internal_name: option1_choices[key1], + option2.internal_name: option2_choices[key2]} + multiworld = setup_dlc_quest_solo_multiworld(choices) + basic_checks(self, multiworld) + + def test_given_option_truple_when_generate_then_basic_checks(self): + num_options = len(options_to_include) + for option1_index in range(0, num_options): + for option2_index in range(option1_index + 1, num_options): + for option3_index in range(option2_index + 1, num_options): + option1 = options_to_include[option1_index] + option2 = options_to_include[option2_index] + option3 = options_to_include[option3_index] + option1_choices = get_option_choices(option1) + option2_choices = get_option_choices(option2) + option3_choices = get_option_choices(option3) + for key1 in option1_choices: + for key2 in option2_choices: + for key3 in option3_choices: + with self.subTest(f"{option1.internal_name}: {key1}, {option2.internal_name}: {key2}, {option3.internal_name}: {key3}"): + choices = {option1.internal_name: option1_choices[key1], + option2.internal_name: option2_choices[key2], + option3.internal_name: option3_choices[key3]} + multiworld = setup_dlc_quest_solo_multiworld(choices) + basic_checks(self, multiworld) + + def test_given_option_quartet_when_generate_then_basic_checks(self): + num_options = len(options_to_include) + for option1_index in range(0, num_options): + for option2_index in range(option1_index + 1, num_options): + for option3_index in range(option2_index + 1, num_options): + for option4_index in range(option3_index + 1, num_options): + option1 = options_to_include[option1_index] + option2 = options_to_include[option2_index] + option3 = options_to_include[option3_index] + option4 = options_to_include[option4_index] + option1_choices = get_option_choices(option1) + option2_choices = get_option_choices(option2) + option3_choices = get_option_choices(option3) + option4_choices = get_option_choices(option4) + for key1 in option1_choices: + for key2 in option2_choices: + for key3 in option3_choices: + for key4 in option4_choices: + with self.subTest( + f"{option1.internal_name}: {key1}, {option2.internal_name}: {key2}, {option3.internal_name}: {key3}, {option4.internal_name}: {key4}"): + choices = {option1.internal_name: option1_choices[key1], + option2.internal_name: option2_choices[key2], + option3.internal_name: option3_choices[key3], + option4.internal_name: option4_choices[key4]} + multiworld = setup_dlc_quest_solo_multiworld(choices) + basic_checks(self, multiworld) diff --git a/worlds/dlcquest/test/__init__.py b/worlds/dlcquest/test/__init__.py new file mode 100644 index 0000000000..e998bd8a5e --- /dev/null +++ b/worlds/dlcquest/test/__init__.py @@ -0,0 +1,53 @@ +from typing import ClassVar + +from typing import Dict, FrozenSet, Tuple, Any +from argparse import Namespace + +from BaseClasses import MultiWorld +from test.TestBase import WorldTestBase +from .. import DLCqworld +from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld +from worlds.AutoWorld import call_all + + +class DLCQuestTestBase(WorldTestBase): + game = "DLCQuest" + world: DLCqworld + player: ClassVar[int] = 1 + + def world_setup(self, *args, **kwargs): + super().world_setup(*args, **kwargs) + if self.constructed: + self.world = self.multiworld.worlds[self.player] # noqa + + @property + def run_default_tests(self) -> bool: + # world_setup is overridden, so it'd always run default tests when importing DLCQuestTestBase + is_not_dlc_test = type(self) is not DLCQuestTestBase + should_run_default_tests = is_not_dlc_test and super().run_default_tests + return should_run_default_tests + + +def setup_dlc_quest_solo_multiworld(test_options=None, seed=None, _cache: Dict[FrozenSet[Tuple[str, Any]], MultiWorld] = {}) -> MultiWorld: #noqa + if test_options is None: + test_options = {} + + # Yes I reuse the worlds generated between tests, its speeds the execution by a couple seconds + frozen_options = frozenset(test_options.items()).union({seed}) + if frozen_options in _cache: + return _cache[frozen_options] + + multiworld = setup_base_solo_multiworld(DLCqworld, ()) + multiworld.set_seed(seed) + # print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test + args = Namespace() + for name, option in DLCqworld.options_dataclass.type_hints.items(): + value = option(test_options[name]) if name in test_options else option.from_any(option.default) + setattr(args, name, {1: value}) + multiworld.set_options(args) + for step in gen_steps: + call_all(multiworld, step) + + _cache[frozen_options] = multiworld + + return multiworld diff --git a/worlds/dlcquest/test/checks/__init__.py b/worlds/dlcquest/test/checks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/dlcquest/test/checks/world_checks.py b/worlds/dlcquest/test/checks/world_checks.py new file mode 100644 index 0000000000..a97093d620 --- /dev/null +++ b/worlds/dlcquest/test/checks/world_checks.py @@ -0,0 +1,42 @@ +from typing import List + +from BaseClasses import MultiWorld, ItemClassification +from .. import DLCQuestTestBase +from ... import Options + + +def get_all_item_names(multiworld: MultiWorld) -> List[str]: + return [item.name for item in multiworld.itempool] + + +def get_all_location_names(multiworld: MultiWorld) -> List[str]: + return [location.name for location in multiworld.get_locations() if not location.event] + + +def assert_victory_exists(tester: DLCQuestTestBase, multiworld: MultiWorld): + campaign = multiworld.campaign[1] + all_items = [item.name for item in multiworld.get_items()] + if campaign == Options.Campaign.option_basic or campaign == Options.Campaign.option_both: + tester.assertIn("Victory Basic", all_items) + if campaign == Options.Campaign.option_live_freemium_or_die or campaign == Options.Campaign.option_both: + tester.assertIn("Victory Freemium", all_items) + + +def collect_all_then_assert_can_win(tester: DLCQuestTestBase, multiworld: MultiWorld): + for item in multiworld.get_items(): + multiworld.state.collect(item) + campaign = multiworld.campaign[1] + if campaign == Options.Campaign.option_basic or campaign == Options.Campaign.option_both: + tester.assertTrue(multiworld.find_item("Victory Basic", 1).can_reach(multiworld.state)) + if campaign == Options.Campaign.option_live_freemium_or_die or campaign == Options.Campaign.option_both: + tester.assertTrue(multiworld.find_item("Victory Freemium", 1).can_reach(multiworld.state)) + + +def assert_can_win(tester: DLCQuestTestBase, multiworld: MultiWorld): + assert_victory_exists(tester, multiworld) + collect_all_then_assert_can_win(tester, multiworld) + + +def assert_same_number_items_locations(tester: DLCQuestTestBase, multiworld: MultiWorld): + non_event_locations = [location for location in multiworld.get_locations() if not location.event] + tester.assertEqual(len(multiworld.itempool), len(non_event_locations)) \ No newline at end of file diff --git a/worlds/dlcquest/test/option_names.py b/worlds/dlcquest/test/option_names.py new file mode 100644 index 0000000000..4a4b46e906 --- /dev/null +++ b/worlds/dlcquest/test/option_names.py @@ -0,0 +1,5 @@ +from .. import DLCqworld + +options_to_exclude = ["progression_balancing", "accessibility", "start_inventory", "start_hints", "death_link"] +options_to_include = [option for option_name, option in DLCqworld.options_dataclass.type_hints.items() + if option_name not in options_to_exclude] diff --git a/worlds/factorio/docs/setup_en.md b/worlds/factorio/docs/setup_en.md index 09ad431a21..b6d4545925 100644 --- a/worlds/factorio/docs/setup_en.md +++ b/worlds/factorio/docs/setup_en.md @@ -31,7 +31,7 @@ them. Factorio player settings page: [Factorio Settings Page](/games/Factorio/pl ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -Validator page: [Yaml Validation Page](/mysterycheck) +Validator page: [Yaml Validation Page](/check) ## Connecting to Someone Else's Factorio Game diff --git a/worlds/ff1/__init__.py b/worlds/ff1/__init__.py index 56b41d62d0..16905cc6da 100644 --- a/worlds/ff1/__init__.py +++ b/worlds/ff1/__init__.py @@ -14,7 +14,7 @@ class FF1Settings(settings.Group): class FF1Web(WebWorld): - settings_page = "https://finalfantasyrandomizer.com/" + options_page = "https://finalfantasyrandomizer.com/" tutorials = [Tutorial( "Multiworld Setup Guide", "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.", @@ -91,7 +91,7 @@ class FF1World(World): def set_rules(self): self.multiworld.completion_condition[self.player] = lambda state: state.has(CHAOS_TERMINATED_EVENT, self.player) - def generate_basic(self): + def create_items(self): items = get_options(self.multiworld, 'items', self.player) if FF1_BRIDGE in items.keys(): self._place_locked_item_in_sphere0(FF1_BRIDGE) diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 456795dac4..6d5e20462f 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -108,7 +108,9 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) * `minimal` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest in a dungeon in ALTTP making it impossible to get and finish the dungeon. -* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. +* `progression_balancing` is a system the Archipelago generator uses to try and reduce + ["BK mode"](/glossary/en/#burger-king-/-bk-mode) + as much as possible. This primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that players almost always have something to do. This can be in a range from 0 to 99, and is 50 by default. This number represents a percentage of the furthest progressible player. @@ -130,7 +132,7 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) there without using any hint points. * `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk" item which isn't necessary for progression to go in these locations. -* `priority_locations` is the inverse of `exlcude_locations`, forcing a progression item in the defined locations. +* `priority_locations` is the inverse of `exclude_locations`, forcing a progression item in the defined locations. * `item_links` allows players to link their items into a group with the same item link name and game. The items declared in `item_pool` get combined and when an item is found for the group, all players in the group receive it. Item links can also have local and non local items, forcing the items to either be placed within the worlds of the group or in diff --git a/worlds/generic/docs/setup_en.md b/worlds/generic/docs/setup_en.md index 132b88e285..93ae217e0d 100644 --- a/worlds/generic/docs/setup_en.md +++ b/worlds/generic/docs/setup_en.md @@ -40,7 +40,7 @@ game you will be playing as well as the settings you would like for that game. YAML is a format very similar to JSON however it is made to be more human-readable. If you are ever unsure of the validity of your YAML file you may check the file by uploading it to the check page on the Archipelago website: -[YAML Validation Page](/mysterycheck) +[YAML Validation Page](/check) ### Creating a YAML diff --git a/worlds/hk/docs/setup_en.md b/worlds/hk/docs/setup_en.md index adf975ff51..fef0f051fe 100644 --- a/worlds/hk/docs/setup_en.md +++ b/worlds/hk/docs/setup_en.md @@ -1,27 +1,27 @@ # Hollow Knight for Archipelago Setup Guide ## Required Software -* Download and unzip the Scarab+ Mod Manager from the [Scarab+ website](https://themulhima.github.io/Scarab/). +* Download and unzip the Lumafly Mod Manager from the [Lumafly website](https://themulhima.github.io/Lumafly/). * A legal copy of Hollow Knight. -## Installing the Archipelago Mod using Scarab+ -1. Launch Scarab+ and ensure it locates your Hollow Knight installation directory. +## Installing the Archipelago Mod using Lumafly +1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. 2. Click the "Install" button near the "Archipelago" mod entry. * If desired, also install "Archipelago Map Mod" to use as an in-game tracker. 3. Launch the game, you're all set! -### What to do if Scarab+ fails to find your XBox Game Pass installation directory +### What to do if Lumafly fails to find your XBox Game Pass installation directory 1. Enter the XBox app and move your mouse over "Hollow Knight" on the left sidebar. 2. Click the three points then click "Manage". 3. Go to the "Files" tab and select "Browse...". 4. Click "Hollow Knight", then "Content", then click the path bar and copy it. -5. Run Scarab+ as an administrator and, when it asks you for the path, paste what you copied in step 4. +5. Run Lumafly as an administrator and, when it asks you for the path, paste what you copied in step 4. #### Alternative Method: 1. Click on your profile then "Settings". 2. Go to the "General" tab and select "CHANGE FOLDER". 3. Look for a folder where you want to install the game (preferably inside a folder on your desktop) and copy the path. -4. Run Scarab+ as an administrator and, when it asks you for the path, paste what you copied in step 3. +4. Run Lumafly as an administrator and, when it asks you for the path, paste what you copied in step 3. Note: The path folder needs to have the "Hollow Knight_Data" folder inside. diff --git a/worlds/hylics2/Rules.py b/worlds/hylics2/Rules.py index 12c22e01cd..6c55c8745b 100644 --- a/worlds/hylics2/Rules.py +++ b/worlds/hylics2/Rules.py @@ -1,91 +1,128 @@ from worlds.generic.Rules import add_rule -from ..AutoWorld import LogicMixin +from BaseClasses import CollectionState -class Hylics2Logic(LogicMixin): +def air_dash(state: CollectionState, player: int) -> bool: + return state.has("PNEUMATOPHORE", player) - def _hylics2_can_air_dash(self, player): - return self.has("PNEUMATOPHORE", player) - def _hylics2_has_airship(self, player): - return self.has("DOCK KEY", player) +def airship(state: CollectionState, player: int) -> bool: + return state.has("DOCK KEY", player) - def _hylics2_has_jail_key(self, player): - return self.has("JAIL KEY", player) - def _hylics2_has_paddle(self, player): - return self.has("PADDLE", player) +def jail_key(state: CollectionState, player: int) -> bool: + return state.has("JAIL KEY", player) - def _hylics2_has_worm_room_key(self, player): - return self.has("WORM ROOM KEY", player) - def _hylics2_has_bridge_key(self, player): - return self.has("BRIDGE KEY", player) +def paddle(state: CollectionState, player: int) -> bool: + return state.has("PADDLE", player) - def _hylics2_has_upper_chamber_key(self, player): - return self.has("UPPER CHAMBER KEY", player) - def _hylics2_has_vessel_room_key(self, player): - return self.has("VESSEL ROOM KEY", player) +def worm_room_key(state: CollectionState, player: int) -> bool: + return state.has("WORM ROOM KEY", player) - def _hylics2_has_house_key(self, player): - return self.has("HOUSE KEY", player) - def _hylics2_has_cave_key(self, player): - return self.has("CAVE KEY", player) +def bridge_key(state: CollectionState, player: int) -> bool: + return state.has("BRIDGE KEY", player) - def _hylics2_has_skull_bomb(self, player): - return self.has("SKULL BOMB", player) - def _hylics2_has_tower_key(self, player): - return self.has("TOWER KEY", player) +def upper_chamber_key(state: CollectionState, player: int) -> bool: + return state.has("UPPER CHAMBER KEY", player) - def _hylics2_has_deep_key(self, player): - return self.has("DEEP KEY", player) - def _hylics2_has_upper_house_key(self, player): - return self.has("UPPER HOUSE KEY", player) +def vessel_room_key(state: CollectionState, player: int) -> bool: + return state.has("VESSEL ROOM KEY", player) - def _hylics2_has_clicker(self, player): - return self.has("CLICKER", player) - def _hylics2_has_tokens(self, player): - return self.has("SAGE TOKEN", player, 3) +def house_key(state: CollectionState, player: int) -> bool: + return state.has("HOUSE KEY", player) - def _hylics2_has_charge_up(self, player): - return self.has("CHARGE UP", player) - def _hylics2_has_cup(self, player): - return self.has("PAPER CUP", player, 1) +def cave_key(state: CollectionState, player: int) -> bool: + return state.has("CAVE KEY", player) - def _hylics2_has_1_member(self, player): - return self.has("Pongorma", player) or self.has("Dedusmuln", player) or self.has("Somsnosa", player) - def _hylics2_has_2_members(self, player): - return (self.has("Pongorma", player) and self.has("Dedusmuln", player)) or\ - (self.has("Pongorma", player) and self.has("Somsnosa", player)) or\ - (self.has("Dedusmuln", player) and self.has("Somsnosa", player)) +def skull_bomb(state: CollectionState, player: int) -> bool: + return state.has("SKULL BOMB", player) - def _hylics2_has_3_members(self, player): - return self.has("Pongorma", player) and self.has("Dedusmuln", player) and self.has("Somsnosa", player) - def _hylics2_enter_arcade2(self, player): - return self._hylics2_can_air_dash(player) and self._hylics2_has_airship(player) +def tower_key(state: CollectionState, player: int) -> bool: + return state.has("TOWER KEY", player) - def _hylics2_enter_wormpod(self, player): - return self._hylics2_has_airship(player) and self._hylics2_has_worm_room_key(player) and\ - self._hylics2_has_paddle(player) - def _hylics2_enter_sageship(self, player): - return self._hylics2_has_skull_bomb(player) and self._hylics2_has_airship(player) and\ - self._hylics2_has_paddle(player) +def deep_key(state: CollectionState, player: int) -> bool: + return state.has("DEEP KEY", player) - def _hylics2_enter_foglast(self, player): - return self._hylics2_enter_wormpod(player) - def _hylics2_enter_hylemxylem(self, player): - return self._hylics2_can_air_dash(player) and self._hylics2_enter_foglast(player) and\ - self._hylics2_has_bridge_key(player) +def upper_house_key(state: CollectionState, player: int) -> bool: + return state.has("UPPER HOUSE KEY", player) + + +def clicker(state: CollectionState, player: int) -> bool: + return state.has("CLICKER", player) + + +def all_tokens(state: CollectionState, player: int) -> bool: + return state.has("SAGE TOKEN", player, 3) + + +def charge_up(state: CollectionState, player: int) -> bool: + return state.has("CHARGE UP", player) + + +def paper_cup(state: CollectionState, player: int) -> bool: + return state.has("PAPER CUP", player) + + +def party_1(state: CollectionState, player: int) -> bool: + return state.has_any({"Pongorma", "Dedusmuln", "Somsnosa"}, player) + + +def party_2(state: CollectionState, player: int) -> bool: + return ( + state.has_all({"Pongorma", "Dedusmuln"}, player) + or state.has_all({"Pongorma", "Somsnosa"}, player) + or state.has_all({"Dedusmuln", "Somsnosa"}, player) + ) + + +def party_3(state: CollectionState, player: int) -> bool: + return state.has_all({"Pongorma", "Dedusmuln", "Somsnosa"}, player) + + +def enter_arcade2(state: CollectionState, player: int) -> bool: + return ( + air_dash(state, player) + and airship(state, player) + ) + + +def enter_wormpod(state: CollectionState, player: int) -> bool: + return ( + airship(state, player) + and worm_room_key(state, player) + and paddle(state, player) + ) + + +def enter_sageship(state: CollectionState, player: int) -> bool: + return ( + skull_bomb(state, player) + and airship(state, player) + and paddle(state, player) + ) + + +def enter_foglast(state: CollectionState, player: int) -> bool: + return enter_wormpod(state, player) + + +def enter_hylemxylem(state: CollectionState, player: int) -> bool: + return ( + air_dash(state, player) + and enter_foglast(state, player) + and bridge_key(state, player) + ) def set_rules(hylics2world): @@ -94,342 +131,439 @@ def set_rules(hylics2world): # Afterlife add_rule(world.get_location("Afterlife: TV", player), - lambda state: state._hylics2_has_cave_key(player)) + lambda state: cave_key(state, player)) # New Muldul add_rule(world.get_location("New Muldul: Underground Chest", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("New Muldul: TV", player), - lambda state: state._hylics2_has_house_key(player)) + lambda state: house_key(state, player)) add_rule(world.get_location("New Muldul: Upper House Chest 1", player), - lambda state: state._hylics2_has_upper_house_key(player)) + lambda state: upper_house_key(state, player)) add_rule(world.get_location("New Muldul: Upper House Chest 2", player), - lambda state: state._hylics2_has_upper_house_key(player)) + lambda state: upper_house_key(state, player)) add_rule(world.get_location("New Muldul: Pot above Vault", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) # New Muldul Vault add_rule(world.get_location("New Muldul: Rescued Blerol 1", player), - lambda state: ((state._hylics2_has_jail_key(player) and state._hylics2_has_paddle(player)) and\ - (state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player))) or\ - state._hylics2_enter_hylemxylem(player)) + lambda state: ( + ( + ( + jail_key(state, player) + and paddle(state, player) + ) + and ( + air_dash(state, player) + or airship(state, player) + ) + ) + or enter_hylemxylem(state, player) + )) add_rule(world.get_location("New Muldul: Rescued Blerol 2", player), - lambda state: ((state._hylics2_has_jail_key(player) and state._hylics2_has_paddle(player)) and\ - (state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player))) or\ - state._hylics2_enter_hylemxylem(player)) + lambda state: ( + ( + ( + jail_key(state, player) + and paddle(state, player) + ) + and ( + air_dash(state, player) + or airship(state, player) + ) + ) + or enter_hylemxylem(state, player) + )) add_rule(world.get_location("New Muldul: Vault Left Chest", player), - lambda state: state._hylics2_enter_hylemxylem(player)) + lambda state: enter_hylemxylem(state, player)) add_rule(world.get_location("New Muldul: Vault Right Chest", player), - lambda state: state._hylics2_enter_hylemxylem(player)) + lambda state: enter_hylemxylem(state, player)) add_rule(world.get_location("New Muldul: Vault Bomb", player), - lambda state: state._hylics2_enter_hylemxylem(player)) + lambda state: enter_hylemxylem(state, player)) # Viewax's Edifice add_rule(world.get_location("Viewax's Edifice: Canopic Jar", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Cave Sarcophagus", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Shielded Key", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Shielded Key", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Tower Pot", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Tower Jar", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Tower Chest", player), - lambda state: state._hylics2_has_paddle(player) and state._hylics2_has_tower_key(player)) + lambda state: ( + paddle(state, player) + and tower_key(state, player) + )) add_rule(world.get_location("Viewax's Edifice: Viewax Pot", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Defeat Viewax", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: TV", player), - lambda state: state._hylics2_has_paddle(player) and state._hylics2_has_jail_key(player)) + lambda state: ( + paddle(state, player) + and jail_key(state, player) + )) add_rule(world.get_location("Viewax's Edifice: Sage Fridge", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Viewax's Edifice: Sage Item 1", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Viewax's Edifice: Sage Item 2", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) # Arcade 1 add_rule(world.get_location("Arcade 1: Key", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Coin Dash", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Burrito Alcove 1", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Burrito Alcove 2", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Behind Spikes Banana", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Pyramid Banana", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Moving Platforms Muscle Applique", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Bed Banana", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) # Airship add_rule(world.get_location("Airship: Talk to Somsnosa", player), - lambda state: state._hylics2_has_worm_room_key(player)) + lambda state: worm_room_key(state, player)) # Foglast add_rule(world.get_location("Foglast: Underground Sarcophagus", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Foglast: Shielded Key", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Foglast: TV", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_clicker(player)) + lambda state: ( + air_dash(state, player) + and clicker(state, player) + )) add_rule(world.get_location("Foglast: Buy Clicker", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Foglast: Shielded Chest", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Foglast: Cave Fridge", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Foglast: Roof Sarcophagus", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Foglast: Under Lair Sarcophagus 1", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Foglast: Under Lair Sarcophagus 2", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Foglast: Under Lair Sarcophagus 3", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Foglast: Sage Sarcophagus", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Foglast: Sage Item 1", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Foglast: Sage Item 2", player), - lambda state: state._hylics2_can_air_dash(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + air_dash(state, player) + and bridge_key(state, player) + )) # Drill Castle add_rule(world.get_location("Drill Castle: Island Banana", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Drill Castle: Island Pot", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Drill Castle: Cave Sarcophagus", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Drill Castle: TV", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) # Sage Labyrinth add_rule(world.get_location("Sage Labyrinth: Sage Item 1", player), - lambda state: state._hylics2_has_deep_key(player)) + lambda state: deep_key(state, player)) add_rule(world.get_location("Sage Labyrinth: Sage Item 2", player), - lambda state: state._hylics2_has_deep_key(player)) + lambda state: deep_key(state, player)) add_rule(world.get_location("Sage Labyrinth: Sage Left Arm", player), - lambda state: state._hylics2_has_deep_key(player)) + lambda state: deep_key(state, player)) add_rule(world.get_location("Sage Labyrinth: Sage Right Arm", player), - lambda state: state._hylics2_has_deep_key(player)) + lambda state: deep_key(state, player)) add_rule(world.get_location("Sage Labyrinth: Sage Left Leg", player), - lambda state: state._hylics2_has_deep_key(player)) + lambda state: deep_key(state, player)) add_rule(world.get_location("Sage Labyrinth: Sage Right Leg", player), - lambda state: state._hylics2_has_deep_key(player)) + lambda state: deep_key(state, player)) # Sage Airship add_rule(world.get_location("Sage Airship: TV", player), - lambda state: state._hylics2_has_tokens(player)) + lambda state: all_tokens(state, player)) # Hylemxylem add_rule(world.get_location("Hylemxylem: Upper Chamber Banana", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Across Upper Reservoir Chest", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Drained Lower Reservoir Chest", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Drained Lower Reservoir Burrito 1", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Drained Lower Reservoir Burrito 2", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Lower Reservoir Hole Pot 1", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Lower Reservoir Hole Pot 2", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Lower Reservoir Hole Pot 3", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Lower Reservoir Hole Sarcophagus", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Drained Upper Reservoir Burrito 1", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Drained Upper Reservoir Burrito 2", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Drained Upper Reservoir Burrito 3", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) add_rule(world.get_location("Hylemxylem: Upper Reservoir Hole Key", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) # extra rules if Extra Items in Logic is enabled if world.extra_items_in_logic[player]: for i in world.get_region("Foglast", player).entrances: - add_rule(i, lambda state: state._hylics2_has_charge_up(player)) + add_rule(i, lambda state: charge_up(state, player)) for i in world.get_region("Sage Airship", player).entrances: - add_rule(i, lambda state: state._hylics2_has_charge_up(player) and state._hylics2_has_cup(player) and\ - state._hylics2_has_worm_room_key(player)) + add_rule(i, lambda state: ( + charge_up(state, player) + and paper_cup(state, player) + and worm_room_key(state, player) + )) for i in world.get_region("Hylemxylem", player).entrances: - add_rule(i, lambda state: state._hylics2_has_charge_up(player) and state._hylics2_has_cup(player)) + add_rule(i, lambda state: ( + charge_up(state, player) + and paper_cup(state, player) + )) add_rule(world.get_location("Sage Labyrinth: Motor Hunter Sarcophagus", player), - lambda state: state._hylics2_has_charge_up(player) and state._hylics2_has_cup(player)) + lambda state: ( + charge_up(state, player) + and paper_cup(state, player) + )) # extra rules if Shuffle Party Members is enabled if world.party_shuffle[player]: for i in world.get_region("Arcade Island", player).entrances: - add_rule(i, lambda state: state._hylics2_has_3_members(player)) + add_rule(i, lambda state: party_3(state, player)) for i in world.get_region("Foglast", player).entrances: - add_rule(i, lambda state: state._hylics2_has_3_members(player) or\ - (state._hylics2_has_2_members(player) and state._hylics2_has_jail_key(player))) + add_rule(i, lambda state: ( + party_3(state, player) + or ( + party_2(state, player) + and jail_key(state, player) + ) + )) for i in world.get_region("Sage Airship", player).entrances: - add_rule(i, lambda state: state._hylics2_has_3_members(player)) + add_rule(i, lambda state: party_3(state, player)) for i in world.get_region("Hylemxylem", player).entrances: - add_rule(i, lambda state: state._hylics2_has_3_members(player)) + add_rule(i, lambda state: party_3(state, player)) add_rule(world.get_location("Viewax's Edifice: Defeat Viewax", player), - lambda state: state._hylics2_has_2_members(player)) + lambda state: party_2(state, player)) add_rule(world.get_location("New Muldul: Rescued Blerol 1", player), - lambda state: state._hylics2_has_2_members(player)) + lambda state: party_2(state, player)) add_rule(world.get_location("New Muldul: Rescued Blerol 2", player), - lambda state: state._hylics2_has_2_members(player)) + lambda state: party_2(state, player)) add_rule(world.get_location("New Muldul: Vault Left Chest", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("New Muldul: Vault Right Chest", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("New Muldul: Vault Bomb", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("Juice Ranch: Battle with Somsnosa", player), - lambda state: state._hylics2_has_2_members(player)) + lambda state: party_2(state, player)) add_rule(world.get_location("Juice Ranch: Somsnosa Joins", player), - lambda state: state._hylics2_has_2_members(player)) + lambda state: party_2(state, player)) add_rule(world.get_location("Airship: Talk to Somsnosa", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("Sage Labyrinth: Motor Hunter Sarcophagus", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) # extra rules if Shuffle Red Medallions is enabled if world.medallion_shuffle[player]: add_rule(world.get_location("New Muldul: Upper House Medallion", player), - lambda state: state._hylics2_has_upper_house_key(player)) + lambda state: upper_house_key(state, player)) add_rule(world.get_location("New Muldul: Vault Rear Left Medallion", player), - lambda state: state._hylics2_enter_foglast(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + enter_foglast(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("New Muldul: Vault Rear Right Medallion", player), - lambda state: state._hylics2_enter_foglast(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + enter_foglast(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("New Muldul: Vault Center Medallion", player), - lambda state: state._hylics2_enter_foglast(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + enter_foglast(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("New Muldul: Vault Front Left Medallion", player), - lambda state: state._hylics2_enter_foglast(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + enter_foglast(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("New Muldul: Vault Front Right Medallion", player), - lambda state: state._hylics2_enter_foglast(player) and state._hylics2_has_bridge_key(player)) + lambda state: ( + enter_foglast(state, player) + and bridge_key(state, player) + )) add_rule(world.get_location("Viewax's Edifice: Fort Wall Medallion", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Jar Medallion", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Viewax's Edifice: Sage Chair Medallion", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Arcade 1: Lonely Medallion", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Arcade 1: Alcove Medallion", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Foglast: Under Lair Medallion", player), - lambda state: state._hylics2_has_bridge_key(player)) + lambda state: bridge_key(state, player)) add_rule(world.get_location("Foglast: Mid-Air Medallion", player), - lambda state: state._hylics2_can_air_dash(player)) + lambda state: air_dash(state, player)) add_rule(world.get_location("Foglast: Top of Tower Medallion", player), - lambda state: state._hylics2_has_paddle(player)) + lambda state: paddle(state, player)) add_rule(world.get_location("Hylemxylem: Lower Reservoir Hole Medallion", player), - lambda state: state._hylics2_has_upper_chamber_key(player)) + lambda state: upper_chamber_key(state, player)) - # extra rules is Shuffle Red Medallions and Party Shuffle are enabled + # extra rules if Shuffle Red Medallions and Party Shuffle are enabled if world.party_shuffle[player] and world.medallion_shuffle[player]: add_rule(world.get_location("New Muldul: Vault Rear Left Medallion", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("New Muldul: Vault Rear Right Medallion", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("New Muldul: Vault Center Medallion", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("New Muldul: Vault Front Left Medallion", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) add_rule(world.get_location("New Muldul: Vault Front Right Medallion", player), - lambda state: state._hylics2_has_3_members(player)) + lambda state: party_3(state, player)) # entrances for i in world.get_region("Airship", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Arcade Island", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player) and state._hylics2_can_air_dash(player)) + add_rule(i, lambda state: ( + airship(state, player) + and air_dash(state, player) + )) for i in world.get_region("Worm Pod", player).entrances: - add_rule(i, lambda state: state._hylics2_enter_wormpod(player)) + add_rule(i, lambda state: enter_wormpod(state, player)) for i in world.get_region("Foglast", player).entrances: - add_rule(i, lambda state: state._hylics2_enter_foglast(player)) + add_rule(i, lambda state: enter_foglast(state, player)) for i in world.get_region("Sage Labyrinth", player).entrances: - add_rule(i, lambda state: state._hylics2_has_skull_bomb(player)) + add_rule(i, lambda state: skull_bomb(state, player)) for i in world.get_region("Sage Airship", player).entrances: - add_rule(i, lambda state: state._hylics2_enter_sageship(player)) + add_rule(i, lambda state: enter_sageship(state, player)) for i in world.get_region("Hylemxylem", player).entrances: - add_rule(i, lambda state: state._hylics2_enter_hylemxylem(player)) + add_rule(i, lambda state: enter_hylemxylem(state, player)) # random start logic (default) if ((not world.random_start[player]) or \ (world.random_start[player] and hylics2world.start_location == "Waynehouse")): # entrances for i in world.get_region("Viewax", player).entrances: - add_rule(i, lambda state: state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player)) + add_rule(i, lambda state: ( + air_dash(state, player) + and airship(state, player) + )) for i in world.get_region("TV Island", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Shield Facility", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Juice Ranch", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) # random start logic (Viewax's Edifice) elif (world.random_start[player] and hylics2world.start_location == "Viewax's Edifice"): for i in world.get_region("Waynehouse", player).entrances: - add_rule(i, lambda state: state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player)) + add_rule(i, lambda state: ( + air_dash(state, player) + or airship(state, player) + )) for i in world.get_region("New Muldul", player).entrances: - add_rule(i, lambda state: state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player)) + add_rule(i, lambda state: ( + air_dash(state, player) + or airship(state, player) + )) for i in world.get_region("New Muldul Vault", player).entrances: - add_rule(i, lambda state: state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player)) + add_rule(i, lambda state: ( + air_dash(state, player) + or airship(state, player) + )) for i in world.get_region("Drill Castle", player).entrances: - add_rule(i, lambda state: state._hylics2_can_air_dash(player) or state._hylics2_has_airship(player)) + add_rule(i, lambda state: ( + air_dash(state, player) + or airship(state, player) + )) for i in world.get_region("TV Island", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Shield Facility", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Juice Ranch", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Sage Labyrinth", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) # random start logic (TV Island) elif (world.random_start[player] and hylics2world.start_location == "TV Island"): for i in world.get_region("Waynehouse", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("New Muldul", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("New Muldul Vault", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Drill Castle", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Viewax", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Shield Facility", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Juice Ranch", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Sage Labyrinth", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) # random start logic (Shield Facility) elif (world.random_start[player] and hylics2world.start_location == "Shield Facility"): for i in world.get_region("Waynehouse", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("New Muldul", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("New Muldul Vault", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Drill Castle", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Viewax", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("TV Island", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) for i in world.get_region("Sage Labyrinth", player).entrances: - add_rule(i, lambda state: state._hylics2_has_airship(player)) + add_rule(i, lambda state: airship(state, player)) \ No newline at end of file diff --git a/worlds/hylics2/__init__.py b/worlds/hylics2/__init__.py index f721fb4749..19d901bf5a 100644 --- a/worlds/hylics2/__init__.py +++ b/worlds/hylics2/__init__.py @@ -130,11 +130,11 @@ class Hylics2World(World): tvs = list(Locations.tv_location_table.items()) # if Extra Items in Logic is enabled place CHARGE UP first and make sure it doesn't get - # placed at Sage Airship: TV + # placed at Sage Airship: TV or Foglast: TV if self.multiworld.extra_items_in_logic[self.player]: tv = self.multiworld.random.choice(tvs) gest = gestures.index((200681, Items.gesture_item_table[200681])) - while tv[1]["name"] == "Sage Airship: TV": + while tv[1]["name"] == "Sage Airship: TV" or tv[1]["name"] == "Foglast: TV": tv = self.multiworld.random.choice(tvs) self.multiworld.get_location(tv[1]["name"], self.player)\ .place_locked_item(self.add_item(gestures[gest][1]["name"], gestures[gest][1]["classification"], @@ -146,7 +146,7 @@ class Hylics2World(World): gest = self.multiworld.random.choice(gestures) tv = self.multiworld.random.choice(tvs) self.multiworld.get_location(tv[1]["name"], self.player)\ - .place_locked_item(self.add_item(gest[1]["name"], gest[1]["classification"], gest[1])) + .place_locked_item(self.add_item(gest[1]["name"], gest[1]["classification"], gest[0])) gestures.remove(gest) tvs.remove(tv) @@ -232,8 +232,10 @@ class Hylics2World(World): # create location for beating the game and place Victory event there loc = Location(self.player, "Defeat Gibby", None, self.multiworld.get_region("Hylemxylem", self.player)) loc.place_locked_item(self.create_event("Victory")) - set_rule(loc, lambda state: state._hylics2_has_upper_chamber_key(self.player) - and state._hylics2_has_vessel_room_key(self.player)) + set_rule(loc, lambda state: ( + state.has("UPPER CHAMBER KEY", self.player) + and state.has("VESSEL ROOM KEY", self.player) + )) self.multiworld.get_region("Hylemxylem", self.player).locations.append(loc) self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) diff --git a/worlds/ladx/docs/setup_en.md b/worlds/ladx/docs/setup_en.md index 538d70d45e..e21c5bddc4 100644 --- a/worlds/ladx/docs/setup_en.md +++ b/worlds/ladx/docs/setup_en.md @@ -40,7 +40,7 @@ your personal settings and export a config file from them. ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the -[YAML Validator](/mysterycheck) page. +[YAML Validator](/check) page. ## Generating a Single-Player Game diff --git a/worlds/lufia2ac/Items.py b/worlds/lufia2ac/Items.py index 20159f480a..190b913c8e 100644 --- a/worlds/lufia2ac/Items.py +++ b/worlds/lufia2ac/Items.py @@ -2,9 +2,8 @@ from enum import auto, Enum from typing import Dict, NamedTuple, Optional from BaseClasses import Item, ItemClassification -from . import Locations -start_id: int = Locations.start_id +start_id: int = 0xAC0000 class ItemType(Enum): @@ -500,7 +499,7 @@ l2ac_item_table: Dict[str, ItemData] = { # 0x01C8: "Key28" # 0x01C9: "Key29" # 0x01CA: "AP item" # replaces "Key30" - # 0x01CB: "Crown" + # 0x01CB: "SOLD OUT" # replaces "Crown" # 0x01CC: "Ruby apple" # 0x01CD: "PURIFIA" # 0x01CE: "Tag ring" diff --git a/worlds/lufia2ac/Options.py b/worlds/lufia2ac/Options.py index c3bbadc9ba..783da8e407 100644 --- a/worlds/lufia2ac/Options.py +++ b/worlds/lufia2ac/Options.py @@ -1,12 +1,16 @@ from __future__ import annotations +import functools +import numbers import random from dataclasses import dataclass from itertools import accumulate, chain, combinations from typing import Any, cast, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Type, TYPE_CHECKING, Union -from Options import AssembleOptions, Choice, DeathLink, ItemDict, Range, SpecialRange, TextChoice, Toggle +from Options import AssembleOptions, Choice, DeathLink, ItemDict, OptionDict, PerGameCommonOptions, Range, \ + SpecialRange, TextChoice, Toggle from .Enemies import enemy_name_to_sprite +from .Items import ItemType, l2ac_item_table if TYPE_CHECKING: from BaseClasses import PlandoOptions @@ -557,6 +561,25 @@ class Goal(Choice): default = option_boss +class GoldModifier(Range): + """Percentage modifier for gold gained from enemies. + + Supported values: 25 – 400 + Default value: 100 (same as in an unmodified game) + """ + + display_name = "Gold modifier" + range_start = 25 + range_end = 400 + default = 100 + + def __call__(self, gold: bytes) -> bytes: + try: + return (int.from_bytes(gold, "little") * self.value // 100).to_bytes(2, "little") + except OverflowError: + return b"\xFF\xFF" + + class HealingFloorChance(Range): """The chance of a floor having a healing tile hidden under a bush. @@ -661,6 +684,105 @@ class RunSpeed(Choice): default = option_disabled +class ShopInterval(SpecialRange): + """Place shops after a certain number of floors. + + E.g., if you set this to 5, then you will be given the opportunity to shop after completing B5, B10, B15, etc., + whereas if you set it to 1, then there will be a shop after every single completed floor. + Shops will offer a random selection of wares; on deeper floors, more expensive items might appear. + You can customize the stock that can appear in shops using the shop_inventory option. + You can control how much gold you will be obtaining from enemies using the gold_multiplier option. + Supported values: disabled, 1 – 10 + Default value: disabled (same as in an unmodified game) + """ + + display_name = "Shop interval" + range_start = 0 + range_end = 10 + default = 0 + special_range_cutoff = 1 + special_range_names = { + "disabled": 0, + } + + +class ShopInventory(OptionDict): + """Determine the item types that can appear in shops. + + The value of this option should be a mapping of item categories (or individual items) to weights (non-negative + integers), which are used as relative probabilities when it comes to including these things in shops. (The actual + contents of the generated shops are selected randomly and are subject to additional constraints such as more + expensive things being allowed only on later floors.) + Supported keys: + non_restorative — a selection of mostly non-restorative red chest consumables + restorative — all HP- or MP-restoring red chest consumables + blue_chest — all blue chest items + spell — all red chest spells + gear — all red chest armors, shields, headgear, rings, and rocks (this respects the gear_variety_after_b9 option, + meaning that you will not encounter any shields, headgear, rings, or rocks in shops from B10 onward unless you + also enabled that option) + weapon — all red chest weapons + Additionally, you can also add extra weights for any specific cave item. If you want your shops to have a + higher than normal chance of selling a Dekar blade, you can, e.g., add "Dekar blade: 5". + You can even forego the predefined categories entirely and design a custom shop pool from scratch by providing + separate weights for each item you want to include. + (Spells, however, cannot be weighted individually and are only available as part of the "spell" category.) + Default value: {spell: 30, gear: 45, weapon: 82} + """ + + display_name = "Shop inventory" + _special_keys = {"non_restorative", "restorative", "blue_chest", "spell", "gear", "weapon"} + valid_keys = _special_keys | {item for item, data in l2ac_item_table.items() + if data.type in {ItemType.BLUE_CHEST, ItemType.ENEMY_DROP, ItemType.ENTRANCE_CHEST, + ItemType.RED_CHEST, ItemType.RED_CHEST_PATCH}} + default: Dict[str, int] = { + "spell": 30, + "gear": 45, + "weapon": 82, + } + value: Dict[str, int] + + def verify(self, world: Type[World], player_name: str, plando_options: PlandoOptions) -> None: + super().verify(world, player_name, plando_options) + for item, weight in self.value.items(): + if not isinstance(weight, numbers.Integral) or weight < 0: + raise Exception(f"Weight for item \"{item}\" from option {self} must be a non-negative integer, " + f"but was \"{weight}\".") + + @property + def total(self) -> int: + return sum(self.value.values()) + + @property + def non_restorative(self) -> int: + return self.value.get("non_restorative", 0) + + @property + def restorative(self) -> int: + return self.value.get("restorative", 0) + + @property + def blue_chest(self) -> int: + return self.value.get("blue_chest", 0) + + @property + def spell(self) -> int: + return self.value.get("spell", 0) + + @property + def gear(self) -> int: + return self.value.get("gear", 0) + + @property + def weapon(self) -> int: + return self.value.get("weapon", 0) + + @functools.cached_property + def custom(self) -> Dict[int, int]: + return {l2ac_item_table[item].code & 0x01FF: weight for item, weight in self.value.items() + if item not in self._special_keys} + + class ShuffleCapsuleMonsters(Toggle): """Shuffle the capsule monsters into the multiworld. @@ -697,7 +819,7 @@ class ShufflePartyMembers(Toggle): @dataclass -class L2ACOptions: +class L2ACOptions(PerGameCommonOptions): blue_chest_chance: BlueChestChance blue_chest_count: BlueChestCount boss: Boss @@ -716,6 +838,7 @@ class L2ACOptions: final_floor: FinalFloor gear_variety_after_b9: GearVarietyAfterB9 goal: Goal + gold_modifier: GoldModifier healing_floor_chance: HealingFloorChance initial_floor: InitialFloor iris_floor_chance: IrisFloorChance @@ -723,5 +846,7 @@ class L2ACOptions: master_hp: MasterHp party_starting_level: PartyStartingLevel run_speed: RunSpeed + shop_interval: ShopInterval + shop_inventory: ShopInventory shuffle_capsule_monsters: ShuffleCapsuleMonsters shuffle_party_members: ShufflePartyMembers diff --git a/worlds/lufia2ac/Utils.py b/worlds/lufia2ac/Utils.py index 6c2e28d137..1fd7e0e171 100644 --- a/worlds/lufia2ac/Utils.py +++ b/worlds/lufia2ac/Utils.py @@ -1,5 +1,7 @@ +import itertools +from operator import itemgetter from random import Random -from typing import Dict, List, MutableSequence, Sequence, Set, Tuple +from typing import Dict, Iterable, List, MutableSequence, Sequence, Set, Tuple def constrained_choices(population: Sequence[int], d: int, *, k: int, random: Random) -> List[int]: @@ -19,3 +21,10 @@ def constrained_shuffle(x: MutableSequence[int], d: int, random: Random) -> None i, j = random.randrange(n), random.randrange(n) if x[i] in constraints[j] and x[j] in constraints[i]: x[i], x[j] = x[j], x[i] + + +def weighted_sample(population: Iterable[int], weights: Iterable[float], k: int, *, random: Random) -> List[int]: + population, keys = zip(*((item, pow(random.random(), 1 / group_weight)) + for item, group in itertools.groupby(sorted(zip(population, weights)), key=itemgetter(0)) + if (group_weight := sum(weight for _, weight in group)))) + return sorted(population, key=dict(zip(population, keys)).__getitem__)[-k:] diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py index f14048a2c4..acb988daaf 100644 --- a/worlds/lufia2ac/__init__.py +++ b/worlds/lufia2ac/__init__.py @@ -2,11 +2,11 @@ import base64 import itertools import os from enum import IntFlag -from typing import Any, ClassVar, Dict, get_type_hints, Iterator, List, Set, Tuple +from typing import Any, ClassVar, Dict, Iterator, List, Set, Tuple, Type import settings from BaseClasses import Item, ItemClassification, Location, MultiWorld, Region, Tutorial -from Options import AssembleOptions +from Options import PerGameCommonOptions from Utils import __version__ from worlds.AutoWorld import WebWorld, World from worlds.generic.Rules import add_rule, set_rule @@ -14,9 +14,9 @@ from .Client import L2ACSNIClient # noqa: F401 from .Items import ItemData, ItemType, l2ac_item_name_to_id, l2ac_item_table, L2ACItem, start_id as items_start_id from .Locations import l2ac_location_name_to_id, L2ACLocation from .Options import CapsuleStartingLevel, DefaultParty, EnemyFloorNumbers, EnemyMovementPatterns, EnemySprites, \ - ExpModifier, Goal, L2ACOptions + Goal, L2ACOptions from .Rom import get_base_rom_bytes, get_base_rom_path, L2ACDeltaPatch -from .Utils import constrained_choices, constrained_shuffle +from .Utils import constrained_choices, constrained_shuffle, weighted_sample from .basepatch import apply_basepatch CHESTS_PER_SPHERE: int = 5 @@ -54,7 +54,8 @@ class L2ACWorld(World): game: ClassVar[str] = "Lufia II Ancient Cave" web: ClassVar[WebWorld] = L2ACWeb() - option_definitions: ClassVar[Dict[str, AssembleOptions]] = get_type_hints(L2ACOptions) + options_dataclass: ClassVar[Type[PerGameCommonOptions]] = L2ACOptions + options: L2ACOptions settings: ClassVar[L2ACSettings] item_name_to_id: ClassVar[Dict[str, int]] = l2ac_item_name_to_id location_name_to_id: ClassVar[Dict[str, int]] = l2ac_location_name_to_id @@ -87,7 +88,7 @@ class L2ACWorld(World): bytearray(f"L2AC{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21] self.rom_name.extend([0] * (21 - len(self.rom_name))) - self.o = L2ACOptions(**{opt: getattr(self.multiworld, opt)[self.player] for opt in self.option_definitions}) + self.o = self.options if self.o.blue_chest_count < self.o.custom_item_pool.count: raise ValueError(f"Number of items in custom_item_pool ({self.o.custom_item_pool.count}) is " @@ -221,6 +222,7 @@ class L2ACWorld(World): rom_bytearray[0x09D59B:0x09D59B + 256] = self.get_node_connection_table() rom_bytearray[0x0B05C0:0x0B05C0 + 18843] = self.get_enemy_stats() rom_bytearray[0x0B4F02:0x0B4F02 + 2] = self.o.master_hp.value.to_bytes(2, "little") + rom_bytearray[0x0BEE9F:0x0BEE9F + 1948] = self.get_shops() rom_bytearray[0x280010:0x280010 + 2] = self.o.blue_chest_count.value.to_bytes(2, "little") rom_bytearray[0x280012:0x280012 + 3] = self.o.capsule_starting_level.xp.to_bytes(3, "little") rom_bytearray[0x280015:0x280015 + 1] = self.o.initial_floor.value.to_bytes(1, "little") @@ -228,6 +230,7 @@ class L2ACWorld(World): rom_bytearray[0x280017:0x280017 + 1] = self.o.iris_treasures_required.value.to_bytes(1, "little") rom_bytearray[0x280018:0x280018 + 1] = self.o.shuffle_party_members.unlock.to_bytes(1, "little") rom_bytearray[0x280019:0x280019 + 1] = self.o.shuffle_capsule_monsters.unlock.to_bytes(1, "little") + rom_bytearray[0x28001A:0x28001A + 1] = self.o.shop_interval.value.to_bytes(1, "little") rom_bytearray[0x280030:0x280030 + 1] = self.o.goal.value.to_bytes(1, "little") rom_bytearray[0x28003D:0x28003D + 1] = self.o.death_link.value.to_bytes(1, "little") rom_bytearray[0x281200:0x281200 + 470] = self.get_capsule_cravings_table() @@ -356,7 +359,7 @@ class L2ACWorld(World): def get_enemy_stats(self) -> bytes: rom: bytes = get_base_rom_bytes() - if self.o.exp_modifier == ExpModifier.default: + if self.o.exp_modifier == 100 and self.o.gold_modifier == 100: return rom[0x0B05C0:0x0B05C0 + 18843] number_of_enemies: int = 224 @@ -365,6 +368,7 @@ class L2ACWorld(World): for enemy_id in range(number_of_enemies): pointer: int = int.from_bytes(enemy_stats[2 * enemy_id:2 * enemy_id + 2], "little") enemy_stats[pointer + 29:pointer + 31] = self.o.exp_modifier(enemy_stats[pointer + 29:pointer + 31]) + enemy_stats[pointer + 31:pointer + 33] = self.o.gold_modifier(enemy_stats[pointer + 31:pointer + 33]) return enemy_stats def get_goal_text_bytes(self) -> bytes: @@ -382,6 +386,90 @@ class L2ACWorld(World): goal_text_bytes = bytes((0x08, *b"\x03".join(line.encode("ascii") for line in goal_text), 0x00)) return goal_text_bytes + b"\x00" * (147 - len(goal_text_bytes)) + def get_shops(self) -> bytes: + rom: bytes = get_base_rom_bytes() + + if not self.o.shop_interval: + return rom[0x0BEE9F:0x0BEE9F + 1948] + + non_restorative_ids = {int.from_bytes(rom[0x0A713D + 2 * i:0x0A713D + 2 * i + 2], "little") for i in range(31)} + restorative_ids = {int.from_bytes(rom[0x08FFDC + 2 * i:0x08FFDC + 2 * i + 2], "little") for i in range(9)} + blue_ids = {int.from_bytes(rom[0x0A6EA0 + 2 * i:0x0A6EA0 + 2 * i + 2], "little") for i in range(41)} + number_of_spells: int = 35 + number_of_items: int = 467 + spells_offset: int = 0x0AFA5B + items_offset: int = 0x0B4F69 + non_restorative_list: List[List[int]] = [list() for _ in range(99)] + restorative_list: List[List[int]] = [list() for _ in range(99)] + blue_list: List[List[int]] = [list() for _ in range(99)] + spell_list: List[List[int]] = [list() for _ in range(99)] + gear_list: List[List[int]] = [list() for _ in range(99)] + weapon_list: List[List[int]] = [list() for _ in range(99)] + custom_list: List[List[int]] = [list() for _ in range(99)] + + for spell_id in range(number_of_spells): + pointer: int = int.from_bytes(rom[spells_offset + 2 * spell_id:spells_offset + 2 * spell_id + 2], "little") + value: int = int.from_bytes(rom[spells_offset + pointer + 15:spells_offset + pointer + 17], "little") + for f in range(value // 1000, 99): + spell_list[f].append(spell_id) + for item_id in range(number_of_items): + pointer = int.from_bytes(rom[items_offset + 2 * item_id:items_offset + 2 * item_id + 2], "little") + buckets: List[List[List[int]]] = list() + if item_id in non_restorative_ids: + buckets.append(non_restorative_list) + if item_id in restorative_ids: + buckets.append(restorative_list) + if item_id in blue_ids: + buckets.append(blue_list) + if not rom[items_offset + pointer] & 0x20 and not rom[items_offset + pointer + 1] & 0x20: + category: int = rom[items_offset + pointer + 7] + if category >= 0x02: + buckets.append(gear_list) + elif category == 0x01: + buckets.append(weapon_list) + if item_id in self.o.shop_inventory.custom: + buckets.append(custom_list) + value = int.from_bytes(rom[items_offset + pointer + 5:items_offset + pointer + 7], "little") + for bucket in buckets: + for f in range(value // 1000, 99): + bucket[f].append(item_id) + + if not self.o.gear_variety_after_b9: + for f in range(99): + del gear_list[f][len(gear_list[f]) % 128:] + + def create_shop(floor: int) -> Tuple[int, ...]: + if self.random.randrange(self.o.shop_inventory.total) < self.o.shop_inventory.spell: + return create_spell_shop(floor) + else: + return create_item_shop(floor) + + def create_spell_shop(floor: int) -> Tuple[int, ...]: + spells = self.random.sample(spell_list[floor], 3) + return 0x03, 0x20, 0x00, *spells, 0xFF + + def create_item_shop(floor: int) -> Tuple[int, ...]: + population = non_restorative_list[floor] + restorative_list[floor] + blue_list[floor] \ + + gear_list[floor] + weapon_list[floor] + custom_list[floor] + weights = itertools.chain(*([weight / len_] * len_ if (len_ := len(list_)) else [] for weight, list_ in + [(self.o.shop_inventory.non_restorative, non_restorative_list[floor]), + (self.o.shop_inventory.restorative, restorative_list[floor]), + (self.o.shop_inventory.blue_chest, blue_list[floor]), + (self.o.shop_inventory.gear, gear_list[floor]), + (self.o.shop_inventory.weapon, weapon_list[floor])]), + (self.o.shop_inventory.custom[item] for item in custom_list[floor])) + items = weighted_sample(population, weights, 5, random=self.random) + return 0x01, 0x04, 0x00, *(b for item in items for b in item.to_bytes(2, "little")), 0x00, 0x00 + + shops = [create_shop(floor) + for floor in range(self.o.shop_interval, 99, self.o.shop_interval) + for _ in range(self.o.shop_interval)] + shop_pointers = itertools.accumulate((len(shop) for shop in shops[:-1]), initial=2 * len(shops)) + shop_bytes = bytes(itertools.chain(*(p.to_bytes(2, "little") for p in shop_pointers), *shops)) + + assert len(shop_bytes) <= 1948, shop_bytes + return shop_bytes.ljust(1948, b"\x00") + @staticmethod def get_node_connection_table() -> bytes: class Connect(IntFlag): diff --git a/worlds/lufia2ac/basepatch/basepatch.asm b/worlds/lufia2ac/basepatch/basepatch.asm index aeae6846e3..f298a1129d 100644 --- a/worlds/lufia2ac/basepatch/basepatch.asm +++ b/worlds/lufia2ac/basepatch/basepatch.asm @@ -71,6 +71,11 @@ org $9EDD60 ; name org $9FA900 ; sprite incbin "ap_logo/ap_logo.bin" warnpc $9FA980 +; sold out item +org $96F9BA ; properties + DB $00,$00,$00,$10,$00,$00,$00,$00,$00,$00,$00,$00,$00 +org $9EDD6C ; name + DB "SOLD OUT " ; overwrites "Crown " org $D08000 ; signature, start of expanded data area @@ -825,6 +830,119 @@ SpellRNG: +; shops +pushpc +org $83B442 + ; DB=$83, x=1, m=1 + JSL Shop ; overwrites STA $7FD0BF +pullpc + +Shop: + STA $7FD0BF ; (overwritten instruction) + LDY $05AC ; load map number + CPY.b #$F0 ; check if ancient cave + BCC + + LDA $05B4 ; check if going to ancient cave entrance + BEQ + + LDA $7FE696 ; load next to next floor number + DEC + CPY.b #$F1 ; check if going to final floor + BCS ++ ; skip a decrement because next floor number is not incremented on final floor + DEC +++: CMP $D08015 ; check if past initial floor + BCC + + STA $4204 ; WRDIVL; dividend = floor number + STZ $4205 ; WRDIVH + TAX + LDA $D0801A + STA $4206 ; WRDIVB; divisor = shop_interval + STA $211C ; M7B; second factor = shop_interval + JSL $8082C7 ; advance RNG (while waiting for division to complete) + LDY $4216 ; RDMPYL; skip if remainder (i.e., floor number mod shop_interval) is not 0 + BNE + + STA $211B + STZ $211B ; M7A; first factor = random number from 0 to 255 + TXA + CLC + SBC $2135 ; MPYM; calculate (floor number) - (random number from 0 to shop_interval-1) - 1 + STA $30 ; set shop id + STZ $05A8 ; initialize variable for sold out item tracking + STZ $05A9 + PHB + PHP + JML $80A33A ; open shop menu ++: RTL + +; shop item select +pushpc +org $82DF50 + ; DB=$83, x=0, m=1 + JML ShopItemSelected ; overwrites JSR $8B08 : CMP.b #$01 +pullpc + +ShopItemSelected: + LDA $1548 ; check inventory free space + BEQ + + JSR LoadShopSlotAsFlag + BIT $05A8 ; test item not already sold + BNE + + JML $82DF79 ; skip quantity selection and go directly to buy/equip ++: JML $82DF80 ; abort and go back to item selection + +; track bought shop items +pushpc +org $82E084 + ; DB=$83, x=0, m=1 + JSL ShopBuy ; overwrites LDA.b #$05 : LDX.w #$0007 + NOP +org $82E10E + ; DB=$83, x=0, m=1 + JSL ShopEquip ; overwrites SEP #$10 : LDX $14DC + NOP +pullpc + +ShopBuy: + JSR LoadShopSlotAsFlag + TSB $05A8 ; mark item as sold + LDA.b #$05 ; (overwritten instruction) + LDX.w #$0007 ; (overwritten instruction) + RTL + +ShopEquip: + JSR LoadShopSlotAsFlag + TSB $05A8 ; mark item as sold + SEP #$10 ; (overwritten instruction) + LDX $14DC ; (overwritten instruction) + RTL + +LoadShopSlotAsFlag: + TDC + LDA $14EC ; load currently selected shop slot number + ASL + TAX + LDA $8ED8C3,X ; load predefined bitmask with a single bit set + RTS + +; mark bought items as sold out +pushpc +org $8285EA + ; DB=$83, x=0, m=0 + JSL SoldOut ; overwrites LDA [$FC],Y : AND #$01FF + NOP +pullpc + +SoldOut: + LDA $8ED8C3,X ; load predefined bitmask with a single bit set + BIT $05A8 ; test sold items + BEQ + + LDA.w #$01CB ; load sold out item id + BRA ++ ++: LDA [$FC],Y ; (overwritten instruction) + AND #$01FF ; (overwritten instruction) +++: RTL + + + ; increase variety of red chest gear after B9 pushpc org $839176 @@ -1009,6 +1127,53 @@ pullpc +; door stairs fix +pushpc +org $839453 + ; DB=$7F, x=0, m=1 + JSL DoorStairsFix ; overwrites JSR $9B18 : JSR $9D11 + NOP #2 +pullpc + +DoorStairsFix: + CLC + LDY.w #$0000 +--: LDX.w #$00FF ; loop through floor layout starting from the bottom right +-: LDA $EA00,X ; read node contents + BEQ + ; always skip empty nodes + BCC ++ ; 1st pass: skip all blocked nodes (would cause door stairs or rare stairs) + LDA $E9F0,X ; 2nd pass: skip only if the one above is also blocked (would cause door stairs) +++: BMI + + INY ; count usable nodes ++: DEX + BPL - + TYA + BNE ++ ; all nodes blocked? + SEC ; set up 2nd, less restrictive pass + BRA -- +++: JSL $8082C7 ; advance RNG + STA $00211B + TDC + STA $00211B ; M7A; first factor = random number from 0 to 255 + TYA + STA $00211C ; M7B; second factor = number of possible stair positions + LDA $002135 ; MPYM; calculate random number from 0 to number of possible stair positions - 1 + TAY + LDX.w #$00FF ; loop through floor layout starting from the bottom right +-: LDA $EA00,X ; read node contents + BEQ + ; always skip empty nodes + BCC ++ ; if 1st pass was sufficient: skip all blocked nodes (prevent door stairs and rare stairs) + LDA $E9F0,X ; if 2nd pass was needed: skip only if the one above is also blocked (prevent door stairs) +++: BMI + + DEY ; count down to locate the (Y+1)th usable node + BMI ++ ++: DEX + BPL - +++: TXA ; return selected stair node coordinate + RTL + + + ; equipment text fix pushpc org $81F2E3 @@ -1054,6 +1219,7 @@ pullpc ; $F02017 1 iris treasures required ; $F02018 1 party members available ; $F02019 1 capsule monsters available +; $F0201A 1 shop interval ; $F02030 1 selected goal ; $F02031 1 goal completion: boss ; $F02032 1 goal completion: iris_treasure_hunt diff --git a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 index 51478e5d52..4ed1815039 100644 Binary files a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 and b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 differ diff --git a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md index 64658a7d27..d24c4ef9f9 100644 --- a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md +++ b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md @@ -49,8 +49,9 @@ Your Party Leader will hold up the item they received when not in a fight or in - Customize the multiworld item pool. (By default, your pool is filled with random blue chest items, but you can place any cave item you want instead) - Customize start inventory, i.e., begin every run with certain items or spells of your choice -- Adjust how much EXP is gained from enemies +- Adjust how much EXP and gold is gained from enemies - Randomize enemy movement patterns, enemy sprites, and which enemy types can appear at which floor numbers +- Option to make shops appear in the cave so that you have a way to spend your hard-earned gold - Option to shuffle your party members and/or capsule monsters into the multiworld, meaning that someone will have to find them in order to unlock them for you to use. While cave diving, you can add newly unlocked members to your party by using the character items from your inventory @@ -75,7 +76,7 @@ Your Party Leader will hold up the item they received when not in a fight or in ###### Bug fixes: -- Vanilla game bugs that could result in softlocks or save file corruption have been fixed +- Vanilla game bugs that could result in anomalous floors, softlocks, or save file corruption have been fixed - (optional) Bugfix for the algorithm that determines the item pool for red chest gear. Enabling this allows the cave to generate shields, headgear, rings, and jewels in red chests even after floor B9 - (optional) Bugfix for the outlandish cravings of capsule monsters in the US version. Enabling this makes feeding work diff --git a/worlds/lufia2ac/docs/setup_en.md b/worlds/lufia2ac/docs/setup_en.md index 4236c26e8a..3762f32fb4 100644 --- a/worlds/lufia2ac/docs/setup_en.md +++ b/worlds/lufia2ac/docs/setup_en.md @@ -44,7 +44,7 @@ your personal settings and export a config file from them. ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the -[YAML Validator](/mysterycheck) page. +[YAML Validator](/check) page. ## Generating a Single-Player Game diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 4be699e9cf..0771989ffc 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -82,9 +82,7 @@ class MessengerWorld(World): self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) def create_regions(self) -> None: - for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: - if region.name in REGION_CONNECTIONS: - region.add_exits(REGION_CONNECTIONS[region.name]) + self.multiworld.regions += [MessengerRegion(reg_name, self) for reg_name in REGIONS] def create_items(self) -> None: # create items that are always in the item pool @@ -138,6 +136,8 @@ class MessengerWorld(World): self.multiworld.itempool += itempool def set_rules(self) -> None: + for reg_name, connections in REGION_CONNECTIONS.items(): + self.multiworld.get_region(reg_name, self.player).add_exits(connections) logic = self.options.logic_level if logic == Logic.option_normal: MessengerRules(self).set_messenger_rules() diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index c5d90e00c8..ce31d43d60 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -32,7 +32,6 @@ class MessengerRegion(Region): loc_dict = {loc: world.location_name_to_id[loc] if loc in world.location_name_to_id else None for loc in locations} self.add_locations(loc_dict, MessengerLocation) - world.multiworld.regions.append(self) class MessengerLocation(Location): diff --git a/worlds/musedash/Items.py b/worlds/musedash/Items.py index be229228bd..63fd3aa51b 100644 --- a/worlds/musedash/Items.py +++ b/worlds/musedash/Items.py @@ -6,7 +6,7 @@ class SongData(NamedTuple): """Special data container to contain the metadata of each song to make filtering work.""" code: Optional[int] - song_is_free: bool + album: str streamer_mode: bool easy: Optional[int] hard: Optional[int] diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py index 7812e28b7a..1807dce2f9 100644 --- a/worlds/musedash/MuseDashCollection.py +++ b/worlds/musedash/MuseDashCollection.py @@ -1,5 +1,5 @@ from .Items import SongData, AlbumData -from typing import Dict, List, Optional +from typing import Dict, List, Set, Optional from collections import ChainMap @@ -15,13 +15,21 @@ class MuseDashCollections: MUSIC_SHEET_NAME: str = "Music Sheet" MUSIC_SHEET_CODE: int = STARTING_CODE - FREE_ALBUMS = [ + FREE_ALBUMS: List[str] = [ "Default Music", "Budget Is Burning: Nano Core", "Budget Is Burning Vol.1", ] - DIFF_OVERRIDES = [ + MUSE_PLUS_DLC: str = "Muse Plus" + DLC: List[str] = [ + # MUSE_PLUS_DLC, # To be included when OptionSets are rendered as part of basic settings. + # "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026. + "Miku in Museland", # Paid DLC not included in Muse Plus + "MSR Anthology", # Part of Muse Plus. Goes away 20th Jan 2024. + ] + + DIFF_OVERRIDES: List[str] = [ "MuseDash ka nanika hi", "Rush-Hour", "Find this Month's Featured Playlist", @@ -48,8 +56,8 @@ class MuseDashCollections: "Error SFX Trap": STARTING_CODE + 9, } - item_names_to_id = ChainMap({}, sfx_trap_items, vfx_trap_items) - location_names_to_id = ChainMap(song_locations, album_locations) + item_names_to_id: ChainMap = ChainMap({}, sfx_trap_items, vfx_trap_items) + location_names_to_id: ChainMap = ChainMap(song_locations, album_locations) def __init__(self) -> None: self.item_names_to_id[self.MUSIC_SHEET_NAME] = self.MUSIC_SHEET_CODE @@ -70,7 +78,6 @@ class MuseDashCollections: # Data is in the format 'Song|UID|Album|StreamerMode|EasyDiff|HardDiff|MasterDiff|SecretDiff' song_name = sections[0] # [1] is used in the client copy to make sure item id's match. - song_is_free = album in self.FREE_ALBUMS steamer_mode = sections[3] == "True" if song_name in self.DIFF_OVERRIDES: @@ -84,7 +91,7 @@ class MuseDashCollections: diff_of_hard = self.parse_song_difficulty(sections[5]) diff_of_master = self.parse_song_difficulty(sections[6]) - self.song_items[song_name] = SongData(item_id_index, song_is_free, steamer_mode, + self.song_items[song_name] = SongData(item_id_index, album, steamer_mode, diff_of_easy, diff_of_hard, diff_of_master) item_id_index += 1 @@ -102,13 +109,13 @@ class MuseDashCollections: self.song_locations[f"{name}-1"] = location_id_index + 1 location_id_index += 2 - def get_songs_with_settings(self, dlc_songs: bool, streamer_mode_active: bool, + def get_songs_with_settings(self, dlc_songs: Set[str], streamer_mode_active: bool, diff_lower: int, diff_higher: int) -> List[str]: """Gets a list of all songs that match the filter settings. Difficulty thresholds are inclusive.""" filtered_list = [] for songKey, songData in self.song_items.items(): - if not dlc_songs and not songData.song_is_free: + if not self.song_matches_dlc_filter(songData, dlc_songs): continue if streamer_mode_active and not songData.streamer_mode: @@ -128,6 +135,19 @@ class MuseDashCollections: return filtered_list + def song_matches_dlc_filter(self, song: SongData, dlc_songs: Set[str]) -> bool: + if song.album in self.FREE_ALBUMS: + return True + + if song.album in dlc_songs: + return True + + # Muse Plus provides access to any DLC not included as a seperate pack + if song.album not in self.DLC and self.MUSE_PLUS_DLC in dlc_songs: + return True + + return False + def parse_song_difficulty(self, difficulty: str) -> Optional[int]: """Attempts to parse the song difficulty.""" if len(difficulty) <= 0 or difficulty == "?" or difficulty == "¿": diff --git a/worlds/musedash/MuseDashData.txt b/worlds/musedash/MuseDashData.txt index 8d6c3f3753..bd07fef7af 100644 --- a/worlds/musedash/MuseDashData.txt +++ b/worlds/musedash/MuseDashData.txt @@ -51,42 +51,42 @@ Mujinku-Vacuum|0-28|Default Music|False|5|7|11| MilK|0-36|Default Music|False|5|7|9| umpopoff|0-41|Default Music|False|0|?|0| Mopemope|0-45|Default Music|False|4|7|9|11 -The Happycore Idol|43-0|Just as Planned Plus|True|2|5|7| -Amatsumikaboshi|43-1|Just as Planned Plus|True|4|6|8|10 -ARIGA THESIS|43-2|Just as Planned Plus|True|3|6|10| -Night of Nights|43-3|Just as Planned Plus|False|4|7|10| -#Psychedelic_Meguro_River|43-4|Just as Planned Plus|False|3|6|8| -can you feel it|43-5|Just as Planned Plus|False|4|6|8|9 -Midnight O'clock|43-6|Just as Planned Plus|True|3|6|8| -Rin|43-7|Just as Planned Plus|True|5|7|10| -Smile-mileS|43-8|Just as Planned Plus|False|6|8|10| -Believing and Being|43-9|Just as Planned Plus|True|4|6|9| -Catalyst|43-10|Just as Planned Plus|False|5|7|9| -don't!stop!eroero!|43-11|Just as Planned Plus|True|5|7|9| -pa pi pu pi pu pi pa|43-12|Just as Planned Plus|False|6|8|10| -Sand Maze|43-13|Just as Planned Plus|True|6|8|10|11 -Diffraction|43-14|Just as Planned Plus|True|5|8|10| -AKUMU|43-15|Just as Planned Plus|False|4|6|8| -Queen Aluett|43-16|Just as Planned Plus|True|7|9|11| -DROPS|43-17|Just as Planned Plus|False|2|5|8| -Frightfully-insane Flan-chan's frightful song|43-18|Just as Planned Plus|False|5|7|10| -snooze|43-19|Just as Planned Plus|False|5|7|10| -Kuishinbo Hacker feat.Kuishinbo Akachan|43-20|Just as Planned Plus|True|5|7|9| -Inu no outa|43-21|Just as Planned Plus|True|3|5|7| -Prism Fountain|43-22|Just as Planned Plus|True|7|9|11| -Gospel|43-23|Just as Planned Plus|False|4|6|9| +The Happycore Idol|43-0|MD Plus Project|True|2|5|7| +Amatsumikaboshi|43-1|MD Plus Project|True|4|6|8|10 +ARIGA THESIS|43-2|MD Plus Project|True|3|6|10| +Night of Nights|43-3|MD Plus Project|False|4|7|10| +#Psychedelic_Meguro_River|43-4|MD Plus Project|False|3|6|8| +can you feel it|43-5|MD Plus Project|False|4|6|8|9 +Midnight O'clock|43-6|MD Plus Project|True|3|6|8| +Rin|43-7|MD Plus Project|True|5|7|10| +Smile-mileS|43-8|MD Plus Project|False|6|8|10| +Believing and Being|43-9|MD Plus Project|True|4|6|9| +Catalyst|43-10|MD Plus Project|False|5|7|9| +don't!stop!eroero!|43-11|MD Plus Project|True|5|7|9| +pa pi pu pi pu pi pa|43-12|MD Plus Project|False|6|8|10| +Sand Maze|43-13|MD Plus Project|True|6|8|10|11 +Diffraction|43-14|MD Plus Project|True|5|8|10| +AKUMU|43-15|MD Plus Project|False|4|6|8| +Queen Aluett|43-16|MD Plus Project|True|7|9|11| +DROPS|43-17|MD Plus Project|False|2|5|8| +Frightfully-insane Flan-chan's frightful song|43-18|MD Plus Project|False|5|7|10| +snooze|43-19|MD Plus Project|False|5|7|10| +Kuishinbo Hacker feat.Kuishinbo Akachan|43-20|MD Plus Project|True|5|7|9| +Inu no outa|43-21|MD Plus Project|True|3|5|7| +Prism Fountain|43-22|MD Plus Project|True|7|9|11| +Gospel|43-23|MD Plus Project|False|4|6|9| East Ai Li Lovely|62-0|Happy Otaku Pack Vol.17|False|2|4|7| Mori Umi no Fune|62-1|Happy Otaku Pack Vol.17|True|5|7|9| Ooi|62-2|Happy Otaku Pack Vol.17|True|5|7|10| Numatta!!|62-3|Happy Otaku Pack Vol.17|True|5|7|9| -SATELLITE|62-4|Happy Otaku Pack Vol.17|False|5|7|9| +SATELLITE|62-4|Happy Otaku Pack Vol.17|False|5|7|9|10 Fantasia Sonata Colorful feat. V!C|62-5|Happy Otaku Pack Vol.17|True|6|8|11| MuseDash ka nanika hi|61-0|Ola Dash|True|?|?|¿| Aleph-0|61-1|Ola Dash|True|7|9|11| Buttoba Supernova|61-2|Ola Dash|False|5|7|10|11 Rush-Hour|61-3|Ola Dash|False|IG|Jh|a2|Eh 3rd Avenue|61-4|Ola Dash|False|3|5|〇| -WORLDINVADER|61-5|Ola Dash|True|5|8|10| +WORLDINVADER|61-5|Ola Dash|True|5|8|10|11 N3V3R G3T OV3R|60-0|maimai DX Limited-time Suite|True|4|7|10| Oshama Scramble!|60-1|maimai DX Limited-time Suite|True|5|7|10| Valsqotch|60-2|maimai DX Limited-time Suite|True|5|9|11| @@ -450,13 +450,13 @@ Love Patrol|63-2|MUSE RADIO FM104|True|3|5|7| Mahorova|63-3|MUSE RADIO FM104|True|3|5|8| Yoru no machi|63-4|MUSE RADIO FM104|True|1|4|7| INTERNET YAMERO|63-5|MUSE RADIO FM104|True|6|8|10| -Abracadabra|43-24|Just as Planned Plus|False|6|8|10| -Squalldecimator feat. EZ-Ven|43-25|Just as Planned Plus|True|5|7|9| -Amateras Rhythm|43-26|Just as Planned Plus|True|6|8|11| -Record one's Dream|43-27|Just as Planned Plus|False|4|7|10| -Lunatic|43-28|Just as Planned Plus|True|5|8|10| -Jiumeng|43-29|Just as Planned Plus|True|3|6|8| -The Day We Become Family|43-30|Just as Planned Plus|True|3|5|8| +Abracadabra|43-24|MD Plus Project|False|6|8|10| +Squalldecimator feat. EZ-Ven|43-25|MD Plus Project|True|5|7|9| +Amateras Rhythm|43-26|MD Plus Project|True|6|8|11| +Record one's Dream|43-27|MD Plus Project|False|4|7|10| +Lunatic|43-28|MD Plus Project|True|5|8|10| +Jiumeng|43-29|MD Plus Project|True|3|6|8| +The Day We Become Family|43-30|MD Plus Project|True|3|5|8| Sutori ma FIRE!?!?|64-0|COSMIC RADIO PEROLIST|True|3|5|8| Tanuki Step|64-1|COSMIC RADIO PEROLIST|True|5|7|10|11 Space Stationery|64-2|COSMIC RADIO PEROLIST|True|5|7|10| @@ -465,7 +465,27 @@ Kawai Splendid Space Thief|64-4|COSMIC RADIO PEROLIST|False|6|8|10|11 Night City Runway|64-5|COSMIC RADIO PEROLIST|True|4|6|8| Chaos Shotgun feat. ChumuNote|64-6|COSMIC RADIO PEROLIST|True|6|8|10| mew mew magical summer|64-7|COSMIC RADIO PEROLIST|False|5|8|10|11 -BrainDance|65-0|Neon Abyss|True|3|6|9| -My Focus!|65-1|Neon Abyss|True|5|7|10| -ABABABA BURST|65-2|Neon Abyss|True|5|7|9| -ULTRA HIGHER|65-3|Neon Abyss|True|4|7|10| \ No newline at end of file +BrainDance|65-0|NeonAbyss|True|3|6|9| +My Focus!|65-1|NeonAbyss|True|5|7|10| +ABABABA BURST|65-2|NeonAbyss|True|5|7|9| +ULTRA HIGHER|65-3|NeonAbyss|True|4|7|10| +Silver Bullet|43-31|MD Plus Project|True|5|7|10| +Random|43-32|MD Plus Project|True|4|7|9| +OTOGE-BOSS-KYOKU-CHAN|43-33|MD Plus Project|False|6|8|10|11 +Crow Rabbit|43-34|MD Plus Project|True|7|9|11| +SyZyGy|43-35|MD Plus Project|True|6|8|10|11 +Mermaid Radio|43-36|MD Plus Project|True|3|5|7| +Helixir|43-37|MD Plus Project|False|6|8|10| +Highway Cruisin'|43-38|MD Plus Project|False|3|5|8| +JACK PT BOSS|43-39|MD Plus Project|False|6|8|10| +Time Capsule|43-40|MD Plus Project|False|7|9|11| +39 Music!|66-0|Miku in Museland|False|3|5|8| +Hand in Hand|66-1|Miku in Museland|False|1|3|6| +Cynical Night Plan|66-2|Miku in Museland|False|4|6|8| +God-ish|66-3|Miku in Museland|False|4|7|10| +Darling Dance|66-4|Miku in Museland|False|4|7|9| +Hatsune Creation Myth|66-5|Miku in Museland|False|6|8|10| +The Vampire|66-6|Miku in Museland|False|4|6|9| +Future Eve|66-7|Miku in Museland|False|4|8|11| +Unknown Mother Goose|66-8|Miku in Museland|False|4|8|10| +Shun-ran|66-9|Miku in Museland|False|4|7|9| \ No newline at end of file diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py index b2f15ecc8e..3fe28187fa 100644 --- a/worlds/musedash/Options.py +++ b/worlds/musedash/Options.py @@ -1,10 +1,19 @@ from typing import Dict -from Options import Toggle, Option, Range, Choice, DeathLink, ItemSet +from Options import Toggle, Option, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions +from dataclasses import dataclass +from .MuseDashCollection import MuseDashCollections class AllowJustAsPlannedDLCSongs(Toggle): - """Whether [Just as Planned]/[Muse Plus] DLC Songs, and all the DLCs along with it, will be included in the randomizer.""" - display_name = "Allow [Just as Planned]/[Muse Plus] DLC Songs" + """Whether [Muse Plus] DLC Songs, and all the albums included in it, can be chosen as randomised songs. + Note: The [Just As Planned] DLC contains all [Muse Plus] songs.""" + display_name = "Allow [Muse Plus] DLC Songs" + +class DLCMusicPacks(OptionSet): + """Which non-[Muse Plus] DLC packs can be chosen as randomised songs.""" + display_name = "DLC Packs" + default = {} + valid_keys = [dlc for dlc in MuseDashCollections.DLC] class StreamerModeEnabled(Toggle): @@ -159,21 +168,22 @@ class ExcludeSongs(ItemSet): display_name = "Exclude Songs" -musedash_options: Dict[str, type(Option)] = { - "allow_just_as_planned_dlc_songs": AllowJustAsPlannedDLCSongs, - "streamer_mode_enabled": StreamerModeEnabled, - "starting_song_count": StartingSongs, - "additional_song_count": AdditionalSongs, - "additional_item_percentage": AdditionalItemPercentage, - "song_difficulty_mode": DifficultyMode, - "song_difficulty_min": DifficultyModeOverrideMin, - "song_difficulty_max": DifficultyModeOverrideMax, - "grade_needed": GradeNeeded, - "music_sheet_count_percentage": MusicSheetCountPercentage, - "music_sheet_win_count_percentage": MusicSheetWinCountPercentage, - "available_trap_types": TrapTypes, - "trap_count_percentage": TrapCountPercentage, - "death_link": DeathLink, - "include_songs": IncludeSongs, - "exclude_songs": ExcludeSongs -} +@dataclass +class MuseDashOptions(PerGameCommonOptions): + allow_just_as_planned_dlc_songs: AllowJustAsPlannedDLCSongs + dlc_packs: DLCMusicPacks + streamer_mode_enabled: StreamerModeEnabled + starting_song_count: StartingSongs + additional_song_count: AdditionalSongs + additional_item_percentage: AdditionalItemPercentage + song_difficulty_mode: DifficultyMode + song_difficulty_min: DifficultyModeOverrideMin + song_difficulty_max: DifficultyModeOverrideMax + grade_needed: GradeNeeded + music_sheet_count_percentage: MusicSheetCountPercentage + music_sheet_win_count_percentage: MusicSheetWinCountPercentage + available_trap_types: TrapTypes + trap_count_percentage: TrapCountPercentage + death_link: DeathLink + include_songs: IncludeSongs + exclude_songs: ExcludeSongs diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py index 78b9c253d5..63ce123c93 100644 --- a/worlds/musedash/__init__.py +++ b/worlds/musedash/__init__.py @@ -1,10 +1,10 @@ from worlds.AutoWorld import World, WebWorld -from worlds.generic.Rules import set_rule from BaseClasses import Region, Item, ItemClassification, Entrance, Tutorial -from typing import List +from typing import List, ClassVar, Type from math import floor +from Options import PerGameCommonOptions -from .Options import musedash_options +from .Options import MuseDashOptions from .Items import MuseDashSongItem, MuseDashFixedItem from .Locations import MuseDashLocation from .MuseDashCollection import MuseDashCollections @@ -23,7 +23,16 @@ class MuseDashWebWorld(WebWorld): ["DeamonHunter"] ) - tutorials = [setup_en] + setup_es = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "Español", + "setup_es.md", + "setup/es", + ["Shiny"] + ) + + tutorials = [setup_en, setup_es] class MuseDashWorld(World): @@ -38,9 +47,9 @@ class MuseDashWorld(World): # World Options game = "Muse Dash" - option_definitions = musedash_options + options_dataclass: ClassVar[Type[PerGameCommonOptions]] = MuseDashOptions topology_present = False - data_version = 9 + data_version = 10 web = MuseDashWebWorld() # Necessary Data @@ -57,14 +66,17 @@ class MuseDashWorld(World): location_count: int def generate_early(self): - dlc_songs = self.multiworld.allow_just_as_planned_dlc_songs[self.player] - streamer_mode = self.multiworld.streamer_mode_enabled[self.player] + dlc_songs = {key for key in self.options.dlc_packs.value} + if (self.options.allow_just_as_planned_dlc_songs.value): + dlc_songs.add(self.md_collection.MUSE_PLUS_DLC) + + streamer_mode = self.options.streamer_mode_enabled (lower_diff_threshold, higher_diff_threshold) = self.get_difficulty_range() # The minimum amount of songs to make an ok rando would be Starting Songs + 10 interim songs + Goal song. # - Interim songs being equal to max starting song count. # Note: The worst settings still allow 25 songs (Streamer Mode + No DLC). - starter_song_count = self.multiworld.starting_song_count[self.player].value + starter_song_count = self.options.starting_song_count.value while True: # In most cases this should only need to run once @@ -95,9 +107,9 @@ class MuseDashWorld(World): def handle_plando(self, available_song_keys: List[str]) -> List[str]: song_items = self.md_collection.song_items - start_items = self.multiworld.start_inventory[self.player].value.keys() - include_songs = self.multiworld.include_songs[self.player].value - exclude_songs = self.multiworld.exclude_songs[self.player].value + start_items = self.options.start_inventory.value.keys() + include_songs = self.options.include_songs.value + exclude_songs = self.options.exclude_songs.value self.starting_songs = [s for s in start_items if s in song_items] self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs] @@ -106,8 +118,8 @@ class MuseDashWorld(World): and s not in include_songs and s not in exclude_songs] def create_song_pool(self, available_song_keys: List[str]): - starting_song_count = self.multiworld.starting_song_count[self.player].value - additional_song_count = self.multiworld.additional_song_count[self.player].value + starting_song_count = self.options.starting_song_count.value + additional_song_count = self.options.additional_song_count.value self.random.shuffle(available_song_keys) @@ -141,7 +153,7 @@ class MuseDashWorld(World): # Then attempt to fufill any remaining songs for interim songs if len(self.included_songs) < additional_song_count: - for _ in range(len(self.included_songs), self.multiworld.additional_song_count[self.player]): + for _ in range(len(self.included_songs), self.options.additional_song_count): if len(available_song_keys) <= 0: break self.included_songs.append(available_song_keys.pop()) @@ -249,40 +261,40 @@ class MuseDashWorld(World): state.has(self.md_collection.MUSIC_SHEET_NAME, self.player, self.get_music_sheet_win_count()) def get_available_traps(self) -> List[str]: - dlc_songs = self.multiworld.allow_just_as_planned_dlc_songs[self.player] + sfx_traps_available = self.options.allow_just_as_planned_dlc_songs.value trap_list = [] - if self.multiworld.available_trap_types[self.player].value & 1 != 0: + if self.options.available_trap_types.value & 1 != 0: trap_list += self.md_collection.vfx_trap_items.keys() # SFX options are only available under Just as Planned DLC. - if dlc_songs and self.multiworld.available_trap_types[self.player].value & 2 != 0: + if sfx_traps_available and self.options.available_trap_types.value & 2 != 0: trap_list += self.md_collection.sfx_trap_items.keys() return trap_list def get_additional_item_percentage(self) -> int: - trap_count = self.multiworld.trap_count_percentage[self.player].value - song_count = self.multiworld.music_sheet_count_percentage[self.player].value - return max(trap_count + song_count, self.multiworld.additional_item_percentage[self.player].value) + trap_count = self.options.trap_count_percentage.value + song_count = self.options.music_sheet_count_percentage.value + return max(trap_count + song_count, self.options.additional_item_percentage.value) def get_trap_count(self) -> int: - multiplier = self.multiworld.trap_count_percentage[self.player].value / 100.0 + multiplier = self.options.trap_count_percentage.value / 100.0 trap_count = (len(self.starting_songs) * 2) + len(self.included_songs) return max(0, floor(trap_count * multiplier)) def get_music_sheet_count(self) -> int: - multiplier = self.multiworld.music_sheet_count_percentage[self.player].value / 100.0 + multiplier = self.options.music_sheet_count_percentage.value / 100.0 song_count = (len(self.starting_songs) * 2) + len(self.included_songs) return max(1, floor(song_count * multiplier)) def get_music_sheet_win_count(self) -> int: - multiplier = self.multiworld.music_sheet_win_count_percentage[self.player].value / 100.0 + multiplier = self.options.music_sheet_win_count_percentage.value / 100.0 sheet_count = self.get_music_sheet_count() return max(1, floor(sheet_count * multiplier)) def get_difficulty_range(self) -> List[int]: - difficulty_mode = self.multiworld.song_difficulty_mode[self.player] + difficulty_mode = self.options.song_difficulty_mode # Valid difficulties are between 1 and 11. But make it 0 to 12 for safety difficulty_bounds = [0, 12] @@ -300,8 +312,8 @@ class MuseDashWorld(World): elif difficulty_mode == 5: difficulty_bounds[0] = 10 elif difficulty_mode == 6: - minimum_difficulty = self.multiworld.song_difficulty_min[self.player].value - maximum_difficulty = self.multiworld.song_difficulty_max[self.player].value + minimum_difficulty = self.options.song_difficulty_min.value + maximum_difficulty = self.options.song_difficulty_max.value difficulty_bounds[0] = min(minimum_difficulty, maximum_difficulty) difficulty_bounds[1] = max(minimum_difficulty, maximum_difficulty) @@ -311,7 +323,7 @@ class MuseDashWorld(World): def fill_slot_data(self): return { "victoryLocation": self.victory_song_name, - "deathLink": self.multiworld.death_link[self.player].value, + "deathLink": self.options.death_link.value, "musicSheetWinCount": self.get_music_sheet_win_count(), - "gradeNeeded": self.multiworld.grade_needed[self.player].value + "gradeNeeded": self.options.grade_needed.value } diff --git a/worlds/musedash/docs/setup_en.md b/worlds/musedash/docs/setup_en.md index 1ab61ff22a..ebf165c7dd 100644 --- a/worlds/musedash/docs/setup_en.md +++ b/worlds/musedash/docs/setup_en.md @@ -8,10 +8,10 @@ - Windows 8 or Newer. - Muse Dash: [Available on Steam](https://store.steampowered.com/app/774171/Muse_Dash/) - - \[Optional\] [Just as Planned] DLC: [Also Available on Steam](https://store.steampowered.com/app/1055810/Muse_Dash__Just_as_planned/) + - \[Optional\] [Muse Plus] DLC: [Also Available on Steam](https://store.steampowered.com/app/2593750/Muse_Dash__Muse_Plus/) - Melon Loader: [GitHub](https://github.com/LavaGang/MelonLoader/releases/latest) - .Net Framework 4.8 may be needed for the installer: [Download](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net48) -- .Net 6.0 (If not already installed): [Download](https://dotnet.microsoft.com/en-us/download/dotnet/6.0#runtime-6.0.15) +- .NET Desktop Runtime 6.0.XX (If not already installed): [Download](https://dotnet.microsoft.com/en-us/download/dotnet/6.0) - Muse Dash Archipelago Mod: [GitHub](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) ## Installing the Archipelago mod to Muse Dash diff --git a/worlds/musedash/docs/setup_es.md b/worlds/musedash/docs/setup_es.md new file mode 100644 index 0000000000..0d737c26d7 --- /dev/null +++ b/worlds/musedash/docs/setup_es.md @@ -0,0 +1,48 @@ +# Guía de instalación para Muse Dash: Archipelago + +## Enlaces rápidos +- [Página Principal](../../../../games/Muse%20Dash/info/en) +- [Página de Configuraciones](../../../../games/Muse%20Dash/player-settings) + +## Software Requerido + +- Windows 8 o más reciente. +- Muse Dash: [Disponible en Steam](https://store.steampowered.com/app/774171/Muse_Dash/) + - \[Opcional\] [Muse Plus] DLC: [tambien disponible on Steam](https://store.steampowered.com/app/2593750/Muse_Dash__Muse_Plus/) +- Melon Loader: [GitHub](https://github.com/LavaGang/MelonLoader/releases/latest) + - .Net Framework 4.8 podría ser necesario para el instalador: [Descarga](https://dotnet.microsoft.com/es-es/download/dotnet-framework/net48) +- Entorno de ejecución de escritorio de .NET 6.0.XX (si aún no está instalado): [Descarga](https://dotnet.microsoft.com/es-es/download/dotnet/6.0) +- Muse Dash Archipelago Mod: [GitHub](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) + +## Instalar el mod de Archipelago en Muse Dash + +1. Descarga [MelonLoader.Installer.exe](https://github.com/LavaGang/MelonLoader/releases/latest) y ejecutalo. +2. Elije la pestaña "automated", haz clic en el botón "select" y busca tu `MuseDash.exe`. Luego haz clic en "install". + - Puedes encontrar la carpeta en Steam buscando el juego en tu biblioteca, haciendo clic derecho sobre el y elegir *Administrar→Ver archivos locales*. + - Si haces clic en la barra superior que te indica la carpeta en la que estas, te dará la dirección de ésta para que puedas copiarla. Al pegar esa dirección en la ventana que **MelonLoader** abre, irá automaticamente a esa carpeta. +3. Ejecuta el juego una vez, y espera hasta que aparezca la pantalla de inicio de Muse Dash antes de cerrarlo. +4. Descarga la última version de [Muse Dash Archipelago Mod](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) y extraelo en la nueva carpeta creada llamada `/Mods/`, localizada en la carpeta de instalación de Muse Dash. + - Todos los archivos deben ir directamente en la carpeta `/Mods/`, y NO en una subcarpeta dentro de la carpeta `/Mods/` + +Si todo fue instalado correctamente, un botón aparecerá en la parte inferior derecha del juego una vez abierto, que te permitirá conectarte al servidor de Archipelago. + +## Generar un juego MultiWorld +1. Entra a la página de [configuraciones de jugador](/games/Muse%20Dash/player-settings) y configura las opciones del juego a tu gusto. +2. Genera tu archivo YAML y úsalo para generar un juego nuevo en el radomizer + - (Instrucciones sobre como generar un juego en Archipelago disponibles en la [guía web de Archipelago en Inglés](/tutorial/Archipelago/setup/en)) + +## Unirse a un juego MultiWorld + +1. Ejecuta Muse Dash y pasa por la pantalla de introducción. Haz clic en el botón de la esquina inferior derecha. +2. Ingresa los detalles de la sesión de archipelago, como la dirección del servidor con el puerto (por ejemplo, archipelago.gg:38381), nombre de usuario y contraseña. +3. Si todo se ingresó correctamente, el pop-up debería desaparecer y se mostrará el menú principal habitual. Al ingresar a la selección de canciones, deberías ver una cantidad limitada de canciones. + +## Solución de problemas + +### No Support Module Loaded + +Este error ocurre cuando Melon Loader no puede encontrar los archivos necesarios para ejecutar mods. Generalmente, hay dos razones principales de este error: una falla al generar los archivos cuando el juego se ejecutó por primera vez con Melon Loader, o un antivirus que elimina los archivos después de la generación. + +Para solucionar este problema, primero debes eliminar Melon Loader de Muse Dash. Puedes hacer esto eliminando la carpeta Melon Loader dentro de la carpeta de Muse Dash. Luego, seguir los pasos de instalación nuevamente. + +Si continúas teniendo problemas y estás utilizando un antivirus, es posible que tengas que desactivarlo temporalmente cuando se ejecute Muse Dash por primera vez, o excluir la carpeta Muse Dash de ser escaneada. diff --git a/worlds/musedash/test/TestCollection.py b/worlds/musedash/test/TestCollection.py index 23348af104..f9422388ae 100644 --- a/worlds/musedash/test/TestCollection.py +++ b/worlds/musedash/test/TestCollection.py @@ -36,14 +36,27 @@ class CollectionsTest(unittest.TestCase): def test_free_dlc_included_in_base_songs(self) -> None: collection = MuseDashCollections() - songs = collection.get_songs_with_settings(False, False, 0, 11) + songs = collection.get_songs_with_settings(set(), False, 0, 12) self.assertIn("Glimmer", songs, "Budget Is Burning Vol.1 is not being included in base songs") self.assertIn("Out of Sense", songs, "Budget Is Burning: Nano Core is not being included in base songs") + def test_dlcs(self) -> None: + collection = MuseDashCollections() + free_song_count = len(collection.get_songs_with_settings(set(), False, 0, 12)) + known_mp_song = "The Happycore Idol" + + for dlc in collection.DLC: + songs_with_dlc = collection.get_songs_with_settings({dlc}, False, 0, 12) + self.assertGreater(len(songs_with_dlc), free_song_count, f"DLC {dlc} did not include extra songs.") + if dlc == collection.MUSE_PLUS_DLC: + self.assertIn(known_mp_song, songs_with_dlc, f"Muse Plus missing muse plus song.") + else: + self.assertNotIn(known_mp_song, songs_with_dlc, f"DLC {dlc} includes Muse Plus songs.") + def test_remove_songs_are_not_generated(self) -> None: collection = MuseDashCollections() - songs = collection.get_songs_with_settings(True, False, 0, 11) + songs = collection.get_songs_with_settings({x for x in collection.DLC}, False, 0, 12) for song_name in self.REMOVED_SONGS: self.assertNotIn(song_name, songs, f"Song '{song_name}' wasn't removed correctly.") diff --git a/worlds/musedash/test/TestDifficultyRanges.py b/worlds/musedash/test/TestDifficultyRanges.py index 58817d0fc3..01420347af 100644 --- a/worlds/musedash/test/TestDifficultyRanges.py +++ b/worlds/musedash/test/TestDifficultyRanges.py @@ -4,6 +4,7 @@ from . import MuseDashTestBase class DifficultyRanges(MuseDashTestBase): def test_all_difficulty_ranges(self) -> None: muse_dash_world = self.multiworld.worlds[1] + dlc_set = {x for x in muse_dash_world.md_collection.DLC} difficulty_choice = self.multiworld.song_difficulty_mode[1] difficulty_min = self.multiworld.song_difficulty_min[1] difficulty_max = self.multiworld.song_difficulty_max[1] @@ -12,7 +13,7 @@ class DifficultyRanges(MuseDashTestBase): self.assertEqual(inputRange[0], lower) self.assertEqual(inputRange[1], upper) - songs = muse_dash_world.md_collection.get_songs_with_settings(True, False, inputRange[0], inputRange[1]) + songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, inputRange[0], inputRange[1]) for songKey in songs: song = muse_dash_world.md_collection.song_items[songKey] if (song.easy is not None and inputRange[0] <= song.easy <= inputRange[1]): diff --git a/worlds/oot/ItemPool.py b/worlds/oot/ItemPool.py index 94e1011ddc..6ca6bc9268 100644 --- a/worlds/oot/ItemPool.py +++ b/worlds/oot/ItemPool.py @@ -350,7 +350,7 @@ def generate_itempool(ootworld): ootworld.itempool = [ootworld.create_item(item) for item in pool] for (location_name, item) in placed_items.items(): location = world.get_location(location_name, player) - location.place_locked_item(ootworld.create_item(item)) + location.place_locked_item(ootworld.create_item(item, allow_arbitrary_name=True)) def get_pool_core(world): @@ -675,7 +675,7 @@ def get_pool_core(world): world.remove_from_start_inventory.append('Scarecrow Song') if world.no_epona_race: - world.multiworld.push_precollected(world.create_item('Epona')) + world.multiworld.push_precollected(world.create_item('Epona', allow_arbitrary_name=True)) world.remove_from_start_inventory.append('Epona') if world.shuffle_smallkeys == 'vanilla': diff --git a/worlds/oot/Location.py b/worlds/oot/Location.py index e2b0e52e4d..3f7d75517e 100644 --- a/worlds/oot/Location.py +++ b/worlds/oot/Location.py @@ -2,6 +2,8 @@ from enum import Enum from .LocationList import location_table from BaseClasses import Location +non_indexed_location_types = {'Boss', 'Event', 'Drop', 'HintStone', 'Hint'} + location_id_offset = 67000 locnames_pre_70 = { "Gift from Sages", @@ -18,7 +20,7 @@ new_name_order = sorted(location_table.keys(), else 0) location_name_to_id = {name: (location_id_offset + index) for (index, name) in enumerate(new_name_order) - if location_table[name][0] not in {'Boss', 'Event', 'Drop', 'HintStone', 'Hint'}} + if location_table[name][0] not in non_indexed_location_types} class DisableType(Enum): ENABLED = 0 @@ -83,3 +85,57 @@ def LocationFactory(locations, player: int): return ret +def build_location_name_groups() -> dict: + + def fix_sing(t) -> tuple: + if isinstance(t, str): + return (t,) + return t + + def rename(d, k1, k2) -> None: + d[k2] = d[k1] + del d[k1] + + # whoever wrote the location table didn't realize they need to add a comma to mark a singleton as a tuple + # so we have to check types unfortunately + tags = set() + for v in location_table.values(): + if v[5] is not None: + tags.update(fix_sing(v[5])) + + sorted_tags = sorted(list(tags)) + + ret = { + tag: {k for k, v in location_table.items() + if v[5] is not None + and tag in fix_sing(v[5]) + and v[0] not in non_indexed_location_types} + for tag in sorted_tags + } + + # Delete tags which are a combination of other tags + del ret['Death Mountain'] + del ret['Forest'] + del ret['Gerudo'] + del ret['Kakariko'] + del ret['Market'] + + # Delete Vanilla and MQ tags because they are just way too broad + del ret['Vanilla'] + del ret['Master Quest'] + + rename(ret, 'Beehive', 'Beehives') + rename(ret, 'Cow', 'Cows') + rename(ret, 'Crate', 'Crates') + rename(ret, 'Deku Scrub', 'Deku Scrubs') + rename(ret, 'FlyingPot', 'Flying Pots') + rename(ret, 'Freestanding', 'Freestanding Items') + rename(ret, 'Pot', 'Pots') + rename(ret, 'RupeeTower', 'Rupee Groups') + rename(ret, 'SmallCrate', 'Small Crates') + rename(ret, 'the Market', 'Market') + rename(ret, 'the Graveyard', 'Graveyard') + rename(ret, 'the Lost Woods', 'Lost Woods') + + return ret + diff --git a/worlds/oot/LocationList.py b/worlds/oot/LocationList.py index 3f4602c428..27ad575699 100644 --- a/worlds/oot/LocationList.py +++ b/worlds/oot/LocationList.py @@ -238,7 +238,7 @@ location_table = OrderedDict([ ("Market Night Green Rupee Crate 1", ("Crate", 0x21, (0,0,24), None, 'Rupee (1)', ("the Market", "Market", "Crate"))), ("Market Night Green Rupee Crate 2", ("Crate", 0x21, (0,0,25), None, 'Rupee (1)', ("the Market", "Market", "Crate"))), ("Market Night Green Rupee Crate 3", ("Crate", 0x21, (0,0,26), None, 'Rupee (1)', ("the Market", "Market", "Crate"))), - ("Market Dog Lady House Crate", ("Crate", 0x35, (0,0,3), None, 'Rupees (5)', ("Market", "Market", "Crate"))), + ("Market Dog Lady House Crate", ("Crate", 0x35, (0,0,3), None, 'Rupees (5)', ("the Market", "Market", "Crate"))), ("Market Guard House Child Crate", ("Crate", 0x4D, (0,0,6), None, 'Rupee (1)', ("the Market", "Market", "Crate"))), ("Market Guard House Child Pot 1", ("Pot", 0x4D, (0,0,9), None, 'Rupee (1)', ("the Market", "Market", "Pot"))), ("Market Guard House Child Pot 2", ("Pot", 0x4D, (0,0,10), None, 'Rupee (1)', ("the Market", "Market", "Pot"))), diff --git a/worlds/oot/Options.py b/worlds/oot/Options.py index 03f5346cee..120027e29d 100644 --- a/worlds/oot/Options.py +++ b/worlds/oot/Options.py @@ -30,7 +30,17 @@ class TrackRandomRange(Range): class Logic(Choice): - """Set the logic used for the generator.""" + """Set the logic used for the generator. + Glitchless: Normal gameplay. Can enable more difficult logical paths using the Logic Tricks option. + Glitched: Many powerful glitches expected, such as bomb hovering and clipping. + Glitched is incompatible with the following settings: + - All forms of entrance randomizer + - MQ dungeons + - Pot shuffle + - Freestanding item shuffle + - Crate shuffle + - Beehive shuffle + No Logic: No logic is used when placing items. Not recommended for most players.""" display_name = "Logic Rules" option_glitchless = 0 option_glitched = 1 @@ -38,12 +48,16 @@ class Logic(Choice): class NightTokens(Toggle): - """Nighttime skulltulas will logically require Sun's Song.""" + """When enabled, nighttime skulltulas logically require Sun's Song.""" display_name = "Nighttime Skulltulas Expect Sun's Song" class Forest(Choice): - """Set the state of Kokiri Forest and the path to Deku Tree.""" + """Set the state of Kokiri Forest and the path to Deku Tree. + Open: Neither the forest exit nor the path to Deku Tree is blocked. + Closed Deku: The forest exit is not blocked; the path to Deku Tree requires Kokiri Sword and Deku Shield. + Closed: Path to Deku Tree requires sword and shield. The forest exit is blocked until Deku Tree is beaten. + Closed forest will force child start, and becomes Closed Deku if interior entrances, overworld entrances, warp songs, or random spawn positions are enabled.""" display_name = "Forest" option_open = 0 option_closed_deku = 1 @@ -53,7 +67,10 @@ class Forest(Choice): class Gate(Choice): - """Set the state of the Kakariko Village gate.""" + """Set the state of the Kakariko Village gate for child. The gate is always open as adult. + Open: The gate starts open. Happy Mask Shop opens upon receiving Zelda's Letter. + Zelda: The gate and Mask Shop open upon receiving Zelda's Letter, without needing to show it to the guard. + Closed: Vanilla behavior; the gate and Mask Shop open upon showing Zelda's Letter to the gate guard.""" display_name = "Kakariko Gate" option_open = 0 option_zelda = 1 @@ -61,12 +78,15 @@ class Gate(Choice): class DoorOfTime(DefaultOnToggle): - """Open the Door of Time by default, without the Song of Time.""" + """When enabled, the Door of Time starts opened, without needing Song of Time.""" display_name = "Open Door of Time" class Fountain(Choice): - """Set the state of King Zora, blocking the way to Zora's Fountain.""" + """Set the state of King Zora, blocking the way to Zora's Fountain. + Open: King Zora starts moved as both ages. Ruto's Letter is removed. + Adult: King Zora must be moved as child, but is always moved for adult. + Closed: Vanilla behavior; King Zora must be shown Ruto's Letter as child to move him as both ages.""" display_name = "Zora's Fountain" option_open = 0 option_adult = 1 @@ -75,7 +95,10 @@ class Fountain(Choice): class Fortress(Choice): - """Set the requirements for access to Gerudo Fortress.""" + """Set the requirements for access to Gerudo Fortress. + Normal: Vanilla behavior; all four carpenters must be rescued. + Fast: Only one carpenter must be rescued, which is the one in the bottom-left of the fortress. + Open: The Gerudo Valley bridge starts repaired. Gerudo Membership Card is given to start if not shuffled.""" display_name = "Gerudo Fortress" option_normal = 0 option_fast = 1 @@ -84,7 +107,14 @@ class Fortress(Choice): class Bridge(Choice): - """Set the requirements for the Rainbow Bridge.""" + """Set the requirements for the Rainbow Bridge. + Open: The bridge is always present. + Vanilla: Bridge requires Shadow Medallion, Spirit Medallion, and Light Arrows. + Stones: Bridge requires a configurable amount of Spiritual Stones. + Medallions: Bridge requires a configurable amount of medallions. + Dungeons: Bridge requires a configurable amount of rewards (stones + medallions). + Tokens: Bridge requires a configurable amount of gold skulltula tokens. + Hearts: Bridge requires a configurable amount of hearts.""" display_name = "Rainbow Bridge Requirement" option_open = 0 option_vanilla = 1 @@ -122,8 +152,9 @@ class StartingAge(Choice): class InteriorEntrances(Choice): - """Shuffles interior entrances. "Simple" shuffles houses and Great Fairies; "All" includes Windmill, Link's House, - Temple of Time, and Kak potion shop.""" + """Shuffles interior entrances. + Simple: Houses and Great Fairies are shuffled. + All: In addition to Simple, includes Windmill, Link's House, Temple of Time, and the Kakariko potion shop.""" display_name = "Shuffle Interior Entrances" option_off = 0 option_simple = 1 @@ -137,7 +168,9 @@ class GrottoEntrances(Toggle): class DungeonEntrances(Choice): - """Shuffles dungeon entrances. Opens Deku, Fire and BotW to both ages. "All" includes Ganon's Castle.""" + """Shuffles dungeon entrances. When enabled, both ages will have access to Fire Temple, Bottom of the Well, and Deku Tree. + Simple: Shuffle dungeon entrances except for Ganon's Castle. + All: Include Ganon's Castle as well.""" display_name = "Shuffle Dungeon Entrances" option_off = 0 option_simple = 1 @@ -146,7 +179,9 @@ class DungeonEntrances(Choice): class BossEntrances(Choice): - """Shuffles boss entrances. "Limited" prevents age-mixing of bosses.""" + """Shuffles boss entrances. + Limited: Bosses will be limited to the ages that typically fight them. + Full: Bosses may be fought as different ages than usual. Child can defeat Phantom Ganon and Bongo Bongo.""" display_name = "Shuffle Boss Entrances" option_off = 0 option_limited = 1 @@ -178,19 +213,19 @@ class SpawnPositions(Choice): alias_true = 3 -class MixEntrancePools(Choice): - """Shuffles entrances into a mixed pool instead of separate ones. "indoor" keeps overworld entrances separate; "all" - mixes them in.""" - display_name = "Mix Entrance Pools" - option_off = 0 - option_indoor = 1 - option_all = 2 +# class MixEntrancePools(Choice): +# """Shuffles entrances into a mixed pool instead of separate ones. "indoor" keeps overworld entrances separate; "all" +# mixes them in.""" +# display_name = "Mix Entrance Pools" +# option_off = 0 +# option_indoor = 1 +# option_all = 2 -class DecoupleEntrances(Toggle): - """Decouple entrances when shuffling them. Also adds the one-way entrance from Gerudo Valley to Lake Hylia if - overworld is shuffled.""" - display_name = "Decouple Entrances" +# class DecoupleEntrances(Toggle): +# """Decouple entrances when shuffling them. Also adds the one-way entrance from Gerudo Valley to Lake Hylia if +# overworld is shuffled.""" +# display_name = "Decouple Entrances" class TriforceHunt(Toggle): @@ -216,13 +251,17 @@ class ExtraTriforces(Range): class LogicalChus(Toggle): - """Bombchus are properly considered in logic. The first found pack will have 20 chus; Kokiri Shop and Bazaar sell - refills; bombchus open Bombchu Bowling.""" + """Bombchus are properly considered in logic. + The first found pack will always have 20 chus. + Kokiri Shop and Bazaar will sell refills at reduced cost. + Bombchus open Bombchu Bowling.""" display_name = "Bombchus Considered in Logic" class DungeonShortcuts(Choice): - """Shortcuts to dungeon bosses are available without any requirements.""" + """Shortcuts to dungeon bosses are available without any requirements. + If enabled, this will impact the logic of dungeons where shortcuts are available. + Choice: Use the option "dungeon_shortcuts_list" to choose shortcuts.""" display_name = "Dungeon Boss Shortcuts Mode" option_off = 0 option_choice = 1 @@ -246,7 +285,11 @@ class DungeonShortcutsList(OptionSet): class MQDungeons(Choice): - """Choose between vanilla and Master Quest dungeon layouts.""" + """Choose between vanilla and Master Quest dungeon layouts. + Vanilla: All layouts are vanilla. + MQ: All layouts are Master Quest. + Specific: Use the option "mq_dungeons_list" to choose which dungeons are MQ. + Count: Use the option "mq_dungeons_count" to choose a number of random dungeons as MQ.""" display_name = "MQ Dungeon Mode" option_vanilla = 0 option_mq = 1 @@ -255,7 +298,7 @@ class MQDungeons(Choice): class MQDungeonList(OptionSet): - """Chosen dungeons to be MQ layout.""" + """With MQ dungeons as Specific: chosen dungeons to be MQ layout.""" display_name = "MQ Dungeon List" valid_keys = { "Deku Tree", @@ -274,41 +317,41 @@ class MQDungeonList(OptionSet): class MQDungeonCount(TrackRandomRange): - """Number of MQ dungeons, chosen randomly.""" + """With MQ dungeons as Count: number of randomly-selected dungeons to be MQ layout.""" display_name = "MQ Dungeon Count" range_start = 0 range_end = 12 default = 0 -class EmptyDungeons(Choice): - """Pre-completed dungeons are barren and rewards are given for free.""" - display_name = "Pre-completed Dungeons Mode" - option_none = 0 - option_specific = 1 - option_count = 2 +# class EmptyDungeons(Choice): +# """Pre-completed dungeons are barren and rewards are given for free.""" +# display_name = "Pre-completed Dungeons Mode" +# option_none = 0 +# option_specific = 1 +# option_count = 2 -class EmptyDungeonList(OptionSet): - """Chosen dungeons to be pre-completed.""" - display_name = "Pre-completed Dungeon List" - valid_keys = { - "Deku Tree", - "Dodongo's Cavern", - "Jabu Jabu's Belly", - "Forest Temple", - "Fire Temple", - "Water Temple", - "Shadow Temple", - "Spirit Temple", - } +# class EmptyDungeonList(OptionSet): +# """Chosen dungeons to be pre-completed.""" +# display_name = "Pre-completed Dungeon List" +# valid_keys = { +# "Deku Tree", +# "Dodongo's Cavern", +# "Jabu Jabu's Belly", +# "Forest Temple", +# "Fire Temple", +# "Water Temple", +# "Shadow Temple", +# "Spirit Temple", +# } -class EmptyDungeonCount(Range): - display_name = "Pre-completed Dungeon Count" - range_start = 1 - range_end = 8 - default = 2 +# class EmptyDungeonCount(Range): +# display_name = "Pre-completed Dungeon Count" +# range_start = 1 +# range_end = 8 +# default = 2 world_options: typing.Dict[str, type(Option)] = { @@ -341,59 +384,8 @@ world_options: typing.Dict[str, type(Option)] = { } -# class LacsCondition(Choice): -# """Set the requirements for the Light Arrow Cutscene in the Temple of Time.""" -# display_name = "Light Arrow Cutscene Requirement" -# option_vanilla = 0 -# option_stones = 1 -# option_medallions = 2 -# option_dungeons = 3 -# option_tokens = 4 - - -# class LacsStones(Range): -# """Set the number of Spiritual Stones required for LACS.""" -# display_name = "Spiritual Stones Required for LACS" -# range_start = 0 -# range_end = 3 -# default = 3 - - -# class LacsMedallions(Range): -# """Set the number of medallions required for LACS.""" -# display_name = "Medallions Required for LACS" -# range_start = 0 -# range_end = 6 -# default = 6 - - -# class LacsRewards(Range): -# """Set the number of dungeon rewards required for LACS.""" -# display_name = "Dungeon Rewards Required for LACS" -# range_start = 0 -# range_end = 9 -# default = 9 - - -# class LacsTokens(Range): -# """Set the number of Gold Skulltula Tokens required for LACS.""" -# display_name = "Tokens Required for LACS" -# range_start = 0 -# range_end = 100 -# default = 40 - - -# lacs_options: typing.Dict[str, type(Option)] = { -# "lacs_condition": LacsCondition, -# "lacs_stones": LacsStones, -# "lacs_medallions": LacsMedallions, -# "lacs_rewards": LacsRewards, -# "lacs_tokens": LacsTokens, -# } - - class BridgeStones(Range): - """Set the number of Spiritual Stones required for the rainbow bridge.""" + """With Stones bridge: set the number of Spiritual Stones required.""" display_name = "Spiritual Stones Required for Bridge" range_start = 0 range_end = 3 @@ -401,7 +393,7 @@ class BridgeStones(Range): class BridgeMedallions(Range): - """Set the number of medallions required for the rainbow bridge.""" + """With Medallions bridge: set the number of medallions required.""" display_name = "Medallions Required for Bridge" range_start = 0 range_end = 6 @@ -409,7 +401,7 @@ class BridgeMedallions(Range): class BridgeRewards(Range): - """Set the number of dungeon rewards required for the rainbow bridge.""" + """With Dungeons bridge: set the number of dungeon rewards required.""" display_name = "Dungeon Rewards Required for Bridge" range_start = 0 range_end = 9 @@ -417,7 +409,7 @@ class BridgeRewards(Range): class BridgeTokens(Range): - """Set the number of Gold Skulltula Tokens required for the rainbow bridge.""" + """With Tokens bridge: set the number of Gold Skulltula Tokens required.""" display_name = "Tokens Required for Bridge" range_start = 0 range_end = 100 @@ -425,7 +417,7 @@ class BridgeTokens(Range): class BridgeHearts(Range): - """Set the number of hearts required for the rainbow bridge.""" + """With Hearts bridge: set the number of hearts required.""" display_name = "Hearts Required for Bridge" range_start = 4 range_end = 20 @@ -442,7 +434,15 @@ bridge_options: typing.Dict[str, type(Option)] = { class SongShuffle(Choice): - """Set where songs can appear.""" + """Set where songs can appear. + Song: Songs are shuffled into other song locations. + Dungeon: Songs are placed into end-of-dungeon locations: + - The 8 boss heart containers + - Sheik in Ice Cavern + - Lens of Truth chest in Bottom of the Well + - Ice Arrows chest in Gerudo Training Ground + - Impa at Hyrule Castle + Any: Songs can appear anywhere in the multiworld.""" display_name = "Shuffle Songs" option_song = 0 option_dungeon = 1 @@ -450,8 +450,10 @@ class SongShuffle(Choice): class ShopShuffle(Choice): - """Randomizes shop contents. "fixed_number" randomizes a specific number of items per shop; - "random_number" randomizes the value for each shop. """ + """Randomizes shop contents. + Off: Shops are not randomized at all. + Fixed Number: Shop contents are shuffled, and a specific number of multiworld locations exist in each shop, controlled by the "shop_slots" option. + Random Number: Same as Fixed Number, but the number of locations per shop is random and may differ between shops.""" display_name = "Shopsanity" option_off = 0 option_fixed_number = 1 @@ -459,15 +461,20 @@ class ShopShuffle(Choice): class ShopSlots(Range): - """Number of items per shop to be randomized into the main itempool. - Only active if Shopsanity is set to "fixed_number." """ + """With Shopsanity fixed number: quantity of multiworld locations per shop to be randomized.""" display_name = "Shuffled Shop Slots" range_start = 0 range_end = 4 class ShopPrices(Choice): - """Controls prices of shop items. "Normal" is a distribution from 0 to 300. "X Wallet" requires that wallet at max. "Affordable" is always 10 rupees.""" + """Controls prices of shop locations. + Normal: Balanced distribution from 0 to 300. + Affordable: Every shop location costs 10 rupees. + Starting Wallet: Prices capped at 99 rupees. + Adult's Wallet: Prices capped at 200 rupees. + Giant's Wallet: Prices capped at 500 rupees. + Tycoon's Wallet: Prices capped at 999 rupees.""" display_name = "Shopsanity Prices" option_normal = 0 option_affordable = 1 @@ -478,7 +485,10 @@ class ShopPrices(Choice): class TokenShuffle(Choice): - """Token rewards from Gold Skulltulas are shuffled into the pool.""" + """Token rewards from Gold Skulltulas can be shuffled into the pool. + Dungeons: Only skulltulas in dungeons are shuffled. + Overworld: Only skulltulas on the overworld (all skulltulas not in dungeons) are shuffled. + All: Every skulltula is shuffled.""" display_name = "Tokensanity" option_off = 0 option_dungeons = 1 @@ -487,7 +497,11 @@ class TokenShuffle(Choice): class ScrubShuffle(Choice): - """Shuffle the items sold by Business Scrubs, and set the prices.""" + """Shuffle the items sold by Business Scrubs, and set the prices. + Off: Only the three business scrubs that sell one-time upgrades in vanilla will have items at their vanilla prices. + Low/"Affordable": All scrub prices are 10 rupees. + Regular/"Expensive": All scrub prices are vanilla. + Random Prices: All scrub prices are randomized between 0 and 99 rupees.""" display_name = "Scrub Shuffle" option_off = 0 option_low = 1 @@ -513,7 +527,11 @@ class ShuffleOcarinas(Toggle): class ShuffleChildTrade(Choice): - """Controls the behavior of the start of the child trade quest.""" + """Controls the behavior of the start of the child trade quest. + Vanilla: Malon will give you the Weird Egg at Hyrule Castle. + Shuffle: Malon will give you a random item, and the Weird Egg is shuffled. + Skip Child Zelda: The game starts with Zelda already met, Zelda's Letter obtained, and the item from Impa obtained. + """ display_name = "Shuffle Child Trade Item" option_vanilla = 0 option_shuffle = 1 @@ -538,30 +556,39 @@ class ShuffleMedigoronCarpet(Toggle): class ShuffleFreestanding(Choice): - """Shuffles freestanding rupees, recovery hearts, Shadow Temple Spinning Pots, and Goron Pot.""" + """Shuffles freestanding rupees, recovery hearts, Shadow Temple Spinning Pots, and Goron Pot drops. + Dungeons: Only freestanding items in dungeons are shuffled. + Overworld: Only freestanding items in the overworld are shuffled. + All: All freestanding items are shuffled.""" display_name = "Shuffle Rupees & Hearts" option_off = 0 - option_all = 1 + option_dungeons = 1 option_overworld = 2 - option_dungeons = 3 + option_all = 3 class ShufflePots(Choice): - """Shuffles pots and flying pots which normally contain an item.""" + """Shuffles pots and flying pots which normally contain an item. + Dungeons: Only pots in dungeons are shuffled. + Overworld: Only pots in the overworld are shuffled. + All: All pots are shuffled.""" display_name = "Shuffle Pots" option_off = 0 - option_all = 1 + option_dungeons = 1 option_overworld = 2 - option_dungeons = 3 + option_all = 3 class ShuffleCrates(Choice): - """Shuffles large and small crates containing an item.""" + """Shuffles large and small crates containing an item. + Dungeons: Only crates in dungeons are shuffled. + Overworld: Only crates in the overworld are shuffled. + All: All crates are shuffled.""" display_name = "Shuffle Crates" option_off = 0 - option_all = 1 + option_dungeons = 1 option_overworld = 2 - option_dungeons = 3 + option_all = 3 class ShuffleBeehives(Toggle): @@ -597,72 +624,113 @@ shuffle_options: typing.Dict[str, type(Option)] = { class ShuffleMapCompass(Choice): - """Control where to shuffle dungeon maps and compasses.""" + """Control where to shuffle dungeon maps and compasses. + Remove: There will be no maps or compasses in the itempool. + Startwith: You start with all maps and compasses. + Vanilla: Maps and compasses remain vanilla. + Dungeon: Maps and compasses are shuffled within their original dungeon. + Regional: Maps and compasses are shuffled only in regions near the original dungeon. + Overworld: Maps and compasses are shuffled locally outside of dungeons. + Any Dungeon: Maps and compasses are shuffled locally in any dungeon. + Keysanity: Maps and compasses can be anywhere in the multiworld.""" display_name = "Maps & Compasses" option_remove = 0 option_startwith = 1 option_vanilla = 2 option_dungeon = 3 - option_overworld = 4 - option_any_dungeon = 5 - option_keysanity = 6 - option_regional = 7 + option_regional = 4 + option_overworld = 5 + option_any_dungeon = 6 + option_keysanity = 7 default = 1 - alias_anywhere = 6 + alias_anywhere = 7 class ShuffleKeys(Choice): - """Control where to shuffle dungeon small keys.""" + """Control where to shuffle dungeon small keys. + Remove/"Keysy": There will be no small keys in the itempool. All small key doors are automatically unlocked. + Vanilla: Small keys remain vanilla. You may start with extra small keys in some dungeons to prevent softlocks. + Dungeon: Small keys are shuffled within their original dungeon. + Regional: Small keys are shuffled only in regions near the original dungeon. + Overworld: Small keys are shuffled locally outside of dungeons. + Any Dungeon: Small keys are shuffled locally in any dungeon. + Keysanity: Small keys can be anywhere in the multiworld.""" display_name = "Small Keys" option_remove = 0 option_vanilla = 2 option_dungeon = 3 - option_overworld = 4 - option_any_dungeon = 5 - option_keysanity = 6 - option_regional = 7 + option_regional = 4 + option_overworld = 5 + option_any_dungeon = 6 + option_keysanity = 7 default = 3 alias_keysy = 0 - alias_anywhere = 6 + alias_anywhere = 7 class ShuffleGerudoKeys(Choice): - """Control where to shuffle the Thieves' Hideout small keys.""" + """Control where to shuffle the Thieves' Hideout small keys. + Vanilla: Hideout keys remain vanilla. + Regional: Hideout keys are shuffled only in the Gerudo Valley/Desert Colossus area. + Overworld: Hideout keys are shuffled locally outside of dungeons. + Any Dungeon: Hideout keys are shuffled locally in any dungeon. + Keysanity: Hideout keys can be anywhere in the multiworld.""" display_name = "Thieves' Hideout Keys" option_vanilla = 0 - option_overworld = 1 - option_any_dungeon = 2 - option_keysanity = 3 - option_regional = 4 - alias_anywhere = 3 + option_regional = 1 + option_overworld = 2 + option_any_dungeon = 3 + option_keysanity = 4 + alias_anywhere = 4 class ShuffleBossKeys(Choice): - """Control where to shuffle boss keys, except the Ganon's Castle Boss Key.""" + """Control where to shuffle boss keys, except the Ganon's Castle Boss Key. + Remove/"Keysy": There will be no boss keys in the itempool. All boss key doors are automatically unlocked. + Vanilla: Boss keys remain vanilla. You may start with extra small keys in some dungeons to prevent softlocks. + Dungeon: Boss keys are shuffled within their original dungeon. + Regional: Boss keys are shuffled only in regions near the original dungeon. + Overworld: Boss keys are shuffled locally outside of dungeons. + Any Dungeon: Boss keys are shuffled locally in any dungeon. + Keysanity: Boss keys can be anywhere in the multiworld.""" display_name = "Boss Keys" option_remove = 0 option_vanilla = 2 option_dungeon = 3 - option_overworld = 4 - option_any_dungeon = 5 - option_keysanity = 6 - option_regional = 7 + option_regional = 4 + option_overworld = 5 + option_any_dungeon = 6 + option_keysanity = 7 default = 3 alias_keysy = 0 - alias_anywhere = 6 + alias_anywhere = 7 class ShuffleGanonBK(Choice): - """Control how to shuffle the Ganon's Castle Boss Key.""" + """Control how to shuffle the Ganon's Castle Boss Key (GCBK). + Remove: GCBK is removed, and the boss key door is automatically unlocked. + Vanilla: GCBK remains vanilla. + Dungeon: GCBK is shuffled within its original dungeon. + Regional: GCBK is shuffled only in Hyrule Field, Market, and Hyrule Castle areas. + Overworld: GCBK is shuffled locally outside of dungeons. + Any Dungeon: GCBK is shuffled locally in any dungeon. + Keysanity: GCBK can be anywhere in the multiworld. + On LACS: GCBK is on the Light Arrow Cutscene, which requires Shadow and Spirit Medallions. + Stones: GCBK will be awarded when reaching the target number of Spiritual Stones. + Medallions: GCBK will be awarded when reaching the target number of medallions. + Dungeons: GCBK will be awarded when reaching the target number of dungeon rewards. + Tokens: GCBK will be awarded when reaching the target number of Gold Skulltula Tokens. + Hearts: GCBK will be awarded when reaching the target number of hearts. + """ display_name = "Ganon's Boss Key" option_remove = 0 option_vanilla = 2 option_dungeon = 3 - option_overworld = 4 - option_any_dungeon = 5 - option_keysanity = 6 - option_on_lacs = 7 - option_regional = 8 + option_regional = 4 + option_overworld = 5 + option_any_dungeon = 6 + option_keysanity = 7 + option_on_lacs = 8 option_stones = 9 option_medallions = 10 option_dungeons = 11 @@ -670,7 +738,7 @@ class ShuffleGanonBK(Choice): option_hearts = 13 default = 0 alias_keysy = 0 - alias_anywhere = 6 + alias_anywhere = 7 class EnhanceMC(Toggle): @@ -679,7 +747,7 @@ class EnhanceMC(Toggle): class GanonBKMedallions(Range): - """Set how many medallions are required to receive Ganon BK.""" + """With medallions GCBK: set how many medallions are required to receive GCBK.""" display_name = "Medallions Required for Ganon's BK" range_start = 1 range_end = 6 @@ -687,7 +755,7 @@ class GanonBKMedallions(Range): class GanonBKStones(Range): - """Set how many Spiritual Stones are required to receive Ganon BK.""" + """With stones GCBK: set how many Spiritual Stones are required to receive GCBK.""" display_name = "Spiritual Stones Required for Ganon's BK" range_start = 1 range_end = 3 @@ -695,7 +763,7 @@ class GanonBKStones(Range): class GanonBKRewards(Range): - """Set how many dungeon rewards are required to receive Ganon BK.""" + """With dungeons GCBK: set how many dungeon rewards are required to receive GCBK.""" display_name = "Dungeon Rewards Required for Ganon's BK" range_start = 1 range_end = 9 @@ -703,7 +771,7 @@ class GanonBKRewards(Range): class GanonBKTokens(Range): - """Set how many Gold Skulltula Tokens are required to receive Ganon BK.""" + """With tokens GCBK: set how many Gold Skulltula Tokens are required to receive GCBK.""" display_name = "Tokens Required for Ganon's BK" range_start = 1 range_end = 100 @@ -711,7 +779,7 @@ class GanonBKTokens(Range): class GanonBKHearts(Range): - """Set how many hearts are required to receive Ganon BK.""" + """With hearts GCBK: set how many hearts are required to receive GCBK.""" display_name = "Hearts Required for Ganon's BK" range_start = 4 range_end = 20 @@ -719,7 +787,9 @@ class GanonBKHearts(Range): class KeyRings(Choice): - """Dungeons have all small keys found at once, rather than individually.""" + """A key ring grants all dungeon small keys at once, rather than individually. + Choose: Use the option "key_rings_list" to choose which dungeons have key rings. + All: All dungeons have key rings instead of small keys.""" display_name = "Key Rings Mode" option_off = 0 option_choose = 1 @@ -728,7 +798,7 @@ class KeyRings(Choice): class KeyRingList(OptionSet): - """Select areas with keyrings rather than individual small keys.""" + """With key rings as Choose: select areas with key rings rather than individual small keys.""" display_name = "Key Ring Areas" valid_keys = { "Thieves' Hideout", @@ -828,7 +898,8 @@ class BigPoeCount(Range): class FAETorchCount(Range): - """Number of lit torches required to open Shadow Temple.""" + """Number of lit torches required to open Shadow Temple. + Does not affect logic; use the trick Shadow Temple Entry with Fire Arrows if desired.""" display_name = "Fire Arrow Entry Torch Count" range_start = 1 range_end = 24 @@ -853,7 +924,11 @@ timesavers_options: typing.Dict[str, type(Option)] = { class CorrectChestAppearance(Choice): - """Changes chest textures and/or sizes to match their contents. "Classic" is the old behavior of CSMC.""" + """Changes chest textures and/or sizes to match their contents. + Off: All chests have their vanilla size/appearance. + Textures: Chest textures reflect their contents. + Both: Like Textures, but progression items and boss keys get big chests, and other items get small chests. + Classic: Old behavior of CSMC; textures distinguish keys from non-keys, and size distinguishes importance.""" display_name = "Chest Appearance Matches Contents" option_off = 0 option_textures = 1 @@ -872,15 +947,24 @@ class InvisibleChests(Toggle): class CorrectPotCrateAppearance(Choice): - """Unchecked pots and crates have a different texture; unchecked beehives will wiggle. With textures_content, pots and crates have an appearance based on their contents; with textures_unchecked, all unchecked pots/crates have the same appearance.""" + """Changes the appearance of pots, crates, and beehives that contain items. + Off: Vanilla appearance for all containers. + Textures (Content): Unchecked pots and crates have a texture reflecting their contents. Unchecked beehives with progression items will wiggle. + Textures (Unchecked): Unchecked pots and crates are golden. Unchecked beehives will wiggle. + """ display_name = "Pot, Crate, and Beehive Appearance" option_off = 0 option_textures_content = 1 option_textures_unchecked = 2 + default = 2 class Hints(Choice): - """Gossip Stones can give hints about item locations.""" + """Gossip Stones can give hints about item locations. + None: Gossip Stones do not give hints. + Mask: Gossip Stones give hints with Mask of Truth. + Agony: Gossip Stones give hints wtih Stone of Agony. + Always: Gossip Stones always give hints.""" display_name = "Gossip Stones" option_none = 0 option_mask = 1 @@ -895,7 +979,9 @@ class MiscHints(DefaultOnToggle): class HintDistribution(Choice): - """Choose the hint distribution to use. Affects the frequency of strong hints, which items are always hinted, etc.""" + """Choose the hint distribution to use. Affects the frequency of strong hints, which items are always hinted, etc. + Detailed documentation on hint distributions can be found on the Archipelago GitHub or OoTRandomizer.com. + The Async hint distribution is intended for async multiworlds. It removes Way of the Hero hints to improve generation times, since they are not very useful in asyncs.""" display_name = "Hint Distribution" option_balanced = 0 option_ddr = 1 @@ -907,10 +993,13 @@ class HintDistribution(Choice): option_useless = 7 option_very_strong = 8 option_async = 9 + default = 9 class TextShuffle(Choice): - """Randomizes text in the game for comedic effect.""" + """Randomizes text in the game for comedic effect. + Except Hints: does not randomize important text such as hints, small/boss key information, and item prices. + Complete: randomizes every textbox, including the useful ones.""" display_name = "Text Shuffle" option_none = 0 option_except_hints = 1 @@ -946,7 +1035,8 @@ class HeroMode(Toggle): class StartingToD(Choice): - """Change the starting time of day.""" + """Change the starting time of day. + Daytime starts at Sunrise and ends at Sunset. Default is between Morning and Noon.""" display_name = "Starting Time of Day" option_default = 0 option_sunrise = 1 @@ -999,7 +1089,11 @@ misc_options: typing.Dict[str, type(Option)] = { } class ItemPoolValue(Choice): - """Changes the number of items available in the game.""" + """Changes the number of items available in the game. + Plentiful: One extra copy of every major item. + Balanced: Original item pool. + Scarce: Extra copies of major items are removed. Heart containers are removed. + Minimal: All major item upgrades not used for locations are removed. All health is removed.""" display_name = "Item Pool" option_plentiful = 0 option_balanced = 1 @@ -1009,7 +1103,12 @@ class ItemPoolValue(Choice): class IceTraps(Choice): - """Adds ice traps to the item pool.""" + """Adds ice traps to the item pool. + Off: All ice traps are removed. + Normal: The vanilla quantity of ice traps are placed. + On/"Extra": There is a chance for some extra ice traps to be placed. + Mayhem: All added junk items are ice traps. + Onslaught: All junk items are replaced by ice traps, even those in the base pool.""" display_name = "Ice Traps" option_off = 0 option_normal = 1 @@ -1021,34 +1120,27 @@ class IceTraps(Choice): class IceTrapVisual(Choice): - """Changes the appearance of ice traps as freestanding items.""" - display_name = "Ice Trap Appearance" + """Changes the appearance of traps, including other games' traps, as freestanding items.""" + display_name = "Trap Appearance" option_major_only = 0 option_junk_only = 1 option_anything = 2 -class AdultTradeStart(OptionSet): - """Choose the items that can appear to start the adult trade sequence. By default it is Claim Check only.""" - display_name = "Adult Trade Sequence Items" - default = {"Claim Check"} - valid_keys = { - "Pocket Egg", - "Pocket Cucco", - "Cojiro", - "Odd Mushroom", - "Poachers Saw", - "Broken Sword", - "Prescription", - "Eyeball Frog", - "Eyedrops", - "Claim Check", - } - - def __init__(self, value: typing.Iterable[str]): - if not value: - value = self.default - super().__init__(value) +class AdultTradeStart(Choice): + """Choose the item that starts the adult trade sequence.""" + display_name = "Adult Trade Sequence Start" + option_pocket_egg = 0 + option_pocket_cucco = 1 + option_cojiro = 2 + option_odd_mushroom = 3 + option_poachers_saw = 4 + option_broken_sword = 5 + option_prescription = 6 + option_eyeball_frog = 7 + option_eyedrops = 8 + option_claim_check = 9 + default = 9 itempool_options: typing.Dict[str, type(Option)] = { @@ -1068,7 +1160,7 @@ class Targeting(Choice): class DisplayDpad(DefaultOnToggle): - """Show dpad icon on HUD for quick actions (ocarina, hover boots, iron boots).""" + """Show dpad icon on HUD for quick actions (ocarina, hover boots, iron boots, mask).""" display_name = "Display D-Pad HUD" @@ -1191,7 +1283,6 @@ oot_options: typing.Dict[str, type(Option)] = { **world_options, **bridge_options, **dungeon_items_options, - # **lacs_options, **shuffle_options, **timesavers_options, **misc_options, diff --git a/worlds/oot/Patches.py b/worlds/oot/Patches.py index ab1e75d1b9..f83b34183c 100644 --- a/worlds/oot/Patches.py +++ b/worlds/oot/Patches.py @@ -2094,10 +2094,14 @@ def patch_rom(world, rom): if not world.dungeon_mq['Ganons Castle']: chest_name = 'Ganons Castle Light Trial Lullaby Chest' location = world.get_location(chest_name) - if location.item.game == 'Ocarina of Time': - item = read_rom_item(rom, location.item.index) + if not location.item.trap: + if location.item.game == 'Ocarina of Time': + item = read_rom_item(rom, location.item.index) + else: + item = read_rom_item(rom, AP_PROGRESSION if location.item.advancement else AP_JUNK) else: - item = read_rom_item(rom, AP_PROGRESSION if location.item.advancement else AP_JUNK) + looks_like_index = get_override_entry(world, location)[5] + item = read_rom_item(rom, looks_like_index) if item['chest_type'] in (GOLD_CHEST, GILDED_CHEST, SKULL_CHEST_BIG): rom.write_int16(0x321B176, 0xFC40) # original 0xFC48 @@ -2106,10 +2110,14 @@ def patch_rom(world, rom): chest_name = 'Spirit Temple Compass Chest' chest_address = 0x2B6B07C location = world.get_location(chest_name) - if location.item.game == 'Ocarina of Time': - item = read_rom_item(rom, location.item.index) + if not location.item.trap: + if location.item.game == 'Ocarina of Time': + item = read_rom_item(rom, location.item.index) + else: + item = read_rom_item(rom, AP_PROGRESSION if location.item.advancement else AP_JUNK) else: - item = read_rom_item(rom, AP_PROGRESSION if location.item.advancement else AP_JUNK) + looks_like_index = get_override_entry(world, location)[5] + item = read_rom_item(rom, looks_like_index) if item['chest_type'] in (BROWN_CHEST, SILVER_CHEST, SKULL_CHEST_SMALL): rom.write_int16(chest_address + 2, 0x0190) # X pos rom.write_int16(chest_address + 6, 0xFABC) # Z pos @@ -2120,10 +2128,14 @@ def patch_rom(world, rom): chest_address_0 = 0x21A02D0 # Address in setup 0 chest_address_2 = 0x21A06E4 # Address in setup 2 location = world.get_location(chest_name) - if location.item.game == 'Ocarina of Time': - item = read_rom_item(rom, location.item.index) + if not location.item.trap: + if location.item.game == 'Ocarina of Time': + item = read_rom_item(rom, location.item.index) + else: + item = read_rom_item(rom, AP_PROGRESSION if location.item.advancement else AP_JUNK) else: - item = read_rom_item(rom, AP_PROGRESSION if location.item.advancement else AP_JUNK) + looks_like_index = get_override_entry(world, location)[5] + item = read_rom_item(rom, looks_like_index) if item['chest_type'] in (BROWN_CHEST, SILVER_CHEST, SKULL_CHEST_SMALL): rom.write_int16(chest_address_0 + 6, 0x0172) # Z pos rom.write_int16(chest_address_2 + 6, 0x0172) # Z pos diff --git a/worlds/oot/Rules.py b/worlds/oot/Rules.py index 9bcb42ab72..fa198e0ce1 100644 --- a/worlds/oot/Rules.py +++ b/worlds/oot/Rules.py @@ -223,9 +223,6 @@ def set_shop_rules(ootworld): # The goal is to automatically set item rules based on age requirements in case entrances were shuffled def set_entrances_based_rules(ootworld): - if ootworld.multiworld.accessibility[ootworld.player] == 'minimal': - return - all_state = ootworld.multiworld.get_all_state(False) for location in filter(lambda location: location.type == 'Shop', ootworld.get_locations()): diff --git a/worlds/oot/Utils.py b/worlds/oot/Utils.py index c2444cd1fe..9faffbdedd 100644 --- a/worlds/oot/Utils.py +++ b/worlds/oot/Utils.py @@ -11,7 +11,7 @@ def data_path(*args): return os.path.join(os.path.dirname(__file__), 'data', *args) -@lru_cache(maxsize=13) # Cache Overworld.json and the 12 dungeons +@lru_cache def read_json(file_path): json_string = "" with io.open(file_path, 'r') as file: diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 539abd9674..6af19683f4 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -10,7 +10,7 @@ from string import printable logger = logging.getLogger("Ocarina of Time") -from .Location import OOTLocation, LocationFactory, location_name_to_id +from .Location import OOTLocation, LocationFactory, location_name_to_id, build_location_name_groups from .Entrance import OOTEntrance from .EntranceShuffle import shuffle_random_entrances, entrance_shuffle_table, EntranceShuffleError from .HintList import getRequiredHints @@ -163,11 +163,13 @@ class OOTWorld(World): "Bottle with Big Poe", "Bottle with Red Potion", "Bottle with Green Potion", "Bottle with Blue Potion", "Bottle with Fairy", "Bottle with Fish", "Bottle with Blue Fire", "Bottle with Bugs", "Bottle with Poe"}, - "Adult Trade Item": {"Pocket Egg", "Pocket Cucco", "Odd Mushroom", + "Adult Trade Item": {"Pocket Egg", "Pocket Cucco", "Cojiro", "Odd Mushroom", "Odd Potion", "Poachers Saw", "Broken Sword", "Prescription", - "Eyeball Frog", "Eyedrops", "Claim Check"} + "Eyeball Frog", "Eyedrops", "Claim Check"}, } + location_name_groups = build_location_name_groups() + def __init__(self, world, player): self.hint_data_available = threading.Event() self.collectible_flags_available = threading.Event() @@ -384,6 +386,7 @@ class OOTWorld(World): self.mq_dungeons_mode = 'count' self.mq_dungeons_count = 0 self.dungeon_mq = {item['name']: (item['name'] in mq_dungeons) for item in dungeon_table} + self.dungeon_mq['Thieves Hideout'] = False # fix for bug in SaveContext:287 # Empty dungeon placeholder for the moment self.empty_dungeons = {name: False for name in self.dungeon_mq} @@ -409,6 +412,9 @@ class OOTWorld(World): self.starting_tod = self.starting_tod.replace('_', '-') self.shuffle_scrubs = self.shuffle_scrubs.replace('_prices', '') + # Convert adult trade option to expected Set + self.adult_trade_start = {self.adult_trade_start.title().replace('_', ' ')} + # Get hint distribution self.hint_dist_user = read_json(data_path('Hints', f'{self.hint_dist}.json')) @@ -446,7 +452,7 @@ class OOTWorld(World): self.always_hints = [hint.name for hint in getRequiredHints(self)] # Determine items which are not considered advancement based on settings. They will never be excluded. - self.nonadvancement_items = {'Double Defense'} + self.nonadvancement_items = {'Double Defense', 'Deku Stick Capacity', 'Deku Nut Capacity'} if (self.damage_multiplier != 'ohko' and self.damage_multiplier != 'quadruple' and self.shuffle_scrubs == 'off' and not self.shuffle_grotto_entrances): # nayru's love may be required to prevent forced damage @@ -633,16 +639,18 @@ class OOTWorld(World): self.multiworld.itempool.remove(item) self.hinted_dungeon_reward_locations[item.name] = loc - def create_item(self, name: str): + def create_item(self, name: str, allow_arbitrary_name: bool = False): if name in item_table: return OOTItem(name, self.player, item_table[name], False, (name in self.nonadvancement_items if getattr(self, 'nonadvancement_items', None) else False)) - return OOTItem(name, self.player, ('Event', True, None, None), True, False) + if allow_arbitrary_name: + return OOTItem(name, self.player, ('Event', True, None, None), True, False) + raise Exception(f"Invalid item name: {name}") def make_event_item(self, name, location, item=None): if item is None: - item = self.create_item(name) + item = self.create_item(name, allow_arbitrary_name=True) self.multiworld.push_item(location, item, collect=False) location.locked = True location.event = True @@ -800,23 +808,25 @@ class OOTWorld(World): self.multiworld.itempool.remove(item) self.multiworld.random.shuffle(locations) fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), locations, stage_items, - single_player_placement=True, lock=True) + single_player_placement=True, lock=True, allow_excluded=True) else: for dungeon_info in dungeon_table: dungeon_name = dungeon_info['name'] locations = gather_locations(self.multiworld, fill_stage, self.player, dungeon=dungeon_name) if isinstance(locations, list): dungeon_items = list(filter(lambda item: dungeon_name in item.name, stage_items)) + if not dungeon_items: + continue for item in dungeon_items: self.multiworld.itempool.remove(item) self.multiworld.random.shuffle(locations) fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), locations, dungeon_items, - single_player_placement=True, lock=True) + single_player_placement=True, lock=True, allow_excluded=True) # Place songs # 5 built-in retries because this section can fail sometimes if self.shuffle_song_items != 'any': - tries = 5 + tries = 10 if self.shuffle_song_items == 'song': song_locations = list(filter(lambda location: location.type == 'Song', self.multiworld.get_unfilled_locations(player=self.player))) @@ -852,7 +862,7 @@ class OOTWorld(World): try: self.multiworld.random.shuffle(song_locations) fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), song_locations[:], songs[:], - True, True) + single_player_placement=True, lock=True, allow_excluded=True) logger.debug(f"Successfully placed songs for player {self.player} after {6 - tries} attempt(s)") except FillError as e: tries -= 1 @@ -888,7 +898,8 @@ class OOTWorld(World): self.multiworld.random.shuffle(shop_locations) for item in shop_prog + shop_junk: self.multiworld.itempool.remove(item) - fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), shop_locations, shop_prog, True, True) + fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), shop_locations, shop_prog, + single_player_placement=True, lock=True, allow_excluded=True) fast_fill(self.multiworld, shop_junk, shop_locations) for loc in shop_locations: loc.locked = True @@ -963,7 +974,7 @@ class OOTWorld(World): multiworld.itempool.remove(item) multiworld.random.shuffle(locations) fill_restrictive(multiworld, multiworld.get_all_state(False), locations, group_stage_items, - single_player_placement=False, lock=True) + single_player_placement=False, lock=True, allow_excluded=True) if fill_stage == 'Song': # We don't want song locations to contain progression unless it's a song # or it was marked as priority. @@ -984,7 +995,7 @@ class OOTWorld(World): multiworld.itempool.remove(item) multiworld.random.shuffle(locations) fill_restrictive(multiworld, multiworld.get_all_state(False), locations, group_dungeon_items, - single_player_placement=False, lock=True) + single_player_placement=False, lock=True, allow_excluded=True) def generate_output(self, output_directory: str): if self.hints != 'none': @@ -1051,7 +1062,10 @@ class OOTWorld(World): def stage_generate_output(cls, multiworld: MultiWorld, output_directory: str): def hint_type_players(hint_type: str) -> set: return {autoworld.player for autoworld in multiworld.get_game_worlds("Ocarina of Time") - if autoworld.hints != 'none' and autoworld.hint_dist_user['distribution'][hint_type]['copies'] > 0} + if autoworld.hints != 'none' + and autoworld.hint_dist_user['distribution'][hint_type]['copies'] > 0 + and (autoworld.hint_dist_user['distribution'][hint_type]['fixed'] > 0 + or autoworld.hint_dist_user['distribution'][hint_type]['weight'] > 0)} try: item_hint_players = hint_type_players('item') @@ -1078,10 +1092,10 @@ class OOTWorld(World): if loc.game == "Ocarina of Time" and loc.item.code and (not loc.locked or (oot_is_item_of_type(loc.item, 'Song') or - (oot_is_item_of_type(loc.item, 'SmallKey') and multiworld.worlds[loc.player].shuffle_smallkeys == 'any_dungeon') or - (oot_is_item_of_type(loc.item, 'HideoutSmallKey') and multiworld.worlds[loc.player].shuffle_hideoutkeys == 'any_dungeon') or - (oot_is_item_of_type(loc.item, 'BossKey') and multiworld.worlds[loc.player].shuffle_bosskeys == 'any_dungeon') or - (oot_is_item_of_type(loc.item, 'GanonBossKey') and multiworld.worlds[loc.player].shuffle_ganon_bosskey == 'any_dungeon'))): + (oot_is_item_of_type(loc.item, 'SmallKey') and multiworld.worlds[loc.player].shuffle_smallkeys in ('overworld', 'any_dungeon', 'regional')) or + (oot_is_item_of_type(loc.item, 'HideoutSmallKey') and multiworld.worlds[loc.player].shuffle_hideoutkeys in ('overworld', 'any_dungeon', 'regional')) or + (oot_is_item_of_type(loc.item, 'BossKey') and multiworld.worlds[loc.player].shuffle_bosskeys in ('overworld', 'any_dungeon', 'regional')) or + (oot_is_item_of_type(loc.item, 'GanonBossKey') and multiworld.worlds[loc.player].shuffle_ganon_bosskey in ('overworld', 'any_dungeon', 'regional')))): if loc.player in barren_hint_players: hint_area = get_hint_area(loc) items_by_region[loc.player][hint_area]['weight'] += 1 @@ -1096,7 +1110,12 @@ class OOTWorld(World): elif barren_hint_players or woth_hint_players: # Check only relevant oot locations for barren/woth for player in (barren_hint_players | woth_hint_players): for loc in multiworld.worlds[player].get_locations(): - if loc.item.code and (not loc.locked or oot_is_item_of_type(loc.item, 'Song')): + if loc.item.code and (not loc.locked or + (oot_is_item_of_type(loc.item, 'Song') or + (oot_is_item_of_type(loc.item, 'SmallKey') and multiworld.worlds[loc.player].shuffle_smallkeys in ('overworld', 'any_dungeon', 'regional')) or + (oot_is_item_of_type(loc.item, 'HideoutSmallKey') and multiworld.worlds[loc.player].shuffle_hideoutkeys in ('overworld', 'any_dungeon', 'regional')) or + (oot_is_item_of_type(loc.item, 'BossKey') and multiworld.worlds[loc.player].shuffle_bosskeys in ('overworld', 'any_dungeon', 'regional')) or + (oot_is_item_of_type(loc.item, 'GanonBossKey') and multiworld.worlds[loc.player].shuffle_ganon_bosskey in ('overworld', 'any_dungeon', 'regional')))): if player in barren_hint_players: hint_area = get_hint_area(loc) items_by_region[player][hint_area]['weight'] += 1 @@ -1183,6 +1202,15 @@ class OOTWorld(World): er_hint_data[self.player][location.address] = main_entrance.name logger.debug(f"Set {location.name} hint data to {main_entrance.name}") + def write_spoiler(self, spoiler_handle: typing.TextIO) -> None: + required_trials_str = ", ".join(t for t in self.skipped_trials if not self.skipped_trials[t]) + spoiler_handle.write(f"\n\nTrials ({self.multiworld.get_player_name(self.player)}): {required_trials_str}\n") + + if self.shopsanity != 'off': + spoiler_handle.write(f"\nShop Prices ({self.multiworld.get_player_name(self.player)}):\n") + for k, v in self.shop_prices.items(): + spoiler_handle.write(f"{k}: {v} Rupees\n") + # Key ring handling: # Key rings are multiple items glued together into one, so we need to give # the appropriate number of keys in the collection state when they are @@ -1265,25 +1293,13 @@ class OOTWorld(World): # Specifically ensures that only real items are gotten, not any events. # In particular, ensures that Time Travel needs to be found. def get_state_with_complete_itempool(self): - all_state = self.multiworld.get_all_state(use_cache=False) - # Remove event progression items - for item, player in all_state.prog_items: - if player == self.player and (item not in item_table or item_table[item][2] is None): - all_state.prog_items[(item, player)] = 0 - # Remove all events and checked locations - all_state.locations_checked = {loc for loc in all_state.locations_checked if loc.player != self.player} - all_state.events = {loc for loc in all_state.events if loc.player != self.player} + all_state = CollectionState(self.multiworld) + for item in self.multiworld.itempool: + if item.player == self.player: + self.multiworld.worlds[item.player].collect(all_state, item) # If free_scarecrow give Scarecrow Song if self.free_scarecrow: all_state.collect(self.create_item("Scarecrow Song"), event=True) - - # Invalidate caches - all_state.child_reachable_regions[self.player] = set() - all_state.adult_reachable_regions[self.player] = set() - all_state.child_blocked_connections[self.player] = set() - all_state.adult_blocked_connections[self.player] = set() - all_state.day_reachable_regions[self.player] = set() - all_state.dampe_reachable_regions[self.player] = set() all_state.stale[self.player] = True return all_state @@ -1349,7 +1365,7 @@ def gather_locations(multiworld: MultiWorld, condition = lambda location: location.name in dungeon_song_locations locations += filter(condition, multiworld.get_unfilled_locations(player=player)) else: - if any(map(lambda v: v in {'keysanity'}, fill_opts.values())): + if any(map(lambda v: v == 'keysanity', fill_opts.values())): return None for player, option in fill_opts.items(): condition = functools.partial(valid_dungeon_item_location, diff --git a/worlds/overcooked2/__init__.py b/worlds/overcooked2/__init__.py index 2bf523b347..0451f32bdd 100644 --- a/worlds/overcooked2/__init__.py +++ b/worlds/overcooked2/__init__.py @@ -172,7 +172,7 @@ class Overcooked2World(World): # random priority locations have no desirable effect on solo seeds return list() - balancing_mode = self.get_options()["LocationBalancing"] + balancing_mode = self.options.location_balancing if balancing_mode == LocationBalancingMode.disabled: # Location balancing is disabled, progression density is purely determined by filler @@ -528,7 +528,7 @@ class Overcooked2World(World): # Game Modifications "LevelPurchaseRequirements": level_purchase_requirements, "Custom66TimerScale": max(0.4, 0.25 + (1.0 - star_threshold_scale)*0.6), - "ShortHordeLevels": self.options.short_horde_levels, + "ShortHordeLevels": self.options.short_horde_levels.result, "CustomLevelOrder": custom_level_order, # Items (Starting Inventory) @@ -584,6 +584,7 @@ class Overcooked2World(World): "TwoStars": star_threshold_scale * 0.75, "OneStar": star_threshold_scale * 0.35, } + base_data["AlwaysServeOldestOrder"] = self.options.always_serve_oldest_order.result return base_data diff --git a/worlds/pokemon_rb/docs/setup_en.md b/worlds/pokemon_rb/docs/setup_en.md index 488f3fdc07..7ba9b3aa09 100644 --- a/worlds/pokemon_rb/docs/setup_en.md +++ b/worlds/pokemon_rb/docs/setup_en.md @@ -7,7 +7,7 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst ## Required Software - BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) - - Version 2.3.1 and later are supported. Version 2.7 is recommended for stability. + - Version 2.3.1 and later are supported. Version 2.9.1 is recommended. - Detailed installation instructions for BizHawk can be found at the above link. - Windows users must run the prereq installer first, which can also be found at the above link. - The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases) @@ -23,7 +23,7 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst Once BizHawk has been installed, open EmuHawk and change the following settings: -- (≤ 2.8) Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to +- (If using 2.8 or earlier) Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to "Lua+LuaInterface". Then restart EmuHawk. This is required for the Lua script to function correctly. **NOTE: Even if "Lua+LuaInterface" is already selected, toggle between the two options and reselect it. Fresh installs** **of newer versions of EmuHawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load** @@ -57,7 +57,7 @@ For `trainer_name` and `rival_name` the following regular characters are allowed * `‘’“”·… ABCDEFGHIJKLMNOPQRSTUVWXYZ():;[]abcdefghijklmnopqrstuvwxyzé'-?!.♂$×/,♀0123456789` -And the following special characters (these each take up one character): +And the following special characters (these each count as one character): * `<'d>` * `<'l>` * `<'t>` diff --git a/worlds/pokemon_rb/docs/setup_es.md b/worlds/pokemon_rb/docs/setup_es.md index 2a943da72f..a6a6aa6ce7 100644 --- a/worlds/pokemon_rb/docs/setup_es.md +++ b/worlds/pokemon_rb/docs/setup_es.md @@ -7,7 +7,7 @@ Al usar BizHawk, esta guía solo es aplicable en los sistemas de Windows y Linux ## Software Requerido - BizHawk: [BizHawk Releases en TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) - - La versión 2.3.1 y posteriores son soportadas. Se recomienda la versión 2.7 para estabilidad. + - La versión 2.3.1 y posteriores son soportadas. Se recomienda la versión 2.9.1. - Instrucciones de instalación detalladas para BizHawk se pueden encontrar en el enlace de arriba. - Los usuarios de Windows deben ejecutar el instalador de prerrequisitos (prereq installer) primero, que también se encuentra en el enlace de arriba. diff --git a/worlds/pokemon_rb/locations.py b/worlds/pokemon_rb/locations.py index ec6375859b..4f1b55a00d 100644 --- a/worlds/pokemon_rb/locations.py +++ b/worlds/pokemon_rb/locations.py @@ -795,7 +795,7 @@ location_data = [ LocationData("Pewter Gym", "Defeat Brock", "Defeat Brock", event=True), LocationData("Cerulean Gym", "Defeat Misty", "Defeat Misty", event=True), LocationData("Vermilion Gym", "Defeat Lt. Surge", "Defeat Lt. Surge", event=True), - LocationData("Celadon Gym", "Defeat Erika", "Defeat Erika", event=True), + LocationData("Celadon Gym-C", "Defeat Erika", "Defeat Erika", event=True), LocationData("Fuchsia Gym", "Defeat Koga", "Defeat Koga", event=True), LocationData("Cinnabar Gym", "Defeat Blaine", "Defeat Blaine", event=True), LocationData("Saffron Gym-C", "Defeat Sabrina", "Defeat Sabrina", event=True), diff --git a/worlds/pokemon_rb/regions.py b/worlds/pokemon_rb/regions.py index cc788dd2ba..f844976548 100644 --- a/worlds/pokemon_rb/regions.py +++ b/worlds/pokemon_rb/regions.py @@ -1456,7 +1456,9 @@ mansion_stair_destinations = [ unreachable_outdoor_entrances = [ "Route 4-C to Mt Moon B1F-NE", "Fuchsia City-Good Rod House Backyard to Fuchsia Good Rod House", - "Cerulean City-Badge House Backyard to Cerulean Badge House" + "Cerulean City-Badge House Backyard to Cerulean Badge House", + # TODO: This doesn't need to be forced if fly location is Pokemon League? + "Route 23-N to Victory Road 2F-E" ] @@ -1592,7 +1594,7 @@ def create_regions(self): connect(multiworld, player, "Menu", "Pallet Town", one_way=True) connect(multiworld, player, "Menu", "Pokedex", one_way=True) connect(multiworld, player, "Menu", "Evolution", one_way=True) - connect(multiworld, player, "Menu", "Fossil", lambda state: logic.fossil_checks(state, + connect(multiworld, player, "Menu", "Fossil", lambda state: logic.fossil_checks(state, state.multiworld.second_fossil_check_condition[player].value, player), one_way=True) connect(multiworld, player, "Pallet Town", "Route 1") connect(multiworld, player, "Route 1", "Viridian City") @@ -2220,7 +2222,7 @@ def create_regions(self): "Cinnabar Gym - Blaine Prize", "Viridian Gym - Giovanni Prize"]: badge_locs.append(multiworld.get_location(loc, player)) multiworld.random.shuffle(badges) - while badges[3].name == "Cascade Badge" and multiworld.badges_needed_for_hm_moves[player] == "on": + while badges[3].name == "Cascade Badge" and multiworld.badges_needed_for_hm_moves[player]: multiworld.random.shuffle(badges) for badge, loc in zip(badges, badge_locs): loc.place_locked_item(badge) @@ -2265,22 +2267,30 @@ def create_regions(self): "Defeat Viridian Gym Giovanni", ] - def adds_reachable_entrances(entrances_copy, item): - state.collect(item, False) + event_locations = self.multiworld.get_filled_locations(player) + + def adds_reachable_entrances(entrances_copy, item, dead_end_cache): + ret = dead_end_cache.get(item.name) + if (ret != None): + return ret + + state_copy = state.copy() + state_copy.collect(item, True) + state.sweep_for_events(locations=event_locations) ret = len([entrance for entrance in entrances_copy if entrance in reachable_entrances or - entrance.parent_region.can_reach(state)]) > len(reachable_entrances) - state.remove(item) + entrance.parent_region.can_reach(state_copy)]) > len(reachable_entrances) + dead_end_cache[item.name] = ret return ret - def dead_end(entrances_copy, e): + def dead_end(entrances_copy, e, dead_end_cache): region = e.parent_region check_warps = set() checked_regions = {region} check_warps.update(region.exits) check_warps.remove(e) for location in region.locations: - if location.item and location.item.name in relevant_events and adds_reachable_entrances(entrances_copy, - location.item): + if location.item and location.item.name in relevant_events and \ + adds_reachable_entrances(entrances_copy, location.item, dead_end_cache): return False while check_warps: warp = check_warps.pop() @@ -2297,16 +2307,22 @@ def create_regions(self): check_warps.update(warp.connected_region.exits) for location in warp.connected_region.locations: if (location.item and location.item.name in relevant_events and - adds_reachable_entrances(entrances_copy, location.item)): + adds_reachable_entrances(entrances_copy, location.item, dead_end_cache)): return False return True starting_entrances = len(entrances) dc_connected = [] - event_locations = self.multiworld.get_filled_locations(player) + rock_tunnel_entrances = [entrance for entrance in entrances if "Rock Tunnel" in entrance.name] + entrances = [entrance for entrance in entrances if entrance not in rock_tunnel_entrances] while entrances: state.update_reachable_regions(player) state.sweep_for_events(locations=event_locations) + + if rock_tunnel_entrances and logic.rock_tunnel(state, player): + entrances += rock_tunnel_entrances + rock_tunnel_entrances = None + reachable_entrances = [entrance for entrance in entrances if entrance in reachable_entrances or entrance.parent_region.can_reach(state)] assert reachable_entrances, \ @@ -2321,30 +2337,29 @@ def create_regions(self): if multiworld.door_shuffle[player] == "full" or len(entrances) != len(reachable_entrances): entrances.sort(key=lambda e: e.name not in entrance_only) - if len(entrances) < 48 and multiworld.door_shuffle[player] == "full": - # Prevent a situation where the only remaining outdoor entrances are ones that cannot be reached - # except by connecting directly to it. - entrances.sort(key=lambda e: e.name in unreachable_outdoor_entrances) + dead_end_cache = {} + # entrances list is empty while it's being sorted, must pass a copy to iterate through entrances_copy = entrances.copy() if multiworld.door_shuffle[player] == "decoupled": - if len(reachable_entrances) <= 8 and not logic.rock_tunnel(state, player): - entrances.sort(key=lambda e: 1 if "Rock Tunnel" in e.name else 2 if e.connected_region is not - None else 3 if e not in reachable_entrances else 0) - else: - entrances.sort(key=lambda e: 1 if e.connected_region is not None else 2 if e not in - reachable_entrances else 0) + entrances.sort(key=lambda e: 1 if e.connected_region is not None else 2 if e not in + reachable_entrances else 0) assert entrances[0].connected_region is None,\ "Ran out of valid reachable entrances in Pokemon Red and Blue door shuffle" elif len(reachable_entrances) > (1 if multiworld.door_shuffle[player] == "insanity" else 8) and len( entrances) <= (starting_entrances - 3): entrances.sort(key=lambda e: 0 if e in reachable_entrances else 2 if - dead_end(entrances_copy, e) else 1) + dead_end(entrances_copy, e, dead_end_cache) else 1) else: entrances.sort(key=lambda e: 0 if e in reachable_entrances else 1 if - dead_end(entrances_copy, e) else 2) + dead_end(entrances_copy, e, dead_end_cache) else 2) if multiworld.door_shuffle[player] == "full": outdoor = outdoor_map(entrances[0].parent_region.name) + if len(entrances) < 48 and not outdoor: + # Prevent a situation where the only remaining outdoor entrances are ones that cannot be reached + # except by connecting directly to it. + entrances.sort(key=lambda e: e.name in unreachable_outdoor_entrances) + entrances.sort(key=lambda e: outdoor_map(e.parent_region.name) != outdoor) assert entrances[0] in reachable_entrances, \ "Ran out of valid reachable entrances in Pokemon Red and Blue door shuffle" diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 9d6f28607e..f208e600b9 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -1,18 +1,17 @@ from __future__ import annotations -import logging -import copy -import os -import threading import base64 -import settings +import copy +import logging +import threading import typing from typing import Any, Dict, Iterable, List, Set, TextIO, TypedDict -from BaseClasses import Region, Entrance, Location, MultiWorld, Item, ItemClassification, CollectionState, Tutorial -from Fill import fill_restrictive -from worlds.AutoWorld import World, AutoLogicRegister, WebWorld -from worlds.generic.Rules import set_rule, add_rule, add_item_rule +import settings +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, MultiWorld, Region, Tutorial +from Options import Accessibility +from worlds.AutoWorld import AutoLogicRegister, WebWorld, World +from worlds.generic.Rules import add_rule, set_rule logger = logging.getLogger("Super Metroid") diff --git a/worlds/sm/docs/multiworld_en.md b/worlds/sm/docs/multiworld_en.md index ce91e7a7e4..1291507743 100644 --- a/worlds/sm/docs/multiworld_en.md +++ b/worlds/sm/docs/multiworld_en.md @@ -49,7 +49,7 @@ them. Player settings page: [Super Metroid Player Settings Page](/games/Super%20 ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -validator page: [YAML Validation page](/mysterycheck) +validator page: [YAML Validation page](/check) ## Generating a Single-Player Game diff --git a/worlds/smw/docs/setup_en.md b/worlds/smw/docs/setup_en.md index 9ca8bdf58a..3967f544a0 100644 --- a/worlds/smw/docs/setup_en.md +++ b/worlds/smw/docs/setup_en.md @@ -50,7 +50,7 @@ them. Player settings page: [Super Mario World Player Settings Page](/games/Supe ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -validator page: [YAML Validation page](/mysterycheck) +validator page: [YAML Validation page](/check) ## Joining a MultiWorld Game diff --git a/worlds/smz3/docs/multiworld_en.md b/worlds/smz3/docs/multiworld_en.md index da6e29ab69..53842a3c6f 100644 --- a/worlds/smz3/docs/multiworld_en.md +++ b/worlds/smz3/docs/multiworld_en.md @@ -47,7 +47,7 @@ them. Player settings page: [SMZ3 Player Settings Page](/games/SMZ3/player-setti ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -validator page: [YAML Validation page](/mysterycheck) +validator page: [YAML Validation page](/check) ## Generating a Single-Player Game diff --git a/worlds/soe/Logic.py b/worlds/soe/Logic.py index 3c173dec2f..e464b7fd3b 100644 --- a/worlds/soe/Logic.py +++ b/worlds/soe/Logic.py @@ -3,7 +3,7 @@ from typing import Protocol, Set from BaseClasses import MultiWorld from worlds.AutoWorld import LogicMixin from . import pyevermizer -from .Options import EnergyCore +from .Options import EnergyCore, OutOfBounds, SequenceBreaks # TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early? @@ -61,4 +61,10 @@ class SecretOfEvermoreLogic(LogicMixin): if w.energy_core == EnergyCore.option_fragments: progress = pyevermizer.P_CORE_FRAGMENT count = w.required_fragments + elif progress == pyevermizer.P_ALLOW_OOB: + if world.worlds[player].out_of_bounds == OutOfBounds.option_logic: + return True + elif progress == pyevermizer.P_ALLOW_SEQUENCE_BREAKS: + if world.worlds[player].sequence_breaks == SequenceBreaks.option_logic: + return True return self._soe_count(progress, world, player, count) >= count diff --git a/worlds/soe/Options.py b/worlds/soe/Options.py index f1a30745f8..3de2de34ac 100644 --- a/worlds/soe/Options.py +++ b/worlds/soe/Options.py @@ -38,6 +38,12 @@ class OffOnFullChoice(Choice): alias_chaos = 2 +class OffOnLogicChoice(Choice): + option_off = 0 + option_on = 1 + option_logic = 2 + + # actual options class Difficulty(EvermizerFlags, Choice): """Changes relative spell cost and stuff""" @@ -93,10 +99,18 @@ class ExpModifier(Range): default = 200 -class FixSequence(EvermizerFlag, DefaultOnToggle): - """Fix some sequence breaks""" - display_name = "Fix Sequence" - flag = '1' +class SequenceBreaks(EvermizerFlags, OffOnLogicChoice): + """Disable, enable some sequence breaks or put them in logic""" + display_name = "Sequence Breaks" + default = 0 + flags = ['', 'j', 'J'] + + +class OutOfBounds(EvermizerFlags, OffOnLogicChoice): + """Disable, enable the out-of-bounds glitch or put it in logic""" + display_name = "Out Of Bounds" + default = 0 + flags = ['', 'u', 'U'] class FixCheats(EvermizerFlag, DefaultOnToggle): @@ -240,7 +254,8 @@ soe_options: typing.Dict[str, AssembleOptions] = { "available_fragments": AvailableFragments, "money_modifier": MoneyModifier, "exp_modifier": ExpModifier, - "fix_sequence": FixSequence, + "sequence_breaks": SequenceBreaks, + "out_of_bounds": OutOfBounds, "fix_cheats": FixCheats, "fix_infinite_ammo": FixInfiniteAmmo, "fix_atlas_glitch": FixAtlasGlitch, diff --git a/worlds/soe/__init__.py b/worlds/soe/__init__.py index f887325c60..9a8f38cdac 100644 --- a/worlds/soe/__init__.py +++ b/worlds/soe/__init__.py @@ -10,12 +10,8 @@ from worlds.generic.Rules import add_item_rule, set_rule from BaseClasses import Entrance, Item, ItemClassification, Location, LocationProgressType, Region, Tutorial from Utils import output_path -try: - import pyevermizer # from package -except ImportError: - import traceback - traceback.print_exc() - from . import pyevermizer # as part of the source tree +import pyevermizer # from package +# from . import pyevermizer # as part of the source tree from . import Logic # load logic mixin from .Options import soe_options, Difficulty, EnergyCore, RequiredFragments, AvailableFragments @@ -179,6 +175,8 @@ class SoEWorld(World): evermizer_seed: int connect_name: str energy_core: int + sequence_breaks: int + out_of_bounds: int available_fragments: int required_fragments: int @@ -191,6 +189,8 @@ class SoEWorld(World): def generate_early(self) -> None: # store option values that change logic self.energy_core = self.multiworld.energy_core[self.player].value + self.sequence_breaks = self.multiworld.sequence_breaks[self.player].value + self.out_of_bounds = self.multiworld.out_of_bounds[self.player].value self.required_fragments = self.multiworld.required_fragments[self.player].value if self.required_fragments > self.multiworld.available_fragments[self.player].value: self.multiworld.available_fragments[self.player].value = self.required_fragments @@ -224,9 +224,8 @@ class SoEWorld(World): max_difficulty = 1 if self.multiworld.difficulty[self.player] == Difficulty.option_easy else 256 # TODO: generate *some* regions from locations' requirements? - r = Region('Menu', self.player, self.multiworld) - r.exits = [Entrance(self.player, 'New Game', r)] - self.multiworld.regions += [r] + menu = Region('Menu', self.player, self.multiworld) + self.multiworld.regions += [menu] def get_sphere_index(evermizer_loc): """Returns 0, 1 or 2 for locations in spheres 1, 2, 3+""" @@ -234,11 +233,14 @@ class SoEWorld(World): return 2 return min(2, len(evermizer_loc.requires)) + # create ingame region + ingame = Region('Ingame', self.player, self.multiworld) + # group locations into spheres (1, 2, 3+ at index 0, 1, 2) spheres: typing.Dict[int, typing.Dict[int, typing.List[SoELocation]]] = {} for loc in _locations: spheres.setdefault(get_sphere_index(loc), {}).setdefault(loc.type, []).append( - SoELocation(self.player, loc.name, self.location_name_to_id[loc.name], r, + SoELocation(self.player, loc.name, self.location_name_to_id[loc.name], ingame, loc.difficulty > max_difficulty)) # location balancing data @@ -280,18 +282,16 @@ class SoEWorld(World): late_locations = self.multiworld.random.sample(late_bosses, late_count) # add locations to the world - r = Region('Ingame', self.player, self.multiworld) for sphere in spheres.values(): for locations in sphere.values(): for location in locations: - r.locations.append(location) + ingame.locations.append(location) if location.name in late_locations: location.progress_type = LocationProgressType.PRIORITY - r.locations.append(SoELocation(self.player, 'Done', None, r)) - self.multiworld.regions += [r] - - self.multiworld.get_entrance('New Game', self.player).connect(self.multiworld.get_region('Ingame', self.player)) + ingame.locations.append(SoELocation(self.player, 'Done', None, ingame)) + menu.connect(ingame, "New Game") + self.multiworld.regions += [ingame] def create_items(self): # add regular items to the pool diff --git a/worlds/soe/docs/multiworld_en.md b/worlds/soe/docs/multiworld_en.md index d995cea56a..58b9aabf6a 100644 --- a/worlds/soe/docs/multiworld_en.md +++ b/worlds/soe/docs/multiworld_en.md @@ -29,7 +29,7 @@ them. Player settings page: [Secret of Evermore Player Settings PAge](/games/Sec ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator -page: [YAML Validation page](/mysterycheck) +page: [YAML Validation page](/check) ## Generating a Single-Player Game diff --git a/worlds/soe/requirements.txt b/worlds/soe/requirements.txt index 878a2a80cc..710f51ddb0 100644 --- a/worlds/soe/requirements.txt +++ b/worlds/soe/requirements.txt @@ -1,18 +1,36 @@ -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp38-cp38-win_amd64.whl#0.44.0 ; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.8' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp39-cp39-win_amd64.whl#0.44.0 ; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.9' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp310-cp310-win_amd64.whl#0.44.0 ; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.10' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp311-cp311-win_amd64.whl#0.44.0 ; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.11' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.8' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.9' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.10' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.11' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.8' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.9' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.10' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#0.44.0 ; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.11' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp38-cp38-macosx_10_9_x86_64.whl#0.44.0 ; sys_platform == 'darwin' and python_version == '3.8' -#pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp39-cp39-macosx_10_9_x86_64.whl#0.44.0 ; sys_platform == 'darwin' and python_version == '3.9' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp39-cp39-macosx_10_9_universal2.whl#0.44.0 ; sys_platform == 'darwin' and python_version == '3.9' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp310-cp310-macosx_10_9_universal2.whl#0.44.0 ; sys_platform == 'darwin' and python_version == '3.10' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-cp311-cp311-macosx_10_9_universal2.whl#0.44.0 ; sys_platform == 'darwin' and python_version == '3.11' -pyevermizer @ https://github.com/black-sliver/pyevermizer/releases/download/v0.44.0/pyevermizer-0.44.0-1.tar.gz#0.44.0 ; python_version < '3.8' or python_version > '3.11' or (sys_platform != 'win32' and sys_platform != 'linux' and sys_platform != 'darwin') or (platform_machine != 'AMD64' and platform_machine != 'x86_64' and platform_machine != 'aarch64' and platform_machine != 'universal2' and platform_machine != 'arm64') +pyevermizer==0.46.1 \ + --hash=sha256:9fd71b5e4af26a5dd24a9cbf5320bf0111eef80320613401a1c03011b1515806 \ + --hash=sha256:23f553ed0509d9a238b2832f775e0b5abd7741b38ab60d388294ee8a7b96c5fb \ + --hash=sha256:7189b67766418a3e7e6c683f09c5e758aa1a5c24316dd9b714984bac099c4b75 \ + --hash=sha256:befa930711e63d5d5892f67fd888b2e65e746363e74599c53e71ecefb90ae16a \ + --hash=sha256:202933ce21e0f33859537bf3800d9a626c70262a9490962e3f450171758507ca \ + --hash=sha256:c20ca69311c696528e1122ebc7d33775ee971f538c0e3e05dd3bfd4de10b82d4 \ + --hash=sha256:74dc689a771ae5ffcd5257e763f571ee890e3e87bdb208233b7f451522c00d66 \ + --hash=sha256:072296baef464daeb6304cf58827dcbae441ad0803039aee1c0caa10d56e0674 \ + --hash=sha256:7921baf20d52d92d6aeb674125963c335b61abb7e1298bde4baf069d11a2d05e \ + --hash=sha256:ca098034a84007038c2bff004582e6e6ac2fa9cc8b9251301d25d7e2adcee6da \ + --hash=sha256:22ddb29823c19be9b15e1b3627db1babfe08b486aede7d5cc463a0a1ae4c75d8 \ + --hash=sha256:bf1c441b49026d9000166be6e2f63fc351a3fda170aa3fdf18d44d5e5d044640 \ + --hash=sha256:9710aa7957b4b1f14392006237eb95803acf27897377df3e85395f057f4316b9 \ + --hash=sha256:8feb676c198bee17ab991ee015828345ac3f87c27dfdb3061d92d1fe47c184b4 \ + --hash=sha256:597026dede72178ff3627a4eb3315de8444461c7f0f856f5773993c3f9790c53 \ + --hash=sha256:70f9b964bdfb5191e8f264644c5d1af3041c66fe15261df8a99b3d719dc680d6 \ + --hash=sha256:74655c0353ffb6cda30485091d0917ce703b128cd824b612b3110a85c79a93d0 \ + --hash=sha256:0e9c74d105d4ec3af12404e85bb8776931c043657add19f798ee69465f92b999 \ + --hash=sha256:d3c13446d3d482b9cce61ac73b38effd26fcdcf7f693a405868d3aaaa4d18ca6 \ + --hash=sha256:371ac3360640ef439a5920ddfe11a34e9d2e546ed886bb8c9ed312611f9f4655 \ + --hash=sha256:6e5cf63b036f24d2ae4375a88df8d0bc93208352939521d1fcac3c829ef2c363 \ + --hash=sha256:edf28f5c4d1950d17343adf6d8d40d12c7e982d1e39535d55f7915e122cd8b0e \ + --hash=sha256:b5ef6f3b4e04f677c296f60f7f4c320ac22cd5bc09c05574460116c8641c801a \ + --hash=sha256:dd651f66720af4abe2ddae29944e299a57ff91e6fca1739e6dc1f8fd7a8c2b39 \ + --hash=sha256:4e278f5f72c27f9703bce5514d2fead8c00361caac03e94b0bf9ad8a144f1eeb \ + --hash=sha256:38f36ea1f545b835c3ecd6e081685a233ac2e3cf0eec8916adc92e4d791098a6 \ + --hash=sha256:0a2e58ed6e7c42f006cc17d32cec1f432f01b3fe490e24d71471b36e0d0d8742 \ + --hash=sha256:c1b658db76240596c03571c60635abe953f36fb55b363202971831c2872ea9a0 \ + --hash=sha256:deb5a84a6a56325eb6701336cdbf70f72adaaeab33cbe953d0e551ecf2592f20 \ + --hash=sha256:b1425c793e0825f58b3726e7afebaf5a296c07cb0d28580d0ee93dbe10dcdf63 \ + --hash=sha256:11995fb4dfd14b5c359591baee2a864c5814650ba0084524d4ea0466edfaf029 \ + --hash=sha256:5d2120b5c93ae322fe2a85d48e3eab4168a19e974a880908f1ac291c0300940f \ + --hash=sha256:254912ea4bfaaffb0abe366e73bd9ecde622677d6afaf2ce8a0c330df99fefd9 \ + --hash=sha256:540d8e4525f0b5255c1554b4589089dc58e15df22f343e9545ea00f7012efa07 \ + --hash=sha256:f69b8ebded7eed181fabe30deabae89fd10c41964f38abb26b19664bbe55c1ae diff --git a/worlds/soe/test/__init__.py b/worlds/soe/test/__init__.py index 3c2a0dc1b6..27d38605aa 100644 --- a/worlds/soe/test/__init__.py +++ b/worlds/soe/test/__init__.py @@ -1,5 +1,20 @@ from test.TestBase import WorldTestBase +from typing import Iterable class SoETestBase(WorldTestBase): game = "Secret of Evermore" + + def assertLocationReachability(self, reachable: Iterable[str] = (), unreachable: Iterable[str] = (), + satisfied=True) -> None: + """ + Tests that unreachable can't be reached. Tests that reachable can be reached if satisfied=True. + Usage: test with satisfied=False, collect requirements into state, test again with satisfied=True + """ + for location in reachable: + self.assertEqual(self.can_reach_location(location), satisfied, + f"{location} is unreachable but should be" if satisfied else + f"{location} is reachable but shouldn't be") + for location in unreachable: + self.assertFalse(self.can_reach_location(location), + f"{location} is reachable but shouldn't be") diff --git a/worlds/soe/test/TestAccess.py b/worlds/soe/test/test_access.py similarity index 100% rename from worlds/soe/test/TestAccess.py rename to worlds/soe/test/test_access.py diff --git a/worlds/soe/test/TestGoal.py b/worlds/soe/test/test_goal.py similarity index 100% rename from worlds/soe/test/TestGoal.py rename to worlds/soe/test/test_goal.py diff --git a/worlds/soe/test/test_oob.py b/worlds/soe/test/test_oob.py new file mode 100644 index 0000000000..27e00cd3e7 --- /dev/null +++ b/worlds/soe/test/test_oob.py @@ -0,0 +1,51 @@ +import typing +from . import SoETestBase + + +class OoBTest(SoETestBase): + """Tests that 'on' doesn't put out-of-bounds in logic. This is also the test base for OoB in logic.""" + options: typing.Dict[str, typing.Any] = {"out_of_bounds": "on"} + + def testOoBAccess(self): + in_logic = self.options["out_of_bounds"] == "logic" + + # some locations that just need a weapon + OoB + oob_reachable = [ + "Aquagoth", "Sons of Sth.", "Mad Monk", "Magmar", # OoB can use volcano shop to skip rock skip + "Levitate", "Fireball", "Drain", "Speed", + "E. Crustacia #107", "Energy Core #285", "Vanilla Gauge #57", + ] + # some locations that should still be unreachable + oob_unreachable = [ + "Tiny", "Rimsala", + "Barrier", "Call Up", "Reflect", "Force Field", "Stop", # Stop guy doesn't spawn for the other entrances + "Pyramid bottom #118", "Tiny's hideout #160", "Tiny's hideout #161", "Greenhouse #275", + ] + # OoB + Diamond Eyes + de_reachable = [ + "Tiny's hideout #160", + ] + # still unreachable + de_unreachable = [ + "Tiny", + "Tiny's hideout #161", + ] + + self.assertLocationReachability(reachable=oob_reachable, unreachable=oob_unreachable, satisfied=False) + self.collect_by_name("Gladiator Sword") + self.assertLocationReachability(reachable=oob_reachable, unreachable=oob_unreachable, satisfied=in_logic) + self.collect_by_name("Diamond Eye") + self.assertLocationReachability(reachable=de_reachable, unreachable=de_unreachable, satisfied=in_logic) + + def testOoBGoal(self): + # still need Energy Core with OoB if sequence breaks are not in logic + for item in ["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]: + self.collect_by_name(item) + self.assertBeatable(False) + self.collect_by_name("Energy Core") + self.assertBeatable(True) + + +class OoBInLogicTest(OoBTest): + """Tests that stuff that should be reachable/unreachable with out-of-bounds actually is.""" + options: typing.Dict[str, typing.Any] = {"out_of_bounds": "logic"} diff --git a/worlds/soe/test/test_sequence_breaks.py b/worlds/soe/test/test_sequence_breaks.py new file mode 100644 index 0000000000..4248f9b47d --- /dev/null +++ b/worlds/soe/test/test_sequence_breaks.py @@ -0,0 +1,45 @@ +import typing +from . import SoETestBase + + +class SequenceBreaksTest(SoETestBase): + """Tests that 'on' doesn't put sequence breaks in logic. This is also the test base for in-logic.""" + options: typing.Dict[str, typing.Any] = {"sequence_breaks": "on"} + + def testSequenceBreaksAccess(self): + in_logic = self.options["sequence_breaks"] == "logic" + + # some locations that just need any weapon + sequence break + break_reachable = [ + "Sons of Sth.", "Mad Monk", "Magmar", + "Fireball", + "Volcano Room1 #73", "Pyramid top #135", + ] + # some locations that should still be unreachable + break_unreachable = [ + "Aquagoth", "Megataur", "Tiny", "Rimsala", + "Barrier", "Call Up", "Levitate", "Stop", "Drain", "Escape", + "Greenhouse #275", "E. Crustacia #107", "Energy Core #285", "Vanilla Gauge #57", + ] + + self.assertLocationReachability(reachable=break_reachable, unreachable=break_unreachable, satisfied=False) + self.collect_by_name("Gladiator Sword") + self.assertLocationReachability(reachable=break_reachable, unreachable=break_unreachable, satisfied=in_logic) + self.collect_by_name("Spider Claw") # Gauge now just needs non-sword + self.assertEqual(self.can_reach_location("Vanilla Gauge #57"), in_logic) + self.collect_by_name("Bronze Spear") # Escape now just needs either Megataur or Rimsala dead + self.assertEqual(self.can_reach_location("Escape"), in_logic) + + def testSequenceBreaksGoal(self): + in_logic = self.options["sequence_breaks"] == "logic" + + # don't need Energy Core with sequence breaks in logic + for item in ["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]: + self.assertBeatable(False) + self.collect_by_name(item) + self.assertBeatable(in_logic) + + +class SequenceBreaksInLogicTest(SequenceBreaksTest): + """Tests that stuff that should be reachable/unreachable with sequence breaks actually is.""" + options: typing.Dict[str, typing.Any] = {"sequence_breaks": "logic"} diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py index 1f46eb79d7..177b6436ae 100644 --- a/worlds/stardew_valley/__init__.py +++ b/worlds/stardew_valley/__init__.py @@ -100,15 +100,15 @@ class StardewValleyWorld(World): return region world_regions, self.randomized_entrances = create_regions(create_region, self.multiworld.random, self.options) - self.multiworld.regions.extend(world_regions) def add_location(name: str, code: Optional[int], region: str): - region = self.multiworld.get_region(region, self.player) + region = world_regions[region] location = StardewLocation(self.player, name, code, region) location.access_rule = lambda _: True region.locations.append(location) create_locations(add_location, self.options, self.multiworld.random) + self.multiworld.regions.extend(world_regions.values()) def create_items(self): self.precollect_starting_season() diff --git a/worlds/stardew_valley/items.py b/worlds/stardew_valley/items.py index d5c71dae46..2d28b4de43 100644 --- a/worlds/stardew_valley/items.py +++ b/worlds/stardew_valley/items.py @@ -368,8 +368,8 @@ def create_arcade_machine_items(item_factory: StardewItemFactory, options: Stard def create_player_buffs(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]): - items.extend(item_factory(item) for item in [Buff.movement] * options.number_of_movement_buffs.value) - items.extend(item_factory(item) for item in [Buff.luck] * options.number_of_luck_buffs.value) + items.extend(item_factory(item) for item in [Buff.movement] * options.movement_buff_number.value) + items.extend(item_factory(item) for item in [Buff.luck] * options.luck_buff_number.value) def create_traveling_merchant_items(item_factory: StardewItemFactory, items: List[Item]): diff --git a/worlds/stardew_valley/logic.py b/worlds/stardew_valley/logic.py index b2841d1566..0746bd7752 100644 --- a/worlds/stardew_valley/logic.py +++ b/worlds/stardew_valley/logic.py @@ -927,7 +927,7 @@ class StardewLogic: return region_rule & ((tool_rule & foraging_rule) | magic_rule) def has_max_buffs(self) -> StardewRule: - return self.received(Buff.movement, self.options.number_of_movement_buffs.value) & self.received(Buff.luck, self.options.number_of_luck_buffs.value) + return self.received(Buff.movement, self.options.movement_buff_number.value) & self.received(Buff.luck, self.options.luck_buff_number.value) def get_weapon_rule_for_floor_tier(self, tier: int): if tier >= 4: @@ -1376,7 +1376,7 @@ class StardewLogic: return self.received(Wallet.rusty_key) def can_win_egg_hunt(self) -> StardewRule: - number_of_movement_buffs = self.options.number_of_movement_buffs.value + number_of_movement_buffs = self.options.movement_buff_number.value if self.options.festival_locations == FestivalLocations.option_hard or number_of_movement_buffs < 2: return True_() return self.received(Buff.movement, number_of_movement_buffs // 2) diff --git a/worlds/stardew_valley/options.py b/worlds/stardew_valley/options.py index 75573359a5..f462f507d4 100644 --- a/worlds/stardew_valley/options.py +++ b/worlds/stardew_valley/options.py @@ -556,8 +556,8 @@ class StardewValleyOptions(PerGameCommonOptions): museumsanity: Museumsanity friendsanity: Friendsanity friendsanity_heart_size: FriendsanityHeartSize - number_of_movement_buffs: NumberOfMovementBuffs - number_of_luck_buffs: NumberOfLuckBuffs + movement_buff_number: NumberOfMovementBuffs + luck_buff_number: NumberOfLuckBuffs exclude_ginger_island: ExcludeGingerIsland trap_items: TrapItems multiple_day_sleep_enabled: MultipleDaySleepEnabled diff --git a/worlds/stardew_valley/regions.py b/worlds/stardew_valley/regions.py index e8daa772d8..d8e2248411 100644 --- a/worlds/stardew_valley/regions.py +++ b/worlds/stardew_valley/regions.py @@ -429,7 +429,7 @@ def create_final_connections(world_options) -> List[ConnectionData]: def create_regions(region_factory: RegionFactory, random: Random, world_options) -> Tuple[ - Iterable[Region], Dict[str, str]]: + Dict[str, Region], Dict[str, str]]: final_regions = create_final_regions(world_options) regions: Dict[str: Region] = {region.name: region_factory(region.name, region.exits) for region in final_regions} @@ -444,7 +444,7 @@ def create_regions(region_factory: RegionFactory, random: Random, world_options) if connection.name in entrances: entrances[connection.name].connect(regions[connection.destination]) - return regions.values(), randomized_data + return regions, randomized_data def randomize_connections(random: Random, world_options, regions_by_name) -> Tuple[ diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 2d4cf2faf6..de4f4e33dc 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -6,12 +6,12 @@ from typing import List, Dict, Any, cast from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification from worlds.AutoWorld import World, WebWorld -from . import Items -from . import Locations -from . import Creatures -from . import Options -from .Items import item_table, group_items, items_by_type, ItemType -from .Rules import set_rules +from . import items +from . import locations +from . import creatures +from . import options +from .items import item_table, group_items, items_by_type, ItemType +from .rules import set_rules logger = logging.getLogger("Subnautica") @@ -27,8 +27,8 @@ class SubnaticaWeb(WebWorld): )] -all_locations = {data["name"]: loc_id for loc_id, data in Locations.location_table.items()} -all_locations.update(Creatures.creature_locations) +all_locations = {data["name"]: loc_id for loc_id, data in locations.location_table.items()} +all_locations.update(creatures.creature_locations) class SubnauticaWorld(World): @@ -40,9 +40,9 @@ class SubnauticaWorld(World): game = "Subnautica" web = SubnaticaWeb() - item_name_to_id = {data.name: item_id for item_id, data in Items.item_table.items()} + item_name_to_id = {data.name: item_id for item_id, data in items.item_table.items()} location_name_to_id = all_locations - option_definitions = Options.options + option_definitions = options.option_definitions data_version = 10 required_client_version = (0, 4, 1) @@ -50,37 +50,53 @@ class SubnauticaWorld(World): creatures_to_scan: List[str] def generate_early(self) -> None: - if self.multiworld.early_seaglide[self.player]: + if self.options.early_seaglide: self.multiworld.local_early_items[self.player]["Seaglide Fragment"] = 2 - scan_option: Options.AggressiveScanLogic = self.multiworld.creature_scan_logic[self.player] + scan_option: options.AggressiveScanLogic = self.options.creature_scan_logic creature_pool = scan_option.get_pool() - self.multiworld.creature_scans[self.player].value = min( + self.options.creature_scans.value = min( len(creature_pool), - self.multiworld.creature_scans[self.player].value + self.options.creature_scans.value ) - self.creatures_to_scan = self.multiworld.random.sample( - creature_pool, self.multiworld.creature_scans[self.player].value) + self.creatures_to_scan = self.random.sample( + creature_pool, self.options.creature_scans.value) def create_regions(self): - self.multiworld.regions += [ - self.create_region("Menu", None, ["Lifepod 5"]), - self.create_region("Planet 4546B", - Locations.events + - [location["name"] for location in Locations.location_table.values()] + - [creature+Creatures.suffix for creature in self.creatures_to_scan]) - ] + # Create Regions + menu_region = Region("Menu", self.player, self.multiworld) + planet_region = Region("Planet 4546B", self.player, self.multiworld) - # Link regions - self.multiworld.get_entrance("Lifepod 5", self.player).connect(self.multiworld.get_region("Planet 4546B", self.player)) + # Link regions together + menu_region.connect(planet_region, "Lifepod 5") - for event in Locations.events: - self.multiworld.get_location(event, self.player).place_locked_item( + # Create regular locations + location_names = itertools.chain((location["name"] for location in locations.location_table.values()), + (creature + creatures.suffix for creature in self.creatures_to_scan)) + for location_name in location_names: + loc_id = self.location_name_to_id[location_name] + location = SubnauticaLocation(self.player, location_name, loc_id, planet_region) + planet_region.locations.append(location) + + # Create events + goal_event_name = self.options.goal.get_event_name() + + for event in locations.events: + location = SubnauticaLocation(self.player, event, None, planet_region) + planet_region.locations.append(location) + location.place_locked_item( SubnauticaItem(event, ItemClassification.progression, None, player=self.player)) - # make the goal event the victory "item" - self.multiworld.get_location(self.multiworld.goal[self.player].get_event_name(), self.player).item.name = "Victory" + if event == goal_event_name: + # make the goal event the victory "item" + location.item.name = "Victory" + + # Register regions to multiworld + self.multiworld.regions += [ + menu_region, + planet_region + ] # refer to Rules.py set_rules = set_rules @@ -88,7 +104,7 @@ class SubnauticaWorld(World): def create_items(self): # Generate item pool pool: List[SubnauticaItem] = [] - extras = self.multiworld.creature_scans[self.player].value + extras = self.options.creature_scans.value grouped = set(itertools.chain.from_iterable(group_items.values())) @@ -139,17 +155,15 @@ class SubnauticaWorld(World): self.multiworld.itempool += pool def fill_slot_data(self) -> Dict[str, Any]: - goal: Options.Goal = self.multiworld.goal[self.player] - swim_rule: Options.SwimRule = self.multiworld.swim_rule[self.player] vanilla_tech: List[str] = [] slot_data: Dict[str, Any] = { - "goal": goal.current_key, - "swim_rule": swim_rule.current_key, + "goal": self.options.goal.current_key, + "swim_rule": self.options.swim_rule.current_key, "vanilla_tech": vanilla_tech, "creatures_to_scan": self.creatures_to_scan, - "death_link": self.multiworld.death_link[self.player].value, - "free_samples": self.multiworld.free_samples[self.player].value, + "death_link": self.options.death_link.value, + "free_samples": self.options.free_samples.value, } return slot_data @@ -161,10 +175,10 @@ class SubnauticaWorld(World): item_table[item_id].classification, item_id, player=self.player) - def create_region(self, name: str, locations=None, exits=None): + def create_region(self, name: str, region_locations=None, exits=None): ret = Region(name, self.player, self.multiworld) - if locations: - for location in locations: + if region_locations: + for location in region_locations: loc_id = self.location_name_to_id.get(location, None) location = SubnauticaLocation(self.player, location, loc_id, ret) ret.locations.append(location) diff --git a/worlds/subnautica/Creatures.py b/worlds/subnautica/creatures.py similarity index 100% rename from worlds/subnautica/Creatures.py rename to worlds/subnautica/creatures.py diff --git a/worlds/subnautica/Exports.py b/worlds/subnautica/exports.py similarity index 95% rename from worlds/subnautica/Exports.py rename to worlds/subnautica/exports.py index 7a69cffc8d..911c6a6f69 100644 --- a/worlds/subnautica/Exports.py +++ b/worlds/subnautica/exports.py @@ -12,8 +12,8 @@ if __name__ == "__main__": os.chdir(new_home) sys.path.append(new_home) - from worlds.subnautica.Locations import Vector, location_table - from worlds.subnautica.Items import item_table, group_items, items_by_type + from worlds.subnautica.locations import Vector, location_table + from worlds.subnautica.items import item_table, group_items, items_by_type from NetUtils import encode export_folder = os.path.join(new_home, "Subnautica Export") diff --git a/worlds/subnautica/Items.py b/worlds/subnautica/items.py similarity index 100% rename from worlds/subnautica/Items.py rename to worlds/subnautica/items.py diff --git a/worlds/subnautica/Locations.py b/worlds/subnautica/locations.py similarity index 100% rename from worlds/subnautica/Locations.py rename to worlds/subnautica/locations.py diff --git a/worlds/subnautica/Options.py b/worlds/subnautica/options.py similarity index 98% rename from worlds/subnautica/Options.py rename to worlds/subnautica/options.py index 582e93eb0e..d8d727a9e1 100644 --- a/worlds/subnautica/Options.py +++ b/worlds/subnautica/options.py @@ -1,7 +1,7 @@ import typing from Options import Choice, Range, DeathLink, Toggle, DefaultOnToggle, StartInventoryPool -from .Creatures import all_creatures, Definitions +from .creatures import all_creatures, Definitions class SwimRule(Choice): @@ -103,7 +103,7 @@ class SubnauticaDeathLink(DeathLink): Note: can be toggled via in-game console command "deathlink".""" -options = { +option_definitions = { "swim_rule": SwimRule, "early_seaglide": EarlySeaglide, "free_samples": FreeSamples, diff --git a/worlds/subnautica/Rules.py b/worlds/subnautica/rules.py similarity index 92% rename from worlds/subnautica/Rules.py rename to worlds/subnautica/rules.py index 793c85be41..3b6c5cd4dd 100644 --- a/worlds/subnautica/Rules.py +++ b/worlds/subnautica/rules.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING, Dict, Callable, Optional from worlds.generic.Rules import set_rule, add_rule -from .Locations import location_table, LocationDict -from .Creatures import all_creatures, aggressive, suffix, hatchable, containment -from .Options import AggressiveScanLogic, SwimRule +from .locations import location_table, LocationDict +from .creatures import all_creatures, aggressive, suffix, hatchable, containment +from .options import AggressiveScanLogic, SwimRule import math if TYPE_CHECKING: @@ -290,16 +290,16 @@ aggression_rules: Dict[int, Callable[["CollectionState", int], bool]] = { def set_rules(subnautica_world: "SubnauticaWorld"): player = subnautica_world.player - world = subnautica_world.multiworld + multiworld = subnautica_world.multiworld for loc in location_table.values(): - set_location_rule(world, player, loc) + set_location_rule(multiworld, player, loc) if subnautica_world.creatures_to_scan: - option = world.creature_scan_logic[player] + option = multiworld.creature_scan_logic[player] for creature_name in subnautica_world.creatures_to_scan: - location = set_creature_rule(world, player, creature_name) + location = set_creature_rule(multiworld, player, creature_name) if creature_name in containment: # there is no other way, hard-required containment add_rule(location, lambda state: has_containment(state, player)) elif creature_name in aggressive: @@ -309,7 +309,7 @@ def set_rules(subnautica_world: "SubnauticaWorld"): lambda state, loc_rule=get_aggression_rule(option, creature_name): loc_rule(state, player)) # Victory locations - set_rule(world.get_location("Neptune Launch", player), + set_rule(multiworld.get_location("Neptune Launch", player), lambda state: get_max_depth(state, player) >= 1444 and has_mobile_vehicle_bay(state, player) and @@ -322,13 +322,14 @@ def set_rules(subnautica_world: "SubnauticaWorld"): state.has("Ion Battery", player) and has_cyclops_shield(state, player)) - set_rule(world.get_location("Disable Quarantine", player), lambda state: - get_max_depth(state, player) >= 1444) + set_rule(multiworld.get_location("Disable Quarantine", player), + lambda state: get_max_depth(state, player) >= 1444) - set_rule(world.get_location("Full Infection", player), lambda state: - get_max_depth(state, player) >= 900) + set_rule(multiworld.get_location("Full Infection", player), + lambda state: get_max_depth(state, player) >= 900) - room = world.get_location("Aurora Drive Room - Upgrade Console", player) - set_rule(world.get_location("Repair Aurora Drive", player), lambda state: room.can_reach(state)) + room = multiworld.get_location("Aurora Drive Room - Upgrade Console", player) + set_rule(multiworld.get_location("Repair Aurora Drive", player), + lambda state: room.can_reach(state)) - world.completion_condition[player] = lambda state: state.has("Victory", player) + multiworld.completion_condition[player] = lambda state: state.has("Victory", player) diff --git a/worlds/subnautica/test/__init__.py b/worlds/subnautica/test/__init__.py index d938830737..69f0b6c7cd 100644 --- a/worlds/subnautica/test/__init__.py +++ b/worlds/subnautica/test/__init__.py @@ -15,11 +15,11 @@ class SubnauticaTest(unittest.TestCase): self.assertGreater(self.scancutoff, id) def testGroupAssociation(self): - from worlds.subnautica import Items - for item_id, item_data in Items.item_table.items(): - if item_data.type == Items.ItemType.group: + from worlds.subnautica import items + for item_id, item_data in items.item_table.items(): + if item_data.type == items.ItemType.group: with self.subTest(item=item_data.name): - self.assertIn(item_id, Items.group_items) - for item_id in Items.group_items: + self.assertIn(item_id, items.group_items) + for item_id in items.group_items: with self.subTest(item_id=item_id): - self.assertEqual(Items.item_table[item_id].type, Items.ItemType.group) + self.assertEqual(items.item_table[item_id].type, items.ItemType.group) diff --git a/worlds/terraria/Rules.dsv b/worlds/terraria/Rules.dsv index 5f5551e465..b511db54de 100644 --- a/worlds/terraria/Rules.dsv +++ b/worlds/terraria/Rules.dsv @@ -305,7 +305,7 @@ Hydraulic Volt Crusher; Calamity; Life Fruit; ; (@mech_boss(1) & Wall of Flesh) | (@calamity & (Living Shard | Wall of Flesh)); Get a Life; Achievement; Life Fruit; Topped Off; Achievement; Life Fruit; -Old One's Army Tier 2; Location | Item; #Old One's Army Tier 1 & (@mech_boss(1) | #Old One's Army Tier 3); +Old One's Army Tier 2; Location | Item; #Old One's Army Tier 1 & ((Wall of Flesh & @mech_boss(1)) | #Old One's Army Tier 3); // Brimstone Elemental Infernal Suevite; Calamity; @pickaxe(150) | Brimstone Elemental; @@ -410,7 +410,7 @@ Scoria Bar; Calamity; Seismic Hampick; Calamity | Pickaxe(210) | Hammer(95); Hardmode Anvil & Scoria Bar; Life Alloy; Calamity; (Hardmode Anvil & Cryonic Bar & Perennial Bar & Scoria Bar) | Necromantic Geode; Advanced Display; Calamity; Hardmode Anvil & Mysterious Circuitry & Dubious Plating & Life Alloy & Long Ranged Sensor Array; -Old One's Army Tier 3; Location | Item; #Old One's Army Tier 1 & Golem; +Old One's Army Tier 3; Location | Item; #Old One's Army Tier 1 & Wall of Flesh & Golem; // Martian Madness Martian Madness; Location | Item; Wall of Flesh & Golem; diff --git a/worlds/timespinner/__init__.py b/worlds/timespinner/__init__.py index de1d58e961..24230862bd 100644 --- a/worlds/timespinner/__init__.py +++ b/worlds/timespinner/__init__.py @@ -49,11 +49,9 @@ class TimespinnerWorld(World): precalculated_weights: PreCalculatedWeights - def __init__(self, world: MultiWorld, player: int): - super().__init__(world, player) - self.precalculated_weights = PreCalculatedWeights(world, player) - def generate_early(self) -> None: + self.precalculated_weights = PreCalculatedWeights(self.multiworld, self.player) + # in generate_early the start_inventory isnt copied over to precollected_items yet, so we can still modify the options directly if self.multiworld.start_inventory[self.player].value.pop('Meyef', 0) > 0: self.multiworld.StartWithMeyef[self.player].value = self.multiworld.StartWithMeyef[self.player].option_true diff --git a/worlds/tloz/docs/multiworld_en.md b/worlds/tloz/docs/multiworld_en.md index 581e8cf7b2..ae53d953b1 100644 --- a/worlds/tloz/docs/multiworld_en.md +++ b/worlds/tloz/docs/multiworld_en.md @@ -44,7 +44,7 @@ them. Player settings page: [The Legend of Zelda Player Settings Page](/games/Th ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -validator page: [YAML Validation page](/mysterycheck) +validator page: [YAML Validation page](/check) ## Generating a Single-Player Game diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index faaafd598b..28eaba6404 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -66,7 +66,7 @@ class WitnessWorld(World): def _get_slot_data(self): return { - 'seed': self.multiworld.per_slot_randoms[self.player].randint(0, 1000000), + 'seed': self.random.randrange(0, 1000000), 'victory_location': int(self.player_logic.VICTORY_LOCATION, 16), 'panelhex_to_id': self.locat.CHECK_PANELHEX_TO_ID, 'item_id_to_door_hexes': StaticWitnessItems.get_item_to_door_mappings(), diff --git a/worlds/witness/docs/en_The Witness.md b/worlds/witness/docs/en_The Witness.md index 2ae478bed0..4d00ecaae4 100644 --- a/worlds/witness/docs/en_The Witness.md +++ b/worlds/witness/docs/en_The Witness.md @@ -10,12 +10,13 @@ config file. Puzzles are randomly generated using the popular [Sigma Rando](https://github.com/sigma144/witness-randomizer). They are made to be similar to the original game, but with different solutions. -Ontop of that each puzzle symbol (Squares, Stars, Dots, etc.) is now an item. +On top of that, each puzzle symbol (Squares, Stars, Dots, etc.) is now an item. Panels with puzzle symbols on them are now locked initially. ## What is a "check" in The Witness? Solving the last panel in a row of panels or an important standalone panel will count as a check, and send out an item. +It is also possible to add Environmental Puzzles into the location pool via the "Shuffle Environmental Puzzles" setting. ## What "items" can you unlock in The Witness? @@ -32,7 +33,7 @@ By default, the audio logs scattered around the world will have 10 hints for you Example: "Shipwreck Vault contains Triangles". -## The Jungle, Orchard, Forest and Color House aren't randomized. What gives? +## The Jungle, Orchard, Forest and Color Bunker aren't randomized. What gives? There are limitations to what can currently be randomized in The Witness. There is an option to turn these non-randomized panels off, called "disable_non_randomized" in your yaml file. This will also slightly change the activation requirement of certain panels, detailed [here](https://github.com/sigma144/witness-randomizer/wiki/Activation-Triggers). @@ -46,4 +47,4 @@ In this case, the generator will make its best attempt to adjust logic according One of the use cases of this could be to pre-open a specific door or pre-activate a single laser. In "shuffle_EPs: obelisk_sides", any Environmental Puzzles in exclude_locations will be pre-completed and not considered for their Obelisk Side. -If every Environmental Puzzle on an Obelisk Side is pre-completed, that side disappears from the location pool entirely. \ No newline at end of file +If every Environmental Puzzle on an Obelisk Side is pre-completed, that side disappears from the location pool entirely. diff --git a/worlds/witness/docs/setup_en.md b/worlds/witness/docs/setup_en.md index 94a50846f9..daa9b8b9b5 100644 --- a/worlds/witness/docs/setup_en.md +++ b/worlds/witness/docs/setup_en.md @@ -29,8 +29,10 @@ To continue an earlier game: ## Archipelago Text Client -It is recommended to have Archipelago's Text Client open on the side to keep track of what items you receive and send, as The Witness has no in-game messages. -
    Or use the Auto-Tracker! +It is recommended to have Archipelago's Text Client open on the side to keep track of what items you receive and send. +
    The Witness does display received and sent items in-game, but only for a short time, and the messages are easy to miss while playing. + +

    Of course, you can also use the Auto-Tracker! ## Auto-Tracking @@ -41,4 +43,4 @@ The Witness has a fully functional map tracker that supports auto-tracking. 3. Click on the "AP" symbol at the top. 4. Enter the AP address, slot name and password. -The rest should take care of itself! Items and checks will be marked automatically, and it even knows your settings - It will hide checks & adjust logic accordingly. Note that the tracker may be out of date. \ No newline at end of file +The rest should take care of itself! Items and checks will be marked automatically, and it even knows your settings - It will hide checks & adjust logic accordingly. diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index 5d8bd5d370..4fd0edc429 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -306,7 +306,7 @@ def make_hints(multiworld: MultiWorld, player: int, hint_amount: int): else: hints.append((f"{loc} contains {item[0]}.", item[2])) - next_random_hint_is_item = multiworld.per_slot_randoms[player].randint(0, 2) + next_random_hint_is_item = multiworld.per_slot_randoms[player].randrange(0, 2) # Moving this to the new system is in the bigger refactoring PR while len(hints) < hint_amount: if next_random_hint_is_item: diff --git a/worlds/zillion/docs/setup_en.md b/worlds/zillion/docs/setup_en.md index 16000dbe3b..22dee5ee55 100644 --- a/worlds/zillion/docs/setup_en.md +++ b/worlds/zillion/docs/setup_en.md @@ -51,7 +51,7 @@ them. ### Verifying your config file -If you would like to validate your config file to make sure it works, you may do so on the [YAML Validator page](/mysterycheck). +If you would like to validate your config file to make sure it works, you may do so on the [YAML Validator page](/check). ## Generating a Single-Player Game