Compare commits

...
11 Commits
Author SHA1 Message Date
Jikoo 63fccdcf91 Bump version to 5.3.0 for release 2026-01-17 23:57:38 -05:00
dependabot[bot]andJikoo 960acbfe9d Bump errorprone versions (#370)
* Bump com.google.errorprone:error_prone_core from 2.45.0 to 2.46.0

Bumps [com.google.errorprone:error_prone_core](https://github.com/google/error-prone) from 2.45.0 to 2.46.0.
- [Release notes](https://github.com/google/error-prone/releases)
- [Commits](https://github.com/google/error-prone/compare/v2.45.0...v2.46.0)

---
updated-dependencies:
- dependency-name: com.google.errorprone:error_prone_core
  dependency-version: 2.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump errorprone-gradle-plugin to 4.4.0

Resolves issues with missing flag required for errorprone-core

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jikoo <jikoo.games@gmail.com>
2026-01-18 02:37:20 +00:00
dependabot[bot] 3c904499c5 Bump com.gradleup.shadow from 9.2.2 to 9.3.1 (#369) 2026-01-17 16:54:05 +00:00
Adam baa6b3d734 Add profile cache (#368)
Instead of reading every single player file from disk when doing a fuzzy match, we now do a single lookup from a SQLite database. The database is populated once with all existing players' names on startup, then maintained when players join. This is a massive performance boost for servers that have high player counts or run on spinning disks.

Lookups now require that the first character of the target player's name be correct (case-insensitive). For Geyser-like names, this extends to the first character of their real name. For example, "XxXJohnMinecraftXxX" can no longer be located by a "johnminecraft" search. A search for "xjohnminecraft" could be used instead. A Geyser player ".FranMinecraft" would require ".f" to be eligible, i.e. "franmc" cannot produce the correct result while ".franmc" might.
2026-01-17 11:50:23 -05:00
Adam 7f6c339e06 Add option to check permissions with toggle (#361)
* Promote Permissions to API
* Document permissions
* Add method for checking a toggle with permissions
2026-01-03 12:20:58 -05:00
dependabot[bot] 00736c8d30 Bump actions/download-artifact from 6 to 7 (#364)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 12:12:35 -05:00
dependabot[bot] cb1ec77c24 Bump actions/upload-artifact from 5 to 6 (#363)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 12:12:25 -05:00
dependabot[bot] c8c8a72819 Bump actions/cache from 4 to 5 (#362)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 12:12:14 -05:00
Alessandro 16f91ddc9f feat: add use sub-permissions 2026-01-01 08:55:43 -05:00
Adam c83511e475 Upload to Modrinth and Hangar (#360)
* Replace curseforge-upload with mc-publish
* Use anchors for common configurations
* Convert script to a shared run step
2025-12-31 14:39:53 -05:00
Jikoo c095a78013 Bump version to 5.2.1-SNAPSHOT for development 2025-12-17 15:29:26 -05:00
25 changed files with 915 additions and 235 deletions

No files matched your search

+9
View File
@@ -8,3 +8,12 @@ updates:
directory: "/"
schedule:
interval: "monthly"
ignore:
# The SQLite JDBC is provided by Spigot and, for now, Paper.
# To ensure it is still present even if Paper stops providing it,
# we need to declare it as a library.
# To prevent needlessly downloading (and loading) a second copy,
# we want to ensure that we are using the provided version.
- dependency-name: "org.xerial:sqlite-jdbc"
# Spigot and Spigot API updates are manual.
- dependency-name: "org.spigotmc:*"
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
# We can't use 'maven' prebuilt cache setup because it requires that the project have a pom file.
# BuildTools installs to Maven local if available, so it's easier to just rely on that.
- name: Cache Spigot dependency
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.m2/repository/org/spigotmc/
@@ -41,7 +41,7 @@ jobs:
# Upload artifacts
- name: Upload Distributable Jar
id: upload-final
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: dist
path: ./dist/*
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download build
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: dist
path: dist
+79 -19
View File
@@ -1,4 +1,4 @@
name: Release to CurseForge
name: Release to external sites
on:
release:
@@ -8,30 +8,90 @@ jobs:
curseforge_release:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- &parse-paper-versions
name: Parse Paper Versions
id: parse_versions
run: |
# Find line declaring Paper versions.
raw=$(grep "\\*\\*Paper:\\*\\*" <<< "${{ github.event.release.body }}")
- name: Fetch Github Release Asset
# Enable extended glob pattern to match 0 or more whitespace characters.
shopt -s extglob
# Trim Paper versions identifier prefix.
raw=${raw/*([[:space:]])'**'Paper:'**'*([[:space:]])/}
# Replace commas and optional spaces with a newline.
raw=${raw//,*([[:space:]])/$'\n'}
# Turn extglob back off.
shopt -u extglob
echo "$raw"
printf "PAPER_VERSIONS<<EOF\n%s\nEOF\n" "$raw" >> "$GITHUB_OUTPUT"
- &fetch-release-asset
name: Fetch Github Release Asset
uses: dsaltares/fetch-gh-release-asset@1.1.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ github.event.release.id }}
file: OpenInv.jar
- name: Set CurseForge Variables
run: . scripts/set_curseforge_env.sh "${{ github.event.release.body }}"
- name: Create CurseForge Release
uses: itsmeow/curseforge-upload@v3
uses: Kira-NT/mc-publish@v3.3
with:
token: "${{ secrets.CURSEFORGE_TOKEN }}"
project_id: 31432
game_endpoint: minecraft
file_path: ./OpenInv.jar
display_name: "${{ github.event.release.name }}"
game_versions: "${{ env.CURSEFORGE_MINECRAFT_VERSIONS }}"
release_type: release
changelog_type: markdown
changelog: "${{ github.event.release.body }}"
curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }}
curseforge-id: 31432
files: ./OpenInv.jar
game-versions: "${{ steps.parse_versions.outputs.PAPER_VERSIONS }}"
game-version-filter: releases
modrinth_release:
runs-on: ubuntu-latest
steps:
- *parse-paper-versions
- *fetch-release-asset
- name: Create Modrinth Release
uses: Kira-NT/mc-publish@v3.3
with:
modrinth-id: 1UlvXbzL
modrinth-featured: false
modrinth-token: ${{ secrets.MODRINTH_TOKEN }}
files: ./OpenInv.jar
game-versions: "${{ steps.parse_versions.outputs.PAPER_VERSIONS }}"
game-version-filter: releases
hangar_release:
runs-on: ubuntu-latest
steps:
- name: Parse Hangar Paper Versions
id: parse_versions
run: |
# Find line declaring Paper versions.
raw=$(grep "\\*\\*Paper:\\*\\*" <<< "${{ github.event.release.body }}")
# Trim Paper versions identifier prefix.
raw=${raw/'**'Paper:'**'/}
# Generate JSON for Hangar. GitHub Actions provides jq 1.7 in the 24.04 image.
# Trim, then split on comma and optional spaces.
hangar_versions=$(jq -nc --arg versions "$raw" '{"PAPER": $versions | trim | split(", *";null)}')
echo "$hangar_versions"
echo "HANGAR_PAPER_VERSIONS=$hangar_versions" >> "$GITHUB_OUTPUT"
- *fetch-release-asset
- name: Create Hangar Release
uses: benwoo1110/hangar-upload-action@v1
with:
api_token: ${{ secrets.HANGAR_TOKEN }}
slug: 'OpenInv'
version: ${{ github.event.release.tag_name }}
channel: release
files: |-
[
{
"path": "./OpenInv.jar",
"platforms": ["PAPER"]
}
]
platform_dependencies: "${{ steps.parse_versions.outputs.HANGAR_PAPER_VERSIONS }}"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
run: |
mkdir -p ./pr
echo ${{ github.event.number }} > ./pr/pr_number
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: pr_number
path: pr/
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Build resource pack
id: upload-resource-pack
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: openinv-legibility-pack
path: ./resource-pack/openinv-legibility-pack/
@@ -0,0 +1,128 @@
/*
* Copyright (C) 2011-2022 lishid. All rights reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.lishid.openinv.util;
import org.bukkit.permissions.Permissible;
import org.jetbrains.annotations.NotNull;
/**
* An enum containing all permissions directly checked by OpenInv.
*
* <p>Note that this is not an exhaustive list! This does not contain
* all permissions managed by Bukkit, largely parent nodes.</p>
*/
public enum Permissions {
/// Permission to open one's own inventory.
INVENTORY_OPEN_SELF("inventory.open.self"),
/// Permission to open someone else's inventory.
INVENTORY_OPEN_OTHER("inventory.open.other"),
/**
* Permission to edit one's own inventory.
*
* <p>Note that this does not guarantee that the user has access to open the inventory!
* Be sure to check {@link #INVENTORY_OPEN_SELF} first.</p>
*/
INVENTORY_EDIT_SELF("inventory.edit.self"),
/**
* Permission to edit someone else's inventory.
*
* <p>Note that this does not guarantee that the user has access to open the inventory!
* Be sure to check {@link #INVENTORY_OPEN_OTHER} first.</p>
*/
INVENTORY_EDIT_OTHER("inventory.edit.other"),
/// Permission to insert any item into the head slot.
INVENTORY_SLOT_HEAD_ANY("inventory.slot.head.any"),
/// Permission to insert any item into the chest slot.
INVENTORY_SLOT_CHEST_ANY("inventory.slot.chest.any"),
/// Permission to insert any item into the legs slot.
INVENTORY_SLOT_LEGS_ANY("inventory.slot.legs.any"),
/// Permission to insert any item into the feet slot.
INVENTORY_SLOT_FEET_ANY("inventory.slot.feet.any"),
/// Permission to drop items as the player via the drop slot.
INVENTORY_SLOT_DROP("inventory.slot.drop"),
/// Permission to open one's own ender chest.
ENDERCHEST_OPEN_SELF("enderchest.open.self"),
/// Permission to open someone else's ender chest.
ENDERCHEST_OPEN_OTHER("enderchest.open.other"),
/**
* Permission to edit one's own ender chest.
*
* <p>Note that this does not guarantee that the user has access to open the inventory!
* Be sure to check {@link #ENDERCHEST_OPEN_SELF} first.</p>
*/
ENDERCHEST_EDIT_SELF("enderchest.edit.self"),
/**
* Permission to edit someone else's ender chest.
*
* <p>Note that this does not guarantee that the user has access to open the inventory!
* Be sure to check {@link #ENDERCHEST_OPEN_OTHER} first.</p>
*/
ENDERCHEST_EDIT_OTHER("enderchest.edit.other"),
/// Permission to clear one's own inventory.
CLEAR_SELF("clear.self"),
/// Permission to clear someone else's inventory.
CLEAR_OTHER("clear.other"),
/// Permission to view inventories and ender chests from other worlds.
ACCESS_CROSSWORLD("access.crossworld"),
/// Permission to access offline players' inventories and ender chests.
ACCESS_OFFLINE("access.offline"),
/// Permission to access online players' inventories and ender chests.
ACCESS_ONLINE("access.online"),
/// Permission granting the ability to edit players with the same access level.
ACCESS_EQUAL_EDIT("access.equal.edit"),
/// Permission to view, but not edit, players with the same access level.
ACCESS_EQUAL_VIEW("access.equal.view"),
/// Permission to deny access to players with the same access level.
ACCESS_EQUAL_DENY("access.equal.deny"),
/// Permission to perform inventory interaction while in spectator mode.
SPECTATE_CLICK("spectate.click"),
/// Permission to use the AnyContainer feature.
CONTAINER_ANY("container.any"),
/// Permission to use the AnyContainer feature but not the command to toggle it.
CONTAINER_ANY_USE("container.any.use"),
/// Permission to use the SilentContainer feature.
CONTAINER_SILENT("container.silent"),
/// Permission to use the SilentContainer feature but not the command to toggle it.
CONTAINER_SILENT_USE("container.silent.use"),
/// Permission to search inventories and ender chests.
SEARCH_INVENTORY("search.inventory"),
/// Permission to search containers.
SEARCH_CONTAINER("search.container");
private final String permission;
Permissions(String permission) {
this.permission = "openinv." + permission;
}
/**
* Check if a {@link Permissible} has this permission.
*
* @param permissible the Permissible
* @return true if the permission is granted
*/
public boolean hasPermission(@NotNull Permissible permissible) {
return permissible.hasPermission(permission);
}
}
@@ -1,5 +1,6 @@
package com.lishid.openinv.util.setting;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
@@ -24,6 +25,15 @@ public interface PlayerToggle {
*/
boolean is(@NotNull UUID uuid);
/**
* Get the state of the toggle for a particular {@link Player},
* accounting for permissions required to use the feature.
*
* @param player the player
* @return true if the setting is enabled and the player has the required permissions
*/
boolean is(@NotNull Player player);
/**
* Set the state of the toggle for a particular player ID.
*
@@ -1,5 +1,7 @@
package com.lishid.openinv.util.setting;
import com.lishid.openinv.util.Permissions;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
@@ -19,8 +21,20 @@ import java.util.UUID;
public final class PlayerToggles {
private static final Map<String, PlayerToggle> TOGGLES = new HashMap<>();
private static final PlayerToggle ANY = add(new MemoryToggle("AnyContainer"));
private static final PlayerToggle SILENT = add(new MemoryToggle("SilentContainer"));
private static final PlayerToggle ANY = add(new MemoryToggle("AnyContainer") {
@Override
public boolean is(@NotNull Player player) {
return is(player.getUniqueId())
&& (Permissions.CONTAINER_ANY.hasPermission(player) || Permissions.CONTAINER_ANY_USE.hasPermission(player));
}
});
private static final PlayerToggle SILENT = add(new MemoryToggle("SilentContainer") {
@Override
public boolean is(@NotNull Player player) {
return is(player.getUniqueId())
&& (Permissions.CONTAINER_SILENT.hasPermission(player) || Permissions.CONTAINER_SILENT_USE.hasPermission(player));
}
});
/**
* Get the AnyContainer toggle.
@@ -72,7 +86,7 @@ public final class PlayerToggles {
throw new IllegalStateException("Cannot create instance of utility class.");
}
private static class MemoryToggle implements PlayerToggle {
private static abstract class MemoryToggle implements PlayerToggle {
private final @NotNull Set<UUID> enabled;
private final @NotNull String name;
@@ -1,66 +0,0 @@
/*
* Copyright (C) 2011-2022 lishid. All rights reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.lishid.openinv.util;
import org.bukkit.permissions.Permissible;
import org.jetbrains.annotations.NotNull;
public enum Permissions {
INVENTORY_OPEN_SELF("inventory.open.self"),
INVENTORY_OPEN_OTHER("inventory.open.other"),
INVENTORY_EDIT_SELF("inventory.edit.self"),
INVENTORY_EDIT_OTHER("inventory.edit.other"),
INVENTORY_SLOT_HEAD_ANY("inventory.slot.head.any"),
INVENTORY_SLOT_CHEST_ANY("inventory.slot.chest.any"),
INVENTORY_SLOT_LEGS_ANY("inventory.slot.legs.any"),
INVENTORY_SLOT_FEET_ANY("inventory.slot.feet.any"),
INVENTORY_SLOT_DROP("inventory.slot.drop"),
ENDERCHEST_OPEN_SELF("enderchest.open.self"),
ENDERCHEST_OPEN_OTHER("enderchest.open.other"),
ENDERCHEST_EDIT_SELF("enderchest.edit.self"),
ENDERCHEST_EDIT_OTHER("enderchest.edit.other"),
CLEAR_SELF("clear.self"),
CLEAR_OTHER("clear.other"),
ACCESS_CROSSWORLD("access.crossworld"),
ACCESS_OFFLINE("access.offline"),
ACCESS_ONLINE("access.online"),
ACCESS_EQUAL_EDIT("access.equal.edit"),
ACCESS_EQUAL_VIEW("access.equal.view"),
ACCESS_EQUAL_DENY("access.equal.deny"),
SPECTATE_CLICK("spectate.click"),
CONTAINER_ANY("container.any"),
CONTAINER_SILENT("container.silent"),
SEARCH_INVENTORY("search.inventory"),
SEARCH_CONTAINER("search.container");
private final String permission;
Permissions(String permission) {
this.permission = "openinv." + permission;
}
public boolean hasPermission(@NotNull Permissible permissible) {
return permissible.hasPermission(permission);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Project meta
group=com.lishid.openinv
version=5.2.0
version=5.3.0
description=A Bukkit plugin for opening normally-inaccessible inventories.
# Gradle configuration
+5 -3
View File
@@ -4,11 +4,12 @@ specialsource = "1.11.5"
planarwrappers = "3.3.0"
annotations = "26.0.2-1"
paperweight = "2.0.0-beta.17"
shadow = "9.2.2"
shadow = "9.3.1"
folia-scheduler-wrapper = "v0.0.3"
errorprone-core = "2.45.0"
errorprone-gradle = "4.2.0"
errorprone-core = "2.46.0"
errorprone-gradle = "4.4.0"
slf4j = "2.0.17"
sqlite-jdbc = "3.49.1.0"
[libraries]
spigotapi = { module = "org.spigotmc:spigot-api", version.ref = "spigotapi" }
@@ -19,6 +20,7 @@ folia-scheduler-wrapper = { module = "com.github.NahuLD.folia-scheduler-wrapper:
errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone-core" }
errorprone-gradle = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version.ref = "errorprone-gradle" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }
[plugins]
paperweight = { id = "io.papermc.paperweight.userdev", version.ref = "paperweight" }
+5 -1
View File
@@ -22,10 +22,14 @@ dependencies {
implementation(project(":openinvadapterspigot", configuration = SpigotReobf.ARTIFACT_CONFIG))
implementation(libs.planarwrappers)
implementation(libs.folia.scheduler.wrapper)
compileOnly(libs.sqlite.jdbc)
}
tasks.processResources {
expand("version" to version)
expand(
"version" to version,
"sqlite" to libs.sqlite.jdbc.get().version
)
}
tasks.jar {
@@ -56,6 +56,7 @@ import java.nio.file.Path;
import java.util.Locale;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.logging.Level;
/**
* The main class for OpenInv.
@@ -69,6 +70,10 @@ public class OpenInv extends FoliaWrappedJavaPlugin implements IOpenInv {
private PlayerLoader playerLoader;
private boolean isSpigot = false;
public PlayerLoader getPlayerLoader() {
return playerLoader;
}
@Override
public void reloadConfig() {
super.reloadConfig();
@@ -96,6 +101,11 @@ public class OpenInv extends FoliaWrappedJavaPlugin implements IOpenInv {
@Override
public void onDisable() {
inventoryManager.evictAll();
try {
playerLoader.getProfileStore().shutdown();
} catch (Exception e) {
getLogger().log(Level.WARNING, "Failed to shut down profile store correctly", e);
}
}
@Override
@@ -39,8 +39,6 @@ import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* A listener managing AnyContainer, SilentContainer, and more.
*/
@@ -71,15 +69,14 @@ public class ContainerListener implements Listener {
}
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
boolean any = Permissions.CONTAINER_ANY.hasPermission(player) && PlayerToggles.any().is(playerId);
boolean any = PlayerToggles.any().is(player);
boolean needsAny = accessor.getAnySilentContainer().isAnyContainerNeeded(event.getClickedBlock());
if (!any && needsAny) {
return;
}
boolean silent = Permissions.CONTAINER_SILENT.hasPermission(player) && PlayerToggles.silent().is(playerId);
boolean silent = PlayerToggles.silent().is(player);
// If anycontainer or silentcontainer is active
if (any || silent) {
@@ -104,7 +101,7 @@ public class ContainerListener implements Listener {
}
InventoryHolder holder = event.getInventory().getHolder();
if (PlayerToggles.silent().is(player.getUniqueId())
if (PlayerToggles.silent().is(player)
&& holder != null
&& this.accessor.getAnySilentContainer().isAnySilentContainer(holder)) {
this.accessor.getAnySilentContainer().deactivateContainer(player);
@@ -5,13 +5,16 @@ import com.google.common.cache.CacheBuilder;
import com.google.errorprone.annotations.Keep;
import com.lishid.openinv.OpenInv;
import com.lishid.openinv.util.config.Config;
import com.lishid.openinv.util.profile.OfflinePlayerProfileStore;
import com.lishid.openinv.util.profile.Profile;
import com.lishid.openinv.util.profile.ProfileStore;
import com.lishid.openinv.util.profile.sqlite.SqliteProfileStore;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.profile.PlayerProfile;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -33,7 +36,8 @@ public class PlayerLoader implements Listener {
private final @NotNull InventoryManager inventoryManager;
private final @NotNull InternalAccessor internalAccessor;
private final @NotNull Logger logger;
private final @NotNull Cache<String, PlayerProfile> lookupCache;
private final @NotNull Cache<String, Profile> lookupCache;
private @NotNull ProfileStore profileStore;
public PlayerLoader(
@NotNull OpenInv plugin,
@@ -46,10 +50,30 @@ public class PlayerLoader implements Listener {
this.config = config;
this.inventoryManager = inventoryManager;
this.internalAccessor = internalAccessor;
try {
SqliteProfileStore sqliteStore = new SqliteProfileStore(plugin);
sqliteStore.setup();
sqliteStore.tryImport();
this.profileStore = sqliteStore;
} catch (Exception e) {
this.profileStore = new OfflinePlayerProfileStore(logger);
}
this.logger = logger;
this.lookupCache = CacheBuilder.newBuilder().maximumSize(20).build();
}
public @NotNull ProfileStore getProfileStore() {
return profileStore;
}
public void setProfileStore(@NotNull ProfileStore profileStore) {
plugin.getLogger().log(
Level.INFO,
() -> "Setting profile store implementation to " + profileStore.getClass().getName()
);
this.profileStore = profileStore;
}
/**
* Load a {@link Player} from an {@link OfflinePlayer}. If the user has not played before or the default world for
* the server is not loaded, this will return {@code null}.
@@ -59,22 +83,20 @@ public class PlayerLoader implements Listener {
* @throws IllegalStateException if the server version is unsupported
*/
public @Nullable Player load(@NotNull OfflinePlayer offline) {
UUID key = offline.getUniqueId();
Player player = offline.getPlayer();
if (player != null) {
return player;
}
player = inventoryManager.getLoadedPlayer(key);
if (player != null) {
return player;
}
if (config.isOfflineDisabled() || !internalAccessor.isSupported()) {
return null;
}
player = inventoryManager.getLoadedPlayer(offline.getUniqueId());
if (player != null) {
return player;
}
if (Bukkit.isPrimaryThread()) {
return internalAccessor.getPlayerDataManager().loadPlayer(offline);
}
@@ -93,13 +115,6 @@ public class PlayerLoader implements Listener {
}
public @Nullable OfflinePlayer matchExact(@NotNull String name) {
// Warn if called on the main thread - if we resort to searching offline players, this may take several seconds.
if (Bukkit.getServer().isPrimaryThread()) {
logger.warning("Call to PlayerSearchCache#matchPlayer made on the main thread!");
logger.warning("This can cause the server to hang, potentially severely.");
logger.log(Level.WARNING, "Current stack trace", new Throwable("Current stack trace"));
}
OfflinePlayer player;
try {
@@ -123,9 +138,9 @@ public class PlayerLoader implements Listener {
}
// Cached offline match.
PlayerProfile cachedResult = lookupCache.getIfPresent(name);
if (cachedResult != null && cachedResult.getUniqueId() != null) {
player = Bukkit.getOfflinePlayer(cachedResult.getUniqueId());
Profile cachedResult = lookupCache.getIfPresent(name);
if (cachedResult != null) {
player = Bukkit.getOfflinePlayer(cachedResult.id());
// Ensure player is an existing player.
if (player.hasPlayedBefore() || player.isOnline()) {
return player;
@@ -135,10 +150,15 @@ public class PlayerLoader implements Listener {
}
// Exact offline match second - ensure offline access works when matchable users are online.
player = Bukkit.getServer().getOfflinePlayer(name);
Profile profile = profileStore.getProfileExact(name);
if (profile == null) {
return null;
}
player = Bukkit.getOfflinePlayer(profile.id());
if (player.hasPlayedBefore()) {
lookupCache.put(name, player.getPlayerProfile());
lookupCache.put(name, profile);
return player;
}
@@ -160,40 +180,35 @@ public class PlayerLoader implements Listener {
}
// Finally, inexact offline match.
float bestMatch = 0;
for (OfflinePlayer offline : Bukkit.getServer().getOfflinePlayers()) {
if (offline.getName() == null) {
// Loaded by UUID only, name has never been looked up.
continue;
}
Profile profile = getProfileStore().getProfileInexact(name);
float currentMatch = StringMetric.compareJaroWinkler(name, offline.getName());
if (currentMatch == 1.0F) {
return offline;
}
if (currentMatch > bestMatch) {
bestMatch = currentMatch;
player = offline;
}
if (profile == null) {
// No match found.
return null;
}
if (player != null) {
// If a match was found, store it.
lookupCache.put(name, player.getPlayerProfile());
// Get associated player and store match.
player = Bukkit.getOfflinePlayer(profile.id());
if (player.hasPlayedBefore()) {
lookupCache.put(name, profile);
return player;
}
// No players have ever joined the server.
return null;
}
@Keep
@EventHandler
private void onPlayerJoin(@NotNull PlayerJoinEvent event) {
plugin.getScheduler().runTaskLaterAsynchronously(() -> updateMatches(event), 7L);
}
private void updateMatches(@NotNull PlayerJoinEvent event) {
// Update profile store.
profileStore.addProfile(new Profile(event.getPlayer()));
// If player is not new, any cached values are valid.
if (event.getPlayer().hasPlayedBefore()) {
if (event.getPlayer().hasPlayedBefore() || lookupCache.size() == 0) {
return;
}
@@ -201,36 +216,19 @@ public class PlayerLoader implements Listener {
String name = event.getPlayer().getName();
lookupCache.invalidate(name);
// If the cache is empty, nothing to do. Don't hit scheduler.
if (lookupCache.size() == 0) {
return;
Iterator<Map.Entry<String, Profile>> iterator = lookupCache.asMap().entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Profile> entry = iterator.next();
String oldMatch = entry.getValue().name();
String lookup = entry.getKey();
float oldMatchScore = StringMetric.compareJaroWinkler(lookup, oldMatch);
float newMatchScore = StringMetric.compareJaroWinkler(lookup, name);
// If new match exceeds old match, delete old match.
if (newMatchScore > oldMatchScore) {
iterator.remove();
}
}
plugin.getScheduler().runTaskLaterAsynchronously(
() -> {
Iterator<Map.Entry<String, PlayerProfile>> iterator = lookupCache.asMap().entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, PlayerProfile> entry = iterator.next();
String oldMatch = entry.getValue().getName();
// Shouldn't be possible - all profiles should be complete.
if (oldMatch == null) {
iterator.remove();
continue;
}
String lookup = entry.getKey();
float oldMatchScore = StringMetric.compareJaroWinkler(lookup, oldMatch);
float newMatchScore = StringMetric.compareJaroWinkler(lookup, name);
// If new match exceeds old match, delete old match.
if (newMatchScore > oldMatchScore) {
iterator.remove();
}
}
},
7L
);
}
}
@@ -0,0 +1,78 @@
package com.lishid.openinv.util.profile;
import com.github.jikoo.planarwrappers.scheduler.TickTimeUnit;
import me.nahu.scheduler.wrapper.WrappedJavaPlugin;
import me.nahu.scheduler.wrapper.runnable.WrappedRunnable;
import org.jetbrains.annotations.NotNull;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public abstract class BatchProfileStore implements ProfileStore {
private final Set<Profile> pending = Collections.synchronizedSet(new HashSet<>());
private final AtomicReference<WrappedRunnable> insertTask = new AtomicReference<>();
protected final @NotNull WrappedJavaPlugin plugin;
protected BatchProfileStore(@NotNull WrappedJavaPlugin plugin) {
this.plugin = plugin;
}
@Override
public void addProfile(@NotNull Profile profile) {
pending.add(profile);
buildBatch();
}
private void buildBatch() {
if (insertTask.compareAndSet(null, new WrappedRunnable() {
@Override
public void run() {
pushBatch();
}
})) {
try {
// Wait 5 seconds to accumulate other player data to reduce scheduler load on larger servers.
insertTask.get().runTaskLaterAsynchronously(plugin, TickTimeUnit.toTicks(5, TimeUnit.SECONDS));
} catch (IllegalStateException e) {
// If scheduling task fails, server is most likely shutting down.
insertTask.set(null);
}
}
}
private void pushBatch() {
Set<Profile> batch = new HashSet<>(pending);
// This is a bit roundabout but removes the risk of data loss.
pending.removeAll(batch);
// Push current batch.
pushBatch(batch);
WrappedRunnable running = insertTask.getAndSet(null);
if (running != null) {
running.cancel();
}
// If more profiles have been added, build another batch.
if (!pending.isEmpty()) {
buildBatch();
}
}
@Override
public void shutdown() {
WrappedRunnable wrappedRunnable = insertTask.get();
if (wrappedRunnable != null) {
wrappedRunnable.cancel();
}
pushBatch();
insertTask.set(null);
}
protected abstract void pushBatch(@NotNull Set<Profile> batch);
}
@@ -0,0 +1,48 @@
package com.lishid.openinv.util.profile;
import me.nahu.scheduler.wrapper.runnable.WrappedRunnable;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.jetbrains.annotations.NotNull;
import java.util.HashSet;
import java.util.Set;
public abstract class OfflinePlayerImporter extends WrappedRunnable {
private final @NotNull BatchProfileStore profileStore;
private final int batchSize;
public OfflinePlayerImporter(@NotNull BatchProfileStore profileStore, int batchSize) {
this.profileStore = profileStore;
this.batchSize = batchSize;
}
@Override
public void run() {
Set<Profile> batch = new HashSet<>();
for (OfflinePlayer offline : Bukkit.getOfflinePlayers()) {
String name = offline.getName();
if (name == null) {
continue;
}
batch.add(new Profile(name, offline.getUniqueId()));
if (batch.size() >= batchSize) {
profileStore.pushBatch(batch);
batch.clear();
}
}
if (!batch.isEmpty()) {
profileStore.pushBatch(batch);
}
onComplete();
}
public abstract void onComplete();
}
@@ -0,0 +1,86 @@
package com.lishid.openinv.util.profile;
import com.lishid.openinv.util.StringMetric;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.logging.Logger;
public class OfflinePlayerProfileStore implements ProfileStore {
private final @NotNull Logger logger;
public OfflinePlayerProfileStore(@NotNull Logger logger) {
this.logger = logger;
}
@Override
public void addProfile(@NotNull Profile profile) {
// No-op. Server handles profile creation and storage.
}
@Override
public void setup() {
// No-op.
}
@Override
public void shutdown() {
// No-op. Nothing to push.
}
@Override
public void tryImport() {
// No-op.
}
@Override
public @Nullable Profile getProfileExact(@NotNull String name) {
ProfileStore.warnMainThread(logger);
@SuppressWarnings("deprecation")
OfflinePlayer offline = Bukkit.getOfflinePlayer(name);
if (!offline.hasPlayedBefore()) {
return null;
}
String realName = offline.getName();
if (realName == null) {
realName = name;
}
return new Profile(realName, offline.getUniqueId());
}
@Override
public @Nullable Profile getProfileInexact(@NotNull String search) {
ProfileStore.warnMainThread(logger);
float bestMatch = 0.0F;
Profile bestProfile = null;
for (OfflinePlayer player : Bukkit.getOfflinePlayers()) {
String name = player.getName();
if (name == null) {
// Discount UUID-only profiles; Direct UUID lookup should already be done.
return null;
}
float currentMatch = StringMetric.compareJaroWinkler(name, name);
if (currentMatch > bestMatch) {
bestMatch = currentMatch;
bestProfile = new Profile(name, player.getUniqueId());
}
// A score of 1 is an exact match. Don't bother checking the rest.
if (currentMatch == 1.0F) {
break;
}
}
return bestProfile;
}
}
@@ -0,0 +1,20 @@
package com.lishid.openinv.util.profile;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
public record Profile(@NotNull String name, @NotNull UUID id) {
public Profile(@NotNull Player player) {
this(player.getName(), player.getUniqueId());
}
@Override
public int hashCode() {
// As names are the unique key, profiles with the same name should collide.
return name.hashCode();
}
}
@@ -0,0 +1,51 @@
package com.lishid.openinv.util.profile;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.logging.Level;
import java.util.logging.Logger;
public interface ProfileStore {
void addProfile(@NotNull Profile profile);
void setup() throws Exception;
void shutdown() throws Exception;
void tryImport() throws Exception;
@Nullable Profile getProfileExact(@NotNull String name);
@Nullable Profile getProfileInexact(@NotNull String search);
static void warnMainThread(@NotNull Logger logger) {
if (!Bukkit.getServer().isPrimaryThread()) {
return;
}
Throwable throwable = new Throwable("Current stack trace");
StackTraceElement[] stackTrace = throwable.getStackTrace();
if (stackTrace.length < 2) {
// Not possible.
return;
}
StackTraceElement caller = stackTrace[1];
logger.warning(() ->
String.format(
"Call to %s#%s made on the main thread!",
caller.getClassName(),
caller.getMethodName()
)
);
logger.warning("This can cause the server to hang, potentially severely.");
logger.log(Level.WARNING, "Current stack trace", throwable);
}
}
@@ -0,0 +1,21 @@
package com.lishid.openinv.util.profile.sqlite;
import com.lishid.openinv.util.StringMetric;
import org.sqlite.Function;
import java.sql.SQLException;
public class JaroWinklerFunction extends Function {
@Override
protected void xFunc() throws SQLException {
if (args() != 2) {
throw new SQLException("JaroWinkler(str, str) requires 2 arguments but got " + args());
}
String val1 = value_text(0);
String val2 = value_text(1);
result(StringMetric.compareJaroWinkler(val1 == null ? "" : val1, val2 == null ? "" : val2));
}
}
@@ -0,0 +1,257 @@
package com.lishid.openinv.util.profile.sqlite;
import com.github.jikoo.planarwrappers.function.ThrowingFunction;
import com.lishid.openinv.util.profile.BatchProfileStore;
import com.lishid.openinv.util.profile.OfflinePlayerImporter;
import com.lishid.openinv.util.profile.Profile;
import me.nahu.scheduler.wrapper.WrappedJavaPlugin;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.sqlite.Function;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Iterator;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
public class SqliteProfileStore extends BatchProfileStore {
private static final int BATCH_SIZE = 1_000;
private static final int MAX_BATCHES = 20;
private Connection connection;
public SqliteProfileStore(@NotNull WrappedJavaPlugin plugin) {
super(plugin);
}
@Override
public void setup() throws Exception {
// Touch implementation to ensure it is available, then create connection.
Class.forName("org.sqlite.JDBC");
connection = DriverManager.getConnection("jdbc:sqlite:" + plugin.getDataFolder().getAbsolutePath() + "/profiles.db");
try (Statement statement = connection.createStatement()) {
// Create main profile table.
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS profiles(
name TEXT PRIMARY KEY,
uuid_least INTEGER NOT NULL,
uuid_most INTEGER NOT NULL,
last_update TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) WITHOUT ROWID
"""
);
// Create meta table.
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS openinv_meta(
name TEXT PRIMARY KEY,
value INTEGER NOT NULL
)
"""
);
}
// Add JaroWinkler function to the connection.
Function.create(connection, "JaroWinkler", new JaroWinklerFunction());
}
@Override
public void shutdown() {
super.shutdown();
try {
if (!connection.getAutoCommit()) {
connection.commit();
}
connection.close();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "Exception closing database: " + e.getMessage(), e);
}
}
@Override
public void tryImport() throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT value FROM openinv_meta WHERE name = 'imported'"
)) {
ResultSet resultSet = statement.executeQuery();
if (resultSet.next() && resultSet.getInt("value") == 1) {
return;
}
}
plugin.getLogger().info("Indexing player names.");
new OfflinePlayerImporter(this, BATCH_SIZE) {
@Override
public void onComplete() {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO openinv_meta(name, value) VALUES ('imported', 1)"
)) {
statement.executeUpdate();
plugin.getLogger().info("Indexed player names successfully.");
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "Exception marking import complete: " + e.getMessage(), e);
}
}
}.runTaskAsynchronously(plugin);
}
@Override
protected void pushBatch(@NotNull Set<Profile> batch) {
if (batch.isEmpty()) {
return;
}
int batchSize = Math.min(batch.size(), BATCH_SIZE);
int rem = batch.size() % batchSize;
Iterator<Profile> iterator = batch.iterator();
try {
boolean autoCommit = connection.getAutoCommit();
if (autoCommit) {
connection.setAutoCommit(false);
}
pushBatch(iterator, batch.size(), batchSize);
if (rem > 0) {
pushBatch(iterator, rem, rem);
}
connection.commit();
if (autoCommit) {
connection.setAutoCommit(true);
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "Encountered an exception updating profiles", e);
}
}
private void pushBatch(Iterator<Profile> iterator, int iteratorSize, int batchSize) throws SQLException {
try (PreparedStatement upsert = createBulkUpsertProfile(batchSize)) {
for (int batchIndex = 0; batchIndex < iteratorSize / batchSize; ++batchIndex) {
for (int entryIndex = 0; entryIndex < batchSize; ++entryIndex) {
int startIndex = entryIndex * 3;
Profile profile = iterator.next();
upsert.setString(startIndex + 1, profile.name());
upsert.setLong(startIndex + 2, profile.id().getLeastSignificantBits());
upsert.setLong(startIndex + 3, profile.id().getMostSignificantBits());
}
upsert.addBatch();
// If we're at the maximum number of batches allowed, commit.
if ((batchIndex + 1) % MAX_BATCHES == 0) {
upsert.executeBatch();
connection.commit();
}
}
upsert.executeBatch();
}
}
private @NotNull PreparedStatement createBulkUpsertProfile(int count) throws SQLException {
return connection.prepareStatement(
"""
INSERT INTO profiles(name, uuid_least, uuid_most)
VALUES (?, ?, ?)
""" + ", (?, ?, ?)".repeat(count - 1) +
"""
ON CONFLICT(name) DO UPDATE SET
uuid_least=excluded.uuid_least,
uuid_most=excluded.uuid_most,
last_update=CURRENT_TIMESTAMP
"""
);
}
@Override
public @Nullable Profile getProfileExact(@NotNull String name) {
return getProfile(
name,
text -> {
PreparedStatement statement = connection.prepareStatement(
"SELECT name, uuid_least, uuid_most FROM `profiles` WHERE name = ?"
);
statement.setString(1, text);
return statement;
}
);
}
@Override
public @Nullable Profile getProfileInexact(@NotNull String search) {
return getProfile(
search,
text -> {
PreparedStatement statement = connection.prepareStatement(
"""
SELECT name, uuid_least, uuid_most FROM `profiles`
WHERE name LIKE ?
ORDER BY JaroWinkler(?, name) DESC
LIMIT 1
"""
);
statement.setString(1, getLikePrefix(text));
statement.setString(2, text);
return statement;
}
);
}
private @NotNull String getLikePrefix(@NotNull String search) {
StringBuilder prefix = new StringBuilder();
int searchLen = search.length();
if (searchLen > 0) {
char first = search.charAt(0);
// Add first character of the search to LIKE prefix.
prefix.append(first);
// If the first character appears to be Geyser-like, append another.
if (searchLen > 1 && (first == '.' || first == '*')) {
prefix.append(search.charAt(1));
}
}
// Add wildcard.
prefix.append('%');
return prefix.toString();
}
private @Nullable Profile getProfile(
@NotNull String text,
@NotNull ThrowingFunction<String, PreparedStatement, SQLException> create
) {
try (PreparedStatement statement = create.apply(text)) {
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
// Fetch profile data.
String name = resultSet.getString(1);
// Ignore illegal data.
if (name == null || name.isEmpty()) {
return null;
}
UUID uuid = new UUID(resultSet.getLong(3), resultSet.getLong(2));
return new Profile(name, uuid);
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "Encountered an exception retrieving profile", e);
}
return null;
}
}
+13 -2
View File
@@ -7,6 +7,9 @@ description: Open a player's inventory as a chest and interact with it in real t
api-version: "1.13"
folia-supported: true
libraries:
- "org.xerial:sqlite-jdbc:${sqlite}"
permissions:
openinv:
@@ -76,8 +79,16 @@ permissions:
# Container features
openinv.container:
children:
openinv.container.any: true
openinv.container.silent: true
openinv.container.any:
children:
openinv.container.any.use: true
openinv.container.any.use:
default: false
openinv.container.silent:
children:
openinv.container.silent.use: true
openinv.container.silent.use:
default: false
# Search functionality
openinv.search:
children:
-58
View File
@@ -1,58 +0,0 @@
#!/bin/bash
#
# Copyright (C) 2011-2021 lishid. All rights reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Note that this script is designed for use in GitHub Actions, and is not
# particularly robust nor configurable. Run from project parent directory.
if [[ ! $1 ]]; then
echo "No changelog, no Minecraft versions."
exit 0
fi
# Find line declaring Paper versions.
raw=$(grep "**Paper:**" <<< "$1")
# Enable extended glob pattern to match 0 or more whitespace characters.
shopt -s extglob
# Trim Paper versions identifier prefix.
raw=${raw##*([[:space:]])'**'Paper:'**'*([[:space:]])}
# Replace commas and optional spaces with a newline.
raw=${raw//,*([[:space:]])/$'\n'}
# Turn extglob back off.
shopt -u extglob
# Split into an array on newlines.
readarray -td $'\n' versions <<< "${raw}"
for version in "${versions[@]}"; do
# Parse Minecraft minor version by dropping everything from the second period onward.
# CurseForge doesn't usually add patch versions for Bukkit, so we're more likely to
# hit a supported identifier this way.
version="${version%[.-]"${version#*.*[.-]}"}"
# Skip already listed versions
if [[ "$minecraft_versions" =~ "$version"($|,) ]]; then
continue
fi
# Append comma if variable is set, then append version.
# Note that Minecraft versions on CurseForge are declared "Minecraft x.y.z"
minecraft_versions="${minecraft_versions:+${minecraft_versions},}Minecraft ${version}"
done
printf "$minecraft_versions\n"
#echo "CURSEFORGE_MINECRAFT_VERSIONS=$minecraft_versions" >> "$GITHUB_ENV"