mirror of
https://github.com/plexusorg/Module-HTTPD.git
synced 2026-06-04 00:56:54 +00:00
fuck it we're rendering blocks using webgl
This commit is contained in:
@@ -3,6 +3,10 @@
|
|||||||
*.iml
|
*.iml
|
||||||
/target/
|
/target/
|
||||||
/src/main/resources/build.properties
|
/src/main/resources/build.properties
|
||||||
|
/src/main/resources/httpd/assets/textures/
|
||||||
|
/src/main/resources/httpd/assets/models/
|
||||||
|
/src/main/resources/httpd/assets/items/
|
||||||
|
/src/main/resources/httpd/assets/.minecraft-version
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+9
-1
@@ -35,7 +35,7 @@ dependencies {
|
|||||||
plexLibrary("org.eclipse.jetty:jetty-server:12.1.9")
|
plexLibrary("org.eclipse.jetty:jetty-server:12.1.9")
|
||||||
plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9")
|
plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9")
|
||||||
plexLibrary("org.eclipse.jetty:jetty-proxy:12.1.9")
|
plexLibrary("org.eclipse.jetty:jetty-proxy:12.1.9")
|
||||||
plexLibrary("de.tr7zw:item-nbt-api:2.15.7")
|
implementation("de.tr7zw:item-nbt-api:2.15.7")
|
||||||
implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom
|
implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom
|
||||||
compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core")
|
compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core")
|
||||||
implementation("commons-io:commons-io:2.22.0")
|
implementation("commons-io:commons-io:2.22.0")
|
||||||
@@ -47,6 +47,10 @@ tasks.getByName<Jar>("jar") {
|
|||||||
archiveVersion.set("")
|
archiveVersion.set("")
|
||||||
from("src/main/resources") {
|
from("src/main/resources") {
|
||||||
exclude("dev/**")
|
exclude("dev/**")
|
||||||
|
exclude("httpd/assets/textures/**")
|
||||||
|
exclude("httpd/assets/models/**")
|
||||||
|
exclude("httpd/assets/items/**")
|
||||||
|
exclude("httpd/assets/.minecraft-version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +67,10 @@ tasks {
|
|||||||
}
|
}
|
||||||
processResources {
|
processResources {
|
||||||
filteringCharset = Charsets.UTF_8.name()
|
filteringCharset = Charsets.UTF_8.name()
|
||||||
|
exclude("httpd/assets/textures/**")
|
||||||
|
exclude("httpd/assets/models/**")
|
||||||
|
exclude("httpd/assets/items/**")
|
||||||
|
exclude("httpd/assets/.minecraft-version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
param(
|
||||||
|
[string]$Version = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Downloads the vanilla Minecraft assets used by the HTTPD live inventory view
|
||||||
|
# into src/main/resources/httpd/assets for local development.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/download-minecraft-assets.ps1 # latest release
|
||||||
|
# ./scripts/download-minecraft-assets.ps1 1.21.10 # specific version
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||||
|
$AssetRoot = Join-Path $ProjectRoot "src/main/resources/httpd/assets"
|
||||||
|
$ManifestUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||||
|
|
||||||
|
$manifest = Invoke-RestMethod -Uri $ManifestUrl -TimeoutSec 30
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
$Version = $manifest.latest.release
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionEntry = $manifest.versions | Where-Object { $_.id -eq $Version } | Select-Object -First 1
|
||||||
|
if ($null -eq $versionEntry) {
|
||||||
|
throw "Minecraft version '$Version' was not found in Mojang's manifest"
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionJson = Invoke-RestMethod -Uri $versionEntry.url -TimeoutSec 30
|
||||||
|
$clientUrl = $versionJson.downloads.client.url
|
||||||
|
|
||||||
|
Write-Host "Downloading Minecraft $Version client assets..."
|
||||||
|
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("plex-httpd-assets-" + [Guid]::NewGuid())
|
||||||
|
New-Item -ItemType Directory -Path $tempDir | Out-Null
|
||||||
|
$jarPath = Join-Path $tempDir "minecraft-$Version.jar"
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $clientUrl -OutFile $jarPath -TimeoutSec 300
|
||||||
|
|
||||||
|
foreach ($directory in @("textures", "models", "items")) {
|
||||||
|
$path = Join-Path $AssetRoot $directory
|
||||||
|
if (Test-Path $path) {
|
||||||
|
Remove-Item -Recurse -Force $path
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead($jarPath)
|
||||||
|
$extracted = 0
|
||||||
|
try {
|
||||||
|
foreach ($entry in $zip.Entries) {
|
||||||
|
if ([string]::IsNullOrEmpty($entry.Name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $entry.FullName -replace '\\', '/'
|
||||||
|
$wanted = $name.StartsWith("assets/minecraft/textures/item/") -or
|
||||||
|
$name.StartsWith("assets/minecraft/textures/block/") -or
|
||||||
|
($name -eq "assets/minecraft/textures/entity/shield/shield_base_nopattern.png") -or
|
||||||
|
$name.StartsWith("assets/minecraft/models/item/") -or
|
||||||
|
$name.StartsWith("assets/minecraft/models/block/") -or
|
||||||
|
$name.StartsWith("assets/minecraft/items/")
|
||||||
|
if (-not $wanted) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = $name.Substring("assets/minecraft/".Length)
|
||||||
|
$target = Join-Path $AssetRoot ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||||
|
$targetParent = Split-Path -Parent $target
|
||||||
|
New-Item -ItemType Directory -Path $targetParent -Force | Out-Null
|
||||||
|
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true)
|
||||||
|
$extracted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Content -Path (Join-Path $AssetRoot ".minecraft-version") -Value $Version -Encoding UTF8
|
||||||
|
Write-Host "Extracted $extracted files to $AssetRoot"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path $tempDir) {
|
||||||
|
Remove-Item -Recurse -Force $tempDir
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Downloads the vanilla Minecraft assets used by the HTTPD live inventory view
|
||||||
|
# into src/main/resources/httpd/assets for local development.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/download-minecraft-assets.sh # latest release
|
||||||
|
# ./scripts/download-minecraft-assets.sh 1.21.10 # specific version
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
PROJECT_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
ASSET_ROOT="$PROJECT_ROOT/src/main/resources/httpd/assets"
|
||||||
|
|
||||||
|
python3 - "$VERSION" "$ASSET_ROOT" <<'PY'
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
version_arg = sys.argv[1].strip()
|
||||||
|
asset_root = Path(sys.argv[2])
|
||||||
|
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_json(url):
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
manifest = get_json(manifest_url)
|
||||||
|
version = version_arg or manifest["latest"]["release"]
|
||||||
|
version_entry = next((v for v in manifest["versions"] if v["id"] == version), None)
|
||||||
|
if version_entry is None:
|
||||||
|
raise SystemExit(f"Minecraft version {version!r} was not found in Mojang's manifest")
|
||||||
|
|
||||||
|
version_json = get_json(version_entry["url"])
|
||||||
|
client_url = version_json["downloads"]["client"]["url"]
|
||||||
|
|
||||||
|
print(f"Downloading Minecraft {version} client assets...")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
jar_path = Path(tmp) / f"minecraft-{version}.jar"
|
||||||
|
with urllib.request.urlopen(client_url, timeout=300) as response, jar_path.open("wb") as out:
|
||||||
|
shutil.copyfileobj(response, out)
|
||||||
|
|
||||||
|
for directory in ("textures", "models", "items"):
|
||||||
|
shutil.rmtree(asset_root / directory, ignore_errors=True)
|
||||||
|
(asset_root / directory).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
prefixes = (
|
||||||
|
"assets/minecraft/textures/item/",
|
||||||
|
"assets/minecraft/textures/block/",
|
||||||
|
"assets/minecraft/textures/entity/shield/shield_base_nopattern.png",
|
||||||
|
"assets/minecraft/models/item/",
|
||||||
|
"assets/minecraft/models/block/",
|
||||||
|
"assets/minecraft/items/",
|
||||||
|
)
|
||||||
|
|
||||||
|
extracted = 0
|
||||||
|
with zipfile.ZipFile(jar_path) as jar:
|
||||||
|
for info in jar.infolist():
|
||||||
|
if info.is_dir() or not info.filename.startswith(prefixes):
|
||||||
|
continue
|
||||||
|
relative = info.filename[len("assets/minecraft/"):]
|
||||||
|
target = asset_root / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with jar.open(info) as source, target.open("wb") as out:
|
||||||
|
shutil.copyfileobj(source, out)
|
||||||
|
extracted += 1
|
||||||
|
|
||||||
|
(asset_root / ".minecraft-version").write_text(version + "\n", encoding="utf-8")
|
||||||
|
print(f"Extracted {extracted} files to {asset_root}")
|
||||||
|
PY
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.plex;
|
package dev.plex;
|
||||||
|
|
||||||
|
import dev.plex.assets.MinecraftAssetsManager;
|
||||||
import dev.plex.authentication.AuthenticationManager;
|
import dev.plex.authentication.AuthenticationManager;
|
||||||
import dev.plex.cache.FileCache;
|
import dev.plex.cache.FileCache;
|
||||||
import dev.plex.config.ModuleConfig;
|
import dev.plex.config.ModuleConfig;
|
||||||
@@ -48,6 +49,9 @@ public class HTTPDModule extends PlexModule
|
|||||||
@Getter
|
@Getter
|
||||||
private static File accessLogFile;
|
private static File accessLogFile;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private static MinecraftAssetsManager minecraftAssetsManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void load()
|
public void load()
|
||||||
{
|
{
|
||||||
@@ -63,6 +67,9 @@ public class HTTPDModule extends PlexModule
|
|||||||
|
|
||||||
accessLogFile = new File(getDataFolder(), moduleConfig.getString("server.logging.file-path", "httpd.log"));
|
accessLogFile = new File(getDataFolder(), moduleConfig.getString("server.logging.file-path", "httpd.log"));
|
||||||
|
|
||||||
|
minecraftAssetsManager = new MinecraftAssetsManager(getDataFolder().toPath());
|
||||||
|
minecraftAssetsManager.refreshAsync();
|
||||||
|
|
||||||
authenticationManager = new AuthenticationManager();
|
authenticationManager = new AuthenticationManager();
|
||||||
if (authenticationManager.provider() == null)
|
if (authenticationManager.provider() == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package dev.plex.assets;
|
||||||
|
|
||||||
|
import dev.plex.util.PlexLog;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
|
||||||
|
public class MinecraftAssetsManager
|
||||||
|
{
|
||||||
|
private static final URI VERSION_MANIFEST = URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
|
||||||
|
private static final Pattern MINECRAFT_VERSION = Pattern.compile("\\d+\\.\\d+(?:\\.\\d+)?(?:-(?:pre|rc)\\d+)?");
|
||||||
|
private static final Pattern VERSION_STRING_MC = Pattern.compile("\\(MC: ([^)]+)\\)");
|
||||||
|
|
||||||
|
private final Path root;
|
||||||
|
private final Path versionFile;
|
||||||
|
private final HttpClient client;
|
||||||
|
private final AtomicBoolean ready = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean refreshStarted = new AtomicBoolean(false);
|
||||||
|
private final String minecraftVersion;
|
||||||
|
|
||||||
|
public MinecraftAssetsManager(Path dataFolder)
|
||||||
|
{
|
||||||
|
this.root = dataFolder.resolve("minecraft-assets");
|
||||||
|
this.versionFile = root.resolve("version.txt");
|
||||||
|
this.minecraftVersion = detectMinecraftVersion();
|
||||||
|
this.client = HttpClient.newBuilder()
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshAsync()
|
||||||
|
{
|
||||||
|
if (!refreshStarted.compareAndSet(false, true))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompletableFuture.runAsync(() ->
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
refreshIfNeeded();
|
||||||
|
ready.set(true);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
PlexLog.log("Unable to download Minecraft assets for HTTPD inventory view: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolve(String category, String relativePath)
|
||||||
|
{
|
||||||
|
if (!ready.get())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path file = root.resolve(category).resolve(relativePath).normalize();
|
||||||
|
Path categoryRoot = root.resolve(category).normalize();
|
||||||
|
if (!file.startsWith(categoryRoot) || !Files.isRegularFile(file))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshIfNeeded() throws IOException, InterruptedException
|
||||||
|
{
|
||||||
|
Files.createDirectories(root);
|
||||||
|
String cachedVersion = Files.exists(versionFile) ? Files.readString(versionFile).trim() : "";
|
||||||
|
if (minecraftVersion.equals(cachedVersion) && hasAssets())
|
||||||
|
{
|
||||||
|
PlexLog.debug("HTTPD Minecraft assets are already cached for {0}", minecraftVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cachedVersion.isEmpty() && !minecraftVersion.equals(cachedVersion))
|
||||||
|
{
|
||||||
|
PlexLog.log("Minecraft version changed from " + cachedVersion + " to " + minecraftVersion + "; recreating HTTPD asset cache");
|
||||||
|
}
|
||||||
|
recreateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAssets()
|
||||||
|
{
|
||||||
|
return Files.isDirectory(root.resolve("textures"))
|
||||||
|
&& Files.isDirectory(root.resolve("models"))
|
||||||
|
&& Files.isDirectory(root.resolve("items"))
|
||||||
|
&& Files.isRegularFile(root.resolve("textures/entity/shield/shield_base_nopattern.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recreateCache() throws IOException, InterruptedException
|
||||||
|
{
|
||||||
|
deleteDirectory(root);
|
||||||
|
Files.createDirectories(root);
|
||||||
|
|
||||||
|
PlexLog.log("Downloading Minecraft " + minecraftVersion + " client assets for HTTPD inventory view");
|
||||||
|
JSONObject version = findVersionJson();
|
||||||
|
String clientUrl = version.getJSONObject("downloads").getJSONObject("client").getString("url");
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(clientUrl))
|
||||||
|
.timeout(Duration.ofMinutes(5))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
if (response.statusCode() / 100 != 2)
|
||||||
|
{
|
||||||
|
throw new IOException("client jar download returned HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream in = response.body(); ZipInputStream zip = new ZipInputStream(in))
|
||||||
|
{
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zip.getNextEntry()) != null)
|
||||||
|
{
|
||||||
|
if (!entry.isDirectory())
|
||||||
|
{
|
||||||
|
copyAsset(entry.getName(), zip);
|
||||||
|
}
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.writeString(versionFile, minecraftVersion + System.lineSeparator());
|
||||||
|
PlexLog.log("HTTPD Minecraft assets cached for " + minecraftVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject findVersionJson() throws IOException, InterruptedException
|
||||||
|
{
|
||||||
|
JSONObject manifest = getJson(VERSION_MANIFEST);
|
||||||
|
JSONArray versions = manifest.getJSONArray("versions");
|
||||||
|
for (int i = 0; i < versions.length(); i++)
|
||||||
|
{
|
||||||
|
JSONObject version = versions.getJSONObject(i);
|
||||||
|
if (minecraftVersion.equals(version.getString("id")))
|
||||||
|
{
|
||||||
|
return getJson(URI.create(version.getString("url")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IOException("Minecraft version " + minecraftVersion + " was not found in Mojang's manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject getJson(URI uri) throws IOException, InterruptedException
|
||||||
|
{
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(uri)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() / 100 != 2)
|
||||||
|
{
|
||||||
|
throw new IOException(uri + " returned HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
return new JSONObject(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyAsset(String name, ZipInputStream zip) throws IOException
|
||||||
|
{
|
||||||
|
String prefix = "assets/minecraft/";
|
||||||
|
if (!name.startsWith(prefix))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String path = name.substring(prefix.length());
|
||||||
|
Path target = null;
|
||||||
|
|
||||||
|
if (path.startsWith("textures/item/") || path.startsWith("textures/block/") || path.equals("textures/entity/shield/shield_base_nopattern.png"))
|
||||||
|
{
|
||||||
|
target = root.resolve(path);
|
||||||
|
}
|
||||||
|
else if (path.startsWith("models/item/") || path.startsWith("models/block/"))
|
||||||
|
{
|
||||||
|
target = root.resolve(path);
|
||||||
|
}
|
||||||
|
else if (path.startsWith("items/"))
|
||||||
|
{
|
||||||
|
target = root.resolve(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Files.createDirectories(target.getParent());
|
||||||
|
Files.copy(zip, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String detectMinecraftVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Object version = Bukkit.class.getMethod("getMinecraftVersion").invoke(null);
|
||||||
|
if (version instanceof String stringVersion && isMinecraftVersion(stringVersion))
|
||||||
|
{
|
||||||
|
return stringVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ReflectiveOperationException ignored)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher versionMatcher = VERSION_STRING_MC.matcher(Bukkit.getVersion());
|
||||||
|
if (versionMatcher.find() && isMinecraftVersion(versionMatcher.group(1)))
|
||||||
|
{
|
||||||
|
return versionMatcher.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String bukkitVersion = Bukkit.getBukkitVersion();
|
||||||
|
int dash = bukkitVersion.indexOf('-');
|
||||||
|
String trimmed = dash == -1 ? bukkitVersion : bukkitVersion.substring(0, dash);
|
||||||
|
if (isMinecraftVersion(trimmed))
|
||||||
|
{
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException("Could not determine Minecraft version from Bukkit version strings: getMinecraftVersion unavailable, getVersion='"
|
||||||
|
+ Bukkit.getVersion() + "', getBukkitVersion='" + bukkitVersion + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isMinecraftVersion(String version)
|
||||||
|
{
|
||||||
|
return version != null && MINECRAFT_VERSION.matcher(version).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteDirectory(Path path) throws IOException
|
||||||
|
{
|
||||||
|
if (!Files.exists(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (var stream = Files.walk(path))
|
||||||
|
{
|
||||||
|
for (Path file : stream.sorted(Comparator.reverseOrder()).toList())
|
||||||
|
{
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ public class RateLimitFilter implements Filter
|
|||||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||||
|
|
||||||
|
if (isExempt(httpRequest))
|
||||||
|
{
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!globalBucket.tryConsume())
|
if (!globalBucket.tryConsume())
|
||||||
{
|
{
|
||||||
reject(httpRequest, httpResponse, globalBucket.retryAfterSeconds(), "global");
|
reject(httpRequest, httpResponse, globalBucket.retryAfterSeconds(), "global");
|
||||||
@@ -72,6 +78,17 @@ public class RateLimitFilter implements Filter
|
|||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isExempt(HttpServletRequest request)
|
||||||
|
{
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
if (path == null) return false;
|
||||||
|
|
||||||
|
// Inventory rendering can legitimately burst dozens of item/model/texture
|
||||||
|
// requests at once when a staff member opens a full inventory. These are
|
||||||
|
// static/cacheable files and should not spend the interactive API budget.
|
||||||
|
return "GET".equalsIgnoreCase(request.getMethod()) && path.startsWith("/assets/");
|
||||||
|
}
|
||||||
|
|
||||||
private TokenBucket bucketFor(String ip)
|
private TokenBucket bucketFor(String ip)
|
||||||
{
|
{
|
||||||
maybeEvict();
|
maybeEvict();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@@ -27,6 +28,7 @@ public class PlayerActionServlet extends HttpServlet
|
|||||||
private static final long FAR_FUTURE_DAYS = 365L * 50L;
|
private static final long FAR_FUTURE_DAYS = 365L * 50L;
|
||||||
private static final List<String> PERMANENT_ACTIONS = List.of("ban", "mute");
|
private static final List<String> PERMANENT_ACTIONS = List.of("ban", "mute");
|
||||||
private static final List<String> TEMP_ACTIONS = List.of("tempban", "tempmute", "freeze");
|
private static final List<String> TEMP_ACTIONS = List.of("tempban", "tempmute", "freeze");
|
||||||
|
private static final List<String> INVENTORY_ACTIONS = List.of("clear-inventory", "clear-selected");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doPost(HttpServletRequest request, HttpServletResponse response)
|
protected void doPost(HttpServletRequest request, HttpServletResponse response)
|
||||||
@@ -44,6 +46,7 @@ public class PlayerActionServlet extends HttpServlet
|
|||||||
String action = request.getParameter("action");
|
String action = request.getParameter("action");
|
||||||
String reason = request.getParameter("reason");
|
String reason = request.getParameter("reason");
|
||||||
String durationStr = request.getParameter("duration");
|
String durationStr = request.getParameter("duration");
|
||||||
|
String slot = request.getParameter("slot");
|
||||||
|
|
||||||
if (uuidStr == null || action == null)
|
if (uuidStr == null || action == null)
|
||||||
{
|
{
|
||||||
@@ -51,7 +54,7 @@ public class PlayerActionServlet extends HttpServlet
|
|||||||
response.getWriter().write("Missing parameters.");
|
response.getWriter().write("Missing parameters.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!PERMANENT_ACTIONS.contains(action) && !TEMP_ACTIONS.contains(action))
|
if (!PERMANENT_ACTIONS.contains(action) && !TEMP_ACTIONS.contains(action) && !INVENTORY_ACTIONS.contains(action))
|
||||||
{
|
{
|
||||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||||
response.getWriter().write("Unknown action.");
|
response.getWriter().write("Unknown action.");
|
||||||
@@ -78,6 +81,12 @@ public class PlayerActionServlet extends HttpServlet
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (INVENTORY_ACTIONS.contains(action))
|
||||||
|
{
|
||||||
|
handleInventoryAction(request, response, staff, uuid, target, action, slot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String safeReason = (reason == null || reason.isBlank()) ? "No reason provided" : reason.trim();
|
String safeReason = (reason == null || reason.isBlank()) ? "No reason provided" : reason.trim();
|
||||||
if (safeReason.length() > 500) safeReason = safeReason.substring(0, 500);
|
if (safeReason.length() > 500) safeReason = safeReason.substring(0, 500);
|
||||||
|
|
||||||
@@ -133,6 +142,80 @@ public class PlayerActionServlet extends HttpServlet
|
|||||||
response.sendRedirect("/player/" + uuid);
|
response.sendRedirect("/player/" + uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void handleInventoryAction(HttpServletRequest request, HttpServletResponse response, AuthenticatedUser staff, UUID uuid, PlexPlayer target, String action, String slot)
|
||||||
|
throws IOException
|
||||||
|
{
|
||||||
|
String ipAddress = request.getRemoteAddr();
|
||||||
|
if ("127.0.0.1".equals(ipAddress))
|
||||||
|
{
|
||||||
|
String forwarded = request.getHeader("X-FORWARDED-FOR");
|
||||||
|
if (forwarded != null) ipAddress = forwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.log(ipAddress + " (xf:" + staff.username() + ") issued " + action + " on " + target.getName() + " (" + uuid + ")" + (slot == null || slot.isBlank() ? "" : " slot " + slot));
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTask(Plex.get(), () ->
|
||||||
|
{
|
||||||
|
Player online = Bukkit.getPlayer(uuid);
|
||||||
|
if (online == null) return;
|
||||||
|
PlayerInventory inv = online.getInventory();
|
||||||
|
if ("clear-inventory".equals(action))
|
||||||
|
{
|
||||||
|
inv.clear();
|
||||||
|
inv.setArmorContents(null);
|
||||||
|
inv.setItemInOffHand(null);
|
||||||
|
online.updateInventory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("clear-selected".equals(action))
|
||||||
|
{
|
||||||
|
clearSlot(inv, slot);
|
||||||
|
online.updateInventory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.sendRedirect("/player/" + uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void clearSlot(PlayerInventory inv, String slot)
|
||||||
|
{
|
||||||
|
if (slot == null) return;
|
||||||
|
if (slot.startsWith("hotbar-"))
|
||||||
|
{
|
||||||
|
Integer index = parseSlotIndex(slot.substring(7), 0, 8);
|
||||||
|
if (index != null) inv.setItem(index, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (slot.startsWith("storage-"))
|
||||||
|
{
|
||||||
|
Integer index = parseSlotIndex(slot.substring(8), 0, 26);
|
||||||
|
if (index != null) inv.setItem(index + 9, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (slot)
|
||||||
|
{
|
||||||
|
case "armor-helmet" -> inv.setHelmet(null);
|
||||||
|
case "armor-chest" -> inv.setChestplate(null);
|
||||||
|
case "armor-legs" -> inv.setLeggings(null);
|
||||||
|
case "armor-boots" -> inv.setBoots(null);
|
||||||
|
case "offhand" -> inv.setItemInOffHand(null);
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer parseSlotIndex(String value, int min, int max)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int index = Integer.parseInt(value);
|
||||||
|
return index >= min && index <= max ? index : null;
|
||||||
|
}
|
||||||
|
catch (NumberFormatException e)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static PunishmentType mapType(String action)
|
private static PunishmentType mapType(String action)
|
||||||
{
|
{
|
||||||
return switch (action)
|
return switch (action)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
|
import dev.plex.HTTPDModule;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
import dev.plex.request.MappingHeaders;
|
import dev.plex.request.MappingHeaders;
|
||||||
@@ -9,11 +10,15 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class AssetsEndpoint extends AbstractServlet
|
public class AssetsEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
private static final Pattern TEXTURE_PATH = Pattern.compile("(item|block)/[a-z0-9_]+\\.png");
|
private static final Pattern TEXTURE_PATH = Pattern.compile("((item|block)/[a-z0-9_]+|entity/shield/[a-z0-9_]+)\\.png");
|
||||||
|
private static final Pattern MODEL_PATH = Pattern.compile("(item|block)/[a-z0-9_]+\\.json");
|
||||||
|
private static final Pattern ITEM_DEF_PATH = Pattern.compile("[a-z0-9_]+\\.json");
|
||||||
|
|
||||||
|
|
||||||
@GetMapping(endpoint = "/assets/dashboard.js")
|
@GetMapping(endpoint = "/assets/dashboard.js")
|
||||||
@@ -37,6 +42,29 @@ public class AssetsEndpoint extends AbstractServlet
|
|||||||
return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/player.js"));
|
return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/player.js"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(endpoint = "/assets/blockrenderer.js")
|
||||||
|
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"})
|
||||||
|
public String blockRendererJs(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/blockrenderer.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(endpoint = "/assets/three.module.js")
|
||||||
|
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=86400"})
|
||||||
|
public String threeJs(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
serveResource("/httpd/assets/three.module.js", response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(endpoint = "/assets/three.core.js")
|
||||||
|
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=86400"})
|
||||||
|
public String threeCoreJs(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
serveResource("/httpd/assets/three.core.js", response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping(endpoint = "/assets/plexlogo.webp")
|
@GetMapping(endpoint = "/assets/plexlogo.webp")
|
||||||
@MappingHeaders(headers = {"content-type;image/webp", "cache-control;public, max-age=86400"})
|
@MappingHeaders(headers = {"content-type;image/webp", "cache-control;public, max-age=86400"})
|
||||||
public String plexLogo(HttpServletRequest request, HttpServletResponse response)
|
public String plexLogo(HttpServletRequest request, HttpServletResponse response)
|
||||||
@@ -49,23 +77,74 @@ public class AssetsEndpoint extends AbstractServlet
|
|||||||
@MappingHeaders(headers = {"content-type;image/png", "cache-control;public, max-age=86400"})
|
@MappingHeaders(headers = {"content-type;image/png", "cache-control;public, max-age=86400"})
|
||||||
public String texture(HttpServletRequest request, HttpServletResponse response)
|
public String texture(HttpServletRequest request, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
String uri = request.getRequestURI();
|
servePathUnder(request, response, "/assets/textures/", TEXTURE_PATH, "textures", "/httpd/assets/textures/");
|
||||||
String prefix = "/assets/textures/";
|
|
||||||
if (!uri.startsWith(prefix))
|
|
||||||
{
|
|
||||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String resourcePath = uri.substring(prefix.length());
|
|
||||||
if (!TEXTURE_PATH.matcher(resourcePath).matches())
|
|
||||||
{
|
|
||||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
serveResource("/httpd/assets/textures/" + resourcePath, response);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(endpoint = "/assets/models/")
|
||||||
|
@MappingHeaders(headers = {"content-type;application/json; charset=utf-8", "cache-control;public, max-age=86400"})
|
||||||
|
public String model(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
servePathUnder(request, response, "/assets/models/", MODEL_PATH, "models", "/httpd/assets/models/");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(endpoint = "/assets/items/")
|
||||||
|
@MappingHeaders(headers = {"content-type;application/json; charset=utf-8", "cache-control;public, max-age=86400"})
|
||||||
|
public String itemDef(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
servePathUnder(request, response, "/assets/items/", ITEM_DEF_PATH, "items", "/httpd/assets/items/");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void servePathUnder(HttpServletRequest request, HttpServletResponse response, String urlPrefix, Pattern allowed, String cacheCategory, String resourcePrefix)
|
||||||
|
{
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (!uri.startsWith(urlPrefix))
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String rest = uri.substring(urlPrefix.length());
|
||||||
|
if (!allowed.matcher(rest).matches())
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentStaff(request) == null)
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (serveCached(cacheCategory, rest, response))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serveResource(resourcePrefix + rest, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean serveCached(String category, String relativePath, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
if (HTTPDModule.getMinecraftAssetsManager() == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Path path = HTTPDModule.getMinecraftAssetsManager().resolve(category, relativePath);
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (OutputStream out = response.getOutputStream())
|
||||||
|
{
|
||||||
|
Files.copy(path, out);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (IOException ignored)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void serveResource(String classpathPath, HttpServletResponse response)
|
private static void serveResource(String classpathPath, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath);
|
try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath);
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import de.tr7zw.changeme.nbtapi.NBT;
|
|||||||
import de.tr7zw.changeme.nbtapi.iface.ReadableItemNBT;
|
import de.tr7zw.changeme.nbtapi.iface.ReadableItemNBT;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.inventory.ItemFlag;
|
import org.bukkit.inventory.ItemFlag;
|
||||||
import org.bukkit.persistence.PersistentDataContainer;
|
import org.bukkit.persistence.PersistentDataContainer;
|
||||||
@@ -47,8 +46,6 @@ public final class PlayerInventoryBroadcaster
|
|||||||
{
|
{
|
||||||
private static final PlayerInventoryBroadcaster INSTANCE = new PlayerInventoryBroadcaster();
|
private static final PlayerInventoryBroadcaster INSTANCE = new PlayerInventoryBroadcaster();
|
||||||
private static final long REFRESH_TICKS = 20L; // 1 second
|
private static final long REFRESH_TICKS = 20L; // 1 second
|
||||||
private static final Map<String, Boolean> TEXTURE_EXISTS = new ConcurrentHashMap<>();
|
|
||||||
private static final Map<String, Map<String, String>> TEXTURE_RESOLVED = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public static PlayerInventoryBroadcaster get()
|
public static PlayerInventoryBroadcaster get()
|
||||||
{
|
{
|
||||||
@@ -257,9 +254,6 @@ public final class PlayerInventoryBroadcaster
|
|||||||
m.put("type", type);
|
m.put("type", type);
|
||||||
m.put("amount", item.getAmount());
|
m.put("amount", item.getAmount());
|
||||||
|
|
||||||
Map<String, String> texture = resolveTextures(item.getType());
|
|
||||||
if (texture != null && !texture.isEmpty()) m.put("texture", texture);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
short maxDur = item.getType().getMaxDurability();
|
short maxDur = item.getType().getMaxDurability();
|
||||||
@@ -354,111 +348,6 @@ public final class PlayerInventoryBroadcaster
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves textures for a Material. For blocks held in 3D form (no
|
|
||||||
* dedicated item sprite, but has block face textures) returns
|
|
||||||
* {@code {top, side}} so the client can render an isometric cube. Items
|
|
||||||
* with a dedicated item sprite — including blocks that render as 2D
|
|
||||||
* sprites in inventory like doors and signs — return {@code {flat}}.
|
|
||||||
* Variant blocks (slab, stairs, wall, fence, etc.) fall back to the
|
|
||||||
* parent block's textures when no dedicated texture exists, mirroring how
|
|
||||||
* Minecraft itself reuses the parent's faces. Results are cached per-material.
|
|
||||||
*/
|
|
||||||
private static Map<String, String> resolveTextures(Material material)
|
|
||||||
{
|
|
||||||
if (material == null) return null;
|
|
||||||
String key = material.name().toLowerCase();
|
|
||||||
Map<String, String> cached = TEXTURE_RESOLVED.get(key);
|
|
||||||
if (cached != null) return cached.isEmpty() ? null : cached;
|
|
||||||
|
|
||||||
Map<String, String> result = resolveTexturesForName(material, key);
|
|
||||||
|
|
||||||
if (result.isEmpty())
|
|
||||||
{
|
|
||||||
String base = stripVariantSuffix(key);
|
|
||||||
if (base != null)
|
|
||||||
{
|
|
||||||
// Stone-style variants reuse the base block (cobblestone_slab → cobblestone);
|
|
||||||
// wood variants reuse planks (oak_slab → oak_planks);
|
|
||||||
// brick variants use the plural form (stone_brick_slab → stone_bricks).
|
|
||||||
for (String candidate : List.of(base, base + "_planks", base + "s"))
|
|
||||||
{
|
|
||||||
result = resolveTexturesForName(material, candidate);
|
|
||||||
if (!result.isEmpty()) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEXTURE_RESOLVED.put(key, result);
|
|
||||||
return result.isEmpty() ? null : result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String stripVariantSuffix(String key)
|
|
||||||
{
|
|
||||||
String[] suffixes = {
|
|
||||||
"_slab", "_stairs", "_wall", "_fence_gate", "_fence",
|
|
||||||
"_pressure_plate", "_button"
|
|
||||||
};
|
|
||||||
for (String suffix : suffixes)
|
|
||||||
{
|
|
||||||
if (key.endsWith(suffix)) return key.substring(0, key.length() - suffix.length());
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, String> resolveTexturesForName(Material material, String key)
|
|
||||||
{
|
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
|
||||||
boolean hasItemSprite = textureExists("item/" + key + ".png");
|
|
||||||
|
|
||||||
if (material.isBlock() && !hasItemSprite)
|
|
||||||
{
|
|
||||||
String top = pickFirstTexture(
|
|
||||||
"block/" + key + "_top.png",
|
|
||||||
"block/" + key + ".png",
|
|
||||||
"block/" + key + "_side.png",
|
|
||||||
"block/" + key + "_front.png");
|
|
||||||
String side = pickFirstTexture(
|
|
||||||
"block/" + key + "_side.png",
|
|
||||||
"block/" + key + ".png",
|
|
||||||
"block/" + key + "_front.png",
|
|
||||||
"block/" + key + "_top.png");
|
|
||||||
if (top != null)
|
|
||||||
{
|
|
||||||
result.put("top", "/assets/textures/" + top);
|
|
||||||
result.put("side", "/assets/textures/" + (side != null ? side : top));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.isEmpty())
|
|
||||||
{
|
|
||||||
String flat = pickFirstTexture(
|
|
||||||
"item/" + key + ".png",
|
|
||||||
"block/" + key + ".png",
|
|
||||||
"block/" + key + "_side.png",
|
|
||||||
"block/" + key + "_front.png",
|
|
||||||
"block/" + key + "_top.png");
|
|
||||||
if (flat != null) result.put("flat", "/assets/textures/" + flat);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String pickFirstTexture(String... candidates)
|
|
||||||
{
|
|
||||||
for (String c : candidates)
|
|
||||||
{
|
|
||||||
if (textureExists(c)) return c;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean textureExists(String relative)
|
|
||||||
{
|
|
||||||
return TEXTURE_EXISTS.computeIfAbsent(relative, p ->
|
|
||||||
PlayerInventoryBroadcaster.class.getResource("/httpd/assets/textures/" + p) != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Subscriber
|
private static final class Subscriber
|
||||||
{
|
{
|
||||||
final AsyncContext ctx;
|
final AsyncContext ctx;
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import * as THREE from '/assets/three.module.js';
|
||||||
|
|
||||||
|
// Renders Minecraft items the way Minecraft does: by walking the official
|
||||||
|
// item-definition → model → parent-chain graph and baking the resulting
|
||||||
|
// elements + textures into a small WebGL scene. Returns an HTMLImageElement
|
||||||
|
// per material, cached forever for that page.
|
||||||
|
|
||||||
|
const RENDER_SIZE = 96;
|
||||||
|
const FACE_BRIGHTNESS = { up: 1.0, down: 0.5, north: 0.8, south: 0.8, east: 0.6, west: 0.6 };
|
||||||
|
const DEFAULT_GUI_TRANSFORM = { rotation: [0, 0, 0], translation: [0, 0, 0], scale: [1, 1, 1] };
|
||||||
|
const DEFAULT_BLOCK_GUI = { rotation: [30, 225, 0], translation: [0, 0, 0], scale: [0.625, 0.625, 0.625] };
|
||||||
|
|
||||||
|
// Default "plains biome" tints — what Minecraft uses for the inventory icon
|
||||||
|
// when no biome context exists. Faces with a `tintindex` get multiplied by
|
||||||
|
// the entry for their material, leaving the texture's grayscale source
|
||||||
|
// (e.g. block/grass_block_top.png) as the only colour signal.
|
||||||
|
const GRASS = 0x91BD59;
|
||||||
|
const FOLIAGE = 0x48B518;
|
||||||
|
const WATER = 0x3F76E4;
|
||||||
|
const TINT_RGB = {
|
||||||
|
grass_block: GRASS, short_grass: GRASS, tall_grass: GRASS,
|
||||||
|
fern: GRASS, large_fern: GRASS, sugar_cane: GRASS,
|
||||||
|
pink_petals: GRASS,
|
||||||
|
oak_leaves: FOLIAGE, jungle_leaves: FOLIAGE, acacia_leaves: FOLIAGE,
|
||||||
|
dark_oak_leaves: FOLIAGE, mangrove_leaves: FOLIAGE, vine: FOLIAGE,
|
||||||
|
birch_leaves: 0x80A755,
|
||||||
|
spruce_leaves: 0x619961,
|
||||||
|
lily_pad: 0x208030,
|
||||||
|
water: WATER, water_bucket: WATER,
|
||||||
|
melon_stem: 0xE0C71C, pumpkin_stem: 0xE0C71C,
|
||||||
|
attached_melon_stem: 0xE0C71C, attached_pumpkin_stem: 0xE0C71C,
|
||||||
|
redstone_wire: 0xFF0000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemCache = new Map(); // material → Promise<HTMLImageElement | null>
|
||||||
|
const itemDefCache = new Map(); // material → Promise<itemdef json>
|
||||||
|
const modelCache = new Map(); // path → Promise<resolved model>
|
||||||
|
const textureCache = new Map(); // path → Promise<THREE.Texture>
|
||||||
|
|
||||||
|
let renderer = null;
|
||||||
|
let scene = null;
|
||||||
|
let camera = null;
|
||||||
|
|
||||||
|
function initThree() {
|
||||||
|
if (renderer) return;
|
||||||
|
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false, preserveDrawingBuffer: false });
|
||||||
|
renderer.setSize(RENDER_SIZE, RENDER_SIZE);
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
// Frustum sized to fit a 16-unit cube at the standard 0.625 GUI scale,
|
||||||
|
// with a touch of padding so corners don't clip after rotation.
|
||||||
|
const half = 8.2;
|
||||||
|
camera = new THREE.OrthographicCamera(-half, half, half, -half, -200, 200);
|
||||||
|
camera.position.set(0, 0, 100);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNs(ref) {
|
||||||
|
if (!ref) return ref;
|
||||||
|
const i = ref.indexOf(':');
|
||||||
|
return i >= 0 ? ref.substring(i + 1) : ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItemDef(name) {
|
||||||
|
if (itemDefCache.has(name)) return itemDefCache.get(name);
|
||||||
|
const p = fetch(`/assets/items/${name}.json`).then(r => {
|
||||||
|
if (!r.ok) throw new Error(`item def ${name}: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
itemDefCache.set(name, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN = {
|
||||||
|
'builtin/generated': { builtin: 'generated', textures: {}, display: {} },
|
||||||
|
'builtin/entity': { builtin: 'entity', textures: {}, display: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadModel(path) {
|
||||||
|
if (BUILTIN[path]) return BUILTIN[path];
|
||||||
|
if (modelCache.has(path)) return modelCache.get(path);
|
||||||
|
const p = (async () => {
|
||||||
|
const r = await fetch(`/assets/models/${path}.json`);
|
||||||
|
if (!r.ok) throw new Error(`model ${path}: ${r.status}`);
|
||||||
|
const data = await r.json();
|
||||||
|
if (!data.parent) return data;
|
||||||
|
const parent = await loadModel(stripNs(data.parent));
|
||||||
|
return mergeModel(parent, data);
|
||||||
|
})();
|
||||||
|
modelCache.set(path, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeModel(parent, child) {
|
||||||
|
return {
|
||||||
|
builtin: child.builtin ?? parent.builtin,
|
||||||
|
elements: child.elements ?? parent.elements,
|
||||||
|
gui_light: child.gui_light ?? parent.gui_light,
|
||||||
|
textures: { ...(parent.textures || {}), ...(child.textures || {}) },
|
||||||
|
display: { ...(parent.display || {}), ...(child.display || {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTextureRef(textures, ref) {
|
||||||
|
let cur = ref;
|
||||||
|
for (let i = 0; i < 16 && cur; i++) {
|
||||||
|
if (!cur.startsWith('#')) return stripNs(cur);
|
||||||
|
cur = textures[cur.substring(1)];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTexture(path) {
|
||||||
|
if (textureCache.has(path)) return textureCache.get(path);
|
||||||
|
const p = new Promise((resolve, reject) => {
|
||||||
|
new THREE.TextureLoader().load(`/assets/textures/${path}.png`, tex => {
|
||||||
|
tex.magFilter = THREE.NearestFilter;
|
||||||
|
tex.minFilter = THREE.NearestFilter;
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
tex.generateMipmaps = false;
|
||||||
|
// Animated textures are vertical strips; crop to the first frame
|
||||||
|
// by repeating only the top square portion of the image.
|
||||||
|
const img = tex.image;
|
||||||
|
if (img && img.height > img.width) {
|
||||||
|
const frame = img.width / img.height;
|
||||||
|
tex.repeat.y = frame;
|
||||||
|
tex.offset.y = 1 - frame;
|
||||||
|
tex.needsUpdate = true;
|
||||||
|
}
|
||||||
|
resolve(tex);
|
||||||
|
}, undefined, reject);
|
||||||
|
});
|
||||||
|
textureCache.set(path, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the first concrete model path from an item definition's model node.
|
||||||
|
// Handles minecraft:model directly and recurses into condition / select /
|
||||||
|
// range_dispatch / etc., taking whatever fallback the structure exposes.
|
||||||
|
function extractModelPath(node) {
|
||||||
|
if (!node) return null;
|
||||||
|
if (typeof node === 'string') return node;
|
||||||
|
if (node.type === 'minecraft:model' && typeof node.model === 'string') return node.model;
|
||||||
|
if (node.type === 'minecraft:special' && typeof node.base === 'string') return node.base;
|
||||||
|
const fields = ['fallback', 'on_false', 'on_true', 'model'];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (node[f]) {
|
||||||
|
const r = extractModelPath(node[f]);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const arrField of ['cases', 'entries']) {
|
||||||
|
const arr = node[arrField];
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
for (const e of arr) {
|
||||||
|
const r = extractModelPath(e.model || e);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tintToRgb(tint) {
|
||||||
|
if (!tint || typeof tint !== 'object') return null;
|
||||||
|
const type = stripNs(tint.type);
|
||||||
|
if (type === 'constant' && Number.isFinite(tint.value)) return tint.value & 0xFFFFFF;
|
||||||
|
if (type === 'grass') return GRASS;
|
||||||
|
if (type === 'foliage') return FOLIAGE;
|
||||||
|
if (type === 'water') return WATER;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTintRgb(node, itemName) {
|
||||||
|
if (!node || typeof node === 'string') return TINT_RGB[itemName] ?? null;
|
||||||
|
if (Array.isArray(node.tints) && node.tints.length) {
|
||||||
|
const rgb = tintToRgb(node.tints[0]);
|
||||||
|
if (rgb != null) return rgb;
|
||||||
|
}
|
||||||
|
const fields = ['fallback', 'on_false', 'on_true', 'model'];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (node[f] && typeof node[f] !== 'string') {
|
||||||
|
const r = extractTintRgb(node[f], itemName);
|
||||||
|
if (r != null) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const arrField of ['cases', 'entries']) {
|
||||||
|
const arr = node[arrField];
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
for (const e of arr) {
|
||||||
|
const r = extractTintRgb(e.model || e, itemName);
|
||||||
|
if (r != null) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TINT_RGB[itemName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert MC face data (face name, element from/to, uv rect) to vertex
|
||||||
|
// positions and UVs ready for a THREE.BufferGeometry. Vertices are emitted
|
||||||
|
// in TL, BL, BR, TR order when looking at the face from outside the cuboid.
|
||||||
|
function faceQuad(face, from, to, uv) {
|
||||||
|
const [x1, y1, z1] = from;
|
||||||
|
const [x2, y2, z2] = to;
|
||||||
|
const [u1, v1, u2, v2] = uv;
|
||||||
|
let positions;
|
||||||
|
switch (face) {
|
||||||
|
case 'up': positions = [x1,y2,z1, x1,y2,z2, x2,y2,z2, x2,y2,z1]; break;
|
||||||
|
case 'down': positions = [x1,y1,z2, x1,y1,z1, x2,y1,z1, x2,y1,z2]; break;
|
||||||
|
case 'north': positions = [x2,y2,z1, x2,y1,z1, x1,y1,z1, x1,y2,z1]; break;
|
||||||
|
case 'south': positions = [x1,y2,z2, x1,y1,z2, x2,y1,z2, x2,y2,z2]; break;
|
||||||
|
case 'east': positions = [x2,y2,z2, x2,y1,z2, x2,y1,z1, x2,y2,z1]; break;
|
||||||
|
case 'west': positions = [x1,y2,z1, x1,y1,z1, x1,y1,z2, x1,y2,z2]; break;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
const uvs = [
|
||||||
|
u1 / 16, 1 - v1 / 16,
|
||||||
|
u1 / 16, 1 - v2 / 16,
|
||||||
|
u2 / 16, 1 - v2 / 16,
|
||||||
|
u2 / 16, 1 - v1 / 16,
|
||||||
|
];
|
||||||
|
return { positions, uvs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a face omits "uv", MC derives it from the element's coords.
|
||||||
|
function defaultUV(face, from, to) {
|
||||||
|
const [x1, y1, z1] = from;
|
||||||
|
const [x2, y2, z2] = to;
|
||||||
|
switch (face) {
|
||||||
|
case 'up':
|
||||||
|
case 'down': return [x1, z1, x2, z2];
|
||||||
|
case 'north':
|
||||||
|
case 'south': return [x1, 16 - y2, x2, 16 - y1];
|
||||||
|
case 'east':
|
||||||
|
case 'west': return [z1, 16 - y2, z2, 16 - y1];
|
||||||
|
default: return [0, 0, 16, 16];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildElementGroup(elem, textures, guiLight, tintRgb) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const faces = elem.faces || {};
|
||||||
|
const sideLit = guiLight !== 'front';
|
||||||
|
for (const [face, data] of Object.entries(faces)) {
|
||||||
|
const texPath = resolveTextureRef(textures, data.texture);
|
||||||
|
if (!texPath) continue;
|
||||||
|
let tex;
|
||||||
|
try { tex = await loadTexture(texPath); }
|
||||||
|
catch { continue; }
|
||||||
|
const uv = data.uv || defaultUV(face, elem.from, elem.to);
|
||||||
|
const quad = faceQuad(face, elem.from, elem.to, uv);
|
||||||
|
if (!quad) continue;
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.Float32BufferAttribute(quad.positions, 3));
|
||||||
|
geom.setAttribute('uv', new THREE.Float32BufferAttribute(quad.uvs, 2));
|
||||||
|
geom.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
const b = sideLit ? (FACE_BRIGHTNESS[face] ?? 1) : 1;
|
||||||
|
const tinted = (data.tintindex !== undefined) && tintRgb != null;
|
||||||
|
const tr = tinted ? ((tintRgb >> 16) & 0xFF) / 255 : 1;
|
||||||
|
const tg = tinted ? ((tintRgb >> 8) & 0xFF) / 255 : 1;
|
||||||
|
const tb = tinted ? ( tintRgb & 0xFF) / 255 : 1;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
map: tex,
|
||||||
|
color: new THREE.Color(b * tr, b * tg, b * tb),
|
||||||
|
transparent: true,
|
||||||
|
alphaTest: 0.01,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: true,
|
||||||
|
// Pull tinted overlay faces a hair toward the camera so they
|
||||||
|
// don't z-fight the non-tinted faces underneath (e.g. grass
|
||||||
|
// block's side overlay over its dirt-and-grass side).
|
||||||
|
polygonOffset: tinted,
|
||||||
|
polygonOffsetFactor: tinted ? -1 : 0,
|
||||||
|
polygonOffsetUnits: tinted ? -1 : 0,
|
||||||
|
});
|
||||||
|
group.add(new THREE.Mesh(geom, mat));
|
||||||
|
}
|
||||||
|
if (elem.rotation) {
|
||||||
|
const origin = elem.rotation.origin || [8, 8, 8];
|
||||||
|
const angle = ((elem.rotation.angle || 0) * Math.PI) / 180;
|
||||||
|
const axis = elem.rotation.axis || 'y';
|
||||||
|
const wrapTo = new THREE.Group();
|
||||||
|
wrapTo.position.set(origin[0], origin[1], origin[2]);
|
||||||
|
const rot = new THREE.Group();
|
||||||
|
if (axis === 'x') rot.rotation.x = angle;
|
||||||
|
else if (axis === 'y') rot.rotation.y = angle;
|
||||||
|
else if (axis === 'z') rot.rotation.z = angle;
|
||||||
|
wrapTo.add(rot);
|
||||||
|
const wrapBack = new THREE.Group();
|
||||||
|
wrapBack.position.set(-origin[0], -origin[1], -origin[2]);
|
||||||
|
rot.add(wrapBack);
|
||||||
|
wrapBack.add(group);
|
||||||
|
return wrapTo;
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildElementsModel(model, tintRgb) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const textures = model.textures || {};
|
||||||
|
const guiLight = model.gui_light;
|
||||||
|
for (const elem of model.elements || []) {
|
||||||
|
group.add(await buildElementGroup(elem, textures, guiLight, tintRgb));
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLayeredSprite(model, tintRgb) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const textures = model.textures || {};
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
const ref = textures[`layer${i}`];
|
||||||
|
if (!ref) break;
|
||||||
|
let tex;
|
||||||
|
try { tex = await loadTexture(stripNs(ref)); }
|
||||||
|
catch { continue; }
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.Float32BufferAttribute([
|
||||||
|
0, 16, 0,
|
||||||
|
0, 0, 0,
|
||||||
|
16, 0, 0,
|
||||||
|
16, 16, 0,
|
||||||
|
], 3));
|
||||||
|
geom.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 0, 0, 1, 0, 1, 1], 2));
|
||||||
|
geom.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
const tinted = i === 0 && tintRgb != null;
|
||||||
|
const tr = tinted ? ((tintRgb >> 16) & 0xFF) / 255 : 1;
|
||||||
|
const tg = tinted ? ((tintRgb >> 8) & 0xFF) / 255 : 1;
|
||||||
|
const tb = tinted ? ( tintRgb & 0xFF) / 255 : 1;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
map: tex,
|
||||||
|
color: new THREE.Color(tr, tg, tb),
|
||||||
|
transparent: true,
|
||||||
|
alphaTest: 0.01,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
mesh.position.z = i * 0.05;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atlasUv(x1, y1, x2, y2, tw = 64, th = 64) {
|
||||||
|
return [x1 / tw, 1 - y1 / th, x1 / tw, 1 - y2 / th, x2 / tw, 1 - y2 / th, x2 / tw, 1 - y1 / th];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBoxFace(group, positions, uv, material) {
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geom.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
||||||
|
geom.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
group.add(new THREE.Mesh(geom, material));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShieldBox(group, from, to, frontUv, backUv, frontMat, backMat, sideMat) {
|
||||||
|
const [x1, y1, z1] = from;
|
||||||
|
const [x2, y2, z2] = to;
|
||||||
|
const full = [0, 0, 0, 1, 1, 1, 1, 0];
|
||||||
|
addBoxFace(group, [x1,y2,z2, x1,y1,z2, x2,y1,z2, x2,y2,z2], frontUv, frontMat); // south/front
|
||||||
|
addBoxFace(group, [x2,y2,z1, x2,y1,z1, x1,y1,z1, x1,y2,z1], backUv, backMat); // north/back
|
||||||
|
addBoxFace(group, [x1,y2,z1, x1,y2,z2, x2,y2,z2, x2,y2,z1], full, sideMat); // top
|
||||||
|
addBoxFace(group, [x1,y1,z2, x1,y1,z1, x2,y1,z1, x2,y1,z2], full, sideMat); // bottom
|
||||||
|
addBoxFace(group, [x2,y2,z2, x2,y1,z2, x2,y1,z1, x2,y2,z1], full, sideMat); // right
|
||||||
|
addBoxFace(group, [x1,y2,z1, x1,y1,z1, x1,y1,z2, x1,y2,z2], full, sideMat); // left
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildShieldSprite() {
|
||||||
|
const tex = await loadTexture('entity/shield/shield_base_nopattern');
|
||||||
|
const textured = new THREE.MeshBasicMaterial({
|
||||||
|
map: tex,
|
||||||
|
transparent: true,
|
||||||
|
alphaTest: 0.01,
|
||||||
|
side: THREE.FrontSide,
|
||||||
|
});
|
||||||
|
const side = new THREE.MeshBasicMaterial({ color: new THREE.Color(0.48, 0.48, 0.52) });
|
||||||
|
const handle = new THREE.MeshBasicMaterial({ color: new THREE.Color(0.30, 0.20, 0.10) });
|
||||||
|
const group = new THREE.Group();
|
||||||
|
|
||||||
|
// Vanilla shields are special entity models, so they don't expose normal
|
||||||
|
// item-model elements. Build a small cuboid approximation from the entity
|
||||||
|
// texture atlas instead of drawing a flat sprite.
|
||||||
|
addShieldBox(group, [2, -3, 7], [14, 19, 9], atlasUv(1, 2, 13, 24), atlasUv(15, 2, 27, 24), textured, textured, side);
|
||||||
|
addShieldBox(group, [5, 4, 5], [11, 12, 7], atlasUv(29, 1, 35, 9), atlasUv(36, 1, 42, 9), textured, textured, handle);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIELD_GUI_TRANSFORM = { rotation: [15, -35, -5], translation: [0, 0, 0], scale: [0.72, 0.72, 0.72] };
|
||||||
|
|
||||||
|
function applyGuiTransform(group, display, isBlockShape) {
|
||||||
|
const gui = (display && display.gui) || (isBlockShape ? DEFAULT_BLOCK_GUI : DEFAULT_GUI_TRANSFORM);
|
||||||
|
const r = gui.rotation || [0, 0, 0];
|
||||||
|
const t = gui.translation || [0, 0, 0];
|
||||||
|
const s = gui.scale || [1, 1, 1];
|
||||||
|
const inner = new THREE.Group();
|
||||||
|
inner.position.set(-8, -8, -8);
|
||||||
|
inner.add(group);
|
||||||
|
const outer = new THREE.Group();
|
||||||
|
outer.rotation.set(
|
||||||
|
THREE.MathUtils.degToRad(r[0]),
|
||||||
|
THREE.MathUtils.degToRad(r[1]),
|
||||||
|
THREE.MathUtils.degToRad(r[2]),
|
||||||
|
);
|
||||||
|
outer.scale.set(s[0], s[1], s[2]);
|
||||||
|
outer.position.set(t[0], t[1], t[2]);
|
||||||
|
outer.add(inner);
|
||||||
|
return outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeGroup(group) {
|
||||||
|
group.traverse(obj => {
|
||||||
|
if (obj.geometry) obj.geometry.dispose();
|
||||||
|
if (obj.material) {
|
||||||
|
// Don't dispose the texture — it's shared via cache.
|
||||||
|
obj.material.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderItem(name) {
|
||||||
|
if (itemCache.has(name)) return itemCache.get(name);
|
||||||
|
const p = (async () => {
|
||||||
|
try {
|
||||||
|
initThree();
|
||||||
|
const itemDef = await loadItemDef(name);
|
||||||
|
const modelRef = extractModelPath(itemDef.model);
|
||||||
|
if (!modelRef) return null;
|
||||||
|
const model = await loadModel(stripNs(modelRef));
|
||||||
|
const isBlockShape = !!(model.elements && model.elements.length);
|
||||||
|
const tintRgb = extractTintRgb(itemDef.model, name);
|
||||||
|
const isShield = name === 'shield';
|
||||||
|
const inner = isShield
|
||||||
|
? await buildShieldSprite()
|
||||||
|
: isBlockShape
|
||||||
|
? await buildElementsModel(model, tintRgb)
|
||||||
|
: await buildLayeredSprite(model, tintRgb);
|
||||||
|
const outer = applyGuiTransform(inner, isShield ? { gui: SHIELD_GUI_TRANSFORM } : model.display, isBlockShape);
|
||||||
|
// The next four lines must stay synchronous so concurrent
|
||||||
|
// renderItem() callers can't interleave on the shared scene.
|
||||||
|
scene.add(outer);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const dataUrl = renderer.domElement.toDataURL('image/png');
|
||||||
|
scene.remove(outer);
|
||||||
|
disposeGroup(outer);
|
||||||
|
return dataUrl;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('blockrenderer: failed', name, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
itemCache.set(name, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { renderItem };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
(function () {
|
(async function () {
|
||||||
const pingEl = document.querySelector('[data-player-ping]');
|
const pingEl = document.querySelector('[data-player-ping]');
|
||||||
const statusEl = document.querySelector('[data-player-status]');
|
const statusEl = document.querySelector('[data-player-status]');
|
||||||
const worldEl = document.querySelector('[data-player-world]');
|
const worldEl = document.querySelector('[data-player-world]');
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
const uuid = pingEl.getAttribute('data-uuid');
|
const uuid = pingEl.getAttribute('data-uuid');
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
|
|
||||||
|
const { renderItem } = await import('/assets/blockrenderer.js');
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -83,20 +85,38 @@
|
|||||||
const actionInput = form.querySelector('[data-action-input]');
|
const actionInput = form.querySelector('[data-action-input]');
|
||||||
const actionLabel = form.querySelector('[data-action-label]');
|
const actionLabel = form.querySelector('[data-action-label]');
|
||||||
const durationField = form.querySelector('[data-duration-field]');
|
const durationField = form.querySelector('[data-duration-field]');
|
||||||
|
const reasonField = form.querySelector('[data-reason-field]');
|
||||||
const reasonInput = form.querySelector('input[name="reason"]');
|
const reasonInput = form.querySelector('input[name="reason"]');
|
||||||
|
const slotInput = form.querySelector('[data-slot-input]');
|
||||||
|
const actionDescription = form.querySelector('[data-action-description]');
|
||||||
|
|
||||||
document.querySelectorAll('[data-admin-action]').forEach(btn => {
|
document.querySelectorAll('[data-admin-action]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const action = btn.getAttribute('data-admin-action');
|
const action = btn.getAttribute('data-admin-action');
|
||||||
const isTemp = btn.getAttribute('data-admin-temp') === 'true';
|
const isTemp = btn.getAttribute('data-admin-temp') === 'true';
|
||||||
|
const noReason = btn.getAttribute('data-admin-no-reason') === 'true';
|
||||||
|
const selectedRequired = btn.getAttribute('data-selected-required') === 'true';
|
||||||
|
if (selectedRequired && !selectedKey) return;
|
||||||
actionInput.value = action;
|
actionInput.value = action;
|
||||||
actionLabel.textContent = action;
|
actionLabel.textContent = action.replace(/-/g, ' ');
|
||||||
|
if (slotInput) slotInput.value = selectedRequired ? selectedKey : '';
|
||||||
durationField.hidden = !isTemp;
|
durationField.hidden = !isTemp;
|
||||||
durationField.querySelector('select').disabled = !isTemp;
|
durationField.querySelector('select').disabled = !isTemp;
|
||||||
if (reasonInput) reasonInput.value = '';
|
if (reasonField) reasonField.hidden = noReason;
|
||||||
|
if (reasonInput) {
|
||||||
|
reasonInput.disabled = noReason;
|
||||||
|
reasonInput.required = !noReason;
|
||||||
|
reasonInput.value = '';
|
||||||
|
}
|
||||||
|
if (actionDescription) {
|
||||||
|
const target = 'Target: <span class="font-medium text-foreground">' + escapeHtml(document.querySelector('h1')?.textContent || 'player') + '</span>';
|
||||||
|
actionDescription.innerHTML = selectedRequired
|
||||||
|
? target + '<br>Slot: <span class="font-mono text-foreground">' + escapeHtml(selectedKey) + '</span>'
|
||||||
|
: target;
|
||||||
|
}
|
||||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||||
else dialog.setAttribute('open', '');
|
else dialog.setAttribute('open', '');
|
||||||
if (reasonInput) setTimeout(() => reasonInput.focus(), 0);
|
if (!noReason && reasonInput) setTimeout(() => reasonInput.focus(), 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,6 +135,17 @@
|
|||||||
// Slot currently rendered in the detail panel (key like "storage-5"); kept across re-renders so the highlight survives data refreshes.
|
// Slot currently rendered in the detail panel (key like "storage-5"); kept across re-renders so the highlight survives data refreshes.
|
||||||
let selectedKey = null;
|
let selectedKey = null;
|
||||||
|
|
||||||
|
function updateInventoryActionButtons() {
|
||||||
|
const selectedItem = getItemBySlotKey(lastInv, selectedKey);
|
||||||
|
const enabled = !!(lastInv && lastInv.online && selectedKey && selectedItem);
|
||||||
|
document.querySelectorAll('[data-selected-required]').forEach(btn => {
|
||||||
|
btn.disabled = !enabled;
|
||||||
|
if (enabled) btn.removeAttribute('disabled');
|
||||||
|
else btn.setAttribute('disabled', '');
|
||||||
|
btn.title = enabled ? 'Clear ' + selectedKey : 'Select an occupied inventory slot first';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderDurabilityBar(item) {
|
function renderDurabilityBar(item) {
|
||||||
if (!item.maxDamage) return '';
|
if (!item.maxDamage) return '';
|
||||||
const damage = item.damage || 0;
|
const damage = item.damage || 0;
|
||||||
@@ -143,23 +174,28 @@
|
|||||||
return escapeHtml(parts.join(' • '));
|
return escapeHtml(parts.join(' • '));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItemIcon(item, large = false) {
|
function renderItemIcon(item) {
|
||||||
const tex = item.texture || {};
|
const name = item.type.toLowerCase();
|
||||||
if (tex.top) {
|
const label = escapeHtml(name.replace(/_/g, ' '));
|
||||||
const side = tex.side || tex.top;
|
return `<span class="absolute inset-0 pointer-events-none" data-item-icon="${escapeHtml(name)}">
|
||||||
const sizeClass = large ? 'iso-cube--lg' : 'iso-cube--sm';
|
<span class="absolute inset-0 grid place-items-center text-[8px] font-mono text-muted-foreground leading-tight px-0.5 text-center break-all">${label}</span>
|
||||||
return `
|
</span>`;
|
||||||
<div class="iso-cube ${sizeClass} pointer-events-none">
|
}
|
||||||
<div class="iso-face iso-top" style="background-image:url(${tex.top})"></div>
|
|
||||||
<div class="iso-face iso-front" style="background-image:url(${side})"></div>
|
async function hydrateIcons(root) {
|
||||||
<div class="iso-face iso-right" style="background-image:url(${side})"></div>
|
const targets = Array.from(root.querySelectorAll('[data-item-icon]:not([data-item-hydrated])'));
|
||||||
</div>
|
for (const el of targets) el.setAttribute('data-item-hydrated', '');
|
||||||
`;
|
await Promise.all(targets.map(async el => {
|
||||||
}
|
const name = el.getAttribute('data-item-icon');
|
||||||
if (tex.flat) {
|
const url = await renderItem(name);
|
||||||
return `<img src="${tex.flat}" alt="${escapeHtml(item.type)}" loading="lazy" class="size-full object-contain pointer-events-none">`;
|
if (!url) return;
|
||||||
}
|
const img = document.createElement('img');
|
||||||
return `<span class="absolute inset-0 grid place-items-center text-[8px] font-mono text-muted-foreground leading-tight px-0.5 text-center break-all pointer-events-none">${escapeHtml(item.type.toLowerCase().replace(/_/g, ' '))}</span>`;
|
img.className = 'size-full object-contain pointer-events-none [image-rendering:pixelated]';
|
||||||
|
img.alt = name;
|
||||||
|
img.src = url;
|
||||||
|
el.innerHTML = '';
|
||||||
|
el.appendChild(img);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSlot(item, key) {
|
function renderSlot(item, key) {
|
||||||
@@ -235,7 +271,7 @@
|
|||||||
const lines = [];
|
const lines = [];
|
||||||
lines.push(`<div class="flex items-start gap-3">
|
lines.push(`<div class="flex items-start gap-3">
|
||||||
<div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
|
<div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
|
||||||
${renderItemIcon(item, true)}
|
${renderItemIcon(item)}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
${safeName ? `<p class="truncate text-base font-medium italic">${safeName}</p>` : ''}
|
${safeName ? `<p class="truncate text-base font-medium italic">${safeName}</p>` : ''}
|
||||||
@@ -325,20 +361,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(inv) {
|
function render(inv) {
|
||||||
|
const previousGrid = invRoot.querySelector('[data-inv-grid]');
|
||||||
|
const previousScrollLeft = previousGrid ? previousGrid.scrollLeft : 0;
|
||||||
lastInv = inv;
|
lastInv = inv;
|
||||||
if (!inv.online) {
|
if (!inv.online) {
|
||||||
selectedKey = null;
|
selectedKey = null;
|
||||||
invRoot.innerHTML = `<p class="py-6 text-center text-sm text-muted-foreground">Player is offline.</p>`;
|
invRoot.innerHTML = `<p class="py-6 text-center text-sm text-muted-foreground">Player is offline.</p>`;
|
||||||
|
updateInventoryActionButtons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
invRoot.innerHTML = `
|
invRoot.innerHTML = `
|
||||||
<div class="grid gap-6 lg:grid-cols-[auto_1fr]">
|
<div class="grid gap-6 lg:grid-cols-[auto_1fr]">
|
||||||
<div data-inv-grid>${renderInventoryGrid(inv)}</div>
|
<div data-inv-grid class="-mx-2 overflow-x-auto px-2 pb-2 sm:mx-0 sm:px-0">
|
||||||
|
<div class="min-w-max">${renderInventoryGrid(inv)}</div>
|
||||||
|
</div>
|
||||||
<div data-inv-detail class="rounded-xl border border-border/40 bg-background/40 p-4">
|
<div data-inv-detail class="rounded-xl border border-border/40 bg-background/40 p-4">
|
||||||
${renderDetailPanel(getItemBySlotKey(inv, selectedKey))}
|
${renderDetailPanel(getItemBySlotKey(inv, selectedKey))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
const grid = invRoot.querySelector('[data-inv-grid]');
|
||||||
|
if (grid) grid.scrollLeft = previousScrollLeft;
|
||||||
|
hydrateIcons(invRoot);
|
||||||
|
updateInventoryActionButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
invRoot.addEventListener('click', (evt) => {
|
invRoot.addEventListener('click', (evt) => {
|
||||||
@@ -360,13 +405,17 @@
|
|||||||
selectedKey = btn.getAttribute('data-slot-key');
|
selectedKey = btn.getAttribute('data-slot-key');
|
||||||
const item = getItemBySlotKey(lastInv, selectedKey);
|
const item = getItemBySlotKey(lastInv, selectedKey);
|
||||||
const detail = invRoot.querySelector('[data-inv-detail]');
|
const detail = invRoot.querySelector('[data-inv-detail]');
|
||||||
if (detail) detail.innerHTML = renderDetailPanel(item);
|
if (detail) {
|
||||||
|
detail.innerHTML = renderDetailPanel(item);
|
||||||
|
hydrateIcons(detail);
|
||||||
|
}
|
||||||
invRoot.querySelectorAll('[data-slot-key]').forEach(el => {
|
invRoot.querySelectorAll('[data-slot-key]').forEach(el => {
|
||||||
const isSelected = el.getAttribute('data-slot-key') === selectedKey;
|
const isSelected = el.getAttribute('data-slot-key') === selectedKey;
|
||||||
el.classList.toggle('ring-2', isSelected);
|
el.classList.toggle('ring-2', isSelected);
|
||||||
el.classList.toggle('ring-primary', isSelected);
|
el.classList.toggle('ring-primary', isSelected);
|
||||||
el.classList.toggle('ring-card', !isSelected);
|
el.classList.toggle('ring-card', !isSelected);
|
||||||
});
|
});
|
||||||
|
updateInventoryActionButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
const invSrc = new EventSource('/api/player/inventory/stream?uuid=' + encodeURIComponent(uuid));
|
const invSrc = new EventSource('/api/player/inventory/stream?uuid=' + encodeURIComponent(uuid));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -85,35 +85,19 @@ PLAYERS
|
|||||||
class="col-span-2 h-9 rounded-full bg-primary/10 px-4 text-sm font-medium text-primary transition-colors hover:bg-primary/20">
|
class="col-span-2 h-9 rounded-full bg-primary/10 px-4 text-sm font-medium text-primary transition-colors hover:bg-primary/20">
|
||||||
Freeze
|
Freeze
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" data-admin-action="clear-inventory" data-admin-temp="false" data-admin-no-reason="true"
|
||||||
|
class="h-9 rounded-full bg-destructive/10 px-4 text-sm font-medium text-destructive transition-colors hover:bg-destructive/20">
|
||||||
|
Clear inventory
|
||||||
|
</button>
|
||||||
|
<button type="button" data-admin-action="clear-selected" data-admin-temp="false" data-admin-no-reason="true" data-selected-required="true" disabled
|
||||||
|
class="h-9 rounded-full bg-destructive/10 px-4 text-sm font-medium text-destructive transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-destructive/10">
|
||||||
|
Clear selected
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
|
||||||
.iso-cube {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
margin: auto;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transform: rotateX(-30deg) rotateY(45deg);
|
|
||||||
}
|
|
||||||
.iso-cube--sm { width: 2.1rem; height: 2.1rem; --iso-half: 1.05rem; }
|
|
||||||
.iso-cube--lg { width: 2.8rem; height: 2.8rem; --iso-half: 1.4rem; }
|
|
||||||
.iso-face {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-size: 100% 100%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
.iso-face.iso-top { transform: rotateX(90deg) translateZ(var(--iso-half)); filter: brightness(1.0); }
|
|
||||||
.iso-face.iso-front { transform: translateZ(var(--iso-half)); filter: brightness(0.82); }
|
|
||||||
.iso-face.iso-right { transform: rotateY(90deg) translateZ(var(--iso-half)); filter: brightness(0.66); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<section class="rise rise-2 mt-4">
|
<section class="rise rise-2 mt-4">
|
||||||
<article class="ring-card rounded-2xl bg-card p-5">
|
<article class="ring-card rounded-2xl bg-card p-5">
|
||||||
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
|
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
|
||||||
@@ -122,21 +106,22 @@ PLAYERS
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<dialog id="action-dialog"
|
<dialog id="action-dialog"
|
||||||
class="ring-card w-[min(28rem,calc(100%-2rem))] rounded-2xl bg-card p-5 text-foreground shadow-2xl backdrop:bg-background/60 backdrop:backdrop-blur-sm">
|
class="fixed inset-0 m-auto ring-card w-[min(28rem,calc(100%-2rem))] rounded-2xl bg-card p-5 text-foreground shadow-2xl backdrop:bg-background/60 backdrop:backdrop-blur-sm">
|
||||||
<form method="POST" action="/api/admin/action" id="action-form" class="flex flex-col gap-4">
|
<form method="POST" action="/api/admin/action" id="action-form" class="flex flex-col gap-4">
|
||||||
<input type="hidden" name="uuid" value="${player_uuid}">
|
<input type="hidden" name="uuid" value="${player_uuid}">
|
||||||
<input type="hidden" name="action" value="" data-action-input>
|
<input type="hidden" name="action" value="" data-action-input>
|
||||||
|
<input type="hidden" name="slot" value="" data-slot-input>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h3 class="text-lg font-medium">
|
<h3 class="text-lg font-medium">
|
||||||
Confirm <span data-action-label class="capitalize">action</span>
|
Confirm <span data-action-label class="capitalize">action</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground" data-action-description>
|
||||||
Target: <span class="font-medium text-foreground">${player_name}</span>
|
Target: <span class="font-medium text-foreground">${player_name}</span>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1.5 text-sm">
|
<label class="flex flex-col gap-1.5 text-sm" data-reason-field>
|
||||||
<span class="text-muted-foreground">Reason</span>
|
<span class="text-muted-foreground">Reason</span>
|
||||||
<input name="reason" type="text" required minlength="1" maxlength="500"
|
<input name="reason" type="text" required minlength="1" maxlength="500"
|
||||||
placeholder="Required"
|
placeholder="Required"
|
||||||
|
|||||||
Reference in New Issue
Block a user