diff --git a/.dockerignore b/.dockerignore index 982e411032..0cc03e22e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -46,7 +46,6 @@ dist /prof/ README.html .vs/ -EnemizerCLI/ /Players/ /SNI/ /sni-*/ diff --git a/.github/pyright-config.json b/.github/pyright-config.json index c5432dbf3c..7bd4698c74 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -19,6 +19,7 @@ "../test/programs/test_multi_server.py", "../test/utils/__init__.py", "../test/webhost/test_descriptions.py", + "../test/webhost/test_suuid.py", "../worlds/AutoSNIClient.py", "type_check.py" ], diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21e1a24b88..ca155031c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z" - name: Create Release - uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: draft: true # don't publish right away, especially since windows build is added by hand prerelease: false @@ -97,13 +97,15 @@ jobs: build/exe.*/ArchipelagoServer.exe setups/* - name: Add to Release - uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: draft: true # see above prerelease: false name: Archipelago ${{ env.RELEASE_VERSION }} files: | setups/* + fail_on_unmatched_files: true + overwrite_files: false # Windows release is usually built by hand env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -165,12 +167,14 @@ jobs: build/exe.*/ArchipelagoServer dist/* - name: Add to Release - uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: draft: true # see above prerelease: false name: Archipelago ${{ env.RELEASE_VERSION }} files: | dist/* + fail_on_unmatched_files: true + overwrite_files: false # should never happen; avoids accidentally changing a release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CommonClient.py b/CommonClient.py index 3f98a4eff1..2da41f8360 100755 --- a/CommonClient.py +++ b/CommonClient.py @@ -1069,7 +1069,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): if "players" in args: ctx.consume_players_package(args["players"]) if "hint_points" in args: - ctx.hint_points = args['hint_points'] + ctx.hint_points = args["hint_points"] if "checked_locations" in args: checked = set(args["checked_locations"]) ctx.checked_locations |= checked @@ -1077,6 +1077,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict): if "permissions" in args: ctx.update_permissions(args["permissions"]) + # Update hint info for local display + if "hint_cost" in args: + ctx.hint_cost = int(args["hint_cost"]) + elif cmd == 'Print': ctx.on_print(args) diff --git a/Dockerfile b/Dockerfile index 363478988c..9740806565 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,5 @@ # hadolint global ignore=SC1090,SC1091 -# Source -FROM scratch AS release -WORKDIR /release -ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip - -# Enemizer -FROM alpine:3.21 AS enemizer -ARG TARGETARCH -WORKDIR /release -COPY --from=release /release/Enemizer.zip . - -# No release for arm architecture. Skip. -RUN if [ "$TARGETARCH" = "amd64" ]; then \ - apk add unzip=6.0-r15 --no-cache && \ - unzip -u Enemizer.zip -d EnemizerCLI && \ - chmod -R 777 EnemizerCLI; \ - else touch EnemizerCLI; fi - # Cython builder stage FROM python:3.12 AS cython-builder @@ -81,15 +63,6 @@ RUN apt-get purge -y \ g++ && \ apt-get autoremove -y -# Copy necessary components -COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI - -# No release for arm architecture. Skip. -RUN if [ "$TARGETARCH" = "amd64" ]; then \ - cp -r /tmp/EnemizerCLI EnemizerCLI; \ - fi; \ - rm -rf /tmp/EnemizerCLI - # Define health check HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:${PORT:-80} || exit 1 diff --git a/Generate.py b/Generate.py index 509bf848d0..ff76b3c649 100644 --- a/Generate.py +++ b/Generate.py @@ -40,6 +40,8 @@ def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument('--spoiler', type=int, default=defaults.spoiler) parser.add_argument('--outputpath', default=settings.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd + parser.add_argument('--allow_quantity', action="store_true", default=defaults.allow_quantity, + help='Allows the use of the quantity option in yamls. Default is the set value in the host.yaml.') parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level') @@ -123,6 +125,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]: player_id: int = 1 player_files: dict[int, str] = {} player_errors: list[str] = [] + allow_quantity = args.allow_quantity for file in os.scandir(args.player_files_path): fname = file.name if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \ @@ -134,7 +137,14 @@ def main(args=None) -> tuple[argparse.Namespace, int]: if yaml is None: logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}") else: - weights_for_file.append(yaml) + quantity = yaml.get("quantity", 1) + if quantity <= 0: + raise ValueError("A quantity of 0 or less is invalid. Please change it to at least 1.") + if not allow_quantity and quantity > 1: + raise ValueError("Quantity greater than 1 is deactivated by host settings.") + + for _ in range(quantity): + weights_for_file.append(yaml) weights_cache[fname] = tuple(weights_for_file) except Exception as e: @@ -575,7 +585,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b raise Exception(f"Invalid game: {ret.game}") if ret.game not in AutoWorldRegister.world_types: from worlds import failed_world_loads - picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] + picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + list(failed_world_loads.keys()), + limit=1)[0] if picks[0] in failed_world_loads: raise Exception(f"No functional world found to handle game {ret.game}. " f"Did you mean '{picks[0]}' ({picks[1]}% sure)? " diff --git a/Launcher.py b/Launcher.py index f794551f35..9c2764fe10 100644 --- a/Launcher.py +++ b/Launcher.py @@ -34,6 +34,79 @@ from Utils import env_cleared_lib_path, init_logging, is_linux, is_macos, is_win if __name__ == "__main__": init_logging('Launcher') +from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type +from worlds import failed_world_loads + + +def open_host_yaml(): + s = settings.get_settings() + file = s.filename + s.save() + assert file, "host.yaml missing" + if is_linux: + exe = which('sensible-editor') or which('gedit') or \ + which('xdg-open') or which('gnome-open') or which('kde-open') + elif is_macos: + exe = which("open") + else: + webbrowser.open(file) + return + + env = env_cleared_lib_path() + subprocess.Popen([exe, file], env=env) + +def open_patch(): + suffixes = [] + for c in components: + if c.type == Type.CLIENT and \ + isinstance(c.file_identifier, SuffixIdentifier) and \ + (c.script_name is None or isfile(get_exe(c)[-1])): + suffixes += c.file_identifier.suffixes + try: + filename = open_filename("Select patch", (("Patches", suffixes),)) + except Exception as e: + messagebox("Error", str(e), error=True) + else: + file, component = identify(filename) + if file and component: + exe = get_exe(component) + if exe is None or not isfile(exe[-1]): + exe = get_exe("Launcher") + + launch([*exe, file], component.cli) + + +def generate_yamls(*args): + from Options import generate_yaml_templates + + parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]") + parser.add_argument("--skip_open_folder", action="store_true") + args = parser.parse_args(args) + + target = Utils.user_path("Players", "Templates") + generate_yaml_templates(target, False) + if not args.skip_open_folder: + open_folder(target) + + +def browse_files(): + open_folder(user_path()) + + +def open_folder(folder_path): + if is_linux: + exe = which('xdg-open') or which('gnome-open') or which('kde-open') + elif is_macos: + exe = which("open") + else: + webbrowser.open(folder_path) + return + + if exe: + env = env_cleared_lib_path() + subprocess.Popen([exe, folder_path], env=env) + else: + logging.warning(f"No file browser available to open {folder_path}") def update_settings(): @@ -150,7 +223,8 @@ def run_gui(launch_components: list["Component"], args: Any) -> None: button_layout: ScrollBox = ObjectProperty(None) search_box: MDTextField = ObjectProperty(None) cards: list[LauncherCard] - current_filter: Sequence["Type"] | None + current_filter: Sequence[str, "Type"] | None + failed_worlds: bool = bool(failed_world_loads) def __init__(self, ctx=None, components=None, args=None): self.title = self.base_title + " " + Utils.__version__ @@ -338,6 +412,39 @@ def run_gui(launch_components: list["Component"], args: Any) -> None: MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + @staticmethod + def copy_to_clipboard(text): + from kivy.core.clipboard import Clipboard + Clipboard.copy(text) + MDSnackbar(MDSnackbarText(text="Copied to clipboard."), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() + + def display_failed(self): + """Display a dialog showing the exceptions produced by any world that failed to load during + initialization.""" + if not self.failed_worlds: + return + from kivymd.uix.dialog import MDDialog, MDDialogIcon, MDDialogHeadlineText, MDDialogContentContainer + from kivymd.uix.divider import MDDivider + from kivymd.uix.list import MDListItem, MDListItemHeadlineText, MDListItemSupportingText + entries = [] + for world, reason in failed_world_loads.items(): + entries.append(MDListItem( + MDListItemHeadlineText(text=world), + MDListItemSupportingText(text=reason), + on_release=lambda x, r=reason: self.copy_to_clipboard(r) + )) + dialog = MDDialog( + MDDialogIcon(icon="alert"), + MDDialogHeadlineText(text="Failed World Loads"), + MDDialogContentContainer( + MDDivider(), + *entries, + orientation="vertical", + ) + ) + dialog.open() + def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None: """ When a patch file is dropped into the window, run the associated component. """ from worlds.LauncherComponents import identify diff --git a/MMBN3Client.py b/MMBN3Client.py index 31c6b309b8..bd36fae4b8 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -241,8 +241,8 @@ async def gba_sync_task(ctx: MMBN3Context): await ctx.server_auth(False) else: if not ctx.version_warning: - logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}." - "Please update to the latest version." + logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. " + "Please update to the latest version. " "Your connection to the Archipelago server will not be accepted.") ctx.version_warning = True except asyncio.TimeoutError: diff --git a/MultiServer.py b/MultiServer.py index ed14b6506f..28ab853ccc 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -2633,8 +2633,8 @@ def parse_args() -> argparse.Namespace: goal: !remaining can be used after goal completion ''') parser.add_argument('--auto_shutdown', default=defaults["auto_shutdown"], type=int, - help="automatically shut down the server after this many minutes without new location checks. " - "0 to keep running. Not yet implemented.") + help="automatically shut down the server after this many seconds without new location checks. " + "0 to keep running.") parser.add_argument('--use_embedded_options', action="store_true", help='retrieve release, remaining and hint options from the multidata file,' ' instead of host.yaml') diff --git a/NetUtils.py b/NetUtils.py index f61dbf9fcb..f792a27288 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -527,7 +527,11 @@ else: except ImportError: pyximport = None try: + import logging + logger = logging.getLogger() + old_level = logger.level from _speedups import LocationStore + logger.setLevel(old_level) except ImportError: warnings.warn("_speedups not available. Falling back to pure python LocationStore. " "Install a matching C++ compiler for your platform to compile _speedups.") diff --git a/Options.py b/Options.py index 57119ff66c..47f4b1d45c 100644 --- a/Options.py +++ b/Options.py @@ -212,6 +212,13 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): else: return cls.name_lookup[value] + def __eq__(self, other: typing.Any) -> bool: + if isinstance(other, self.__class__): + return self.value == other.value + if isinstance(other, Option): + raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") + return self.value == other + def __int__(self) -> T: return self.value @@ -930,13 +937,34 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin class OptionCounter(OptionDict): min: int | None = None max: int | None = None + cull_zeroes: bool = False def __init__(self, value: dict[str, int]) -> None: - super(OptionCounter, self).__init__(collections.Counter(value)) + cleaned_dict = {} + + invalid_value_errors = [] + for key, value in value.items(): + if not isinstance(value, (int, float)) or int(value) != value: + invalid_value_errors += [f"Invalid value {value} for key {key}, must be an integer."] + continue + + if self.cull_zeroes and value == 0: + continue + + cleaned_dict[key] = int(value) + + if invalid_value_errors: + type_errors = [f"For option {self.__class__.__name__}:"] + invalid_value_errors + raise TypeError("\n".join(invalid_value_errors)) + + super(OptionCounter, self).__init__(collections.Counter(cleaned_dict)) def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None: super(OptionCounter, self).verify(world, player_name, plando_options) + self.verify_values() + + def verify_values(self): range_errors = [] if self.max is not None: @@ -959,13 +987,8 @@ class OptionCounter(OptionDict): class ItemDict(OptionCounter): verify_item_name = True - min = 0 - - def __init__(self, value: dict[str, int]) -> None: - # Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter - value = {item_name: amount for item_name, amount in value.items() if amount != 0} - - super(ItemDict, self).__init__(value) + # Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter + cull_zeroes = True class OptionList(Option[typing.List[typing.Any]], VerifyKeys): @@ -1446,7 +1469,7 @@ class NonLocalItems(ItemSet): class StartInventory(ItemDict): - """Start with the specified amount of these items. Example: "Bomb: 1" """ + """Start with the specified amount of these items. Example: {Bomb: 1, Arrow: 3} """ verify_item_name = True display_name = "Start Inventory" rich_text_doc = True @@ -1454,7 +1477,7 @@ class StartInventory(ItemDict): class StartInventoryPool(StartInventory): - """Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1" + """Start with the specified amount of these items and don't place them in the world. Example: {Bomb: 1, Arrow: 3} The game decides what the replacement items will be. """ @@ -1833,27 +1856,30 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - presets = world.web.options_presets.copy() - presets.update({"": {}}) + try: + presets = world.web.options_presets.copy() + presets.update({"": {}}) - option_groups = get_option_groups(world) - for name, preset in presets.items(): - res = template.render( - option_groups=option_groups, - __version__=__version__, - game=game_name, - world_version=world.world_version.as_simple_string(), - yaml_dump=yaml_dump_scalar, - dictify_range=dictify_range, - cleandoc=cleandoc, - preset_name=name, - preset=preset, - ) - preset_name = f" - {name}" if name else "" - with open(os.path.join(preset_folder if name else target_folder, - get_file_safe_name(game_name + preset_name) + ".yaml"), - "w", encoding="utf-8-sig") as f: - f.write(res) + option_groups = get_option_groups(world) + for name, preset in presets.items(): + res = template.render( + option_groups=option_groups, + __version__=__version__, + game=game_name, + world_version=world.world_version.as_simple_string(), + yaml_dump=yaml_dump_scalar, + dictify_range=dictify_range, + cleandoc=cleandoc, + preset_name=name, + preset=preset, + ) + preset_name = f" - {name}" if name else "" + with open(os.path.join(preset_folder if name else target_folder, + get_file_safe_name(game_name + preset_name) + ".yaml"), + "w", encoding="utf-8-sig") as f: + f.write(res) + except Exception as ex: + raise Exception(f"Template generation failed for world {game_name}") from ex def dump_player_options(multiworld: MultiWorld) -> None: diff --git a/README.md b/README.md index 7a0c663db0..ffe9650358 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ Currently, the following games are supported: * The Witness * Sonic Adventure 2: Battle * Starcraft 2 -* Donkey Kong Country 3 * Dark Souls 3 * Super Mario World * Pokémon Red and Blue diff --git a/UndertaleClient.py b/UndertaleClient.py index b0efce206a..bd9936f7c0 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -49,7 +49,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor): if isinstance(self.ctx, UndertaleContext): os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True) tempInstall = steaminstall - if not os.path.isfile(os.path.join(tempInstall, "data.win")): + if tempInstall and not os.path.isfile(os.path.join(tempInstall, "data.win")): tempInstall = None if tempInstall is None: tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" diff --git a/Utils.py b/Utils.py index 0210086274..2e57c15aed 100644 --- a/Utils.py +++ b/Utils.py @@ -52,7 +52,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.6.7" +__version__ = "0.6.8" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -450,13 +450,10 @@ safe_builtins = frozenset(( class RestrictedUnpickler(pickle.Unpickler): - generic_properties_module: Optional[object] - def __init__(self, *args: Any, **kwargs: Any) -> None: super(RestrictedUnpickler, self).__init__(*args, **kwargs) self.options_module = importlib.import_module("Options") self.net_utils_module = importlib.import_module("NetUtils") - self.generic_properties_module = None def find_class(self, module: str, name: str) -> type: if module == "builtins" and name in safe_builtins: @@ -470,10 +467,6 @@ class RestrictedUnpickler(pickle.Unpickler): "SlotType", "NetworkSlot", "HintStatus"}: return getattr(self.net_utils_module, name) # Options and Plando are unpickled by WebHost -> Generate - if module == "worlds.generic" and name == "PlandoItem": - if not self.generic_properties_module: - self.generic_properties_module = importlib.import_module("worlds.generic") - return getattr(self.generic_properties_module, name) # pep 8 specifies that modules should have "all-lowercase names" (options, not Options) if module.lower().endswith("options"): if module == "Options": diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index f1ac7ad558..b3dc203a3d 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -48,6 +48,8 @@ app.config["JOB_THRESHOLD"] = 1 app.config["JOB_TIME"] = 600 # maximum time in seconds since last activity for a room to be hosted app.config["MAX_ROOM_TIMEOUT"] = 259200 +# minimum time in days since last activity for a room to be deleted. 0 to disable. +app.config["ROOM_AUTO_DELETE"] = 0 # memory limit for generator processes in bytes app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296 @@ -71,7 +73,9 @@ CLI(app) def to_python(value: str) -> uuid.UUID: - return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) + if "=" in value or any(c.isspace() for c in value): + raise ValueError("Invalid UUID format") + return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=' * (-len(value) % 4))) def to_url(value: uuid.UUID) -> str: diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 1a61564500..165e08a103 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -100,13 +100,18 @@ def init_generator(config: dict[str, Any]) -> None: db.generate_mapping() -def cleanup(): - """delete unowned user-content""" +def cleanup(config: dict[str, Any]): + """delete unowned or old user-content""" + auto_delete: int = config.get("ROOM_AUTO_DELETE", 0) with db_session: # >>> bool(uuid.UUID(int=0)) # True rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True) seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True) + if auto_delete > 0: + cutoff = utcnow() - timedelta(days=auto_delete) + rooms += Room.select(lambda room: room.last_activity < cutoff).delete(bulk=True) + seeds += Seed.select(lambda seed: not seed.rooms and seed.creation_time < cutoff).delete(bulk=True) slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True) # Command gets deleted by ponyorm Cascade Delete, as Room is Required if rooms or seeds or slots: @@ -118,7 +123,7 @@ def autohost(config: dict): stop_event = _stop_event try: with Locker("autohost"): - cleanup() + cleanup(config) hosters = [] for x in range(config["HOSTERS"]): hoster = MultiworldInstance(config, x) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index fd194f2232..28d6760db0 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -10,5 +10,5 @@ Flask-Cors==6.0.2 bokeh==3.8.2 markupsafe==3.0.3 setproctitle==1.3.7 -mistune==3.2.0 +mistune==3.2.1 docutils==0.22.4 diff --git a/WebHostLib/static/assets/weightedOptions.js b/WebHostLib/static/assets/weightedOptions.js index 0417ab174b..4aa99963d5 100644 --- a/WebHostLib/static/assets/weightedOptions.js +++ b/WebHostLib/static/assets/weightedOptions.js @@ -123,12 +123,26 @@ window.addEventListener('load', () => { }); const addRangeRow = (optionName) => { - const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`; + const inputQuery = `input[data-option="${optionName}"]`; const inputTarget = document.querySelector(inputQuery); const newValue = inputTarget.value; - if (!/^-?\d+$/.test(newValue)) { - alert('Range values must be a positive or negative integer!'); - return; + switch (inputTarget.type) { + case 'number': + if (!/^-?\d+$/.test(newValue)) { + alert('Range values must be a positive or negative integer!'); + return; + } + break; + case 'text': + if (newValue === "") { + alert('Range values for text must be a non-empty string!'); + return; + } + break; + default: + console.error(`Found unsupported input type: ${inputTarget.type}`); + return; + break; } inputTarget.value = ''; const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`); diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 7de14f0d82..1b90091c29 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}