WebHost, Core: Developer-defined game option presets. (#2143)
This commit is contained in:
		
							parent
							
								
									3619abc7ca
								
							
						
					
					
						commit
						79ad54623b
					
				| 
						 | 
				
			
			@ -3,11 +3,8 @@ import logging
 | 
			
		|||
import os
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
import yaml
 | 
			
		||||
from jinja2 import Template
 | 
			
		||||
 | 
			
		||||
import Options
 | 
			
		||||
from Utils import __version__, local_path
 | 
			
		||||
from Utils import local_path
 | 
			
		||||
from worlds.AutoWorld import AutoWorldRegister
 | 
			
		||||
 | 
			
		||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +25,7 @@ def create():
 | 
			
		|||
    weighted_options = {
 | 
			
		||||
        "baseOptions": {
 | 
			
		||||
            "description": "Generated by https://archipelago.gg/",
 | 
			
		||||
            "name": "Player",
 | 
			
		||||
            "name": "",
 | 
			
		||||
            "game": {},
 | 
			
		||||
        },
 | 
			
		||||
        "games": {},
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +40,7 @@ def create():
 | 
			
		|||
            "baseOptions": {
 | 
			
		||||
                "description": f"Generated by https://archipelago.gg/ for {game_name}",
 | 
			
		||||
                "game": game_name,
 | 
			
		||||
                "name": "Player",
 | 
			
		||||
                "name": "",
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -117,10 +114,46 @@ def create():
 | 
			
		|||
                    }
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                logging.debug(f"{option} not exported to Web options.")
 | 
			
		||||
                logging.debug(f"{option} not exported to Web Options.")
 | 
			
		||||
 | 
			
		||||
        player_options["gameOptions"] = game_options
 | 
			
		||||
 | 
			
		||||
        player_options["presetOptions"] = {}
 | 
			
		||||
        for preset_name, preset in world.web.options_presets.items():
 | 
			
		||||
            player_options["presetOptions"][preset_name] = {}
 | 
			
		||||
            for option_name, option_value in preset.items():
 | 
			
		||||
                # Random range type settings are not valid.
 | 
			
		||||
                assert (not str(option_value).startswith("random-")), \
 | 
			
		||||
                    f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
 | 
			
		||||
                    f"values are not supported for presets."
 | 
			
		||||
 | 
			
		||||
                # Normal random is supported, but needs to be handled explicitly.
 | 
			
		||||
                if option_value == "random":
 | 
			
		||||
                    player_options["presetOptions"][preset_name][option_name] = option_value
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                option = world.options_dataclass.type_hints[option_name].from_any(option_value)
 | 
			
		||||
                if isinstance(option, Options.SpecialRange) and isinstance(option_value, str):
 | 
			
		||||
                    assert option_value in option.special_range_names, \
 | 
			
		||||
                        f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
 | 
			
		||||
                        f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
 | 
			
		||||
 | 
			
		||||
                    # Still use the true value for the option, not the name.
 | 
			
		||||
                    player_options["presetOptions"][preset_name][option_name] = option.value
 | 
			
		||||
                elif isinstance(option, Options.Range):
 | 
			
		||||
                    player_options["presetOptions"][preset_name][option_name] = option.value
 | 
			
		||||
                elif isinstance(option_value, str):
 | 
			
		||||
                    # For Choice and Toggle options, the value should be the name of the option. This is to prevent
 | 
			
		||||
                    # setting a preset for an option with an overridden from_text method that would normally be okay,
 | 
			
		||||
                    # but would not be okay for the webhost's current implementation of player options UI.
 | 
			
		||||
                    assert option.name_lookup[option.value] == option_value, \
 | 
			
		||||
                        f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
 | 
			
		||||
                        f"Values must not be resolved to a different option via option.from_text (or an alias)."
 | 
			
		||||
                    player_options["presetOptions"][preset_name][option_name] = option.current_key
 | 
			
		||||
                else:
 | 
			
		||||
                    # int and bool values are fine, just resolve them to the current key for webhost.
 | 
			
		||||
                    player_options["presetOptions"][preset_name][option_name] = option.current_key
 | 
			
		||||
 | 
			
		||||
        os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
 | 
			
		||||
 | 
			
		||||
        with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,8 +16,9 @@ window.addEventListener('load', () => {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    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.");
 | 
			
		||||
      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', resetOptions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +37,17 @@ window.addEventListener('load', () => {
 | 
			
		|||
    const nameInput = document.getElementById('player-name');
 | 
			
		||||
    nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
 | 
			
		||||
    nameInput.value = playerOptions.name;
 | 
			
		||||
 | 
			
		||||
    // Presets
 | 
			
		||||
    const presetSelect = document.getElementById('game-options-preset');
 | 
			
		||||
    presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
 | 
			
		||||
    for (const preset in results['presetOptions']) {
 | 
			
		||||
      const presetOption = document.createElement('option');
 | 
			
		||||
      presetOption.innerText = preset;
 | 
			
		||||
      presetSelect.appendChild(presetOption);
 | 
			
		||||
    }
 | 
			
		||||
    presetSelect.value = localStorage.getItem(`${gameName}-preset`);
 | 
			
		||||
    results['presetOptions']['__default'] = {};
 | 
			
		||||
  }).catch((e) => {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
    const url = new URL(window.location.href);
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +57,8 @@ window.addEventListener('load', () => {
 | 
			
		|||
 | 
			
		||||
const resetOptions = () => {
 | 
			
		||||
  localStorage.removeItem(gameName);
 | 
			
		||||
  localStorage.removeItem(`${gameName}-hash`)
 | 
			
		||||
  localStorage.removeItem(`${gameName}-hash`);
 | 
			
		||||
  localStorage.removeItem(`${gameName}-preset`);
 | 
			
		||||
  window.location.reload();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -77,6 +90,10 @@ const createDefaultOptions = (optionData) => {
 | 
			
		|||
    }
 | 
			
		||||
    localStorage.setItem(gameName, JSON.stringify(newOptions));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!localStorage.getItem(`${gameName}-preset`)) {
 | 
			
		||||
    localStorage.setItem(`${gameName}-preset`, '__default');
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const buildUI = (optionData) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -84,8 +101,11 @@ const buildUI = (optionData) => {
 | 
			
		|||
  const leftGameOpts = {};
 | 
			
		||||
  const rightGameOpts = {};
 | 
			
		||||
  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]; }
 | 
			
		||||
    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));
 | 
			
		||||
| 
						 | 
				
			
			@ -129,16 +149,17 @@ const buildOptionsTable = (options, romOpts = false) => {
 | 
			
		|||
        select.setAttribute('data-key', option);
 | 
			
		||||
        if (romOpts) { select.setAttribute('data-romOpt', '1'); }
 | 
			
		||||
        options[option].options.forEach((opt) => {
 | 
			
		||||
          const option = document.createElement('option');
 | 
			
		||||
          option.setAttribute('value', opt.value);
 | 
			
		||||
          option.innerText = opt.name;
 | 
			
		||||
          const optionElement = document.createElement('option');
 | 
			
		||||
          optionElement.setAttribute('value', opt.value);
 | 
			
		||||
          optionElement.innerText = opt.name;
 | 
			
		||||
 | 
			
		||||
          if ((isNaN(currentOptions[gameName][option]) &&
 | 
			
		||||
            (parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
 | 
			
		||||
            (opt.value === currentOptions[gameName][option]))
 | 
			
		||||
          {
 | 
			
		||||
            option.selected = true;
 | 
			
		||||
            optionElement.selected = true;
 | 
			
		||||
          }
 | 
			
		||||
          select.appendChild(option);
 | 
			
		||||
          select.appendChild(optionElement);
 | 
			
		||||
        });
 | 
			
		||||
        select.addEventListener('change', (event) => updateGameOption(event.target));
 | 
			
		||||
        element.appendChild(select);
 | 
			
		||||
| 
						 | 
				
			
			@ -162,6 +183,7 @@ const buildOptionsTable = (options, romOpts = false) => {
 | 
			
		|||
        element.classList.add('range-container');
 | 
			
		||||
 | 
			
		||||
        let range = document.createElement('input');
 | 
			
		||||
        range.setAttribute('id', option);
 | 
			
		||||
        range.setAttribute('type', 'range');
 | 
			
		||||
        range.setAttribute('data-key', option);
 | 
			
		||||
        range.setAttribute('min', options[option].min);
 | 
			
		||||
| 
						 | 
				
			
			@ -205,11 +227,11 @@ const buildOptionsTable = (options, romOpts = false) => {
 | 
			
		|||
          let presetOption = document.createElement('option');
 | 
			
		||||
          presetOption.innerText = presetName;
 | 
			
		||||
          presetOption.value = options[option].value_names[presetName];
 | 
			
		||||
          const words = presetOption.innerText.split("_");
 | 
			
		||||
          const words = presetOption.innerText.split('_');
 | 
			
		||||
          for (let i = 0; i < words.length; i++) {
 | 
			
		||||
            words[i] = words[i][0].toUpperCase() + words[i].substring(1);
 | 
			
		||||
          }
 | 
			
		||||
          presetOption.innerText = words.join(" ");
 | 
			
		||||
          presetOption.innerText = words.join(' ');
 | 
			
		||||
          specialRangeSelect.appendChild(presetOption);
 | 
			
		||||
        });
 | 
			
		||||
        let customOption = document.createElement('option');
 | 
			
		||||
| 
						 | 
				
			
			@ -294,6 +316,90 @@ const buildOptionsTable = (options, romOpts = false) => {
 | 
			
		|||
  return table;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const setPresets = (optionsData, presetName) => {
 | 
			
		||||
  const defaults = optionsData['gameOptions'];
 | 
			
		||||
  const preset = optionsData['presetOptions'][presetName];
 | 
			
		||||
 | 
			
		||||
  localStorage.setItem(`${gameName}-preset`, presetName);
 | 
			
		||||
 | 
			
		||||
  if (!preset) {
 | 
			
		||||
    console.error(`No presets defined for preset name: '${presetName}'`);
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const updateOptionElement = (option, presetValue) => {
 | 
			
		||||
    const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
 | 
			
		||||
    const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
 | 
			
		||||
 | 
			
		||||
    if (presetValue === 'random') {
 | 
			
		||||
      randomElement.classList.add('active');
 | 
			
		||||
      optionElement.disabled = true;
 | 
			
		||||
      updateGameOption(randomElement, false);
 | 
			
		||||
    } else {
 | 
			
		||||
      optionElement.value = presetValue;
 | 
			
		||||
      randomElement.classList.remove('active');
 | 
			
		||||
      optionElement.disabled = undefined;
 | 
			
		||||
      updateGameOption(optionElement, false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  for (const option in defaults) {
 | 
			
		||||
    let presetValue = preset[option];
 | 
			
		||||
    if (presetValue === undefined) {
 | 
			
		||||
      // Using the default value if not set in presets.
 | 
			
		||||
      presetValue = defaults[option]['defaultValue'];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    switch (defaults[option].type) {
 | 
			
		||||
      case 'range':
 | 
			
		||||
        const numberElement = document.querySelector(`#${option}-value`);
 | 
			
		||||
        if (presetValue === 'random') {
 | 
			
		||||
          numberElement.innerText = defaults[option]['defaultValue'] === 'random'
 | 
			
		||||
              ? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
 | 
			
		||||
              : defaults[option]['defaultValue'];
 | 
			
		||||
        } else {
 | 
			
		||||
          numberElement.innerText = presetValue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        updateOptionElement(option, presetValue);
 | 
			
		||||
        break;
 | 
			
		||||
 | 
			
		||||
      case 'select': {
 | 
			
		||||
        updateOptionElement(option, presetValue);
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      case 'special_range': {
 | 
			
		||||
        const selectElement = document.querySelector(`select[data-key='${option}']`);
 | 
			
		||||
        const rangeElement = document.querySelector(`input[data-key='${option}']`);
 | 
			
		||||
        const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
 | 
			
		||||
 | 
			
		||||
        if (presetValue === 'random') {
 | 
			
		||||
          randomElement.classList.add('active');
 | 
			
		||||
          selectElement.disabled = true;
 | 
			
		||||
          rangeElement.disabled = true;
 | 
			
		||||
          updateGameOption(randomElement, false);
 | 
			
		||||
        } else {
 | 
			
		||||
          rangeElement.value = presetValue;
 | 
			
		||||
          selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
 | 
			
		||||
              parseInt(presetValue) : 'custom';
 | 
			
		||||
          document.getElementById(`${option}-value`).innerText = presetValue;
 | 
			
		||||
 | 
			
		||||
          randomElement.classList.remove('active');
 | 
			
		||||
          selectElement.disabled = undefined;
 | 
			
		||||
          rangeElement.disabled = undefined;
 | 
			
		||||
          updateGameOption(rangeElement, false);
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      default:
 | 
			
		||||
        console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
 | 
			
		||||
  const active = event.target.classList.contains('active');
 | 
			
		||||
  const randomButton = event.target;
 | 
			
		||||
| 
						 | 
				
			
			@ -321,8 +427,15 @@ const updateBaseOption = (event) => {
 | 
			
		|||
  localStorage.setItem(gameName, JSON.stringify(options));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateGameOption = (optionElement) => {
 | 
			
		||||
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
 | 
			
		||||
  const options = JSON.parse(localStorage.getItem(gameName));
 | 
			
		||||
 | 
			
		||||
  if (toggleCustomPreset) {
 | 
			
		||||
    localStorage.setItem(`${gameName}-preset`, '__custom');
 | 
			
		||||
    const presetElement = document.getElementById('game-options-preset');
 | 
			
		||||
    presetElement.value = '__custom';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (optionElement.classList.contains('randomize-button')) {
 | 
			
		||||
    // If the event passed in is the randomize button, then we know what we must do.
 | 
			
		||||
    options[gameName][optionElement.getAttribute('data-key')] = 'random';
 | 
			
		||||
| 
						 | 
				
			
			@ -336,7 +449,21 @@ const updateGameOption = (optionElement) => {
 | 
			
		|||
 | 
			
		||||
const exportOptions = () => {
 | 
			
		||||
  const options = JSON.parse(localStorage.getItem(gameName));
 | 
			
		||||
  if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
 | 
			
		||||
  const preset = localStorage.getItem(`${gameName}-preset`);
 | 
			
		||||
  switch (preset) {
 | 
			
		||||
    case '__default':
 | 
			
		||||
      options['description'] = `Generated by https://archipelago.gg with the default preset.`;
 | 
			
		||||
      break;
 | 
			
		||||
 | 
			
		||||
    case '__custom':
 | 
			
		||||
      options['description'] = `Generated by https://archipelago.gg.`;
 | 
			
		||||
      break;
 | 
			
		||||
 | 
			
		||||
    default:
 | 
			
		||||
      options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!options.name || options.name.trim().length === 0) {
 | 
			
		||||
    return showUserMessage('You must enter a player name!');
 | 
			
		||||
  }
 | 
			
		||||
  const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -90,6 +90,31 @@ html{
 | 
			
		|||
    flex-direction: row;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#player-options #meta-options {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
    gap: 20px;
 | 
			
		||||
    padding: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#player-options div {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#player-options #meta-options label {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    min-width: 180px;
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#player-options #meta-options input,
 | 
			
		||||
#player-options #meta-options select {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    min-width: 150px;
 | 
			
		||||
    width: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#player-options .left, #player-options .right{
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -188,6 +213,12 @@ html{
 | 
			
		|||
        border-radius: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #player-options #meta-options {
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: flex-start;
 | 
			
		||||
        gap: 6px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #player-options #game-options{
 | 
			
		||||
        justify-content: flex-start;
 | 
			
		||||
        flex-wrap: wrap;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,10 +28,24 @@
 | 
			
		|||
            <a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
 | 
			
		||||
        </p>
 | 
			
		||||
 | 
			
		||||
        <p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
 | 
			
		||||
            items if you are playing in a MultiWorld.</label><br />
 | 
			
		||||
            <input id="player-name" placeholder="Player Name" data-key="name" maxlength="16" />
 | 
			
		||||
        </p>
 | 
			
		||||
        <div id="meta-options">
 | 
			
		||||
            <div>
 | 
			
		||||
                <label for="player-name">
 | 
			
		||||
                    Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
 | 
			
		||||
                </label>
 | 
			
		||||
                <input id="player-name" placeholder="Player" data-key="name" maxlength="16" />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div>
 | 
			
		||||
                <label for="game-options-preset">
 | 
			
		||||
                    Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
 | 
			
		||||
                </label>
 | 
			
		||||
                <select id="game-options-preset">
 | 
			
		||||
                    <option value="__default">Defaults</option>
 | 
			
		||||
                    <option value="__custom" hidden>Custom</option>
 | 
			
		||||
                </select>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <h2>Game Options</h2>
 | 
			
		||||
        <div id="game-options">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -73,6 +73,53 @@ for your world specifically on the webhost:
 | 
			
		|||
`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be
 | 
			
		||||
prefixed with the same string as defined here. Default already has 'en'.
 | 
			
		||||
 | 
			
		||||
`options_presets` (optional) A `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values 
 | 
			
		||||
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names of
 | 
			
		||||
the options and the values are the values to be set for that option. These presets will be available for users to select from on the game's options page.
 | 
			
		||||
 | 
			
		||||
Note: The values must be a non-aliased value for the option type and can only include the following option types:
 | 
			
		||||
 | 
			
		||||
  - If you have a `Range`/`SpecialRange` option, the value should be an `int` between the `range_start` and `range_end`
 | 
			
		||||
    values.
 | 
			
		||||
    - If you have a `SpecialRange` option, the value can alternatively be a `str` that is one of the 
 | 
			
		||||
      `special_range_names` keys.
 | 
			
		||||
  - If you have a `Choice` option, the value should be a `str` that is one of the `option_<name>` values. 
 | 
			
		||||
  - If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`.
 | 
			
		||||
  - `random` is also a valid value for any of these option types.
 | 
			
		||||
 | 
			
		||||
`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on the webhost at this time.
 | 
			
		||||
 | 
			
		||||
Here is an example of a defined preset:
 | 
			
		||||
```python
 | 
			
		||||
# presets.py
 | 
			
		||||
options_presets = {
 | 
			
		||||
    "Limited Potential": {
 | 
			
		||||
        "progression_balancing":    0,
 | 
			
		||||
        "fairy_chests_per_zone":    2,
 | 
			
		||||
        "starting_class":           "random",
 | 
			
		||||
        "chests_per_zone":          30,
 | 
			
		||||
        "vendors":                  "normal",
 | 
			
		||||
        "architect":                "disabled",
 | 
			
		||||
        "gold_gain_multiplier":     "half",
 | 
			
		||||
        "number_of_children":       2,
 | 
			
		||||
        "free_diary_on_generation": False,
 | 
			
		||||
        "health_pool":              10,
 | 
			
		||||
        "mana_pool":                10,
 | 
			
		||||
        "attack_pool":              10,
 | 
			
		||||
        "magic_damage_pool":        10,
 | 
			
		||||
        "armor_pool":               5,
 | 
			
		||||
        "equip_pool":               10,
 | 
			
		||||
        "crit_chance_pool":         5,
 | 
			
		||||
        "crit_damage_pool":         5,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# __init__.py
 | 
			
		||||
class RLWeb(WebWorld):
 | 
			
		||||
    options_presets = options_presets
 | 
			
		||||
    # ...
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### MultiWorld Object
 | 
			
		||||
 | 
			
		||||
The `MultiWorld` object references the whole multiworld (all items and locations
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,63 @@
 | 
			
		|||
import unittest
 | 
			
		||||
 | 
			
		||||
from worlds import AutoWorldRegister
 | 
			
		||||
from Options import Choice, SpecialRange, Toggle, Range
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestOptionPresets(unittest.TestCase):
 | 
			
		||||
    def test_option_presets_have_valid_options(self):
 | 
			
		||||
        """Test that all predefined option presets are valid options."""
 | 
			
		||||
        for game_name, world_type in AutoWorldRegister.world_types.items():
 | 
			
		||||
            presets = world_type.web.options_presets
 | 
			
		||||
            for preset_name, preset in presets.items():
 | 
			
		||||
                for option_name, option_value in preset.items():
 | 
			
		||||
                    with self.subTest(game=game_name, preset=preset_name, option=option_name):
 | 
			
		||||
                        try:
 | 
			
		||||
                            option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
 | 
			
		||||
                            supported_types = [Choice, Toggle, Range, SpecialRange]
 | 
			
		||||
                            if not any([issubclass(option.__class__, t) for t in supported_types]):
 | 
			
		||||
                                self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
 | 
			
		||||
                                          f"is not a supported type for webhost. "
 | 
			
		||||
                                          f"Supported types: {', '.join([t.__name__ for t in supported_types])}")
 | 
			
		||||
                        except AssertionError as ex:
 | 
			
		||||
                            self.fail(f"Option '{option_name}': '{option_value}' in preset '{preset_name}' for game "
 | 
			
		||||
                                      f"'{game_name}' is not valid. Error: {ex}")
 | 
			
		||||
                        except KeyError as ex:
 | 
			
		||||
                            self.fail(f"Option '{option_name}' in preset '{preset_name}' for game '{game_name}' is "
 | 
			
		||||
                                      f"not a defined option. Error: {ex}")
 | 
			
		||||
 | 
			
		||||
    def test_option_preset_values_are_explicitly_defined(self):
 | 
			
		||||
        """Test that option preset values are not a special flavor of 'random' or use from_text to resolve another
 | 
			
		||||
        value.
 | 
			
		||||
        """
 | 
			
		||||
        for game_name, world_type in AutoWorldRegister.world_types.items():
 | 
			
		||||
            presets = world_type.web.options_presets
 | 
			
		||||
            for preset_name, preset in presets.items():
 | 
			
		||||
                for option_name, option_value in preset.items():
 | 
			
		||||
                    with self.subTest(game=game_name, preset=preset_name, option=option_name):
 | 
			
		||||
                        # Check for non-standard random values.
 | 
			
		||||
                        self.assertFalse(
 | 
			
		||||
                            str(option_value).startswith("random-"),
 | 
			
		||||
                            f"'{option_name}': '{option_value}' in preset '{preset_name}' for game '{game_name}' "
 | 
			
		||||
                            f"is not supported for webhost. Special random values are not supported for presets."
 | 
			
		||||
                        )
 | 
			
		||||
 | 
			
		||||
                        option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
 | 
			
		||||
 | 
			
		||||
                        # Check for from_text resolving to a different value. ("random" is allowed though.)
 | 
			
		||||
                        if option_value != "random" and isinstance(option_value, str):
 | 
			
		||||
                            # Allow special named values for SpecialRange option presets.
 | 
			
		||||
                            if isinstance(option, SpecialRange):
 | 
			
		||||
                                self.assertTrue(
 | 
			
		||||
                                    option_value in option.special_range_names,
 | 
			
		||||
                                    f"Invalid preset '{option_name}': '{option_value}' in preset '{preset_name}' "
 | 
			
		||||
                                    f"for game '{game_name}'. Expected {option.special_range_names.keys()} or "
 | 
			
		||||
                                    f"{option.range_start}-{option.range_end}."
 | 
			
		||||
                                )
 | 
			
		||||
                            else:
 | 
			
		||||
                                self.assertTrue(
 | 
			
		||||
                                    option.name_lookup.get(option.value, None) == option_value,
 | 
			
		||||
                                    f"'{option_name}': '{option_value}' in preset '{preset_name}' for game "
 | 
			
		||||
                                    f"'{game_name}' is not supported for webhost. Values must not be resolved to a "
 | 
			
		||||
                                    f"different option via option.from_text (or an alias)."
 | 
			
		||||
                                )
 | 
			
		||||
| 
						 | 
				
			
			@ -186,6 +186,9 @@ class WebWorld:
 | 
			
		|||
    bug_report_page: Optional[str]
 | 
			
		||||
    """display a link to a bug report page, most likely a link to a GitHub issue page."""
 | 
			
		||||
 | 
			
		||||
    options_presets: Dict[str, Dict[str, Any]] = {}
 | 
			
		||||
    """A dictionary containing a collection of developer-defined game option presets."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class World(metaclass=AutoWorldRegister):
 | 
			
		||||
    """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
from typing import Any, Dict
 | 
			
		||||
 | 
			
		||||
from .Options import Architect, GoldGainMultiplier, Vendors
 | 
			
		||||
 | 
			
		||||
rl_options_presets: Dict[str, Dict[str, Any]] = {
 | 
			
		||||
    # Example preset using only literal values.
 | 
			
		||||
    "Unknown Fate": {
 | 
			
		||||
        "progression_balancing":    "random",
 | 
			
		||||
        "accessibility":            "random",
 | 
			
		||||
        "starting_gender":          "random",
 | 
			
		||||
        "starting_class":           "random",
 | 
			
		||||
        "new_game_plus":            "random",
 | 
			
		||||
        "fairy_chests_per_zone":    "random",
 | 
			
		||||
        "chests_per_zone":          "random",
 | 
			
		||||
        "universal_fairy_chests":   "random",
 | 
			
		||||
        "universal_chests":         "random",
 | 
			
		||||
        "vendors":                  "random",
 | 
			
		||||
        "architect":                "random",
 | 
			
		||||
        "architect_fee":            "random",
 | 
			
		||||
        "disable_charon":           "random",
 | 
			
		||||
        "require_purchasing":       "random",
 | 
			
		||||
        "progressive_blueprints":   "random",
 | 
			
		||||
        "gold_gain_multiplier":     "random",
 | 
			
		||||
        "number_of_children":       "random",
 | 
			
		||||
        "free_diary_on_generation": "random",
 | 
			
		||||
        "khidr":                    "random",
 | 
			
		||||
        "alexander":                "random",
 | 
			
		||||
        "leon":                     "random",
 | 
			
		||||
        "herodotus":                "random",
 | 
			
		||||
        "health_pool":              "random",
 | 
			
		||||
        "mana_pool":                "random",
 | 
			
		||||
        "attack_pool":              "random",
 | 
			
		||||
        "magic_damage_pool":        "random",
 | 
			
		||||
        "armor_pool":               "random",
 | 
			
		||||
        "equip_pool":               "random",
 | 
			
		||||
        "crit_chance_pool":         "random",
 | 
			
		||||
        "crit_damage_pool":         "random",
 | 
			
		||||
        "allow_default_names":      False,
 | 
			
		||||
        "death_link":               "random",
 | 
			
		||||
    },
 | 
			
		||||
    # A preset I actually use, using some literal values and some from the option itself.
 | 
			
		||||
    "Limited Potential": {
 | 
			
		||||
        "progression_balancing":    "disabled",
 | 
			
		||||
        "fairy_chests_per_zone":    2,
 | 
			
		||||
        "starting_class":           "random",
 | 
			
		||||
        "chests_per_zone":          30,
 | 
			
		||||
        "vendors":                  Vendors.option_normal,
 | 
			
		||||
        "architect":                Architect.option_disabled,
 | 
			
		||||
        "gold_gain_multiplier":     GoldGainMultiplier.option_half,
 | 
			
		||||
        "number_of_children":       2,
 | 
			
		||||
        "free_diary_on_generation": False,
 | 
			
		||||
        "health_pool":              10,
 | 
			
		||||
        "mana_pool":                10,
 | 
			
		||||
        "attack_pool":              10,
 | 
			
		||||
        "magic_damage_pool":        10,
 | 
			
		||||
        "armor_pool":               5,
 | 
			
		||||
        "equip_pool":               10,
 | 
			
		||||
        "crit_chance_pool":         5,
 | 
			
		||||
        "crit_damage_pool":         5,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ from worlds.AutoWorld import WebWorld, World
 | 
			
		|||
from .Items import RLItem, RLItemData, event_item_table, get_items_by_category, item_table
 | 
			
		||||
from .Locations import RLLocation, location_table
 | 
			
		||||
from .Options import rl_options
 | 
			
		||||
from .Presets import rl_options_presets
 | 
			
		||||
from .Regions import create_regions
 | 
			
		||||
from .Rules import set_rules
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +23,7 @@ class RLWeb(WebWorld):
 | 
			
		|||
    )]
 | 
			
		||||
    bug_report_page = "https://github.com/ThePhar/RogueLegacyRandomizer/issues/new?assignees=&labels=bug&template=" \
 | 
			
		||||
                      "report-an-issue---.md&title=%5BIssue%5D"
 | 
			
		||||
    options_presets = rl_options_presets
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RLWorld(World):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue