Compare commits

...
26 Commits
Author SHA1 Message Date
Jim C K FlatenandClaude Opus 5.5 a2e71c1e9d Normalize line endings.
The previous ".gitattributes" setting "text=lf" is not valid, so Git ignored it
and a few files were committed with CRLF line endings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:46:18 +01:00
Jim C K Flaten f6b1958d56 Reduce allocations in world interface. 2026-10-26 02:42:44 +01:00
Jim C K Flaten 1c6d533d8d Improve version mismatch message behavior. 2026-10-26 01:13:25 +01:00
Jim C K Flaten 04d8b99634 Don't croak when a player disconnects with messages in flight. 2026-10-26 01:13:03 +01:00
Jim C K Flaten db881dbdb4 Validate server and level keys. 2026-10-26 00:45:50 +01:00
Jim C K FlatenandClaude Opus 5.5 132b704e90 Log plugin message handler errors on the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:56:18 +01:00
Jim C K Flaten 3ca19e814e Squelch biome warning on Paper 1.21.3+ 2026-10-25 23:47:13 +01:00
Jim C K FlatenandClaude Opus 5.5 40bd82176b Add testbed with a real server and client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:19:03 +01:00
Jim C K Flaten 25c73598bb Fix world lookup by mutated key. 2026-10-25 21:24:10 +01:00
Matt Lavallee 04fc2e17f9 Throttle login-sync and regular LOD requests independently. 2026-10-05 23:01:40 +02:00
Matt Lavallee 61ed6de27d Support DH network protocol version 16 2026-10-05 22:57:59 +02:00
Jim C K Flaten 56a0111057 Don't trigger LOD updates for events that happen during LOD generation. 2026-08-16 16:31:04 +02:00
Jim C K Flaten 61c8f6a465 Disable chunk discard by default. 2026-08-16 15:59:43 +02:00
Keith ConstableandClaude Sonnet 5 443bc32613 Fix WorldConfiguration.has() and stuck pre-generator on missing config key
Commit 9feff5e broke the pregen command by adding a `has()`-based fallback to pregen, when `has()` wasn't overridden in WorldConfiguration.

---

WorldConfiguration overrode get()/set()/unset() to redirect into the
backing global config under a worlds.<name>. prefix, but never
overrode has(), clear(), increment(), or decrement() — those kept
reading/writing the view's own local map, which nothing ever
populates. has() always returned false for every key in every world.

PreGenerator.run() used that has() to decide between the current and
deprecated concurrency-limit config keys. With has() always false, it
took the deprecated-key branch, which has no default, so getInt()
returned null and unboxing to int NPE'd on the first line of run().
The exception was swallowed by BukkitScheduler's fire-and-forget
future, leaving isPreGenerating() reporting true forever — /dhs pregen
start looked like it was running but never made progress.

Give WorldConfiguration real has()/clear()/increment()/decrement()
overrides, default the concurrency limit and floor it at 1 so a
missing config key can't NPE or spin the rate-limit loop, and log
pre-generator failures instead of discarding them silently.

Validated against a Paper 26.2 test server: pregen now advances to
completion under both the current-key and deprecated-key-only config,
with zero exceptions logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:43:55 +02:00
Jim C K Flaten 17feb207f5 Correct query result check in loadLod() 2026-08-11 15:35:57 +02:00
Matt Lavallee 369e822607 Housekeeping: Gradle 9.6.1, Shadow 9.6.1, and dependency updates
## Housekeeping: Gradle 9.6.1, Shadow 9.6.1, and dependency updates

Routine build maintenance. No source files changed — build configuration only.

| Dependency | Before | After |
|------------|--------|-------|
| Gradle wrapper | 8.14.3 | 9.6.1 |
| `com.gradleup.shadow` | 8.3.8 | 9.6.1 |
| `org.gradle.toolchains.foojay-resolver-convention` | — | 1.0.0 (new) |
| `org.jetbrains:annotations` | 25.0.0 | 26.1.0 |
| `com.google.guava:guava` | 33.3.1-jre | 33.6.0-jre |
| `org.bstats:bstats-bukkit` | 3.1.0 | 3.2.1 |
| `org.json:json` | 20240303 | 20260719 |
| `org.tukaani:xz` | 1.10 | 1.12 |
| `junit:junit` | 4.11 | 4.13.2 |
| FoliaLib | `com.github.technicallycoded:FoliaLib:v0.4.3` | `com.tcoded:FoliaLib:0.5.2` |

### Notes

- **Gradle 9:** replaced the removed `$buildDir` with `layout.buildDirectory`. Added `gradle-daemon-jvm.properties` (toolchain 21) and the foojay resolver so the Java 21 toolchain auto-provisions.
- **FoliaLib** moved to upstream's own repo `https://repo.tcoded.com/releases` under the `com.tcoded` group, because JitPack's builds of 0.5.x fail on an upstream Gradle implicit-dependency error and cap out at 0.4.4. Package name is unchanged, so the existing `relocate` rule and `BukkitScheduler` needed no changes.
- Added `.vscode/settings.json` to `.gitignore`.

### Verification

`./gradlew clean build` passes with tests and zero Gradle deprecation warnings under `--warning-mode all`. Shadow 9 needed no build-script migration; the shaded jar is equivalent to the Shadow 8 output aside from dropping stale empty `META-INF/versions/9/org/tukaani/...` directory entries.
2026-08-04 21:28:07 +02:00
Jim C K Flaten 0ad2d159b8 Clamp heightmap to min/max heights. 2026-08-04 20:15:48 +02:00
terbin 3cb9894e50 Fix chunk access in getMaterialRecordAt() when "dummy mode" is enabled. 2026-08-04 19:54:18 +02:00
Jim C K Flaten 25c14d0893 Ignore events for unloaded worlds. 2026-07-29 20:45:53 +02:00
Jim C K Flaten 9feff5efa5 Enforce LOD request concurrency limit. 2026-07-22 22:50:35 +02:00
Jim C K Flaten bda0d067ee Restore chunk cache priming for benchmark runs. 2026-07-21 23:02:14 +02:00
Jim C K Flaten cf0fbf4854 Shorten chunk lifecycle. 2026-07-21 21:01:08 +02:00
Jim C K Flaten 8dbb9e773c Use dummy chunks for world border rejections. 2026-07-17 20:59:02 +02:00
Jim C K Flaten 744af7c1d3 Support cross-world LOD requests. 2026-07-12 18:55:55 +02:00
Jim C K Flaten b485b00e82 Bump version number. 2026-07-09 17:57:54 +02:00
Jim C K Flaten e5421ad0f6 Merge tag '0.14.0' into develop
0.14.0
2026-07-09 17:57:33 +02:00
48 changed files with 3125 additions and 1133 deletions

No files matched your search

+2 -1
View File
@@ -1 +1,2 @@
* text=lf
* text=auto eol=lf
*.bat text eol=crlf
+7
View File
@@ -27,6 +27,9 @@ replay_pid*
/.idea
/*.iml
# Visual Studio Code project config
/.vscode
# Gradle output
/.gradle
/build
@@ -34,3 +37,7 @@ replay_pid*
# Test server
/server
/folia
# Testbed downloads and runtime files
/testbed/.cache
/testbed/run
+12 -12
View File
@@ -1,10 +1,10 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version '8.3.8'
id 'com.gradleup.shadow' version '9.6.1'
}
group = 'no.jckf.dhs'
version = '0.14.0'
version = '0.15.0-SNAPSHOT'
description = 'Distant Horizons Support'
java {
@@ -18,7 +18,7 @@ def paperVersions = [
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
maven { url = 'https://jitpack.io/' }
maven { url = 'https://repo.tcoded.com/releases' }
}
configurations {
@@ -29,16 +29,16 @@ configurations {
}
dependencies {
compileOnly 'org.jetbrains:annotations:25.0.0'
compileOnly 'org.jetbrains:annotations:26.1.0'
implementation 'com.google.guava:guava:33.3.1-jre'
implementation 'com.google.guava:guava:33.6.0-jre'
shade 'org.bstats:bstats-bukkit:3.1.0'
shade 'org.json:json:20240303'
shade 'org.tukaani:xz:1.10'
shade 'com.github.technicallycoded:FoliaLib:v0.4.3'
shade 'org.bstats:bstats-bukkit:3.2.1'
shade 'org.json:json:20260719'
shade 'org.tukaani:xz:1.12'
shade 'com.tcoded:FoliaLib:0.5.2'
testImplementation 'junit:junit:4.11'
testImplementation 'junit:junit:4.13.2'
}
paperVersions.each { mcVersion ->
@@ -53,7 +53,7 @@ paperVersions.each { mcVersion ->
// Custom processResources task per version
def processTask = tasks.register("processResources_${taskSuffix}", Copy) {
from(sourceSets[sourceSetName].resources.srcDirs)
into("$buildDir/resources-processed/$mcVersion")
into(layout.buildDirectory.dir("resources-processed/$mcVersion"))
include '**/*.yml'
@@ -88,7 +88,7 @@ paperVersions.each { mcVersion ->
exclude '**/*.yml'
}
from("$buildDir/resources-processed/$mcVersion")
from(layout.buildDirectory.dir("resources-processed/$mcVersion"))
configurations = [project.configurations.shade]
+2
View File
@@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=21
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+4 -7
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
Vendored
+82 -94
View File
@@ -1,94 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+4
View File
@@ -1 +1,5 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'DistantHorizonsSupport'
@@ -22,11 +22,12 @@ import no.jckf.dhsupport.core.Coordinates;
import no.jckf.dhsupport.core.Utils;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
import no.jckf.dhsupport.core.configuration.WorldConfiguration;
import no.jckf.dhsupport.core.scheduling.Scheduler;
import no.jckf.dhsupport.core.world.AbstractWorldInterface;
import no.jckf.dhsupport.core.world.MaterialRecord;
import no.jckf.dhsupport.core.world.WorldInterface;
import org.bukkit.*;
import org.bukkit.block.data.BlockData;
import javax.annotation.Nullable;
import java.io.File;
@@ -34,9 +35,10 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
public class BukkitWorldInterface implements WorldInterface
public class BukkitWorldInterface extends AbstractWorldInterface
{
protected static boolean BIOME_KEY_WARNING_SENT = false;
@@ -46,14 +48,8 @@ public class BukkitWorldInterface implements WorldInterface
protected World world;
protected Configuration config;
protected WorldConfiguration worldConfig;
protected Logger logger;
protected boolean dummyMode = false;
protected Map<Integer, String> dummyColumn;
protected boolean trustHeightMap = true;
@@ -65,7 +61,11 @@ public class BukkitWorldInterface implements WorldInterface
protected EnumMap<Material, MaterialRecord> materialCache = new EnumMap<>(Material.class);
protected Map<String, Map<String, String>> propertyCache = new HashMap<>();
// Building these is expensive, and a world only has so many block states. Every LOD gets its own instance through
// newInstance(), so these are shared between all instances for the same world.
protected final Map<BlockData, String> blockStateStrings;
protected final Map<BlockData, Map<String, String>> blockProperties;
protected UnsafeValues unsafeValues;
@@ -80,12 +80,23 @@ public class BukkitWorldInterface implements WorldInterface
protected int lightOffset = 0;
protected Integer seaLevel;
protected Integer dummyMaxY;
public BukkitWorldInterface(DhSupportBukkitPlugin plugin, World world, Configuration config)
{
this(plugin, world, config, new ConcurrentHashMap<>(), new ConcurrentHashMap<>());
}
protected BukkitWorldInterface(DhSupportBukkitPlugin plugin, World world, Configuration config, Map<BlockData, String> blockStateStrings, Map<BlockData, Map<String, String>> blockProperties)
{
super(world.getName(), config);
this.plugin = plugin;
this.world = world;
this.config = config;
this.worldConfig = new WorldConfiguration(this, config);
this.blockStateStrings = blockStateStrings;
this.blockProperties = blockProperties;
this.lightOffset = this.getConfig().getInt(DhsConfig.LIGHT_OFFSET_HACK, this.plugin.getDhSupport().getGameVersion().equals("1.20.1") ? this.world.getMinHeight() : 0);
this.trustHeightMap = this.getConfig().getBool(DhsConfig.TRUST_HEIGHT_MAP, this.trustHeightMap);
@@ -112,17 +123,21 @@ public class BukkitWorldInterface implements WorldInterface
}
// Detect if we're running under Paper (or a Paper fork) that has this patch:
// https://github.com/PaperMC/Paper/commit/5bf259115c1ce29dd96df5fcf53739c94d39f902
try {
Class<?> regionAccessor = Class.forName("org.bukkit.RegionAccessor");
// Since 1.21.3, biomes come from the registry and report their real keys, custom ones included.
// Before that, Biome was an enum and every custom biome was reported as "minecraft:custom".
if (org.bukkit.block.Biome.class.isEnum()) {
// Detect if we're running under Paper (or a Paper fork) that has this patch:
// https://github.com/PaperMC/Paper/commit/5bf259115c1ce29dd96df5fcf53739c94d39f902
try {
Class<?> regionAccessor = Class.forName("org.bukkit.RegionAccessor");
this.getBiomeKey = this.unsafeValues.getClass().getMethod("getBiomeKey", regionAccessor, int.class, int.class, int.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
if (!BIOME_KEY_WARNING_SENT) {
this.getLogger().warning("Custom biomes are not supported on this server.");
this.getBiomeKey = this.unsafeValues.getClass().getMethod("getBiomeKey", regionAccessor, int.class, int.class, int.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
if (!BIOME_KEY_WARNING_SENT) {
this.getLogger().warning("Custom biomes are not supported on this server.");
BIOME_KEY_WARNING_SENT = true;
BIOME_KEY_WARNING_SENT = true;
}
}
}
@@ -140,7 +155,7 @@ public class BukkitWorldInterface implements WorldInterface
@Override
public WorldInterface newInstance()
{
BukkitWorldInterface newInstance = new BukkitWorldInterface(this.plugin, this.world, this.config);
BukkitWorldInterface newInstance = new BukkitWorldInterface(this.plugin, this.world, this.config, this.blockStateStrings, this.blockProperties);
newInstance.setLogger(this.getLogger());
@@ -150,6 +165,11 @@ public class BukkitWorldInterface implements WorldInterface
}
protected ChunkSnapshot getChunk(int x, int z)
{
return this.getChunk(x, z, null);
}
protected ChunkSnapshot getChunk(int x, int z, @Nullable ChunkSnapshot alreadyLoaded)
{
int chunkX = Coordinates.blockToChunk(x);
int chunkZ = Coordinates.blockToChunk(z);
@@ -157,7 +177,7 @@ public class BukkitWorldInterface implements WorldInterface
int sectionX = Coordinates.chunkToSection(chunkX);
int sectionZ = Coordinates.chunkToSection(chunkZ);
if (this.sectionX == null) {
if (this.sectionX == null || this.sectionZ == null) {
this.sectionX = sectionX;
this.sectionZ = sectionZ;
} else if (this.sectionX != sectionX || this.sectionZ != sectionZ) {
@@ -177,7 +197,9 @@ public class BukkitWorldInterface implements WorldInterface
Scheduler scheduler = this.plugin.getDhSupport().getScheduler();
if (scheduler.canReadWorldAsync()) {
if (alreadyLoaded != null) {
chunk = alreadyLoaded;
} else if (scheduler.canReadWorldAsync()) {
chunk = this.world.getChunkAt(chunkX, chunkZ).getChunkSnapshot(true, true, false);
} else {
chunk = scheduler.runOnRegionThread(world.getUID(), x, z, () ->
@@ -201,18 +223,6 @@ public class BukkitWorldInterface implements WorldInterface
return this.world.getUID();
}
@Override
public String getName()
{
return this.world.getName();
}
@Override
public String getKey()
{
return this.getName().replaceAll("[^a-zA-Z0-9_-]", "");
}
@Override
public double getCoordinateScale()
{
@@ -234,18 +244,6 @@ public class BukkitWorldInterface implements WorldInterface
return this.worldConfig.getBool(DhsConfig.USE_VANILLA_WORLD_BORDER, true);
}
@Override
public void setDummyMode(boolean enable)
{
this.dummyMode = enable;
}
@Override
public boolean isDummyMode()
{
return this.dummyMode;
}
protected Map<Integer, String> buildDummyColumn()
{
int minY = this.getMinY();
@@ -383,7 +381,13 @@ public class BukkitWorldInterface implements WorldInterface
int chunkX = Coordinates.blockToChunk(x);
int chunkZ = Coordinates.blockToChunk(z);
return this.world.loadChunk(chunkX, chunkZ, false);
if (!this.world.loadChunk(chunkX, chunkZ, false)) {
return false;
}
this.getChunk(x, z);
return true;
}
@Override
@@ -396,7 +400,13 @@ public class BukkitWorldInterface implements WorldInterface
int chunkX = Coordinates.blockToChunk(x);
int chunkZ = Coordinates.blockToChunk(z);
return this.world.loadChunk(chunkX, chunkZ, true);
if (!this.world.loadChunk(chunkX, chunkZ, true)) {
return false;
}
this.getChunk(x, z);
return true;
}
@Override
@@ -414,9 +424,16 @@ public class BukkitWorldInterface implements WorldInterface
int chunkZ = Coordinates.blockToChunk(z);
try {
CompletableFuture<Chunk> chunkFuture = (CompletableFuture<Chunk>) this.getChunkAtAsync.invoke(this.world, chunkX, chunkZ, false);
return ((CompletableFuture<Chunk>) this.getChunkAtAsync.invoke(this.world, chunkX, chunkZ, false))
.thenApply((chunk) -> {
if (chunk == null) {
return false;
}
return chunkFuture.thenApply(Objects::nonNull);
this.getChunk(x, z, chunk.getChunkSnapshot(true, true, false));
return true;
});
} catch (InvocationTargetException | IllegalAccessException exception) {
throw new RuntimeException(exception);
}
@@ -437,9 +454,16 @@ public class BukkitWorldInterface implements WorldInterface
int chunkZ = Coordinates.blockToChunk(z);
try {
CompletableFuture<Chunk> chunkFuture = (CompletableFuture<Chunk>) this.getChunkAtAsync.invoke(this.world, chunkX, chunkZ, true);
return ((CompletableFuture<Chunk>) this.getChunkAtAsync.invoke(this.world, chunkX, chunkZ, true))
.thenApply((chunk) -> {
if (chunk == null) {
return false;
}
return chunkFuture.thenApply(Objects::nonNull);
this.getChunk(x, z, chunk.getChunkSnapshot(true, true, false));
return true;
});
} catch (InvocationTargetException | IllegalAccessException exception) {
throw new RuntimeException(exception);
}
@@ -499,7 +523,11 @@ public class BukkitWorldInterface implements WorldInterface
@Override
public int getSeaLevel()
{
return Math.min(Math.max(this.getMinY(), this.world.getSeaLevel()), this.getMaxY());
if (this.seaLevel == null) {
this.seaLevel = Math.clamp(this.world.getSeaLevel(), this.getMinY(), this.getMaxY() - 1);
}
return this.seaLevel;
}
@Override
@@ -528,10 +556,14 @@ public class BukkitWorldInterface implements WorldInterface
}
if (this.isDummyMode()) {
return this.getDummyColumn().isEmpty() ? this.getMinY() : Collections.max(this.getDummyColumn().keySet());
if (this.dummyMaxY == null) {
this.dummyMaxY = this.getDummyColumn().isEmpty() ? this.getMinY() : Collections.max(this.getDummyColumn().keySet());
}
return this.dummyMaxY;
}
return this.getChunk(x, z).getHighestBlockYAt(Coordinates.blockToChunkRelative(x), Coordinates.blockToChunkRelative(z));
return Math.clamp(this.getChunk(x, z).getHighestBlockYAt(Coordinates.blockToChunkRelative(x), Coordinates.blockToChunkRelative(z)), this.getMinY(), this.getMaxY() - 1);
}
@Override
@@ -555,12 +587,6 @@ public class BukkitWorldInterface implements WorldInterface
return key.toString();
}
@Override
public String getBiomeAt(int x, int z)
{
return this.getBiomeAt(x, this.getSeaLevel(), z);
}
@Override
public String getMaterialAt(int x, int y, int z)
{
@@ -572,9 +598,18 @@ public class BukkitWorldInterface implements WorldInterface
}
public MaterialRecord getMaterialRecordAt(int x, int y, int z)
{
if (this.isDummyMode()) {
return new MaterialRecord(Utils.namespaceKey(this.getDummyMaterialAt(y)), false);
}
return this.getMaterialRecord(this.getChunk(x, z).getBlockType(Coordinates.blockToChunkRelative(x), y, Coordinates.blockToChunkRelative(z)));
}
protected MaterialRecord getMaterialRecord(Material material)
{
return this.materialCache.computeIfAbsent(
this.getChunk(x, z).getBlockType(Coordinates.blockToChunkRelative(x), y, Coordinates.blockToChunkRelative(z)),
material,
(mat) -> {
String key = Utils.unNamespaceKey(mat.getKey().toString());
String mapped = this.config.getString(DhsConfig.MATERIAL_MAP + '.' + key, key);
@@ -587,16 +622,38 @@ public class BukkitWorldInterface implements WorldInterface
);
}
protected BlockData getBlockDataAt(int x, int y, int z)
{
return this.getChunk(x, z).getBlockData(Coordinates.blockToChunkRelative(x), y, Coordinates.blockToChunkRelative(z));
}
@Override
public String getBlockStateAsStringAt(int x, int y, int z)
{
MaterialRecord materialRecord = this.getMaterialRecordAt(x, y, z);
if (this.isDummyMode()) {
return this.getMaterialRecordAt(x, y, z).toString();
}
if (this.isDummyMode() || materialRecord.isMapped()) {
BlockData blockData = this.getBlockDataAt(x, y, z);
String blockStateString = this.blockStateStrings.get(blockData);
// Check before calling computeIfAbsent(), as the method reference would allocate on every call, even on hits.
if (blockStateString == null) {
blockStateString = this.blockStateStrings.computeIfAbsent(blockData, this::createBlockStateString);
}
return blockStateString;
}
protected String createBlockStateString(BlockData blockData)
{
MaterialRecord materialRecord = this.getMaterialRecord(blockData.getMaterial());
if (materialRecord.isMapped()) {
return materialRecord.toString();
}
Map<String, String> properties = this.getBlockPropertiesAt(x, y, z);
Map<String, String> properties = this.getBlockProperties(blockData);
if (properties.isEmpty()) {
return materialRecord.toString();
@@ -627,14 +684,24 @@ public class BukkitWorldInterface implements WorldInterface
return Collections.emptyMap();
}
String dataString = this.getChunk(x, z).getBlockData(Coordinates.blockToChunkRelative(x), y, Coordinates.blockToChunkRelative(z)).getAsString();
return this.getBlockProperties(this.getBlockDataAt(x, y, z));
}
Map<String, String> cached = this.propertyCache.get(dataString);
protected Map<String, String> getBlockProperties(BlockData blockData)
{
Map<String, String> properties = this.blockProperties.get(blockData);
if (cached != null) {
return cached;
if (properties == null) {
properties = this.blockProperties.computeIfAbsent(blockData, this::parseBlockProperties);
}
return properties;
}
protected Map<String, String> parseBlockProperties(BlockData blockData)
{
String dataString = blockData.getAsString();
int kvStart = dataString.indexOf('[');
if (kvStart == -1) {
@@ -653,11 +720,7 @@ public class BukkitWorldInterface implements WorldInterface
properties.put(kv[0], kv[1]);
}
properties = Collections.unmodifiableMap(properties);
this.propertyCache.put(dataString, properties);
return properties;
return Collections.unmodifiableMap(properties);
}
@Override
@@ -680,12 +743,6 @@ public class BukkitWorldInterface implements WorldInterface
return (byte) this.getChunk(x, z).getBlockSkyLight(Coordinates.blockToChunkRelative(x), Math.clamp(y + this.lightOffset, this.getMinY(), this.getMaxY() - 1), Coordinates.blockToChunkRelative(z));
}
@Override
public Configuration getConfig()
{
return this.worldConfig;
}
@Override
public boolean isBeacon(int x, int y, int z)
{
@@ -39,6 +39,7 @@ import javax.annotation.Nullable;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class DhSupportBukkitPlugin extends JavaPlugin
{
@@ -79,10 +80,12 @@ public class DhSupportBukkitPlugin extends JavaPlugin
this.metrics.addCustomChart(new MultiLineChart("dh_render_distance", () -> {
Map<String, Integer> renderDistances = new HashMap<>();
for (Configuration playerConfig : this.dhSupport.getPlayerConfigurations().values()) {
String rd = String.valueOf(playerConfig.getInt(DhsConfig.RENDER_DISTANCE));
for (Map<UUID, Configuration> playerConfigurations : this.dhSupport.getPlayerConfigurations().values()) {
for (Configuration playerConfig : playerConfigurations.values()) {
String rd = String.valueOf(playerConfig.getInt(DhsConfig.RENDER_DISTANCE));
renderDistances.put(rd, renderDistances.getOrDefault(rd, 0) + 1);
renderDistances.put(rd, renderDistances.getOrDefault(rd, 0) + 1);
}
}
return renderDistances;
@@ -142,7 +142,7 @@ public class DhsCommand implements CommandExecutor
return true;
}
Configuration config = this.plugin.getDhSupport().getPlayerConfiguration(player.getUniqueId());
Configuration config = this.plugin.getDhSupport().getPlayerConfiguration(player.getUniqueId(), player.getWorld().getUID());
if (config == null) {
sender.sendMessage(ChatColor.YELLOW + "The player " + ChatColor.WHITE + player.getName() + ChatColor.YELLOW + " is not using Distant Horizons.");
@@ -27,7 +27,6 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
@@ -48,7 +47,7 @@ public class PlayerHandler implements Listener
DhSupport dhSupport = this.plugin.getDhSupport();
dhSupport.getScheduler().runOnMainThreadDelayed(() -> {
if (dhSupport.getPlayerConfiguration(player.getUniqueId()) != null) {
if (dhSupport.getPlayerConfiguration(player.getUniqueId(), player.getWorld().getUID()) != null) {
return null;
}
@@ -64,5 +63,6 @@ public class PlayerHandler implements Listener
public void onPlayerDisconnect(PlayerQuitEvent event)
{
this.plugin.getDhSupport().clearPlayerConfiguration(event.getPlayer().getUniqueId());
this.plugin.getDhSupport().getPluginMessageHandler().onPlayerDisconnect(event.getPlayer().getUniqueId());
}
}
@@ -20,12 +20,15 @@ package no.jckf.dhsupport.bukkit.handler;
import no.jckf.dhsupport.bukkit.BukkitPluginMessageSender;
import no.jckf.dhsupport.bukkit.DhSupportBukkitPlugin;
import no.jckf.dhsupport.core.handler.ConnectionClosedException;
import no.jckf.dhsupport.core.handler.PluginMessageHandler;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.PluginMessageListener;
import org.jetbrains.annotations.NotNull;
import java.util.logging.Level;
public class PluginMessageProxy extends Handler implements PluginMessageListener
{
protected PluginMessageHandler pluginMessageHandler;
@@ -58,7 +61,11 @@ public class PluginMessageProxy extends Handler implements PluginMessageListener
{
try {
this.pluginMessageHandler.onPluginMessageReceived(channel, player.getUniqueId(), message);
} catch (ConnectionClosedException exception) {
player.sendMessage(ChatColor.RED + "Distant Horizons: " + exception.getMessage());
} catch (RuntimeException exception) {
this.plugin.getDhSupport().getLogger().log(Level.WARNING, "Error while handling plugin message from " + player.getName() + ".", exception);
player.sendMessage(ChatColor.RED + "Distant Horizons: " + exception.getMessage());
}
}
@@ -25,6 +25,7 @@ import no.jckf.dhsupport.core.configuration.DhsConfig;
import no.jckf.dhsupport.core.database.Database;
import no.jckf.dhsupport.core.database.migrations.AddLodVersionColumn;
import no.jckf.dhsupport.core.database.migrations.CreateLodsTable;
import no.jckf.dhsupport.core.database.migrations.DeleteLodsBelowVersion3;
import no.jckf.dhsupport.core.database.migrations.DeleteLodsWithoutVersion;
import no.jckf.dhsupport.core.database.models.LodModel;
import no.jckf.dhsupport.core.database.repositories.AsyncLodRepository;
@@ -36,6 +37,7 @@ import no.jckf.dhsupport.core.handler.PluginMessageHandler;
import no.jckf.dhsupport.core.lodbuilders.LodBuilder;
import no.jckf.dhsupport.core.message.plugin.FullDataChunkMessage;
import no.jckf.dhsupport.core.message.plugin.FullDataPartialUpdateMessage;
import no.jckf.dhsupport.core.message.plugin.LevelInitMessage;
import no.jckf.dhsupport.core.message.plugin.PluginMessageSender;
import no.jckf.dhsupport.core.scheduling.Scheduler;
import no.jckf.dhsupport.core.world.WorldInterface;
@@ -48,8 +50,7 @@ import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
public class DhSupport implements Configurable
@@ -87,7 +88,7 @@ public class DhSupport implements Configurable
protected Map<String, LodModel> touchedLods = new ConcurrentHashMap<>();
protected Map<UUID, Configuration> playerConfigurations = new HashMap<>();
protected Map<UUID, Map<UUID, Configuration>> playerConfigurations = new HashMap<>();
protected Map<UUID, PreGenerator> preGenerators = new HashMap<>();
@@ -119,6 +120,7 @@ public class DhSupport implements Configurable
this.database.addMigration(CreateLodsTable.class);
this.database.addMigration(AddLodVersionColumn.class);
this.database.addMigration(DeleteLodsWithoutVersion.class);
this.database.addMigration(DeleteLodsBelowVersion3.class);
this.database.migrate();
} catch (Exception exception) {
@@ -217,15 +219,47 @@ public class DhSupport implements Configurable
return;
}
if (!LevelInitMessage.isValidLevelKey(worldInterface.getLevelKey())) {
this.warning("World \"" + worldInterface.getName() + "\" has the level key \"" + worldInterface.getLevelKey() + "\", which Distant Horizons clients don't accept. Distant Horizons is disabled in this world. Level keys can be up to 150 characters of a-z, A-Z, 0-9, _ and -. Rename the world, or change level_key_prefix.");
}
if (!LevelInitMessage.isValidServerKey(worldInterface.getServerKey())) {
this.warning("World \"" + worldInterface.getName() + "\" has the server key \"" + worldInterface.getServerKey() + "\", which Distant Horizons clients don't accept. It will not be used. Server keys can be up to 150 characters of a-z, A-Z, 0-9, _ and -.");
}
// Clients tell levels apart by their level key, so two worlds sharing one will get mixed up.
for (WorldInterface other : this.worldInterfaces.values()) {
if (!other.getId().equals(id) && other.getLevelKey().equals(worldInterface.getLevelKey())) {
this.warning("Worlds \"" + other.getName() + "\" and \"" + worldInterface.getName() + "\" have the same level key \"" + worldInterface.getLevelKey() + "\". Distant Horizons can't tell them apart. Rename one of them, or give one a different level_key_prefix.");
}
}
this.worldInterfaces.put(id, worldInterface);
}
public Map<UUID, WorldInterface> getWorldInterfaces()
{
return this.worldInterfaces;
}
@Nullable
public WorldInterface getWorldInterface(UUID id)
{
return this.worldInterfaces.get(id);
}
@Nullable
public WorldInterface getWorldInterfaceByLevelKey(String levelKey)
{
for (WorldInterface world : this.worldInterfaces.values()) {
if (world.getLevelKey().equals(levelKey)) {
return world;
}
}
return null;
}
public PluginMessageHandler getPluginMessageHandler()
{
return this.pluginMessageHandler;
@@ -302,19 +336,21 @@ public class DhSupport implements Configurable
this.warning("A newer version of the plugin is available.");
}
public Map<UUID, Configuration> getPlayerConfigurations()
public Map<UUID, Map<UUID, Configuration>> getPlayerConfigurations()
{
return this.playerConfigurations;
}
public void setPlayerConfiguration(UUID playerId, Configuration playerConfiguration)
public void setPlayerConfiguration(UUID playerId, Map<UUID, Configuration> playerConfigurations)
{
this.playerConfigurations.put(playerId, playerConfiguration);
this.playerConfigurations.put(playerId, playerConfigurations);
}
public Configuration getPlayerConfiguration(UUID playerId)
public Configuration getPlayerConfiguration(UUID playerId, UUID worldId)
{
return this.playerConfigurations.get(playerId);
Map<UUID, Configuration> configurations = this.playerConfigurations.get(playerId);
return configurations == null ? null : configurations.get(worldId);
}
public void clearPlayerConfiguration(UUID playerId)
@@ -393,65 +429,63 @@ public class DhSupport implements Configurable
protected CompletableFuture<LodModel> generateLod(UUID worldId, SectionPosition position, boolean manageChunks)
{
int worldX = Coordinates.sectionToBlock(position.getX());
int worldZ = Coordinates.sectionToBlock(position.getZ());
int sectionWorldX = Coordinates.sectionToBlock(position.getX());
int sectionWorldZ = Coordinates.sectionToBlock(position.getZ());
WorldInterface world = this.getWorldInterface(worldId).newInstance();
boolean generateNewChunks = world.getConfig().getBool(DhsConfig.GENERATE_NEW_CHUNKS, true);
boolean generateNewChunks = world.getConfig().getBool(DhsConfig.GENERATE_NEW_CHUNKS, true) && world.isInsideWorldBorder(sectionWorldX, sectionWorldZ, Lod.width);
Map<Long, CompletableFuture<Boolean>> loads = new HashMap<>();
List<CompletableFuture<Boolean>> loads = new ArrayList<>();
if (manageChunks) {
boolean discardNewChunks = world.getConfig().getBool(DhsConfig.DISCARD_NEW_CHUNKS, false);
// Load all the chunks we need for this request.
Coordinates.foreachChunkInSection(position.getX(), position.getZ(), (chunkX, chunkZ) -> {
if (!world.isChunkLoaded(chunkX, chunkZ)) {
if (generateNewChunks) {
loads.put(Coordinates.pack(chunkX, chunkZ), world.loadOrGenerateChunkAsync(chunkX, chunkZ));
} else {
loads.put(Coordinates.pack(chunkX, chunkZ), world.loadChunkAsync(chunkX, chunkZ));
}
}
Coordinates.foreachChunkInSection(position.getX(), position.getZ(), (chunkWorldX, chunkWorldZ) -> {
final boolean alreadyLoaded = world.isChunkLoaded(chunkWorldX, chunkWorldZ);
loads.add(
(generateNewChunks ? world.loadOrGenerateChunkAsync(chunkWorldX, chunkWorldZ) : world.loadChunkAsync(chunkWorldX, chunkWorldZ))
.whenComplete((loaded, throwable) -> {
if (!alreadyLoaded) {
if (discardNewChunks) {
world.discardChunk(chunkWorldX, chunkWorldZ);
} else {
world.unloadChunkAsync(chunkWorldX, chunkWorldZ);
}
}
})
.exceptionally((throwable -> {
this.warning(throwable.getMessage());
return false;
}))
);
});
}
// Wait for chunk loads, then...
return CompletableFuture.allOf(loads.values().toArray(new CompletableFuture[0]))
return CompletableFuture.allOf(loads.toArray(new CompletableFuture[0]))
.thenComposeAsync((asd) -> {
boolean loadRejected = loads
.values()
.stream()
.map(loadRequest -> {
try {
return loadRequest.get();
} catch (InterruptedException | ExecutionException exception) {
return false;
}
})
.anyMatch(Predicate.isEqual(false));
boolean loadRejected = loads.stream()
.anyMatch((load) -> !load.join());
if (loadRejected) {
// Use dummy chunks instead of real world data.
world.setDummyMode(true);
} else {
// Prime chunk data in world interface.
Coordinates.foreachChunkInSection(position.getX(), position.getZ(), (chunkX, chunkZ) -> {
world.getMaterialAt(chunkX, world.getSeaLevel(), chunkZ);
});
} else if (!manageChunks) {
// This only executes for benchmark runs, and ensures that LOD builders receive a world interface that has already cached the necessary chunks.
// The normal execution path, with manageChunks=true, performs caching during chunk loading.
final int seaLevel = world.getSeaLevel();
Coordinates.foreachChunkInSection(
position.getX(),
position.getZ(),
(chunkWorldX, chunkWorldZ) -> world.getMaterialAt(chunkWorldX, seaLevel, chunkWorldZ)
);
}
// Discard the chunks we loaded.
this.getScheduler().runOnRegionThread(worldId, worldX, worldZ, () -> {
for (long packed : loads.keySet()) {
world.discardChunk(
Coordinates.unpackX(packed),
Coordinates.unpackZ(packed)
);
}
return null;
});
// No LOD was found. Start building a new one.
CompletableFuture<Lod> lodFuture = this.queueBuilder(worldId, position, this.getBuilder(world, position));
@@ -490,11 +524,23 @@ public class DhSupport implements Configurable
String key = lodModel.toString();
// Ignore events triggered during LOD generation.
if (this.queuedBuilders.containsKey(key)) {
return;
}
if (this.touchedLods.containsKey(key)) {
return;
}
this.debug("Touched LOD at " + this.getWorldInterface(worldId).getName() + " " + x + " " + z + (reason == null ? "." : ": " + reason));
WorldInterface world = this.getWorldInterface(worldId);
if (world == null) {
this.warning("Received a touch for unloaded world " + worldId + ". Ignoring.");
return;
}
this.debug("Touched LOD at " + world.getName() + " " + x + " " + z + (reason == null ? "." : ": " + reason));
this.touchedLods.put(key, lodModel);
}
@@ -547,12 +593,7 @@ public class DhSupport implements Configurable
return;
}
String levelKeyPrefix = worldConfig.getString(DhsConfig.LEVEL_KEY_PREFIX);
String levelKey = world.getKey();
if (levelKeyPrefix != null) {
levelKey = levelKeyPrefix + levelKey;
}
String levelKey = world.getLevelKey();
int lodChunkX = Coordinates.sectionToChunk(lodModelToDelete.getX());
int lodChunkZ = Coordinates.sectionToChunk(lodModelToDelete.getZ());
@@ -563,7 +604,7 @@ public class DhSupport implements Configurable
// TODO: Don't use Bukkit classes.
for (Player player : Bukkit.getWorld(newLodModel.getWorldId()).getPlayers()) {
Configuration playerConfig = this.getPlayerConfiguration(player.getUniqueId());
Configuration playerConfig = this.getPlayerConfiguration(player.getUniqueId(), world.getId());
// No config for this player? Probably not using DH.
if (playerConfig == null) {
@@ -643,6 +684,12 @@ public class DhSupport implements Configurable
this.getScheduler().runOnSeparateThread(() -> {
this.preGenerators.get(world.getId()).run();
return null;
}).exceptionally((exception) -> {
this.getLogger().log(Level.WARNING, "Pre-generation of " + world.getName() + " failed.", exception);
this.stopPreGenerator(world);
return null;
});
}
@@ -18,6 +18,7 @@
package no.jckf.dhsupport.core;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
import no.jckf.dhsupport.core.database.models.LodModel;
import no.jckf.dhsupport.core.dataobject.SectionPosition;
@@ -68,7 +69,12 @@ public class PreGenerator implements Runnable
public void run()
{
int rateLimit = this.world.getConfig().getInt(DhsConfig.FULL_DATA_REQUEST_CONCURRENCY_LIMIT);
Configuration worldConfig = this.world.getConfig();
// Fall back to the old key in case the server's config file is for an older plugin version.
int rateLimit = Math.max(1, worldConfig.has(DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT)
? worldConfig.getInt(DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT)
: worldConfig.getInt(DhsConfig.FULL_DATA_REQUEST_CONCURRENCY_LIMIT, 20));
int currentX = this.centerX;
int currentZ = this.centerZ;
+148 -148
View File
@@ -1,148 +1,148 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Utils
{
private static final byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes(StandardCharsets.US_ASCII);
private static final Map<String, String> NAMESPACE_CACHE = new ConcurrentHashMap<>();
private static final Map<String, String> UNNAMESPACE_CACHE = new ConcurrentHashMap<>();
public static String bytesToHex(byte[] bytes)
{
byte[] hexChars = new byte[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars, StandardCharsets.UTF_8);
}
public static String bytesToBin(byte[] bytes)
{
StringBuilder builder = new StringBuilder();
for (byte b : bytes) {
builder.append(Integer.toBinaryString((b & 0xFF) | 0x0100).substring(1));
}
return builder.toString().replaceAll("(.{4})", "$1 ").trim();
}
public static String ucFirst(String string, Locale locale)
{
if (string.isEmpty()) {
return "";
}
return string.substring(0, 1).toUpperCase(locale) + string.substring(1);
}
public static String ucFirst(String string)
{
return ucFirst(string, Locale.getDefault());
}
public static String humanReadableDuration(final Duration duration)
{
if (duration.toDays() >= 365) {
return "infinite";
}
final StringBuilder builder = new StringBuilder();
if (duration.toDays() > 0) {
builder.append(duration.toDaysPart()).append(" day");
if (duration.toDaysPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
if (duration.toHours() > 0) {
builder.append(duration.toHoursPart()).append(" hour");
if (duration.toHoursPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
if (duration.toMinutes() > 0) {
builder.append(duration.toMinutesPart()).append(" minute");
if (duration.toMinutesPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
if (duration.toSeconds() > 0 || builder.isEmpty()) {
builder.append(duration.toSecondsPart()).append(" second");
if (duration.toSecondsPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
return builder.substring(0, builder.length() - 2);
}
/**
* @deprecated For awareness: This method caches results by key only.
*/
public static String namespaceKey(String key, String defaultNamespace)
{
return NAMESPACE_CACHE.computeIfAbsent(key, (k) -> k.contains(":") ? k : defaultNamespace + ":" + k);
}
public static String namespaceKey(String key)
{
return namespaceKey(key, "minecraft");
}
/**
* @deprecated For awareness: This method caches results by key only.
*/
public static String unNamespaceKey(String key, String namespace)
{
return UNNAMESPACE_CACHE.computeIfAbsent(key, (k) -> k.startsWith(namespace + ":") ? k.substring(namespace.length() + 1) : k);
}
public static String unNamespaceKey(String key)
{
return unNamespaceKey(key, "minecraft");
}
}
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Utils
{
private static final byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes(StandardCharsets.US_ASCII);
private static final Map<String, String> NAMESPACE_CACHE = new ConcurrentHashMap<>();
private static final Map<String, String> UNNAMESPACE_CACHE = new ConcurrentHashMap<>();
public static String bytesToHex(byte[] bytes)
{
byte[] hexChars = new byte[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars, StandardCharsets.UTF_8);
}
public static String bytesToBin(byte[] bytes)
{
StringBuilder builder = new StringBuilder();
for (byte b : bytes) {
builder.append(Integer.toBinaryString((b & 0xFF) | 0x0100).substring(1));
}
return builder.toString().replaceAll("(.{4})", "$1 ").trim();
}
public static String ucFirst(String string, Locale locale)
{
if (string.isEmpty()) {
return "";
}
return string.substring(0, 1).toUpperCase(locale) + string.substring(1);
}
public static String ucFirst(String string)
{
return ucFirst(string, Locale.getDefault());
}
public static String humanReadableDuration(final Duration duration)
{
if (duration.toDays() >= 365) {
return "infinite";
}
final StringBuilder builder = new StringBuilder();
if (duration.toDays() > 0) {
builder.append(duration.toDaysPart()).append(" day");
if (duration.toDaysPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
if (duration.toHours() > 0) {
builder.append(duration.toHoursPart()).append(" hour");
if (duration.toHoursPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
if (duration.toMinutes() > 0) {
builder.append(duration.toMinutesPart()).append(" minute");
if (duration.toMinutesPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
if (duration.toSeconds() > 0 || builder.isEmpty()) {
builder.append(duration.toSecondsPart()).append(" second");
if (duration.toSecondsPart() != 1) {
builder.append("s");
}
builder.append(", ");
}
return builder.substring(0, builder.length() - 2);
}
/**
* @deprecated For awareness: This method caches results by key only.
*/
public static String namespaceKey(String key, String defaultNamespace)
{
return NAMESPACE_CACHE.computeIfAbsent(key, (k) -> k.contains(":") ? k : defaultNamespace + ":" + k);
}
public static String namespaceKey(String key)
{
return namespaceKey(key, "minecraft");
}
/**
* @deprecated For awareness: This method caches results by key only.
*/
public static String unNamespaceKey(String key, String namespace)
{
return UNNAMESPACE_CACHE.computeIfAbsent(key, (k) -> k.startsWith(namespace + ":") ? k.substring(namespace.length() + 1) : k);
}
public static String unNamespaceKey(String key)
{
return unNamespaceKey(key, "minecraft");
}
}
@@ -21,7 +21,6 @@ package no.jckf.dhsupport.core.configuration;
import org.checkerframework.checker.nullness.qual.PolyNull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -36,6 +35,11 @@ public class Configuration
this.variables.clear();
}
public void clear(String prefix)
{
this.variables.keySet().removeIf((key) -> key.startsWith(prefix));
}
public boolean has(String key)
{
return this.variables.containsKey(key);
@@ -119,4 +123,9 @@ public class Configuration
{
return (int) this.variables.compute(key, (k, v) -> v == null ? 1 : (int) v + 1);
}
public int decrement(String key)
{
return (int) this.variables.compute(key, (k, v) -> v == null ? -1 : (int) v - 1);
}
}
@@ -32,8 +32,15 @@ public abstract class DhsConfig
public static String DISTANT_GENERATION_ENABLED = "distant_generation_enabled";
/**
* @deprecated Replaced by CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT.
*/
public static String FULL_DATA_REQUEST_CONCURRENCY_LIMIT = "full_data_request_concurrency_limit";
public static String CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT = "client_lod_request_concurrency_limit";
public static String SERVER_LOD_REQUEST_CONCURRENCY_LIMIT = "server_lod_request_concurrency_limit";
public static String REAL_TIME_UPDATES_ENABLED = "real_time_updates_enabled";
public static String REAL_TIME_UPDATE_RADIUS = "real_time_update_radius";
@@ -68,6 +75,8 @@ public abstract class DhsConfig
public static String GENERATE_NEW_CHUNKS_WARNING = "generate_new_chunks_warning";
public static String DISCARD_NEW_CHUNKS = "discard_new_chunks";
public static String BUILDER_TYPE = "builder_type";
public static String TRUST_HEIGHT_MAP = "trust_height_map";
@@ -23,6 +23,12 @@ import org.checkerframework.checker.nullness.qual.PolyNull;
import javax.annotation.Nullable;
/**
* A view over a backing {@link Configuration}, scoped to a single world via a
* {@code worlds.<name>.} key prefix. Holds no state of its own — every method reads or writes
* through to the backing config, falling back to the unprefixed (global) key when a
* world-specific value isn't set.
*/
public class WorldConfiguration extends Configuration
{
protected static String WORLD_PREFIX = "worlds.%s.";
@@ -37,23 +43,53 @@ public class WorldConfiguration extends Configuration
this.config = config;
}
protected String key(String key)
{
return WORLD_PREFIX.formatted(this.world.getName()) + key;
}
@Override
public void set(String key, @Nullable Object value)
{
this.config.set(WORLD_PREFIX.formatted(this.world.getName()) + key, value);
this.config.set(this.key(key), value);
}
@Override
public void unset(String key)
{
this.config.unset(WORLD_PREFIX.formatted(this.world.getName()) + key);
this.config.unset(this.key(key));
}
@Override
public @PolyNull Object get(String key, @PolyNull Object defaultValue)
{
Object specific = this.config.get(WORLD_PREFIX.formatted(this.world.getName()) + key);
Object specific = this.config.get(this.key(key));
return specific == null ? this.config.get(key, defaultValue) : specific;
}
@Override
public boolean has(String key)
{
// Mirror get()'s resolution order: world-specific first, then the global key.
return this.config.has(this.key(key)) || this.config.has(key);
}
@Override
public void clear()
{
this.config.clear(WORLD_PREFIX.formatted(this.world.getName()));
}
@Override
public int increment(String key)
{
return this.config.increment(this.key(key));
}
@Override
public int decrement(String key)
{
return this.config.decrement(this.key(key));
}
}
@@ -0,0 +1,42 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.database.migrations;
import no.jckf.dhsupport.core.database.Database;
import java.sql.Statement;
/** Version 2 LODs lack the "regenerate" flag added by DH network protocol 16. */
public class DeleteLodsBelowVersion3 extends Migration
{
public DeleteLodsBelowVersion3(Database database)
{
super(database);
}
@Override
public void up() throws Exception
{
try (Statement statement = this.database.getConnection().createStatement()) {
statement.execute("""
DELETE FROM lods WHERE version < 3;
""");
}
}
}
@@ -22,7 +22,7 @@ import java.util.UUID;
public class LodModel extends Model
{
public static int VERSION = 2;
public static int VERSION = 3;
public static LodModel create()
{
@@ -102,14 +102,12 @@ public class LodRepository
statement.setInt(3, sectionZ);
try (ResultSet result = statement.executeQuery()) {
result.next();
byte[] data = result.getBytes("data");
if (data == null) {
if (!result.next()) {
return null;
}
byte[] data = result.getBytes("data");
return LodModel.create()
.setWorldId(worldId)
.setX(sectionX)
@@ -40,6 +40,8 @@ public class Lod extends DataObject
public static CompressionType compressionType = CompressionType.LZMA2;
protected static final int MAX_DICT_SIZE = 1 << 20;
public static int width = 64;
protected static volatile byte[] compressedGenerationStep;
@@ -159,6 +161,7 @@ public class Lod extends DataObject
encoder.writeBoolean(true); // Apply to parent
encoder.writeBoolean(false); // Apply to children
encoder.writeBoolean(false); // Regenerate
encoder.writeLong(now); // Created at
encoder.writeLong(now); // Updated at
@@ -171,7 +174,13 @@ public class Lod extends DataObject
try {
LZMA2Options options = new LZMA2Options(3);
options.setDictSize(Math.max(LZMA2Options.DICT_SIZE_MIN, Math.min(uncompressedData.length, 1 << 20)));
int dictSize = LZMA2Options.DICT_SIZE_MIN;
while (dictSize < uncompressedData.length && dictSize < MAX_DICT_SIZE) {
dictSize <<= 1;
}
options.setDictSize(dictSize);
OutputStream compressorStream = new XZOutputStream(compressedStream, options, XZ.CHECK_CRC64);
@@ -0,0 +1,47 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.enums;
/**
* Sent as its ordinal. Declaration order must match EDhApiGeneratorPlan.
*
* @see EDhApiGeneratorPlan
*/
public enum GeneratorPlan
{
SURFACE_THEN_CHUNKS(true),
SURFACE_ONLY(true),
CHUNKS_ONLY(true),
DISABLED(false)
;
public final boolean generationEnabled;
GeneratorPlan(boolean generationEnabled)
{
this.generationEnabled = generationEnabled;
}
public static GeneratorPlan byOrdinal(int ordinal)
{
GeneratorPlan[] values = GeneratorPlan.values();
return ordinal < 0 || ordinal >= values.length ? GeneratorPlan.DISABLED : values[ordinal];
}
}
@@ -0,0 +1,31 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.handler;
/**
* Thrown when a DH connection is closed with a reason meant for the player, such as a protocol version mismatch.
* Unlike other exceptions from message handling, this is expected and not a server error.
*/
public class ConnectionClosedException extends RuntimeException
{
public ConnectionClosedException(String reason)
{
super(reason);
}
}
@@ -18,7 +18,6 @@
package no.jckf.dhsupport.core.handler;
import no.jckf.dhsupport.core.Coordinates;
import no.jckf.dhsupport.core.DhSupport;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
@@ -28,9 +27,9 @@ import no.jckf.dhsupport.core.message.plugin.FullDataChunkMessage;
import no.jckf.dhsupport.core.message.plugin.FullDataSourceRequestMessage;
import no.jckf.dhsupport.core.message.plugin.FullDataSourceResponseMessage;
import no.jckf.dhsupport.core.world.WorldInterface;
import org.bukkit.Bukkit;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
public class LodHandler
{
@@ -40,6 +39,8 @@ public class LodHandler
protected PluginMessageHandler pluginMessageHandler;
protected AtomicInteger requestsInFlight = new AtomicInteger(0);
public LodHandler(DhSupport dhSupport, PluginMessageHandler pluginMessageHandler)
{
this.dhSupport = dhSupport;
@@ -60,13 +61,23 @@ public class LodHandler
return;
}
// TODO: Some sort of Player wrapper or interface object. Bukkit classes should not be imported here.
UUID worldUuid = Bukkit.getPlayer(requestMessage.getSender()).getWorld().getUID();
Configuration dhsConfig = this.dhSupport.getConfig();
WorldInterface world = this.dhSupport.getWorldInterface(worldUuid);
WorldInterface world = this.dhSupport.getWorldInterfaceByLevelKey(requestMessage.getWorldName());
if (world == null) {
ExceptionMessage exceptionMessage = new ExceptionMessage();
exceptionMessage.isResponseTo(requestMessage);
exceptionMessage.setTypeId(ExceptionMessage.TYPE_REQUEST_REJECTED);
exceptionMessage.setMessage("Unknown level " + requestMessage.getWorldName());
this.pluginMessageHandler.sendPluginMessage(requestMessage.getSender(), exceptionMessage);
return;
}
UUID worldUuid = world.getId();
Configuration config = world.getConfig();
Configuration playerConfig = this.dhSupport.getPlayerConfiguration(requestMessage.getSender());
Configuration playerConfig = this.dhSupport.getPlayerConfiguration(requestMessage.getSender(), worldUuid);
if (!config.getBool(DhsConfig.DISTANT_GENERATION_ENABLED) || !playerConfig.getBool(DhsConfig.DISTANT_GENERATION_ENABLED)) {
ExceptionMessage exceptionMessage = new ExceptionMessage();
@@ -99,51 +110,44 @@ public class LodHandler
return;
}
int worldX = Coordinates.sectionToBlock(position.getX());
int worldZ = Coordinates.sectionToBlock(position.getZ());
int clientRateLimit = playerConfig.getInt(DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT, 0);
int serverRateLimit = dhsConfig.getInt(DhsConfig.SERVER_LOD_REQUEST_CONCURRENCY_LIMIT, 0);
Integer borderCenterX = world.getWorldBorderX();
Integer borderCenterZ = world.getWorldBorderZ();
Integer borderRadius = world.getWorldBorderRadius();
// The client runs its sync-on-load and generation queues independently, each against its own limit.
boolean isSyncRequest = requestMessage.getTimestamp() != null;
if (borderCenterX != null && borderCenterZ != null && borderRadius != null) {
int minX = borderCenterX - borderRadius;
int maxX = borderCenterX + borderRadius;
int minZ = borderCenterZ - borderRadius;
int maxZ = borderCenterZ + borderRadius;
int higherLodX = worldX + 64;
int higherLodZ = worldZ + 64;
if (higherLodX < minX || worldX > maxX || higherLodZ < minZ || worldZ > maxZ) {
ExceptionMessage exceptionMessage = new ExceptionMessage();
exceptionMessage.isResponseTo(requestMessage);
exceptionMessage.setTypeId(ExceptionMessage.TYPE_REQUEST_REJECTED);
exceptionMessage.setMessage("World border");
this.pluginMessageHandler.sendPluginMessage(requestMessage.getSender(), exceptionMessage);
return;
}
if (isSyncRequest) {
clientRateLimit = playerConfig.getInt(DhsConfig.LOGIN_DATA_SYNC_RC_LIMIT, 0);
}
/*boolean generate = config.getBool(DhsConfig.GENERATE_NEW_CHUNKS);
String activeRequestCounter = isSyncRequest ? "active-sync-requests" : "active-requests";
if (!generate) {
for (int relativeChunkX = 0; relativeChunkX < Lod.width / 16; relativeChunkX++) {
for (int relativeChunkZ = 0; relativeChunkZ < Lod.width / 16; relativeChunkZ++) {
if (!world.chunkExists(worldX + relativeChunkX * 16, worldZ + relativeChunkZ * 16)) {
ExceptionMessage exceptionMessage = new ExceptionMessage();
exceptionMessage.isResponseTo(requestMessage);
exceptionMessage.setTypeId(ExceptionMessage.TYPE_REQUEST_REJECTED);
exceptionMessage.setMessage("Fog of war");
this.pluginMessageHandler.sendPluginMessage(requestMessage.getSender(), exceptionMessage);
return;
}
}
boolean exceedsClientRateLimit = playerConfig.increment(activeRequestCounter) > clientRateLimit;
boolean exceedsServerRateLimit = serverRateLimit > 0 && this.requestsInFlight.incrementAndGet() > serverRateLimit;
if (exceedsClientRateLimit || exceedsServerRateLimit) {
if (serverRateLimit > 0) {
this.requestsInFlight.decrementAndGet();
}
}*/
playerConfig.decrement(activeRequestCounter);
ExceptionMessage exceptionMessage = new ExceptionMessage();
exceptionMessage.isResponseTo(requestMessage);
exceptionMessage.setTypeId(ExceptionMessage.TYPE_RATE_LIMITED);
exceptionMessage.setMessage("Rate limited by the server");
this.pluginMessageHandler.sendPluginMessage(requestMessage.getSender(), exceptionMessage);
return;
}
this.dhSupport.getLod(worldUuid, position)
.whenComplete((lodModel, exception) -> {
if (serverRateLimit > 0) {
this.requestsInFlight.decrementAndGet();
}
playerConfig.decrement(activeRequestCounter);
})
.thenAccept((lodModel) -> {
if (lodModel == null) {
ExceptionMessage exceptionMessage = new ExceptionMessage();
@@ -31,6 +31,8 @@ import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class PlayerConfigHandler
@@ -53,6 +55,10 @@ public class PlayerConfigHandler
// TODO: Some sort of Player wrapper or interface object. Bukkit classes should not be imported here.
Player player = Bukkit.getPlayer(initRequestMessage.getSender());
if (player == null) {
return;
}
NamespacedKey worldKey = NamespacedKey.fromString(initRequestMessage.getWorldKey());
// TODO: This too 👎
@@ -72,19 +78,16 @@ public class PlayerConfigHandler
WorldInterface world = this.dhSupport.getWorldInterface(worldUuid);
Configuration dhsConfig = world.getConfig();
String levelKeyPrefix = dhsConfig.getString(DhsConfig.LEVEL_KEY_PREFIX);
String levelKey = world.getKey();
if (levelKeyPrefix != null) {
levelKey = levelKeyPrefix + levelKey;
// Clients would refuse this level init. A warning was logged when the world was loaded.
if (!LevelInitMessage.isValidLevelKey(world.getLevelKey())) {
this.dhSupport.debug("Not sending level init for world " + world.getName() + " to " + player.getName() + ", as its level key is invalid.");
return;
}
LevelInitMessage levelInitResponse = new LevelInitMessage();
levelInitResponse.setNamespacedKey(worldKey.toString());
levelInitResponse.setServerKey(dhsConfig.getString(DhsConfig.SERVER_KEY, ""));
levelInitResponse.setLevelKey(levelKey);
levelInitResponse.setServerKey(LevelInitMessage.isValidServerKey(world.getServerKey()) ? world.getServerKey() : "");
levelInitResponse.setLevelKey(world.getLevelKey());
levelInitResponse.setTime(System.currentTimeMillis());
this.pluginMessageHandler.sendPluginMessage(initRequestMessage.getSender(), levelInitResponse);
});
@@ -93,69 +96,82 @@ public class PlayerConfigHandler
// TODO: Some sort of Player wrapper or interface object. Bukkit classes should not be imported here.
Player player = Bukkit.getPlayer(configMessage.getSender());
WorldInterface world = this.dhSupport.getWorldInterface(player.getWorld().getUID());
String permission = USAGE_PERMISSION.formatted(world.getName());
boolean dhUseIsAllowed = !player.isPermissionSet(permission) || player.hasPermission(permission);
Configuration dhsConfig = world.getConfig();
Configuration clientConfig = configMessage.toConfiguration();
// This is not very flexible, but will do for now.
for (String key : RemotePlayerConfigMessage.KEYS) {
//this.dhSupport.getLogger().info("Config key " + key + ":");
Object dhsValue = dhsConfig.get(key);
Object clientValue = clientConfig.get(key);
Object keepValue = null;
if (key.equals(DhsConfig.DISTANT_GENERATION_ENABLED)) {
if (dhUseIsAllowed) {
if (!(boolean) clientValue) {
player.sendMessage(ChatColor.GOLD + "This server supports Distant Horizons, but you have disabled Distant Generation in your DH settings.");
}
} else {
this.dhSupport.info("Player " + player.getName() + " has been denied the use of Distant Horizons through permissions.");
keepValue = false;
}
}
if (key.equals(DhsConfig.REAL_TIME_UPDATES_ENABLED) && !dhUseIsAllowed) {
keepValue = false;
}
// TODO: This is ugly. Move to comparator closures like in DH.
if (key.equals(DhsConfig.BORDER_CENTER_X)) {
keepValue = world.getWorldBorderX();
} else if (key.equals(DhsConfig.BORDER_CENTER_Z)) {
keepValue = world.getWorldBorderZ();
} else if(key.equals(DhsConfig.BORDER_RADIUS)) {
keepValue = world.getWorldBorderRadius();
}
if (keepValue == null) {
if (dhsValue instanceof Boolean dhsBool && clientValue instanceof Boolean clientBool) {
keepValue = dhsBool && clientBool;
//this.dhSupport.getLogger().info(" Server " + (dhsBool ? "Y" : "N") + " or client " + (clientBool ? "Y" : "N") + " = " + ((boolean) keepValue ? "Y" : "N"));
} else if (dhsValue instanceof Integer dhsInt && clientValue instanceof Integer clientInt) {
keepValue = dhsInt < clientInt ? dhsInt : clientInt;
//this.dhSupport.getLogger().info(" Server " + dhsInt + " or client " + clientInt + " = " + keepValue);
} else {
//this.dhSupport.getLogger().info(" Uhh... 😵‍💫");
}
}
clientConfig.set(key, keepValue == null ? -1 : keepValue);
if (player == null) {
return;
}
this.dhSupport.setPlayerConfiguration(configMessage.getSender(), clientConfig);
Map<UUID, Configuration> playerConfigurations = new HashMap<>();
for (WorldInterface world : this.dhSupport.getWorldInterfaces().values()) {
String permission = USAGE_PERMISSION.formatted(world.getName());
boolean dhUseIsAllowed = !player.isPermissionSet(permission) || player.hasPermission(permission);
Configuration dhsConfig = world.getConfig();
Configuration clientConfig = configMessage.toConfiguration();
// This is not very flexible, but will do for now.
for (String key : RemotePlayerConfigMessage.KEYS) {
//this.dhSupport.getLogger().info("Config key " + key + ":");
Object dhsValue = dhsConfig.get(key);
Object clientValue = clientConfig.get(key);
Object keepValue = null;
if (key.equals(DhsConfig.DISTANT_GENERATION_ENABLED)) {
if (dhUseIsAllowed) {
if (!(boolean) clientValue) {
player.sendMessage(ChatColor.GOLD + "This server supports Distant Horizons, but you have disabled Distant Generation in your DH settings.");
}
} else {
this.dhSupport.info("Player " + player.getName() + " has been denied the use of Distant Horizons through permissions.");
keepValue = false;
}
}
if (key.equals(DhsConfig.REAL_TIME_UPDATES_ENABLED) && !dhUseIsAllowed) {
keepValue = false;
}
// If no value was found under the new key, read the old key to see if the server's config file is for an older plugin version.
if (key.equals(DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT) && dhsValue == null) {
dhsValue = dhsConfig.get(DhsConfig.FULL_DATA_REQUEST_CONCURRENCY_LIMIT);
}
// TODO: This is ugly. Move to comparator closures like in DH.
if (key.equals(DhsConfig.BORDER_CENTER_X)) {
keepValue = world.getWorldBorderX();
} else if (key.equals(DhsConfig.BORDER_CENTER_Z)) {
keepValue = world.getWorldBorderZ();
} else if(key.equals(DhsConfig.BORDER_RADIUS)) {
keepValue = world.getWorldBorderRadius();
}
if (keepValue == null) {
if (dhsValue instanceof Boolean dhsBool && clientValue instanceof Boolean clientBool) {
keepValue = dhsBool && clientBool;
//this.dhSupport.getLogger().info(" Server " + (dhsBool ? "Y" : "N") + " or client " + (clientBool ? "Y" : "N") + " = " + ((boolean) keepValue ? "Y" : "N"));
} else if (dhsValue instanceof Integer dhsInt && clientValue instanceof Integer clientInt) {
keepValue = dhsInt < clientInt ? dhsInt : clientInt;
//this.dhSupport.getLogger().info(" Server " + dhsInt + " or client " + clientInt + " = " + keepValue);
} else {
//this.dhSupport.getLogger().info(" Uhh... 😵‍💫");
}
}
clientConfig.set(key, keepValue == null ? -1 : keepValue);
}
playerConfigurations.put(world.getId(), clientConfig);
}
this.dhSupport.setPlayerConfiguration(configMessage.getSender(), playerConfigurations);
RemotePlayerConfigMessage configResponse = new RemotePlayerConfigMessage();
configResponse.fromConfiguration(clientConfig);
configResponse.fromConfiguration(playerConfigurations.get(player.getWorld().getUID()));
this.pluginMessageHandler.sendPluginMessage(configMessage.getSender(), configResponse);
});
@@ -1,180 +1,202 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.handler;
import no.jckf.dhsupport.core.DhSupport;
import no.jckf.dhsupport.core.Utils;
import no.jckf.dhsupport.core.bytestream.Decoder;
import no.jckf.dhsupport.core.bytestream.Encoder;
import no.jckf.dhsupport.core.event.EventBus;
import no.jckf.dhsupport.core.message.MessageTypeRegistry;
import no.jckf.dhsupport.core.message.plugin.*;
import org.jetbrains.annotations.NotNull;
import javax.annotation.Nullable;
import java.util.UUID;
public class PluginMessageHandler
{
protected DhSupport dhSupport;
private MessageTypeRegistry messageTypeRegistry;
public final String pluginChannel = "distant_horizons:msg";
public final short protocolVersion = 15;
private EventBus<PluginMessage> eventBus;
public PluginMessageHandler(DhSupport dhSupport)
{
this.dhSupport = dhSupport;
this.eventBus = new EventBus<>();
// Define plugin channel message types.
this.messageTypeRegistry = new MessageTypeRegistry();
this.messageTypeRegistry.registerMessageType(0, null);
this.messageTypeRegistry.registerMessageType(1, CloseReasonMessage.class);
this.messageTypeRegistry.registerMessageType(2, LevelInitMessage.class);
this.messageTypeRegistry.registerMessageType(3, LevelInitRequestMessage.class);
this.messageTypeRegistry.registerMessageType(4, RemotePlayerConfigMessage.class);
this.messageTypeRegistry.registerMessageType(5, CancelMessage.class);
this.messageTypeRegistry.registerMessageType(6, ExceptionMessage.class);
this.messageTypeRegistry.registerMessageType(7, FullDataSourceRequestMessage.class);
this.messageTypeRegistry.registerMessageType(8, FullDataSourceResponseMessage.class);
this.messageTypeRegistry.registerMessageType(9, FullDataPartialUpdateMessage.class);
this.messageTypeRegistry.registerMessageType(10, FullDataChunkMessage.class);
}
public void onEnable()
{
}
public void onDisable()
{
}
@Nullable
public EventBus<PluginMessage> getEventBus()
{
return this.eventBus;
}
public void onPluginMessageReceived(@NotNull String channel, @NotNull UUID senderUuid, byte[] data)
{
PluginMessage message;
try {
message = this.readPluginMessage(senderUuid, data);
} catch (Exception exception) {
this.dhSupport.warning("Error while parsing incoming plugin message: " + exception.getClass() + " - " + exception.getMessage());
this.dhSupport.warning("Data was: " + Utils.bytesToHex(data));
return;
}
if (message == null) {
return;
}
if (message instanceof CloseReasonMessage) {
throw new RuntimeException(((CloseReasonMessage) message).getReason());
}
message.setSender(senderUuid);
this.eventBus.dispatch(message);
}
protected PluginMessage readPluginMessage(UUID senderUuid, byte[] data)
{
//this.dhSupport.info("Plugin message received. Length: " + data.length);
Decoder decoder = new Decoder(data);
// Read the client's protocol version.
short protocolVersion = decoder.readShort();
if (protocolVersion != this.protocolVersion) {
CloseReasonMessage closeReasonMessage = new CloseReasonMessage();
closeReasonMessage.setReason((protocolVersion > this.protocolVersion ? "Server" : "Client") + " is outdated.");
this.sendPluginMessage(senderUuid, closeReasonMessage);
return closeReasonMessage;
}
// Read the message type ID.
short messageTypeId = decoder.readShort();
Class<? extends PluginMessage> messageClass = (Class<? extends PluginMessage>) this.messageTypeRegistry.getMessageClass(messageTypeId);
//this.dhSupport.info("Looks like a " + messageClass.getSimpleName());
PluginMessage message;
try {
message = messageClass.getConstructor().newInstance();
if (message instanceof TrackablePluginMessage) {
((TrackablePluginMessage) message).setTracker(decoder.readInt());
}
message.decode(decoder);
} catch (Exception exception) {
this.dhSupport.warning("Failed to init message class: " + exception.getClass() + " - " + exception.getMessage());
return null;
}
return message;
}
public void sendPluginMessage(UUID recipientUuid, PluginMessage message)
{
int messageTypeId = this.messageTypeRegistry.getMessageTypeId(message.getClass());
if (messageTypeId == -1) {
this.dhSupport.warning("Trying to send unknown message type: " + message.getClass());
return;
}
Encoder encoder = new Encoder(message.bufferSize());
encoder.writeShort(this.protocolVersion);
encoder.writeShort(messageTypeId);
if (message instanceof TrackablePluginMessage) {
encoder.writeInt(((TrackablePluginMessage) message).getTracker());
}
try {
message.encode(encoder);
} catch (Exception exception) {
this.dhSupport.warning("Failed to encode " + message.getClass().getSimpleName());
exception.printStackTrace();
return;
}
byte[] fullMessage = encoder.toByteArray();
//this.dhSupport.info("Sending: " + Utils.bytesToHex(fullMessage));
this.dhSupport.getPluginMessageSender().sendPluginMessage(recipientUuid, this.pluginChannel, fullMessage);
}
}
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.handler;
import no.jckf.dhsupport.core.DhSupport;
import no.jckf.dhsupport.core.Utils;
import no.jckf.dhsupport.core.bytestream.Decoder;
import no.jckf.dhsupport.core.bytestream.Encoder;
import no.jckf.dhsupport.core.event.EventBus;
import no.jckf.dhsupport.core.message.MessageTypeRegistry;
import no.jckf.dhsupport.core.message.plugin.*;
import org.jetbrains.annotations.NotNull;
import javax.annotation.Nullable;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public class PluginMessageHandler
{
protected DhSupport dhSupport;
private MessageTypeRegistry messageTypeRegistry;
public final String pluginChannel = "distant_horizons:msg";
public final short protocolVersion = 16;
private EventBus<PluginMessage> eventBus;
// Players whose DH connection has been closed. Their messages are ignored until they reconnect.
protected Set<UUID> closedConnections = ConcurrentHashMap.newKeySet();
public PluginMessageHandler(DhSupport dhSupport)
{
this.dhSupport = dhSupport;
this.eventBus = new EventBus<>();
// Define plugin channel message types.
this.messageTypeRegistry = new MessageTypeRegistry();
this.messageTypeRegistry.registerMessageType(0, null);
this.messageTypeRegistry.registerMessageType(1, CloseReasonMessage.class);
this.messageTypeRegistry.registerMessageType(2, LevelInitMessage.class);
this.messageTypeRegistry.registerMessageType(3, LevelInitRequestMessage.class);
this.messageTypeRegistry.registerMessageType(4, RemotePlayerConfigMessage.class);
this.messageTypeRegistry.registerMessageType(5, CancelMessage.class);
this.messageTypeRegistry.registerMessageType(6, ExceptionMessage.class);
this.messageTypeRegistry.registerMessageType(7, FullDataSourceRequestMessage.class);
this.messageTypeRegistry.registerMessageType(8, FullDataSourceResponseMessage.class);
this.messageTypeRegistry.registerMessageType(9, FullDataPartialUpdateMessage.class);
this.messageTypeRegistry.registerMessageType(10, FullDataChunkMessage.class);
}
public void onEnable()
{
}
public void onDisable()
{
}
public void onPlayerDisconnect(UUID playerId)
{
this.closedConnections.remove(playerId);
}
@Nullable
public EventBus<PluginMessage> getEventBus()
{
return this.eventBus;
}
public void onPluginMessageReceived(@NotNull String channel, @NotNull UUID senderUuid, byte[] data)
{
if (this.closedConnections.contains(senderUuid)) {
return;
}
PluginMessage message;
try {
message = this.readPluginMessage(senderUuid, data);
} catch (Exception exception) {
this.dhSupport.warning("Error while parsing incoming plugin message: " + exception.getClass() + " - " + exception.getMessage());
this.dhSupport.warning("Data was: " + Utils.bytesToHex(data));
return;
}
if (message == null) {
return;
}
if (message instanceof CloseReasonMessage) {
this.closedConnections.add(senderUuid);
throw new ConnectionClosedException(((CloseReasonMessage) message).getReason());
}
message.setSender(senderUuid);
this.eventBus.dispatch(message);
}
protected PluginMessage readPluginMessage(UUID senderUuid, byte[] data)
{
//this.dhSupport.info("Plugin message received. Length: " + data.length);
Decoder decoder = new Decoder(data);
// Read the client's protocol version.
short protocolVersion = decoder.readShort();
if (protocolVersion != this.protocolVersion) {
CloseReasonMessage closeReasonMessage = new CloseReasonMessage();
if (protocolVersion > this.protocolVersion) {
closeReasonMessage.setReason("This server's DH plugin is outdated. You will not receive any LOD data.");
} else {
closeReasonMessage.setReason("Your client's version of DH is outdated. You will not receive any LOD data.");
}
this.sendPluginMessage(senderUuid, closeReasonMessage);
return closeReasonMessage;
}
// Read the message type ID.
short messageTypeId = decoder.readShort();
Class<? extends PluginMessage> messageClass = (Class<? extends PluginMessage>) this.messageTypeRegistry.getMessageClass(messageTypeId);
//this.dhSupport.info("Looks like a " + messageClass.getSimpleName());
PluginMessage message;
try {
message = messageClass.getConstructor().newInstance();
if (message instanceof TrackablePluginMessage) {
((TrackablePluginMessage) message).setTracker(decoder.readInt());
}
message.decode(decoder);
} catch (Exception exception) {
this.dhSupport.warning("Failed to init message class: " + exception.getClass() + " - " + exception.getMessage());
return null;
}
return message;
}
public void sendPluginMessage(UUID recipientUuid, PluginMessage message)
{
int messageTypeId = this.messageTypeRegistry.getMessageTypeId(message.getClass());
if (messageTypeId == -1) {
this.dhSupport.warning("Trying to send unknown message type: " + message.getClass());
return;
}
Encoder encoder = new Encoder(message.bufferSize());
encoder.writeShort(this.protocolVersion);
encoder.writeShort(messageTypeId);
if (message instanceof TrackablePluginMessage) {
encoder.writeInt(((TrackablePluginMessage) message).getTracker());
}
try {
message.encode(encoder);
} catch (Exception exception) {
this.dhSupport.warning("Failed to encode " + message.getClass().getSimpleName());
exception.printStackTrace();
return;
}
byte[] fullMessage = encoder.toByteArray();
//this.dhSupport.info("Sending: " + Utils.bytesToHex(fullMessage));
this.dhSupport.getPluginMessageSender().sendPluginMessage(recipientUuid, this.pluginChannel, fullMessage);
}
}
@@ -1,42 +1,42 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message;
import java.util.ArrayList;
import java.util.List;
public class MessageTypeRegistry
{
protected List<Class<? extends Message>> messageTypes = new ArrayList<>();
public void registerMessageType(int id, Class<? extends Message> messageClass)
{
this.messageTypes.add(id, messageClass);
}
public Class<? extends Message> getMessageClass(int id)
{
return this.messageTypes.size() - 1 >= id ? this.messageTypes.get(id) : null;
}
public int getMessageTypeId(Class<? extends Message> messageClass)
{
return this.messageTypes.indexOf(messageClass);
}
}
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message;
import java.util.ArrayList;
import java.util.List;
public class MessageTypeRegistry
{
protected List<Class<? extends Message>> messageTypes = new ArrayList<>();
public void registerMessageType(int id, Class<? extends Message> messageClass)
{
this.messageTypes.add(id, messageClass);
}
public Class<? extends Message> getMessageClass(int id)
{
return this.messageTypes.size() - 1 >= id ? this.messageTypes.get(id) : null;
}
public int getMessageTypeId(Class<? extends Message> messageClass)
{
return this.messageTypes.indexOf(messageClass);
}
}
@@ -1,91 +1,111 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message.plugin;
import no.jckf.dhsupport.core.bytestream.Decoder;
import no.jckf.dhsupport.core.bytestream.Encoder;
public class LevelInitMessage extends PluginMessage // TODO: Should probably eventually be a TrackablePluginMessage
{
protected String namespacedKey;
protected String serverKey;
protected String levelKey;
protected long time;
public void setNamespacedKey(String namespacedKey)
{
this.namespacedKey = namespacedKey;
}
public String getNamespacedKey()
{
return namespacedKey;
}
public void setServerKey(String serverKey)
{
this.serverKey = serverKey;
}
public String getServerKey()
{
return this.serverKey;
}
public void setLevelKey(String levelKey)
{
this.levelKey = levelKey;
}
public String getLevelKey()
{
return this.levelKey;
}
public void setTime(long time)
{
this.time = time;
}
public long getTime()
{
return this.time;
}
@Override
public void encode(Encoder encoder)
{
encoder.writeShortString(this.namespacedKey);
encoder.writeShortString(this.serverKey);
encoder.writeShortString(this.levelKey);
encoder.writeLong(this.time);
}
@Override
public void decode(Decoder decoder)
{
this.namespacedKey = decoder.readShortString();
this.serverKey = decoder.readShortString();
this.levelKey = decoder.readShortString();
this.time = decoder.readLong();
}
}
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message.plugin;
import no.jckf.dhsupport.core.bytestream.Decoder;
import no.jckf.dhsupport.core.bytestream.Encoder;
import java.util.regex.Pattern;
public class LevelInitMessage extends PluginMessage // TODO: Should probably eventually be a TrackablePluginMessage
{
// Distant Horizons clients refuse level init messages with keys that don't match these.
public static final Pattern SERVER_KEY_PATTERN = Pattern.compile("^(?=.{1,150}$)[a-zA-Z0-9_-]+$");
public static final Pattern LEVEL_KEY_PATTERN = Pattern.compile("^(?=.{1,150}$)([a-zA-Z0-9_-]+@)?[a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)?$");
protected String namespacedKey;
protected String serverKey;
protected String levelKey;
protected long time;
/**
* The server key is optional, so empty keys are valid too.
*/
public static boolean isValidServerKey(String serverKey)
{
return serverKey.isEmpty() || SERVER_KEY_PATTERN.matcher(serverKey).matches();
}
public static boolean isValidLevelKey(String levelKey)
{
return LEVEL_KEY_PATTERN.matcher(levelKey).matches();
}
public void setNamespacedKey(String namespacedKey)
{
this.namespacedKey = namespacedKey;
}
public String getNamespacedKey()
{
return namespacedKey;
}
public void setServerKey(String serverKey)
{
this.serverKey = serverKey;
}
public String getServerKey()
{
return this.serverKey;
}
public void setLevelKey(String levelKey)
{
this.levelKey = levelKey;
}
public String getLevelKey()
{
return this.levelKey;
}
public void setTime(long time)
{
this.time = time;
}
public long getTime()
{
return this.time;
}
@Override
public void encode(Encoder encoder)
{
encoder.writeShortString(this.namespacedKey);
encoder.writeShortString(this.serverKey);
encoder.writeShortString(this.levelKey);
encoder.writeLong(this.time);
}
@Override
public void decode(Decoder decoder)
{
this.namespacedKey = decoder.readShortString();
this.serverKey = decoder.readShortString();
this.levelKey = decoder.readShortString();
this.time = decoder.readLong();
}
}
@@ -1,40 +1,40 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message.plugin;
import no.jckf.dhsupport.core.message.Message;
import javax.annotation.Nullable;
import java.util.UUID;
public abstract class PluginMessage extends Message
{
protected UUID sender;
public void setSender(UUID uuid)
{
this.sender = uuid;
}
@Nullable
public UUID getSender()
{
return this.sender;
}
}
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message.plugin;
import no.jckf.dhsupport.core.message.Message;
import javax.annotation.Nullable;
import java.util.UUID;
public abstract class PluginMessage extends Message
{
protected UUID sender;
public void setSender(UUID uuid)
{
this.sender = uuid;
}
@Nullable
public UUID getSender()
{
return this.sender;
}
}
@@ -22,6 +22,7 @@ import no.jckf.dhsupport.core.bytestream.Decoder;
import no.jckf.dhsupport.core.bytestream.Encoder;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
import no.jckf.dhsupport.core.enums.GeneratorPlan;
public class RemotePlayerConfigMessage extends PluginMessage
{
@@ -31,7 +32,7 @@ public class RemotePlayerConfigMessage extends PluginMessage
DhsConfig.BORDER_CENTER_X,
DhsConfig.BORDER_CENTER_Z,
DhsConfig.BORDER_RADIUS,
DhsConfig.FULL_DATA_REQUEST_CONCURRENCY_LIMIT,
DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT,
DhsConfig.REAL_TIME_UPDATES_ENABLED,
DhsConfig.REAL_TIME_UPDATE_RADIUS,
DhsConfig.LOGIN_DATA_SYNC_ENABLED,
@@ -44,7 +45,7 @@ public class RemotePlayerConfigMessage extends PluginMessage
protected boolean distantGenerationEnabled;
protected int fullDataRequestConcurrencyLimit;
protected int clientLodRequestConcurrencyLimit;
protected boolean realTimeUpdatesEnabled;
@@ -84,14 +85,14 @@ public class RemotePlayerConfigMessage extends PluginMessage
return this.distantGenerationEnabled;
}
public void setFullDataRequestConcurrencyLimit(int limit)
public void setClientLodRequestConcurrencyLimit(int limit)
{
this.fullDataRequestConcurrencyLimit = limit;
this.clientLodRequestConcurrencyLimit = limit;
}
public int getFullDataRequestConcurrencyLimit()
public int getClientLodRequestConcurrencyLimit()
{
return this.fullDataRequestConcurrencyLimit;
return this.clientLodRequestConcurrencyLimit;
}
public void setRealTimeUpdatesEnabled(boolean enabled)
@@ -187,14 +188,15 @@ public class RemotePlayerConfigMessage extends PluginMessage
@Override
public void encode(Encoder encoder)
{
encoder.writeBoolean(this.distantGenerationEnabled);
// Sent as the most permissive plan, so the client's own plan decides the generation mode.
encoder.writeByte((this.distantGenerationEnabled ? GeneratorPlan.SURFACE_THEN_CHUNKS : GeneratorPlan.DISABLED).ordinal());
encoder.writeInt(this.renderDistance);
encoder.writeInt(this.borderCenterX);
encoder.writeInt(this.borderCenterZ);
encoder.writeInt(this.borderRadius);
encoder.writeInt(this.fullDataRequestConcurrencyLimit);
encoder.writeInt(this.clientLodRequestConcurrencyLimit);
encoder.writeBoolean(this.realTimeUpdatesEnabled);
encoder.writeInt(this.realTimeUpdateRadius);
@@ -209,14 +211,14 @@ public class RemotePlayerConfigMessage extends PluginMessage
@Override
public void decode(Decoder decoder)
{
this.distantGenerationEnabled = decoder.readBoolean();
this.distantGenerationEnabled = GeneratorPlan.byOrdinal(decoder.readByte()).generationEnabled;
this.renderDistance = decoder.readInt();
this.borderCenterX = decoder.readInt();
this.borderCenterZ = decoder.readInt();
this.borderRadius = decoder.readInt();
this.fullDataRequestConcurrencyLimit = decoder.readInt();
this.clientLodRequestConcurrencyLimit = decoder.readInt();
this.realTimeUpdatesEnabled = decoder.readBoolean();
this.realTimeUpdateRadius = decoder.readInt();
@@ -241,7 +243,7 @@ public class RemotePlayerConfigMessage extends PluginMessage
this.borderCenterZ = borderCenterZ == null ? 0 : borderCenterZ;
this.borderRadius = borderRadius == null ? 0 : borderRadius;
this.fullDataRequestConcurrencyLimit = config.getInt(DhsConfig.FULL_DATA_REQUEST_CONCURRENCY_LIMIT);
this.clientLodRequestConcurrencyLimit = config.getInt(DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT);
this.realTimeUpdatesEnabled = config.getBool(DhsConfig.REAL_TIME_UPDATES_ENABLED);
this.realTimeUpdateRadius = config.getInt(DhsConfig.REAL_TIME_UPDATE_RADIUS);
@@ -264,7 +266,7 @@ public class RemotePlayerConfigMessage extends PluginMessage
config.set(DhsConfig.BORDER_CENTER_Z, this.borderCenterZ);
config.set(DhsConfig.BORDER_RADIUS, this.borderRadius);
config.set(DhsConfig.FULL_DATA_REQUEST_CONCURRENCY_LIMIT, this.fullDataRequestConcurrencyLimit);
config.set(DhsConfig.CLIENT_LOD_REQUEST_CONCURRENCY_LIMIT, this.clientLodRequestConcurrencyLimit);
config.set(DhsConfig.REAL_TIME_UPDATES_ENABLED, this.realTimeUpdatesEnabled);
config.set(DhsConfig.REAL_TIME_UPDATE_RADIUS, this.realTimeUpdateRadius);
@@ -0,0 +1,125 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.world;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
import no.jckf.dhsupport.core.configuration.WorldConfiguration;
public abstract class AbstractWorldInterface implements WorldInterface
{
protected String name;
protected String key;
protected String levelKey;
protected String serverKey;
protected Configuration config;
protected WorldConfiguration worldConfig;
protected boolean dummyMode = false;
public AbstractWorldInterface(String name, Configuration config)
{
this.name = name;
this.key = name.replaceAll("[^a-zA-Z0-9_-]", "");
this.config = config;
this.worldConfig = new WorldConfiguration(this, config);
// Not updated if the config changes at runtime.
this.levelKey = this.worldConfig.getString(DhsConfig.LEVEL_KEY_PREFIX, "") + this.key;
this.serverKey = this.worldConfig.getString(DhsConfig.SERVER_KEY, "");
}
@Override
public String getName()
{
return this.name;
}
@Override
public String getKey()
{
return this.key;
}
@Override
public String getLevelKey()
{
return this.levelKey;
}
@Override
public String getServerKey()
{
return this.serverKey;
}
@Override
public Configuration getConfig()
{
return this.worldConfig;
}
@Override
public void setDummyMode(boolean enable)
{
this.dummyMode = enable;
}
@Override
public boolean isDummyMode()
{
return this.dummyMode;
}
@Override
public boolean isInsideWorldBorder(int x, int z, int areaWidth)
{
Integer borderCenterX = this.getWorldBorderX();
Integer borderCenterZ = this.getWorldBorderZ();
Integer borderRadius = this.getWorldBorderRadius();
if (borderCenterX == null || borderCenterZ == null || borderRadius == null) {
return true;
}
int borderMinX = borderCenterX - borderRadius;
int borderMaxX = borderCenterX + borderRadius;
int borderMinZ = borderCenterZ - borderRadius;
int borderMaxZ = borderCenterZ + borderRadius;
int sectionMaxX = x + (areaWidth - 1);
int sectionMaxZ = z + (areaWidth - 1);
return x <= borderMaxX
&& sectionMaxX >= borderMinX
&& z <= borderMaxZ
&& sectionMaxZ >= borderMinZ;
}
@Override
public String getBiomeAt(int x, int z)
{
return this.getBiomeAt(x, this.getSeaLevel(), z);
}
}
@@ -35,6 +35,10 @@ public interface WorldInterface extends Configurable
String getKey();
String getLevelKey();
String getServerKey();
double getCoordinateScale();
@Nullable Integer getWorldBorderX();
@@ -43,6 +47,8 @@ public interface WorldInterface extends Configurable
@Nullable Integer getWorldBorderRadius();
boolean isInsideWorldBorder(int x, int z, int area);
void setDummyMode(boolean enable);
boolean isDummyMode();
+256 -238
View File
@@ -1,238 +1,256 @@
# Where should the plugin store its database file?
# Required.
# Default: "{datadir}/data.sqlite"
database_path: "{datadir}/data.sqlite"
# Maximum supported render distance, given as a radius in number of chunks. Clients will be capped to this value.
# Required.
# Default: 1024
render_distance: 1024
# Are LOD features enabled at all?
# Disable if you only want to use the plugin for level key info in a proxied setup.
# Required.
# Default: true
distant_generation_enabled: true
# Number of parallel LOD requests per client.
# Required.
# Default: 20
full_data_request_concurrency_limit: 20
# Send LOD updates to players in real-time?
# Required.
# Default: true
real_time_updates_enabled: true
# How close to an update players have to be in order to receive it. Given as a radius in number of chunks.
# Required.
# Default: 128
real_time_update_radius: 128
# Let clients ask for updates to LODs they've previously downloaded.
# Required.
# Default: true
login_data_sync_enabled: true
# How far away can a player request updates for during login sync? Given as a radius in number of chunks.
# Required.
# Default: 128
login_data_sync_radius: 128
# How many login sync requests can a client send at once?
# Required.
# Default: 50
login_data_sync_rc_limit: 50
# Currently unsupported.
# Required.
# Default: 0
max_data_transfer_speed: 0
# Should new chunks be generated to build LODs?
# If you disable this, the plugin will not be able to create LODs in areas where chunks don't already exist.
# Optional.
# Default: true
#generate_new_chunks: false
# Show warning during startup if chunk generation is enabled?
# Optional.
# Default: true
#generate_new_chunks_warning: false
# Specs for a fake LOD to send when chunk generation is disabled and LOD requests otherwise would have been rejected.
# Optional.
# Default: Disabled
#dummy_chunk:
# - "0-4,bedrock"
# - "5-79,stone"
# - "80-126,water"
# How frequently (in seconds) should LODs be updated if world changes are detected?
# Required.
# Default: 15
lod_refresh_interval: 15
# Select which LOD builder to use.
# Valid options are: FullBuilder, FastOverworldBuilder, and None.
# Required.
# Default: FullBuilder
builder_type: FullBuilder
# Trust world height map.
# Disable if you're using a world that was generated with an external tool and your LODs come out broken.
# WARNING: This will make LOD building MUCH slower!
# Optional.
# Default: true
#trust_height_map: false
# Number of blocks to advance between each sample on the Y axis when generating LODs.
# A setting of 1 will sample every block, while 2 will sample every other block, and so on.
# CURRENTLY BROKEN. THIS VALUE IS IGNORED.
# Required.
# Default: 2
builder_resolution: 2
# Whether to include non-colliding blocks when they are the top-most block.
# True means tall grass, flowers, and snow will color the block below them.
# Optional.
# Default: true
#include_non-colliding_top_layer: false
# If your server is accessible via multiple different addresses, consider setting a server key to let clients know
# that even though the address is different; it's the same server.
# This value must be GLOBALLY UNIQUE! No other server on planet Earth should have the same server key as you.
# Optional.
#server_key:
# When running DHS on a proxied server, set this to a value that is unique to this specific server.
# Optional.
#level_key_prefix:
# Use the vanilla world border of the respective world.
# Optional.
# Default: true
#use_vanilla_world_border: false
# How many chunks beyond the vanilla world border can we see (if it's enabled)?. If set to auto, it uses the server's
# view distance, which effectively means that if standing at the border you can see as far as the server would generate
# chunks naturally.
# Optional.
# Default: auto
#vanilla_world_border_expansion: 12
# List of blocks to replace with other blocks during LOD building.
# Useful to hide ugly blocks or valuable resources.
# Don't use the "minecraft:" prefix for vanilla blocks.
# DO use the namespace prefix for custom blocks.
# Optional.
# Default: Hide bamboo, seagrass, kelp, and iron + diamond ores.
material_map:
bamboo: air
seagrass: water
tall_seagrass: water
kelp: water
kelp_plant: water
iron_ore: stone
diamond_ore: stone
# Vertical distance to offset light data.
# This is a hack to work around SPIGOT-7498. If you're somehow on a 1.20.1 server _with_ this patch, set the value to 0.
# Optional.
# Default:
# - On 1.20.1: Minimum build height (-64 for vanilla worlds)
# - Any other version: 0
#light_offset_hack: 0
# World specific settings.
# You can override any of the above settings on a per-world basis.
worlds:
world:
# Use builder that is optimized for overworld.
builder_type: FastOverworldBuilder
# Should biome be checked for every data point, or just once per column?
# Enable if your world has 3d/vertical biomes.
# Optional.
# Default: false
#sample_biomes_3d: true
# When using the FastOverworldBuilder, should LODs always contain data down to at least sea level?
# Enable if your world has floating islands or mountains/structures with large overhangs above sea level.
# Optional.
# Default: false
#scan_to_sea_level: true
# When using the FastOverworldBuilder, should the bottom data point be stretched to the lowest permitted build
# height? This will make the LODs look more complete, and gives a theoretical boost to render performance.
# Optional.
# Default: true
#fast_underfill: false
# Limit LOD generation to within this area. Alle three values are in blocks.
# Optional.
#border_center_x: 0
#border_center_z: 0
#border_radius: 5000
world_nether:
# It might make sense to have a lower view distance in the nether, since visibility will be low simply because of
# the level design anyway.
render_distance: 64
# This is a hack to prevent light from shining through ceilings, mostly noticeable on the nether ceiling.
# Only works with the FullBuilder, should only be used with builder_resolution=1, and does have some side effects.
#perform_underglow_hack: true
#builder_resolution: 1
# List of events that should trigger LOD updates.
# Required.
# Default: A bunch (:
update_events:
- org.bukkit.event.block.BlockPlaceEvent
- org.bukkit.event.block.BlockBreakEvent
# - org.bukkit.event.block.TNTPrimeEvent
- org.bukkit.event.block.BlockExplodeEvent
# - org.bukkit.event.block.BlockIgniteEvent
# - org.bukkit.event.block.BlockBurnEvent
# - org.bukkit.event.block.LeavesDecayEvent
# - org.bukkit.event.block.BlockFadeEvent
# - org.bukkit.event.block.BlockGrowEvent
# - org.bukkit.event.block.MoistureChangeEvent
# - org.bukkit.event.block.BlockRedstoneEvent
- org.bukkit.event.block.BlockPistonExtendEvent
- org.bukkit.event.block.BlockPistonRetractEvent
# - org.bukkit.event.block.SpongeAbsorbEvent
# - org.bukkit.event.inventory.FurnaceBurnEvent
- org.bukkit.event.world.StructureGrowEvent
- org.bukkit.event.world.ChunkPopulateEvent
# Number of worker threads used to generate LODs.
# Required.
# Default: 4
scheduler_threads: 4
# Show a message to players who join if they don't have Distant Horizons installed?
# Optional.
# Default: Disabled
#ad_message:
# - "§aThis server supports §bDistant Horizons§a!"
# - "§aVisit §nhttp://dh-mod.com/§r§a to learn more."
# If a user uses /dh should we give them a help text?
# Optional.
# Default: true
#enable_dh_command_help_text: false
# Should we notify server admins when a new version of the plugin is available?
# Optional.
# Default: true
#check_for_updates: false
# Enable debug logging?
# Optional.
# Default: false
#debug: true
# Don't touch :)
config_version: 10
# Where should the plugin store its database file?
# Required.
# Default: "{datadir}/data.sqlite"
database_path: "{datadir}/data.sqlite"
# Maximum supported render distance, given as a radius in number of chunks. Clients will be capped to this value.
# Required.
# Default: 1024
render_distance: 1024
# Are LOD features enabled at all?
# Disable if you only want to use the plugin for level key info in a proxied setup.
# Required.
# Default: true
distant_generation_enabled: true
# Maximum number of concurrent LOD requests per client.
# Required.
# Default: 20
client_lod_request_concurrency_limit: 20
# Maximum concurrent LOD requests server-wide, across all players. 0 = unlimited.
# Optional.
# Default: 0
#server_lod_request_concurrency_limit: 100
# Send LOD updates to players in real-time?
# Required.
# Default: true
real_time_updates_enabled: true
# How close to an update players have to be in order to receive it. Given as a radius in number of chunks.
# Required.
# Default: 128
real_time_update_radius: 128
# Let clients ask for updates to LODs they've previously downloaded.
# Required.
# Default: true
login_data_sync_enabled: true
# How far away can a player request updates for during login sync? Given as a radius in number of chunks.
# Required.
# Default: 128
login_data_sync_radius: 128
# How many login sync requests can a client send at once?
# Required.
# Default: 50
login_data_sync_rc_limit: 50
# Currently unsupported.
# Required.
# Default: 0
max_data_transfer_speed: 0
# Should new chunks be generated to build LODs?
# If you disable this, the plugin will not be able to create LODs in areas where chunks don't already exist.
# Optional.
# Default: true
#generate_new_chunks: false
# Show warning during startup if chunk generation is enabled?
# Optional.
# Default: true
#generate_new_chunks_warning: false
# Should new chunks be saved to disk, or should the plugin attempt to discard them?
# There is no guarantee that the server will respect every discard request, so chunks may still end up saved to disk.
# Before you consider enabling this feature, please see https://github.com/PaperMC/Paper/issues/14188
# Optional.
# Default: false
#discard_new_chunks: true
# Specs for a fake LOD to send when chunk generation is disabled and LOD requests otherwise would have been rejected.
# Optional.
# Default: Disabled
#dummy_chunk:
# - "0-4,bedrock"
# - "5-79,stone"
# - "80-126,water"
# How frequently (in seconds) should LODs be updated if world changes are detected?
# Required.
# Default: 15
lod_refresh_interval: 15
# Select which LOD builder to use.
# Valid options are: FullBuilder, FastOverworldBuilder, and None.
# Required.
# Default: FullBuilder
builder_type: FullBuilder
# Trust world height map.
# Disable if you're using a world that was generated with an external tool and your LODs come out broken.
# WARNING: This will make LOD building MUCH slower!
# Optional.
# Default: true
#trust_height_map: false
# Number of blocks to advance between each sample on the Y axis when generating LODs.
# A setting of 1 will sample every block, while 2 will sample every other block, and so on.
# CURRENTLY BROKEN. THIS VALUE IS IGNORED.
# Required.
# Default: 2
builder_resolution: 2
# Whether to include non-colliding blocks when they are the top-most block.
# True means tall grass, flowers, and snow will color the block below them.
# Optional.
# Default: true
#include_non-colliding_top_layer: false
# If your server is accessible via multiple different addresses, consider setting a server key to let clients know
# that even though the address is different; it's the same server.
# This value must be GLOBALLY UNIQUE! No other server on planet Earth should have the same server key as you.
# Can only contain letters (a-z, A-Z), digits (0-9), underscores and dashes, and be at most 150 characters long.
# Optional.
# Default: empty
#server_key:
# When running DHS on a proxied server, set this to a value that is unique to this specific server.
# Each world's level key is this prefix followed by the world name, with all characters except letters (a-z, A-Z),
# digits (0-9), underscores and dashes removed. The prefix should only use those characters as well, but may end with @.
# Level keys must be between 1 and 150 characters long, and unique for each world.
# Optional.
# Default: empty
#level_key_prefix:
# Use the vanilla world border of the respective world.
# Optional.
# Default: true
#use_vanilla_world_border: false
# How many chunks beyond the vanilla world border can we see (if it's enabled)?. If set to auto, it uses the server's
# view distance, which effectively means that if standing at the border you can see as far as the server would generate
# chunks naturally.
# Optional.
# Default: auto
#vanilla_world_border_expansion: 12
# List of blocks to replace with other blocks during LOD building.
# Useful to hide ugly blocks or valuable resources.
# Don't use the "minecraft:" prefix for vanilla blocks.
# DO use the namespace prefix for custom blocks.
# Optional.
# Default: Hide bamboo, seagrass, kelp, and iron + diamond ores.
material_map:
bamboo: air
seagrass: water
tall_seagrass: water
kelp: water
kelp_plant: water
iron_ore: stone
diamond_ore: stone
# Vertical distance to offset light data.
# This is a hack to work around SPIGOT-7498. If you're somehow on a 1.20.1 server _with_ this patch, set the value to 0.
# Optional.
# Default:
# - On 1.20.1: Minimum build height (-64 for vanilla worlds)
# - Any other version: 0
#light_offset_hack: 0
# World specific settings.
# You can override any of the above settings on a per-world basis.
worlds:
world:
# Use builder that is optimized for overworld.
builder_type: FastOverworldBuilder
# Should biome be checked for every data point, or just once per column?
# Enable if your world has 3d/vertical biomes.
# Optional.
# Default: false
#sample_biomes_3d: true
# When using the FastOverworldBuilder, should LODs always contain data down to at least sea level?
# Enable if your world has floating islands or mountains/structures with large overhangs above sea level.
# Optional.
# Default: false
#scan_to_sea_level: true
# When using the FastOverworldBuilder, should the bottom data point be stretched to the lowest permitted build
# height? This will make the LODs look more complete, and gives a theoretical boost to render performance.
# Optional.
# Default: true
#fast_underfill: false
# Limit LOD generation to within this area. Alle three values are in blocks.
# Optional.
#border_center_x: 0
#border_center_z: 0
#border_radius: 5000
world_nether:
# It might make sense to have a lower view distance in the nether, since visibility will be low simply because of
# the level design anyway.
render_distance: 64
# This is a hack to prevent light from shining through ceilings, mostly noticeable on the nether ceiling.
# Only works with the FullBuilder, should only be used with builder_resolution=1, and does have some side effects.
#perform_underglow_hack: true
#builder_resolution: 1
# List of events that should trigger LOD updates.
# Required.
# Default: A bunch (:
update_events:
- org.bukkit.event.block.BlockPlaceEvent
- org.bukkit.event.block.BlockBreakEvent
# - org.bukkit.event.block.TNTPrimeEvent
- org.bukkit.event.block.BlockExplodeEvent
# - org.bukkit.event.block.BlockIgniteEvent
# - org.bukkit.event.block.BlockBurnEvent
# - org.bukkit.event.block.LeavesDecayEvent
# - org.bukkit.event.block.BlockFadeEvent
# - org.bukkit.event.block.BlockGrowEvent
# - org.bukkit.event.block.MoistureChangeEvent
# - org.bukkit.event.block.BlockRedstoneEvent
- org.bukkit.event.block.BlockPistonExtendEvent
- org.bukkit.event.block.BlockPistonRetractEvent
# - org.bukkit.event.block.SpongeAbsorbEvent
# - org.bukkit.event.inventory.FurnaceBurnEvent
- org.bukkit.event.world.StructureGrowEvent
- org.bukkit.event.world.ChunkPopulateEvent
# Number of worker threads used to generate LODs.
# Required.
# Default: 4
scheduler_threads: 4
# Show a message to players who join if they don't have Distant Horizons installed?
# Optional.
# Default: Disabled
#ad_message:
# - "§aThis server supports §bDistant Horizons§a!"
# - "§aVisit §nhttp://dh-mod.com/§r§a to learn more."
# If a user uses /dh should we give them a help text?
# Optional.
# Default: true
#enable_dh_command_help_text: false
# Should we notify server admins when a new version of the plugin is available?
# Optional.
# Default: true
#check_for_updates: false
# Enable debug logging?
# Optional.
# Default: false
#debug: true
# Don't touch :)
config_version: 12
@@ -0,0 +1,179 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core;
import junit.framework.TestCase;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
import no.jckf.dhsupport.core.world.FakeWorldInterface;
import no.jckf.dhsupport.core.world.WorldInterface;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
public class WorldLookupTest extends TestCase
{
protected DhSupport dhSupport;
protected WorldInterface overworld;
protected WorldInterface nether;
protected WorldInterface spacedWorld;
protected List<String> warnings = new ArrayList<>();
@Override
protected void setUp()
{
this.dhSupport = new DhSupport("test", "1.21");
this.dhSupport.setLogger(capturingLogger(this.warnings));
Configuration config = this.dhSupport.getConfig();
config.set(DhsConfig.LEVEL_KEY_PREFIX, "prefix_");
config.set("worlds.My World.v2." + DhsConfig.LEVEL_KEY_PREFIX, "");
this.overworld = new FakeWorldInterface("world", config);
this.nether = new FakeWorldInterface("world_nether", config);
// Level keys are built from sanitized world names, so they can't be fed back into Bukkit.getWorld().
this.spacedWorld = new FakeWorldInterface("My World.v2", config);
for (WorldInterface world : new WorldInterface[]{ this.overworld, this.nether, this.spacedWorld }) {
this.dhSupport.setWorldInterface(world.getId(), world);
}
}
protected static Logger capturingLogger(List<String> messages)
{
Logger logger = Logger.getAnonymousLogger();
logger.setUseParentHandlers(false);
logger.addHandler(new Handler()
{
@Override
public void publish(LogRecord record)
{
messages.add(record.getMessage());
}
@Override
public void flush()
{
}
@Override
public void close()
{
}
});
return logger;
}
public void testForwardLookupResolvesBackToSameWorld()
{
for (WorldInterface world : this.dhSupport.getWorldInterfaces().values()) {
assertSame(world, this.dhSupport.getWorldInterfaceByLevelKey(world.getLevelKey()));
}
}
public void testBackwardLookupResolvesToMatchingWorld()
{
assertSame(this.overworld, this.dhSupport.getWorldInterfaceByLevelKey("prefix_world"));
assertSame(this.nether, this.dhSupport.getWorldInterfaceByLevelKey("prefix_world_nether"));
assertSame(this.spacedWorld, this.dhSupport.getWorldInterfaceByLevelKey("MyWorldv2"));
}
public void testBackwardLookupRequiresExactMatch()
{
// Neither the unprefixed key nor a shared prefix should match.
assertNull(this.dhSupport.getWorldInterfaceByLevelKey("world"));
assertNull(this.dhSupport.getWorldInterfaceByLevelKey("prefix_"));
}
public void testBackwardLookupDoesNotMatchRawWorldName()
{
assertNull(this.dhSupport.getWorldInterfaceByLevelKey("My World.v2"));
}
public void testBackwardLookupReturnsNullForUnknownKey()
{
assertNull(this.dhSupport.getWorldInterfaceByLevelKey("prefix_world_the_end"));
assertNull(this.dhSupport.getWorldInterfaceByLevelKey(""));
}
public void testRemovedWorldIsNoLongerFound()
{
this.dhSupport.setWorldInterface(this.nether.getId(), null);
assertNull(this.dhSupport.getWorldInterfaceByLevelKey("prefix_world_nether"));
}
public void testNoWarningForUniqueLevelKeys()
{
assertTrue(this.warnings.toString(), this.warnings.isEmpty());
}
public void testWarningForDuplicateLevelKey()
{
// "My World.v2" and "MyWorld.v2" both sanitize to "MyWorldv2", and have the same empty prefix.
this.dhSupport.getConfig().set("worlds.MyWorld.v2." + DhsConfig.LEVEL_KEY_PREFIX, "");
WorldInterface duplicate = new FakeWorldInterface("MyWorld.v2", this.dhSupport.getConfig());
this.dhSupport.setWorldInterface(duplicate.getId(), duplicate);
assertEquals(1, this.warnings.size());
assertTrue(this.warnings.get(0), this.warnings.get(0).contains("\"MyWorldv2\""));
}
public void testNoWarningWhenReplacingSameWorld()
{
this.dhSupport.setWorldInterface(this.overworld.getId(), this.overworld);
assertTrue(this.warnings.toString(), this.warnings.isEmpty());
}
public void testWarningForInvalidLevelKey()
{
// Without a prefix, nothing is left of the name once it is sanitized.
this.dhSupport.getConfig().set("worlds.世界." + DhsConfig.LEVEL_KEY_PREFIX, "");
WorldInterface world = new FakeWorldInterface("世界", this.dhSupport.getConfig());
this.dhSupport.setWorldInterface(world.getId(), world);
assertEquals(1, this.warnings.size());
assertTrue(this.warnings.get(0), this.warnings.get(0).contains("\"世界\" has the level key \"\""));
}
public void testWarningForInvalidServerKey()
{
this.dhSupport.getConfig().set(DhsConfig.SERVER_KEY, "my server!");
WorldInterface world = new FakeWorldInterface("other", this.dhSupport.getConfig());
this.dhSupport.setWorldInterface(world.getId(), world);
assertEquals(1, this.warnings.size());
assertTrue(this.warnings.get(0), this.warnings.get(0).contains("server key \"my server!\""));
}
}
@@ -0,0 +1,122 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.configuration;
import junit.framework.TestCase;
import no.jckf.dhsupport.core.world.WorldInterface;
import java.lang.reflect.Proxy;
public class WorldConfigurationTest extends TestCase
{
protected Configuration globalConfig;
protected WorldConfiguration worldConfig;
protected WorldConfiguration otherWorldConfig;
@Override
protected void setUp()
{
this.globalConfig = new Configuration();
this.worldConfig = new WorldConfiguration(fakeWorld("world"), this.globalConfig);
this.otherWorldConfig = new WorldConfiguration(fakeWorld("world_nether"), this.globalConfig);
}
/**
* WorldConfiguration only ever calls getName() on the world it's given, so a minimal proxy
* stands in for the ~40 other methods WorldInterface declares.
*/
protected static WorldInterface fakeWorld(String name)
{
return (WorldInterface) Proxy.newProxyInstance(
WorldInterface.class.getClassLoader(),
new Class[]{ WorldInterface.class },
(proxy, method, args) -> method.getName().equals("getName") ? name : null
);
}
public void testHasIsTrueForGlobalOnlyKey()
{
this.globalConfig.set("client_lod_request_concurrency_limit", 20);
assertTrue(this.worldConfig.has("client_lod_request_concurrency_limit"));
}
public void testHasIsTrueForWorldSpecificKey()
{
this.worldConfig.set("client_lod_request_concurrency_limit", 4);
assertTrue(this.worldConfig.has("client_lod_request_concurrency_limit"));
}
public void testHasIsFalseForUnsetKey()
{
assertFalse(this.worldConfig.has("nonexistent_key"));
}
public void testHasAgreesWithGetForGlobalKey()
{
this.globalConfig.set("some_key", "value");
assertEquals(this.worldConfig.has("some_key"), this.worldConfig.get("some_key") != null);
}
public void testHasAgreesWithGetForWorldSpecificKey()
{
this.worldConfig.set("some_key", "value");
assertEquals(this.worldConfig.has("some_key"), this.worldConfig.get("some_key") != null);
}
public void testHasAgreesWithGetForUnsetKey()
{
assertEquals(this.worldConfig.has("some_key"), this.worldConfig.get("some_key") != null);
}
public void testSetDoesNotShadowOtherWorlds()
{
this.worldConfig.set("some_key", "world-value");
assertFalse(this.otherWorldConfig.has("some_key"));
assertNull(this.otherWorldConfig.get("some_key"));
}
public void testIncrementIsVisibleThroughGet()
{
this.worldConfig.increment("counter");
this.worldConfig.increment("counter");
assertEquals(2, (int) this.worldConfig.getInt("counter"));
}
public void testClearDropsOnlyThisWorldsKeys()
{
this.globalConfig.set("global_key", "global-value");
this.worldConfig.set("world_key", "world-value");
this.otherWorldConfig.set("other_world_key", "other-world-value");
this.worldConfig.clear();
assertFalse(this.worldConfig.has("world_key"));
assertTrue(this.globalConfig.has("global_key"));
assertTrue(this.otherWorldConfig.has("other_world_key"));
}
}
@@ -0,0 +1,52 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.message.plugin;
import junit.framework.TestCase;
public class LevelInitMessageTest extends TestCase
{
public void testValidLevelKeys()
{
for (String levelKey : new String[]{ "world", "DHTestWorld", "world_nether", "a-b_C9", "server1_world", "lobby@world", "lobby@minecraft:overworld", "a".repeat(150) }) {
assertTrue(levelKey, LevelInitMessage.isValidLevelKey(levelKey));
}
}
public void testInvalidLevelKeys()
{
for (String levelKey : new String[]{ "", "my world", "world.v2", "verdén", "a@b@c", "a:b:c", "@world", "world:", "a".repeat(151) }) {
assertFalse(levelKey, LevelInitMessage.isValidLevelKey(levelKey));
}
}
public void testValidServerKeys()
{
for (String serverKey : new String[]{ "", "abc", "my-server_1", "a".repeat(150) }) {
assertTrue(serverKey, LevelInitMessage.isValidServerKey(serverKey));
}
}
public void testInvalidServerKeys()
{
for (String serverKey : new String[]{ "my server", "server!", "lobby@net", "a:b", "サーバー", "a".repeat(151) }) {
assertFalse(serverKey, LevelInitMessage.isValidServerKey(serverKey));
}
}
}
@@ -0,0 +1,196 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.world;
import junit.framework.TestCase;
import no.jckf.dhsupport.core.configuration.Configuration;
import no.jckf.dhsupport.core.configuration.DhsConfig;
public class AbstractWorldInterfaceTest extends TestCase
{
protected Configuration globalConfig;
@Override
protected void setUp()
{
this.globalConfig = new Configuration();
}
public void testNameIsKeptAsIs()
{
assertEquals("My World.v2", new FakeWorldInterface("My World.v2", this.globalConfig).getName());
}
public void testKeyKeepsAllowedCharacters()
{
assertEquals("world_nether-2", new FakeWorldInterface("world_nether-2", this.globalConfig).getKey());
}
public void testKeyStripsDisallowedCharacters()
{
assertEquals("MyWorldv2", new FakeWorldInterface("My World.v2", this.globalConfig).getKey());
assertEquals("worldsnether", new FakeWorldInterface("worlds/nether", this.globalConfig).getKey());
assertEquals("verdn", new FakeWorldInterface("verdén", this.globalConfig).getKey());
}
public void testLevelKeyWithoutPrefixIsKey()
{
WorldInterface world = new FakeWorldInterface("My World", this.globalConfig);
assertEquals("MyWorld", world.getLevelKey());
}
public void testLevelKeyUsesGlobalPrefix()
{
this.globalConfig.set(DhsConfig.LEVEL_KEY_PREFIX, "server1_");
WorldInterface world = new FakeWorldInterface("My World", this.globalConfig);
assertEquals("server1_MyWorld", world.getLevelKey());
}
public void testLevelKeyPrefersWorldSpecificPrefix()
{
this.globalConfig.set(DhsConfig.LEVEL_KEY_PREFIX, "server1_");
this.globalConfig.set("worlds.world_nether." + DhsConfig.LEVEL_KEY_PREFIX, "nether_");
WorldInterface overworld = new FakeWorldInterface("world", this.globalConfig);
WorldInterface nether = new FakeWorldInterface("world_nether", this.globalConfig);
assertEquals("server1_world", overworld.getLevelKey());
assertEquals("nether_world_nether", nether.getLevelKey());
}
public void testValidPrefixIsKept()
{
this.globalConfig.set(DhsConfig.LEVEL_KEY_PREFIX, "lobby@");
assertEquals("lobby@world", new FakeWorldInterface("world", this.globalConfig).getLevelKey());
}
public void testPrefixIsUsedAsIs()
{
// Invalid prefixes are not fixed up. DhSupport warns about the resulting level key instead.
this.globalConfig.set(DhsConfig.LEVEL_KEY_PREFIX, "my server.");
assertEquals("my server.world", new FakeWorldInterface("world", this.globalConfig).getLevelKey());
}
public void testServerKeyIsEmptyByDefault()
{
assertEquals("", new FakeWorldInterface("world", this.globalConfig).getServerKey());
}
public void testValidServerKeyIsKept()
{
this.globalConfig.set(DhsConfig.SERVER_KEY, "my-server_1");
assertEquals("my-server_1", new FakeWorldInterface("world", this.globalConfig).getServerKey());
}
public void testLevelKeyIsFixedAtConstruction()
{
WorldInterface world = new FakeWorldInterface("world", this.globalConfig);
this.globalConfig.set(DhsConfig.LEVEL_KEY_PREFIX, "server1_");
assertEquals("world", world.getLevelKey());
}
public void testDummyModeIsOffByDefault()
{
WorldInterface world = new FakeWorldInterface("world", this.globalConfig);
assertFalse(world.isDummyMode());
world.setDummyMode(true);
assertTrue(world.isDummyMode());
}
public void testEverythingIsInsideMissingWorldBorder()
{
FakeWorldInterface world = new FakeWorldInterface("world", this.globalConfig);
assertTrue(world.isInsideWorldBorder(1_000_000, -1_000_000, 64));
// A partial border counts as no border.
world.borderX = 0;
world.borderZ = 0;
assertTrue(world.isInsideWorldBorder(1_000_000, -1_000_000, 64));
}
public void testAreaInsideWorldBorder()
{
FakeWorldInterface world = borderedWorld(0, 0, 100);
assertTrue(world.isInsideWorldBorder(0, 0, 64));
assertTrue(world.isInsideWorldBorder(-64, -64, 64));
}
public void testAreaOverlappingWorldBorderEdgeIsInside()
{
FakeWorldInterface world = borderedWorld(0, 0, 100);
// Starts exactly on the max edge.
assertTrue(world.isInsideWorldBorder(100, 0, 64));
assertTrue(world.isInsideWorldBorder(0, 100, 64));
// Ends exactly on the min edge.
assertTrue(world.isInsideWorldBorder(-163, 0, 64));
assertTrue(world.isInsideWorldBorder(0, -163, 64));
}
public void testAreaOutsideWorldBorder()
{
FakeWorldInterface world = borderedWorld(0, 0, 100);
assertFalse(world.isInsideWorldBorder(101, 0, 64));
assertFalse(world.isInsideWorldBorder(0, 101, 64));
assertFalse(world.isInsideWorldBorder(-164, 0, 64));
assertFalse(world.isInsideWorldBorder(0, -164, 64));
}
public void testWorldBorderWithOffsetCenter()
{
FakeWorldInterface world = borderedWorld(1000, -500, 50);
assertTrue(world.isInsideWorldBorder(1000, -500, 1));
assertFalse(world.isInsideWorldBorder(0, 0, 64));
}
public void testBiomeWithoutYUsesSeaLevel()
{
FakeWorldInterface world = new FakeWorldInterface("world", this.globalConfig);
world.seaLevel = 70;
assertEquals("biome@5,70,7", world.getBiomeAt(5, 7));
}
protected FakeWorldInterface borderedWorld(int centerX, int centerZ, int radius)
{
FakeWorldInterface world = new FakeWorldInterface("world", this.globalConfig);
world.borderX = centerX;
world.borderZ = centerZ;
world.borderRadius = radius;
return world;
}
}
@@ -0,0 +1,227 @@
/*
* DH Support, server-side support for Distant Horizons.
* Copyright (C) 2024 Jim C K Flaten
*
* 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, either version 3 of the License, or
* (at your option) any later version.
*
* 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 no.jckf.dhsupport.core.world;
import no.jckf.dhsupport.core.configuration.Configuration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Keeps everything AbstractWorldInterface implements, and stubs out the rest.
* Sea level and border can be set to drive the base class logic.
*/
public class FakeWorldInterface extends AbstractWorldInterface
{
protected UUID id = UUID.randomUUID();
public int seaLevel = 63;
public Integer borderX;
public Integer borderZ;
public Integer borderRadius;
public FakeWorldInterface(String name, Configuration config)
{
super(name, config);
}
@Override
public WorldInterface newInstance()
{
return new FakeWorldInterface(this.name, this.config);
}
@Override
public UUID getId()
{
return this.id;
}
@Override
public double getCoordinateScale()
{
return 1;
}
@Override
public Integer getWorldBorderX()
{
return this.borderX;
}
@Override
public Integer getWorldBorderZ()
{
return this.borderZ;
}
@Override
public Integer getWorldBorderRadius()
{
return this.borderRadius;
}
@Override
public boolean chunkExists(int x, int z)
{
return false;
}
@Override
public boolean isChunkLoaded(int x, int z)
{
return false;
}
@Override
public boolean loadChunk(int x, int z)
{
return false;
}
@Override
public boolean loadOrGenerateChunk(int x, int z)
{
return false;
}
@Override
public CompletableFuture<Boolean> loadChunkAsync(int x, int z)
{
return CompletableFuture.completedFuture(false);
}
@Override
public CompletableFuture<Boolean> loadOrGenerateChunkAsync(int x, int z)
{
return CompletableFuture.completedFuture(false);
}
@Override
public boolean unloadChunk(int x, int z)
{
return false;
}
@Override
public boolean unloadChunkAsync(int x, int z)
{
return false;
}
@Override
public boolean discardChunk(int x, int z)
{
return false;
}
@Override
public int getMinY()
{
return 0;
}
@Override
public int getMaxY()
{
return 0;
}
@Override
public int getSeaLevel()
{
return this.seaLevel;
}
@Override
public int getSpawnX()
{
return 0;
}
@Override
public int getSpawnY()
{
return 0;
}
@Override
public int getSpawnZ()
{
return 0;
}
@Override
public int getHighestYAt(int x, int z)
{
return 0;
}
@Override
public String getBiomeAt(int x, int y, int z)
{
return "biome@" + x + "," + y + "," + z;
}
@Override
public String getMaterialAt(int x, int y, int z)
{
return null;
}
@Override
public String getBlockStateAsStringAt(int x, int y, int z)
{
return null;
}
@Override
public Map<String, String> getBlockPropertiesAt(int x, int y, int z)
{
return Map.of();
}
@Override
public byte getBlockLightAt(int x, int y, int z)
{
return 0;
}
@Override
public byte getSkyLightAt(int x, int y, int z)
{
return 0;
}
@Override
public boolean isBeacon(int x, int y, int z)
{
return false;
}
@Override
public int getBeaconColor(int x, int y, int z)
{
return 0;
}
}
+50
View File
@@ -0,0 +1,50 @@
# Testbed
A real Paper server running the plugin, and a real Fabric client running Distant Horizons, for manual and end-to-end testing.
Pinned versions live in `versions.env`.
## Usage
```sh
testbed/setup.sh # Once, and after bumping versions.env. Downloads into testbed/.cache.
testbed/server.sh # Builds the plugin and runs the server in the foreground. Type "stop" to quit.
testbed/client.sh # In a second terminal. Opens a game window and joins the server.
```
The server runs in offline mode with the whitelist off, and the client logs in offline as `DhTester`, who is made operator so `/dhs` works in game. Set `PLAYER_NAME` for both scripts to use another name.
By default the main world is called `DH Test World`. The space in the name tests level key sanitizing. Set `LEVEL_NAME` before the first `server.sh` run to use another name. `server.properties` is only written when it doesn't exist, so to change it later, edit the file or delete `testbed/run`.
Set `SKIP_BUILD=1` to start the server without rebuilding the plugin.
## Automated test
```sh
testbed/test.sh
```
Starts a server and client in a fresh world under `testbed/run/test`, runs four checks, shuts both down and exits non-zero if any check fails. A run takes about two minutes, and needs a desktop session since the client opens a window.
1. **Login**: the client gets a level init for the overworld with the expected level key.
2. **LOD generation**: `dhs status` shows the player using DH, with a generation speed above zero.
3. **Custom biome**: the world gets a datapack that makes `dhtest:test_biome`, a copy of plains, the only overworld biome. The plugin's stored LODs must contain that key and not `minecraft:custom`, and the server must not warn that custom biomes are unsupported.
4. **Dimension switch**: teleporting to the nether gives a level init with the nether's level key.
The plugin config gets the invalid server key `DH testbed server!`. The plugin must warn about it and send clients an empty server key instead. Set `SERVER_KEY` to test another one.
Each check also fails if the server logged exceptions, or if the plugin sent the player an error message in chat. Handler exceptions only show up in chat, not in the server log.
The run directory is deleted after a passing run, and kept after a failure for its `server.out` and `client.out` logs. Set `KEEP_RUN=1` to always keep it, and `PLUGIN_JAR` to test a prebuilt jar, such as an older version.
## Files
- `testbed/.cache`: launcher, game files, Java runtime, Paper and DH jars. Shared between runs.
- `testbed/run/server`, `testbed/run/client`: server and client game directories, including worlds, configs and logs.
Both are gitignored. Delete `testbed/run` for a fresh world and fresh configs.
## Useful log lines
- Client, `testbed/run/client/logs/latest.log`: `Level init received for [...]: server key [...], level key [...]`
- Server console: `dhs status` shows DH players and generation speed.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Starts a Fabric client with Distant Horizons and joins the testbed server.
#
# Environment:
# PLAYER_NAME Offline-mode username. Defaults to DhTester. Use the same value for server.sh.
# SERVER_HOST Defaults to localhost.
# SERVER_PORT Defaults to 25565.
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_setup
mkdir -p "$CLIENT_DIR/mods"
rm -f "$CLIENT_DIR"/mods/DistantHorizons-*.jar
cp "$DH_JAR" "$CLIENT_DIR/mods/"
# Skip first-launch screens and keep running when the window loses focus.
if [[ ! -f "$CLIENT_DIR/options.txt" ]]; then
cat > "$CLIENT_DIR/options.txt" <<EOF
onboardAccessibility:false
pauseOnLostFocus:false
skipMultiplayerWarning:true
joinedFirstServer:true
EOF
fi
exec "$PORTABLEMC" --main-dir "$MC_MAIN_DIR" start "fabric:$MC_VERSION" \
--mc-dir "$CLIENT_DIR" \
--username "$PLAYER_NAME" \
--join-server "${SERVER_HOST:-localhost}" \
--join-server-port "${SERVER_PORT:-25565}"
+53
View File
@@ -0,0 +1,53 @@
# Shared paths and helpers for the testbed scripts. Sourced, not executed.
TESTBED_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$TESTBED_DIR")"
source "$TESTBED_DIR/versions.env"
CACHE_DIR="$TESTBED_DIR/.cache"
RUN_DIR="${RUN_DIR:-$TESTBED_DIR/run}"
SERVER_DIR="$RUN_DIR/server"
CLIENT_DIR="$RUN_DIR/client"
MC_MAIN_DIR="$CACHE_DIR/minecraft"
PORTABLEMC="$CACHE_DIR/venv/bin/portablemc"
# Mojang's Java runtime, installed by portablemc along with the client. The server uses it too.
JAVA="$MC_MAIN_DIR/jvm/java-runtime-epsilon/bin/java"
# Offline-mode username, shared by the client and the server's op list.
PLAYER_NAME="${PLAYER_NAME:-DhTester}"
PAPER_JAR="$CACHE_DIR/paper-$MC_VERSION-$PAPER_BUILD.jar"
DH_JAR="$CACHE_DIR/DistantHorizons-$DH_VERSION.jar"
# download <url> <destination> <sha256|sha512> <hash>
download()
{
local url="$1" dest="$2" algorithm="$3" expected="$4"
if [[ -f "$dest" ]] && echo "$expected $dest" | "${algorithm}sum" --check --status; then
return
fi
echo "Downloading $(basename "$dest")..."
curl --fail --location --silent --show-error --output "$dest.part" "$url"
if ! echo "$expected $dest.part" | "${algorithm}sum" --check --status; then
rm -f "$dest.part"
echo "Checksum mismatch for $url" >&2
exit 1
fi
mv "$dest.part" "$dest"
}
require_setup()
{
if [[ ! -x "$JAVA" || ! -f "$PAPER_JAR" || ! -f "$DH_JAR" || ! -x "$PORTABLEMC" ]]; then
echo "Testbed is not set up. Run testbed/setup.sh first." >&2
exit 1
fi
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Builds the plugin and runs a Paper server with it in the foreground. Type "stop" to shut down.
#
# Environment:
# LEVEL_NAME Name of the main world. Defaults to a name with a space in it,
# since world names like that have broken level key lookups before.
# SKIP_BUILD Set to 1 to reuse the last built plugin jar.
# PLUGIN_JAR Use this plugin jar instead of building one. Implies SKIP_BUILD.
# PLAYER_NAME Player to make operator, so /dhs works in game. Defaults to DhTester.
# SERVER_KEY Sets server_key in the plugin config when it is first created.
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_setup
LEVEL_NAME="${LEVEL_NAME:-DH Test World}"
if [[ -z "${PLUGIN_JAR:-}" ]]; then
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
(cd "$PROJECT_DIR" && ./gradlew --quiet build -x test)
fi
PLUGIN_JAR="$(ls -t "$PROJECT_DIR"/build/libs/1.16.5/DistantHorizonsSupport-*.jar | head -n 1)"
fi
mkdir -p "$SERVER_DIR/plugins"
rm -f "$SERVER_DIR"/plugins/DistantHorizonsSupport-*.jar
cp "$PLUGIN_JAR" "$SERVER_DIR/plugins/"
# Start from the plugin's default config, so settings that aren't overridden keep their defaults.
if [[ -n "${SERVER_KEY:-}" && ! -f "$SERVER_DIR/plugins/DHSupport/config.yml" ]]; then
mkdir -p "$SERVER_DIR/plugins/DHSupport"
python3 -I - "$PLUGIN_JAR" "$SERVER_DIR/plugins/DHSupport/config.yml" "$SERVER_KEY" <<'EOF'
import json, sys, zipfile
plugin_jar, config_path, server_key = sys.argv[1:]
with zipfile.ZipFile(plugin_jar) as jar:
config = jar.read('config.yml').decode()
# A JSON string is also a valid YAML string, with any quotes escaped.
with open(config_path, 'w') as file:
file.write(config.rstrip('\n') + '\n\nserver_key: ' + json.dumps(server_key) + '\n')
EOF
fi
echo "eula=true" > "$SERVER_DIR/eula.txt"
# Only written once, so manual changes to server.properties are kept between runs.
if [[ ! -f "$SERVER_DIR/server.properties" ]]; then
# Non-ASCII characters are written as \uXXXX escapes, which Java's properties format always understands.
ESCAPED_LEVEL_NAME="$(python3 -I -c 'import sys; print("".join(c if ord(c) < 128 else "\\u%04x" % ord(c) for c in sys.argv[1]))' "$LEVEL_NAME")"
cat > "$SERVER_DIR/server.properties" <<EOF
level-name=$ESCAPED_LEVEL_NAME
online-mode=false
white-list=false
motd=DH Support testbed
spawn-protection=0
view-distance=8
EOF
fi
# Commands like /dhs need operator. In offline mode the UUID is derived from the name, like Java's UUID.nameUUIDFromBytes().
if [[ ! -f "$SERVER_DIR/ops.json" ]] || ! grep -q "\"$PLAYER_NAME\"" "$SERVER_DIR/ops.json"; then
PLAYER_UUID="$(python3 -c '
import hashlib, sys, uuid
digest = bytearray(hashlib.md5(("OfflinePlayer:" + sys.argv[1]).encode()).digest())
digest[6] = digest[6] & 0x0f | 0x30
digest[8] = digest[8] & 0x3f | 0x80
print(uuid.UUID(bytes=bytes(digest)))
' "$PLAYER_NAME")"
cat > "$SERVER_DIR/ops.json" <<EOF
[
{
"uuid": "$PLAYER_UUID",
"name": "$PLAYER_NAME",
"level": 4,
"bypassesPlayerLimit": false
}
]
EOF
fi
echo "Starting Paper $MC_VERSION with $(basename "$PLUGIN_JAR")..."
# Java encodes file paths using the locale. Without UTF-8, it can't create worlds with names like "世界".
export LC_ALL=C.UTF-8
cd "$SERVER_DIR"
exec "$JAVA" -Xms2G -Xmx4G -jar "$PAPER_JAR" --nogui
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Downloads everything the testbed needs into testbed/.cache. Safe to re-run.
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
mkdir -p "$CACHE_DIR"
if [[ ! -x "$PORTABLEMC" ]] || ! "$PORTABLEMC" --version 2>/dev/null | grep -q "$PORTABLEMC_VERSION"; then
echo "Installing portablemc $PORTABLEMC_VERSION..."
python3 -m venv "$CACHE_DIR/venv"
"$CACHE_DIR/venv/bin/pip" install --quiet --disable-pip-version-check "portablemc==$PORTABLEMC_VERSION"
fi
echo "Installing Minecraft $MC_VERSION with Fabric, and its Java runtime..."
"$PORTABLEMC" --main-dir "$MC_MAIN_DIR" start "fabric:$MC_VERSION" --dry > /dev/null
download "$PAPER_URL" "$PAPER_JAR" sha256 "$PAPER_SHA256"
download "$DH_URL" "$DH_JAR" sha512 "$DH_SHA512"
echo "Testbed is ready."
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env bash
# Runs a real server and client in a fresh world, and checks that:
# 1. Login: the client gets a level init with the expected level key.
# 2. LOD generation: the server sees the player using DH and generates LODs.
# 3. Custom biome: LODs from a datapack biome store its real key, not "minecraft:custom".
# 4. Dimension switch: moving to the nether gives a new level init, without errors.
# After each step, the server log is checked for exceptions, and the client chat for errors the
# plugin reports to the player.
#
# Needs a desktop session, as the client opens a window. Exits non-zero if any check fails.
#
# Environment:
# LEVEL_NAME Name of the main world. Defaults to "DH Test World".
# SERVER_KEY Server key in the plugin config. Defaults to an invalid one, which the plugin must not send.
# SKIP_BUILD Set to 1 to reuse the last built plugin jar.
# PLUGIN_JAR Test this plugin jar instead of building one.
# KEEP_RUN Set to 1 to keep testbed/run/test after a passing run. It is always kept after a failure.
set -uo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_setup
export RUN_DIR="$TESTBED_DIR/run/test"
export LEVEL_NAME="${LEVEL_NAME:-DH Test World}"
export SERVER_KEY="${SERVER_KEY:-DH testbed server!}"
export PLAYER_NAME
SERVER_DIR="$RUN_DIR/server"
CLIENT_DIR="$RUN_DIR/client"
SERVER_OUT="$RUN_DIR/server.out"
CLIENT_OUT="$RUN_DIR/client.out"
CONSOLE="$RUN_DIR/console"
# Expected level key for a world name, without a prefix. Mirrors AbstractWorldInterface.
expected_level_key()
{
echo "$1" | sed 's/[^a-zA-Z0-9_-]//g'
}
# Expected server key sent to clients. Invalid keys are not sent, and the server logs a warning instead.
expected_server_key()
{
if [[ -z "$1" || "$1" =~ ^[a-zA-Z0-9_-]{1,150}$ ]]; then
echo "$1"
fi
}
LEVEL_KEY="$(expected_level_key "$LEVEL_NAME")"
NETHER_LEVEL_KEY="$(expected_level_key "${LEVEL_NAME}_nether")"
SERVER_KEY_EXPECTED="$(expected_server_key "$SERVER_KEY")"
# Datapack biome that replaces every biome in the overworld.
CUSTOM_BIOME="dhtest:test_biome"
SERVER_PID=""
CLIENT_PID=""
FAILURES=0
#===========#
# reporting #
#===========#
pass()
{
echo "PASS $1"
}
fail()
{
echo "FAIL $1"
[[ $# -gt 1 ]] && echo " $2"
FAILURES=$((FAILURES + 1))
}
abort()
{
fail "$@"
exit 1
}
#=========#
# helpers #
#=========#
# Log text without ANSI colour codes, from an optional line number onwards.
log_text()
{
local file="$1" from="${2:-1}"
tail -n "+$from" "$file" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g'
}
line_count()
{
wc -l < "$1" 2>/dev/null || echo 0
}
# wait_for <file> <regex> <timeout seconds> [from line]
wait_for()
{
local file="$1" pattern="$2" timeout="$3" from="${4:-1}"
local deadline=$((SECONDS + timeout))
while ((SECONDS < deadline)); do
if log_text "$file" "$from" | grep -qE "$pattern"; then
return 0
fi
if [[ -n "$SERVER_PID" ]] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
abort "Server exited unexpectedly" "See $SERVER_OUT"
fi
if [[ -n "$CLIENT_PID" ]] && ! kill -0 "$CLIENT_PID" 2>/dev/null; then
abort "Client exited unexpectedly" "See $CLIENT_OUT"
fi
sleep 1
done
return 1
}
console()
{
echo "$1" >&3
}
# Fails the given check if the server has logged exceptions or errors since the given line.
check_server_errors()
{
local check="$1" from="$2"
local errors
errors="$(log_text "$SERVER_OUT" "$from" | grep -E "Exception|ERROR\]|Cannot invoke" | head -n 5)"
if [[ -n "$errors" ]]; then
fail "$check: server logged errors" "$(echo "$errors" | head -n 1)"
return 1
fi
return 0
}
# Fails the given check if the plugin has sent the player error messages since the given line.
# PluginMessageProxy catches exceptions from message handlers and sends them to the player's chat,
# so they never reach the server log. DH's own client messages have a colour code and don't match.
check_client_errors()
{
local check="$1" from="$2"
local errors
errors="$(log_text "$CLIENT_OUT" "$from" | grep -aE "\[CHAT\] (§c)?Distant Horizons: " | head -n 5)"
if [[ -n "$errors" ]]; then
fail "$check: plugin sent errors to the player" "$(echo "$errors" | head -n 1 | sed -E 's/.*\[CHAT\] //')"
return 1
fi
return 0
}
# Writes a datapack that makes $CUSTOM_BIOME, a copy of vanilla plains, the only overworld biome.
# The biome definition and data pack format come from the client jar, so they match the game version.
create_biome_datapack()
{
local pack="$SERVER_DIR/$LEVEL_NAME/datapacks/dhtest"
local client_jar
client_jar="$(ls "$MC_MAIN_DIR"/versions/fabric-"$MC_VERSION"-*/fabric-"$MC_VERSION"-*.jar | head -n 1)"
mkdir -p "$pack/data/dhtest/worldgen/biome" "$pack/data/minecraft/dimension"
python3 -I - "$client_jar" "$pack" <<'EOF'
import json, sys, zipfile
client_jar, pack = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(client_jar) as jar:
pack_format = json.loads(jar.read('version.json'))['pack_version']['data_major']
plains = jar.read('data/minecraft/worldgen/biome/plains.json')
with open(pack + '/pack.mcmeta', 'w') as file:
json.dump({'pack': {
'description': 'DH Support testbed biome',
'pack_format': pack_format,
'min_format': pack_format,
'max_format': pack_format,
}}, file)
with open(pack + '/data/dhtest/worldgen/biome/test_biome.json', 'wb') as file:
file.write(plains)
with open(pack + '/data/minecraft/dimension/overworld.json', 'w') as file:
json.dump({
'type': 'minecraft:overworld',
'generator': {
'type': 'minecraft:noise',
'settings': 'minecraft:overworld',
'biome_source': {'type': 'minecraft:fixed', 'biome': 'dhtest:test_biome'},
},
}, file)
EOF
}
# Prints how often each given biome key appears in the plugin's stored LODs, one count per line.
# LOD data holds several XZ-compressed sections, so every XZ stream in each row is decompressed.
count_lod_biomes()
{
python3 -I - "$SERVER_DIR/plugins/DHSupport/data.sqlite" "$@" <<'EOF'
import lzma, sqlite3, sys
database, keys = sys.argv[1], [key.encode() for key in sys.argv[2:]]
counts = [0] * len(keys)
try:
connection = sqlite3.connect('file:' + database + '?mode=ro', uri=True, timeout=10)
rows = connection.execute('SELECT data FROM lods').fetchall()
except sqlite3.Error:
rows = []
for (data,) in rows:
start = 0
while (start := data.find(b'\xfd7zXZ\x00', start)) >= 0:
try:
raw = lzma.LZMADecompressor(format=lzma.FORMAT_XZ).decompress(data[start:])
counts = [count + raw.count(key) for count, key in zip(counts, keys)]
except lzma.LZMAError:
pass
start += 6
print('\n'.join(str(count) for count in counts))
EOF
}
stop_all()
{
if [[ -n "$CLIENT_PID" ]] && kill -0 "$CLIENT_PID" 2>/dev/null; then
kill -- "-$CLIENT_PID" 2>/dev/null
fi
if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
console "stop" 2>/dev/null
for _ in $(seq 1 60); do
kill -0 "$SERVER_PID" 2>/dev/null || break
sleep 1
done
kill -- "-$SERVER_PID" 2>/dev/null
fi
exec 3>&- 2>/dev/null
if ((FAILURES == 0)) && [[ "${KEEP_RUN:-0}" != "1" ]]; then
rm -rf "$RUN_DIR"
else
echo "Logs kept in $RUN_DIR"
fi
}
#=======#
# setup #
#=======#
rm -rf "$RUN_DIR"
mkdir -p "$RUN_DIR"
mkfifo "$CONSOLE"
create_biome_datapack
trap stop_all EXIT
trap 'exit 130' INT TERM
echo "Starting server with world \"$LEVEL_NAME\" (expected level key: $LEVEL_KEY) and server key \"$SERVER_KEY\" (expected: $SERVER_KEY_EXPECTED)..."
# setsid puts each side in its own process group, so cleanup can stop everything they started.
setsid "$TESTBED_DIR/server.sh" < "$CONSOLE" > "$SERVER_OUT" 2>&1 &
SERVER_PID=$!
# Hold the console pipe open for the whole run. Closing it would end the server's input.
exec 3> "$CONSOLE"
wait_for "$SERVER_OUT" 'Done \(' 300 || abort "Server did not start within 300 seconds" "See $SERVER_OUT"
check_server_errors "Server startup" 1 || exit 1
#==========#
# 1. login #
#==========#
echo "Starting client..."
SERVER_MARK=$(line_count "$SERVER_OUT")
setsid "$TESTBED_DIR/client.sh" > "$CLIENT_OUT" 2>&1 &
CLIENT_PID=$!
if ! wait_for "$SERVER_OUT" "$PLAYER_NAME.*joined the game" 300 "$SERVER_MARK"; then
abort "Login: client did not join within 300 seconds" "$(log_text "$SERVER_OUT" "$SERVER_MARK" | grep -E "lost connection" | head -n 1)"
fi
if ! wait_for "$CLIENT_OUT" "Level init received for \[minecraft:overworld\]" 60; then
abort "Login: client got no level init for the overworld within 60 seconds" "$(log_text "$CLIENT_OUT" | grep -oE "Server sent invalid (level|server) key" | head -n 1)"
fi
# Give the client time to send its first LOD requests.
sleep 10
LEVEL_INIT="$(log_text "$CLIENT_OUT" | grep -oE "Level init received for \[minecraft:overworld\]: server key \[[^]]*\], level key \[[^]]*\]" | tail -n 1)"
ACTUAL_KEY="$(echo "$LEVEL_INIT" | sed -E 's/.*level key \[([^]]*)\]/\1/')"
ACTUAL_SERVER_KEY="$(echo "$LEVEL_INIT" | sed -E 's/.*server key \[([^]]*)\].*/\1/')"
if [[ "$ACTUAL_KEY" != "$LEVEL_KEY" ]]; then
fail "Login: wrong level key" "Expected $LEVEL_KEY, got $ACTUAL_KEY"
elif [[ "$ACTUAL_SERVER_KEY" != "$SERVER_KEY_EXPECTED" ]]; then
fail "Login: wrong server key" "Expected $SERVER_KEY_EXPECTED, got $ACTUAL_SERVER_KEY"
elif [[ "$SERVER_KEY_EXPECTED" != "$SERVER_KEY" ]] && ! log_text "$SERVER_OUT" | grep -q "has the server key"; then
fail "Login: server did not warn about the invalid server key"
elif log_text "$CLIENT_OUT" | grep -qE "invalid (level|server) key"; then
fail "Login: client rejected a key" "$(log_text "$CLIENT_OUT" | grep -oE "Server sent invalid (level|server) key" | head -n 1)"
elif check_server_errors "Login" "$SERVER_MARK" && check_client_errors "Login" 1; then
pass "Login: level key is $ACTUAL_KEY, server key is \"$ACTUAL_SERVER_KEY\""
fi
#===================#
# 2. LOD generation #
#===================#
echo "Waiting for LOD generation..."
SERVER_MARK=$(line_count "$SERVER_OUT")
CLIENT_MARK=$(line_count "$CLIENT_OUT")
LOD_OK=0
DEADLINE=$((SECONDS + 120))
while ((SECONDS < DEADLINE)); do
STATUS_MARK=$(line_count "$SERVER_OUT")
console "dhs status"
sleep 5
STATUS="$(log_text "$SERVER_OUT" "$((STATUS_MARK + 1))")"
SPEED="$(echo "$STATUS" | grep -oE "Current generation speed: [0-9.]+" | tail -n 1 | grep -oE "[0-9.]+$")"
if echo "$STATUS" | grep -qE "using Distant Horizons: .*$PLAYER_NAME" \
&& [[ -n "$SPEED" ]] && awk "BEGIN { exit !($SPEED > 0) }"; then
LOD_OK=1
break
fi
done
if ((LOD_OK)); then
check_server_errors "LOD generation" "$SERVER_MARK" \
&& check_client_errors "LOD generation" "$CLIENT_MARK" \
&& pass "LOD generation: $SPEED chunks per second"
else
fail "LOD generation: no generation for $PLAYER_NAME within 120 seconds" "Last speed: ${SPEED:-none}"
check_client_errors "LOD generation" "$CLIENT_MARK"
fi
#=================#
# 3. custom biome #
#=================#
echo "Checking custom biome in LODs..."
BIOME_OK=0
DEADLINE=$((SECONDS + 60))
while ((SECONDS < DEADLINE)); do
mapfile -t BIOME_COUNTS < <(count_lod_biomes "$CUSTOM_BIOME" "minecraft:custom")
if ((${BIOME_COUNTS[0]:-0} > 0)); then
BIOME_OK=1
break
fi
sleep 5
done
if log_text "$SERVER_OUT" | grep -q "Custom biomes are not supported"; then
fail "Custom biome: server warned that custom biomes are not supported"
elif ((!BIOME_OK)); then
fail "Custom biome: $CUSTOM_BIOME not found in stored LODs within 60 seconds" "minecraft:custom found ${BIOME_COUNTS[1]:-0} times"
elif ((${BIOME_COUNTS[1]:-0} > 0)); then
fail "Custom biome: stored LODs also contain minecraft:custom" "${BIOME_COUNTS[1]} times"
else
pass "Custom biome: $CUSTOM_BIOME found ${BIOME_COUNTS[0]} times in stored LODs"
fi
#=====================#
# 4. dimension switch #
#=====================#
echo "Switching to the nether..."
SERVER_MARK=$(line_count "$SERVER_OUT")
CLIENT_MARK=$(line_count "$CLIENT_OUT")
# Creative, so the player can't suffocate if they land inside netherrack.
console "gamemode creative $PLAYER_NAME"
console "execute in minecraft:the_nether run tp $PLAYER_NAME 0 100 0"
if wait_for "$CLIENT_OUT" "Level init received for \[minecraft:the_nether\]" 60 "$CLIENT_MARK"; then
NETHER_KEY="$(log_text "$CLIENT_OUT" "$CLIENT_MARK" | grep -oE "Level init received for \[minecraft:the_nether\]: .*level key \[[^]]*\]" | tail -n 1 | sed -E 's/.*level key \[([^]]*)\]/\1/')"
# Give the client time to send LOD requests for the new level.
sleep 20
if [[ "$NETHER_KEY" != "$NETHER_LEVEL_KEY" ]]; then
fail "Dimension switch: wrong nether level key" "Expected $NETHER_LEVEL_KEY, got $NETHER_KEY"
elif log_text "$CLIENT_OUT" "$CLIENT_MARK" | grep -q "invalid level key"; then
fail "Dimension switch: client rejected the level key"
elif check_server_errors "Dimension switch" "$SERVER_MARK" && check_client_errors "Dimension switch" "$CLIENT_MARK"; then
pass "Dimension switch: nether level key is $NETHER_KEY"
fi
else
fail "Dimension switch: client got no level init for the nether within 60 seconds"
fi
#=========#
# summary #
#=========#
if ((FAILURES > 0)); then
echo "$FAILURES check(s) failed."
exit 1
fi
echo "All checks passed."
+13
View File
@@ -0,0 +1,13 @@
# Pinned versions for the testbed. Bump these together.
MC_VERSION="26.3"
PAPER_BUILD="223"
PAPER_URL="https://fill-data.papermc.io/v1/objects/f8e684a4fef2a8b46d03aac049b49d59275e9ce5367dba7c6075cb5780f38470/paper-26.3-223.jar"
PAPER_SHA256="f8e684a4fef2a8b46d03aac049b49d59275e9ce5367dba7c6075cb5780f38470"
DH_VERSION="3.3.4-26.3"
DH_URL="https://cdn.modrinth.com/data/uCdwusMi/versions/TGgEbP9A/DistantHorizons-3.3.4-26.3-fabric-neoforge.jar"
DH_SHA512="0b90b1b08d99974bfc3c8e86b2ceb252e2f91715ed66b925004c28335fb44e1bddbbb6b8043e4d4d53c87ed247789cd1788b1ba4e3f4394efe154e0c59cff236"
PORTABLEMC_VERSION="5.0.6"