diff --git a/BaseClasses.py b/BaseClasses.py index bbf110d2..13e74d13 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -782,10 +782,9 @@ class RegionType(int, Enum): class Region(object): - - def __init__(self, name: str, type: str, hint, player: int, world: Optional[MultiWorld] = None): + def __init__(self, name: str, type_: RegionType, hint, player: int, world: Optional[MultiWorld] = None): self.name = name - self.type = type + self.type = type_ self.entrances = [] self.exits = [] self.locations = [] diff --git a/Generate.py b/Generate.py index 4704a59d..295eb140 100644 --- a/Generate.py +++ b/Generate.py @@ -23,6 +23,7 @@ import Options from worlds.alttp import Bosses from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister +import copy categories = set(AutoWorldRegister.world_types) @@ -148,7 +149,7 @@ def main(args=None, callback=ERmain): if category_name is None: weights_cache[path][key] = option elif category_name not in weights_cache[path]: - raise Exception(f"Meta: Category {category_name} is not present in {path}.") + logging.warning(f"Meta: Category {category_name} is not present in {path}.") else: weights_cache[path][category_name][key] = option @@ -330,7 +331,7 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di def roll_linked_options(weights: dict) -> dict: - weights = weights.copy() # make sure we don't write back to other weights sets in same_settings + weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings for option_set in weights["linked_options"]: if "name" not in option_set: raise ValueError("One of your linked options does not have a name.") @@ -352,7 +353,7 @@ def roll_linked_options(weights: dict) -> dict: def roll_triggers(weights: dict, triggers: list) -> dict: - weights = weights.copy() # make sure we don't write back to other weights sets in same_settings + weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights["_Generator_Version"] = Utils.__version__ for i, option_set in enumerate(triggers): try: diff --git a/Options.py b/Options.py index ef786e47..a5ef5f9c 100644 --- a/Options.py +++ b/Options.py @@ -125,6 +125,8 @@ class Toggle(Option): def get_option_name(cls, value): return ["No", "Yes"][int(value)] + __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ + class DefaultOnToggle(Toggle): default = 1 @@ -184,6 +186,8 @@ class Choice(Option): else: raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") + __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ + class Range(Option, int): range_start = 0 diff --git a/Utils.py b/Utils.py index df5d3c96..c2eab405 100644 --- a/Utils.py +++ b/Utils.py @@ -23,7 +23,7 @@ class Version(typing.NamedTuple): build: int -__version__ = "0.2.2" +__version__ = "0.2.3" version_tuple = tuplize_version(__version__) from yaml import load, dump, safe_load diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 085cfe56..847e99c8 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -193,6 +193,15 @@ def discord(): return redirect("https://discord.gg/archipelago") +@app.route('/datapackage') +@cache.cached() +def get_datapackge(): + """A pretty print version of /api/datapackage""" + from worlds import network_data_package + import json + return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain") + + from WebHostLib.customserver import run_server_process from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index 63483abc..e7029722 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -31,6 +31,7 @@ def get_datapackge(): from worlds import network_data_package return network_data_package + @api_endpoints.route('/datapackage_version') @cache.cached() def get_datapackge_versions(): diff --git a/WebHostLib/static/assets/gameInfo/en_Slay the Spire.md b/WebHostLib/static/assets/gameInfo/en_Slay the Spire.md new file mode 100644 index 00000000..9ec04834 --- /dev/null +++ b/WebHostLib/static/assets/gameInfo/en_Slay the Spire.md @@ -0,0 +1,29 @@ +# Slay the Spire (PC) + +## Where is the settings page? +The player settings page for this game is located here. It contains all the options +you need to configure and export a config file. + +## What does randomization do to this game? +Every non-boss relic drop, every boss relic and rare card drop, and every other card draw is replaced with an +archipelago item. In heart runs, the blue key is also disconnected from the Archipelago item, so you can gather both. + +## What items and locations get shuffled? +15 card packs, 10 relics, and 3 boss relics and rare card drops are shuffled into the item pool and can be found at any +location that would normally give you these items, except for card packs, which are found at every other normal enemy +encounter. + +## Which items can be in another player's world? +Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to +limit certain items to your own world. + +## When the player receives an item, what happens? +When the player receives an item, you will see the counter in the top right corner with the Archipelago symbol increment +by one. By clicking on this icon, it'll open a menu that lists all the items you received, but have not yet accepted. +You can take any relics and card packs sent to you and add them to your current run. It is advised that you do not open +this menu until you are outside an encounter or event to prevent the game from soft-locking. + +## What happens if a player dies in a run? +When a player dies, they will be taken back to the main menu and will need to reconnect to start climbing the spire +from the beginning, but they will have access to all the items ever sent to them in the Archipelago menu in the top +right. Any items found in an earlier run will not be sent again if you encounter them in the same location. diff --git a/WebHostLib/static/assets/tutorial/slay-the-spire/slay-the-spire_en.md b/WebHostLib/static/assets/tutorial/slay-the-spire/slay-the-spire_en.md new file mode 100644 index 00000000..9ff2bc32 --- /dev/null +++ b/WebHostLib/static/assets/tutorial/slay-the-spire/slay-the-spire_en.md @@ -0,0 +1,32 @@ +# Slay the Spire Setup Guide + +## Required Software + +For steam-based installation, subscribe to the following mods: + +- ModTheSpire from the [Slay the Spire Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=1605060445) +- BaseMod from the [Slay the Spire Workshop](https://steamcommunity.com/workshop/filedetails/?id=1605833019) +- Archipelago Multiworld Randomizer Mod from the [Slay the Spire Workshop](https://steamcommunity.com/sharedfiles/filedetails/?id=2596397288) + +## Configuring your YAML file + +### What is a YAML file and why do I need one? +Your YAML file contains a set of configuration options which provide the generator with information about how +it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows +each player to enjoy an experience customized for their taste, and different players in the same multiworld +can all have different options. + +### Where do I get a YAML file? +you can customize your settings by visiting the [Slay the Spire Settings Page](/games/Slay%20the%20Spire/player-settings). + +### Connect to the MultiServer +For Steam-based installations, if you are subscribed to ModTheSpire, when you launch the game, you should have the +option to launch the game with mods. On the mod loader screen, ensure you only have the following mods enabled and then +start the game: + +- BaseMod +- Archipelago Multiworld Randomizer + +Once you are in-game, you will be able to click the **Archipelago** menu option and enter the ip and port (separated by +a colon) in the hostname field and enter your player slot name in the Slot Name field. Then click connect, and now you +are ready to climb the spire! diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index 882d931f..66a46336 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -390,5 +390,24 @@ ] } ] + }, + { + "gameTitle": "Slay the Spire", + "tutorials": [ + { + "name": "Multiworld Setup Guide", + "description": "A guide to setting up Slay the Spire for Archipelago. This guide covers single-player, multiworld, and related software.", + "files": [ + { + "language": "English", + "filename": "slay-the-spire/slay-the-spire_en.md", + "link": "slay-the-spire/slay-the-spire/en", + "authors": [ + "Phar" + ] + } + ] + } + ] } ] diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index 91789c2f..7d0a9698 100644 --- a/WebHostLib/static/assets/weighted-settings.js +++ b/WebHostLib/static/assets/weighted-settings.js @@ -128,19 +128,24 @@ const buildUI = (settingData) => { expandButton.classList.add('invisible'); gameDiv.appendChild(expandButton); - const optionsDiv = buildOptionsDiv(game, settingData.games[game].gameSettings); - gameDiv.appendChild(optionsDiv); + const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings); + gameDiv.appendChild(weightedSettingsDiv); + + const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems); + gameDiv.appendChild(itemsDiv); gamesWrapper.appendChild(gameDiv); collapseButton.addEventListener('click', () => { collapseButton.classList.add('invisible'); - optionsDiv.classList.add('invisible'); + weightedSettingsDiv.classList.add('invisible'); + itemsDiv.classList.add('invisible'); expandButton.classList.remove('invisible'); }); expandButton.addEventListener('click', () => { collapseButton.classList.remove('invisible'); - optionsDiv.classList.remove('invisible'); + weightedSettingsDiv.classList.remove('invisible'); + itemsDiv.classList.remove('invisible'); expandButton.classList.add('invisible'); }); }); @@ -207,10 +212,10 @@ const buildGameChoice = (games) => { gameChoiceDiv.appendChild(table); }; -const buildOptionsDiv = (game, settings) => { +const buildWeightedSettingsDiv = (game, settings) => { const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const optionsWrapper = document.createElement('div'); - optionsWrapper.classList.add('settings-wrapper'); + const settingsWrapper = document.createElement('div'); + settingsWrapper.classList.add('settings-wrapper'); Object.keys(settings).forEach((settingName) => { const setting = settings[settingName]; @@ -268,27 +273,6 @@ const buildOptionsDiv = (game, settings) => { break; case 'range': - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerHTML = 'This is a range option. You may enter valid numerical values in the text box below, ' + - `then press the "Add" button to add a weight for it.
Minimum value: ${setting.min}
` + - `Maximum value: ${setting.max}`; - 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')); } - }); - const rangeTable = document.createElement('table'); const rangeTbody = document.createElement('tbody'); @@ -324,6 +308,79 @@ const buildOptionsDiv = (game, settings) => { 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}`; + 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', updateGameSetting); + 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); + }); + Object.keys(currentSettings[game][settingName]).forEach((option) => { if (currentSettings[game][settingName][option] > 0) { const tr = document.createElement('tr'); @@ -403,58 +460,6 @@ const buildOptionsDiv = (game, settings) => { rangeTable.appendChild(rangeTbody); settingWrapper.appendChild(rangeTable); - - 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', updateGameSetting); - 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); - }); break; default: @@ -462,10 +467,158 @@ const buildOptionsDiv = (game, settings) => { return; } - optionsWrapper.appendChild(settingWrapper); + settingsWrapper.appendChild(settingWrapper); }); - return optionsWrapper; + return settingsWrapper; +}; + +const buildItemsDiv = (game, items) => { + 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.sort().forEach((item) => { + const itemDiv = buildItemDiv(game, item); + + if (currentSettings[game].start_inventory.includes(item)){ + itemDiv.setAttribute('data-setting', 'start_inventory'); + startInventory.appendChild(itemDiv); + } else if (currentSettings[game].local_items.includes(item)) { + itemDiv.setAttribute('data-setting', 'local_items'); + localItems.appendChild(itemDiv); + } else if (currentSettings[game].non_local_items.includes(item)) { + itemDiv.setAttribute('data-setting', 'non_local_items'); + nonLocalItems.appendChild(itemDiv); + } else { + 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 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 itemDiv = buildItemDiv(game, 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; + + if (oldSetting) { + 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 (!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 updateVisibleGames = () => { diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-settings.css index ae488aff..28ab4a10 100644 --- a/WebHostLib/static/styles/weighted-settings.css +++ b/WebHostLib/static/styles/weighted-settings.css @@ -90,6 +90,38 @@ html{ cursor: pointer; } +#weighted-settings .items-wrapper{ + display: flex; + flex-direction: row; + justify-content: space-between; +} + +#weighted-settings .items-div h3{ + margin-bottom: 0.5rem; +} + +#weighted-settings .items-wrapper .item-set-wrapper{ + width: 24%; +} + +#weighted-settings .items-wrapper .item-container{ + border: 1px solid #ffffff; + border-radius: 2px; + width: 100%; + height: 300px; + overflow-y: auto; + overflow-x: hidden; +} + +#weighted-settings .items-wrapper .item-container .item-div{ + padding: 0.15rem; + cursor: pointer; +} + +#weighted-settings .items-wrapper .item-container .item-div:hover{ + background-color: rgba(0, 0, 0, 0.1); +} + #weighted-settings #weighted-settings-button-row{ display: flex; flex-direction: row; diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html index f6409916..7ed14ce0 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -9,7 +9,7 @@ {% include 'header/grassHeader.html' %}

Currently Supported Games

- {% for game, description in worlds.items() %} + {% for game, description in worlds.items() | sort %}

{{ game }}

Settings Page diff --git a/WebHostLib/templates/userContent.html b/WebHostLib/templates/userContent.html index 6d32f100..3e34c0e3 100644 --- a/WebHostLib/templates/userContent.html +++ b/WebHostLib/templates/userContent.html @@ -31,7 +31,7 @@ {{ room.seed.id|suuid }} {{ room.id|suuid }} - >={{ room.seed.slots|length }} + {{ room.seed.slots|length }} {{ room.creation_time.strftime("%Y-%m-%d %H:%M") }} {{ room.last_activity.strftime("%Y-%m-%d %H:%M") }} @@ -56,7 +56,7 @@ {% for seed in seeds %} {{ seed.id|suuid }} - {% if seed.multidata %}>={{ seed.slots|length }}{% else %}1{% endif %} + {% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %} {{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }} diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index cdd7e315..d1911a92 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -100,7 +100,7 @@ def uploads(): if file.filename == '': flash('No selected file') elif file and allowed_file(file.filename): - if zipfile.is_zipfile(file.filename): + if zipfile.is_zipfile(file): with zipfile.ZipFile(file, 'r') as zfile: res = upload_zip_to_db(zfile) if type(res) == str: @@ -108,12 +108,13 @@ def uploads(): elif res: return redirect(url_for("view_seed", seed=res.id)) else: + file.seek(0) # offset from is_zipfile check # noinspection PyBroadException try: multidata = file.read() MultiServer.Context.decompress(multidata) - except: - flash("Could not load multidata. File may be corrupted or incompatible.") + except Exception as e: + flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})") else: seed = Seed(multidata=multidata, owner=session["_id"]) flush() # place into DB and generate ids diff --git a/host.yaml b/host.yaml index 6411702a..1b2e6a53 100644 --- a/host.yaml +++ b/host.yaml @@ -64,7 +64,7 @@ generator: # general weights file, within the stated player_files_path location # gets used if players is higher than the amount of per-player files found to fill remaining slots weights_file_path: "weights.yaml" - # Meta file name, within the stated player_files_path location, TODO: re-implement this + # Meta file name, within the stated player_files_path location meta_file_path: "meta.yaml" # Create a spoiler file # 0 -> None diff --git a/test/TestBase.py b/test/TestBase.py index 431aaa00..cf73cec2 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -9,6 +9,7 @@ Utils.local_path.cached_path = file_path from BaseClasses import MultiWorld, CollectionState from worlds.alttp.Items import ItemFactory + class TestBase(unittest.TestCase): world: MultiWorld _state_cache = {} diff --git a/test/base/__init__.py b/test/base/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test/base/TestFill.py b/test/general/TestFill.py similarity index 98% rename from test/base/TestFill.py rename to test/general/TestFill.py index 38447b62..573a224f 100644 --- a/test/base/TestFill.py +++ b/test/general/TestFill.py @@ -1,4 +1,4 @@ -from typing import NamedTuple +from typing import NamedTuple, List import unittest from worlds.AutoWorld import World from Fill import FillError, fill_restrictive @@ -28,8 +28,8 @@ def generate_multi_world(players: int = 1) -> MultiWorld: class PlayerDefinition(NamedTuple): id: int menu: Region - locations: list[Location] - prog_items: list[Item] + locations: List[Location] + prog_items: List[Item] def generate_player_data(multi_world: MultiWorld, player_id: int, location_count: int, prog_item_count: int) -> PlayerDefinition: @@ -40,7 +40,7 @@ def generate_player_data(multi_world: MultiWorld, player_id: int, location_count return PlayerDefinition(player_id, menu, locations, prog_items) -def generate_locations(count: int, player_id: int, address: int = None, region: Region = None) -> list[Location]: +def generate_locations(count: int, player_id: int, address: int = None, region: Region = None) -> List[Location]: locations = [] for i in range(count): name = "player" + str(player_id) + "_location" + str(i) @@ -50,7 +50,7 @@ def generate_locations(count: int, player_id: int, address: int = None, region: return locations -def generate_items(count: int, player_id: int, advancement: bool = False, code: int = None) -> list[Location]: +def generate_items(count: int, player_id: int, advancement: bool = False, code: int = None) -> List[Item]: items = [] for i in range(count): name = "player" + str(player_id) + "_item" + str(i) diff --git a/test/minor_glitches/TestMinor.py b/test/minor_glitches/TestMinor.py index ac277205..db77ee91 100644 --- a/test/minor_glitches/TestMinor.py +++ b/test/minor_glitches/TestMinor.py @@ -4,15 +4,15 @@ from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_entrances from worlds.alttp.InvertedRegions import mark_dark_world_regions -from worlds.alttp.ItemPool import difficulties, generate_itempool +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import create_regions from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase from worlds import AutoWorld + class TestMinor(TestBase): def setUp(self): self.world = MultiWorld(1) @@ -30,8 +30,10 @@ class TestMinor(TestBase): self.world.worlds[1].create_items() self.world.required_medallions[1] = ['Ether', 'Quake'] self.world.itempool.extend(get_dungeon_item_pool(self.world)) - self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1)) + self.world.itempool.extend(ItemFactory( + ['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', + 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1)) self.world.get_location('Agahnim 1', 1).item = None self.world.get_location('Agahnim 2', 1).item = None mark_dark_world_regions(self.world, 1) - self.world.worlds[1].set_rules() \ No newline at end of file + self.world.worlds[1].set_rules() diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index 4b0814d3..cca78999 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -3,7 +3,7 @@ from worlds.alttp.Bosses import BossFactory from Fill import fill_restrictive from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import lookup_boss_drops -from worlds.alttp.Options import smallkey_shuffle +from worlds.alttp.Options import smallkey_shuffle def create_dungeons(world, player): diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index 962f1297..772026d7 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -95,7 +95,7 @@ class ShopPriceModifier(Range): """Percentage modifier for shuffled item prices in shops""" range_start = 0 default = 100 - range_end = 10000 + range_end = 400 class WorldState(Choice): option_standard = 1 diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index a5d35b1b..29401117 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -264,7 +264,7 @@ def ShopSlotFill(world): price = world.random.randrange(8, 56) shop.push_inventory(location.shop_slot, item_name, - min(int(price * 5 * world.shop_price_modifier[location.player] / 100), 9999), 1, + min(int(price * world.shop_price_modifier[location.player] / 100) * 5, 9999), 1, location.item.player if location.item.player != location.player else 0) if 'P' in world.shop_shuffle[location.player]: price_to_funny_price(shop.inventory[location.shop_slot], world, location.player) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 62cf4dec..9cb46ff9 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -336,7 +336,8 @@ class ALTTPWorld(World): standard_keyshuffle_players = set() for player in world.get_game_players("A Link to the Past"): if world.mode[player] == 'standard' and world.smallkey_shuffle[player] \ - and world.smallkey_shuffle[player] != smallkey_shuffle.option_universal: + and world.smallkey_shuffle[player] != smallkey_shuffle.option_universal and \ + world.smallkey_shuffle[player] != smallkey_shuffle.option_own_dungeons: standard_keyshuffle_players.add(player) if not world.ganonstower_vanilla[player] or \ world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}: @@ -350,23 +351,31 @@ class ALTTPWorld(World): # Make sure the escape small key is placed first in standard with key shuffle to prevent running out of spots # TODO: this might be worthwhile to introduce as generic option for various games and then optimize it if standard_keyshuffle_players: - viable = [] + viable = {} for location in world.get_locations(): if location.player in standard_keyshuffle_players \ and location.item is None \ and location.can_reach(world.state): - viable.append(location) - world.random.shuffle(viable) + viable.setdefault(location.player, []).append(location) + for player in standard_keyshuffle_players: + loc = world.random.choice(viable[player]) key = world.create_item("Small Key (Hyrule Castle)", player) - loc = viable.pop() loc.place_locked_item(key) fill_locations.remove(loc) world.random.shuffle(fill_locations) # TODO: investigate not creating the key in the first place - progitempool[:] = [item for item in progitempool if - item.player not in standard_keyshuffle_players or - item.name != "Small Key (Hyrule Castle)"] + if __debug__: + # keeping this here while I'm not sure we caught all instances of multiple HC small keys in the pool + count = len(progitempool) + progitempool[:] = [item for item in progitempool if + item.player not in standard_keyshuffle_players or + item.name != "Small Key (Hyrule Castle)"] + assert len(progitempool) + len(standard_keyshuffle_players) == count + else: + progitempool[:] = [item for item in progitempool if + item.player not in standard_keyshuffle_players or + item.name != "Small Key (Hyrule Castle)"] if trash_counts: locations_mapping = {player: [] for player in trash_counts} diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index 046ee6ea..1826ba93 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -68,16 +68,12 @@ class HKWorld(World): self.world.itempool += pool - def set_rules(self): set_rules(self.world, self.player) def create_regions(self): create_regions(self.world, self.player) - def generate_output(self): - pass # Hollow Knight needs no output files - def fill_slot_data(self): slot_data = {} for option_name in self.options: diff --git a/worlds/oot/Rules.py b/worlds/oot/Rules.py index ec44459b..7ef75640 100644 --- a/worlds/oot/Rules.py +++ b/worlds/oot/Rules.py @@ -140,6 +140,11 @@ def set_rules(ootworld): location = world.get_location('Sheik in Ice Cavern', player) add_item_rule(location, lambda item: item.player == player and item.type == 'Song') + if ootworld.skip_child_zelda: + # If skip child zelda is on, the item at Song from Impa must be giveable by the save context. + location = world.get_location('Song from Impa', player) + add_item_rule(location, lambda item: item in SaveContext.giveable_items) + for name in ootworld.always_hints: add_rule(world.get_location(name, player), guarantee_hint) diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index ee936a44..20139d03 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -24,6 +24,7 @@ from .N64Patch import create_patch_file from .Cosmetics import patch_cosmetics from .Hints import hint_dist_keys, get_hint_area, buildWorldGossipHints from .HintList import getRequiredHints +from .SaveContext import SaveContext from Utils import get_options, output_path from BaseClasses import MultiWorld, CollectionState, RegionType @@ -471,13 +472,16 @@ class OOTWorld(World): self.remove_from_start_inventory.remove(item.name) removed_items.append(item.name) else: - self.starting_items[item.name] += 1 - if item.type == 'Song': - self.starting_songs = True - # Call the junk fill and get a replacement - if item in self.itempool: - self.itempool.remove(item) - self.itempool.append(self.create_item(*get_junk_item(pool=junk_pool))) + if item.name not in SaveContext.giveable_items: + raise Exception(f"Invalid OoT starting item: {item.name}") + else: + self.starting_items[item.name] += 1 + if item.type == 'Song': + self.starting_songs = True + # Call the junk fill and get a replacement + if item in self.itempool: + self.itempool.remove(item) + self.itempool.append(self.create_item(*get_junk_item(pool=junk_pool))) if self.start_with_consumables: self.starting_items['Deku Sticks'] = 30 self.starting_items['Deku Nuts'] = 40 @@ -718,7 +722,6 @@ class OOTWorld(World): impa = self.world.get_location("Song from Impa", self.player) if self.skip_child_zelda: if impa.item is None: - from .SaveContext import SaveContext item_to_place = self.world.random.choice(list(item for item in self.world.itempool if item.player == self.player and item.name in SaveContext.giveable_items)) impa.place_locked_item(item_to_place) diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 6abcbfbe..fe8a4870 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -44,6 +44,7 @@ class SMWorld(World): itemManager: ItemManager locations = {} + hint_blacklist = {'Nothing', 'NoEnergy'} Logic.factory('vanilla') @@ -85,6 +86,7 @@ class SMWorld(World): # keeps Nothing items local so no player will ever pickup Nothing # doing so reduces contribution of this world to the Multiworld the more Nothing there is though self.world.local_items[self.player].value.add('Nothing') + self.world.local_items[self.player].value.add('NoEnergy') if (self.variaRando.args.morphPlacement == "early"): self.world.local_items[self.player].value.add('Morph') @@ -126,7 +128,7 @@ class SMWorld(World): weaponCount[2] += 1 else: isAdvancement = False - elif item.Type == 'Nothing': + elif item.Category == 'Nothing': isAdvancement = False itemClass = ItemManager.Items[item.Type].Class diff --git a/worlds/sm/variaRandomizer/randomizer.py b/worlds/sm/variaRandomizer/randomizer.py index a95764df..0da2b2d0 100644 --- a/worlds/sm/variaRandomizer/randomizer.py +++ b/worlds/sm/variaRandomizer/randomizer.py @@ -16,6 +16,7 @@ from utils.doorsmanager import DoorsManager from logic.logic import Logic import utils.log +from worlds.sm.Options import StartLocation # we need to know the logic before doing anything else def getLogic(): @@ -498,10 +499,12 @@ class VariaRandomizer: sys.exit(-1) args.startLocation = random.choice(possibleStartAPs) elif args.startLocation not in possibleStartAPs: - optErrMsgs.append('Invalid start location: {}. {}'.format(args.startLocation, reasons[args.startLocation])) - optErrMsgs.append('Possible start locations with these settings: {}'.format(possibleStartAPs)) - dumpErrorMsgs(args.output, optErrMsgs) - sys.exit(-1) + args.startLocation = 'Landing Site' + world.start_location[player] = StartLocation(StartLocation.default) + #optErrMsgs.append('Invalid start location: {}. {}'.format(args.startLocation, reasons[args.startLocation])) + #optErrMsgs.append('Possible start locations with these settings: {}'.format(possibleStartAPs)) + #dumpErrorMsgs(args.output, optErrMsgs) + #sys.exit(-1) ap = getAccessPoint(args.startLocation) if 'forcedEarlyMorph' in ap.Start and ap.Start['forcedEarlyMorph'] == True: forceArg('morphPlacement', 'early', "'Morph Placement' forced to early for custom start location")