mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 04:01:32 -06:00

If a NamedRange has a `special_range_names` entry outside the `range_start` and `range_end`, the HTML5 range input will clamp the submitted value to the closest value in the range. These means that, for example, Pokemon RB's "HM Compatibility" option's "Vanilla (-1)" option would instead get posted as "0" rather than "-1". This change updates NamedRange to behave like TextChoice, where the select element has a `name` attribute matching the option, and there is an additional element to be able to provide an option other than the select element's choices. This uses a different suffix of `-range` rather than `-custom` that TextChoice uses. The reason is we need some way to decide whether to use the custom value or the select value, and that method needs to work without JavaScript. For TextChoice this is easy, if the custom field is empty use the select element. For NamedRange this is more difficult as the browser will always submit *something*. My choice was to only use the value from the range if the select box is set to "custom". Since this only happens with JS as "custom' is hidden, I made the range hidden under no-JS. If it's preferred, I could make the select box hidden instead. Let me know. This PR also makes the `js-required` class set `display: none` with `!important` as otherwise the class wouldn't work on any rule that had `display: flex` with more specificity than a single class.
282 lines
9.8 KiB
Python
282 lines
9.8 KiB
Python
import collections.abc
|
|
import json
|
|
import os
|
|
from textwrap import dedent
|
|
from typing import Dict, Union
|
|
from docutils.core import publish_parts
|
|
|
|
import yaml
|
|
from flask import redirect, render_template, request, Response
|
|
|
|
import Options
|
|
from Utils import local_path
|
|
from worlds.AutoWorld import AutoWorldRegister
|
|
from . import app, cache
|
|
from .generate import get_meta
|
|
|
|
|
|
def create() -> None:
|
|
target_folder = local_path("WebHostLib", "static", "generated")
|
|
yaml_folder = os.path.join(target_folder, "configs")
|
|
|
|
Options.generate_yaml_templates(yaml_folder)
|
|
|
|
|
|
def get_world_theme(game_name: str) -> str:
|
|
if game_name in AutoWorldRegister.world_types:
|
|
return AutoWorldRegister.world_types[game_name].web.theme
|
|
return 'grass'
|
|
|
|
|
|
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
|
world = AutoWorldRegister.world_types[world_name]
|
|
if world.hidden or world.web.options_page is False:
|
|
return redirect("games")
|
|
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
|
|
|
|
start_collapsed = {"Game Options": False}
|
|
for group in world.web.option_groups:
|
|
start_collapsed[group.name] = group.start_collapsed
|
|
|
|
return render_template(
|
|
template,
|
|
world_name=world_name,
|
|
world=world,
|
|
option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
|
|
start_collapsed=start_collapsed,
|
|
issubclass=issubclass,
|
|
Options=Options,
|
|
theme=get_world_theme(world_name),
|
|
)
|
|
|
|
|
|
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
|
|
from .generate import start_generation
|
|
return start_generation(options, get_meta({}))
|
|
|
|
|
|
def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
|
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
|
response.headers["Content-Type"] = "text/yaml"
|
|
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
|
return response
|
|
|
|
|
|
@app.template_filter("dedent")
|
|
def filter_dedent(text: str) -> str:
|
|
return dedent(text).strip("\n ")
|
|
|
|
|
|
@app.template_filter("rst_to_html")
|
|
def filter_rst_to_html(text: str) -> str:
|
|
"""Converts reStructuredText (such as a Python docstring) to HTML."""
|
|
if text.startswith(" ") or text.startswith("\t"):
|
|
text = dedent(text)
|
|
elif "\n" in text:
|
|
lines = text.splitlines()
|
|
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
|
|
|
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
|
'raw_enable': False,
|
|
'file_insertion_enabled': False,
|
|
'output_encoding': 'unicode'
|
|
})['body']
|
|
|
|
|
|
@app.template_test("ordered")
|
|
def test_ordered(obj):
|
|
return isinstance(obj, collections.abc.Sequence)
|
|
|
|
|
|
@app.route("/games/<string:game>/option-presets", methods=["GET"])
|
|
@cache.cached()
|
|
def option_presets(game: str) -> Response:
|
|
world = AutoWorldRegister.world_types[game]
|
|
|
|
presets = {}
|
|
for preset_name, preset in world.web.options_presets.items():
|
|
presets[preset_name] = {}
|
|
for preset_option_name, preset_option in preset.items():
|
|
if preset_option == "random":
|
|
presets[preset_name][preset_option_name] = preset_option
|
|
continue
|
|
|
|
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
|
|
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
|
|
assert preset_option in option.special_range_names, \
|
|
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
|
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
|
|
|
presets[preset_name][preset_option_name] = option.value
|
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
|
presets[preset_name][preset_option_name] = option.value
|
|
elif isinstance(preset_option, str):
|
|
# Ensure the option value is valid for Choice and Toggle options
|
|
assert option.name_lookup[option.value] == preset_option, \
|
|
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
|
|
f"Values must not be resolved to a different option via option.from_text (or an alias)."
|
|
# Use the name of the option
|
|
presets[preset_name][preset_option_name] = option.current_key
|
|
else:
|
|
# Use the name of the option
|
|
presets[preset_name][preset_option_name] = option.current_key
|
|
|
|
class SetEncoder(json.JSONEncoder):
|
|
def default(self, obj):
|
|
from collections.abc import Set
|
|
if isinstance(obj, Set):
|
|
return list(obj)
|
|
return json.JSONEncoder.default(self, obj)
|
|
|
|
json_data = json.dumps(presets, cls=SetEncoder)
|
|
response = Response(json_data)
|
|
response.headers["Content-Type"] = "application/json"
|
|
return response
|
|
|
|
|
|
@app.route("/weighted-options")
|
|
def weighted_options_old():
|
|
return redirect("games", 301)
|
|
|
|
|
|
@app.route("/games/<string:game>/weighted-options")
|
|
@cache.cached()
|
|
def weighted_options(game: str):
|
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
|
|
|
|
|
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
|
def generate_weighted_yaml(game: str):
|
|
if request.method == "POST":
|
|
intent_generate = False
|
|
options = {}
|
|
|
|
for key, val in request.form.items():
|
|
if "||" not in key:
|
|
if len(str(val)) == 0:
|
|
continue
|
|
|
|
options[key] = val
|
|
else:
|
|
if int(val) == 0:
|
|
continue
|
|
|
|
[option, setting] = key.split("||")
|
|
options.setdefault(option, {})[setting] = int(val)
|
|
|
|
# Error checking
|
|
if "name" not in options:
|
|
return "Player name is required."
|
|
|
|
# Remove POST data irrelevant to YAML
|
|
if "intent-generate" in options:
|
|
intent_generate = True
|
|
del options["intent-generate"]
|
|
if "intent-export" in options:
|
|
del options["intent-export"]
|
|
|
|
# Properly format YAML output
|
|
player_name = options["name"]
|
|
del options["name"]
|
|
|
|
formatted_options = {
|
|
"name": player_name,
|
|
"game": game,
|
|
"description": f"Generated by https://archipelago.gg/ for {game}",
|
|
game: options,
|
|
}
|
|
|
|
if intent_generate:
|
|
return generate_game({player_name: formatted_options})
|
|
|
|
else:
|
|
return send_yaml(player_name, formatted_options)
|
|
|
|
|
|
# Player options pages
|
|
@app.route("/games/<string:game>/player-options")
|
|
@cache.cached()
|
|
def player_options(game: str):
|
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
|
|
|
|
|
# YAML generator for player-options
|
|
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
|
|
def generate_yaml(game: str):
|
|
if request.method == "POST":
|
|
options = {}
|
|
intent_generate = False
|
|
for key, val in request.form.items(multi=True):
|
|
if key in options:
|
|
if not isinstance(options[key], list):
|
|
options[key] = [options[key]]
|
|
options[key].append(val)
|
|
else:
|
|
options[key] = val
|
|
|
|
for key, val in options.copy().items():
|
|
key_parts = key.rsplit("||", 2)
|
|
# Detect and build ItemDict options from their name pattern
|
|
if key_parts[-1] == "qty":
|
|
if key_parts[0] not in options:
|
|
options[key_parts[0]] = {}
|
|
if val != "0":
|
|
options[key_parts[0]][key_parts[1]] = int(val)
|
|
del options[key]
|
|
|
|
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value
|
|
elif key_parts[-1].endswith("-custom"):
|
|
if val:
|
|
options[key_parts[-1][:-7]] = val
|
|
|
|
del options[key]
|
|
|
|
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
|
|
elif key_parts[-1].endswith("-range"):
|
|
if options[key_parts[-1][:-6]] == "custom":
|
|
options[key_parts[-1][:-6]] = val
|
|
|
|
del options[key]
|
|
|
|
# Detect random-* keys and set their options accordingly
|
|
for key, val in options.copy().items():
|
|
if key.startswith("random-"):
|
|
options[key.removeprefix("random-")] = "random"
|
|
del options[key]
|
|
|
|
# Error checking
|
|
if not options["name"]:
|
|
return "Player name is required."
|
|
|
|
# Remove POST data irrelevant to YAML
|
|
preset_name = 'default'
|
|
if "intent-generate" in options:
|
|
intent_generate = True
|
|
del options["intent-generate"]
|
|
if "intent-export" in options:
|
|
del options["intent-export"]
|
|
if "game-options-preset" in options:
|
|
preset_name = options["game-options-preset"]
|
|
del options["game-options-preset"]
|
|
|
|
# Properly format YAML output
|
|
player_name = options["name"]
|
|
del options["name"]
|
|
|
|
description = f"Generated by https://archipelago.gg/ for {game}"
|
|
if preset_name != 'default' and preset_name != 'custom':
|
|
description += f" using {preset_name} preset"
|
|
|
|
formatted_options = {
|
|
"name": player_name,
|
|
"game": game,
|
|
"description": description,
|
|
game: options,
|
|
}
|
|
|
|
if intent_generate:
|
|
return generate_game({player_name: formatted_options})
|
|
|
|
else:
|
|
return send_yaml(player_name, formatted_options)
|